mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 06:15:51 +00:00
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:
272
src/services/goal/__tests__/goalState.test.ts
Normal file
272
src/services/goal/__tests__/goalState.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
33
src/services/goal/goalAudit.ts
Normal file
33
src/services/goal/goalAudit.ts
Normal 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'
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
55
src/services/goal/goalStorage.ts
Normal file
55
src/services/goal/goalStorage.ts
Normal 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)
|
||||
}
|
||||
149
src/services/goal/prompts.ts
Normal file
149
src/services/goal/prompts.ts
Normal 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')
|
||||
}
|
||||
Reference in New Issue
Block a user