Lets Claude set its own session goal; an independent evaluator keeps it working at Stop until the goal is met (defers to native ProposeGoal when available).

Lets Claude set its own session goal, then keeps it working until an independent evaluator confirms the goal is met.
mcp__goal-mode__set_goal with one measurable end state (and optionally its check) when the user asked for a verifiable, multi-turn outcome. No approval prompt; the model reports the goal in one line. Refused in plan mode, when a goal is already set, or when Claude Code's native ProposeGoal tool is available (the model is told to use that instead).claude-sonnet-5-5 judges the goal from the final message plus the transcript tail (real tool output only counts). Unmet blocks the stop with the reason. A transcript over the 4 MiB read cap is judged by a fork of the session instead./goal-mode auto [hint] (or a bare /goal-mode set) hands the choice to Claude. Claude reads the situation (this conversation, any approved plan, the repository state, unfinished or blocked work) and picks the one next outcome that fits it, as one measurable end state. It sets that goal itself without asking you to choose, tells you in one line and starts. An existing goal is cleared first and passed along as context. The optional hint steers the choice (/goal-mode auto 배포 마무리 위주로)./goal-mode set <condition> [--check <command>] sets the goal yourself. No nudge or model judgement is involved, and an existing goal is replaced. Claude starts on it at once (a kickoff prompt) and the same evaluator judges every stop. It is refused in plan mode and while a loop runs./goal-mode set 새로 추진할 목표를 정해봐. Claude then turns it into one measurable end state (asking you when the choice is yours) and refines the goal with set_goal, once.Your words: in /goal-mode)./goal-mode shows the goal./goal-mode clear removes it.Known limitation: if the native /goal is also set by hand, both evaluators run.
Cost and latency: while a goal is active, every Stop makes one evaluator call (about 2–20 s observed). That call is billed to the session's credentials but does not appear in /cost, since plugin completions stay out of the session totals. When no goal is set, the Stop hook makes no model call.
/goal-mode loop on works through <repo>/.claude/goals.md, one goal per line:
- [ ] make add() return a + b :: check: source ~/dev/mlx_env/bin/activate && pytest -q
For each pending line the loop:
worktrees/goal-NN-<slug> on branch goal/NN-<slug>;check itself in the worktree (non-zero exit blocks with its output, no model call), refuses uncommitted changes, then asks the evaluator;[x] and starts the next one.When stuck (5 unmet checks in a row, or the evaluator fails) it waits 2 minutes and sends a "change your approach" prompt, at most twice. After that the line is marked [!] and the loop moves on. The model may call defer_goal for an external blocker after 2 failed checks; the deferral is applied at the next Stop.
While a loop goal runs, the plugin refuses:
goal/* branch, and force pushes;git reset --hard, sudo, deploy and cluster commands, and rm -rf outside the worktree;.claude/goal-loop.deny.Other notes:
.claude/goal-loop.log (JSONL)..git/info/exclude: the queue, log, deny file and worktrees/./goal-mode loop off|status|resume: resume takes over a loop whose session went away. A resumed (claude --continue) owner session continues by itself.The model is nudged to set its own goal at two points, with no extra model call.
ExitPlanMode succeeds. This also fixes a bug where set_goal kept refusing with "plan mode" for the rest of the turn the plan was approved in.The nudge is a short context note. The model still decides, and skips it when the request has no verifiable end state. No nudge is sent when a goal is already set, in plan mode, or while a loop runs. Turn it off with the autoGoal option.
hooks/register.tsx 1222 lines1import { atom, read, update } from 'claude-code'
2import { segment } from './segment'
3import type { EngineInterface, ModelForkResult, Register } from 'claude-code'
4
5import type { Goal, GoalMeta, LoopView } from '../types'
6import { checkBash, checkPath, uncommitted } from './guard'
7import { markItem, nextPending, parseQueue, slugify, summarize } from './queue'
8
9// Self-set goal mode: the model sets a session goal with `set_goal`, and at
10// every Stop an independent evaluator decides whether the goal is met; while
11// it is not, the stop is blocked and the model keeps working. When Claude
12// Code's own ProposeGoal tool is available, the model is told to use it and
13// `set_goal` refuses, so two evaluators never run at once.
14//
15// Loop mode (/goal-mode loop on) feeds goals from <repo>/.claude/goals.md one
16// after another, each in its own git worktree, with the queue's check run by
17// the plugin itself, retries with a change of approach, and guards on what an
18// unattended session may run. Only the Stop hook advances the queue.
19
20const TOOL = 'set_goal'
21const TOOL_ID = 'mcp__goal-mode__set_goal'
22const DEFER_TOOL = 'defer_goal'
23const DEFER_TOOL_ID = 'mcp__goal-mode__defer_goal'
24const COMMAND = 'goal-mode'
25const NATIVE_TOOL = 'ProposeGoal'
26
27const EVALUATOR_MODEL = 'claude-sonnet-5-5'
28const EVALUATOR_TIMEOUT_MS = 60_000
29const MAX_CONDITION_CHARS = 600
30// Auto-declaration: a prompt shorter than this, a question, or a slash command
31// is never classified; the classifier gives up after the timeout.
32const AUTO_GOAL_MIN_CHARS = 30
33// A request for an outcome, not a question. No model call: a classifier on
34// every prompt cost seconds (21 s measured under load) for a nudge the model
35// can decline itself.
36const OUTCOME_VERB = /(고쳐|고치|수정|구현|만들|추가|작성|통과|마이그레이션|리팩터|옮겨|적용|설치|배포|최적화|해결|완성|이전해|바꿔|변환|정리해|\bfix|\bimplement|\badd\b|\bbuild|\bmigrate|\brefactor|\bmake\b.*\bpass|\bport\b|\bdeploy|\bconvert|\bupgrade)/i
37const MAX_UNMET_PER_TURN = 5
38const MAX_CHECKS = 30
39const MAX_PARSE_FAILURES = 2
40const TRANSCRIPT_TAIL_CHARS = 40_000
41const BLOCK_CHARS = 2_000
42
43const LOOP_MAX_RETRIES = 2
44const LOOP_RETRY_DELAY_MS = 120_000
45const LOOP_KICKOFF_DELAY_MS = 1_000
46const LOOP_OWNER_FRESH_MS = 10 * 60_000
47const LOOP_CHECK_TIMEOUT_MS = 600_000
48const LOOP_MIN_CHECKS_TO_DEFER = 2
49const LOOP_LOG_MAX_LINES = 2_000
50const LOOP_EXCLUDES = ['worktrees/', '.claude/goals.md', '.claude/goal-loop.log', '.claude/goal-loop.deny']
51
52// Change a tag when that value's fields change: it declines what an earlier
53// version stored, which outlives a reload.
54const goalAtom = atom({ plugin: 'goal-mode', key: 'goal' } as const, null, {
55 shape: 'goal-2',
56})
57const metaAtom = atom(
58 { plugin: 'goal-mode', key: 'meta' } as const,
59 { nativeSeen: false },
60 { shape: 'meta-2' },
61)
62
63const POLICY = `# Goal mode (goal-mode plugin)
64
65You can set a completion goal for this session with the ${TOOL_ID} tool. Once set, a separate evaluator checks the goal every time you try to stop, and blocks the stop until the goal is met. Only the user can clear it (/${COMMAND} clear).
66
67If the ${NATIVE_TOOL} tool is in your tool list, use it instead, with ask_user false, and do not call ${TOOL_ID}.
68
69Set a goal on your own, without asking, when ALL of these hold:
70- The user asked for an outcome with a verifiable end state ("make the tests pass", "migrate every call site", "fix the build").
71- The work will plausibly span multiple turns or many tool calls.
72- The condition follows directly from the user's request. Never widen scope; never add goals the user did not ask for.
73- You are not in plan mode.
74Do not set goals for questions, explanations, one-off edits, or reviews. Set at most one goal; never re-set a goal the user cleared.
75
76When a "goal-mode:" note says a request or an approved plan qualifies, set the goal before you start the work, unless the request clearly has no verifiable end state.
77
78Write the condition as ONE measurable end state with its check, e.g. "\`pytest -q\` exits 0 with no failures". The evaluator cannot run commands or read files: it sees only the conversation. So before you stop, re-run the check and leave its real output in the transcript.
79
80After setting a goal, tell the user in one line that you set it and quote the condition.`
81
82const LOOP_POLICY = `# Autonomous goal loop (goal-mode plugin) — ACTIVE
83
84An unattended loop is feeding you goals from the user's queue. Nobody is watching this session.
85- Do not ask questions (AskUserQuestion is refused) and do not enter plan mode. Decide with a stated assumption and record it in the commit message.
86- A "CONFIDENCE HALT" becomes a call to ${DEFER_TOOL_ID} with the reason, and only for external blockers (credentials, a human decision, missing access) after at least ${LOOP_MIN_CHECKS_TO_DEFER} failed checks. Otherwise keep working.
87- Work only inside the goal's worktree; commit after each meaningful step. Push only your goal/* branch, never main or with force.
88- Do not call ${TOOL_ID}: the loop sets goals itself.`
89
90const SET_GOAL_DESCRIPTION = `Set this session's completion goal. After it is set, every attempt to stop is checked by an independent evaluator and blocked until the condition is met; only the user can clear it. Use only when the user asked for an outcome with a verifiable end state that spans multiple turns, and never to widen scope. If the ${NATIVE_TOOL} tool is available, use that instead.`
91
92const SET_GOAL_SCHEMA = {
93 type: 'object',
94 properties: {
95 condition: {
96 type: 'string',
97 description: `One measurable end state, at most ${MAX_CONDITION_CHARS} characters, e.g. "\`bun test\` exits 0".`,
98 },
99 check: {
100 type: 'string',
101 description:
102 'Optional: the command or observation that proves the condition, e.g. "pytest -q".',
103 },
104 },
105 required: ['condition'],
106}
107
108const DEFER_DESCRIPTION = `Autonomous goal loop only: give up on the current queued goal because of an external blocker (credentials, a human decision, missing access), so the loop marks it blocked and moves on. Allowed only after at least ${LOOP_MIN_CHECKS_TO_DEFER} failed checks; stop right after calling it.`
109
110const DEFER_SCHEMA = {
111 type: 'object',
112 properties: {
113 reason: { type: 'string', description: 'The external blocker, in one or two sentences.' },
114 },
115 required: ['reason'],
116}
117
118const EVALUATOR_SYSTEM = `You are a strict, skeptical judge of whether an AI coding agent has met a goal. You receive the goal, the agent's final message, and the tail of its transcript (tool calls and their real outputs).
119
120Rules:
121- Only real tool output in the transcript counts as evidence. The agent's own claims ("tests pass", "done") are NOT evidence.
122- If the goal names a check (a command, a file state), the latest run of that check in the transcript must show success. An earlier success followed by further edits without a re-run is NOT met.
123- If evidence is missing, partial, or ambiguous, the goal is NOT met.
124- Judge only the stated goal; do not add requirements.
125
126Reply with exactly one line of JSON and nothing else:
127{"met": true|false, "reason": "<one sentence: what evidence shows it is met, or what is still missing>"}`
128
129type Verdict =
130 | { kind: 'met'; reason: string }
131 | { kind: 'unmet'; reason: string }
132 | { kind: 'unparsable'; raw: string }
133 | { kind: 'failed'; reason: string }
134
135type StopFields = {
136 transcript_path: string
137 last_assistant_message?: string
138}
139
140type LoopCurrent = {
141 condition: string
142 check?: string
143 worktree: string
144 branch: string
145 startedAt: number
146}
147
148type LoopRecord = {
149 on: boolean
150 repoRoot: string
151 sessionId?: string
152 heartbeatAt: number
153 seq: number
154 retries: number
155 current?: LoopCurrent
156}
157
158type LogEvent = 'start' | 'unmet' | 'met' | 'retry' | 'blocked' | 'deferred' | 'denied' | 'queue-empty' | 'error'
159
160export const register: Register = (on, options) => {
161 const autoGoal = options.autoGoal !== false
162
163 on('session.start', async ($, e, next) => {
164 await update($, metaAtom, meta => ({ ...meta, cwd: e.cwd }))
165 await $.tool.register({
166 name: TOOL,
167 description: SET_GOAL_DESCRIPTION,
168 inputSchema: SET_GOAL_SCHEMA,
169 isDeferred: false,
170 })
171 await $.tool.register({
172 name: DEFER_TOOL,
173 description: DEFER_DESCRIPTION,
174 inputSchema: DEFER_SCHEMA,
175 isDeferred: false,
176 })
177 await $.command.register({
178 name: COMMAND,
179 description: 'Goal: /goal-mode auto [hint] (Claude picks one for now) | set <condition> [--check <cmd>] | status | clear | loop on|off|resume|status',
180 })
181 await showStatus($)
182
183 return next(e)
184 })
185
186 // Remembers who this session is; on a resumed session that owned a loop,
187 // picks the loop goal back up.
188 on('classic.SessionStart', async ($, e, next) => {
189 const below = await next(e)
190 await update($, metaAtom, meta => ({ ...meta, sessionId: e.session_id, cwd: e.cwd }))
191 const root = await repoRootOf($, e.cwd)
192 if (root === undefined) return below
193 const rec = await readLoop($, root)
194 if (rec === undefined || !rec.on || rec.current === undefined) return below
195
196 if (e.source === 'resume' && rec.sessionId === e.session_id) {
197 await resumeLoop($, rec, 'resumed session')
198 } else if (Date.now() - rec.heartbeatAt > LOOP_OWNER_FRESH_MS) {
199 $.ui.toast(`goal-mode: a loop is on in this repo but idle. /${COMMAND} loop resume to continue it here.`)
200 }
201
202 return below
203 })
204
205 on('prompt.compose', async ($, e, next) => {
206 const composed = await next(e)
207 const meta = await read($, metaAtom)
208
209 return {
210 ...composed,
211 sections: [
212 ...composed.sections,
213 { id: 'goal-mode:policy', text: POLICY, scope: 'session' as const },
214 ...(meta.loop !== undefined
215 ? [{ id: 'goal-mode:loop', text: LOOP_POLICY, scope: 'session' as const }]
216 : []),
217 ],
218 }
219 })
220
221 // The native tool is rendered only where the feature is on: seeing it means
222 // the model should propose through it, so this plugin's evaluator stays off.
223 on('tool.describe', { tool: NATIVE_TOOL }, async ($, e, next) => {
224 await update($, metaAtom, meta => ({ ...meta, nativeSeen: true }))
225
226 return next(e)
227 })
228
229 on('tool.call', { tool: TOOL_ID }, async ($, e) => {
230 const input = e as unknown as { condition?: unknown; check?: unknown }
231 const condition =
232 typeof input.condition === 'string' ? input.condition.trim() : ''
233 const check =
234 typeof input.check === 'string' && input.check.trim() !== ''
235 ? input.check.trim()
236 : undefined
237 const meta = await read($, metaAtom)
238 const current = await read($, goalAtom)
239 // The person's /goal-mode set may name a direction ("pick our next goal")
240 // rather than an end state: Claude turns it into one, once.
241 const refining = current !== null && current.source === 'user' && current.intent === undefined
242
243 // A goal the person set here is refined here, native tool or not.
244 if (meta.nativeSeen && !refining) {
245 return {
246 deny: `${NATIVE_TOOL} is available in this session: use it with ask_user false instead of ${TOOL_ID}.`,
247 }
248 }
249 if (meta.loop !== undefined) {
250 return { deny: 'An autonomous loop is running: it sets goals itself from the queue.' }
251 }
252 if (meta.lastMode === 'plan') {
253 return {
254 deny: 'Plan mode is active, so a goal cannot be set yet. Keep planning; set the goal after the plan is approved.',
255 }
256 }
257 if (current !== null && !refining) {
258 return {
259 deny:
260 current.source === 'user' && current.intent !== undefined
261 ? `The person's goal was already refined once into "${current.condition}". Only the user can change it now (/${COMMAND} set or clear).`
262 : `A goal is already set: "${current.condition}". Only the user can clear it (/${COMMAND} clear).`,
263 }
264 }
265 if (condition === '') {
266 return { deny: 'condition is required: one measurable end state.' }
267 }
268 if (condition.length > MAX_CONDITION_CHARS) {
269 return {
270 deny: `condition is limited to ${MAX_CONDITION_CHARS} characters; state one end state and its check.`,
271 }
272 }
273
274 if (refining) {
275 const intent = current.condition
276 await update($, goalAtom, () => ({ ...newGoal(condition, check, 'user', Date.now()), intent }))
277 $.ui.toast(`Goal refined: ${clip(condition, 80)}`)
278 await showStatus($)
279
280 return {
281 result: `The person's goal "${clip(intent, 120)}" is now: ${condition}${check ? ` (check: ${check})` : ''}. This was the one refinement; the evaluator checks it against their words too. Tell the user the refined goal in one line.`,
282 }
283 }
284
285 await update($, goalAtom, () => newGoal(condition, check, 'model', Date.now()))
286 $.ui.toast(`Goal set: ${clip(condition, 80)}`)
287 await showStatus($)
288
289 return {
290 result: `Goal set: ${condition}${check ? ` (check: ${check})` : ''}. Stops are now checked by an evaluator until it is met. Tell the user you set it.`,
291 }
292 })
293
294 // Records the deferral only; the next Stop marks the goal and moves on, so a
295 // new goal is never judged against this turn's transcript.
296 on('tool.call', { tool: DEFER_TOOL_ID }, async ($, e) => {
297 const reason =
298 typeof (e as unknown as { reason?: unknown }).reason === 'string'
299 ? (e as unknown as { reason: string }).reason.trim()
300 : ''
301 const meta = await read($, metaAtom)
302 const goal = await read($, goalAtom)
303
304 if (meta.loop === undefined || goal === null || goal.source !== 'queue') {
305 return { deny: `${DEFER_TOOL_ID} is only for goals of a running autonomous loop.` }
306 }
307 if (goal.checks < LOOP_MIN_CHECKS_TO_DEFER) {
308 return {
309 deny: `Too early to defer: only ${goal.checks} failed check(s). Keep working; defer only an external blocker after ${LOOP_MIN_CHECKS_TO_DEFER} failed checks.`,
310 }
311 }
312 if (reason === '') return { deny: 'reason is required.' }
313 await update($, goalAtom, g => (g === null ? null : { ...g, deferPending: reason }))
314
315 return { result: 'Deferral recorded. Stop now; the loop marks this goal blocked and moves to the next one.' }
316 })
317
318 // Loop-mode guards: no questions, no plan mode, no edits outside the
319 // worktree, no dangerous shell. Inactive unless this session runs a loop.
320 on('tool.call', async ($, e, next) => {
321 const meta = await read($, metaAtom)
322 const loop = meta.loop
323 if (loop === undefined) return next(e)
324
325 const refusal = guardRefusal(e as unknown as Record<string, unknown>, loop)
326 if (refusal === undefined) return next(e)
327 await appendLog($, loop.repoRoot, { event: 'denied', tool: e.tool, reason: refusal })
328
329 return { deny: refusal }
330 })
331
332 // Only a prompt a person typed resets the per-turn cap; a stop the evaluator
333 // blocked continues the same turn and raises no UserPromptSubmit.
334 on('classic.UserPromptSubmit', async ($, e, next) => {
335 await rememberMode($, e.permission_mode)
336 if (e.agent_id === undefined) {
337 await update($, goalAtom, goal => (goal === null ? null : resetTurn(goal)))
338 await showStatus($)
339 }
340 const below = await next(e)
341 if (!autoGoal || e.agent_id !== undefined || e.permission_mode === 'plan') return below
342 const note = await autoGoalNote($, e.prompt)
343 if (note === undefined) return below
344
345 return { ...below, additionalContext: [...(below.additionalContext ?? []), note] }
346 })
347
348 // The mode is otherwise read only at prompts and stops, so a plan approved
349 // mid-turn left set_goal refusing "plan mode" for the rest of that turn.
350 // Leaving plan mode is also the surest sign of multi-turn work: say so.
351 on('tool.call', { tool: 'ExitPlanMode' }, async ($, e, next) => {
352 const ran = await next(e)
353 if (ran.deny !== undefined || ran.isError === true) return ran
354 await update($, metaAtom, meta => ({ ...meta, lastMode: 'default' }))
355 if (!autoGoal) return ran
356 const note = await goalNote($, 'The plan was just approved, so this is multi-turn work.')
357 if (note === undefined) return ran
358
359 return { ...ran, context: [...(ran.context ?? []), note] }
360 })
361
362 on('tool.call', { tool: 'EnterPlanMode' }, async ($, e, next) => {
363 const ran = await next(e)
364 if (ran.deny === undefined && ran.isError !== true) await update($, metaAtom, meta => ({ ...meta, lastMode: 'plan' }))
365
366 return ran
367 })
368
369 on('classic.Stop', async ($, e, next) => {
370 await rememberMode($, e.permission_mode)
371 const below = await next(e)
372 const goal = await read($, goalAtom)
373
374 if (
375 e.agent_id !== undefined ||
376 below.block !== undefined ||
377 below.preventContinuation === true ||
378 goal === null ||
379 goal.status !== 'active'
380 ) {
381 return below
382 }
383
384 if (goal.source === 'queue') {
385 return loopStop($, goal, e, below)
386 }
387
388 const verdict = await evaluate($, goal, e)
389
390 if (verdict.kind === 'met') {
391 await update($, goalAtom, () => null)
392 $.ui.toast(`Goal met: ${clip(verdict.reason, 100)}`)
393 await showStatus($)
394
395 return below
396 }
397 if (verdict.kind === 'failed') {
398 return pause($, below, `the goal check could not complete (${verdict.reason})`)
399 }
400 if (verdict.kind === 'unparsable') {
401 const failures = goal.parseFailures + 1
402 if (failures >= MAX_PARSE_FAILURES) {
403 return pause($, below, 'the goal check gave no readable verdict')
404 }
405 await update($, goalAtom, g => (g === null ? null : { ...g, parseFailures: failures }))
406
407 return below
408 }
409
410 const counted = await countUnmet($, verdict.reason)
411 if (counted.checks >= MAX_CHECKS) {
412 return pause($, below, `the goal stayed unmet after ${counted.checks} checks`)
413 }
414 if (counted.unmetThisTurn >= MAX_UNMET_PER_TURN) {
415 return pause($, below, 'goal checks kept finding it unmet this turn')
416 }
417 await showStatus($)
418
419 return { ...below, block: blockText(verdict.reason, goal) }
420 })
421
422 on('command.run', { command: COMMAND }, async ($, e) => {
423 const args = e.args.trim().split(/\s+/).filter(word => word !== '')
424
425 if (args[0] === 'loop') {
426 return { text: await loopCommand($, args[1] ?? 'status') }
427 }
428
429 // `auto`, or `set` with nothing after it: Claude picks the goal itself.
430 if (args[0] === 'auto' || (args[0] === 'set' && args.length === 1)) {
431 return { text: await autoCommand($, args[0] === 'auto' ? e.args.trim().slice('auto'.length).trim() : '') }
432 }
433 if (args[0] === 'set') {
434 return { text: await setCommand($, e.args.trim().slice('set'.length)) }
435 }
436
437 const goal = await read($, goalAtom)
438 if (args[0] === 'clear') {
439 await update($, goalAtom, () => null)
440 await showStatus($)
441
442 return { text: goal === null ? 'No goal set.' : `Goal cleared: ${goal.condition}` }
443 }
444 if (goal === null) {
445 return { text: 'No goal set. Claude sets one itself when a task has a verifiable end state.' }
446 }
447
448 return {
449 text: [
450 `Goal ${goal.status}${goal.source === 'user' ? (goal.intent !== undefined ? ' (yours, refined by Claude)' : ' (set by you)') : ''}: ${goal.condition}`,
451 goal.intent !== undefined ? `Your words: ${goal.intent}` : undefined,
452 goal.check ? `Check: ${goal.check}` : undefined,
453 `Checks: ${goal.checks} (this turn ${goal.unmetThisTurn})`,
454 goal.lastReason ? `Last check: ${goal.lastReason}` : 'Last check: not yet evaluated',
455 goal.pausedBecause ? `Paused: ${goal.pausedBecause}. Send a message to continue.` : undefined,
456 `/${COMMAND} clear to stop early`,
457 ]
458 .filter(line => line !== undefined)
459 .join('\n'),
460 }
461 })
462}
463
464// `<condition> [--check <command>]`, the text after `/goal-mode set`.
465export function parseSetArgs(text: string): { condition: string; check?: string } {
466 const at = text.search(/(^|\s)--check(\s|$)/)
467 if (at < 0) return { condition: text.trim() }
468 const condition = text.slice(0, at).trim()
469 const check = text.slice(at).replace(/^\s*--check/, '').trim()
470 return { condition, check: check === '' ? undefined : check }
471}
472
473// The person sets the goal themself: the same evaluator, no model judgement
474// on whether to set it, and the work starts at once.
475// The person hands the choice of goal to Claude: no goal is stored here; the
476// kickoff asks Claude to read the situation, pick one measurable end state and
477// set it with set_goal (an empty slot, so the tool takes it), then start.
478async function autoCommand($: EngineInterface, hint: string): Promise<string> {
479 const meta = await read($, metaAtom)
480 if (meta.loop !== undefined) return `An autonomous loop is running and sets goals from its queue: /${COMMAND} loop off first.`
481 if (meta.lastMode === 'plan') return 'Plan mode is active: approve the plan first; the approval itself prompts Claude to set a goal.'
482 const previous = await read($, goalAtom)
483 if (previous !== null) await update($, goalAtom, () => null)
484 await showStatus($)
485 const how = meta.nativeSeen ? `${NATIVE_TOOL} with ask_user false` : TOOL_ID
486 const kickoff = [
487 `[goal-mode] The person ran /${COMMAND} auto: choose this session's goal yourself, to fit the situation as it stands now.`,
488 hint !== '' ? `Their hint: ${hint}` : undefined,
489 previous !== null ? `The goal before it (now cleared): ${previous.condition}${previous.lastReason ? ` — last check: ${previous.lastReason}` : ''}` : undefined,
490 'Read the situation first: this conversation, any approved plan, the repository state (git status, branch, open PR), and work left unfinished or blocked.',
491 'Pick the one next outcome that matters most and can be finished in this session, stated as one measurable end state with the check that proves it. Stay within the work this session is already about; do not widen scope.',
492 `Decide it yourself, without asking the person to choose; ask only if every candidate needs their call (credentials, payment, an irreversible step). Then call ${how}, tell the person the goal in one line, and start on it.`,
493 ]
494 .filter(line => line !== undefined)
495 .join('\n')
496 $.clock.after(50, () => {
497 $.prompt.submit({ text: kickoff }).catch(() => $.ui.toast('goal-mode: the kickoff prompt was not submitted; send any message to start.'))
498 })
499
500 return previous === null
501 ? 'Claude picks a goal for the current situation now and sets it.'
502 : `Cleared "${clip(previous.condition, 60)}"; Claude picks a new goal for the current situation now.`
503}
504
505async function setCommand($: EngineInterface, text: string): Promise<string> {
506 const { condition, check } = parseSetArgs(text)
507 const meta = await read($, metaAtom)
508 if (condition === '') return `Usage: /${COMMAND} set <one measurable end state> [--check <command that proves it>]`
509 if (condition.length > MAX_CONDITION_CHARS) return `The condition is limited to ${MAX_CONDITION_CHARS} characters; state one end state.`
510 if (meta.loop !== undefined) return `An autonomous loop is running and sets goals from its queue: /${COMMAND} loop off first.`
511 if (meta.lastMode === 'plan') return 'Plan mode is active: every stop would be blocked while nothing can be done. Set the goal after the plan is approved.'
512 const previous = await read($, goalAtom)
513 await update($, goalAtom, () => newGoal(condition, check, 'user', Date.now()))
514 await showStatus($)
515 // A prompt from a command.run hook would wait on this very command: a timer
516 // submits it once the hook has returned.
517 const kickoff =
518 `[goal-mode] The person set this session's goal with /${COMMAND} set: "${condition}"${check ? ` (check: ${check})` : ''}. ` +
519 'If it names a direction rather than one measurable end state (e.g. "pick our next goal"), first turn it into one, asking the person if the choice is theirs, and call mcp__goal-mode__set_goal once to refine it; ' +
520 'otherwise work toward it now. Every stop is checked by an independent evaluator until it is met, so before stopping re-run the check and leave its real output in the transcript.'
521 $.clock.after(50, () => {
522 $.prompt.submit({ text: kickoff }).catch(() => $.ui.toast('goal-mode: the goal is set, but the kickoff prompt was not submitted; send any message to start.'))
523 })
524
525 return [
526 previous === null ? `Goal set: ${condition}` : `Goal replaced: ${condition}\n(was: ${previous.condition})`,
527 check ? `Check: ${check}` : undefined,
528 `Claude starts on it now; /${COMMAND} clear to stop.`,
529 ]
530 .filter(line => line !== undefined)
531 .join('\n')
532}
533
534// ---------------------------------------------------------------- loop mode
535
536async function loopCommand($: EngineInterface, action: string): Promise<string> {
537 const meta = await read($, metaAtom)
538 const root = await repoRootOf($, meta.cwd)
539 if (root === undefined) return 'Not inside a git repository: the loop needs one.'
540 const rec = await readLoop($, root)
541
542 if (action === 'status') return loopStatus($, root, rec)
543
544 if (action === 'off') {
545 if (rec === undefined || !rec.on) return 'The loop is not on.'
546 await writeLoop($, { ...rec, on: false })
547
548 return rec.current === undefined
549 ? 'Loop off.'
550 : `Loop off: the current goal "${clip(rec.current.condition, 60)}" keeps running; no further goals are taken.`
551 }
552
553 if (action !== 'on' && action !== 'resume') {
554 return `Usage: /${COMMAND} loop on|off|resume|status`
555 }
556
557 const fresh = rec !== undefined && Date.now() - rec.heartbeatAt < LOOP_OWNER_FRESH_MS
558 if (rec?.on === true && rec.sessionId !== meta.sessionId && fresh) {
559 return 'Another session runs this loop and was active in the last 10 minutes; stop it there first.'
560 }
561 const goal = await read($, goalAtom)
562 if (goal !== null && goal.source !== 'queue') {
563 return `A session goal is active ("${clip(goal.condition, 60)}"): /${COMMAND} clear it before starting the loop.`
564 }
565
566 await ensureExcludes($, root)
567 let queueText: string
568 try {
569 queueText = await $.fs.read(queuePath(root))
570 } catch {
571 return `No queue: write goals to ${queuePath(root)}, one per line, e.g.\n- [ ] make add() return a + b :: check: pytest -q`
572 }
573
574 const owned: LoopRecord = {
575 on: true,
576 repoRoot: root,
577 sessionId: meta.sessionId,
578 heartbeatAt: Date.now(),
579 seq: rec?.seq ?? 0,
580 retries: rec?.retries ?? 0,
581 current: rec?.current,
582 }
583 await writeLoop($, owned)
584
585 if (owned.current !== undefined) {
586 await resumeLoop($, owned, action === 'resume' ? 'resumed by the user' : 'loop turned on')
587
588 return `Loop on: continuing goal "${clip(owned.current.condition, 60)}" in ${owned.current.worktree}.`
589 }
590 const pending = summarize(parseQueue(queueText)).pending
591 const started = await startNext($, owned)
592
593 return started === undefined
594 ? `Loop on, but the queue has no pending goals (${pending}).`
595 : `Loop on: ${pending} pending goal(s); started "${clip(started.condition, 60)}" in ${started.worktree}.`
596}
597
598async function loopStatus($: EngineInterface, root: string, rec: LoopRecord | undefined): Promise<string> {
599 let counts = 'no queue file'
600 try {
601 const s = summarize(parseQueue(await $.fs.read(queuePath(root))))
602 counts = `${s.done} done, ${s.blocked} blocked, ${s.pending} pending of ${s.total}`
603 } catch {
604 // No queue yet.
605 }
606 let tail: string[] = []
607 try {
608 tail = (await $.fs.read(logPath(root))).trim().split('\n').slice(-5)
609 } catch {
610 // No log yet.
611 }
612
613 return [
614 `Loop ${rec?.on === true ? 'on' : 'off'} · queue: ${counts}`,
615 rec?.current ? `Current: ${rec.current.condition}\nWorktree: ${rec.current.worktree} (${rec.current.branch}) · retries ${rec.retries}/${LOOP_MAX_RETRIES}` : 'Current: none',
616 tail.length > 0 ? `Recent log:\n${tail.join('\n')}` : undefined,
617 ]
618 .filter(line => line !== undefined)
619 .join('\n')
620}
621
622// Takes the next pending queue line, makes its worktree, sets the goal and
623// sends the kickoff. Returns undefined when the queue is empty (loop turns off).
624async function startNext($: EngineInterface, rec: LoopRecord): Promise<LoopCurrent | undefined> {
625 const root = rec.repoRoot
626 for (let attempt = 0; attempt < 5; attempt++) {
627 let text: string
628 try {
629 text = await $.fs.read(queuePath(root))
630 } catch {
631 text = ''
632 }
633 const item = nextPending(parseQueue(text))
634 if (item === undefined) {
635 await writeLoop($, { ...rec, on: false, current: undefined, retries: 0 })
636 await setLoopView($, undefined)
637 await update($, goalAtom, () => null)
638 await appendLog($, root, { event: 'queue-empty' })
639 await notify($, 'Goal loop: queue finished.')
640 await showStatus($)
641
642 return undefined
643 }
644
645 const seq = rec.seq + 1
646 const name = `${String(seq).padStart(2, '0')}-${slugify(item.condition)}`
647 const worktree = `${root}/worktrees/goal-${name}`
648 const branch = `goal/${name}`
649 rec = { ...rec, seq }
650
651 const made = await run($, ['git', '-C', root, 'worktree', 'add', worktree, '-b', branch], root, 60_000)
652 if (made.exitCode !== 0) {
653 const why = clip(made.stderr.trim() || made.stdout.trim() || 'git worktree add failed', 300)
654 await markQueue($, root, item.condition, '!', `blocked ${isoNow()}: could not create worktree: ${why}`)
655 await appendLog($, root, { event: 'error', goal: item.condition, reason: why })
656 await writeLoop($, rec)
657 continue
658 }
659
660 const current: LoopCurrent = {
661 condition: item.condition,
662 check: item.check,
663 worktree,
664 branch,
665 startedAt: Date.now(),
666 }
667 rec = { ...rec, current, retries: 0, heartbeatAt: Date.now() }
668 await writeLoop($, rec)
669 await setLoopView($, { repoRoot: root, worktree, branch, extraDeny: await readDenyFile($, root) })
670 await update($, goalAtom, () => newGoal(item.condition, item.check, 'queue', Date.now()))
671 await appendLog($, root, { event: 'start', goal: item.condition, worktree, branch })
672 await showStatus($)
673 submitLater($, root, kickoffText(current, seq), LOOP_KICKOFF_DELAY_MS)
674
675 return current
676 }
677
678 await writeLoop($, { ...rec, on: false })
679 await notify($, 'Goal loop stopped: could not start any goal (worktree errors).')
680
681 return undefined
682}
683
684async function resumeLoop($: EngineInterface, rec: LoopRecord, why: string): Promise<void> {
685 const current = rec.current
686 if (current === undefined) return
687 const owned = { ...rec, sessionId: (await read($, metaAtom)).sessionId, heartbeatAt: Date.now() }
688 await writeLoop($, owned)
689 await setLoopView($, {
690 repoRoot: rec.repoRoot,
691 worktree: current.worktree,
692 branch: current.branch,
693 extraDeny: await readDenyFile($, rec.repoRoot),
694 })
695 const goal = await read($, goalAtom)
696 if (goal === null || goal.source !== 'queue' || goal.condition !== current.condition) {
697 await update($, goalAtom, () => newGoal(current.condition, current.check, 'queue', current.startedAt))
698 } else {
699 await update($, goalAtom, g => (g === null ? null : resetTurn(g)))
700 }
701 await appendLog($, rec.repoRoot, { event: 'start', goal: current.condition, reason: why })
702 await showStatus($)
703 submitLater(
704 $,
705 rec.repoRoot,
706 `[goal-mode loop] Continuing (${why}).\n\n${kickoffText(current, owned.seq)}`,
707 LOOP_KICKOFF_DELAY_MS,
708 )
709}
710
711// A prompt submitted from inside a hook that holds the turn (command.run,
712// classic.Stop) is refused by the engine: it would wait on that very turn. A
713// timer fires after the hook returned, and the prompt then starts a turn of
714// its own once the session is idle.
715function submitLater($: EngineInterface, root: string, text: string, delayMs: number): void {
716 $.clock.after(delayMs, () => {
717 $.prompt.submit({ text }).catch(async error => {
718 await appendLog($, root, { event: 'error', reason: `prompt not submitted: ${clip(String(error), 300)}` })
719 await notify($, 'Goal loop stalled: a prompt could not be submitted. /goal-mode loop resume to retry.')
720 })
721 })
722}
723
724async function loopStop<R extends { block?: string }>(
725 $: EngineInterface,
726 goal: Goal,
727 e: StopFields,
728 below: R,
729): Promise<R> {
730 const meta = await read($, metaAtom)
731 const loop = meta.loop
732 const root = loop?.repoRoot ?? (await repoRootOf($, meta.cwd))
733 if (root === undefined) return below
734 const rec = await readLoop($, root)
735 if (rec === undefined || rec.current === undefined || loop === undefined) {
736 // The loop record is gone (turned off elsewhere): drop the orphan goal.
737 await update($, goalAtom, () => null)
738 await setLoopView($, undefined)
739 await showStatus($)
740
741 return below
742 }
743 await writeLoop($, { ...rec, heartbeatAt: Date.now() })
744
745 if (goal.deferPending !== undefined) {
746 await finishGoal($, rec, '!', `deferred ${isoNow()}: ${goal.deferPending}`, 'deferred', goal.deferPending)
747
748 return below
749 }
750
751 // 1. The queue's own check, run deterministically in the worktree.
752 let evidence: string | undefined
753 if (goal.check !== undefined) {
754 let ran: ProcessResult
755 try {
756 ran = await run($, ['bash', '-lc', goal.check], loop.worktree, LOOP_CHECK_TIMEOUT_MS)
757 } catch (error) {
758 return loopStuck($, rec, goal, below, `the check could not run (${clip(String(error), 200)})`)
759 }
760 const output = clip(`${ran.stdout}\n${ran.stderr}`.trim(), 1_500, true)
761 if (ran.exitCode !== 0) {
762 return loopUnmet($, rec, goal, below, `check \`${goal.check}\` exited ${ran.exitCode}:\n${output}`)
763 }
764 evidence = `The loop ran the check \`${goal.check}\` in ${loop.worktree}: exit 0.\n${output}`
765 }
766
767 // 2. Every step must be committed before the goal can count as met.
768 const status = await run($, ['git', '-C', loop.worktree, 'status', '--porcelain'], loop.worktree, 60_000).catch(
769 () => undefined,
770 )
771 const pending = status === undefined ? [] : uncommitted(status.stdout)
772 if (pending.length > 0) {
773 return loopUnmet($, rec, goal, below, `uncommitted changes in ${loop.worktree}; commit them:\n${clip(pending.join('\n'), 600)}`)
774 }
775
776 // 3. The independent judge, given the check's result as evidence.
777 const verdict = await evaluate($, goal, e, evidence)
778 if (verdict.kind === 'met') {
779 await finishGoal($, rec, 'x', `done ${isoNow()} ${loop.branch}, ${goal.checks} failed checks`, 'met', verdict.reason)
780
781 return below
782 }
783 if (verdict.kind === 'unmet') return loopUnmet($, rec, goal, below, verdict.reason)
784 if (verdict.kind === 'failed') return loopStuck($, rec, goal, below, `the goal check could not complete (${verdict.reason})`)
785
786 const failures = goal.parseFailures + 1
787 if (failures >= MAX_PARSE_FAILURES) return loopStuck($, rec, goal, below, 'the goal check gave no readable verdict')
788 await update($, goalAtom, g => (g === null ? null : { ...g, parseFailures: failures }))
789
790 return below
791}
792
793async function loopUnmet<R extends { block?: string }>(
794 $: EngineInterface,
795 rec: LoopRecord,
796 goal: Goal,
797 below: R,
798 reason: string,
799): Promise<R> {
800 const counted = await countUnmet($, reason)
801 await appendLog($, rec.repoRoot, { event: 'unmet', goal: goal.condition, reason: clip(reason, 300), checks: counted.checks })
802 if (counted.checks >= MAX_CHECKS) {
803 await finishGoal($, rec, '!', `blocked ${isoNow()}: unmet after ${counted.checks} checks: ${clip(reason, 200)}`, 'blocked', reason)
804
805 return below
806 }
807 if (counted.unmetThisTurn >= MAX_UNMET_PER_TURN) {
808 return loopStuck($, rec, goal, below, 'goal checks kept finding it unmet this turn')
809 }
810 await showStatus($)
811
812 return { ...below, block: blockText(reason, goal) }
813}
814
815// Stuck: retry with a change of approach after a pause, then give up on the
816// goal and move on. The stop itself is allowed either way.
817async function loopStuck<R>($: EngineInterface, rec: LoopRecord, goal: Goal, below: R, because: string): Promise<R> {
818 if (rec.retries >= LOOP_MAX_RETRIES || rec.current === undefined) {
819 const last = goal.lastReason ?? because
820 await finishGoal($, rec, '!', `blocked ${isoNow()}: ${clip(last, 200)}`, 'blocked', last)
821
822 return below
823 }
824 const retries = rec.retries + 1
825 await writeLoop($, { ...rec, retries })
826 await update($, goalAtom, g => (g === null ? null : resetTurn(g)))
827 await appendLog($, rec.repoRoot, { event: 'retry', goal: goal.condition, reason: because, retries })
828 await showStatus($)
829 const current = rec.current
830 const reasons = (await read($, goalAtom))?.recentReasons ?? []
831 submitLater($, rec.repoRoot, retryText(current, retries, reasons), LOOP_RETRY_DELAY_MS)
832
833 return below
834}
835
836// Marks the queue line, logs, notifies and, while the loop is on, starts the
837// next goal. The worktree and branch stay for review.
838async function finishGoal(
839 $: EngineInterface,
840 rec: LoopRecord,
841 mark: 'x' | '!',
842 note: string,
843 event: LogEvent,
844 reason: string,
845): Promise<void> {
846 const current = rec.current
847 if (current === undefined) return
848 await markQueue($, rec.repoRoot, current.condition, mark, note)
849 await appendLog($, rec.repoRoot, { event, goal: current.condition, reason: clip(reason, 300), branch: current.branch })
850 await notify($, `Goal ${mark === 'x' ? 'met' : event}: ${clip(current.condition, 80)}`)
851 await update($, goalAtom, () => null)
852 await setLoopView($, undefined)
853 const after: LoopRecord = { ...rec, current: undefined, retries: 0, heartbeatAt: Date.now() }
854 await writeLoop($, after)
855 await showStatus($)
856 if (after.on) await startNext($, after)
857}
858
859function guardRefusal(e: Record<string, unknown>, loop: LoopView): string | undefined {
860 const tool = String(e.tool)
861 if (tool === 'AskUserQuestion') {
862 return `Autonomous loop: nobody can answer. Decide with a stated assumption, or call ${DEFER_TOOL_ID} for an external blocker.`
863 }
864 if (tool === 'EnterPlanMode') return 'Autonomous loop: plan mode would stop the loop; plan inline and keep working.'
865 if (tool === 'Bash' && typeof e.command === 'string') return checkBash(e.command, loop)
866 if ((tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit') && typeof e.file_path === 'string') {
867 return checkPath(e.file_path, loop)
868 }
869 if (tool === 'NotebookEdit' && typeof e.notebook_path === 'string') return checkPath(e.notebook_path, loop)
870
871 return undefined
872}
873
874function kickoffText(current: LoopCurrent, seq: number): string {
875 return [
876 `[goal-mode loop] Goal #${seq}: ${current.condition}`,
877 current.check
878 ? `Check (the loop runs it in the worktree at every stop; it must exit 0): ${current.check}`
879 : 'No check command: an independent evaluator judges the goal from the transcript, so leave real evidence.',
880 `Work ONLY in the git worktree ${current.worktree} (branch ${current.branch}); cd there first. Edits elsewhere in the repository are refused.`,
881 'Commit after each meaningful step: the goal is not accepted with uncommitted changes.',
882 `You may push only this branch (git push -u origin ${current.branch}); never main/master, never with force.`,
883 `Nobody is watching: do not ask questions. Decide with a stated assumption and record it in the commit message. For an external blocker (credentials, a human decision, missing access), call ${DEFER_TOOL_ID} after at least ${LOOP_MIN_CHECKS_TO_DEFER} failed checks.`,
884 ].join('\n')
885}
886
887function retryText(current: LoopCurrent, retries: number, reasons: readonly string[]): string {
888 return [
889 `[goal-mode loop] Retry ${retries}/${LOOP_MAX_RETRIES} for: ${current.condition}`,
890 'The last checks found it unmet:',
891 ...reasons.map(reason => `- ${clip(reason, 400)}`),
892 `Step back: list the approaches you have tried and why they failed, then pick a materially different approach (a subagent may investigate). Continue in ${current.worktree} on ${current.branch}.`,
893 ].join('\n')
894}
895
896type ProcessResult = { exitCode: number; stdout: string; stderr: string }
897
898async function run($: EngineInterface, argv: string[], cwd: string, timeoutMs: number): Promise<ProcessResult> {
899 const result = await $.process.run(argv, { cwd, timeoutMs })
900
901 return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }
902}
903
904// The main checkout's root, also when called from inside a worktree.
905async function repoRootOf($: EngineInterface, cwd: string | undefined): Promise<string | undefined> {
906 if (cwd === undefined) return undefined
907 try {
908 const ran = await run($, ['git', 'rev-parse', '--path-format=absolute', '--git-common-dir'], cwd, 15_000)
909 if (ran.exitCode !== 0) return undefined
910 const common = ran.stdout.trim()
911
912 return common.endsWith('/.git') ? common.slice(0, -'/.git'.length) : undefined
913 } catch {
914 return undefined
915 }
916}
917
918const loopKey = (root: string) => `loop:${root}`
919const queuePath = (root: string) => `${root}/.claude/goals.md`
920const logPath = (root: string) => `${root}/.claude/goal-loop.log`
921const denyPath = (root: string) => `${root}/.claude/goal-loop.deny`
922
923async function readLoop($: EngineInterface, root: string): Promise<LoopRecord | undefined> {
924 const value = await $.store.get(loopKey(root))
925
926 return typeof value === 'object' && value !== null ? (value as LoopRecord) : undefined
927}
928
929async function writeLoop($: EngineInterface, rec: LoopRecord): Promise<void> {
930 await $.store.set(loopKey(rec.repoRoot), rec)
931}
932
933async function setLoopView($: EngineInterface, loop: LoopView | undefined): Promise<void> {
934 await update($, metaAtom, meta => ({ ...meta, loop }))
935}
936
937async function markQueue($: EngineInterface, root: string, condition: string, mark: 'x' | '!', note: string): Promise<void> {
938 try {
939 const text = await $.fs.read(queuePath(root))
940 await $.fs.write(queuePath(root), markItem(text, condition, mark, note))
941 } catch {
942 // The queue file vanished; the log still records the outcome.
943 }
944}
945
946async function readDenyFile($: EngineInterface, root: string): Promise<string[]> {
947 try {
948 return (await $.fs.read(denyPath(root)))
949 .split('\n')
950 .map(line => line.trim())
951 .filter(line => line !== '' && !line.startsWith('#'))
952 } catch {
953 return []
954 }
955}
956
957async function ensureExcludes($: EngineInterface, root: string): Promise<void> {
958 const path = `${root}/.git/info/exclude`
959 let text = ''
960 try {
961 text = await $.fs.read(path)
962 } catch {
963 // No exclude file yet.
964 }
965 const have = new Set(text.split('\n').map(line => line.trim()))
966 const missing = LOOP_EXCLUDES.filter(line => !have.has(line))
967 if (missing.length === 0) return
968 const head = text === '' || text.endsWith('\n') ? text : `${text}\n`
969 await $.fs.write(path, `${head}# goal-mode loop\n${missing.join('\n')}\n`)
970}
971
972async function appendLog($: EngineInterface, root: string, entry: Record<string, unknown>): Promise<void> {
973 try {
974 let text = ''
975 try {
976 text = await $.fs.read(logPath(root))
977 } catch {
978 // First entry.
979 }
980 const lines = text.split('\n').filter(line => line !== '')
981 lines.push(JSON.stringify({ ts: isoNow(), ...entry }))
982 await $.fs.write(logPath(root), `${lines.slice(-LOOP_LOG_MAX_LINES).join('\n')}\n`)
983 } catch {
984 // Logging never stops the loop.
985 }
986}
987
988async function notify($: EngineInterface, text: string): Promise<void> {
989 $.ui.toast(text)
990 try {
991 await $.ui.notify(text, { title: 'goal-mode' })
992 } catch {
993 // No notification channel (headless); the toast and the log remain.
994 }
995}
996
997// ------------------------------------------------------------- evaluation
998
999async function evaluate($: EngineInterface, goal: Goal, e: StopFields, evidence?: string): Promise<Verdict> {
1000 const ask = [
1001 `Goal: ${goal.condition}`,
1002 // A refined goal still answers to the person's words: a refinement that
1003 // dodged them is not met.
1004 goal.intent !== undefined ? `The person asked for: ${goal.intent} (the goal above is Claude's measurable form of it; judge the goal, and call it unmet if it plainly fails that request).` : undefined,
1005 goal.check ? `Check: ${goal.check}` : undefined,
1006 evidence ? `\nVerified by the harness (trustworthy):\n${evidence}` : undefined,
1007 '',
1008 "The agent's final message:",
1009 e.last_assistant_message ?? '(none)',
1010 ]
1011 .filter(line => line !== undefined)
1012 .join('\n')
1013
1014 let tail: string | undefined
1015 try {
1016 tail = transcriptTail(await $.fs.read(e.transcript_path))
1017 } catch {
1018 tail = undefined
1019 }
1020
1021 // A transcript past the read cap is judged by a fork over the session's own
1022 // context: same model, so less independent, but it sees everything.
1023 // A request the engine refuses to send (a blocked model) rejects; it must
1024 // pause the goal, not throw and leave every later Stop silently skipped.
1025 let reply: ModelForkResult
1026 try {
1027 reply =
1028 tail === undefined
1029 ? await $.model.fork({
1030 prompt: `${EVALUATOR_SYSTEM}\n\nThe transcript above is the agent's work. ${ask}`,
1031 })
1032 : await $.model.complete({
1033 model: EVALUATOR_MODEL,
1034 system: EVALUATOR_SYSTEM,
1035 prompt: `${ask}\n\nTranscript tail (oldest first):\n${tail}`,
1036 maxTokens: 300,
1037 timeoutMs: EVALUATOR_TIMEOUT_MS,
1038 })
1039 } catch (error) {
1040 return { kind: 'failed', reason: clip(String(error), 200) }
1041 }
1042
1043 if (!reply.isAnswered) {
1044 return { kind: 'failed', reason: reply.reason }
1045 }
1046
1047 return parseVerdict(reply.text)
1048}
1049
1050export function parseVerdict(text: string): Verdict {
1051 const match = text.match(/\{[\s\S]*\}/)
1052 if (match === null) {
1053 return { kind: 'unparsable', raw: text }
1054 }
1055 try {
1056 const parsed = JSON.parse(match[0]) as { met?: unknown; reason?: unknown }
1057 const reason = typeof parsed.reason === 'string' ? parsed.reason : 'no reason given'
1058 if (parsed.met === true) return { kind: 'met', reason }
1059 if (parsed.met === false) return { kind: 'unmet', reason }
1060
1061 return { kind: 'unparsable', raw: text }
1062 } catch {
1063 return { kind: 'unparsable', raw: text }
1064 }
1065}
1066
1067// Renders the newest transcript rows (text, tool calls, tool results) as plain
1068// lines, keeping the last TRANSCRIPT_TAIL_CHARS characters.
1069export function transcriptTail(jsonl: string): string {
1070 const lines: string[] = []
1071 for (const raw of jsonl.split('\n')) {
1072 if (raw.trim() === '') continue
1073 let row: { type?: string; isMeta?: boolean; message?: { role?: string; content?: unknown } }
1074 try {
1075 row = JSON.parse(raw)
1076 } catch {
1077 continue
1078 }
1079 if ((row.type !== 'user' && row.type !== 'assistant') || row.message === undefined) continue
1080 const role = row.message.role ?? row.type
1081 const content = row.message.content
1082 if (typeof content === 'string') {
1083 lines.push(`[${role}] ${clip(content, 2_000)}`)
1084 continue
1085 }
1086 if (!Array.isArray(content)) continue
1087 for (const block of content as Array<Record<string, unknown>>) {
1088 if (block.type === 'text' && typeof block.text === 'string') {
1089 lines.push(`[${role}] ${clip(block.text, 2_000)}`)
1090 } else if (block.type === 'tool_use') {
1091 lines.push(`[tool_use ${String(block.name)}] ${clip(JSON.stringify(block.input), 1_000)}`)
1092 } else if (block.type === 'tool_result') {
1093 lines.push(`[tool_result${block.is_error === true ? ' error' : ''}] ${clip(resultText(block.content), 2_500)}`)
1094 }
1095 }
1096 }
1097 const joined = lines.join('\n')
1098
1099 return joined.length > TRANSCRIPT_TAIL_CHARS ? joined.slice(-TRANSCRIPT_TAIL_CHARS) : joined
1100}
1101
1102function resultText(content: unknown): string {
1103 if (typeof content === 'string') return content
1104 if (!Array.isArray(content)) return ''
1105
1106 return content
1107 .map(part =>
1108 typeof part === 'object' && part !== null && (part as { type?: unknown }).type === 'text'
1109 ? String((part as { text?: unknown }).text)
1110 : '',
1111 )
1112 .join('\n')
1113}
1114
1115// ------------------------------------------------------------- goal state
1116
1117function newGoal(condition: string, check: string | undefined, source: Goal['source'], setAt: number): Goal {
1118 return {
1119 condition,
1120 check,
1121 source,
1122 setAt,
1123 checks: 0,
1124 unmetThisTurn: 0,
1125 parseFailures: 0,
1126 recentReasons: [],
1127 status: 'active',
1128 }
1129}
1130
1131function resetTurn(goal: Goal): Goal {
1132 return { ...goal, unmetThisTurn: 0, parseFailures: 0, status: 'active', pausedBecause: undefined }
1133}
1134
1135async function countUnmet($: EngineInterface, reason: string): Promise<{ checks: number; unmetThisTurn: number }> {
1136 const goal = await update($, goalAtom, g =>
1137 g === null
1138 ? null
1139 : {
1140 ...g,
1141 checks: g.checks + 1,
1142 unmetThisTurn: g.unmetThisTurn + 1,
1143 lastReason: reason,
1144 recentReasons: [...g.recentReasons, reason].slice(-3),
1145 },
1146 )
1147
1148 return { checks: goal?.checks ?? 0, unmetThisTurn: goal?.unmetThisTurn ?? 0 }
1149}
1150
1151function blockText(reason: string, goal: Goal): string {
1152 return clip(
1153 `Goal not met: ${reason}\nKeep working toward the goal: ${goal.condition}${goal.check ? `\nCheck: ${goal.check}` : ''}\nRe-run the check before you stop so its output is in the transcript.`,
1154 BLOCK_CHARS,
1155 )
1156}
1157
1158async function pause<R>($: EngineInterface, below: R, because: string): Promise<R> {
1159 await update($, goalAtom, g =>
1160 g === null ? null : { ...g, status: 'paused' as const, pausedBecause: because },
1161 )
1162 $.ui.toast(`Goal paused: ${because}. Send a message to continue, or /${COMMAND} clear.`)
1163 await showStatus($)
1164
1165 return below
1166}
1167
1168// Whether a typed prompt plausibly asks for multi-turn work toward an outcome.
1169export function autoGoalCandidate(prompt: string): boolean {
1170 const text = prompt.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, ' ').trim()
1171 if (text.length < AUTO_GOAL_MIN_CHARS) return false
1172 if (/<command-name>/.test(text) || text.startsWith('/')) return false
1173 // A question, not a request for an outcome.
1174 if (/[??]\s*$/.test(text) || /(인가요|일까요|나요|습니까|어때|뭐야|왜)\s*[??]?\s*$/.test(text)) return false
1175
1176 return OUTCOME_VERB.test(text)
1177}
1178
1179async function autoGoalNote($: EngineInterface, prompt: string): Promise<string | undefined> {
1180 if (!autoGoalCandidate(prompt)) return undefined
1181
1182 return goalNote($, 'This request looks like work toward an outcome.')
1183}
1184
1185// The nudge itself; undefined when no goal can be set now.
1186async function goalNote($: EngineInterface, why: string): Promise<string | undefined> {
1187 const goal = await read($, goalAtom)
1188 const meta = await read($, metaAtom)
1189 if (goal !== null || meta.loop !== undefined) return undefined
1190 const how = meta.nativeSeen ? `${NATIVE_TOOL} with ask_user false` : TOOL_ID
1191
1192 return `goal-mode: ${why} If it has a verifiable end state and will take more than a few tool calls, set the session goal now with ${how}, before starting: one measurable end state from the user's own words, with the check that proves it. Do not widen scope. Otherwise skip this note and say nothing about it.`
1193}
1194
1195async function rememberMode($: EngineInterface, mode: string | undefined): Promise<void> {
1196 if (mode === undefined) return
1197 const meta: GoalMeta = await read($, metaAtom)
1198 if (meta.lastMode !== mode) {
1199 await update($, metaAtom, m => ({ ...m, lastMode: mode }))
1200 }hooks/segment.ts 24 lines1import type { EngineInterface } from 'claude-code'
2
3// This mod's piece of the person's own status line: ~/.claude/statusline-command.sh
4// joins ~/.claude/statusline/<sessionId>/<mod>.txt after its own fields, so the
5// engine's one-row-per-plugin notices (`$.ui.status`) stay for what needs the
6// person's eye. A leading '!' asks the status line to draw the piece in red;
7// '' clears it. Copied per mod: a mod imports nothing outside its folder.
8
9const MOD = 'goal-mode'
10let last: string | undefined
11
12export async function segment($: EngineInterface, text: string): Promise<void> {
13 if (text === last) return
14 last = text
15 try {
16 const home = await $.env.get('HOME')
17 if (home === undefined) return
18 await $.fs.write(`${home}/.claude/statusline/${await $.session.id()}/${MOD}.txt`, text)
19 } catch {
20 // No status line piece is worth failing a hook over.
21 last = undefined
22 }
23}
24hooks/guard.ts 112 lines1// Loop-mode guards: pure checks on a tool call while an unattended loop runs.
2// Each returns the refusal text, or undefined to let the call through.
3// Regexes are a second line of defence; worktree isolation and the push rules
4// are the first.
5
6export type GuardContext = {
7 repoRoot: string
8 worktree: string
9 extraDeny: readonly string[]
10}
11
12const DEPLOY =
13 /\b(kubectl|helm)\b|\bterraform\s+apply\b|\bdocker\s+push\b|\bgcloud\b[^\n;&|]*\bdeploy\b|\bfly(ctl)?\s+deploy\b|\bvercel\b/
14const RESET_HARD = /\bgit\b[^\n;&|]*\breset\b[^\n;&|]*--hard\b/
15const SUDO = /(^|[\s;&|(])sudo\s/
16const PUSH = /\bgit\b[^\n;&|]*\bpush\b[^\n;&|]*/g
17const FORCE = /(\s--force(-with-lease)?\b|\s-[a-zA-Z]*f\b|\s\+\S)/
18const PROTECTED_BRANCH = /(^|[\s:/])(main|master)(\s|$)/
19const RM_RF = /(^|[\s;&|(])rm\s+((?:-[a-zA-Z]+\s+)+)([^\n;&|]*)/g
20
21// Message arguments and heredoc bodies are prose, not commands: a commit
22// message that mentions "git push origin main" must not be refused. Other
23// quoted strings (bash -c "...") are still checked.
24const MESSAGE_ARG = /(\s(?:-m|--message|--body|--title))(=|\s+)("(?:\\.|[^"\\])*"|'[^']*')/g
25const HEREDOC = /<<-?\s*(['"]?)(\w+)\1[^\n]*\n[\s\S]*?\n\s*\2(?=\s|$)/g
26
27export function stripProse(command: string): string {
28 return command.replace(MESSAGE_ARG, "$1$2''").replace(HEREDOC, "<<'$2'\n$2")
29}
30
31export function checkBash(raw: string, ctx: GuardContext): string | undefined {
32 const command = stripProse(raw)
33 for (const push of command.matchAll(PUSH)) {
34 const text = push[0]
35 if (FORCE.test(text)) {
36 return 'Autonomous loop: force-push is refused.'
37 }
38 if (PROTECTED_BRANCH.test(text)) {
39 return 'Autonomous loop: pushing main/master is refused; push only your goal/* branch.'
40 }
41 if (!/\bgoal\//.test(text)) {
42 return 'Autonomous loop: name the branch when pushing: git push -u origin goal/<your-branch>.'
43 }
44 }
45 if (RESET_HARD.test(command)) return 'Autonomous loop: git reset --hard is refused; commit or revert instead.'
46 if (SUDO.test(command)) return 'Autonomous loop: sudo is refused.'
47 if (DEPLOY.test(command)) return 'Autonomous loop: deploy and cluster commands are refused.'
48
49 for (const rm of command.matchAll(RM_RF)) {
50 const flags = rm[2] ?? ''
51 if (!/r/i.test(flags) || !/f/.test(flags)) continue
52 for (const target of (rm[3] ?? '').trim().split(/\s+/)) {
53 if (target === '') continue
54 if (!isSafeRmTarget(target, ctx.worktree)) {
55 return `Autonomous loop: rm -rf ${target} is refused; only paths inside ${ctx.worktree} (or relative ones without ..) may be removed.`
56 }
57 }
58 }
59
60 for (const pattern of ctx.extraDeny) {
61 let re: RegExp
62 try {
63 re = new RegExp(pattern)
64 } catch {
65 continue
66 }
67 if (re.test(command)) return `Autonomous loop: refused by .claude/goal-loop.deny (${pattern}).`
68 }
69
70 return undefined
71}
72
73function isSafeRmTarget(target: string, worktree: string): boolean {
74 const bare = target.replace(/^['"]|['"]$/g, '')
75 if (bare.includes('..') || bare.startsWith('~') || bare.includes('$') || bare === '*' || bare === '.' || bare === '/') {
76 return false
77 }
78 if (bare.startsWith('/')) return isInside(bare, worktree)
79
80 return true
81}
82
83// Edits inside the repository but outside the goal's worktree would change the
84// main checkout; anything outside the repository (scratch, memory) is allowed.
85export function checkPath(filePath: string, ctx: GuardContext): string | undefined {
86 if (!filePath.startsWith('/')) return undefined
87 if (isInside(filePath, ctx.repoRoot) && !isInside(filePath, ctx.worktree)) {
88 return `Autonomous loop: edits inside the repository must stay in the goal's worktree ${ctx.worktree}.`
89 }
90
91 return undefined
92}
93
94const ARTIFACT =
95 /(^|\/)(__pycache__|\.pytest_cache|\.mypy_cache|\.ruff_cache|\.venv|node_modules|target|dist|build|\.next)(\/|$)|\.py[co]$|(^|\/)\.DS_Store$/
96
97// The `git status --porcelain` lines that still need a commit: any change to a
98// tracked file, and new files other than build and cache artifacts (which
99// running the check itself leaves behind).
100export function uncommitted(porcelain: string): string[] {
101 return porcelain
102 .split('\n')
103 .filter(line => line.trim() !== '')
104 .filter(line => !(line.startsWith('??') && ARTIFACT.test(line.slice(3).trim())))
105}
106
107export function isInside(path: string, dir: string): boolean {
108 const base = dir.endsWith('/') ? dir : `${dir}/`
109
110 return path === dir || path.startsWith(base)
111}
112hooks/queue.ts 79 lines1// The loop's work queue: a Markdown checklist a person writes, one goal per
2// line. `[ ]` pending, `[x]` done, `[!]` blocked; the loop appends its note as
3// a trailing HTML comment so the line stays readable.
4//
5// - [ ] fix add() :: check: pytest -q tests/test_counter.py
6// - [x] fix add() :: check: pytest -q <!-- done 2026-10-24T13:10Z goal/01-fix-add, 4 checks -->
7
8export type QueueMark = ' ' | 'x' | '!'
9
10export type QueueItem = {
11 line: number
12 mark: QueueMark
13 condition: string
14 check?: string
15 note?: string
16}
17
18const ITEM = /^\s*[-*] \[([ xX!])\] (.*)$/
19const NOTE = /\s*<!--([\s\S]*?)-->\s*$/
20const CHECK = ' :: check:'
21
22export function parseQueue(text: string): QueueItem[] {
23 const items: QueueItem[] = []
24 text.split('\n').forEach((raw, line) => {
25 const match = raw.match(ITEM)
26 if (match === null) return
27 const mark = (match[1] === 'X' ? 'x' : match[1]) as QueueMark
28 let body = match[2] ?? ''
29 let note: string | undefined
30 const noteMatch = body.match(NOTE)
31 if (noteMatch !== null) {
32 note = (noteMatch[1] ?? '').trim()
33 body = body.slice(0, noteMatch.index)
34 }
35 const at = body.indexOf(CHECK)
36 const condition = (at < 0 ? body : body.slice(0, at)).trim()
37 const check = at < 0 ? undefined : body.slice(at + CHECK.length).trim() || undefined
38 if (condition === '') return
39 items.push({ line, mark, condition, check, note })
40 })
41
42 return items
43}
44
45export function nextPending(items: readonly QueueItem[]): QueueItem | undefined {
46 return items.find(item => item.mark === ' ')
47}
48
49// Marks the first pending line whose condition matches, so a person editing
50// the file meanwhile does not shift the loop onto the wrong line.
51export function markItem(text: string, condition: string, mark: 'x' | '!', note: string): string {
52 const lines = text.split('\n')
53 const target = parseQueue(text).find(item => item.mark === ' ' && item.condition === condition)
54 if (target === undefined) return text
55 const raw = lines[target.line] ?? ''
56 const withoutNote = raw.replace(NOTE, '')
57 const marked = withoutNote.replace(/\[[ xX!]\]/, `[${mark}]`)
58 lines[target.line] = `${marked} <!-- ${note.replace(/--!?>/g, '- ->').replace(/\n/g, ' ')} -->`
59
60 return lines.join('\n')
61}
62
63export function summarize(items: readonly QueueItem[]): { done: number; blocked: number; pending: number; total: number } {
64 const count = (mark: QueueMark) => items.filter(item => item.mark === mark).length
65
66 return { done: count('x'), blocked: count('!'), pending: count(' '), total: items.length }
67}
68
69export function slugify(condition: string): string {
70 const slug = condition
71 .toLowerCase()
72 .replace(/[^a-z0-9]+/g, '-')
73 .replace(/^-+|-+$/g, '')
74 .slice(0, 30)
75 .replace(/-+$/, '')
76
77 return slug === '' ? 'goal' : slug
78}
79types/index.d.ts 48 lines1export type GoalStatus = 'active' | 'paused'
2
3export type Goal = {
4 condition: string
5 check?: string
6 // 'model': set by the model with set_goal; 'user': set by the person with
7 // /goal-mode set; 'queue': taken from the loop's goals.md, whose check the
8 // loop runs itself.
9 source: 'model' | 'user' | 'queue'
10 setAt: number
11 checks: number
12 unmetThisTurn: number
13 parseFailures: number
14 recentReasons: string[]
15 lastReason?: string
16 status: GoalStatus
17 pausedBecause?: string
18 deferPending?: string
19 // A goal the person set with /goal-mode set that Claude then turned into a
20 // measurable end state (once): the person's own words, kept beside it.
21 intent?: string
22}
23
24export type LoopView = {
25 repoRoot: string
26 worktree: string
27 branch: string
28 extraDeny: string[]
29}
30
31export type GoalMeta = {
32 nativeSeen: boolean
33 lastMode?: string
34 sessionId?: string
35 cwd?: string
36 // Set while this session owns a running loop goal; the guards read it.
37 loop?: LoopView
38}
39
40declare module 'claude-code' {
41 interface PluginState {
42 'goal-mode': {
43 goal: Shaped<Goal | null>
44 meta: Shaped<GoalMeta>
45 }
46 }
47}
48