feat: /goal命令能力支持,参考codex实现 (#1261)

* feat: /goal命令能力支持,参考codex实现

* fix: 修复promp和提示词不一致的问题

* fix: 修复 goal 功能多项 AI 审查问题

- prompt 中 update 行为描述与运行时不一致(no-op → error)
- src/commands/goal/ 使用相对路径导入,改为 src/* 别名
- /goal 命令标记 bridgeSafe 但含交互式对话框,改为 false
- useGoalContinuation 中 origin 使用 as unknown as string 强转,改为直接传字符串
- ResumeConversation 路径缺少 goal hydration,补齐恢复逻辑
- onCancel 在非查询状态下误暂停 goal,加 queryGuard 守卫
- resumeGoal 允许从终态恢复,收紧为仅允许 paused 状态
- buildGoalContextBlock 生成畸形 XML 属性,改为合法 budget 属性

* fix: 修复剩余AI审查的问题

* fix: 防止goal状态丢失

* fix: 修复Biome规范错误问题

* fix: 修复部分情况下goal无法启动的问题

* fix: 增加断网后状态默认设置为PAUSE机制、完成暂停-恢复状态切换,且正常进行前端渲染。设置达到max turn后处理逻辑。

* fix: 修复终端异常断开情况,resume续跑;修复用户消息排队信息被goal输出信息覆盖的问题。

* fix: apply biome formatting to pass CI lint check

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: skip slash command echo in setUserInputOnProcessing to prevent UI flash

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: moyu <moyu@kingsoft.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
moy16
2026-06-14 10:44:10 +08:00
committed by GitHub
parent 5bfe6fa590
commit 3e3e1de81b
28 changed files with 2248 additions and 30 deletions

View File

@@ -0,0 +1,272 @@
/**
* Unit tests for the per-session goal state machine.
*
* Pure-function tests: no FS, no network. The bootstrap/state.ts side
* effect chain pulls in log.ts so we mock that to keep the suite fast
* and side-effect free.
*/
import { beforeEach, describe, expect, mock, test } from 'bun:test'
import { logMock } from '../../../../tests/mocks/log.js'
mock.module('src/utils/log.ts', logMock)
import {
_clearAllGoalsForTesting,
BLOCKED_CONSECUTIVE_THRESHOLD,
continueGoalFromMaxTurns,
clearGoal,
completeGoal,
formatGoalElapsed,
formatGoalStatusLabel,
getActiveElapsedMs,
getGoal,
incrementGoalTurns,
markUsageLimited,
markGoalMaxTurnsReached,
MAX_GOAL_TURNS,
pauseGoal,
recordBlockedAttempt,
resumeGoal,
setGoal,
updateGoalTokens,
} from '../goalState.js'
const SESSION = 'test-session-id'
beforeEach(() => {
_clearAllGoalsForTesting()
})
describe('setGoal — creates an active goal with sane defaults', () => {
test('initial state has status active, zero tokens, no budget by default', () => {
const g = setGoal('improve test coverage', { sessionId: SESSION })
expect(g.status).toBe('active')
expect(g.objective).toBe('improve test coverage')
expect(g.tokensUsed).toBe(0)
expect(g.tokenBudget).toBeNull()
expect(g.blockedAttempts).toBe(0)
expect(g.turnsExecuted).toBe(0)
})
test('accepts a positive integer token budget', () => {
const g = setGoal('x', { tokenBudget: 5000, sessionId: SESSION })
expect(g.tokenBudget).toBe(5000)
})
test('rejects non-finite or negative budgets as null', () => {
expect(
setGoal('a', { tokenBudget: Number.NaN, sessionId: SESSION }).tokenBudget,
).toBeNull()
expect(
setGoal('a', { tokenBudget: -1, sessionId: SESSION }).tokenBudget,
).toBeNull()
expect(
setGoal('a', { tokenBudget: Infinity, sessionId: SESSION }).tokenBudget,
).toBeNull()
})
test('setGoal replaces an existing goal entirely', () => {
setGoal('first', { tokenBudget: 100, sessionId: SESSION })
updateGoalTokens(50, SESSION)
const g = setGoal('second', { sessionId: SESSION })
expect(g.objective).toBe('second')
expect(g.tokensUsed).toBe(0)
expect(g.tokenBudget).toBeNull()
})
})
describe('pause / resume — preserves active elapsed time', () => {
test('pause then resume keeps accumulated active time', async () => {
setGoal('x', { sessionId: SESSION })
await Bun.sleep(10)
const paused = pauseGoal(SESSION)
expect(paused?.status).toBe('paused')
expect(paused?.accumulatedActiveMs).toBeGreaterThanOrEqual(10)
const before = paused?.accumulatedActiveMs ?? 0
await Bun.sleep(20)
const resumed = resumeGoal(SESSION)
expect(resumed?.status).toBe('active')
expect(resumed?.accumulatedActiveMs).toBe(before)
})
test('pause is a no-op on a non-active goal', () => {
setGoal('x', { sessionId: SESSION })
pauseGoal(SESSION)
const second = pauseGoal(SESSION)
expect(second).toBeNull()
})
test('resume is a no-op on an active goal', () => {
setGoal('x', { sessionId: SESSION })
expect(resumeGoal(SESSION)).toBeNull()
})
test('getActiveElapsedMs while active includes ongoing interval', async () => {
setGoal('x', { sessionId: SESSION })
await Bun.sleep(10)
const g = getGoal(SESSION)!
expect(getActiveElapsedMs(g)).toBeGreaterThanOrEqual(10)
})
test('getActiveElapsedMs while paused freezes at accumulated total', async () => {
setGoal('x', { sessionId: SESSION })
await Bun.sleep(10)
pauseGoal(SESSION)
const g = getGoal(SESSION)!
const a = getActiveElapsedMs(g)
await Bun.sleep(20)
const b = getActiveElapsedMs(g)
expect(b).toBe(a)
})
})
describe('updateGoalTokens — accumulates and triggers budget_limited', () => {
test('accumulates positive deltas', () => {
setGoal('x', { tokenBudget: 1000, sessionId: SESSION })
updateGoalTokens(100, SESSION)
updateGoalTokens(200, SESSION)
expect(getGoal(SESSION)?.tokensUsed).toBe(300)
})
test('crossing budget transitions to budget_limited', () => {
setGoal('x', { tokenBudget: 100, sessionId: SESSION })
updateGoalTokens(150, SESSION)
expect(getGoal(SESSION)?.status).toBe('budget_limited')
})
test('further updates after budget_limited are no-ops (status-guarded)', () => {
setGoal('x', { tokenBudget: 100, sessionId: SESSION })
updateGoalTokens(150, SESSION)
updateGoalTokens(50, SESSION) // should not accumulate
expect(getGoal(SESSION)?.tokensUsed).toBe(150)
})
test('coerces non-finite or negative deltas to zero', () => {
setGoal('x', { tokenBudget: 1000, sessionId: SESSION })
updateGoalTokens(Number.NaN, SESSION)
updateGoalTokens(-100, SESSION)
updateGoalTokens(Infinity, SESSION)
expect(getGoal(SESSION)?.tokensUsed).toBe(0)
})
test('no-op when there is no goal', () => {
expect(updateGoalTokens(100, SESSION)).toBeNull()
})
})
describe('recordBlockedAttempt — CODEX 3-consecutive-attempts audit', () => {
test('first attempt records but stays active', () => {
setGoal('x', { sessionId: SESSION })
const r = recordBlockedAttempt('compile error', SESSION)
expect(r?.status).toBe('active')
expect(r?.attempts).toBe(1)
})
test('three same-reason attempts in a row flip to blocked', () => {
setGoal('x', { sessionId: SESSION })
recordBlockedAttempt('compile error', SESSION)
recordBlockedAttempt('compile error', SESSION)
const r = recordBlockedAttempt('compile error', SESSION)
expect(r?.status).toBe('blocked')
expect(r?.attempts).toBe(BLOCKED_CONSECUTIVE_THRESHOLD)
})
test('different reason resets counter', () => {
setGoal('x', { sessionId: SESSION })
recordBlockedAttempt('A', SESSION)
recordBlockedAttempt('A', SESSION)
const r = recordBlockedAttempt('B', SESSION)
expect(r?.status).toBe('active')
expect(r?.attempts).toBe(1)
})
test('case-insensitive comparison', () => {
setGoal('x', { sessionId: SESSION })
recordBlockedAttempt('compile error', SESSION)
recordBlockedAttempt('Compile Error', SESSION)
const r = recordBlockedAttempt('COMPILE ERROR', SESSION)
expect(r?.status).toBe('blocked')
})
test('resume resets blocked attempts', () => {
setGoal('x', { sessionId: SESSION })
recordBlockedAttempt('oops', SESSION)
recordBlockedAttempt('oops', SESSION)
pauseGoal(SESSION)
resumeGoal(SESSION)
expect(getGoal(SESSION)!.blockedAttempts).toBe(0)
})
})
describe('completeGoal / clearGoal / markUsageLimited', () => {
test('completeGoal transitions to complete', () => {
setGoal('x', { sessionId: SESSION })
const g = completeGoal(SESSION)
expect(g?.status).toBe('complete')
})
test('clearGoal removes entirely', () => {
setGoal('x', { sessionId: SESSION })
expect(clearGoal(SESSION)).toBe(true)
expect(getGoal(SESSION)).toBeNull()
})
test('markUsageLimited transitions active → usage_limited', () => {
setGoal('x', { sessionId: SESSION })
markUsageLimited(SESSION)
expect(getGoal(SESSION)?.status).toBe('usage_limited')
})
})
describe('incrementGoalTurns', () => {
test('counts correctly', () => {
setGoal('x', { sessionId: SESSION })
expect(incrementGoalTurns(SESSION)).toBe(1)
expect(incrementGoalTurns(SESSION)).toBe(2)
expect(getGoal(SESSION)?.turnsExecuted).toBe(2)
})
test('returns 0 when no goal', () => {
expect(incrementGoalTurns(SESSION)).toBe(0)
})
})
describe('max_turns lifecycle', () => {
test('markGoalMaxTurnsReached flips active goal once cap is reached', () => {
setGoal('x', { sessionId: SESSION })
const goal = getGoal(SESSION)!
goal.turnsExecuted = MAX_GOAL_TURNS
const marked = markGoalMaxTurnsReached(SESSION)
expect(marked?.status).toBe('max_turns')
})
test('continueGoalFromMaxTurns resets turns and re-activates goal', () => {
setGoal('x', { sessionId: SESSION })
const goal = getGoal(SESSION)!
goal.turnsExecuted = MAX_GOAL_TURNS
markGoalMaxTurnsReached(SESSION)
const resumed = continueGoalFromMaxTurns(SESSION)
expect(resumed?.status).toBe('active')
expect(resumed?.turnsExecuted).toBe(0)
})
})
describe('formatGoalStatusLabel', () => {
test('returns human-readable labels', () => {
expect(formatGoalStatusLabel('active')).toBe('Active')
expect(formatGoalStatusLabel('paused')).toBe('Paused')
expect(formatGoalStatusLabel('blocked')).toBe('Blocked')
expect(formatGoalStatusLabel('budget_limited')).toBe('Budget Limited')
expect(formatGoalStatusLabel('usage_limited')).toBe('Usage Limited')
expect(formatGoalStatusLabel('max_turns')).toBe('Max Turns Reached')
expect(formatGoalStatusLabel('complete')).toBe('Complete')
})
})
describe('formatGoalElapsed', () => {
test('returns "0s" for brand-new goals', () => {
const g = setGoal('x', { sessionId: SESSION })
expect(formatGoalElapsed(g)).toBe('0s')
})
})

View File

@@ -0,0 +1,33 @@
/**
* Audit rules constants for goal completion and blocked assessment.
* Shared by prompt templates and integration tests.
*/
import { BLOCKED_CONSECUTIVE_THRESHOLD, MAX_GOAL_TURNS } from './goalState.js'
import type { GoalStatus } from '../../types/logs.js'
export { BLOCKED_CONSECUTIVE_THRESHOLD, MAX_GOAL_TURNS }
export const COMPLETION_AUDIT_RULES = [
'Derive concrete requirements from the objective and any referenced files.',
'Preserve the original scope — do not redefine success around what is already done.',
'For every explicit requirement, identify authoritative evidence (test output, file content, command result).',
'Treat tests, manifests, and verifiers as evidence only after confirming they actually cover the requirement.',
'Treat uncertain or indirect evidence as "not achieved".',
'The audit must PROVE completion, not merely fail to find remaining work.',
] as const
export const BLOCKED_AUDIT_RULES = [
'The same blocking condition must persist across at least 3 consecutive continuation turns.',
'"Difficult", "slow", or "partially incomplete" is NOT blocked.',
'Only genuinely insurmountable obstacles qualify (missing credentials, external service down, etc.).',
] as const
export function isGoalTerminal(status: GoalStatus): boolean {
return (
status === 'complete' ||
status === 'blocked' ||
status === 'budget_limited' ||
status === 'usage_limited' ||
status === 'max_turns'
)
}

View File

@@ -1,30 +1,295 @@
/**
* Stub for the goal feature module.
* Per-session goal state machine. Pure in-memory management — no FS,
* no network. Persistence is handled by goalStorage.ts.
*
* The goal feature is not yet implemented. This stub exists so that
* PromptInputFooterLeftSide.tsx's require() can be resolved by Bun's
* bundler (build.ts). At runtime, getGoal() returns null, so the
* GoalElapsedIndicator component renders nothing.
*
* When the goal feature is implemented, replace this stub with the
* real implementation.
* Uses Map<string, GoalState> keyed by sessionId so concurrent
* sub-sessions (agents, worktrees) don't leak into each other.
*/
import type { GoalState, GoalStatus } from '../../types/logs.js'
import { getSessionId } from '../../bootstrap/state.js'
import { logForDebugging } from '../../utils/debug.js'
export type GoalState = {
status:
| 'active'
| 'paused'
| 'budget_limited'
| 'usage_limited'
| 'blocked'
| 'complete'
[key: string]: unknown
export const BLOCKED_CONSECUTIVE_THRESHOLD = 3
export const MAX_GOAL_TURNS = 150
const goals = new Map<string, GoalState>()
function goalLog(
tag: string,
msg: string,
extra?: Record<string, unknown>,
): void {
const suffix = extra ? ` ${JSON.stringify(extra)}` : ''
logForDebugging(`[goal] ${tag}: ${msg}${suffix}`)
}
export function getGoal(): GoalState | null {
return null
function resolveSessionId(sessionId?: string): string {
return sessionId ?? getSessionId()
}
export function getActiveElapsedMs(_goal: GoalState): number {
return 0
export function setGoal(
objective: string,
options?: { tokenBudget?: number; sessionId?: string },
): GoalState {
const id = resolveSessionId(options?.sessionId)
const budget =
options?.tokenBudget !== undefined &&
Number.isFinite(options.tokenBudget) &&
options.tokenBudget > 0
? options.tokenBudget
: null
const now = Date.now()
const state: GoalState = {
objective,
status: 'active',
tokenBudget: budget,
tokensUsed: 0,
startTime: now,
pausedAt: null,
accumulatedActiveMs: 0,
blockedAttempts: 0,
lastBlockReason: null,
createdAt: now,
updatedAt: now,
turnsExecuted: 0,
}
goals.set(id, state)
goalLog('SET', `objective="${objective.slice(0, 80)}"`, {
tokenBudget: state.tokenBudget,
})
return state
}
export function getGoal(sessionId?: string): GoalState | null {
return goals.get(resolveSessionId(sessionId)) ?? null
}
export function clearGoal(sessionId?: string): boolean {
const had = goals.has(resolveSessionId(sessionId))
const result = goals.delete(resolveSessionId(sessionId))
if (had) goalLog('CLEAR', 'goal removed')
return result
}
export function pauseGoal(sessionId?: string): GoalState | null {
const id = resolveSessionId(sessionId)
const goal = goals.get(id)
if (!goal || goal.status !== 'active') return null
const now = Date.now()
goal.accumulatedActiveMs += now - goal.startTime
goal.pausedAt = now
goal.status = 'paused'
goal.updatedAt = now
goalLog(
'PAUSE',
`paused after ${Math.round(goal.accumulatedActiveMs / 1000)}s active`,
)
return goal
}
export function resumeGoal(sessionId?: string): GoalState | null {
const id = resolveSessionId(sessionId)
const goal = goals.get(id)
if (!goal) return null
if (goal.status !== 'paused') {
return null
}
const now = Date.now()
goal.startTime = now
goal.pausedAt = null
goal.status = 'active'
goal.updatedAt = now
goalLog('RESUME', 'goal resumed, blockedAttempts reset')
goal.blockedAttempts = 0
goal.lastBlockReason = null
return goal
}
/**
* Transition an active goal into max_turns once continuation cap is hit.
* Idempotent: repeated calls while already max_turns are no-ops.
*/
export function markGoalMaxTurnsReached(sessionId?: string): GoalState | null {
const goal = getGoal(sessionId)
if (!goal || goal.status !== 'active') return null
if (goal.turnsExecuted < MAX_GOAL_TURNS) return null
goal.status = 'max_turns'
goal.updatedAt = Date.now()
goalLog('MAX_TURNS', `reached ${MAX_GOAL_TURNS} turns`)
return goal
}
/**
* Reset continuation turn counter after a max_turns stop and resume work.
* This is a deliberate user action (`/goal continue`) to prevent silent
* runaway loops.
*/
export function continueGoalFromMaxTurns(sessionId?: string): GoalState | null {
const goal = getGoal(sessionId)
if (!goal || goal.status !== 'max_turns') return null
const now = Date.now()
goal.turnsExecuted = 0
goal.status = 'active'
goal.startTime = now
goal.pausedAt = null
goal.blockedAttempts = 0
goal.lastBlockReason = null
goal.updatedAt = now
goalLog(
'CONTINUE',
`turn counter reset, status active (max=${MAX_GOAL_TURNS})`,
)
return goal
}
export function completeGoal(sessionId?: string): GoalState | null {
const id = resolveSessionId(sessionId)
const goal = goals.get(id)
if (!goal) return null
const now = Date.now()
if (goal.status === 'active' && goal.pausedAt === null) {
goal.accumulatedActiveMs += now - goal.startTime
}
goal.status = 'complete'
goal.updatedAt = now
goalLog('COMPLETE', `goal achieved`, {
tokensUsed: goal.tokensUsed,
turns: goal.turnsExecuted,
})
return goal
}
export function updateGoalTokens(
delta: number,
sessionId?: string,
): GoalState | null {
const id = resolveSessionId(sessionId)
const goal = goals.get(id)
if (!goal) return null
if (goal.status !== 'active') return null
if (!Number.isFinite(delta) || delta <= 0) return goal
const sanitized = delta
goal.tokensUsed += sanitized
goal.updatedAt = Date.now()
if (goal.tokenBudget !== null && goal.tokensUsed >= goal.tokenBudget) {
goal.status = 'budget_limited'
goalLog(
'BUDGET_LIMITED',
`tokens ${goal.tokensUsed} >= budget ${goal.tokenBudget}`,
)
} else if (sanitized > 0) {
goalLog(
'TOKENS',
`+${sanitized} → total ${goal.tokensUsed}${goal.tokenBudget ? `/${goal.tokenBudget}` : ''}`,
)
}
return goal
}
export function markUsageLimited(sessionId?: string): GoalState | null {
const id = resolveSessionId(sessionId)
const goal = goals.get(id)
if (!goal || goal.status !== 'active') return null
goal.status = 'usage_limited'
goal.updatedAt = Date.now()
return goal
}
export function incrementGoalTurns(sessionId?: string): number {
const id = resolveSessionId(sessionId)
const goal = goals.get(id)
if (!goal) return 0
goal.turnsExecuted += 1
goal.updatedAt = Date.now()
goalLog('TURN', `#${goal.turnsExecuted}/${MAX_GOAL_TURNS}`, {
status: goal.status,
tokensUsed: goal.tokensUsed,
})
return goal.turnsExecuted
}
export function recordBlockedAttempt(
reason: string,
sessionId?: string,
): { status: GoalStatus; attempts: number } | null {
const id = resolveSessionId(sessionId)
const goal = goals.get(id)
if (!goal || goal.status !== 'active') return null
const normalised = reason.trim().toLowerCase()
if (
goal.lastBlockReason !== null &&
goal.lastBlockReason.trim().toLowerCase() !== normalised
) {
goal.blockedAttempts = 0
}
goal.lastBlockReason = reason
goal.blockedAttempts += 1
goal.updatedAt = Date.now()
if (goal.blockedAttempts >= BLOCKED_CONSECUTIVE_THRESHOLD) {
goal.status = 'blocked'
goalLog('BLOCKED', `3-strike reached! reason="${normalised}"`)
} else {
goalLog(
'BLOCK_ATTEMPT',
`attempt ${goal.blockedAttempts}/${BLOCKED_CONSECUTIVE_THRESHOLD} reason="${normalised}"`,
)
}
return { status: goal.status, attempts: goal.blockedAttempts }
}
/**
* Wall-clock time the goal has been actively worked on (excludes
* paused intervals). Used by status displays and completion reports.
*/
export function getActiveElapsedMs(goal: GoalState): number {
const ongoing =
goal.status === 'active' && goal.pausedAt === null
? Date.now() - goal.startTime
: 0
return goal.accumulatedActiveMs + ongoing
}
/** Test-only: wipe the in-memory map without touching disk. */
export function _clearAllGoalsForTesting(): void {
goals.clear()
}
/**
* Test/internal: hydrate the in-memory map from persisted state.
* Called by goalStorage on session resume.
*/
export function _setGoalFromPersistedState(
state: GoalState,
sessionId?: string,
): void {
goals.set(resolveSessionId(sessionId), state)
}
/** Format the elapsed time as "Xm Ys" / "Ys" for UI display. */
export function formatGoalElapsed(goal: GoalState): string {
const elapsedMs = getActiveElapsedMs(goal)
const seconds = Math.floor(elapsedMs / 1000)
const minutes = Math.floor(seconds / 60)
if (minutes === 0) return `${seconds}s`
return `${minutes}m ${seconds % 60}s`
}
/** Human-readable status label for UI. */
export function formatGoalStatusLabel(status: GoalStatus): string {
switch (status) {
case 'active':
return 'Active'
case 'paused':
return 'Paused'
case 'blocked':
return 'Blocked'
case 'budget_limited':
return 'Budget Limited'
case 'usage_limited':
return 'Usage Limited'
case 'max_turns':
return 'Max Turns Reached'
case 'complete':
return 'Complete'
}
}

View File

@@ -0,0 +1,55 @@
/**
* Goal persistence bridge — connects the in-memory `goalState` map
* to the JSONL transcript that backs --resume.
*
* Splitting this off keeps goalState pure (testable without touching
* the file system) while still giving the slash command + tool a
* single call to "save the current goal".
*/
import type { UUID } from 'crypto'
import { getSessionId } from '../../bootstrap/state.js'
import type { GoalState } from '../../types/logs.js'
import {
clearGoalEntry as clearGoalEntryOnDisk,
saveGoal as saveGoalOnDisk,
} from '../../utils/sessionStorage.js'
import { _setGoalFromPersistedState, getGoal } from './goalState.js'
/**
* Snapshot the current in-memory goal for the running session to the
* JSONL transcript. Called by every mutating helper in goalState
* (set / pause / resume / complete / token update / blocked).
*
* No-op when there is no goal — used as a fire-and-forget convenience.
*/
export function persistCurrentGoal(): void {
const sessionId = getSessionId() as UUID
const goal = getGoal(sessionId)
if (!goal) return
saveGoalOnDisk(sessionId, goal)
}
/**
* Hydrate the in-memory map from a `loadTranscriptFile` result. Called
* by REPL.tsx after restoreSessionMetadata so `--resume` carries the
* goal across process restarts.
*/
export function hydrateGoalFromTranscript(
goalsMap: Map<UUID, GoalState>,
sessionId?: UUID,
): GoalState | null {
const id = (sessionId ?? (getSessionId() as UUID)) as UUID
const state = goalsMap.get(id)
if (!state) return null
_setGoalFromPersistedState(state, id)
return state
}
/**
* Persist an explicit clear — writes the `goal-cleared` tombstone so
* a future --resume cannot resurrect a stale goal entry.
*/
export function persistGoalClear(): void {
const sessionId = getSessionId() as UUID
clearGoalEntryOnDisk(sessionId)
}

View File

@@ -0,0 +1,149 @@
/**
* Goal-steering prompt templates injected into the model as meta-messages.
*
* Three templates correspond to the three CODEX steering scenarios:
*
* 1. **Continuation** — injected automatically when the agent is idle
* and a goal is active. Drives the auto-continuation loop.
*
* 2. **Budget limit** — injected once when the token budget is reached.
* Instructs the model to stop substantive work and summarize progress.
*
* 3. **Objective updated** — injected when the user changes the
* objective mid-conversation via `/goal <new>`.
*
* All templates are wrapped in `<goal-steering>` XML so the model can
* distinguish system-injected goal guidance from user conversation.
*/
import type { GoalState } from '../../types/logs.js'
import { formatGoalElapsed, getActiveElapsedMs } from './goalState.js'
function formatTokenUsage(goal: GoalState): string {
if (goal.tokenBudget !== null) {
const remaining = Math.max(0, goal.tokenBudget - goal.tokensUsed)
return `Tokens used: ${goal.tokensUsed} / ${goal.tokenBudget} (${remaining} remaining)`
}
return `Tokens used: ${goal.tokensUsed}`
}
/**
* Continuation prompt — the core auto-run steering message.
*
* Mirrors CODEX `prompts/templates/goals/continuation.md`:
* - Reminds model of the objective
* - Provides token budget status
* - Embeds completion-audit and blocked-audit rules
* - Forbids scope reduction
*/
export function buildContinuationPrompt(goal: GoalState): string {
const elapsed = formatGoalElapsed(goal)
const tokenInfo = formatTokenUsage(goal)
const turnInfo = `Continuation turns executed: ${goal.turnsExecuted}`
return `<goal-steering type="continuation">
You have an active goal to work on. Continue making progress.
## Active Goal
${goal.objective}
## Status
- Elapsed active time: ${elapsed}
- ${tokenInfo}
- ${turnInfo}
## Instructions
Continue working towards the goal. Do NOT narrow the scope of the goal — even if you cannot complete everything in one turn, maintain the full objective and make as much progress as possible.
When you believe the goal is fully achieved, use the GoalTool to mark it complete. Before doing so, perform a strict Completion Audit:
### Completion Audit
1. Derive concrete requirements from the objective and any referenced files.
2. Preserve the original scope — do not redefine success around what is already done.
3. For every explicit requirement, identify authoritative evidence (test output, file content, command result).
4. Treat tests, manifests, and verifiers as evidence only after confirming they actually cover the requirement.
5. Treat uncertain or indirect evidence as "not achieved".
6. The audit must PROVE completion, not merely fail to find remaining work.
### Blocked Audit
If you encounter an obstacle you genuinely cannot overcome:
- Do NOT mark blocked on the first encounter.
- The same blocking condition must persist for at least 3 consecutive continuation turns before you may mark blocked.
- "Difficult", "slow", or "partially incomplete" is NOT blocked.
- If blocked, use the GoalTool with status "blocked" and a clear reason.
Resume working now.
</goal-steering>`
}
/**
* Budget-limit prompt — injected once when tokensUsed >= tokenBudget.
*
* Mirrors CODEX `prompts/templates/goals/budget_limit.md`:
* - Instructs the model to stop new substantive work
* - Asks for a progress summary and what remains
*/
export function buildBudgetLimitPrompt(goal: GoalState): string {
return `<goal-steering type="budget_limit">
## Token Budget Reached
Your token budget for this goal has been exhausted.
- Goal: ${goal.objective}
- Tokens used: ${goal.tokensUsed}${goal.tokenBudget !== null ? ` / ${goal.tokenBudget}` : ''}
- Active time: ${formatGoalElapsed(goal)}
**Stop all substantive work immediately.** Do NOT start new file edits, tool calls, or explorations.
Instead, provide a brief summary:
1. What has been accomplished so far.
2. What remains to be done.
3. Any blockers or issues encountered.
Then use the GoalTool to mark the goal as complete (if truly done) or leave it in its current state for the user to decide.
</goal-steering>`
}
/**
* Objective-updated prompt — injected by the `/goal` command when the
* user replaces or sets a new objective mid-conversation.
*
* Mirrors CODEX `prompts/templates/goals/objective_updated.md`.
* Note: the `/goal` command already injects `<goal-objective-updated>`
* directly; this function provides the full steering context around it.
*/
export function buildObjectiveUpdatedPrompt(
newObjective: string,
previousObjective?: string,
): string {
const previousSection = previousObjective
? `\nPrevious objective: ${previousObjective}\n`
: ''
return `<goal-steering type="objective_updated">
The user has updated the active goal.${previousSection}
New objective: ${newObjective}
Acknowledge the updated objective and begin working towards it. All previous progress that is still relevant should be preserved, but the new objective takes priority.
Follow the same Completion Audit and Blocked Audit rules described in prior goal-steering messages.
</goal-steering>`
}
/**
* Compact summary of the goal for system prompt injection.
* Kept short to minimise prompt-cache displacement.
*/
export function buildGoalContextBlock(goal: GoalState): string {
const elapsed = formatGoalElapsed(goal)
const elapsedMs = getActiveElapsedMs(goal)
const budget =
goal.tokenBudget !== null ? ` budget="${goal.tokenBudget}"` : ''
return [
`<active-goal status="${goal.status}" elapsed="${elapsed}" elapsed_ms="${elapsedMs}" tokens="${goal.tokensUsed}"${budget} turns="${goal.turnsExecuted}">`,
goal.objective,
'</active-goal>',
].join('\n')
}