Twelve actionable items (7 Major + 5 Minor) from the CodeRabbit review on claude-code-best/claude-code#386: - docs/internals/autonomy-jira.md: typo "due input close" → "due to input close". - src/utils/autonomyRuns.ts: - selectPersistedAutonomyRuns no longer evicts active (queued/running) runs when the combined list exceeds AUTONOMY_RUNS_MAX. Active runs are kept in full and the inactive history is capped to the remaining budget so persisted ownership for live work survives. - isValidOwnerProcessId now allows pid <= 4_194_304 so a live run owned by the maximum Linux PID is not treated as stale. - src/utils/autonomyAuthority.ts: maskCodeFencedLines tracks the active fence length and only closes the fence when a same-character run of equal-or- greater length appears with no trailing content, so a nested ```yaml inside an outer ```` block no longer leaks fake `tasks:` entries into the parser. - src/cli/print.ts: late-shutdown branches in the cron and scheduled-task paths now call cancelQueuedAutonomyCommands({ commands: [command] }) instead of markAutonomyRunCancelled(...). Updating run state alone left the queue-side record orphaned for resume/recovery. - src/utils/processUserInput/processSlashCommand.tsx: scheduled-task-result notification is enqueued before finalizeAutonomyRunCompleted (which queues follow-up autonomy commands) so both at priority: 'later' land in order and the next autonomy step can not run before the worker's output is observed. - src/screens/REPL.tsx + src/utils/handlePromptSubmit.ts: - onQuery now returns Promise<boolean>: false from the concurrent-guard skip path, true otherwise. Other call sites use `void onQuery(...)` and are unaffected. handlePromptSubmit's onQuery prop type matches. - The autonomy-prompt callsite captures the executed flag, finalizes claim.claimedCommands as { type: 'completed' } only when onQuery actually ran, and runs the completed-finalize in its own try/catch so a failure there does not propagate into the outer catch and trigger a second finalize as { type: 'failed' } for the same commands. - Removed the unsafe `command.value as string` cast; createUserMessage already accepts `string | ContentBlockParam[]`. - createUserMessage mock in src/__tests__/handlePromptSubmit.test.ts now matches the new Promise<boolean> shape. - packages/builtin-tools/src/tools/RemoteTriggerTool/__tests__/ RemoteTriggerTool.test.ts: - Inline auth mock replaced with the shared tests/mocks/auth (added). - The full mock of src/constants/oauth.js is replaced by a narrow side-effect-only mock that overrides the env-reading helpers (getOauthConfig, fileSuffixForOauthConfig, MCP_CLIENT_METADATA_URL) and delegates pure data exports to the real module. - tests/integration/dependency-overrides.test.ts: - mermaid does not export `./package.json` in its exports map, so require.resolve('mermaid/package.json') throws ERR_PACKAGE_PATH_NOT_EXPORTED in runtimes that honor exports semantics. The test now resolves the package entry and walks up to the package root via a small findPackageJson helper. - readFileSync from node:fs is replaced with `await Bun.file(...).text()` to match the project's Bun-API requirement. Validation: - bun run typecheck (clean). - bun test → 3996 pass / 0 fail across 305 test files. Targets PRs: - amDosion/claude-code-bast#8 (fork-internal review) - claude-code-best/claude-code#386 (upstream review, same head branch)
574 lines
14 KiB
TypeScript
574 lines
14 KiB
TypeScript
import {
|
|
basename,
|
|
dirname,
|
|
isAbsolute,
|
|
join,
|
|
relative,
|
|
resolve,
|
|
} from 'node:path'
|
|
import { getProjectRoot } from '../bootstrap/state.js'
|
|
import { getCwd } from './cwd.js'
|
|
import { getFsImplementation } from './fsOperations.js'
|
|
import { normalizePathForConfigKey } from './path.js'
|
|
|
|
export const AUTONOMY_DIR = join('.claude', 'autonomy')
|
|
export const AUTONOMY_DIR_POSIX = '.claude/autonomy'
|
|
export const AUTONOMY_AGENTS_FILENAME = 'AGENTS.md'
|
|
export const AUTONOMY_HEARTBEAT_FILENAME = 'HEARTBEAT.md'
|
|
export const AUTONOMY_AGENTS_PATH_POSIX = `${AUTONOMY_DIR_POSIX}/${AUTONOMY_AGENTS_FILENAME}`
|
|
export const AUTONOMY_HEARTBEAT_PATH_POSIX = `${AUTONOMY_DIR_POSIX}/${AUTONOMY_HEARTBEAT_FILENAME}`
|
|
|
|
export type HeartbeatAuthorityTask = {
|
|
name: string
|
|
interval: string
|
|
prompt: string
|
|
steps: HeartbeatAuthorityTaskStep[]
|
|
}
|
|
|
|
export type HeartbeatAuthorityTaskStep = {
|
|
name: string
|
|
prompt: string
|
|
waitFor?: string
|
|
}
|
|
|
|
export type AutonomyAuthorityFile = {
|
|
path: string
|
|
relativePath: string
|
|
content: string
|
|
}
|
|
|
|
export type AutonomyAuthoritySnapshot = {
|
|
rootDir: string
|
|
currentDir: string
|
|
agentsFiles: AutonomyAuthorityFile[]
|
|
agentsContent: string | null
|
|
heartbeatFile: AutonomyAuthorityFile | null
|
|
heartbeatContent: string | null
|
|
heartbeatTasks: HeartbeatAuthorityTask[]
|
|
}
|
|
|
|
type AutonomyAuthorityParams = {
|
|
rootDir?: string
|
|
currentDir?: string
|
|
}
|
|
|
|
export type AutonomyTriggerKind =
|
|
| 'proactive-tick'
|
|
| 'scheduled-task'
|
|
| 'managed-flow-step'
|
|
|
|
export type PreparedAutonomyTurn = {
|
|
rootDir: string
|
|
currentDir: string
|
|
trigger: AutonomyTriggerKind
|
|
prompt: string
|
|
dueHeartbeatTasks: HeartbeatAuthorityTask[]
|
|
nowMs: number
|
|
}
|
|
|
|
const heartbeatTaskLastRunByKey = new Map<string, number>()
|
|
|
|
function isPathWithinRoot(rootDir: string, currentDir: string): boolean {
|
|
const delta = relative(rootDir, currentDir)
|
|
return delta === '' || (!delta.startsWith('..') && !isAbsolute(delta))
|
|
}
|
|
|
|
function listAuthorityDirectories(
|
|
rootDir: string,
|
|
currentDir: string,
|
|
): string[] {
|
|
const resolvedRoot = resolve(rootDir)
|
|
const resolvedCurrent = resolve(currentDir)
|
|
if (!isPathWithinRoot(resolvedRoot, resolvedCurrent)) {
|
|
return [resolvedRoot]
|
|
}
|
|
|
|
const dirs: string[] = []
|
|
let cursor = resolvedCurrent
|
|
for (;;) {
|
|
dirs.push(cursor)
|
|
if (cursor === resolvedRoot) {
|
|
break
|
|
}
|
|
const parent = dirname(cursor)
|
|
if (parent === cursor) {
|
|
break
|
|
}
|
|
cursor = parent
|
|
}
|
|
return dirs.reverse()
|
|
}
|
|
|
|
async function readAuthorityFile(
|
|
filePath: string,
|
|
rootDir: string,
|
|
): Promise<AutonomyAuthorityFile | null> {
|
|
try {
|
|
const content = (await getFsImplementation().readFile(filePath, {
|
|
encoding: 'utf-8',
|
|
})) as string
|
|
const trimmed = content.trim()
|
|
if (!trimmed) {
|
|
return null
|
|
}
|
|
return {
|
|
path: filePath,
|
|
relativePath:
|
|
normalizePathForConfigKey(relative(rootDir, filePath)) ||
|
|
basename(filePath),
|
|
content: trimmed,
|
|
}
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function mergeAgentsAuthority(files: AutonomyAuthorityFile[]): string | null {
|
|
if (files.length === 0) {
|
|
return null
|
|
}
|
|
|
|
return files
|
|
.map(file => `## ${file.relativePath}\n${file.content}`)
|
|
.join('\n\n')
|
|
}
|
|
|
|
/**
|
|
* Replaces fenced code-block content (and the ``` / ~~~ fence delimiters
|
|
* themselves) with empty strings while preserving the index of every
|
|
* other line. Used by the heartbeat parser so that `tasks:` literals
|
|
* appearing inside Markdown code samples in HEARTBEAT.md docs do not
|
|
* collide with the real config block.
|
|
*/
|
|
function maskCodeFencedLines(lines: string[]): string[] {
|
|
const masked = lines.slice()
|
|
let activeFenceChar: '`' | '~' | null = null
|
|
let activeFenceLen = 0
|
|
for (let i = 0; i < masked.length; i++) {
|
|
const trimmed = masked[i]!.trim()
|
|
const fenceMatch = trimmed.match(/^([`~])\1{2,}/)
|
|
if (fenceMatch) {
|
|
const fenceChar = fenceMatch[1]! as '`' | '~'
|
|
const fenceLen = fenceMatch[0]!.length
|
|
const trailing = trimmed.slice(fenceLen)
|
|
if (activeFenceChar === null) {
|
|
activeFenceChar = fenceChar
|
|
activeFenceLen = fenceLen
|
|
} else if (
|
|
activeFenceChar === fenceChar &&
|
|
fenceLen >= activeFenceLen &&
|
|
trailing.trim() === ''
|
|
) {
|
|
activeFenceChar = null
|
|
activeFenceLen = 0
|
|
}
|
|
masked[i] = ''
|
|
continue
|
|
}
|
|
if (activeFenceChar !== null) {
|
|
masked[i] = ''
|
|
}
|
|
}
|
|
return masked
|
|
}
|
|
|
|
export function parseHeartbeatAuthorityTasks(
|
|
content: string,
|
|
): HeartbeatAuthorityTask[] {
|
|
const tasks: HeartbeatAuthorityTask[] = []
|
|
const lines = maskCodeFencedLines(content.split('\n'))
|
|
const getIndent = (line: string): number =>
|
|
line.length - line.trimStart().length
|
|
const parseScalar = (line: string, key: string): string =>
|
|
line
|
|
.replace(key, '')
|
|
.trim()
|
|
.replace(/^["']|["']$/g, '')
|
|
|
|
function parseSteps(
|
|
startIndex: number,
|
|
stepsIndent: number,
|
|
): { steps: HeartbeatAuthorityTaskStep[]; nextIndex: number } {
|
|
const steps: HeartbeatAuthorityTaskStep[] = []
|
|
let index = startIndex
|
|
|
|
while (index < lines.length) {
|
|
const line = lines[index]!
|
|
const trimmed = line.trim()
|
|
const indent = getIndent(line)
|
|
|
|
if (!trimmed) {
|
|
index += 1
|
|
continue
|
|
}
|
|
|
|
if (indent <= stepsIndent) {
|
|
break
|
|
}
|
|
|
|
if (!trimmed.startsWith('- name:')) {
|
|
index += 1
|
|
continue
|
|
}
|
|
|
|
const stepIndent = indent
|
|
const name = parseScalar(trimmed, '- name:')
|
|
let prompt = ''
|
|
let waitFor: string | undefined
|
|
index += 1
|
|
|
|
while (index < lines.length) {
|
|
const nextLine = lines[index]!
|
|
const nextTrimmed = nextLine.trim()
|
|
const nextIndent = getIndent(nextLine)
|
|
|
|
if (!nextTrimmed) {
|
|
index += 1
|
|
continue
|
|
}
|
|
|
|
if (nextIndent <= stepIndent) {
|
|
break
|
|
}
|
|
|
|
if (nextTrimmed.startsWith('prompt:')) {
|
|
prompt = parseScalar(nextTrimmed, 'prompt:')
|
|
} else if (nextTrimmed.startsWith('wait_for:')) {
|
|
waitFor = parseScalar(nextTrimmed, 'wait_for:')
|
|
}
|
|
|
|
index += 1
|
|
}
|
|
|
|
if (name && prompt) {
|
|
steps.push({
|
|
name,
|
|
prompt,
|
|
...(waitFor ? { waitFor } : {}),
|
|
})
|
|
}
|
|
}
|
|
|
|
return { steps, nextIndex: index }
|
|
}
|
|
|
|
const tasksLineIndex = lines.findIndex(line => line.trim() === 'tasks:')
|
|
if (tasksLineIndex === -1) {
|
|
return tasks
|
|
}
|
|
|
|
const tasksIndent = getIndent(lines[tasksLineIndex]!)
|
|
let index = tasksLineIndex + 1
|
|
|
|
while (index < lines.length) {
|
|
const line = lines[index]!
|
|
const trimmed = line.trim()
|
|
const indent = getIndent(line)
|
|
|
|
if (!trimmed) {
|
|
index += 1
|
|
continue
|
|
}
|
|
|
|
if (indent <= tasksIndent) {
|
|
break
|
|
}
|
|
|
|
if (!trimmed.startsWith('- name:')) {
|
|
index += 1
|
|
continue
|
|
}
|
|
|
|
const taskIndent = indent
|
|
const name = parseScalar(trimmed, '- name:')
|
|
let interval = ''
|
|
let prompt = ''
|
|
let steps: HeartbeatAuthorityTaskStep[] = []
|
|
index += 1
|
|
|
|
while (index < lines.length) {
|
|
const nextLine = lines[index]!
|
|
const nextTrimmed = nextLine.trim()
|
|
const nextIndent = getIndent(nextLine)
|
|
|
|
if (!nextTrimmed) {
|
|
index += 1
|
|
continue
|
|
}
|
|
|
|
if (nextIndent <= tasksIndent) {
|
|
break
|
|
}
|
|
|
|
if (nextIndent === taskIndent && nextTrimmed.startsWith('- name:')) {
|
|
break
|
|
}
|
|
|
|
if (nextIndent <= taskIndent) {
|
|
break
|
|
}
|
|
|
|
if (nextTrimmed.startsWith('interval:')) {
|
|
interval = parseScalar(nextTrimmed, 'interval:')
|
|
index += 1
|
|
continue
|
|
}
|
|
|
|
if (nextTrimmed.startsWith('prompt:')) {
|
|
prompt = parseScalar(nextTrimmed, 'prompt:')
|
|
index += 1
|
|
continue
|
|
}
|
|
|
|
if (nextTrimmed === 'steps:') {
|
|
const parsed = parseSteps(index + 1, nextIndent)
|
|
steps = parsed.steps
|
|
index = parsed.nextIndex
|
|
continue
|
|
}
|
|
|
|
index += 1
|
|
}
|
|
|
|
if (name && interval && prompt) {
|
|
tasks.push({
|
|
name,
|
|
interval,
|
|
prompt,
|
|
steps,
|
|
})
|
|
}
|
|
}
|
|
|
|
return tasks
|
|
}
|
|
|
|
function parseHeartbeatIntervalMs(interval: string): number | null {
|
|
const match = interval.trim().match(/^(\d+)\s*(ms|s|m|h|d)?$/i)
|
|
if (!match) {
|
|
return null
|
|
}
|
|
|
|
const value = Number.parseInt(match[1]!, 10)
|
|
const unit = (match[2] ?? 'm').toLowerCase()
|
|
switch (unit) {
|
|
case 'ms':
|
|
return value
|
|
case 's':
|
|
return value * 1_000
|
|
case 'm':
|
|
return value * 60_000
|
|
case 'h':
|
|
return value * 60 * 60_000
|
|
case 'd':
|
|
return value * 24 * 60 * 60_000
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
|
|
function heartbeatTaskKey(
|
|
rootDir: string,
|
|
task: HeartbeatAuthorityTask,
|
|
): string {
|
|
return `${rootDir}::${task.name}::${task.interval}::${task.prompt}`
|
|
}
|
|
|
|
function collectDueHeartbeatTasks(
|
|
snapshot: AutonomyAuthoritySnapshot,
|
|
nowMs: number,
|
|
): HeartbeatAuthorityTask[] {
|
|
const due: HeartbeatAuthorityTask[] = []
|
|
for (const task of snapshot.heartbeatTasks) {
|
|
const intervalMs = parseHeartbeatIntervalMs(task.interval)
|
|
if (intervalMs == null) {
|
|
continue
|
|
}
|
|
const key = heartbeatTaskKey(snapshot.rootDir, task)
|
|
const lastRunMs = heartbeatTaskLastRunByKey.get(key)
|
|
if (lastRunMs !== undefined && nowMs - lastRunMs < intervalMs) {
|
|
continue
|
|
}
|
|
due.push(task)
|
|
}
|
|
return due
|
|
}
|
|
|
|
function markHeartbeatTasksConsumed(
|
|
snapshot: AutonomyAuthoritySnapshot,
|
|
tasks: HeartbeatAuthorityTask[],
|
|
nowMs: number,
|
|
): void {
|
|
for (const task of tasks) {
|
|
heartbeatTaskLastRunByKey.set(
|
|
heartbeatTaskKey(snapshot.rootDir, task),
|
|
nowMs,
|
|
)
|
|
}
|
|
}
|
|
|
|
export function resetAutonomyAuthorityForTests(): void {
|
|
heartbeatTaskLastRunByKey.clear()
|
|
}
|
|
|
|
export function hasAutonomyConfig(rootDir?: string): boolean {
|
|
const root = resolve(rootDir ?? getProjectRoot())
|
|
const fs = getFsImplementation()
|
|
try {
|
|
const agentsPath = join(root, AUTONOMY_DIR, AUTONOMY_AGENTS_FILENAME)
|
|
const heartbeatPath = join(root, AUTONOMY_DIR, AUTONOMY_HEARTBEAT_FILENAME)
|
|
return fs.existsSync(agentsPath) || fs.existsSync(heartbeatPath)
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
export async function loadAutonomyAuthority(
|
|
params: AutonomyAuthorityParams = {},
|
|
): Promise<AutonomyAuthoritySnapshot> {
|
|
const rootDir = resolve(params.rootDir ?? getProjectRoot())
|
|
const currentDir = resolve(params.currentDir ?? getCwd())
|
|
const authorityDirs = listAuthorityDirectories(rootDir, currentDir)
|
|
|
|
const [agentsResults, heartbeatFile] = await Promise.all([
|
|
Promise.all(
|
|
authorityDirs.map(async dir =>
|
|
readAuthorityFile(
|
|
join(dir, AUTONOMY_DIR, AUTONOMY_AGENTS_FILENAME),
|
|
rootDir,
|
|
),
|
|
),
|
|
),
|
|
readAuthorityFile(
|
|
join(rootDir, AUTONOMY_DIR, AUTONOMY_HEARTBEAT_FILENAME),
|
|
rootDir,
|
|
),
|
|
])
|
|
const agentsFiles = agentsResults.filter(
|
|
(file): file is AutonomyAuthorityFile => file !== null,
|
|
)
|
|
|
|
return {
|
|
rootDir,
|
|
currentDir,
|
|
agentsFiles,
|
|
agentsContent: mergeAgentsAuthority(agentsFiles),
|
|
heartbeatFile,
|
|
heartbeatContent: heartbeatFile?.content ?? null,
|
|
heartbeatTasks: heartbeatFile
|
|
? parseHeartbeatAuthorityTasks(heartbeatFile.content)
|
|
: [],
|
|
}
|
|
}
|
|
|
|
export async function buildAutonomyTurnPrompt(params: {
|
|
basePrompt: string
|
|
trigger: AutonomyTriggerKind
|
|
rootDir?: string
|
|
currentDir?: string
|
|
nowMs?: number
|
|
}): Promise<string> {
|
|
const prepared = await prepareAutonomyTurnPrompt(params)
|
|
commitPreparedAutonomyTurn(prepared)
|
|
return prepared.prompt
|
|
}
|
|
|
|
export async function prepareAutonomyTurnPrompt(params: {
|
|
basePrompt: string
|
|
trigger: AutonomyTriggerKind
|
|
rootDir?: string
|
|
currentDir?: string
|
|
nowMs?: number
|
|
}): Promise<PreparedAutonomyTurn> {
|
|
const snapshot = await loadAutonomyAuthority({
|
|
rootDir: params.rootDir,
|
|
currentDir: params.currentDir,
|
|
})
|
|
const nowMs = params.nowMs ?? Date.now()
|
|
const dueHeartbeatTasks =
|
|
params.trigger === 'proactive-tick'
|
|
? collectDueHeartbeatTasks(snapshot, nowMs)
|
|
: []
|
|
const duePromptTasks = dueHeartbeatTasks.filter(
|
|
task => task.steps.length === 0,
|
|
)
|
|
|
|
const sections: string[] = []
|
|
if (snapshot.agentsContent) {
|
|
sections.push(
|
|
`Workspace authority from ${AUTONOMY_AGENTS_FILENAME}:\n${snapshot.agentsContent}`,
|
|
)
|
|
}
|
|
if (snapshot.heartbeatContent) {
|
|
sections.push(
|
|
`Workspace heartbeat guidance from ${AUTONOMY_HEARTBEAT_FILENAME}:\n${snapshot.heartbeatContent}`,
|
|
)
|
|
}
|
|
if (duePromptTasks.length > 0) {
|
|
sections.push(
|
|
[
|
|
`Due ${AUTONOMY_HEARTBEAT_FILENAME} tasks:`,
|
|
...duePromptTasks.map(
|
|
task => `- ${task.name} (${task.interval}): ${task.prompt}`,
|
|
),
|
|
].join('\n'),
|
|
)
|
|
}
|
|
|
|
if (sections.length === 0) {
|
|
return {
|
|
rootDir: snapshot.rootDir,
|
|
currentDir: snapshot.currentDir,
|
|
trigger: params.trigger,
|
|
prompt: params.basePrompt,
|
|
dueHeartbeatTasks,
|
|
nowMs,
|
|
}
|
|
}
|
|
|
|
const prelude =
|
|
params.trigger === 'proactive-tick'
|
|
? 'This is an autonomous proactive turn. Follow the workspace authority below before acting.'
|
|
: 'This prompt was generated automatically. Follow the workspace authority below before acting.'
|
|
|
|
return {
|
|
rootDir: snapshot.rootDir,
|
|
currentDir: snapshot.currentDir,
|
|
trigger: params.trigger,
|
|
prompt: [
|
|
prelude,
|
|
'<autonomy_authority>',
|
|
...sections,
|
|
'</autonomy_authority>',
|
|
params.basePrompt,
|
|
].join('\n\n'),
|
|
dueHeartbeatTasks,
|
|
nowMs,
|
|
}
|
|
}
|
|
|
|
export function commitPreparedAutonomyTurn(
|
|
prepared: PreparedAutonomyTurn,
|
|
): void {
|
|
if (
|
|
prepared.trigger !== 'proactive-tick' ||
|
|
prepared.dueHeartbeatTasks.length === 0
|
|
) {
|
|
return
|
|
}
|
|
const snapshot: AutonomyAuthoritySnapshot = {
|
|
rootDir: prepared.rootDir,
|
|
currentDir: prepared.currentDir,
|
|
agentsFiles: [],
|
|
agentsContent: null,
|
|
heartbeatFile: null,
|
|
heartbeatContent: null,
|
|
heartbeatTasks: prepared.dueHeartbeatTasks,
|
|
}
|
|
markHeartbeatTasksConsumed(
|
|
snapshot,
|
|
prepared.dueHeartbeatTasks,
|
|
prepared.nowMs,
|
|
)
|
|
}
|