diff --git a/README.md b/README.md index 0065c9422d..a4b1802732 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ verboo /login ## What Works - **Tool-driven coding workflows**: Bash, file read/write/edit, grep, glob, agents, tasks, MCP, and slash commands +- **Persistent session goals**: `/goal ` keeps a goal active across turns; use `/goal status`, `/goal pause`, `/goal resume`, `/goal clear`, and `/goal complete` to manage it. A new `/goal ` replaces the current goal. - **Streaming responses**: Real-time token output and tool progress - **Tool calling**: Multi-step tool loops with model calls, tool execution, and follow-up responses - **Images**: URL and base64 image inputs for providers that support vision diff --git a/package.json b/package.json index 125bc0ad31..c2d2399fec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@verboo/code", - "version": "0.15.34", + "version": "0.15.35", "description": "Verboo Code — coding agent for the Verboo platform", "type": "module", "bin": { diff --git a/scripts/e2e/cli.e2e.mjs b/scripts/e2e/cli.e2e.mjs index ac1dbd5fde..018d2c72b6 100644 --- a/scripts/e2e/cli.e2e.mjs +++ b/scripts/e2e/cli.e2e.mjs @@ -32,6 +32,36 @@ for (const fullscreen of [false, true]) test(`routing header appears once with t } finally { await cli.stop() } }) +test('interactive /goal shows, retries, and clears the active objective', { timeout: 60_000 }, async () => { + const objective = 'Explain where slash commands are registered' + const cli = await startCli({ columns: 120, rows: 40, slashCommands: true, routerOptions: { routedCompletion: true } }) + try { + await cli.waitFor(() => cli.screen().includes('❯')) + cli.write(`/goal ${objective}`) + await cli.waitFor(() => cli.screen().includes(`│ ❯ /goal ${objective}`)) + cli.write('\r') + await cli.waitFor(() => cli.screen().includes(`Goal active: ${objective}`)) + await cli.waitFor(() => cli.screen().includes('E2E_ROUTED_COMPLETE fixture-1')) + assert.equal(cli.router.requests.length, 1) + + const goalRequestIndex = () => cli.router.requests.findIndex(request => + (JSON.stringify(request.messages).match(/\/goal<\/command-name>/g) || []).length >= 2, + ) + cli.write(`/goal ${objective}`) + await cli.waitFor(() => cli.screen().includes(`│ ❯ /goal ${objective}`)) + cli.write('\r') + await cli.waitFor(() => cli.screen().includes('Goal already active; continuing:')) + await cli.waitFor(() => goalRequestIndex() >= 0) + await cli.waitFor(() => cli.screen().includes(`E2E_ROUTED_COMPLETE fixture-${goalRequestIndex() + 1}`)) + + cli.write('/goal clear') + await cli.waitFor(() => cli.screen().includes('│ ❯ /goal clear')) + cli.write('\r') + await cli.waitFor(() => cli.screen().includes('Goal cleared:') && !cli.screen().includes(`Goal active: ${objective}`)) + assert.deepEqual(cli.router.unexpected, []) + } finally { await cli.stop() } +}) + for (const fullscreen of [false, true]) for (const [columns, rows] of [[40, 12], [80, 24], [120, 40]]) for (const agents of [1, 2, 8, 20]) { test(`installed CLI: ${agents} agents, ${columns}x${rows}, fullscreen=${fullscreen}`, { timeout: 60_000 }, async () => { const cli = await startCli({ columns, rows, fullscreen, routerOptions: { agents }, args: ['E2E_PARENT: delegate to the fixture agents.'] }) diff --git a/scripts/e2e/fake-router.mjs b/scripts/e2e/fake-router.mjs index c3416a2b11..c423cd7d7a 100644 --- a/scripts/e2e/fake-router.mjs +++ b/scripts/e2e/fake-router.mjs @@ -37,7 +37,7 @@ export async function createFakeRouter({ agents = 2, omitUsage = false, zeroUsag emit({ role: 'assistant' }) if (routedCompletion && body.tools?.length) { emit({ reasoning_content: 'Synthetic hidden reasoning.' }) - emit({ content: 'Oi! E2E_ROUTED_COMPLETE' }) + emit({ content: `Oi! E2E_ROUTED_COMPLETE ${id}` }) emit({}, 'stop', { prompt_tokens: 10, completion_tokens: 4 }) } else if (isChild && childToolLoop) { emit({ tool_calls: [{ index: 0, id: `read-${sequence}`, type: 'function', function: { name: 'Read', arguments: JSON.stringify({ file_path: 'README.md' }) } }] }) diff --git a/scripts/e2e/terminal.mjs b/scripts/e2e/terminal.mjs index 4e7f88365f..7366eb0981 100644 --- a/scripts/e2e/terminal.mjs +++ b/scripts/e2e/terminal.mjs @@ -9,7 +9,7 @@ import { spawn, spawnSync } from 'node:child_process' import { once } from 'node:events' import { createFakeRouter } from './fake-router.mjs' -export async function startCli({ columns = 80, rows = 24, fullscreen = false, usePty = true, model = 'fixture-model', args = [], routerOptions } = {}) { +export async function startCli({ columns = 80, rows = 24, fullscreen = false, usePty = true, slashCommands = false, model = 'fixture-model', args = [], routerOptions } = {}) { const consumer = (await readFile(resolve('.artifacts/package/consumer-path.txt'), 'utf8')).trim() const root = resolve('.artifacts/pty') await mkdir(root, { recursive: true }) @@ -49,7 +49,7 @@ export async function startCli({ columns = 80, rows = 24, fullscreen = false, us terminal.unicode.activeVersion = '11' let child let stderr = '' - const cliArgs = ['--import', pathToFileURL(resolve('scripts/e2e/transport-preload.mjs')).href, join(consumer, 'node_modules/@verboo/code/bin/verboo'), '--model', model, '--dangerously-skip-permissions', '--setting-sources', 'user', '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}', '--disable-slash-commands', '--debug-file', join(dir, 'debug.log'), ...args] + const cliArgs = ['--import', pathToFileURL(resolve('scripts/e2e/transport-preload.mjs')).href, join(consumer, 'node_modules/@verboo/code/bin/verboo'), '--model', model, '--dangerously-skip-permissions', '--setting-sources', 'user', '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}', ...(slashCommands ? [] : ['--disable-slash-commands']), '--debug-file', join(dir, 'debug.log'), ...args] try { if (usePty) { child = pty.spawn(process.execPath, cliArgs, { cwd: project, env, cols: columns, rows, name: 'xterm-256color' }) diff --git a/scripts/npm-pack-json.mjs b/scripts/npm-pack-json.mjs new file mode 100644 index 0000000000..e7a708a5d7 --- /dev/null +++ b/scripts/npm-pack-json.mjs @@ -0,0 +1,25 @@ +/** + * npm versions have emitted both an array and a single object for `npm pack + * --json`. Normalize both shapes while rejecting output that cannot safely + * describe the tarball consumed by the package verification step. + */ +export function parseNpmPackJson(stdout) { + let parsed + try { + parsed = JSON.parse(stdout) + } catch (error) { + throw new Error(`npm pack returned invalid JSON: ${error.message}`) + } + + let pack = Array.isArray(parsed) ? parsed[0] : parsed + // npm 12 can wrap the one packed workspace under its package name. + if (pack && typeof pack === 'object' && typeof pack.filename !== 'string' && Object.keys(pack).length === 1) { + const wrapped = Object.values(pack)[0] + pack = Array.isArray(wrapped) ? wrapped[0] : wrapped + } + if (!pack || typeof pack !== 'object' || typeof pack.filename !== 'string' || !Array.isArray(pack.files)) { + const shape = pack && typeof pack === 'object' ? `object fields: ${Object.keys(pack).join(', ') || '(none)'}` : typeof pack + throw new Error(`npm pack returned JSON without a package filename and files list (${shape})`) + } + return pack +} diff --git a/scripts/npm-pack-json.test.mjs b/scripts/npm-pack-json.test.mjs new file mode 100644 index 0000000000..c71292513b --- /dev/null +++ b/scripts/npm-pack-json.test.mjs @@ -0,0 +1,24 @@ +import { describe, expect, test } from 'bun:test' +import { parseNpmPackJson } from './npm-pack-json.mjs' + +describe('parseNpmPackJson', () => { + const pack = { filename: 'verboo-code-1.2.3.tgz', files: [{ path: 'dist/cli.mjs' }] } + + test('accepts npm output as a one-item array', () => { + expect(parseNpmPackJson(JSON.stringify([pack]))).toEqual(pack) + }) + + test('accepts npm output as a single object', () => { + expect(parseNpmPackJson(JSON.stringify(pack))).toEqual(pack) + }) + + test('accepts npm output wrapped under the package name', () => { + expect(parseNpmPackJson(JSON.stringify({ '@verboo/code': pack }))).toEqual(pack) + }) + + test('rejects malformed npm output', () => { + expect(() => parseNpmPackJson('not json')).toThrow('npm pack returned invalid JSON') + expect(() => parseNpmPackJson('{}')).toThrow('without a package filename and files list') + expect(() => parseNpmPackJson('[]')).toThrow('without a package filename and files list') + }) +}) diff --git a/scripts/prepare-cli-package.mjs b/scripts/prepare-cli-package.mjs index 06e4f7801c..93618d85c2 100644 --- a/scripts/prepare-cli-package.mjs +++ b/scripts/prepare-cli-package.mjs @@ -3,6 +3,7 @@ import { createHash } from 'node:crypto' import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, resolve, join } from 'node:path' import { tmpdir } from 'node:os' +import { parseNpmPackJson } from './npm-pack-json.mjs' // Build once before this script. Neither packing nor installing may rebuild it. const output = resolve('.artifacts/package') @@ -28,7 +29,7 @@ function npm(args, cwd) { return result.stdout } if (!process.argv.includes('--install-only') && !process.argv.includes('--verify-only')) { - const [pack] = JSON.parse(npm(['pack', '--ignore-scripts', '--json', '--pack-destination', output])) + const pack = parseNpmPackJson(npm(['pack', '--ignore-scripts', '--json', '--pack-destination', output])) writeFileSync(join(output, 'sha256.txt'), `${createHash('sha256').update(readFileSync(join(output, pack.filename))).digest('hex')} ${pack.filename}\n`) writeFileSync(join(output, 'package-info.json'), JSON.stringify({ tarball: pack.filename, files: pack.files.map(file => file.path) }, null, 2)) } diff --git a/scripts/run-tests-isolated.ts b/scripts/run-tests-isolated.ts index cbc8c2301e..5a099aef1b 100644 --- a/scripts/run-tests-isolated.ts +++ b/scripts/run-tests-isolated.ts @@ -83,7 +83,9 @@ export async function main(): Promise { process.stdout.write(`[${results.length}/${files.length}] ${result.exitCode ? 'FAIL' : 'PASS'} ${file}\n`) } } - await Promise.all(Array.from({ length: Math.min(files.length, positiveInteger(process.env.TEST_ISOLATION_CONCURRENCY, 4)) }, worker)) + // Each suite launches a full Bun process. Running four UI-heavy suites at + // once starves their short terminal/render deadlines on CI-sized machines. + await Promise.all(Array.from({ length: Math.min(files.length, positiveInteger(process.env.TEST_ISOLATION_CONCURRENCY, 1)) }, worker)) const failures = results.filter(r => r.exitCode !== 0) const reportDir = process.env.TEST_REPORT_DIR || '.artifacts/test-results' await mkdir(reportDir, { recursive: true }) diff --git a/src/commands.ts b/src/commands.ts index d605c46ac0..ee0cc24a12 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -27,6 +27,7 @@ import onboardGithub from './commands/onboard-github/index.js' import knowledge from './commands/knowledge/index.js' import memory from './commands/memory/index.js' import help from './commands/help/index.js' +import goal from './commands/goal/index.js' import ide from './commands/ide/index.js' import init from './commands/init.js' import initVerifiers from './commands/init-verifiers.js' @@ -305,6 +306,7 @@ const COMMANDS = memoize((): Command[] => [ files, heapDump, help, + goal, ide, init, keybindings, diff --git a/src/commands/goal/goal.tsx b/src/commands/goal/goal.tsx new file mode 100644 index 0000000000..13b436666e --- /dev/null +++ b/src/commands/goal/goal.tsx @@ -0,0 +1,180 @@ +import type { LocalJSXCommandCall } from '../../types/command.js' +import { + clearSessionGoal, + createSessionGoal, + getSessionGoal, + startSessionGoal, + setSessionGoalStatus, + type SessionGoal, +} from '../../utils/goals.js' + +function formatGoal(goal: SessionGoal): string { + const status = goal.status === 'complete' ? 'completed' : goal.status + const blocker = + goal.status === 'blocked' && goal.blocker?.reason + ? `\nBlocker: ${goal.blocker.reason}` + : '' + return `Current goal (${status}):\n${goal.objective}${blocker}` +} + +function usage(): string { + return [ + 'Usage:', + ' /goal Set or replace a persistent goal and start working on it', + ' /goal Show the current goal', + ' /goal status Show the current goal', + ' /goal complete Mark the current goal complete', + ' /goal pause Pause the current goal', + ' /goal resume Resume a paused or blocked goal', + ' /goal clear Remove the current goal', + ].join('\n') +} + +export const call: LocalJSXCommandCall = async (onDone, _context, args) => { + const input = args.trim() + const [action, ...rest] = input.split(/\s+/) + const normalizedAction = action?.toLowerCase() + const isSingleAction = rest.length === 0 + + if ( + !input || + (isSingleAction && + (normalizedAction === 'status' || normalizedAction === 'show')) + ) { + const goal = getSessionGoal() + onDone( + goal + ? formatGoal(goal) + : `No goal is set for this session.\n\n${usage()}`, + { display: 'system' }, + ) + return null + } + + if ( + isSingleAction && + (normalizedAction === 'help' || normalizedAction === '--help') + ) { + onDone(usage(), { display: 'system' }) + return null + } + + if ( + isSingleAction && + (normalizedAction === 'set' || normalizedAction === 'start') + ) { + onDone(usage(), { display: 'system' }) + return null + } + + if ( + isSingleAction && + ['clear', 'stop', 'off', 'reset', 'none', 'cancel'].includes( + normalizedAction ?? '', + ) + ) { + let goal: SessionGoal | null + try { + goal = clearSessionGoal() + } catch (error) { + onDone(error instanceof Error ? error.message : String(error), { + display: 'system', + }) + return null + } + onDone( + goal + ? `Goal cleared:\n${goal.objective}` + : 'No goal is set for this session.', + { display: 'system' }, + ) + return null + } + + if ( + isSingleAction && + (normalizedAction === 'complete' || + normalizedAction === 'pause' || + normalizedAction === 'resume') + ) { + const status = + normalizedAction === 'complete' + ? 'complete' + : normalizedAction === 'pause' + ? 'paused' + : 'active' + let goal: SessionGoal | null + try { + goal = setSessionGoalStatus(status) + } catch (error) { + onDone(error instanceof Error ? error.message : String(error), { + display: 'system', + }) + return null + } + if (!goal) { + const current = getSessionGoal() + const message = current?.status === 'complete' + ? 'This goal is already complete. Set a new goal with /goal .' + : 'No goal is set for this session.' + onDone(message, { display: 'system' }) + return null + } + + const result = normalizedAction === 'resume' + ? `Goal resumed:\n${goal.objective}` + : normalizedAction === 'pause' + ? `Goal paused:\n${goal.objective}` + : `Goal marked complete:\n${goal.objective}` + const shouldQuery = normalizedAction === 'resume' + onDone(result, { + display: 'system', + shouldQuery, + ...(shouldQuery + ? { + metaMessages: [ + `The user resumed this persistent goal. Continue working toward it: ${JSON.stringify(goal.objective)}`, + ], + } + : {}), + }) + return null + } + + const objective = + (normalizedAction === 'set' || normalizedAction === 'start') && rest.length > 0 + ? rest.join(' ') + : input + if (!objective.trim()) { + onDone(usage(), { display: 'system' }) + return null + } + + try { + const previous = getSessionGoal() + const goal = previous + ? startSessionGoal(objective) + : createSessionGoal(objective) + const hadUnfinishedGoal = previous && previous.status !== 'complete' + const sameObjective = previous?.objective === goal.objective + const result = !hadUnfinishedGoal + ? `Goal set:\n${goal.objective}` + : sameObjective && previous.status === 'active' + ? `Goal already active; continuing:\n${goal.objective}` + : sameObjective + ? `Goal resumed:\n${goal.objective}` + : `Goal replaced:\n${goal.objective}` + onDone(result, { + display: 'system', + shouldQuery: true, + metaMessages: [ + `The user set or resumed this persistent goal for the current session. Work toward it across turns until it is complete or the user pauses or clears it: ${JSON.stringify(goal.objective)}`, + ], + }) + } catch (error) { + onDone(error instanceof Error ? error.message : String(error), { + display: 'system', + }) + } + return null +} diff --git a/src/commands/goal/index.ts b/src/commands/goal/index.ts new file mode 100644 index 0000000000..80ea2a4ae6 --- /dev/null +++ b/src/commands/goal/index.ts @@ -0,0 +1,11 @@ +import type { Command } from '../../commands.js' + +const goal = { + type: 'local-jsx', + name: 'goal', + description: 'Set and track a persistent goal for this session', + argumentHint: '[|status|complete|pause|resume]', + load: () => import('./goal.js'), +} satisfies Command + +export default goal diff --git a/src/components/PromptInput/GoalIndicator.tsx b/src/components/PromptInput/GoalIndicator.tsx new file mode 100644 index 0000000000..7745060a4c --- /dev/null +++ b/src/components/PromptInput/GoalIndicator.tsx @@ -0,0 +1,36 @@ +import { useSyncExternalStore, type ReactNode } from 'react' +import { Box, Text } from '../../ink.js' +import { + getSessionGoalSnapshot, + subscribeToSessionGoal, + summarizeGoalObjective, +} from '../../utils/goals.js' + +export function GoalIndicator(): ReactNode { + const goal = useSyncExternalStore( + subscribeToSessionGoal, + getSessionGoalSnapshot, + getSessionGoalSnapshot, + ) + + if (!goal || goal.status === 'complete') return null + + const label = goal.status === 'active' ? 'Goal active' : `Goal ${goal.status}` + const color = + goal.status === 'active' + ? 'green' + : goal.status === 'blocked' + ? 'red' + : 'yellow' + + return ( + + + {label}: + + + {summarizeGoalObjective(goal.objective)} + + + ) +} diff --git a/src/components/PromptInput/PromptInput.tsx b/src/components/PromptInput/PromptInput.tsx index 0adddc6383..42a72bf004 100644 --- a/src/components/PromptInput/PromptInput.tsx +++ b/src/components/PromptInput/PromptInput.tsx @@ -193,7 +193,7 @@ type Props = { }; // Bottom slot has maxHeight="50%"; reserve lines for footer, border, status. -const PROMPT_FOOTER_LINES = 5; +const PROMPT_FOOTER_LINES = 6; const MIN_INPUT_VIEWPORT_LINES = 3; function PromptInput({ debug, diff --git a/src/components/PromptInput/PromptInputFooter.tsx b/src/components/PromptInput/PromptInputFooter.tsx index 022c4e617f..e626afe0ac 100644 --- a/src/components/PromptInput/PromptInputFooter.tsx +++ b/src/components/PromptInput/PromptInputFooter.tsx @@ -33,6 +33,7 @@ import { PromptInputFooterLeftSide } from './PromptInputFooterLeftSide.js'; import { PromptInputFooterSuggestions, type SuggestionItem } from './PromptInputFooterSuggestions.js'; import { PromptInputHelpMenu } from './PromptInputHelpMenu.js'; import { ContextUsageRow } from './ContextUsageRow.js'; +import { GoalIndicator } from './GoalIndicator.js'; /** * ContextWindowDisplay with memo to prevent re-renders on every keystroke. @@ -187,6 +188,7 @@ function PromptInputFooter({ {mode === 'prompt' && !isShort && !exitMessage.show && !isPasting && statusLineShouldDisplay(settings) && } + {mode === 'prompt' && !exitMessage.show && !isPasting && } diff --git a/src/constants/tools.ts b/src/constants/tools.ts index bdfdf070d3..ad6c70afd0 100644 --- a/src/constants/tools.ts +++ b/src/constants/tools.ts @@ -22,6 +22,7 @@ import { TASK_CREATE_TOOL_NAME } from '../tools/TaskCreateTool/constants.js' import { TASK_GET_TOOL_NAME } from '../tools/TaskGetTool/constants.js' import { TASK_LIST_TOOL_NAME } from '../tools/TaskListTool/constants.js' import { TASK_UPDATE_TOOL_NAME } from '../tools/TaskUpdateTool/constants.js' +import { GOAL_UPDATE_TOOL_NAME } from '../tools/GoalUpdateTool/constants.js' import { TOOL_SEARCH_TOOL_NAME } from '../tools/ToolSearchTool/prompt.js' import { SYNTHETIC_OUTPUT_TOOL_NAME } from '../tools/SyntheticOutputTool/SyntheticOutputTool.js' import { ENTER_WORKTREE_TOOL_NAME } from '../tools/EnterWorktreeTool/constants.js' @@ -38,6 +39,7 @@ export const ALL_AGENT_DISALLOWED_TOOLS = new Set([ EXIT_PLAN_MODE_V2_TOOL_NAME, ENTER_PLAN_MODE_TOOL_NAME, ASK_USER_QUESTION_TOOL_NAME, + GOAL_UPDATE_TOOL_NAME, TASK_STOP_TOOL_NAME, // Prevent recursive workflow execution inside subagents. ...(feature('WORKFLOW_SCRIPTS') ? [WORKFLOW_TOOL_NAME] : []), diff --git a/src/tools.ts b/src/tools.ts index ecccb60c84..e565bceb7b 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -72,6 +72,7 @@ import { TaskCreateTool } from './tools/TaskCreateTool/TaskCreateTool.js' import { TaskGetTool } from './tools/TaskGetTool/TaskGetTool.js' import { TaskUpdateTool } from './tools/TaskUpdateTool/TaskUpdateTool.js' import { TaskListTool } from './tools/TaskListTool/TaskListTool.js' +import { GoalUpdateTool } from './tools/GoalUpdateTool/GoalUpdateTool.js' import uniqBy from 'lodash-es/uniqBy.js' import { isToolSearchEnabledOptimistic } from './utils/toolSearch.js' import { isTodoV2Enabled } from './utils/tasks.js' @@ -195,6 +196,7 @@ export function getAllBaseTools(): Tools { NotebookEditTool, WebFetchTool, TodoWriteTool, + GoalUpdateTool, WebSearchTool, TaskStopTool, AskUserQuestionTool, diff --git a/src/tools/GoalUpdateTool/GoalUpdateTool.ts b/src/tools/GoalUpdateTool/GoalUpdateTool.ts new file mode 100644 index 0000000000..e2ed364ad8 --- /dev/null +++ b/src/tools/GoalUpdateTool/GoalUpdateTool.ts @@ -0,0 +1,164 @@ +import { z } from 'zod/v4' +import { buildTool, type ToolDef } from '../../Tool.js' +import { + getSessionGoal, + recordGoalBlocker, + setSessionGoalStatus, +} from '../../utils/goals.js' +import { lazySchema } from '../../utils/lazySchema.js' +import { jsonStringify } from '../../utils/slowOperations.js' +import { GOAL_UPDATE_TOOL_NAME } from './constants.js' +import { DESCRIPTION, PROMPT } from './prompt.js' + +const inputSchema = lazySchema(() => + z.strictObject({ + status: z.enum(['complete', 'paused', 'blocked']).describe( + 'The new status for the current session goal', + ), + reason: z.string().optional().describe( + 'A concise explanation, required when marking the goal blocked', + ), + }), +) +type InputSchema = ReturnType + +const outputSchema = lazySchema(() => + z.object({ + success: z.boolean(), + message: z.string(), + status: z.string().optional(), + consecutiveTurns: z.number().optional(), + }), +) +type OutputSchema = ReturnType + +type Output = z.infer + +function countUserTurns(messages: Array<{ type: string; isMeta?: boolean }>): number { + return messages.reduce( + (count, message) => + message.type === 'user' && !message.isMeta ? count + 1 : count, + 0, + ) +} + +export const GoalUpdateTool = buildTool({ + name: GOAL_UPDATE_TOOL_NAME, + searchHint: 'update persistent session goal status', + maxResultSizeChars: 10_000, + strict: true, + userFacingName() { + return '' + }, + get inputSchema(): InputSchema { + return inputSchema() + }, + get outputSchema(): OutputSchema { + return outputSchema() + }, + async description() { + return DESCRIPTION + }, + async prompt() { + return PROMPT + }, + toAutoClassifierInput(input) { + return `${input.status}${input.reason ? `: ${input.reason}` : ''}` + }, + renderToolUseMessage() { + return null + }, + async checkPermissions(input) { + return { behavior: 'allow', updatedInput: input } + }, + async call({ status, reason }, context) { + if (status === 'blocked' && !reason?.trim()) { + return { + data: { + success: false, + message: 'A concise reason is required when marking a goal blocked.', + }, + } + } + + if (context.agentId) { + return { + data: { + success: false, + message: 'Only the main session can update its persistent goal.', + }, + } + } + + const current = getSessionGoal() + if (!current) { + return { + data: { success: false, message: 'No goal is set for this session.' }, + } + } + + if (current.status !== 'active') { + return { + data: { + success: false, + status: current.status, + message: `The goal is ${current.status}. Use /goal resume after the user is ready to continue.`, + }, + } + } + + if (status === 'blocked') { + const result = recordGoalBlocker( + reason ?? '', + countUserTurns(context.messages), + ) + if (!result.goal) { + return { + data: { success: false, message: 'No goal is set for this session.' }, + } + } + if (!result.blocked) { + return { + data: { + success: false, + status: result.goal.status, + consecutiveTurns: result.consecutiveTurns, + message: `The blocker was recorded for ${result.consecutiveTurns} of 3 consecutive user turns. Continue making useful progress where possible; do not describe the goal as blocked yet.`, + }, + } + } + return { + data: { + success: true, + status: result.goal.status, + consecutiveTurns: result.consecutiveTurns, + message: `Goal marked blocked after the same blocker recurred for three consecutive user turns: ${result.goal.blocker?.reason}`, + }, + } + } + + const goal = setSessionGoalStatus(status) + if (!goal) { + return { + data: { success: false, message: 'The goal could not be updated.' }, + } + } + return { + data: { + success: true, + status: goal.status, + message: + goal.status === 'complete' + ? 'Goal marked complete.' + : 'Goal paused because the user requested it.', + }, + } + }, + mapToolResultToToolResultBlockParam(output: Output, toolUseID) { + return { + tool_use_id: toolUseID, + type: 'tool_result', + content: jsonStringify(output), + } + }, +} satisfies ToolDef) diff --git a/src/tools/GoalUpdateTool/constants.ts b/src/tools/GoalUpdateTool/constants.ts new file mode 100644 index 0000000000..ce43f9d875 --- /dev/null +++ b/src/tools/GoalUpdateTool/constants.ts @@ -0,0 +1 @@ +export const GOAL_UPDATE_TOOL_NAME = 'GoalUpdate' diff --git a/src/tools/GoalUpdateTool/prompt.ts b/src/tools/GoalUpdateTool/prompt.ts new file mode 100644 index 0000000000..be67e5859c --- /dev/null +++ b/src/tools/GoalUpdateTool/prompt.ts @@ -0,0 +1,12 @@ +export const DESCRIPTION = + 'Update the status of the current session goal after meaningful progress or completion' + +export const PROMPT = `Use this tool to update the persistent goal created with /goal. + +## Goal status rules + +- Mark a goal complete only after its objective has actually been achieved. +- Mark a goal paused only when the user explicitly asks to pause it. +- Mark a goal blocked only when the same blocker has prevented meaningful progress for three consecutive user turns. Give a concise, specific reason. The tool records the repeated blocker and will reject blocked status until that threshold is met; one turn can count only once. +- Do not update a goal owned by a different session or a subagent. The user can resume a paused or blocked goal with /goal resume. +` diff --git a/src/utils/attachments.ts b/src/utils/attachments.ts index e3c8362190..523579766c 100644 --- a/src/utils/attachments.ts +++ b/src/utils/attachments.ts @@ -35,6 +35,7 @@ import { isTodoV2Enabled, } from './tasks.js' import { getPlanFilePath, getPlan } from './plans.js' +import { formatGoalReminder, getSessionGoal } from './goals.js' import { getConnectedIdeName } from './ide.js' import { filterInjectedMemoryFiles, @@ -741,6 +742,22 @@ export type TeamContextAttachment = { * This is janky * TODO: Generate attachments when we create messages */ +function getSessionGoalAttachments( + toolUseContext: ToolUseContext, +): Attachment[] { + // The persistent goal belongs to the main session. Agents receive scoped + // task prompts from the coordinator and must not change the session goal. + if (toolUseContext.agentId) return [] + const goal = getSessionGoal() + if (!goal || goal.status === 'paused' || goal.status === 'complete') return [] + return [ + { + type: 'critical_system_reminder', + content: formatGoalReminder(goal), + }, + ] +} + export async function getAttachments( input: string | null, toolUseContext: ToolUseContext, @@ -758,7 +775,10 @@ export async function getAttachments( // getAttachmentMessages runs — returning [] here silently drops them. // Coworker runs with --bare and depends on task-notification for // mid-tool-call notifications from Local*Task/Remote*Task. - return getQueuedCommandAttachments(queuedCommands) + return [ + ...(await getQueuedCommandAttachments(queuedCommands)), + ...getSessionGoalAttachments(toolUseContext), + ] } // This will slow down submissions @@ -881,6 +901,7 @@ export async function getAttachments( // replaces it; see src/services/skillSearch/prefetch.ts. maybe('plan_mode', () => getPlanModeAttachments(messages, toolUseContext)), maybe('plan_mode_exit', () => getPlanModeExitAttachment(toolUseContext)), + maybe('session_goal', async () => getSessionGoalAttachments(toolUseContext)), ...(feature('TRANSCRIPT_CLASSIFIER') ? [ maybe('auto_mode', () => diff --git a/src/utils/goals.test.ts b/src/utils/goals.test.ts new file mode 100644 index 0000000000..a2d6c3c09a --- /dev/null +++ b/src/utils/goals.test.ts @@ -0,0 +1,515 @@ +import { randomUUID } from 'crypto' +import { createElement } from 'react' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { getSessionId, switchSession } from '../bootstrap/state.js' +import { GoalIndicator } from '../components/PromptInput/GoalIndicator.js' +import { call as runGoalCommand } from '../commands/goal/goal.js' +import { GOAL_UPDATE_TOOL_NAME } from '../tools/GoalUpdateTool/constants.js' +import { GoalUpdateTool } from '../tools/GoalUpdateTool/GoalUpdateTool.js' +import type { ToolUseContext } from '../Tool.js' +import { acquireEnvMutex, releaseEnvMutex } from '../entrypoints/sdk/shared.js' +import { asSessionId } from '../types/ids.js' +import { createUserMessage } from './messages.js' +import { getAttachments } from './attachments.js' +import { renderToString } from './staticRender.js' +import { + clearSessionGoal, + createSessionGoal, + formatGoalReminder, + getSessionGoal, + getSessionGoalSnapshot, + recordGoalBlocker, + startSessionGoal, + setSessionGoalStatus, + subscribeToSessionGoal, + summarizeGoalObjective, +} from './goals.js' +import { getClaudeConfigHomeDir, setClaudeConfigHomeDirForTesting } from './envUtils.js' +import type { LocalJSXCommandOnDone } from '../types/command.js' + +const originalConfigDir = process.env.VERBOO_CONFIG_DIR +const originalSimpleMode = process.env.CLAUDE_CODE_SIMPLE +const originalDisableAttachments = process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS +const originalSessionId = getSessionId() + +let configDir = '' +let mutexHeld = false + +beforeEach(async () => { + const mutex = await acquireEnvMutex() + expect(mutex.acquired).toBe(true) + mutexHeld = true + + configDir = mkdtempSync(join(tmpdir(), 'verboo-goal-tests-')) + process.env.VERBOO_CONFIG_DIR = configDir + setClaudeConfigHomeDirForTesting(configDir) + process.env.CLAUDE_CODE_SIMPLE = '1' + delete process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS + switchSession(asSessionId(randomUUID())) +}) + +afterEach(() => { + try { + switchSession(originalSessionId) + if (originalConfigDir === undefined) delete process.env.VERBOO_CONFIG_DIR + else process.env.VERBOO_CONFIG_DIR = originalConfigDir + if (originalSimpleMode === undefined) delete process.env.CLAUDE_CODE_SIMPLE + else process.env.CLAUDE_CODE_SIMPLE = originalSimpleMode + if (originalDisableAttachments === undefined) { + delete process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS + } else { + process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS = originalDisableAttachments + } + setClaudeConfigHomeDirForTesting(undefined) + if (configDir) rmSync(configDir, { recursive: true, force: true }) + } finally { + if (mutexHeld) { + releaseEnvMutex() + mutexHeld = false + } + } +}) + +function runCommand(args: string): Promise<{ + value?: string + options?: Parameters[1] +}> { + return new Promise((resolve, reject) => { + let completed = false + const onDone: LocalJSXCommandOnDone = (value, options) => { + completed = true + resolve({ value, options }) + } + void runGoalCommand(onDone, {} as never, args) + .then(() => { + if (!completed) reject(new Error('Goal command did not call onDone')) + }) + .catch(reject) + }) +} + +function userMessages(count: number) { + return Array.from({ length: count }, (_, index) => + createUserMessage({ content: `user turn ${index + 1}` }), + ) +} + +async function callGoalUpdate( + status: 'complete' | 'paused' | 'blocked', + turns: number, + reason?: string, + agentId?: string, +) { + const context = { + agentId, + messages: userMessages(turns), + } as unknown as ToolUseContext + return GoalUpdateTool.call( + { status, reason }, + context, + ) +} + +describe('/goal persistence and lifecycle', () => { + it('persists one goal per session and restores it when that session is resumed', () => { + const sessionId = getSessionId() + const goal = createSessionGoal('Ship the new onboarding flow') + const goalFile = join(getClaudeConfigHomeDir(), 'goals', `${sessionId}.json`) + + expect(readFileSync(goalFile, 'utf8')).toContain('Ship the new onboarding flow') + expect(getSessionGoal()).toEqual(goal) + + const otherSessionId = asSessionId(randomUUID()) + switchSession(otherSessionId) + expect(getSessionGoal()).toBeNull() + + switchSession(sessionId) + expect(getSessionGoal()).toEqual(goal) + }) + + it('isolates session files and rejects unsafe session IDs', () => { + createSessionGoal('Keep this goal private to the session') + expect(getSessionGoal('../outside')).toBeNull() + expect(getSessionGoal('../../outside')).toBeNull() + + const otherSessionId = asSessionId(randomUUID()) + switchSession(otherSessionId) + expect(getSessionGoal()).toBeNull() + }) + + it('rejects a second unfinished goal, then allows a new goal after completion', () => { + createSessionGoal('Finish the first objective') + expect(() => createSessionGoal('Start another objective')).toThrow( + 'already has an unfinished goal', + ) + + expect(setSessionGoalStatus('complete')?.status).toBe('complete') + expect(createSessionGoal('Start another objective').objective).toBe( + 'Start another objective', + ) + }) + + it('replaces, retries, and clears goals without leaving stale session state', () => { + const first = startSessionGoal('Explain the command registry') + const retry = startSessionGoal('Explain the command registry') + expect(retry).toMatchObject({ + objective: first.objective, + status: 'active', + createdAt: first.createdAt, + }) + expect(retry.blocker).toBeUndefined() + + recordGoalBlocker('Missing docs', 1) + const replacement = startSessionGoal('Document slash commands') + expect(replacement).toMatchObject({ + objective: 'Document slash commands', + status: 'active', + }) + expect(replacement.blocker).toBeUndefined() + + const goalFile = join( + getClaudeConfigHomeDir(), + 'goals', + `${getSessionId()}.json`, + ) + expect(clearSessionGoal()).toEqual(replacement) + expect(getSessionGoal()).toBeNull() + expect(existsSync(goalFile)).toBe(false) + expect(clearSessionGoal()).toBeNull() + }) + + it('publishes current-session goal changes to live UI subscribers', () => { + const notifications: Array = [] + const unsubscribe = subscribeToSessionGoal(() => { + notifications.push(getSessionGoalSnapshot()?.status ?? null) + }) + try { + expect(getSessionGoalSnapshot()).toBeNull() + createSessionGoal('Update the readme') + setSessionGoalStatus('paused') + setSessionGoalStatus('active') + clearSessionGoal() + expect(notifications).toEqual(['active', 'paused', 'active', null]) + } finally { + unsubscribe() + } + }) + + it('summarizes long goals and strips terminal control characters', () => { + const longObjective = + 'Explain the command registry in detail and show where slash commands are registered' + expect(summarizeGoalObjective(longObjective, 24)).toBe( + 'Explain the command…', + ) + expect(summarizeGoalObjective(longObjective).length).toBeLessThanOrEqual(54) + expect(summarizeGoalObjective(longObjective).endsWith('…')).toBe(true) + expect(summarizeGoalObjective('Review the CLI\n\u001b[31m safely')).toBe( + 'Review the CLI [31m safely', + ) + expect(summarizeGoalObjective('A long goal', 0)).toBe('') + }) + + it('records blocker candidates once per turn and blocks only after three consecutive matching turns', () => { + createSessionGoal('Complete the migration') + + expect(recordGoalBlocker('Database is unavailable', 1)).toMatchObject({ + consecutiveTurns: 1, + blocked: false, + }) + expect(recordGoalBlocker('database is unavailable!', 1)).toMatchObject({ + consecutiveTurns: 1, + blocked: false, + }) + expect(recordGoalBlocker('DATABASE is unavailable', 2)).toMatchObject({ + consecutiveTurns: 2, + blocked: false, + }) + expect(recordGoalBlocker('database is unavailable', 3)).toMatchObject({ + consecutiveTurns: 3, + blocked: true, + }) + expect(getSessionGoal()?.status).toBe('blocked') + }) + + it('resets blocker streaks when the reason changes or a turn is skipped', () => { + createSessionGoal('Complete the migration') + + expect(recordGoalBlocker('Missing credentials', 1).consecutiveTurns).toBe(1) + expect(recordGoalBlocker('Service unavailable', 2).consecutiveTurns).toBe(1) + expect(recordGoalBlocker('Service unavailable', 4).consecutiveTurns).toBe(1) + expect(getSessionGoal()?.status).toBe('active') + }) + + it('clears blocker history when resuming and keeps completed goals terminal', () => { + createSessionGoal('Complete the migration') + recordGoalBlocker('Missing credentials', 1) + expect(setSessionGoalStatus('paused')?.status).toBe('paused') + expect(setSessionGoalStatus('active')).toMatchObject({ + status: 'active', + blocker: undefined, + }) + expect(setSessionGoalStatus('complete')?.status).toBe('complete') + expect(setSessionGoalStatus('active')).toBeNull() + }) + + it('returns null for missing and malformed goal files', () => { + expect(getSessionGoal()).toBeNull() + const goalFile = join( + getClaudeConfigHomeDir(), + 'goals', + `${getSessionId()}.json`, + ) + const directory = join(getClaudeConfigHomeDir(), 'goals') + mkdirSync(directory, { recursive: true }) + writeFileSync(goalFile, '{not valid json') + expect(getSessionGoal()).toBeNull() + }) + + it('formats active, partial-blocker, and blocked reminders without allowing markup injection', () => { + const goal = createSessionGoal('keep working') + const activeReminder = formatGoalReminder(goal) + expect(activeReminder).toContain('Persistent Goal') + expect(activeReminder).not.toContain('') + + const partial = recordGoalBlocker('', 1).goal! + expect(formatGoalReminder(partial)).toContain('1 of 3 consecutive user turns') + expect(formatGoalReminder(partial)).not.toContain('') + + recordGoalBlocker('', 2) + const blocked = recordGoalBlocker('', 3).goal! + expect(formatGoalReminder(blocked)).toContain('Persistent Goal (blocked)') + expect(formatGoalReminder(blocked)).not.toContain('') + }) +}) + +describe('/goal command', () => { + it('registers as a CLI command with discoverable usage', async () => { + const { default: command } = await import('../commands/goal/index.js') + expect(command.name).toBe('goal') + expect(command.argumentHint).toContain('objective') + }) + + it('starts, shows, pauses, resumes, and completes a goal through the command handler', async () => { + const started = await runCommand('Build a CLI dashboard') + expect(started.value).toContain('Goal set:') + expect(started.options).toMatchObject({ display: 'system', shouldQuery: true }) + expect(started.options?.metaMessages?.[0]).toContain('Build a CLI dashboard') + expect(getSessionGoal()?.status).toBe('active') + + const status = await runCommand('status') + expect(status.value).toContain('Current goal (active)') + expect(status.value).toContain('Build a CLI dashboard') + expect(status.options?.shouldQuery).toBeUndefined() + + const paused = await runCommand('pause') + expect(paused.value).toContain('Goal paused') + expect(getSessionGoal()?.status).toBe('paused') + + const resumed = await runCommand('resume') + expect(resumed.value).toContain('Goal resumed') + expect(resumed.options?.shouldQuery).toBe(true) + expect(getSessionGoal()?.status).toBe('active') + + const completed = await runCommand('complete') + expect(completed.value).toContain('Goal marked complete') + expect(getSessionGoal()?.status).toBe('complete') + }) + + it('shows usage when empty, replaces an unfinished goal, and retries the active goal', async () => { + const empty = await runCommand('') + expect(empty.value).toContain('No goal is set') + expect(empty.value).toContain('/goal ') + + createSessionGoal('Existing objective') + const replacement = await runCommand('A different objective') + expect(replacement.value).toContain('Goal replaced:') + expect(replacement.options?.shouldQuery).toBe(true) + expect(getSessionGoal()?.objective).toBe('A different objective') + + const retry = await runCommand('A different objective') + expect(retry.value).toContain('Goal already active; continuing:') + expect(retry.options?.shouldQuery).toBe(true) + expect(getSessionGoal()?.objective).toBe('A different objective') + }) + + it('accepts goal text beginning with a control word and supports /goal clear aliases', async () => { + const objective = await runCommand('Complete all onboarding tests') + expect(objective.value).toContain('Goal set:') + expect(objective.options?.shouldQuery).toBe(true) + expect(getSessionGoal()?.objective).toBe('Complete all onboarding tests') + + const cleared = await runCommand('clear') + expect(cleared.value).toContain('Goal cleared:') + expect(cleared.options?.shouldQuery).toBeUndefined() + expect(getSessionGoal()).toBeNull() + + createSessionGoal('A goal to cancel') + expect((await runCommand('cancel')).value).toContain('Goal cleared:') + expect(getSessionGoal()).toBeNull() + }) + + it('handles help, missing goals, and blocked goal resumption', async () => { + const help = await runCommand('--help') + expect(help.value).toContain('/goal resume') + expect(help.value).toContain('/goal clear') + + expect((await runCommand('pause')).value).toContain('No goal is set') + expect((await runCommand('resume')).value).toContain('No goal is set') + expect((await runCommand('complete')).value).toContain('No goal is set') + + const started = await runCommand('start Restore a paused workflow') + expect(started.value).toContain('Goal set:') + expect(getSessionGoal()?.objective).toBe('Restore a paused workflow') + + recordGoalBlocker('Required API is offline', 1) + recordGoalBlocker('Required API is offline', 2) + recordGoalBlocker('Required API is offline', 3) + expect(getSessionGoal()?.status).toBe('blocked') + + const resumed = await runCommand('resume') + expect(resumed.value).toContain('Goal resumed:') + expect(resumed.options?.shouldQuery).toBe(true) + expect(getSessionGoal()?.status).toBe('active') + expect(getSessionGoal()?.blocker).toBeUndefined() + + await runCommand('complete') + expect((await runCommand('resume')).value).toContain('already complete') + }) +}) + +describe('GoalUpdate tool and goal prompt attachment', () => { + it('shows the active objective in the prompt footer and reflects lifecycle changes', async () => { + const renderIndicator = () => + renderToString(createElement(GoalIndicator), 80) + + expect((await renderIndicator()).trim()).toBe('') + createSessionGoal('Explain where slash commands are registered') + expect(await renderIndicator()).toContain( + 'Goal active: Explain where slash commands are registered', + ) + + setSessionGoalStatus('paused') + expect(await renderIndicator()).toContain('Goal paused:') + setSessionGoalStatus('active') + recordGoalBlocker('External service is unavailable', 1) + recordGoalBlocker('External service is unavailable', 2) + recordGoalBlocker('External service is unavailable', 3) + expect(await renderIndicator()).toContain('Goal blocked:') + setSessionGoalStatus('complete') + expect((await renderIndicator()).trim()).toBe('') + }) + + it('is registered under the expected internal tool name', () => { + expect(GoalUpdateTool.name).toBe(GOAL_UPDATE_TOOL_NAME) + expect(GoalUpdateTool.name).toBe('GoalUpdate') + }) + + it('is exposed in the real command/tool registries and denied to agents', async () => { + const [{ getCommands }, { getAllBaseTools }, { ALL_AGENT_DISALLOWED_TOOLS }] = + await Promise.all([ + import('../commands.js'), + import('../tools.js'), + import('../constants/tools.js'), + ]) + const commands = await getCommands(process.cwd()) + expect(commands.some(command => command.name === 'goal')).toBe(true) + expect( + getAllBaseTools().some(tool => tool.name === GOAL_UPDATE_TOOL_NAME), + ).toBe(true) + expect(ALL_AGENT_DISALLOWED_TOOLS.has(GOAL_UPDATE_TOOL_NAME)).toBe(true) + }) + + it('rejects updates without a goal and prevents subagents from changing it', async () => { + const missing = await callGoalUpdate('complete', 1) + expect(missing.data).toMatchObject({ + success: false, + message: 'No goal is set for this session.', + }) + + createSessionGoal('Main session objective') + const subagent = await callGoalUpdate( + 'complete', + 1, + undefined, + 'a0123456789abcdef', + ) + expect(subagent.data).toMatchObject({ success: false }) + expect(getSessionGoal()?.status).toBe('active') + }) + + it('requires a reason and waits for three matching turns before marking blocked', async () => { + createSessionGoal('Complete the migration') + + const emptyReason = await callGoalUpdate('blocked', 1, ' ') + expect(emptyReason.data).toMatchObject({ + success: false, + message: 'A concise reason is required when marking a goal blocked.', + }) + + const firstTurn = await callGoalUpdate('blocked', 1, 'Database is down') + expect(firstTurn.data).toMatchObject({ success: false, consecutiveTurns: 1 }) + const duplicate = await callGoalUpdate('blocked', 1, 'database is down!') + expect(duplicate.data).toMatchObject({ success: false, consecutiveTurns: 1 }) + const secondTurn = await callGoalUpdate('blocked', 2, 'Database is down') + expect(secondTurn.data).toMatchObject({ success: false, consecutiveTurns: 2 }) + const thirdTurn = await callGoalUpdate('blocked', 3, 'Database is down') + expect(thirdTurn.data).toMatchObject({ + success: true, + status: 'blocked', + consecutiveTurns: 3, + }) + expect(getSessionGoal()?.status).toBe('blocked') + }) + + it('allows the agent to complete a goal only from active state', async () => { + createSessionGoal('Finish the feature') + const completed = await callGoalUpdate('complete', 1) + expect(completed.data).toMatchObject({ success: true, status: 'complete' }) + + const repeated = await callGoalUpdate('complete', 2) + expect(repeated.data).toMatchObject({ success: false, status: 'complete' }) + }) + + it('injects active and blocked goals into the model context but omits paused/completed goals and subagents', async () => { + const context = { agentId: undefined } as unknown as ToolUseContext + + createSessionGoal('Implement the settings screen') + let attachments = await getAttachments(null, context, null, [], []) + expect(attachments).toContainEqual( + expect.objectContaining({ + type: 'critical_system_reminder', + content: expect.stringContaining('Implement the settings screen'), + }), + ) + + const subagentContext = { + agentId: 'a0123456789abcdef', + } as unknown as ToolUseContext + attachments = await getAttachments(null, subagentContext, null, [], []) + expect(attachments).toEqual([]) + + recordGoalBlocker('External API unavailable', 1) + recordGoalBlocker('External API unavailable', 2) + recordGoalBlocker('External API unavailable', 3) + attachments = await getAttachments(null, context, null, [], []) + expect(attachments[0]).toMatchObject({ + type: 'critical_system_reminder', + content: expect.stringContaining('Persistent Goal (blocked)'), + }) + + setSessionGoalStatus('paused') + expect(await getAttachments(null, context, null, [], [])).toEqual([]) + setSessionGoalStatus('complete') + expect(await getAttachments(null, context, null, [], [])).toEqual([]) + }) +}) diff --git a/src/utils/goals.ts b/src/utils/goals.ts new file mode 100644 index 0000000000..90f9e7f172 --- /dev/null +++ b/src/utils/goals.ts @@ -0,0 +1,294 @@ +import { join } from 'path' +import { getSessionId } from '../bootstrap/state.js' +import { getClaudeConfigHomeDir } from './envUtils.js' +import { isENOENT } from './errors.js' +import { getFsImplementation } from './fsOperations.js' +import { writeFileSyncAndFlush_DEPRECATED } from './file.js' + +export type GoalStatus = 'active' | 'paused' | 'blocked' | 'complete' + +export type SessionGoal = { + objective: string + status: GoalStatus + createdAt: string + updatedAt: string + blocker?: { + reason: string + key: string + consecutiveTurns: number + lastUserTurn: number + } +} + +const goalListeners = new Set<() => void>() +let goalSnapshotSessionId: string | null = null +let goalSnapshot: SessionGoal | null = null +let goalSnapshotInitialized = false + +const SESSION_ID_PATTERN = /^[a-zA-Z0-9_-]{1,128}$/ + +function getGoalFilePath(sessionId: string = getSessionId()): string { + if (!SESSION_ID_PATTERN.test(sessionId)) { + throw new Error('Invalid session ID for goal storage') + } + return join(getClaudeConfigHomeDir(), 'goals', `${sessionId}.json`) +} + +function isGoalStatus(value: unknown): value is GoalStatus { + return ( + value === 'active' || + value === 'paused' || + value === 'blocked' || + value === 'complete' + ) +} + +function parseGoal(value: unknown): SessionGoal | null { + if (typeof value !== 'object' || value === null) return null + const candidate = value as Partial + if ( + typeof candidate.objective !== 'string' || + !candidate.objective.trim() || + !isGoalStatus(candidate.status) || + typeof candidate.createdAt !== 'string' || + typeof candidate.updatedAt !== 'string' + ) { + return null + } + + const blocker = candidate.blocker + if ( + blocker !== undefined && + (typeof blocker !== 'object' || + blocker === null || + typeof blocker.reason !== 'string' || + typeof blocker.key !== 'string' || + typeof blocker.consecutiveTurns !== 'number' || + typeof blocker.lastUserTurn !== 'number') + ) { + return null + } + + return candidate as SessionGoal +} + +export function getSessionGoal( + sessionId: string = getSessionId(), +): SessionGoal | null { + const fs = getFsImplementation() + try { + const raw = fs.readFileSync(getGoalFilePath(sessionId), { + encoding: 'utf8', + }) + return parseGoal(JSON.parse(raw)) + } catch (error) { + if (!isENOENT(error)) { + // A damaged or unreadable goal should not prevent the user from working. + return null + } + return null + } +} + +export function subscribeToSessionGoal(listener: () => void): () => void { + goalListeners.add(listener) + return () => goalListeners.delete(listener) +} + +/** Stable snapshot for UI subscribers; updated whenever the current goal changes. */ +export function getSessionGoalSnapshot(): SessionGoal | null { + const sessionId = getSessionId() + if (!goalSnapshotInitialized || goalSnapshotSessionId !== sessionId) { + goalSnapshot = getSessionGoal(sessionId) + goalSnapshotSessionId = sessionId + goalSnapshotInitialized = true + } + return goalSnapshot +} + +function publishSessionGoalChange( + sessionId: string, + goal: SessionGoal | null, +): void { + if (sessionId === getSessionId()) { + goalSnapshot = goal + goalSnapshotSessionId = sessionId + goalSnapshotInitialized = true + for (const listener of goalListeners) listener() + } +} + +function saveGoal(goal: SessionGoal, sessionId: string = getSessionId()): void { + const fs = getFsImplementation() + const directory = join(getClaudeConfigHomeDir(), 'goals') + fs.mkdirSync(directory, { mode: 0o700 }) + writeFileSyncAndFlush_DEPRECATED( + getGoalFilePath(sessionId), + `${JSON.stringify(goal, null, 2)}\n`, + { encoding: 'utf8', mode: 0o600 }, + ) + publishSessionGoalChange(sessionId, goal) +} + +export function createSessionGoal(objective: string): SessionGoal { + const normalizedObjective = objective.trim() + if (!normalizedObjective) throw new Error('The goal cannot be empty') + + const existing = getSessionGoal() + if (existing && existing.status !== 'complete') { + throw new Error( + `This session already has an unfinished goal (${existing.status}). Mark it complete with /goal complete before setting another one.`, + ) + } + + const now = new Date().toISOString() + const goal: SessionGoal = { + objective: normalizedObjective, + status: 'active', + createdAt: now, + updatedAt: now, + } + saveGoal(goal) + return goal +} + +/** Start or replace the goal from the interactive /goal command. */ +export function startSessionGoal(objective: string): SessionGoal { + const normalizedObjective = objective.trim() + if (!normalizedObjective) throw new Error('The goal cannot be empty') + + const current = getSessionGoal() + const now = new Date().toISOString() + const goal: SessionGoal = { + objective: normalizedObjective, + status: 'active', + createdAt: + current?.objective === normalizedObjective && current.status !== 'complete' + ? current.createdAt + : now, + updatedAt: now, + } + saveGoal(goal) + return goal +} + +export function clearSessionGoal( + sessionId: string = getSessionId(), +): SessionGoal | null { + const path = getGoalFilePath(sessionId) + const goal = getSessionGoal(sessionId) + try { + getFsImplementation().unlinkSync(path) + } catch (error) { + if (!isENOENT(error)) throw error + } + publishSessionGoalChange(sessionId, null) + return goal +} + +export function summarizeGoalObjective( + objective: string, + maxChars = 54, +): string { + if (!Number.isInteger(maxChars) || maxChars < 1) return '' + const normalized = objective + .replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ') + .replace(/\s+/g, ' ') + .trim() + if (normalized.length <= maxChars) return normalized + + const prefix = normalized.slice(0, maxChars - 1) + const lastSpace = prefix.lastIndexOf(' ') + const summary = lastSpace >= Math.floor(maxChars * 0.6) + ? prefix.slice(0, lastSpace) + : prefix + return `${summary.trimEnd()}…` +} + +export function setSessionGoalStatus( + status: 'active' | 'paused' | 'complete', +): SessionGoal | null { + const current = getSessionGoal() + if (!current || (current.status === 'complete' && status !== 'complete')) { + return null + } + const goal: SessionGoal = { + ...current, + status, + updatedAt: new Date().toISOString(), + blocker: undefined, + } + saveGoal(goal) + return goal +} + +function normalizeBlocker(reason: string): string { + return reason + .trim() + .toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, ' ') + .trim() +} + +export function recordGoalBlocker( + reason: string, + userTurn: number, +): { goal: SessionGoal | null; consecutiveTurns: number; blocked: boolean } { + const current = getSessionGoal() + if (!current || current.status !== 'active') { + return { goal: current, consecutiveTurns: 0, blocked: false } + } + + const normalizedReason = reason.trim() + const key = normalizeBlocker(normalizedReason) + if (!key) { + return { goal: current, consecutiveTurns: 0, blocked: false } + } + + const previous = current.blocker + const isSameTurn = previous?.key === key && previous.lastUserTurn === userTurn + const isNextTurn = previous?.key === key && previous.lastUserTurn === userTurn - 1 + const consecutiveTurns = isSameTurn + ? previous.consecutiveTurns + : isNextTurn + ? previous.consecutiveTurns + 1 + : 1 + const blocked = consecutiveTurns >= 3 + const goal: SessionGoal = { + ...current, + status: blocked ? 'blocked' : 'active', + updatedAt: new Date().toISOString(), + blocker: { + reason: normalizedReason, + key, + consecutiveTurns, + lastUserTurn: userTurn, + }, + } + saveGoal(goal) + return { goal, consecutiveTurns, blocked } +} + +function quoteForReminder(value: string): string { + return JSON.stringify(value).replace(/[<>&]/g, char => { + switch (char) { + case '<': + return '\\u003c' + case '>': + return '\\u003e' + default: + return '\\u0026' + } + }) +} + +export function formatGoalReminder(goal: SessionGoal): string { + const objective = quoteForReminder(goal.objective) + if (goal.status === 'blocked') { + return `## Persistent Goal (blocked)\n\nThe user set this goal for the current session: ${objective}\n\nThe same blocker was encountered on three consecutive turns: ${quoteForReminder(goal.blocker?.reason ?? '')}. Stop retrying actions that depend on it. Explain the blocker and ask the user for the change or information needed to continue. Resume work only after the user runs /goal resume or changes the goal.` + } + const blockerProgress = goal.blocker + ? `\n\nA possible blocker has been recorded on ${goal.blocker.consecutiveTurns} of 3 consecutive user turns: ${quoteForReminder(goal.blocker.reason)}. Keep trying useful approaches. If this same blocker prevents meaningful progress for three consecutive turns, GoalUpdate can mark the goal blocked.` + : '' + return `## Persistent Goal\n\nThe user set this goal for the current session: ${objective}\n\nKeep working toward this objective across turns until it is complete or the user pauses it. Use GoalUpdate to mark it complete only after the objective has actually been achieved. Do not mark it paused unless the user explicitly asks to pause. Mark it blocked only after the same blocker recurs on three consecutive user turns and no useful progress is possible.${blockerProgress}` +}