SLOPSHOPPER

goal-mode

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).

newguardcommandtoaststatusprompt
v0.4.0MITupdated 2026-10-26Intrect-io/claude-mod/goal-mode
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · goal-mode
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /goal-mode ⎿ goal-mode: No goal set. Claude sets one itself when a task has a verifiable end state. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

goal-mode

Lets Claude set its own session goal, then keeps it working until an independent evaluator confirms the goal is met.

  • Setter: the model calls 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).
  • Evaluator: at every main-thread Stop, 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.
  • Pauses (stop is allowed, a typed message resumes): evaluator failure, two unreadable verdicts, 5 unmet checks in one turn, or 30 checks in total.
  • User commands:
  • /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.
  • The text may name a direction instead of an end state, e.g. /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 stay beside the refined goal (Your words: in /goal-mode).
  • The evaluator judges the refined goal against those words too, so a refinement that dodges them is not met.
  • A second change is refused.
  • /goal-mode shows the goal.
  • /goal-mode clear removes it.
  • The model cannot clear or replace a goal.

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.

Loop mode (unattended)

/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:

  1. creates worktrees/goal-NN-<slug> on branch goal/NN-<slug>;
  2. sends a kickoff prompt;
  3. at every Stop, runs the line's check itself in the worktree (non-zero exit blocks with its output, no model call), refuses uncommitted changes, then asks the evaluator;
  4. on success marks the line [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:

  • AskUserQuestion and EnterPlanMode;
  • edits inside the repository but outside the worktree;
  • pushes other than an explicit goal/* branch, and force pushes;
  • git reset --hard, sudo, deploy and cluster commands, and rm -rf outside the worktree;
  • any regex listed in .claude/goal-loop.deny.

Other notes:

  • Log: .claude/goal-loop.log (JSONL).
  • Excluded from git automatically via .git/info/exclude: the queue, log, deny file and worktrees/.
  • Branches are never merged automatically; review and merge them yourself.
  • /goal-mode loop off|status|resume: resume takes over a loop whose session went away. A resumed (claude --continue) owner session continues by itself.
  • Only one session may run a repository's loop at a time: another session active in the last 10 minutes keeps it.

Auto-declared goals (0.3.0)

The model is nudged to set its own goal at two points, with no extra model call.

  • When a plan is approved: right after 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.
  • On a typed prompt: when the prompt asks for an outcome. That means a request verb such as 고쳐/구현/추가/통과/fix/implement/migrate, at least 30 characters, not a question, and not a slash command.

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.

Source 5 files
hooks/register.tsx 1222 lines
1import { 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 lines
1import 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}
24
hooks/guard.ts 112 lines
1// 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}
112
hooks/queue.ts 79 lines
1// 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}
79
types/index.d.ts 48 lines
1export 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