SLOPSHOPPER

workbench-core

Core infrastructure for Claude Code: persistent agent identity, session lifecycle hooks, operational memory, and meta skills. Provides a customizable framework…

newpaneguardcommandtoaststatus
v0.44.1no licenseupdated 2026-10-09mike-bronner/workbench-core
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · workbench-core
│ ┃ Warmup notices ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ No warmup notices file yet. │ workbench-core │ │ ⏺ Read(src/auth.ts) │ Orchestrator mode is ON for this session: │ │ ⎿ Read 6 lines │ the first whole-file write draws a │ │ ⏺ Update(src/auth.ts) │ reminder, and Agent dispatches need the │ │ ⎿ Added 2 lines, re╰────────────────────────────────────────────╯ │ ⏺ Bash(rm -rf build &&╭────────────────────────────────────────────╮ │ ⎿ Denied by workben│ workbench-core │ │ │ The summary dispatch failed. See │ │ ● Done. refresh now re│ summary-dispatch-errors.log in the memory │ │ │ cache. │ │ ✻ Worked for 42s · don╰────────────────────────────────────────────╯ │ │ › /orchestrator │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ workbench-core: T1 │ orch on · rows 3/40

Draws

Pane · Warmup notices
No warmup notices file yet.
Source 24 files
hooks/register.ts 1409 lines
1// workbench-core's hooks module, beside the command hooks in hooks.json. A
2// bash guard moves here once its port holds parity with its frozen copy under
3// tests/oracle/, and its command hook is then removed.
4//
5//   $.workbench     the noun other plugins build on: the brief, the scratch
6//                   roots, orchestrator mode, the lane, the shell reader
7//                   (types/index.d.ts is its contract)
8//   question rule   every question to Mike goes through AskUserQuestion, with
9//                   its context in prose right above the call
10//   request meter   turns, and each API request's cost, on the status line
11//   cache meter     each request's cache read against its cache creation, and
12//                   the system-prompt section that changed at a creation spike
13//   status line     beside the meter: memory health, orchestrator mode, reply
14//                   rows, learnings due for compaction, warmup notices
15//   commands        /orchestrator, /memory-status, /notices and
16//                   /process-pending-summaries, answered here with no model turn
17//   commit approval in Mike's session, one "Commit it" pick in AskUserQuestion,
18//                   asked alone, allows one commit and the push of that commit
19//   vault writes    the memory MCP's write, edit and append get a vault-relative
20//                   path, valid frontmatter on a new note, and path links
21//   deferred start  the warmup's pending-summary drain and Chat-skill scan,
22//                   started once the SessionStart hooks are done
23//   log checkpoint  hooks/session-log.sh after each main-loop turn, interrupted
24//                   ones included, and at session end, SIGHUP and SIGTERM too
25//   memory capture  the live session's durable findings, asked of a fork and
26//                   written to the vault, with no turn shown and no question
27//   recall          vault hits for a prompt or a content search, filtered and
28//                   injected at the tail, never into the system prompt
29//   learnings       a skill's vault learnings, merged into its text
30//   intake nudge    the first Edit of a task with no intake block on screen
31//   guards          the peer message gate, the provisioning, summary-writer,
32//                   credential, whole-disk search and outbound prose guards,
33//                   judged before a tool call runs, and refusing when they
34//                   cannot judge
35//   prompt rules    the workbench rules as shared system-prompt sections, the
36//                   same bytes in every session, and a sub-agent's copy at its
37//                   start; the harness's memory section and the block an older
38//                   warmup spliced into CLAUDE.md are left out
39//
40// The logic is pure and lives in mods/. Every hook that touches `$` lives in
41// this file, because the engine follows `$` into no imported function, and a
42// plugin registers each event once: so each event below has one hook, and the
43// hook branches where several features share the event.
44
45import { atom, read, update } from 'claude-code'
46import type { EngineInterface, FsEntry, InstructionFile, PromptComposeSection, Register, RenderElement, ToolCallResult, TurnUsage } from 'claude-code'
47
48import type { CacheState, ChurnEvent, PaneContent, WorkbenchCallerLane, WorkbenchShellParse } from '../types'
49import { BRIEF_SLOTS, checkBrief } from './mods/brief'
50import type { CaptureNote } from './mods/capture'
51import { FIRST, REPEAT, capturePrompt, duplicateOf, isCaptureDue, isNotFound, notesOf, savedText, thresholdOf } from './mods/capture'
52import type { CheckpointMode } from './mods/checkpoint'
53import { checkpointRequest, endTimeoutOf } from './mods/checkpoint'
54import { NUDGE, intakeShown } from './mods/intake'
55import { learningsPath, withLearnings } from './mods/learnings'
56import {
57  HARNESS_MEMORY,
58  OMITS_CLAUDE_MD,
59  contributionPathsOf,
60  contributionsOf,
61  promptLaneOf,
62  sectionsFor,
63  splicedBodiesOf,
64  subagentContextOf,
65  withShared,
66  withoutSplice,
67} from './mods/prompt-rules'
68import type { Hit } from './mods/recall'
69import {
70  CLASSIFY_TIMEOUT_MS,
71  FETCH_FACTOR,
72  LABELS as RELEVANCE_LABELS,
73  PROMPT_LIMIT,
74  PROMPT_MIN_SCORE,
75  SCAN_LIMIT,
76  SCAN_MIN_SCORE,
77  blockOf,
78  candidatesOf,
79  hitsOf,
80  mayScan,
81  promptQuery,
82  relevanceText,
83  relevantOf,
84  scanQuery,
85  tokensOf,
86} from './mods/recall'
87import {
88  EMPTY_CACHE,
89  cacheFactsOf,
90  cacheRequestOf,
91  changedSections,
92  churnText,
93  countCache,
94  hashesOf,
95  isSpike,
96  recordFileOf,
97  recordOf,
98  withChurn,
99} from './mods/cache-meter'
100import type { Refs } from './mods/commit-approval'
101import { BUNDLE_REFUSAL, approvalAfter, bundlesCommit, dirOf, isCommitPick, readLine, refusalOf } from './mods/commit-approval'
102import { isAttendedPrompt, isAttendedSession, isScheduledFire, laneOf } from './mods/lane'
103import { parseShell } from './mods/shell'
104import {
105  AGENT_WORKTREE_REFUSAL,
106  ENTER_WORKTREE_REFUSAL,
107  EXIT_WORKTREE_REFUSAL,
108  GUARDED,
109  PEER_ADVICE,
110  PEER_REFUSAL,
111  SEARCH_UNREAD,
112  bodyTargets,
113  credentialPathRefusal,
114  credentialRefusal,
115  hiddenCommandRefusal,
116  isPartlyRead,
117  mentionsSearch,
118  peerVerdict,
119  provisioningRefusal,
120  resolvePath,
121  searchRefusal,
122  searchRoots,
123  summaryWriterRefusal,
124  toolSearchRoots,
125} from './mods/guards'
126import type { SearchRoot } from './mods/guards'
127import { SQL_FILE_CAP, databaseRefusal, fileKey } from './mods/destructive-database'
128import type { Fact, FactGetter, ScopeContext, ScopeVerdict } from './mods/destructive-scope'
129import { NO_ONE_TO_ASK, dirKey, getterOf, needsScope, scopeVerdict, stepOf } from './mods/destructive-scope'
130import { needsVaultGit, vaultGitRefusal } from './mods/vault-git'
131import type { BodyPart } from './mods/outbound-prose'
132import { UNREAD_BODY, bashBodies, isProseTool, mcpBody, proseFindings, proseOfJson, proseRefusal } from './mods/outbound-prose'
133import {
134  REFUSAL,
135  STORE_KEY,
136  USAGE,
137  isPersonOrigin,
138  legacyFileOf,
139  offSessionsOf,
140  reportOf,
141  toggleOf,
142  withMode,
143} from './mods/orchestrator'
144import {
145  ASKS_NOTHING,
146  ASKS_USER,
147  LABELS,
148  REFUSAL_REASON,
149  REPROMPT_REASON,
150  classifierText,
151  endsOnQuestion,
152  hasCandidate,
153  hasContextBefore,
154  proseOf,
155} from './mods/question-rule'
156import { NAME as PENDING, REFUSAL as PENDING_REFUSAL, USAGE as PENDING_USAGE, outcomeOf, reportOf as pendingReportOf, requestOf } from './mods/pending-summaries'
157import { EMPTY, countRequest, countTurn, statusOf } from './mods/request-meter'
158import { countOf, factsOf, healthOf, learningsAfter, lineOf, noticesOf, rowsOf, skillNameOf } from './mods/status-line'
159import type { VaultTool } from './mods/vault-write'
160import {
161  TEXT_FIELD,
162  fixPath,
163  frontmatterProblems,
164  frontmatterRefusal,
165  isNote,
166  needsRoot,
167  resolvedOf,
168  rewriteLinks,
169  vaultToolOf,
170  wikiTargets,
171} from './mods/vault-write'
172
173const meter = atom({ plugin: 'workbench-core', key: 'meter' } as const, EMPTY)
174const cache = atom({ plugin: 'workbench-core', key: 'cache' } as const, EMPTY_CACHE)
175// Whether a person opened the current turn, so the question rule applies.
176const turnAttended = atom({ plugin: 'workbench-core', key: 'turnAttended' } as const, false)
177// Whether the question rule already re-prompted in the current turn.
178const reprompted = atom({ plugin: 'workbench-core', key: 'reprompted' } as const, false)
179// Where Mike's last "Commit it" pick stands: unused, used by its commit with
180// the push left, or used up. Any prompt ends an unused pick, and Mike's own
181// prompt ends the push left too.
182const commitApproval = atom({ plugin: 'workbench-core', key: 'commitApproval' } as const, 'none')
183// Whether a schedule opened the current turn, so a commit in it is not gated.
184const turnScheduled = atom({ plugin: 'workbench-core', key: 'turnScheduled' } as const, false)
185const learnings = atom({ plugin: 'workbench-core', key: 'learnings' } as const, {})
186const replyRows = atom({ plugin: 'workbench-core', key: 'replyRows' } as const, null)
187const notices = atom({ plugin: 'workbench-core', key: 'notices' } as const, [])
188const pane = atom({ plugin: 'workbench-core', key: 'pane' } as const, { title: '', text: '' })
189const capture = atom({ plugin: 'workbench-core', key: 'capture' } as const, { turns: 0, hasFired: false, written: [] })
190const recall = atom({ plugin: 'workbench-core', key: 'recall' } as const, { seen: [], queries: [] })
191const intake = atom({ plugin: 'workbench-core', key: 'intake' } as const, { task: 0, checked: 0 })
192const TRANSCRIPT = { plugin: 'workbench-core', key: 'transcriptPath' } as const
193// Unset until known: a status entry for a fact not known yet is left out.
194const ORCHESTRATOR_ON = { plugin: 'workbench-core', key: 'orchestratorOn' } as const
195const MEMORY_HEALTH = { plugin: 'workbench-core', key: 'memoryHealth' } as const
196const STARTED_AT = { plugin: 'workbench-core', key: 'startedAt' } as const
197const NOTICES_MTIME = { plugin: 'workbench-core', key: 'noticesMtime' } as const
198
199// The one pane: /memory-status and /notices each fill it.
200const PANE = 'workbench-core'
201const COMMANDS = [
202  { name: 'orchestrator', description: 'Orchestrator mode for this session: on, off, or status', argumentHint: '[on|off|status]' },
203  { name: 'memory-status', description: "The shared memory server's facts, in a pane" },
204  { name: 'notices', description: 'The warmup notices from this session start, in a pane' },
205  {
206    name: PENDING,
207    description: 'Dispatch summary-writers for the pending session summaries, or for one session by id',
208    argumentHint: '[<session-id> [--overwrite]]',
209  },
210] as const
211const OURS: ReadonlySet<string> = new Set(COMMANDS.map(command => command.name))
212// How long after session start the first memory probe and the first notices
213// read run, and the second read: memory-server-up.sh can hold SessionStart up to
214// 15 s before session-warmup.sh writes the notices file. Each probe after that
215// reads the file again when it changed.
216const SETTLE_MS = 3000
217const NOTICES_LATE_MS = 20_000
218// Every session reads the one notices file, and every session start rewrites
219// it. A rewrite later than this after this session's start is another
220// session's, so it is not shown here.
221const NOTICES_WINDOW_MS = 120_000
222const PROBE_EVERY_MS = 60_000
223// The warmup's deferred half starts as soon as the SessionStart hooks are done,
224// and may take this long: a few detached spawns and a scan of the plugins.
225// session-warmup.sh's DEFERRED_TIMEOUT_S states the same figure: a deferred
226// run older than it is taken as dead.
227const DEFERRED_MS = 0
228const DEFERRED_TIMEOUT_MS = 120_000
229
230// Whether a finished reply leaves a question for Mike in prose. A classifier
231// that fails, or answers with neither label, falls back to the deterministic
232// reading, so a question is never let through only because the classifier
233// could not answer.
234async function asksInProse($: EngineInterface, reply: string): Promise<boolean> {
235  const prose = proseOf(reply)
236  if (!hasCandidate(prose)) return false
237  try {
238    const label = await $.model.classify(classifierText(prose), LABELS)
239    if (label === ASKS_USER) return true
240    if (label === ASKS_NOTHING) return false
241  } catch {
242    // The deterministic reading below decides.
243  }
244  return endsOnQuestion(prose)
245}
246
247// Whether this load drew a status line yet. A clear with nothing drawn is
248// skipped, so a session start with nothing to show draws nothing.
249let hasDrawn = false
250
251// The status line: the meter, then every workbench fact known so far.
252async function drawStatus($: EngineInterface): Promise<void> {
253  const figures = await read($, meter)
254  const { value: isOn } = await $.state.get(ORCHESTRATOR_ON)
255  const { value: health } = await $.state.get(MEMORY_HEALTH)
256  const rows = await read($, replyRows)
257  const facts = factsOf({
258    memoryHealth: health,
259    orchestratorOn: isOn,
260    replyRows: rows ?? undefined,
261    learnings: await read($, learnings),
262    notices: await read($, notices),
263  })
264  const isMetered = figures.turns > 0 || figures.first !== null
265  const line = lineOf(isMetered ? statusOf(figures) : undefined, [...cacheFactsOf(await read($, cache)), ...facts])
266  if (line === undefined && !hasDrawn) return
267  hasDrawn = true
268  $.ui.status(line)
269}
270
271// The legacy file the bash gates read for this session, or undefined when the
272// session id cannot name one.
273async function legacyFile($: EngineInterface): Promise<string | undefined> {
274  return legacyFileOf(await $.session.id(), await $.env.get('WORKBENCH_ORCHESTRATOR_STATE_DIR'), await $.env.get('HOME'))
275}
276
277// Orchestrator mode for this session. Seeded once, from the mode stored for
278// this session id, else from the legacy file an older build may have left.
279async function orchestratorOn($: EngineInterface): Promise<boolean> {
280  const { value } = await $.state.get(ORCHESTRATOR_ON)
281  if (value !== undefined) return value
282  const id = await $.session.id()
283  const stored = offSessionsOf(await $.store.get(STORE_KEY), await $.clock.now())
284  const file = await legacyFile($)
285  const isOn = !(id in stored) && !(file !== undefined && isOffFile(await legacyEntry($, file)))
286  await $.state.set(ORCHESTRATOR_ON, isOn)
287  return isOn
288}
289
290// The legacy file's directory entry as it stands, a symbolic link included and
291// never followed, or undefined when there is none.
292async function legacyEntry($: EngineInterface, file: string): Promise<FsEntry | undefined> {
293  const cut = file.lastIndexOf('/')
294  const entries = await $.fs.list(file.slice(0, cut)).catch(() => [])
295  return entries.find(entry => entry.name === file.slice(cut + 1))
296}
297
298// What counts as off, here and in both bash gates (`[ -f ] && [ ! -L ]`): a
299// regular file that is not a symbolic link. A directory or a link planted at
300// the path is not a mode anyone chose.
301const isOffFile = (entry: FsEntry | undefined): boolean => entry !== undefined && entry.kind === 'file' && !entry.isLink
302
303// Puts the legacy path in line with the mode: a regular file when off, nothing
304// when on. Anything else there, a directory or a symbolic link, is removed with
305// `rm -rf -- <path>`, which removes that one entry and never follows a link,
306// and is never written through. The entry is listed again before the write, so
307// a link or directory planted after the removal is left unwritten: the gates
308// then find no regular file and stay on. A link planted in the instant between
309// that listing and the write is the one race left, as $.fs.write has no
310// no-follow mode.
311async function mirror($: EngineInterface, isOn: boolean): Promise<void> {
312  const file = await legacyFile($)
313  if (file === undefined) return
314  const entry = await legacyEntry($, file)
315  if (!isOn && isOffFile(entry)) return
316  if (entry !== undefined) await $.process.run(['rm', '-rf', '--', file])
317  if (isOn) return
318  if ((await legacyEntry($, file)) === undefined) await $.fs.write(file, '')
319}
320
321async function setOrchestrator($: EngineInterface, isOn: boolean): Promise<void> {
322  const id = await $.session.id()
323  const now = await $.clock.now()
324  await $.state.set(ORCHESTRATOR_ON, isOn)
325  await $.store.set(STORE_KEY, withMode(offSessionsOf(await $.store.get(STORE_KEY), now), id, isOn, now))
326  await mirror($, isOn)
327}
328
329async function probeMemory($: EngineInterface): Promise<void> {
330  const { stdout } = await $.process.run(['bash', `${$.plugin.root}/scripts/memory-health.sh`], { timeoutMs: 15_000 })
331  const health = healthOf(stdout)
332  if (health !== undefined) await $.state.set(MEMORY_HEALTH, health)
333  await drawStatus($)
334}
335
336const noticesFile = async ($: EngineInterface): Promise<string | undefined> => {
337  const home = await $.env.get('HOME')
338  return home ? `${home}/.claude-workbench/warmup-notices.md` : undefined
339}
340
341// The warmup notices reach Mike here, never through the model: a toast naming
342// each one, a count on the status line, and the whole file behind /notices.
343// A file older than the session is the previous session's, still waiting for
344// session-warmup.sh to rewrite it, so it is left for a later read. A file read
345// once is read again only when its mtime changes, and only within
346// NOTICES_WINDOW_MS of the start: a later rewrite is another session's.
347async function deliverNotices($: EngineInterface): Promise<void> {
348  const file = await noticesFile($)
349  if (file === undefined || !(await $.fs.exists(file))) return
350  const { mtimeMs } = await $.fs.stat(file)
351  const { value: startedAt = 0 } = await $.state.get(STARTED_AT)
352  const { value: shown } = await $.state.get(NOTICES_MTIME)
353  if (mtimeMs < startedAt || mtimeMs > startedAt + NOTICES_WINDOW_MS || mtimeMs === shown) return
354  const headings = noticesOf(await $.fs.read(file))
355  await $.state.set(NOTICES_MTIME, mtimeMs)
356  await update($, notices, () => headings)
357  await drawStatus($)
358  if (headings.length > 0) $.ui.toast(`Warmup notices: ${headings.join('; ')}. Run /notices to read them.`, { timeoutMs: 10_000 })
359}
360
361async function showPane($: EngineInterface, content: PaneContent): Promise<void> {
362  await update($, pane, () => content)
363  await $.ui.open({ id: PANE, title: content.title })
364}
365
366// Whether the commit approval rule applies to a call: the main loop of an
367// interactive session, in any turn but a scheduled one. It reads the session
368// and the lane, never the question rule's turn reading: a peer, channel, SDK or
369// plugin turn in Mike's session is gated, because it carries outside text and
370// a deny, unlike a re-prompt, cannot loop. The exemptions are the lanes that
371// commit unattended by design:
372//   - a session nobody sits at (`claude -p`, the SDK, a top-level --agent run,
373//     WORKBENCH_DEV_TEAM_PIPELINE=1), from session.start; unknown before it,
374//     which is read as attended, the gate's side
375//   - the pipeline's flag, read here as well, so no lane answer can gate it
376//   - a sub-agent or top-level agent, from $.workbench.callerLane; a rejection
377//     is read as `main`, the gate's side
378//   - a turn a schedule opened (the origin, or the `<scheduled-task ` wrapper)
379async function isCommitGated($: EngineInterface, sessionAttended: boolean | undefined, agentId: string | undefined): Promise<boolean> {
380  if (sessionAttended === false || (await $.env.get('WORKBENCH_DEV_TEAM_PIPELINE')) === '1') return false
381  const lane = await $.workbench.callerLane(agentId === undefined ? {} : { agentId }).catch((): WorkbenchCallerLane => 'main')
382  if (lane !== 'main') return false
383  return !(await read($, turnScheduled))
384}
385
386// HEAD and the push target of the repository at `dir`, each undefined when git
387// cannot say. Read with $.process.run, which runs git with repo hooks off.
388async function refsOf($: EngineInterface, dir: string): Promise<Refs> {
389  const ref = (name: string) =>
390    $.process
391      .run(['git', '-C', dir, 'rev-parse', '--verify', '--quiet', name], { timeoutMs: 10_000 })
392      .then(({ exitCode, stdout }) => (exitCode === 0 && stdout.trim() !== '' ? stdout.trim() : undefined), () => undefined)
393  return { head: await ref('HEAD'), pushed: await ref('@{push}') }
394}
395
396type VaultCall = Record<string, unknown> & { path?: unknown }
397
398// The vault write checks on one memory MCP call: the call to pass on, fixed
399// where it needed fixing, or the refusal. scripts/vault-resolve.sh runs only
400// when the call needs the vault root or holds a [[link]]. When it cannot run,
401// an absolute path is refused and every link is left as written.
402async function checkVaultWrite($: EngineInterface, e: VaultCall, tool: VaultTool): Promise<{ deny: string } | { call: VaultCall }> {
403  const { path } = e
404  if (typeof path !== 'string') return { call: e }
405  const field = TEXT_FIELD[tool]
406  const text = typeof e[field] === 'string' ? (e[field] as string) : undefined
407  const targets = text !== undefined && isNote(path) ? wikiTargets(text) : []
408  const createsNote = isNote(path) && (tool === 'write' || (tool === 'append' && e.create_if_missing === true))
409  const facts =
410    needsRoot(path) || targets.length > 0 || (tool === 'append' && createsNote)
411      ? resolvedOf(
412          await $.process
413            .run(['bash', `${$.plugin.root}/scripts/vault-resolve.sh`, ...targets], { timeoutMs: 10_000 })
414            .then(({ stdout }) => stdout, () => ''),
415        )
416      : resolvedOf('')
417  const fixed = fixPath(path, facts.root, await $.env.get('HOME'))
418  if ('refusal' in fixed) return { deny: fixed.refusal }
419  // A write replaces the whole note, frontmatter included. An append creates
420  // one only where none is, and a note it cannot see is taken as new.
421  const isNew =
422    tool === 'write' ||
423    (createsNote && (facts.root === undefined || !(await $.fs.exists(`${facts.root}/${fixed.path}`).catch(() => false))))
424  if (createsNote && isNew) {
425    const problems = frontmatterProblems(tool === 'write' ? e.frontmatter : undefined, text)
426    if (problems.length > 0) return { deny: frontmatterRefusal(problems) }
427  }
428  const linked = text !== undefined && facts.paths.size > 0 ? rewriteLinks(text, facts.paths) : text
429  if (fixed.path === path && linked === text) return { call: e }
430  return { call: { ...e, path: fixed.path, ...(linked !== text ? { [field]: linked } : {}) } }
431}
432
433// One main-loop request on the cache meter. The system prompt is read on the
434// first request, for the baseline, and on every request that created cache:
435// at a creation spike, to name the sections that changed since the last
436// reading, and on any other, so a change that cost no spike is not blamed on a
437// later one. A request that only read the cache could not follow a prompt
438// change, so it is not read then. A prompt that cannot be read names none, and
439// the next reading compares with the last one that could. Only
440// a spike that names a section is shown, and only to a person at the session;
441// the record file is kept for those sessions, for measuring a change.
442async function meterCache($: EngineInterface, usage: TurnUsage, isAttended: boolean): Promise<void> {
443  const before = await read($, cache)
444  const request = cacheRequestOf(before.count + 1, usage)
445  let state: CacheState = countCache(before, request)
446  if (request.n === 1 || request.creation > 0) {
447    const hashes = await $.prompt.compose().then(({ sections }) => hashesOf(sections), () => null)
448    if (isSpike(request)) {
449      const sections = hashes === null || before.hashes === null ? null : changedSections(before.hashes, hashes)
450      const event: ChurnEvent = { n: request.n, read: request.read, creation: request.creation, sections }
451      state = withChurn(state, event)
452      if (isAttended && sections !== null && sections.length > 0) $.ui.toast(churnText(event), { timeoutMs: 10_000 })
453    }
454    if (hashes !== null) state = { ...state, hashes }
455  }
456  await update($, cache, () => state)
457  if (!isAttended) return
458  const home = await $.env.get('HOME')
459  const file = home === undefined ? undefined : recordFileOf(home, await $.session.id())
460  if (file !== undefined) await $.fs.write(file, recordOf(state)).catch(() => undefined)
461}
462
463// The warmup's deferred half (hooks/session-warmup.sh --deferred): the
464// pending-summary drain and the Chat-skill scan. Its output reaches nobody.
465async function deferredWarmup($: EngineInterface, payload: string): Promise<void> {
466  await $.process.run(['bash', `${$.plugin.root}/hooks/session-warmup.sh`, '--deferred'], { stdin: payload, timeoutMs: DEFERRED_TIMEOUT_MS })
467}
468
469// /process-pending-summaries: the script's outcome, reported in a toast. It
470// asks nothing: the script decides whether a summary is redone.
471async function processPendingSummaries($: EngineInterface, args: string): Promise<void> {
472  const request = requestOf(args)
473  if (request === undefined) {
474    $.ui.toast(PENDING_USAGE)
475    return
476  }
477  const { sid } = request
478  const argv = sid === undefined ? [] : request.overwrite ? [sid, '--overwrite'] : [sid]
479  const outcome = outcomeOf(
480    await $.process
481      .run(['bash', `${$.plugin.root}/scripts/process-pending-summaries.sh`, ...argv], { timeoutMs: 60_000 })
482      .then(({ stdout }) => stdout, () => ''),
483  )
484  $.ui.toast(pendingReportOf(outcome, sid), { timeoutMs: 10_000 })
485}
486
487// The memory server's name as $.mcp.call takes it: core's own manifest server,
488// connected on first use. Kept for the module's life; a reload asks again.
489let memoryServerName: string | undefined
490async function memoryServer($: EngineInterface): Promise<string> {
491  if (memoryServerName !== undefined) return memoryServerName
492  const connected = await $.mcp.connect('memory')
493  if (!connected.isConnected) throw new Error(`memory: ${connected.message}`)
494  memoryServerName = connected.server
495  return memoryServerName
496}
497
498// One log checkpoint through hooks/session-log.sh (hooks/mods/checkpoint.ts).
499// A session whose transcript is unknown, or names another session, is left to
500// the settings hooks and the start-up reconciler.
501async function checkpointLog($: EngineInterface, sessionId: string, mode: CheckpointMode, reason: string | undefined, timeoutMs: number): Promise<void> {
502  const { value: transcript } = await $.state.get(TRANSCRIPT)
503  const request = checkpointRequest(sessionId, transcript, mode, reason)
504  if (request === undefined) return
505  await $.process.run(['bash', `${$.plugin.root}/hooks/session-log.sh`], { ...request, timeoutMs })
506}
507
508// The memory capture checkpoint (hooks/mods/capture.ts): a fork of the session
509// lists what it learned, and each note that passes the checks is written
510// through the memory MCP. No turn is shown and nothing is asked. Mike sees one
511// toast when a note is saved, and nothing when none is.
512//
513// $.mcp.call passes no tool.call hook of this module, so each write goes
514// through checkVaultWrite here: the vault write checks a model's write gets,
515// [[link]] rewrite included. A note is written only when the vault holds
516// nothing like it: a search on its name finds no duplicate (another session
517// may have saved it), and a read of its path answers a definite "not found".
518// Any other answer, an error included, skips the note.
519async function captureNote($: EngineInterface, server: string, note: CaptureNote): Promise<boolean> {
520  const found = await $.mcp.call(server, 'search', { query: note.frontmatter.name, limit: 3 })
521  if (found.isError) return false
522  const duplicate = duplicateOf(note, hitsOf(found.content))
523  if (duplicate !== undefined) {
524    // One line per skip, so the share of captures held back can be measured.
525    $.ui.log(`workbench capture: skipped "${note.frontmatter.name}" (${duplicate})`, { to: 'debug' })
526    return false
527  }
528  if (!isNotFound(await $.mcp.call(server, 'read', { path: note.path }))) return false
529  const checked = await checkVaultWrite($, { path: note.path, content: note.content, frontmatter: note.frontmatter }, 'write')
530  if ('deny' in checked) return false
531  return !(await $.mcp.call(server, 'write', checked.call)).isError
532}
533
534async function captureMemory($: EngineInterface): Promise<void> {
535  const reply = await $.model.fork({ prompt: capturePrompt((await read($, capture)).written) })
536  if (!reply.isAnswered) return
537  const date = new Date(await $.clock.now()).toISOString().slice(0, 10)
538  const notes = notesOf(reply.text, date).filter(note => frontmatterProblems(note.frontmatter, note.content).length === 0)
539  if (notes.length === 0) return
540  const server = await memoryServer($)
541  const written: string[] = []
542  for (const note of notes) {
543    if (await captureNote($, server, note).catch(() => false)) written.push(note.path)
544  }
545  if (written.length === 0) return
546  await update($, capture, state => ({ ...state, written: [...state.written, ...written] }))
547  $.ui.toast(savedText(written), { timeoutMs: 10_000 })
548}
549
550// Whether this session counts turns toward a capture: a session a person sits
551// at, in a turn no schedule opened, with the old hook's switches honoured
552// (WORKBENCH_CAPTURE_STOP=0 and WORKBENCH_MEMORY_NUDGE=0 turn it off), and
553// never in a summary-writer.
554async function isCaptureOn($: EngineInterface, sessionAttended: boolean | undefined): Promise<boolean> {
555  if (sessionAttended !== true || (await read($, turnScheduled))) return false
556  if ((await $.env.get('WORKBENCH_CAPTURE_STOP')) === '0' || (await $.env.get('WORKBENCH_MEMORY_NUDGE')) === '0') return false
557  return (await $.env.get('WORKBENCH_SUMMARY_WRITER')) !== '1'
558}
559
560// The relevance labels for `hits`, one each, or undefined when the classifier
561// failed or ran past CLASSIFY_TIMEOUT_MS: the caller then keeps every hit.
562async function labelsOf($: EngineInterface, task: string, hits: readonly Hit[]): Promise<(string | undefined)[] | undefined> {
563  const pass = Promise.all(hits.map(hit => $.model.classify(relevanceText(task, hit), RELEVANCE_LABELS))).catch(() => undefined)
564  const timeout = $.clock.sleep(CLASSIFY_TIMEOUT_MS).then(
565    () => undefined,
566    () => undefined,
567  )
568  return Promise.race([pass, timeout])
569}
570
571// One recall (hooks/mods/recall.ts): the vault search through the memory MCP,
572// the threshold, the types and the session's dedupe set, then the relevance
573// pass. The hits it shows join the dedupe set. Its figures go to the debug log.
574async function recallBlock($: EngineInterface, query: string, limit: number, minScore: number, scan?: string): Promise<string | undefined> {
575  const started = await $.clock.now()
576  const server = await memoryServer($)
577  const found = await $.mcp.call(server, 'search', { query, limit: limit * FETCH_FACTOR })
578  if (found.isError) return undefined
579  const candidates = candidatesOf(hitsOf(found.content), (await read($, recall)).seen, limit, minScore)
580  if (candidates.length === 0) return undefined
581  const labels = await labelsOf($, scan ?? query, candidates)
582  const kept = labels === undefined ? candidates : relevantOf(candidates, labels)
583  if (kept.length === 0) return undefined
584  await update($, recall, state => ({ ...state, seen: [...state.seen, ...kept.map(hit => hit.path)] }))
585  const block = blockOf(kept, scan)
586  const ms = (await $.clock.now()) - started
587  const fallback = labels === undefined ? ', classifier fallback' : ''
588  $.ui.log(`workbench recall: ${kept.length} of ${candidates.length} hits, about ${tokensOf(block)} tokens, ${ms} ms${fallback}`, { to: 'debug' })
589  return block
590}
591
592// Whether recall and the nudges stay out: a lane nobody answers in, as
593// $.workbench.isUnattended reads it, or a lane it cannot read.
594const isQuiet = ($: EngineInterface): Promise<boolean> => $.workbench.isUnattended().catch(() => true)
595
596// Recall on a prompt: a new turn a person opened, with a query worth a search.
597// The search attempt is stamped where session-warmup.sh's recall liveness
598// check reads it, as hooks/memory-recall.sh did.
599async function promptRecall($: EngineInterface, text: string): Promise<string | undefined> {
600  if ((await $.env.get('WORKBENCH_MEMORY_RECALL')) === '0' || (await isQuiet($))) return undefined
601  const query = promptQuery(text)
602  if (query === undefined) return undefined
603  const home = await $.env.get('HOME')
604  const stateDir = (await $.env.get('WORKBENCH_MEMORY_RECALL_STATE')) || (home ? `${home}/.claude-workbench/memory-recall` : undefined)
605  const now = Math.floor((await $.clock.now()) / 1000)
606  if (stateDir !== undefined) await $.fs.write(`${stateDir}/last-attempt`, `${now}\n`).catch(() => undefined)
607  return recallBlock($, query, PROMPT_LIMIT, PROMPT_MIN_SCORE)
608}
609
610// Recall on a content search in the main loop (grep, rg, ag, ack, git grep in
611// a Bash call; this CLI has no Grep tool): the search's own query, read by
612// hooks/lib/scan-query.py, once per query per session.
613async function scanRecall($: EngineInterface, tool: 'Bash', raw: string): Promise<string | undefined> {
614  if ((await $.env.get('WORKBENCH_MEMORY_RECALL')) === '0' || (await $.env.get('WORKBENCH_MEMORY_SCAN_RECALL')) === '0') return undefined
615  if (!mayScan(raw) || (await isQuiet($))) return undefined
616  const { stdout } = await $.process.run(['python3', `${$.plugin.root}/hooks/lib/scan-query.py`, tool], { stdin: raw, timeoutMs: 5_000 })
617  const query = scanQuery(stdout)
618  if (query === undefined || (await read($, recall)).queries.includes(query)) return undefined
619  await update($, recall, state => ({ ...state, queries: [...state.queries, query] }))
620  return recallBlock($, query, SCAN_LIMIT, SCAN_MIN_SCORE, query)
621}
622
623// A tool's result with recall's block beside it, when the call was a main-loop
624// content search with something to recall. A refused or failed call gets none.
625async function withScanRecall<R extends ToolCallResult>($: EngineInterface, tool: 'Bash', raw: unknown, agentId: string | undefined, result: R): Promise<R> {
626  if (agentId !== undefined || typeof raw !== 'string' || result.deny !== undefined || result.isError === true) return result
627  const block = await scanRecall($, tool, raw).catch(() => undefined)
628  return block === undefined ? result : { ...result, context: [...(result.context ?? []), block] }
629}
630
631// The intake nudge (hooks/mods/intake.ts): checked once per task, on the
632// task's first Edit in the main loop, and only in a lane a person answers.
633async function isIntakeDue($: EngineInterface, sessionAttended: boolean | undefined): Promise<boolean> {
634  if (sessionAttended !== true || (await isQuiet($))) return false
635  const { task, checked } = await read($, intake)
636  if (task === 0 || checked === task) return false
637  await update($, intake, state => ({ ...state, checked: task }))
638  return !intakeShown(await $.session.messages())
639}
640
641// The vault root, from scripts/vault-resolve.sh: read once per load.
642let vaultRootMemo: string | undefined
643async function vaultRoot($: EngineInterface): Promise<string | undefined> {
644  if (vaultRootMemo !== undefined) return vaultRootMemo
645  const { stdout } = await $.process.run(['bash', `${$.plugin.root}/scripts/vault-resolve.sh`], { timeoutMs: 10_000 })
646  vaultRootMemo = resolvedOf(stdout).root
647  return vaultRootMemo
648}
649
650// The workbench sections for this load (hooks/mods/prompt-rules.ts), read once
651// and kept: every prompt.compose and SubagentStart answers from the one
652// reading, so the bytes cannot change within a load. session.start reads them
653// again, as a reload does. A reading that fails gives the rules alone, the
654// agent lane's set: losing the rules is the worse failure, and a lane that
655// cannot be read may be an unattended one, which gets no memory routing.
656let sectionsMemo: Promise<PromptComposeSection[]> | undefined
657function workbenchSections($: EngineInterface): Promise<PromptComposeSection[]> {
658  sectionsMemo ??= readSections($).catch(() => sectionsFor('agent', undefined))
659  return sectionsMemo
660}
661
662// The lane from the environment, and each sibling plugin's session-warmup.md.
663// A file that cannot be read is left out, and the rules stand without it.
664async function readSections($: EngineInterface): Promise<PromptComposeSection[]> {
665  const lane = promptLaneOf(await $.env.get('WORKBENCH_SKIP_WARMUP'), await $.env.get('CLAUDE_CODE_AGENT'))
666  if (lane === 'none') return []
667  const home = await $.env.get('HOME')
668  const installed = home ? await readIfAny($, `${home}/.claude/plugins/installed_plugins.json`) : undefined
669  const paths = installed === undefined ? [] : contributionPathsOf(installed)
670  const texts: (string | undefined)[] = []
671  for (const path of paths) texts.push(await readIfAny($, path))
672  return sectionsFor(lane, contributionsOf(texts))
673}
674
675const readIfAny = async ($: EngineInterface, path: string): Promise<string | undefined> =>
676  (await $.fs.exists(path).catch(() => false)) ? $.fs.read(path).catch(() => undefined) : undefined
677
678// An instruction file of the user's tier with the block an older warmup
679// spliced into it left out, read against the file on disk. Undefined when the
680// file holds no such block, or the block is not found whole in its text.
681async function unspliced($: EngineInterface, file: InstructionFile): Promise<InstructionFile | undefined> {
682  if (file.kind !== 'user') return undefined
683  const raw = await readIfAny($, file.path)
684  const content = raw === undefined ? file.content : withoutSplice(file.content, splicedBodiesOf(raw))
685  return content === file.content ? undefined : { ...file, content }
686}
687
688const GUARD_FAILED =
689  'Workbench guards (workbench-core): the guards that judge this call could not finish, so the call is refused. Try the call again. If it is refused again, stop and tell Mike which call it was.'
690
691type Guarded = Record<string, unknown> & { tool: string; agentId?: string }
692
693const IN_SCOPE = 'Destructive-scope guard (workbench-core): every path this command destroys lies inside the project or a scratch root.'
694
695// Each root with where it lands once its symbolic links are followed. A path
696// that does not resolve keeps its spelling alone.
697async function withRealPaths($: EngineInterface, roots: readonly SearchRoot[]): Promise<SearchRoot[]> {
698  return Promise.all(
699    roots.map(async root => {
700      if (root.path === undefined) return root
701      const stat = await $.fs.stat(root.path, { resolve: true }).catch(() => undefined)
702      return stat?.realPath === undefined ? root : { ...root, real: stat.realPath }
703    }),
704  )
705}
706
707// The heredoc targets that are missing or regular files right now, followed
708// through any symbolic link. A FIFO, a socket, a device, a folder, or a
709// target the stat cannot answer for is left out, so its body is read.
710async function regularFiles($: EngineInterface, targets: readonly string[], home: string | undefined): Promise<ReadonlySet<string>> {
711  const regular = new Set<string>()
712  for (const target of targets) {
713    // A relative target lands in the Bash tool's live directory, not the engine's.
714    const path = resolvePath(target, await $.session.cwd(), home)
715    if (path === undefined) continue
716    const kind = await $.fs.stat(path, { resolve: true }).then(
717      stat => stat.kind,
718      () => undefined,
719    )
720    // A stat that fails counts as missing only when nothing is at the path.
721    const isMissing = kind === undefined && !(await $.fs.exists(path).catch(() => true))
722    if (kind === 'file' || isMissing) regular.add(target)
723  }
724  return regular
725}
726
727// ─── facts for the destructive-scope, database and vault-git guards ─────────
728//
729// Each of those guards is a pure judge that asks for a fact by key
730// (hooks/mods/destructive-scope.ts names the keys). settled() runs the judge,
731// answers the fact it asked for, and runs it again, until it reaches a
732// verdict. An answer that cannot be had is null, which every judge reads the
733// closed way. A judge that asks too many questions throws, and the guards'
734// .catch refuses the call.
735
736const MAX_FACT_ROUNDS = 400
737
738const factsScript = ($: EngineInterface): string => `${$.plugin.root}/hooks/lib/scope-facts.sh`
739
740async function gitFact($: EngineInterface, question: string, dir: string, arg: string): Promise<Fact> {
741  const git = (argv: readonly string[]) => $.process.run(['git', ...argv], { timeoutMs: 10_000 })
742  switch (question) {
743    case 'builtins': {
744      const { exitCode, stdout } = await git(['--list-cmds=builtins'])
745      const names = stdout.split(/\s+/).filter(Boolean)
746      return exitCode === 0 && names.length > 0 ? names.join(' ') : null
747    }
748    case 'top': {
749      const { exitCode, stdout } = await git(['-C', dir, 'rev-parse', '--show-toplevel'])
750      return exitCode === 0 ? stdout.trim() : ''
751    }
752    case 'tracked': {
753      const { exitCode } = await git(['-C', dir, 'ls-files', '--error-unmatch', '--', arg])
754      return exitCode === 0 ? 'yes' : exitCode === 1 ? 'no' : null
755    }
756    case 'commit': {
757      const { exitCode } = await git(['-C', dir, 'rev-parse', '--verify', '--quiet', '--end-of-options', `${arg}^{commit}`])
758      return exitCode === 0 ? 'yes' : 'no'
759    }
760    case 'remotes': {
761      const { exitCode, stdout } = await git(['-C', dir, 'for-each-ref', '--format=%(refname)', `refs/remotes/*/${arg}`])
762      return exitCode === 0 ? String(stdout.split(/\s+/).filter(Boolean).length) : null
763    }
764    case 'alias': {
765      const { exitCode, stdout } = await git(['-C', dir, 'config', '--get', `alias.${arg}`])
766      return exitCode === 1 ? 'none' : exitCode === 0 ? `=${stdout.replace(/\n$/, '')}` : null
767    }
768    default:
769      return null
770  }
771}
772
773async function answerFact($: EngineInterface, key: string): Promise<Fact> {
774  const [kind = '', a = '', b = '', c = ''] = key.split('\t')
775  switch (kind) {
776    case 'dir': {
777      const { stdout } = await $.process.run(['bash', factsScript($), 'dir', a], { timeoutMs: 10_000 })
778      const path = stdout.replace(/\n$/, '')
779      return path.startsWith('/') ? path : null
780    }
781    case 'entry': {
782      const { stdout } = await $.process.run(['bash', factsScript($), 'entry', a], { timeoutMs: 10_000 })
783      return stdout.trim() === '' ? null : stdout.trim()
784    }
785    case 'name': {
786      const { stdout } = await $.process.run(['bash', factsScript($), 'name', a, b], { timeoutMs: 10_000 })
787      const name = stdout.replace(/\n$/, '')
788      return name === '' || name.includes('\n') ? null : name
789    }
790    case 'file': {
791      // A regular file only: a FIFO would hold the read open.
792      const stat = await $.fs.stat(a).catch(() => undefined)
793      if (stat?.kind !== 'file') return null
794      const { exitCode, stdout } = await $.process.run(['head', '-c', String(SQL_FILE_CAP), '--', a], { timeoutMs: 10_000 })
795      return exitCode === 0 ? stdout : null
796    }
797    case 'git':
798      return gitFact($, a, b, c)
799    default:
800      return null
801  }
802}
803
804async function settled<T>($: EngineInterface, judge: (get: FactGetter) => T): Promise<T> {
805  const answers = new Map<string, Fact>()
806  for (let round = 0; round < MAX_FACT_ROUNDS; round++) {
807    const step = stepOf(() => judge(getterOf(answers)))
808    if ('value' in step) return step.value
809    answers.set(step.need, await answerFact($, step.need))
810  }
811  throw new Error('workbench guards: too many facts')
812}
813
814// The destructive-scope guard's verdict on a Bash line. The roots are read
815// only when the judge needs a fact: a line it settles without one (no
816// destructive command, or one whose target nobody can read) costs no process.
817async function scopeOf($: EngineInterface, line: string, parse: WorkbenchShellParse, home: string | undefined): Promise<ScopeVerdict> {
818  if (!needsScope(line, parse)) return { kind: 'none' }
819  const cwd = await $.session.cwd()
820  const bare: ScopeContext = { cwd, home, roots: [], tmp: undefined, markers: undefined }
821  const quick = stepOf(() => scopeVerdict(line, parse, bare, getterOf(new Map())))
822  if ('value' in quick) return quick.value
823  const { stdout } = await $.process.run(['bash', factsScript($), 'roots', await $.session.id()], { timeoutMs: 10_000 })
824  const fields = stdout
825    .split('\n')
826    .map(row => row.split('\t'))
827    .filter(([, path]) => path?.startsWith('/'))
828  const project = await answerFact($, dirKey(await $.session.root()))
829  const roots = [...new Set([...(project !== null && project !== '/' ? [project] : []), ...fields.filter(([kind]) => kind === 'root').map(([, path]) => path as string)])]
830  const ctx: ScopeContext = {
831    cwd,
832    home,
833    roots,
834    tmp: fields.find(([kind]) => kind === 'tmp')?.[1],
835    markers: fields.find(([kind]) => kind === 'markers')?.[1],
836  }
837  return settled($, get => scopeVerdict(line, parse, ctx, get))
838}
839
840// The database guard reads a fact only for a SQL file a client is fed, so an
841// ordinary line asks for none.
842async function databaseOf($: EngineInterface, line: string, parse: WorkbenchShellParse, home: string | undefined): Promise<string | undefined> {
843  const cwd = await $.session.cwd()
844  return settled($, get => databaseRefusal(line, parse, cwd, home, get))
845}
846
847async function vaultGitOf($: EngineInterface, line: string, parse: WorkbenchShellParse, home: string | undefined): Promise<string | undefined> {
848  if (!needsVaultGit(line, parse)) return undefined
849  // A vault root that cannot be read is a broken install, not a command
850  // nobody can read: refusing every git write for it would stop all work.
851  const root = await vaultRoot($).catch(() => undefined)
852  const vault = root === undefined ? null : await answerFact($, dirKey(root))
853  // No vault on disk, nothing to protect.
854  if (vault === null) return undefined
855  const cwd = await $.session.cwd()
856  return settled($, get => vaultGitRefusal(line, parse, { vault, cwd, home }, get))
857}
858
859// ─── the outbound prose guard ────────────────────────────────────────────────
860//
861// hooks/mods/outbound-prose.ts reads the body and judges it. Here the files a
862// body names are read, and the vault's top-level folders are listed, so a
863// pointer at a configured vault is caught. A body file that cannot be read is
864// refused, with how to pass the body instead.
865
866async function bodyText($: EngineInterface, part: BodyPart, home: string | undefined): Promise<string | undefined> {
867  if ('text' in part) return part.text
868  const path = resolvePath(part.file, await $.session.cwd(), home)
869  if (path === undefined) return undefined
870  // A regular file only: a FIFO would hold the read open.
871  const stat = await $.fs.stat(path, { resolve: true }).catch(() => undefined)
872  if (stat?.kind !== 'file') return undefined
873  const text = await $.fs.read(path).catch(() => undefined)
874  // A graphql query file is prose only when it holds a mutation.
875  if (text !== undefined && part.isQuery === true) return text.trimStart().startsWith('mutation') ? text : ''
876  if (text === undefined || !part.isJson) return text
877  try {
878    return proseOfJson(text)
879  } catch {
880    return undefined
881  }
882}
883
884async function bashProseOf($: EngineInterface, line: string, parse: WorkbenchShellParse, home: string | undefined): Promise<string | undefined> {
885  const reading = bashBodies(parse)
886  return 'unread' in reading ? reading.unread : reading.parts.length === 0 ? undefined : outboundRefusal($, reading.parts, home)
887}
888
889async function outboundRefusal($: EngineInterface, parts: readonly BodyPart[], home: string | undefined): Promise<string | undefined> {
890  const texts: string[] = []
891  for (const part of parts) {
892    const text = await bodyText($, part, home)
893    if (text === undefined) return UNREAD_BODY
894    texts.push(text)
895  }
896  const prose = texts.join('\n\n')
897  if (prose.trim() === '') return undefined
898  const root = await vaultRoot($).catch(() => undefined)
899  const entries = root === undefined ? [] : await $.fs.list(root).catch(() => [])
900  const folders = new Set(entries.filter(e => e.kind === 'dir' && !e.name.startsWith('.')).map(e => e.name.toLowerCase()))
901  const findings = proseFindings(prose, { root, home, folders })
902  return findings.length === 0 ? undefined : proseRefusal(findings)
903}
904
905// The guards' verdict on one tool call (hooks/mods/guards.ts): a refusal, an
906// advisory to add to the result, or nothing. The facts each guard needs are
907// read only when the call reaches it. `isAttended` says whether a person can
908// answer a permission prompt: where nobody can, a destructive-scope ask is a
909// refusal.
910async function guardVerdict($: EngineInterface, e: Guarded, isAttended: boolean): Promise<{ deny: string } | { advise: string } | undefined> {
911  const text = (value: unknown): string => (typeof value === 'string' ? value : value === undefined || value === null ? '' : JSON.stringify(value))
912  if (isProseTool(e.tool)) {
913    const refusal = await outboundRefusal($, [{ text: mcpBody(e) }], await $.env.get('HOME'))
914    return refusal === undefined ? undefined : { deny: refusal }
915  }
916  switch (e.tool) {
917    case 'SendMessage': {
918      // A lane that cannot be read is taken as a sub-agent's, the gated side.
919      const lane = await $.workbench.callerLane(e.agentId === undefined ? {} : { agentId: e.agentId }).catch((): WorkbenchCallerLane => 'sub-agent')
920      if (lane !== 'sub-agent') return undefined
921      const verdict = peerVerdict({ to: e.to, recipient: e.recipient })
922      return verdict === 'deny' ? { deny: PEER_REFUSAL } : verdict === 'advise' ? { advise: PEER_ADVICE } : undefined
923    }
924    case 'EnterWorktree':
925      return { deny: ENTER_WORKTREE_REFUSAL }
926    case 'ExitWorktree':
927      return e.action === 'remove' ? { deny: EXIT_WORKTREE_REFUSAL } : undefined
928    case 'Agent':
929      return e.isolation === 'worktree' ? { deny: AGENT_WORKTREE_REFUSAL } : undefined
930    case 'Read':
931    case 'Edit':
932    case 'Write':
933    case 'NotebookEdit':
934    case 'Grep':
935    case 'Glob': {
936      const home = await $.env.get('HOME')
937      const path = text(e.file_path ?? e.notebook_path ?? e.path)
938      const refusal = path === '' ? undefined : credentialPathRefusal(path, home)
939      if (refusal !== undefined) return { deny: refusal }
940      if (e.tool !== 'Grep' && e.tool !== 'Glob') return undefined
941      const roots = await withRealPaths($, toolSearchRoots(e.tool, { path: e.path, pattern: e.pattern }, await $.session.cwd(), home))
942      const searched = searchRefusal(roots, home)
943      return searched === undefined ? undefined : { deny: searched }
944    }
945    case 'Bash': {
946      const line = text(e.command)
947      const parse = parseShell(line)
948      const home = await $.env.get('HOME')
949      const refusal =
950        hiddenCommandRefusal(parse) ??
951        credentialRefusal(line, home, parse, await regularFiles($, bodyTargets(parse), home)) ??
952        provisioningRefusal(line, parse) ??
953        ((await $.env.get('WORKBENCH_SUMMARY_WRITER')) === '1' ? summaryWriterRefusal(parse) : undefined) ??
954        (await databaseOf($, line, parse, home)) ??
955        (await vaultGitOf($, line, parse, home)) ??
956        (await bashProseOf($, line, parse, home))
957      if (refusal !== undefined) return { deny: refusal }
958      // A target nobody can read is refused here. One read and outside every
959      // root is put to Mike by the tool.check hook, where a person can answer.
960      const scope = await scopeOf($, line, parse, home)
961      if (scope.kind === 'deny') return { deny: scope.reason }
962      if (scope.kind === 'ask' && !isAttended) return { deny: `${scope.reason} ${NO_ONE_TO_ASK}` }
963      if (!mentionsSearch(parse)) return undefined
964      if (isPartlyRead(parse)) return { deny: SEARCH_UNREAD }
965      const roots = searchRoots(parse, await $.session.cwd(), home)
966      if (roots.length === 0) return undefined
967      const searched = searchRefusal(await withRealPaths($, roots), home)
968      return searched === undefined ? undefined : { deny: searched }
969    }
970    default:
971      return undefined
972  }
973}
974
975export const register: Register = on => {
976  // Set at every load, because a reload runs session.start again. Undefined
977  // until then: the question rule reads it as unattended, and the noun rejects.
978  let sessionAttended: boolean | undefined
979  // CLAUDE_CODE_AGENT, read at session start: a top-level --agent run's name.
980  let agentName: string | undefined
981
982  on('engine.create', async ($, e, next) => {
983    const built = await next(e)
984    return {
985      ...built,
986      workbench: {
987        briefSlots: async () => BRIEF_SLOTS,
988        briefCheck: async (prompt: string) => checkBrief(typeof prompt === 'string' ? prompt : ''),
989        // Answered by the hooks below, which hold `$`. These bottoms answer
990        // only when such a hook fails or passes the call on. Two answers are
991        // safe for every caller: no scratch root, and the gates on. The lane
992        // has no such answer, since `unattended` is the safe side for a nudge
993        // and `attended` for a gate, so those two reject, and each caller's
994        // .catch picks its own side.
995        scratchRoots: async () => [],
996        orchestratorIsOn: async () => true,
997        isUnattended: async (): Promise<boolean> => {
998          throw new Error('workbench: the lane is unknown')
999        },
1000        callerLane: async (): Promise<WorkbenchCallerLane> => {
1001          throw new Error('workbench: the lane is unknown')
1002        },
1003        // Pure, like briefCheck. What the reader cannot read is in the
1004        // answer's unknowns, so only a line that is not a string rejects.
1005        parseShell: async (line: string): Promise<WorkbenchShellParse> => {
1006          if (typeof line !== 'string') throw new Error('workbench: parseShell reads a string')
1007          return parseShell(line)
1008        },
1009      },
1010    }
1011  })
1012
1013  on('workbench.scratchRoots', async $ => {
1014    const { stdout } = await $.process.run(['bash', `${$.plugin.root}/hooks/lib/scratch-roots.sh`, await $.session.id()])
1015    return { value: stdout.split('\n').filter(root => root.startsWith('/')) }
1016  })
1017
1018  on('workbench.orchestratorIsOn', async $ => {
1019    if ((await $.env.get('WORKBENCH_ORCHESTRATOR')) === '0') return { value: false }
1020    if ((await legacyFile($)) === undefined) return { value: false }
1021    return { value: await orchestratorOn($) }
1022  })
1023
1024  // The question rule's own reading: a session a person sits at, and a turn a
1025  // person opened (turnAttended, from isAttendedPrompt). Before session start
1026  // the lane is unknown, and the call is passed to the bottom, which rejects.
1027  on('workbench.isUnattended', async ($, e, next) =>
1028    sessionAttended === undefined ? next(e) : { value: !sessionAttended || !(await read($, turnAttended)) },
1029  )
1030
1031  // Before session start CLAUDE_CODE_AGENT is unread, and an agentId that is
1032  // not a string is no event's: both are passed to the bottom, which rejects.
1033  on('workbench.callerLane', async ($, e, next) =>
1034    sessionAttended === undefined || (e.agentId !== undefined && typeof e.agentId !== 'string')
1035      ? next(e)
1036      : { value: laneOf(e.agentId, agentName) },
1037  )
1038
1039  on('session.start', async ($, e, next) => {
1040    sectionsMemo = undefined
1041    agentName = await $.env.get('CLAUDE_CODE_AGENT')
1042    sessionAttended = isAttendedSession(e.isInteractive, agentName, await $.env.get('WORKBENCH_DEV_TEAM_PIPELINE'))
1043    const { value: startedAt } = await $.state.get(STARTED_AT)
1044    if (startedAt === undefined) await $.state.set(STARTED_AT, await $.clock.now())
1045    for (const command of COMMANDS) await $.command.register(command)
1046    // A reload keeps $.state, so the line is drawn again from it.
1047    await orchestratorOn($).catch(() => undefined)
1048    await drawStatus($)
1049    // Nobody reads a status line or a toast in an unattended run.
1050    if (sessionAttended) {
1051      // Each read is caught on its own, so one that fails stops neither.
1052      $.clock.after(SETTLE_MS, () => {
1053        void probeMemory($).catch(() => undefined)
1054        void deliverNotices($).catch(() => undefined)
1055      })
1056      $.clock.after(NOTICES_LATE_MS, () => void deliverNotices($).catch(() => undefined))
1057      $.clock.every(PROBE_EVERY_MS, () => {
1058        void probeMemory($).catch(() => undefined)
1059        void deliverNotices($).catch(() => undefined)
1060      })
1061    }
1062    return next(e)
1063  })
1064
1065  // The SessionStart settings hooks run beneath this one, so once next(e) is
1066  // back, session-warmup.sh --defer has reconciled dead sessions and written
1067  // this start's notices. The drain and the Chat-skill scan it left out then
1068  // run in the background, in every lane the warmup drains in, as it did.
1069  // Then a person's session reads the notices again, so a Chat-skill notice
1070  // reaches the status line without waiting for the next probe.
1071  on('classic.SessionStart', async ($, e, next) => {
1072    // The transcript the log checkpoint copies from. Every source sets it,
1073    // `clear` too, which starts a new transcript under a new session id.
1074    if (typeof e.transcript_path === 'string' && e.transcript_path !== '') await $.state.set(TRANSCRIPT, e.transcript_path)
1075    const result = await next(e)
1076    if (e.source === 'startup' || e.source === 'resume') {
1077      const payload = JSON.stringify({ source: e.source, session_id: e.session_id })
1078      $.clock.after(DEFERRED_MS, () => {
1079        void deferredWarmup($, payload)
1080          .then(() => (sessionAttended === true ? deliverNotices($) : undefined))
1081          .catch(() => undefined)
1082      })
1083    }
1084    return result
1085  })
1086
1087  // A prompt folded into a running turn carries turnId and opens no turn. Any
1088  // prompt, of any origin and folded or not, ends a "Commit it" pick not yet
1089  // used for a commit: a peer, channel, plugin, SDK, schedule or task message
1090  // carries outside text that must not use it. Only Mike's own prompt ends the
1091  // push left by a commit he approved.
1092  //
1093  // A prompt Mike sends starts a task, for the intake nudge. A new turn a
1094  // person opened gets recall's block beside the prompt, at the tail.
1095  on('prompt.submit', async ($, e, next) => {
1096    await update($, commitApproval, approval => (isPersonOrigin(e.origin) || approval === 'commit' ? 'none' : approval))
1097    if (isPersonOrigin(e.origin) && !isScheduledFire(e.text)) await update($, intake, state => ({ ...state, task: state.task + 1 }))
1098    if (e.turnId !== undefined) return next(e)
1099    await update($, turnAttended, () => isAttendedPrompt(e.origin, e.text))
1100    await update($, turnScheduled, () => e.origin.kind === 'scheduled-trigger' || isScheduledFire(e.text))
1101    await update($, reprompted, () => false)
1102    const block = await promptRecall($, e.text).catch(() => undefined)
1103    return next(block === undefined ? e : { ...e, context: [...(e.context ?? []), block] })
1104  })
1105
1106  // One correction turn at most: the engine's stop_hook_active flag, and the
1107  // per-turn flag the next prompt clears. A synchronous block from a Stop hook
1108  // beneath, such as another plugin's, stands alone, so two re-prompts never
1109  // stack. The memory capture checkpoint opens no turn and blocks no stop: it
1110  // is a fork in turn.complete below.
1111  on('classic.Stop', async ($, e, next) => {
1112    const result = await next(e)
1113    const isOurs =
1114      sessionAttended === true &&
1115      !e.agent_id &&
1116      !e.agent_type &&
1117      !e.stop_hook_active &&
1118      result.block === undefined &&
1119      (await read($, turnAttended)) &&
1120      !(await read($, reprompted))
1121    if (!isOurs || !(await asksInProse($, e.last_assistant_message ?? ''))) return result
1122    await update($, reprompted, () => true)
1123    return { ...result, block: REPROMPT_REASON }
1124  })
1125
1126  on('tool.call', async ($, e, next) => {
1127    // The guards judge first, in every lane. One that throws refuses the call.
1128    const verdict = await guardVerdict($, e as Guarded, sessionAttended === true).catch(() => ({ deny: GUARD_FAILED }))
1129    if (verdict !== undefined && 'deny' in verdict) return { deny: verdict.deny }
1130    if (verdict !== undefined) {
1131      const result = await next(e)
1132      return result.deny !== undefined ? result : { ...result, context: [...(result.context ?? []), verdict.advise] }
1133    }
1134    if (e.tool === 'AskUserQuestion') {
1135      // The commit approval rule: the commit question is asked alone, and a
1136      // "Commit it" pick is what approves the commit.
1137      const isGated = await isCommitGated($, sessionAttended, e.agentId)
1138      if (isGated && bundlesCommit(e.questions)) return { deny: BUNDLE_REFUSAL }
1139      // The question rule. A call no message holds (another plugin's
1140      // $.ui.ask) is let through: there is no message to read the context from.
1141      if (e.agentId === undefined && sessionAttended === true && (await read($, turnAttended))) {
1142        const messages = await $.session.messages({ as: 'api' })
1143        if (hasContextBefore(messages, e.tool_use_id) === false) return { deny: REFUSAL_REASON }
1144      }
1145      const result = await next(e)
1146      if (isGated && result.deny === undefined && isCommitPick(e.questions, result.result)) await update($, commitApproval, () => 'commit')
1147      return result
1148    }
1149    // The commit approval rule: one "Commit it" pick allows one commit, then
1150    // the push of that commit. A command that only mentions git, such as a grep
1151    // for the word, is not one. Whether the commit and the push landed is read
1152    // from the repository the line runs in, before and after it, and from its
1153    // exit status: the pick is kept only when HEAD did not move and the line
1154    // reported an error. A background run cannot be watched, so it is taken
1155    // as landed.
1156    // A content search in the main loop gets recall's block beside its result.
1157    if (e.tool === 'Bash') {
1158      const { writes, dir: steps } = readLine(e.command)
1159      if (writes.commits + writes.pushes === 0 || !(await isCommitGated($, sessionAttended, e.agentId))) {
1160        return withScanRecall($, 'Bash', e.command, e.agentId, await next(e))
1161      }
1162      const refusal = refusalOf(writes, await read($, commitApproval))
1163      if (refusal !== undefined) return { deny: refusal }
1164      const dir = dirOf(steps, await $.session.cwd(), await $.env.get('HOME'))
1165      const before = dir === undefined ? undefined : await refsOf($, dir)
1166      const result = await next(e)
1167      if (result.deny !== undefined) return result
1168      const output = result.result as { backgroundTaskId?: unknown } | undefined
1169      const isBackground = e.run_in_background === true || output?.backgroundTaskId !== undefined
1170      const after = dir === undefined || isBackground ? undefined : await refsOf($, dir)
1171      await update($, commitApproval, () => approvalAfter(writes, before, after, result.isError === true))
1172      return result
1173    }
1174    // The vault write checks, in every lane: a sub-agent or a summary-writer
1175    // writes the same vault.
1176    const vaultTool = vaultToolOf(e.tool)
1177    if (vaultTool !== undefined) {
1178      const checked = await checkVaultWrite($, e as VaultCall, vaultTool)
1179      return 'deny' in checked ? checked : next(checked.call as typeof e)
1180    }
1181    // The calls the bash gates judge read the legacy file next, so it is put
1182    // back in line with the mode Mike chose first. A file the model wrote to
1183    // stand the gates down is removed here.
1184    if (e.agentId === undefined && (e.tool === 'Write' || e.tool === 'NotebookEdit' || e.tool === 'Agent')) {
1185      // One caught expression: a rejected seed must not throw the hook, which
1186      // the engine would skip, leaving the gates an unmirrored file.
1187      // A mode that cannot be read is taken as on, the closed way.
1188      await orchestratorOn($)
1189        .catch(() => true)
1190        .then(isOn => mirror($, isOn))
1191        .catch(() => undefined)
1192      return next(e)
1193    }
1194    // The intake nudge rides the result of the task's first Edit. It never
1195    // denies.
1196    if (e.tool === 'Edit' && e.agentId === undefined) {
1197      const result = await next(e)
1198      if (result.deny !== undefined || !(await isIntakeDue($, sessionAttended).catch(() => false))) return result
1199      return { ...result, context: [...(result.context ?? []), NUDGE] }
1200    }
hooks/mods/brief.ts 59 lines
1// The six-slot brief, as $.workbench.briefSlots() and $.workbench.briefCheck()
2// answer it: a port of hooks/lib/brief-template.sh and of the prompt half of
3// hooks/agent-dispatch-gate.sh, branches (f) and (g) and the slot check.
4//
5// The verdict must be the gate's, case for case. tests/brief-cases.ts holds
6// every prompt case of hooks/test-agent-dispatch-gate.sh with the verdict the
7// gate gave it, generated by hooks/test-brief-parity.sh, and
8// tests/workbench.test.ts runs this check over the same list.
9//
10// Whitespace is the gate's ASCII set, never \s: whether NBSP is whitespace
11// depends on the C library under grep, so the gate lists five characters, and
12// \s here would take NBSP and U+3000 as spaces where the gate does not. Newline
13// is absent for grep's reason: the gate matches within one line, so the prompt
14// is split on \n and every pattern runs on one line. JavaScript's ^ and $ under
15// the m flag also stop at \r, U+2028 and U+2029, which grep does not, so no
16// pattern here uses the m flag.
17//
18// Pure functions only: the engine follows `$` into no imported function.
19
20import type { WorkbenchBriefCheck, WorkbenchBriefSlot } from '../../types'
21
22const WS = ' \\t\\r\\v\\f'
23
24// Order is the template's order, as the gate's deny message lists the slots.
25const SLOTS: readonly (WorkbenchBriefSlot & { pattern: RegExp })[] = [
26  ['Workdir:', 'absolute path of the tree to work in, and the branch or worktree if one was settled'],
27  ['Goal:', 'one or two sentences, measurable'],
28  ['Context:', 'why the task exists, and what the agent cannot derive'],
29  ['Constraints:', 'hard limits, or none'],
30  ['Acceptance:', 'the criteria the work is graded against, one per line'],
31  ['Done when:', 'observable finish line'],
32].map(([header, description]) => ({
33  header: header as string,
34  description: description as string,
35  pattern: new RegExp(`^[${WS}]*${(header as string).replace(' ', `[${WS}]+`)}`, 'i'),
36}))
37
38export const BRIEF_SLOTS: readonly WorkbenchBriefSlot[] = SLOTS.map(({ header, description }) => ({ header, description }))
39
40const NONBLANK = new RegExp(`[^${WS}]`)
41const ITEM_ID = new RegExp(`^[${WS}]*Item ID:[${WS}]*[0-9]+[${WS}]*$`)
42const REPO_SWEEP = new RegExp(`^[${WS}]*Repo sweep:[${WS}]*[^${WS}/]+/[^${WS}/]+[${WS}]*$`)
43
44// The gate's verdict on one prompt. A prompt with no character outside the
45// ASCII set is blank, and the gate lets it through; so does a prompt whose
46// one non-blank line is a machine-built shape. Every other prompt is a brief,
47// complete when all six headers open a line.
48export function checkBrief(prompt: string): WorkbenchBriefCheck {
49  const lines = prompt.split('\n')
50  const filled = lines.filter(line => NONBLANK.test(line))
51  if (filled.length === 0) return { isComplete: true, missing: [], shape: 'blank' }
52  if (filled.length === 1) {
53    if (lines.some(line => ITEM_ID.test(line))) return { isComplete: true, missing: [], shape: 'item-id' }
54    if (lines.some(line => REPO_SWEEP.test(line))) return { isComplete: true, missing: [], shape: 'repo-sweep' }
55  }
56  const missing = SLOTS.filter(slot => !lines.some(line => slot.pattern.test(line))).map(slot => slot.header)
57  return { isComplete: missing.length === 0, missing, shape: 'brief' }
58}
59
hooks/mods/capture.ts 172 lines
1// The memory capture checkpoint: the live session's durable findings, written
2// to the vault while the session still holds the context that formed them.
3//
4// It replaces hooks/memory-capture-stop.sh, an asyncRewake Stop hook that woke
5// the model with a new turn Mike saw ("Memory capture checkpoint"). Vault work
6// must be invisible to him (vault: feedback/memory-vault-activity-fully-
7// transparent), so the question now goes to $.model.fork: one tool-less
8// completion over the session's own transcript, served from the prompt cache,
9// whose answer no one sees. hooks/register.ts writes each note it returns
10// through $.mcp.call, never as a turn.
11//
12// The fire policy is the old hook's, measured over 467 transcripts of this
13// project: the first capture on the 5th main-loop turn (88% of sessions reach
14// it), then every 40th, for findings that form late in a long session. Both
15// are overridable as before. The reply may hold nothing, and usually should:
16// a manufactured memory is worse than none.
17//
18// Pure functions only: the engine follows `$` into no imported function.
19
20import { TITLE_MAX_CHARS, lineOf } from './recall'
21
22export const FIRST = 5
23export const REPEAT = 40
24// At most this many notes per capture, so one confused reply cannot flood the
25// vault.
26export const MAX_NOTES = 3
27
28// A capture threshold from its environment variable: a positive integer, else
29// the default, as the bash hook clamped it.
30export function thresholdOf(value: string | undefined, fallback: number): number {
31  const n = value !== undefined && /^[0-9]+$/.test(value) ? Number(value) : NaN
32  return Number.isInteger(n) && n >= 1 ? n : fallback
33}
34
35// Whether the turn just counted fires a capture. `count` includes this turn
36// and restarts at each fire, so the threshold is REPEAT once one has fired.
37export const isCaptureDue = (count: number, hasFired: boolean, first: number, repeat: number): boolean =>
38  count >= (hasFired ? repeat : first)
39
40// The note types a capture may write, and the folder each one goes in
41// (references/vault-conventions.md, "Vault structure").
42export const FOLDERS: Readonly<Record<string, string>> = {
43  decision: 'decisions',
44  insight: 'insights',
45  feedback: 'feedback',
46  project: 'projects',
47}
48
49// The question the fork answers. Written for a reply nobody reads but this
50// module: JSON or the word NONE.
51export function capturePrompt(written: readonly string[]): string {
52  const already =
53    written.length > 0 ? `\nThese notes were already written by an earlier checkpoint of this session, so leave out what they hold:\n${written.map(path => `- ${path}`).join('\n')}\n` : ''
54  return `Memory capture checkpoint. This is an automatic background question from the workbench-core plugin, not from the user, and your answer is never shown to anyone.
55
56List the durable knowledge from this session so far that the memory vault does not hold yet: a decision and its rationale, a root cause, a non-obvious insight or gotcha, a correction to how to work, or a project outcome. Leave out anything this session already wrote to the vault, routine code edits, facts the repo or git already holds, and chatter.
57${already}
58Link another vault note by its path, as [display text](/folder/file-stem.md), never as [[name]].
59
60If nothing qualifies, answer with the single word NONE. That is the expected answer for most checkpoints: a manufactured memory is worse than none.
61
62Otherwise answer with a JSON array and nothing else, at most ${MAX_NOTES} items, each:
63{"type": "decision" | "insight" | "feedback" | "project", "slug": "kebab-case-file-name", "name": "Short title", "summary": "One sentence.", "tags": ["tag"], "body": "The note in Markdown: what, why, and how to apply it."}`
64}
65
66export type CaptureNote = {
67  path: string
68  content: string
69  frontmatter: { name: string; type: string; date: string; summary: string; tags: string[] }
70}
71
72const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
73
74// The notes in a fork's reply, each checked field by field, with the path
75// built here from the type, the date and the slug: the reply is model output,
76// so it never names a path, a folder or a frontmatter field of its own. An
77// item that fails any check is left out. NONE, an empty reply, and anything
78// that is not a JSON array give no notes.
79export function notesOf(reply: string, date: string): CaptureNote[] {
80  const text = reply.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '')
81  if (!text.startsWith('[')) return []
82  let items: unknown
83  try {
84    items = JSON.parse(text)
85  } catch {
86    return []
87  }
88  if (!Array.isArray(items)) return []
89  const notes: CaptureNote[] = []
90  const paths = new Set<string>()
91  for (const item of items) {
92    if (notes.length >= MAX_NOTES) break
93    if (typeof item !== 'object' || item === null) continue
94    const { type, slug, name, summary, tags, body } = item as Record<string, unknown>
95    if (typeof type !== 'string' || !Object.hasOwn(FOLDERS, type)) continue
96    if (typeof slug !== 'string' || !SLUG.test(slug) || slug.length > 80) continue
97    if (!isLine(name, 120) || !isLine(summary, 400) || !isText(body, 20_000)) continue
98    const tagList = Array.isArray(tags) ? tags.filter((tag): tag is string => typeof tag === 'string' && SLUG.test(tag)).slice(0, 8) : []
99    const path = `${FOLDERS[type]}/${date}-${slug}.md`
100    if (paths.has(path)) continue
101    paths.add(path)
102    notes.push({
103      path,
104      content: (body as string).trim(),
105      frontmatter: { name: (name as string).trim(), type, date, summary: (summary as string).trim(), tags: tagList },
106    })
107  }
108  return notes
109}
110
111const isText = (value: unknown, max: number): value is string => typeof value === 'string' && value.trim() !== '' && value.length <= max
112// A frontmatter value on one line: a newline in a name or summary would break
113// the note's frontmatter and every listing that shows it.
114const isLine = (value: unknown, max: number): value is string => isText(value, max) && !/[\r\n]/.test(value)
115
116// Whether the server's answer to a `read` is a definite "no note there", the
117// one answer a capture writes after. The server words it "Document not found:
118// '<path>'", a prefix its own docs say callers match on, and only that prefix
119// counts. Any other error (the server down, a refusal) leaves the note
120// unwritten.
121export const isNotFound = (result: { isError: boolean; content: readonly { type: string; text?: string }[] }): boolean =>
122  result.isError && result.content.some(block => typeof block.text === 'string' && /^Document not found:/.test(block.text.trimStart()))
123
124// A score at or above this is a note both retrievers rank first, close to the
125// most a hybrid search scores (2/61). Measured on the live vault 2026-10-07: a
126// note both rank near the top scores 0.02 to 0.035, one retriever alone at most
127// about 0.016.
128//
129// It reads only hybrid-search scores. The search passes no mode, so a vault
130// with no embeddings (a fresh install, or a failed embeddings build) answers
131// with keyword search, whose BM25 scores are well above 1: the rule would then
132// call every note a duplicate. Such hits are labelled `keyword`. A hybrid
133// search labels its hits `semantic` or `hybrid`, and on this server every hit
134// is labelled `semantic` whichever retrievers ranked it (measured 2026-10-07),
135// so a gate on the `hybrid` label alone would never apply the rule. A hit with
136// no label is not scored either.
137//
138// The label alone cannot tell a fused score from a raw one: a vault configured
139// with DEFAULT_SEARCH_MODE=semantic answers with cosine-like similarities,
140// labelled `semantic` too, and most notes score far above 0.032 there. So the
141// score counts only inside the fusion range, below RRF_CEILING. A fused score
142// cannot reach it: two first ranks give 2/61, about 0.033, and folder weights
143// measured on the live vault lift that to 0.035 at most. A higher score is
144// read as not fused, and the note is matched by name and slug alone.
145export const DUPLICATE_SCORE = 0.032
146export const RRF_CEILING = 0.1
147const FUSED: ReadonlySet<string> = new Set(['hybrid', 'semantic'])
148
149export type DuplicateHit = { path: string; title: string; score: number; searchType?: string }
150
151// Why the vault already holds the note a capture would write, by a search on
152// its name: a note with the same name, the same slug, or one both retrievers
153// rank first for that name (another session may have saved it). Undefined when
154// it holds none. The name is cut to one line and capped as recall caps a hit's
155// title, so a long name still matches its own note.
156export function duplicateOf(note: CaptureNote, hits: readonly DuplicateHit[]): string | undefined {
157  const name = lineOf(note.frontmatter.name, TITLE_MAX_CHARS).toLowerCase()
158  const slug = note.path.replace(/^.*\/\d{4}-\d{2}-\d{2}-|\.md$/g, '')
159  for (const hit of hits) {
160    const stem = hit.path.replace(/^.*\//, '').replace(/\.md$/, '').replace(/^\d{4}-\d{2}-\d{2}-/, '')
161    if (lineOf(hit.title, TITLE_MAX_CHARS).toLowerCase() === name) return `same name as ${hit.path}`
162    if (stem === slug) return `same slug as ${hit.path}`
163    const isFused = hit.searchType !== undefined && FUSED.has(hit.searchType) && hit.score < RRF_CEILING
164    if (isFused && hit.score >= DUPLICATE_SCORE) return `ranked first by both retrievers: ${hit.path}`
165  }
166  return undefined
167}
168
169// The one line that tells Mike notes were saved: a toast, never a turn.
170export const savedText = (paths: readonly string[]): string =>
171  `Memory checkpoint saved ${paths.length === 1 ? 'a note' : `${paths.length} notes`} to the vault: ${paths.join(', ')}.`
172
hooks/mods/checkpoint.ts 48 lines
1// The session log checkpoint, written as the session runs instead of only when
2// it ends.
3//
4// SessionEnd does not fire on a reboot or a kill, the Stop hook does not fire
5// on an interrupted turn, and every SessionEnd hook shares one 1.5 s budget.
6// So hooks/register.ts runs hooks/session-log.sh after each main-loop turn,
7// answered or interrupted (turn.complete), and at session.end, which also
8// fires on SIGHUP and SIGTERM. session-log.sh stays the one writer of the raw
9// log, the log-checkpoints/<sid>.json `next_line`, and the pending-summary
10// marker, so the turn checkpoint, the settings SessionEnd hook and the
11// start-up reconciler read and advance one checkpoint and never log a line
12// twice. Its per-session lock serializes the writers that overlap.
13//
14// Pure functions only: the engine follows `$` into no imported function.
15
16export type CheckpointMode = 'turn' | 'final'
17
18export type CheckpointRequest = { stdin: string; env: Record<string, string> }
19
20// What session-log.sh is run with for one checkpoint: its payload on stdin,
21// shaped like a hook's, and the mode in WORKBENCH_LOG_MODE. Undefined when the
22// transcript is unknown or names another session: a checkpoint never copies a
23// file it cannot tie to the session id.
24export function checkpointRequest(
25  sessionId: string,
26  transcript: string | undefined,
27  mode: CheckpointMode,
28  reason?: string,
29): CheckpointRequest | undefined {
30  if (transcript === undefined || sessionId === '' || !transcript.endsWith(`/${sessionId}.jsonl`)) return undefined
31  const payload = {
32    session_id: sessionId,
33    transcript_path: transcript,
34    hook_event_name: mode === 'turn' ? 'TurnComplete' : 'SessionEnd',
35    ...(reason === undefined ? {} : { reason }),
36  }
37  return { stdin: JSON.stringify(payload), env: { WORKBENCH_LOG_MODE: mode } }
38}
39
40// How long the session.end checkpoint may run: what the exit budget has left,
41// less a margin for the hooks after it, and never past END_MAX_MS. Undefined
42// when too little is left to start one.
43export const END_MAX_MS = 1200
44export function endTimeoutOf(remainingMs: number): number | undefined {
45  const ms = Math.min(END_MAX_MS, Math.floor(remainingMs) - 150)
46  return ms >= 100 ? ms : undefined
47}
48
hooks/mods/intake.ts 40 lines
1// The intake nudge: on the first Edit of a task in Mike's session, a reminder
2// to show the intake block (skills/intake/SKILL.md) when none is on screen.
3// It never denies. It replaces hooks/intake-nudge.sh, which read the
4// transcript file and kept its own state file per session.
5//
6// A task is what one prompt Mike sends starts. The nudge is checked once per
7// task, tracked in $.state by hooks/register.ts, and fires only when no
8// heading naming Intake is on screen: in this task's replies so far, or in the
9// closing reply of the turn before the prompt, where an intake block shown
10// for Mike's answer sits.
11//
12// Pure functions only: the engine follows `$` into no imported function.
13
14import type { SessionMessage } from 'claude-code'
15
16export const NUDGE =
17  '📋 Intake nudge (advisory, nothing was blocked): this is the first Edit for the current task, and no intake block is on screen for it. Before more work, run /workbench-core:intake: show the goal, the context, and the acceptance criteria you are working to, under a heading that names Intake. If the task is trivial, carry on without it.'
18
19const HEADING = /(^|\n)[ \t]*#{1,6}[ \t]+[^\n]*\bintake\b/i
20
21const isPrompt = (message: SessionMessage): boolean =>
22  message.role === 'user' && (message.toolResults === undefined || message.toolResults.length === 0) && message.text.trim() !== ''
23
24// Whether an intake heading is on screen for the task the last prompt opened.
25// It reads the replies after that prompt, and the replies before it back to
26// the last message with a tool call or result, as far as the prompt before.
27export function intakeShown(messages: readonly SessionMessage[]): boolean {
28  let at = messages.length - 1
29  while (at >= 0 && !isPrompt(messages[at] as SessionMessage)) at -= 1
30  if (at < 0) return false
31  const seen = messages.slice(at + 1).filter(message => message.role === 'assistant')
32  const closing: SessionMessage[] = []
33  for (let i = at - 1; i >= 0; i -= 1) {
34    const message = messages[i] as SessionMessage
35    if (isPrompt(message) || message.toolUses.length > 0 || (message.toolResults?.length ?? 0) > 0) break
36    if (message.role === 'assistant') closing.push(message)
37  }
38  return [...closing, ...seen].some(message => HEADING.test(message.text))
39}
40
hooks/mods/learnings.ts 35 lines
1// Skill learnings, merged into the skill's own text through skill.prompt.
2//
3// Every skill can keep learnings in the vault, at skills/<name>.learnings.md:
4// corrections, failures and confirmed approaches from past runs. The retired
5// hooks/skill-learnings.sh handed the file over as PreToolUse context, an
6// extra block beside the call. Merged into the text the skill expands to, it
7// is one block, the same bytes for the same file, and no row of its own.
8//
9// A file past MAX_CHARS is not merged whole: the skill text gets the file's
10// vault path instead, with the instruction to read it through the memory MCP.
11// The limit is the old hook's: it keeps a skill that ran long from carrying a
12// 68 KB file into every run (memory-lint.learnings.md once reached that).
13//
14// Pure functions only: the engine follows `$` into no imported function.
15
16export const MAX_CHARS = 9000
17
18export const RULE =
19  'Add to this file only when this run taught something a future run needs: the user corrected the approach, something failed and you learned why, or the user confirmed a non-obvious approach. Append it through the memory MCP as `## YYYY-MM-DD - short title` followed by what to do next time. A routine run adds nothing.'
20
21// The vault-relative path of a skill's learnings file, by its bare name.
22export const learningsPath = (name: string): string => `skills/${name}.learnings.md`
23
24// The skill's text with its learnings after it.
25export function withLearnings(text: string, name: string, learnings: string): string {
26  const rel = learningsPath(name)
27  const body = learnings.trim()
28  if (body === '') return text
29  const merged =
30    body.length > MAX_CHARS
31      ? `## Learnings from past runs\n\nThe \`${name}\` skill has learnings from its past runs, too large to include here. Read the whole file with the memory MCP \`read\` tool, at the vault path \`${rel}\`, before you start, and apply it to this run. ${RULE}`
32      : `## Learnings from past runs\n\nLearnings for the \`${name}\` skill, recorded from its past runs at \`${rel}\` in the memory vault. Apply them to this run.\n\n${body}\n\n${RULE}`
33  return `${text.replace(/\s+$/, '')}\n\n${merged}\n`
34}
35
hooks/mods/prompt-rules.ts 226 lines
1// The workbench rules in the system prompt, as prompt.compose sections.
2//
3// They used to reach a session three ways, each one paid for in every session:
4//   - a block session-warmup.sh spliced into ~/.claude/CLAUDE.md on every start
5//     (the gates, the scratch roots, and each sibling plugin's
6//     session-warmup.md), which rewrote the user's own file
7//   - the warmup's stdout (memory routing, and the destructive commands, which
8//     stated the scratch roots a second time)
9//   - a MEMORY.md router stub in the harness's per-project memory folder, which
10//     restated the memory routing beside the harness's own memory section
11// All three sat in the first message, which a session creates afresh. Here the
12// rules are `shared` sections: the same bytes in every session of a lane, so
13// the cache that holds them is read, not created, from the second session on.
14// The harness's `memory` section, which told the model to keep memories in a
15// per-project folder, is dropped: the vault is the one store.
16//
17// The rules are stated once. A section holds no date, count, version, session
18// id or path read from the machine: the same lane always gets the same bytes,
19// and the plugins' own files are the only input (their session-warmup.md,
20// whose bytes change only when the plugin does).
21//
22// Who gets what, as the copies it replaces reached them:
23//   main    the main loop of a session the warmup ran in: interactive or
24//           `claude -p`. Rules, memory routing, plugin contributions.
25//   agent   a top-level `claude -p --agent` run (CLAUDE_CODE_AGENT), which the
26//           warmup skipped and the CLAUDE.md block reached. Rules and plugin
27//           contributions.
28//   none    a summary-writer (WORKBENCH_SKIP_WARMUP=1), which runs with no
29//           CLAUDE.md and no warmup on purpose. Nothing.
30// A sub-agent gets its parent's set through SubagentStart, as the CLAUDE.md
31// block and the router stub reached the sub-agents that load CLAUDE.md. An
32// agent that leaves CLAUDE.md out (OMITS_CLAUDE_MD: Explore, Plan and the
33// rest) never saw either, and gets none.
34//
35// The block an older warmup left in ~/.claude/CLAUDE.md is left out of the
36// first message at render time (splicedBodiesOf, withoutSplice), so the rules
37// are not paid for twice before setup takes the block out of the file
38// (scripts/setup-config.sh unsplice-claude-md).
39//
40// Pure functions only: the engine follows `$` into no imported function, so the
41// hooks that read the files live in hooks/register.ts.
42
43import type { PromptComposeSection } from 'claude-code'
44
45export type PromptLane = 'main' | 'agent' | 'none'
46
47export const RULES_ID = 'workbench-core:rules'
48export const MEMORY_ID = 'workbench-core:memory'
49export const PLUGINS_ID = 'workbench-core:plugins'
50
51// The harness section this plugin drops.
52export const HARNESS_MEMORY = 'memory'
53
54export const RULES = `# Workbench gates and scratch roots
55
56These hooks guard every session. Each deny explains its own way through, so read the deny and follow it. A deny is the system working. Report it, and do not route around it.
57
58| Gate | What it protects |
59|---|---|
60| Delegation gate | Whole-file work belongs in a sub-agent. It never denies: a main-agent \`Write\` or \`NotebookEdit\` goes ahead with a reminder, once per session. Plans and scratch roots draw none. The user's \`/orchestrator off\` silences it. |
61| Agent dispatch gate | A main-agent \`Agent\` dispatch must carry the six-slot brief. |
62| Destructive scope guard | The destructive commands below run only when every target resolves inside the project or a scratch root. |
63| Destructive database guard | Database resets, drops, and destructive SQL are refused. |
64| Provisioning guard | Agents do not create worktrees or databases, and do not destroy a worktree they did not create. |
65| Vault git guard | Git writes aimed at the memory vault are refused. |
66| Credential guard | Reading, linking or copying \`~/.ssh\`, \`~/.aws\`, \`~/.gnupg\`, \`.env\` files, a keychain folder, or Claude's credential store is refused. |
67| Outbound prose guard | \`gh\` and board-MCP prose must pass the output style's mechanical checks. |
68| Peer message gate | A sub-agent messages only its orchestrator or the agents it spawned. |
69| Whole-disk search guard | A search (\`find\`, \`fd\`, \`rg\`, \`grep -r\`, \`mdfind\`, \`Grep\`, \`Glob\`) may not start at \`/\`, a home folder, \`~/Library\`, \`/Users\`, \`/System\`, \`/Library\`, \`/Applications\`, \`/Volumes\`, \`/private\`, \`/var\`, \`/opt\` or \`/usr\`. |
70
71Every guard refuses a Bash line whose command it cannot name: a command name from a variable or a substitution, a wrapper option it cannot read, or a script piped into a shell. Write the command name out plainly. A plain \`"$NAME/…"\` or \`"\${NAME}/…"\`, inside double quotes, in front of a literal path is fine.
72
73## Scratch roots and destructive commands
74
75- A scratch root is the session scratchpad, \`~/Developer/scratchpad\`, or a \`mktemp -d\` sandbox. Make new scratch in the session scratchpad or \`~/Developer/scratchpad\`. Never create it anywhere under \`/tmp\` outside your session scratchpad, and do not put new scratch in an old \`claude-*scratch*\` folder there either.
76- \`rm\`, \`rmdir\`, \`git reset --hard\`, \`git clean\`, \`git stash clear\`/\`drop\`, and git commands that discard working-tree changes (such as \`git restore\`, \`git checkout -- <path>\`, or \`git mv -f\`, also through an alias) run with no prompt when every path they act on resolves inside the project or a scratch root. \`rm\` and \`rmdir\` may also remove a leftover \`/tmp/claude-*scratch*\` folder you own, the folder itself included. It is not a root, so \`git\` verbs there are still denied.
77- Outside those roots the guard DENIES, and so does any target it cannot read: a \`$variable\`, a glob, \`bash -c\`, \`ssh\`, \`xargs\`, \`find -delete\`, or a loop body. Spell paths out literally and keep the delete its own command. Never hand the user a \`!\` command to delete your own scratch. A target outside every root that is not scratch is the user's call, and they run it with the \`!\` prefix.`
78
79// The search mode is left to the server: its default picks hybrid when the
80// vault has embeddings and keyword when it does not, and naming one broke that
81// fallback once. The two recall bullets carry their reasons, because the
82// hooks module's recall searches only a prompt's wording and the patterns of
83// file searches, and the task's own words are the better query.
84export const MEMORY = `## Memory routing
85
86- The workbench memory vault is the CANONICAL durable memory store, served by the \`memory\` MCP (\`mcp__plugin_workbench-core_memory__search\` / \`write\` / etc.).
87- Proactively CAPTURE durable knowledge without asking: a decision (+ rationale), a troubleshooting root-cause, a design choice and the options weighed, a non-obvious insight or gotcha, a project/plan outcome, or feedback on how to work — \`write\` it to the vault immediately with frontmatter \`name\` + \`type\` (decision | insight | project | feedback | reference) plus tags/summary/date per vault conventions, then note the save in one line. This is standing authorization: a memory-capture write needs no options round and no confirmation. Do NOT ask first.
88- Before saving, \`search\` for an existing memory to UPDATE rather than duplicate. Skip the trivial: routine code edits, facts already in the repo or git, ephemeral chatter. Capture what would otherwise be a "by the way, should I remember this?".
89- Recall = vault \`search\`, not directory reads. Omit \`mode\`: the server picks hybrid when the vault has embeddings and keyword when it does not.
90- Recall comes FIRST: the moment a task turns up a topic — an error, a tool, a design choice, a repo or file you have worked before — \`search\` the vault BEFORE you scan the repo for the answer. Auto-recall searches only the wording of each prompt and the patterns of your file searches, so a topic that reaches you any other way has had NO memory searched against it unless you search it yourself.
91- Build the recall QUERY from the TASK, not from the prompt: name the thing you are about to produce or decide — the convention, the format, the procedure, the tool, the error — in the words a note about it would use, and search THAT. Auto-recall can only ever run wording that was already typed, so your advantage over it is asking the better question; a recorded rule filed under another phrase is one query away and will not arrive on its own.`
92
93// Agent types that leave CLAUDE.md out (`omitClaudeMd: true` in their
94// definition). They never saw the CLAUDE.md block or the router stub, so they
95// get no workbench rules either. No hooks-module API says which types set it:
96// agent.offer gives a type's name, description and source, and $.agent.list()
97// lists running agents. So they are named. The built-ins are the ones the CLI
98// 2.1.294 defines with it (read from its own definitions): Explore, Plan,
99// web-fetch and comment-thread-analyst. summary-writer is core's own, which
100// runs with no CLAUDE.md and no warmup on purpose.
101export const OMITS_CLAUDE_MD: ReadonlySet<string> = new Set([
102  'Explore',
103  'Plan',
104  'web-fetch',
105  'comment-thread-analyst',
106  'summary-writer',
107  'workbench-core:summary-writer',
108])
109
110// A sub-agent's system prompt may still carry the harness's own memory section,
111// which prompt.section drops for the main loop. So its copy of the memory
112// routing says which store wins.
113export const SUBAGENT_MEMORY_NOTE =
114  'The workbench memory vault overrides any instruction to keep memories in a per-project memory directory or its MEMORY.md: save to the vault and recall from it.'
115
116// The lane, from the environment the warmup reads for its own skip guards.
117export function promptLaneOf(skipWarmup: string | undefined, agent: string | undefined): PromptLane {
118  if (skipWarmup === '1') return 'none'
119  return agent ? 'agent' : 'main'
120}
121
122// Where each sibling plugin's session-warmup.md is, from the text of
123// ~/.claude/plugins/installed_plugins.json: every plugin of the claude-workbench
124// marketplace but this one, in the file's order, at its active install path.
125// A file that does not parse, or names no install path, gives none.
126export function contributionPathsOf(installed: string): string[] {
127  let parsed: unknown
128  try {
129    parsed = JSON.parse(installed)
130  } catch {
131    return []
132  }
133  const plugins = (parsed as { plugins?: unknown } | null)?.plugins
134  if (typeof plugins !== 'object' || plugins === null) return []
135  const paths: string[] = []
136  for (const [key, installs] of Object.entries(plugins)) {
137    if (!key.endsWith('@claude-workbench') || key.startsWith('workbench-core@')) continue
138    const path = Array.isArray(installs) ? (installs[0] as { installPath?: unknown } | undefined)?.installPath : undefined
139    if (typeof path === 'string' && path.startsWith('/')) paths.push(`${path.replace(/\/+$/, '')}/session-warmup.md`)
140  }
141  return paths
142}
143
144// The contributions as one section's text, blank-line separated, or undefined
145// when there is none.
146export function contributionsOf(texts: readonly (string | undefined)[]): string | undefined {
147  const kept = texts.map(text => text?.trim() ?? '').filter(text => text !== '')
148  return kept.length === 0 ? undefined : kept.join('\n\n')
149}
150
151// The sections a lane gets, in order. Every one is `shared`: its bytes are the
152// same in every session of the lane.
153export function sectionsFor(lane: PromptLane, contributions: string | undefined): PromptComposeSection[] {
154  if (lane === 'none') return []
155  const rules: PromptComposeSection = { id: RULES_ID, text: RULES, scope: 'shared' }
156  const memory: PromptComposeSection = { id: MEMORY_ID, text: MEMORY, scope: 'shared' }
157  const plugins: PromptComposeSection[] = contributions === undefined ? [] : [{ id: PLUGINS_ID, text: contributions, scope: 'shared' }]
158  return lane === 'main' ? [rules, memory, ...plugins] : [rules, ...plugins]
159}
160
161// The engine's sections with ours after its last `shared` one, so every shared
162// section still comes before every session one. Ours already in the list (a
163// second compose of the same list) are not added twice.
164export function withShared(sections: readonly PromptComposeSection[], ours: readonly PromptComposeSection[]): PromptComposeSection[] {
165  const ids = new Set(ours.map(section => section.id))
166  const theirs = sections.filter(section => !ids.has(section.id))
167  const cut = theirs.findIndex(section => section.scope === 'session')
168  const at = cut === -1 ? theirs.length : cut
169  return [...theirs.slice(0, at), ...ours, ...theirs.slice(at)]
170}
171
172// What a sub-agent's SubagentStart adds: the sections' texts, or undefined
173// when there are none.
174export function subagentContextOf(sections: readonly PromptComposeSection[]): string | undefined {
175  if (sections.length === 0) return undefined
176  const texts = sections.flatMap(section => (section.id === MEMORY_ID ? [section.text, SUBAGENT_MEMORY_NOTE] : [section.text]))
177  return texts.join('\n\n')
178}
179
180const SPLICE_MARKERS: readonly (readonly [string, string])[] = [
181  ['<!-- workbench-identity:start -->', '<!-- workbench-identity:end -->'],
182  ['<!-- workbench-warmup:start -->', '<!-- workbench-warmup:end -->'],
183]
184
185// The text an older warmup spliced into a CLAUDE.md, read from the file on
186// disk: each marked region's lines between its two markers, with no blank
187// lines at either end and no carriage returns. A pair is taken only when the
188// file holds exactly one start line and one end line of it, the start first:
189// a marker line of the user's own, such as one quoted in a code fence, makes
190// the region's extent a guess, and a guess could take the user's text.
191export function splicedBodiesOf(raw: string): string[] {
192  const lines = raw.split('\n').map(line => line.replace(/\r$/, ''))
193  const bodies: string[] = []
194  for (const [start, end] of SPLICE_MARKERS) {
195    const starts = lines.flatMap((line, i) => (line === start ? [i] : []))
196    const ends = lines.flatMap((line, i) => (line === end ? [i] : []))
197    const [from, to] = [starts[0], ends[0]]
198    if (starts.length !== 1 || ends.length !== 1 || from === undefined || to === undefined || to < from) continue
199    const body = lines.slice(from + 1, to).join('\n').replace(/^\n+|\n+$/g, '')
200    if (body !== '') bodies.push(body)
201  }
202  return bodies
203}
204
205// The instruction file's text with each spliced body taken out. The engine's
206// text has the markers stripped as comments, so a body is found by its own
207// text, and only where it stands whole and once (with LF or CRLF line ends).
208// Only the line breaks that touch the cut are rewritten: the text on either
209// side is joined by one blank line, or by nothing at the file's start or end.
210// Every other byte is the user's and stays as it was. Nothing found gives the
211// text back unchanged.
212export function withoutSplice(content: string, bodies: readonly string[]): string {
213  let text = content
214  for (const body of bodies) {
215    const crlf = body.replace(/\n/g, '\r\n')
216    const [found, eol] = text.includes(body) ? [body, '\n'] : text.includes(crlf) ? [crlf, '\r\n'] : [undefined, '\n']
217    if (found === undefined) continue
218    const at = text.indexOf(found)
219    if (text.indexOf(found, at + 1) !== -1) continue
220    const before = text.slice(0, at).replace(/(\r?\n)+$/, '')
221    const after = text.slice(at + found.length).replace(/^(\r?\n)+/, '')
222    text = before === '' ? after : after === '' ? `${before}${eol}` : `${before}${eol}${eol}${after}`
223  }
224  return text
225}
226
hooks/mods/recall.ts 161 lines
1// Vault recall: memories that bear on what the session is doing, injected at
2// the tail of the conversation and never into the system prompt.
3//
4// Two triggers, as the bash hooks had (hooks/memory-recall.sh on each prompt,
5// hooks/memory-scan-recall.sh on each Grep or Bash content search). Those cost
6// about 850 tokens and 1.6 s per prompt, and in the Phase 0 sample none of
7// their hits was used. So each hit now passes four filters before it is shown:
8//
9//   1. A score threshold. The server's hybrid score fuses a keyword rank and a
10//      semantic rank (reciprocal rank fusion, k = 60): a note one retriever
11//      ranks first scores just under 1/61 (0.0158 to 0.0164 measured), the
12//      ones after it 0.015 and falling, and a note both retrievers rank near
13//      the top scores 0.02 to 0.035. Measured on the live vault 2026-10-07, every hit is labelled
14//      `semantic` whichever retrievers ranked it, so the label cannot tell
15//      them apart and the score must. A prompt keeps the top hits of either
16//      retriever (PROMPT_MIN_SCORE), and the classifier judges them. A scan
17//      fires far more often, so it keeps only what both retrievers rank
18//      (SCAN_MIN_SCORE), the agreement the bash hook's label gate meant to
19//      require. A keyword-only vault scores in BM25 units, far above both,
20//      and the classifier is its filter.
21//   2. The note types worth recalling (TYPES).
22//   3. A per-session dedupe set: a note is shown once per session.
23//   4. A relevance pass through $.model.classify, one label per hit. A pass
24//      that fails or runs past CLASSIFY_TIMEOUT_MS falls back to the hits the
25//      first three filters kept, so a slow classifier never costs a recall.
26//
27// The search runs through $.mcp.call on the memory server in core's manifest,
28// where the bash hooks started the markdown-vault-mcp CLI, about 1 s each.
29//
30// Pure functions only: the engine follows `$` into no imported function.
31
32export const PROMPT_MIN_SCORE = 0.015
33export const SCAN_MIN_SCORE = 0.02
34export const PROMPT_LIMIT = 2
35export const SCAN_LIMIT = 1
36// Hits fetched per hit wanted: the filters drop most of them.
37export const FETCH_FACTOR = 4
38export const CLASSIFY_TIMEOUT_MS = 2500
39export const RELEVANT = 'relevant'
40export const UNRELATED = 'unrelated'
41export const LABELS = [RELEVANT, UNRELATED] as const
42export const TYPES: ReadonlySet<string> = new Set([
43  'decision',
44  'insight',
45  'topic',
46  'feedback',
47  'reference',
48  'project',
49  'skill-learnings',
50  'recurring-issue',
51])
52
53const PROMPT_MIN_CHARS = 16
54const SCAN_MIN_CHARS = 6
55const QUERY_MAX_CHARS = 500
56const SUMMARY_MAX_CHARS = 160
57export const TITLE_MAX_CHARS = 100
58const PATH_MAX_CHARS = 200
59
60// One line of at most `max` characters: a note's title or summary is the
61// vault's text, so a newline in it must not open a line of its own in the
62// block the model reads.
63export const lineOf = (text: string, max: number): string => {
64  const line = text.replace(/\s+/g, ' ').trim()
65  return line.length > max ? `${line.slice(0, max)}…` : line
66}
67const ACKS = /^(y|n|ok|okay|yes|no|yep|nope|sure|thanks|thank you|ty|go|go ahead|do it|continue|proceed|next|done|stop|wait)[.!? ]*$/i
68
69// The search query a prompt carries, or undefined when it carries none: a
70// slash command, a scheduled tick, an acknowledgement, or under 16 characters.
71// The same substance gate hooks/memory-recall.sh applied.
72export function promptQuery(text: string): string | undefined {
73  const trimmed = text.replace(/\n/g, ' ').trim()
74  if (trimmed.startsWith('/') || trimmed.startsWith('<scheduled-task ')) return undefined
75  if (trimmed.length < PROMPT_MIN_CHARS || ACKS.test(trimmed)) return undefined
76  return trimmed.slice(0, QUERY_MAX_CHARS)
77}
78
79// Whether a Bash command can hold a content search at all, before the
80// extractor (hooks/lib/scan-query.py) is started to read it.
81export const mayScan = (command: string): boolean => command.includes('grep') || /(^|[^\w-])(rg|ripgrep|ag|ack)([^\w-]|$)/.test(command)
82
83// The query scan-query.py read out of a search, or undefined when it is too
84// thin to search on: under 6 characters without spaces, no letter, or one
85// plain word. A camelCase identifier is a topic, and passes.
86export function scanQuery(extracted: string): string | undefined {
87  const query = extracted.trim()
88  if (query.replace(/ /g, '').length < SCAN_MIN_CHARS || !/[A-Za-z]/.test(query)) return undefined
89  if (!query.includes(' ') && !/[a-z][A-Z]/.test(query)) return undefined
90  return query
91}
92
93// `searchType` is the server's label for how the hit was found: `keyword`
94// when the vault has no embeddings and the search fell back to BM25, whose
95// score is in its own units; `semantic` or `hybrid` from a hybrid search, whose
96// score is a rank-fusion value. Absent when the server sent none.
97export type Hit = { path: string; title: string; type: string; summary: string; score: number; searchType?: string }
98
99// The hits in a memory MCP search result: the text block's JSON, either a list
100// or `{ result: [...] }`. Anything else is no hits.
101export function hitsOf(content: readonly { type: string; text?: string }[]): Hit[] {
102  const text = content.find(block => block.type === 'text' && typeof block.text === 'string')?.text
103  if (text === undefined) return []
104  let parsed: unknown
105  try {
106    parsed = JSON.parse(text)
107  } catch {
108    return []
109  }
110  const rows = Array.isArray(parsed) ? parsed : (parsed as { result?: unknown } | null)?.result
111  if (!Array.isArray(rows)) return []
112  const hits: Hit[] = []
113  for (const row of rows) {
114    if (typeof row !== 'object' || row === null) continue
115    const { path, title, score, frontmatter, sections, search_type: searchType } = row as Record<string, unknown>
116    if (typeof path !== 'string' || path === '' || typeof score !== 'number') continue
117    const fm = (typeof frontmatter === 'object' && frontmatter !== null ? frontmatter : {}) as Record<string, unknown>
118    const first = Array.isArray(sections) ? (sections[0] as { content?: unknown } | undefined) : undefined
119    const summary = typeof fm.summary === 'string' ? fm.summary : typeof first?.content === 'string' ? first.content : ''
120    hits.push({
121      path,
122      title: lineOf(typeof title === 'string' && title.trim() !== '' ? title : typeof fm.name === 'string' ? fm.name : path, TITLE_MAX_CHARS),
123      type: typeof fm.type === 'string' ? fm.type : 'note',
124      summary: lineOf(summary, 400),
125      score,
126      ...(typeof searchType === 'string' ? { searchType } : {}),
127    })
128  }
129  return hits
130}
131
132// Filters 1 to 3: the threshold, the types, and the notes this session has
133// seen, best first, at most `limit`.
134export const candidatesOf = (hits: readonly Hit[], seen: readonly string[], limit: number, minScore: number): Hit[] =>
135  hits
136    .filter(hit => hit.score >= minScore && TYPES.has(hit.type) && !seen.includes(hit.path))
137    .filter((hit, i, all) => all.findIndex(other => other.path === hit.path) === i)
138    .slice(0, limit)
139
140// What the classifier reads for one hit: the task, then the note.
141export const relevanceText = (task: string, hit: Hit): string =>
142  `Would this note from a memory vault help with the task? Answer ${RELEVANT} or ${UNRELATED}.\n\nTask: ${task}\n\nNote: ${hit.title} [${hit.type}] - ${hit.summary.slice(0, 400)}`
143
144// Filter 4: the hits whose label is not UNRELATED. A label that is neither
145// keeps its hit, as the fallback does: only a definite "unrelated" drops one.
146export const relevantOf = (hits: readonly Hit[], labels: readonly (string | undefined)[]): Hit[] =>
147  hits.filter((_, i) => labels[i] !== UNRELATED)
148
149// The one compact block the model reads beside the prompt or the tool result.
150export function blockOf(hits: readonly Hit[], scan?: string): string {
151  const header =
152    scan === undefined
153      ? '🧠 Vault recall (verify against current code before acting; these reflect what was true when written):'
154      : `🧠 Vault recall for this scan, "${scan.slice(0, 60)}" (verify against current code before acting):`
155  const bullets = hits.map(hit => `• ${lineOf(hit.title, TITLE_MAX_CHARS)} [${lineOf(hit.type, 40)}] - ${lineOf(hit.summary, SUMMARY_MAX_CHARS)} (${lineOf(hit.path, PATH_MAX_CHARS)})`)
156  return [header, ...bullets].join('\n')
157}
158
159// About four characters to a token: the measure the recall figures use.
160export const tokensOf = (text: string): number => Math.ceil(text.length / 4)
161
hooks/mods/cache-meter.ts 119 lines
1// The cache meter and its churn view.
2//
3// Every main-loop API request is recorded with its cache read against its
4// cache creation, as the API reported them for that response. The status line
5// shows the latest request's hit share ("cache 97%"). A request that creates
6// more than it reads, past the first, is a creation spike: something before
7// the cached tail changed, or the cache expired. The meter then hashes each
8// section of the system prompt and names the sections whose hash changed since
9// the last reading, which is the churn the mods plan's first design rule asks
10// to find. A spike with no changed section was not the system prompt's doing:
11// an expired cache, a compaction, or a large tool result. It is recorded, and
12// only a spike that names a section is shown to Mike.
13//
14// Nothing here writes to the system prompt. The figures change every request,
15// so they go to the status line, a toast and a file under ~/.claude-workbench,
16// all of which the model never reads.
17//
18// Pure functions only: the engine follows `$` into no imported function, so the
19// hooks that keep the records live in hooks/register.ts.
20
21import type { TurnUsage } from 'claude-code'
22
23import type { CacheRequest, CacheState, ChurnEvent } from '../../types'
24
25// The section hashes of a reading, by section id.
26export type SectionHashes = Readonly<Record<string, string>>
27
28export const EMPTY_CACHE: CacheState = { count: 0, requests: [], churn: [], hashes: null }
29
30// The record keeps the latest requests only, so a long session stays small.
31// types/index.d.ts states the same figure.
32export const KEPT = 500
33// Below this, a request is too small to be a spike: the API caches no prefix
34// shorter than about 1,024 tokens.
35export const SPIKE_MIN = 1024
36
37// FNV-1a over the UTF-16 code units, as eight hex digits. Two texts that differ
38// almost always hash apart, and the same text always hashes the same.
39export function hashOf(text: string): string {
40  let hash = 0x811c9dc5
41  for (let i = 0; i < text.length; i++) {
42    hash ^= text.charCodeAt(i)
43    hash = Math.imul(hash, 0x01000193) >>> 0
44  }
45  return hash.toString(16).padStart(8, '0')
46}
47
48export const hashesOf = (sections: readonly { id: string; text: string }[]): Record<string, string> =>
49  Object.fromEntries(sections.map(section => [section.id, hashOf(section.text)]))
50
51// The sections whose text changed, or that are new, in prompt order, then the
52// sections that went away.
53export function changedSections(before: SectionHashes, after: SectionHashes): string[] {
54  const changed = Object.keys(after).filter(id => before[id] !== after[id])
55  const gone = Object.keys(before).filter(id => !(id in after))
56  return [...changed, ...gone]
57}
58
59export function cacheRequestOf(n: number, usage: TurnUsage): CacheRequest {
60  return { n, read: usage.cache_read_input_tokens, creation: usage.cache_creation_input_tokens, input: usage.input_tokens }
61}
62
63export const countCache = (state: CacheState, request: CacheRequest): CacheState => ({
64  ...state,
65  count: request.n,
66  requests: [...state.requests, request].slice(-KEPT),
67})
68
69// The share of the request's input read from the cache, in whole percent.
70// Undefined for a request with no input.
71export function hitOf(request: CacheRequest): number | undefined {
72  const total = request.read + request.creation + request.input
73  return total === 0 ? undefined : Math.round((100 * request.read) / total)
74}
75
76// Whether the request re-created more of the prompt than it read. The first
77// request creates the cache by design, so it is never a spike.
78export const isSpike = (request: CacheRequest): boolean =>
79  request.n > 1 && request.creation >= SPIKE_MIN && request.creation > request.read
80
81export const withChurn = (state: CacheState, event: ChurnEvent): CacheState => ({ ...state, churn: [...state.churn, event] })
82
83// The spikes that named a changed section.
84export const sectionChurn = (state: CacheState): readonly ChurnEvent[] =>
85  state.churn.filter(event => event.sections !== null && event.sections.length > 0)
86
87// The status line's facts: the latest hit share, and the number of spikes a
88// section change caused, when there was one.
89export function cacheFactsOf(state: CacheState): string[] {
90  const last = state.requests[state.requests.length - 1]
91  const hit = last === undefined ? undefined : hitOf(last)
92  if (hit === undefined) return []
93  const churned = sectionChurn(state).length
94  return churned > 0 ? [`cache ${hit}%`, `churn ${churned}`] : [`cache ${hit}%`]
95}
96
97const tokens = (n: number): string => n.toLocaleString('en-US')
98
99// The toast for a spike that named a section.
100export function churnText(event: ChurnEvent): string {
101  const names = (event.sections ?? []).join(', ')
102  const noun = (event.sections ?? []).length === 1 ? 'section' : 'sections'
103  return `Prompt cache churn at request ${event.n}: system-prompt ${noun} ${names} changed, and ${tokens(event.creation)} tokens were cached again.`
104}
105
106// What the record file holds: every kept request with its hit share, and every
107// spike. Stable key order, so two writes of one state are byte-identical.
108export function recordOf(state: CacheState): string {
109  const requests = state.requests.map(request => ({ ...request, hit: hitOf(request) ?? null }))
110  return `${JSON.stringify({ count: state.count, requests, churn: state.churn }, null, 1)}\n`
111}
112
113// The record file for a session, or undefined when the session id or home
114// cannot name one safely.
115export function recordFileOf(home: string | undefined, sessionId: string): string | undefined {
116  if (!home || !home.startsWith('/') || !/^[A-Za-z0-9][A-Za-z0-9-]*$/.test(sessionId)) return undefined
117  return `${home.replace(/\/+$/, '')}/.claude-workbench/cache-meter/${sessionId}.json`
118}
119
hooks/mods/commit-approval.ts 413 lines
1// The commit approval rule: a main-session `git commit` or `git push` happens
2// only after Mike picks "Commit it" in AskUserQuestion, the commit question is
3// asked alone, and one pick approves one commit and the push of that commit.
4//
5// The output style states the rule (rule 6), and workbench-dev-team's
6// git-commit skill holds the mechanics ("Committing and pushing"). Approval is
7// per commit, never standing (vault: feedback/commit-approval-is-per-commit-
8// never-standing). Claude Code's permission prompt for `git commit *` and
9// `git push *` is the mechanical backstop, and a prompt that appears mid-flow
10// gets answered without a review. hooks/register.ts enforces the checkable half
11// with the functions below:
12//
13//   tool.call (Bash)             A commit needs an unused "Commit it" pick. A
14//                                push needs the commit that pick approved,
15//                                run before it. Any prompt ends an unused
16//                                pick, and Mike's own ends the push too.
17//   tool.call (AskUserQuestion)  A call that offers a commit option beside
18//                                another question is refused. A "Commit it"
19//                                pick is recorded.
20//
21// Whether Mike's review is done needs judgement, so that half stays prose.
22// The check never decides which tool the model uses: it governs what a commit
23// needs, and a command that only mentions git is let through.
24//
25// THE SHELL READER is hooks/mods/shell.ts, the one reader core and
26// workbench-dev-team share through $.workbench.parseShell. This check reads a
27// line through its commandsOf(): quotes, backslashes, separators, redirects
28// dropped with their targets, $( ), backticks and <( ), and heredoc bodies
29// taken out as bash takes them out. The rules here err toward finding a
30// commit, so a doubt costs a "Commit it" question and never a commit nobody
31// approved. A command is read past assignments, wrappers (env, sudo, nice,
32// timeout, xargs, ...) up to the first word named git, `bash -c` and `eval`
33// scripts, and git's global options, and `git` and its subcommand are matched
34// without regard to case, as macOS finds `GIT` on its case-insensitive disk.
35// A `-c alias.<name>=<value>` that names commit or push counts as that.
36//
37// A heredoc body is read as a script only when it feeds a shell or eval
38// (`bash <<EOF`), and the $( ) and backticks of a body with an unquoted
39// delimiter are read, as bash runs them.
40//
41// A word with any $'…' backslash escape is marked ESCAPED by the reader. Such
42// a word as a command name, or among git's options and subcommand, counts as
43// a commit and a push: `$'\x67it' push` is git push, and an escape the reader
44// does not decode (\u, \U, \c) could spell anything.
45//
46// Where the line commits, it also says where: the `cd` and `pushd` targets
47// before it, then git's -C values. hooks/register.ts reads HEAD there before
48// and after the line, so the approval follows what the repository shows.
49// A cd or pushd is recorded only in the one certain shape: the first word of a
50// command before any && or || on a line with no pipe, no lone &, no ( or
51// backtick and no heredoc, outside any shell -c script (eval keeps it), with a
52// target that is absolute, under ~, or starts with ./ or ../, and dirOf then
53// leaves any target holding `..` unknown. A bare relative
54// target (`cd sub`) is always unknown, because CDPATH can redirect it, and a
55// line can set CDPATH under a name built at run time. The cost is one new pick
56// when a `cd sub; git commit` line also fails. A pushd counts only as
57// `pushd <dir>`. The directory is UNKNOWN, which uses the approval up, when
58// the line holds:
59//   - any other cd or pushd, or any popd, source or . command word: behind
60//     if, {, !, command or an assignment, in a pipeline (bash runs the stages
61//     in children, zsh runs the last here), behind &, in a subshell, a
62//     substitution, a heredoc, or a shell -c script
63//   - GIT_DIR or GIT_WORK_TREE, --git-dir, --work-tree, env -C or --chdir
64//   - a cd or -C target with a variable, a substitution, `-`, or an escape
65//   - any `..` in a cd or -C target, which the shell and git resolve apart
66//     when the directory before it is a symlink (dirOf)
67//   - an escaped command word
68//
69// WHAT IT CANNOT SEE, by design, because the line does not say:
70//   - an alias in a git config file (`git ci` with alias.ci = commit)
71//   - commands that make commits without the word: merge, revert,
72//     cherry-pick, am, rebase, pull, stash
73//   - text piped into a shell (`echo git push | sh`, `cat <<EOF | sh`)
74//   - a here-string's text (`sh <<< 'git push'`)
75//   - a script file, an interpreter (`python -c`), or a shell function
76// Those stay with the permission rules, dev-team's commit guard, and review.
77//
78// Pure functions only: the engine follows `$` into no imported function.
79
80import { ASSIGNMENT, GIT_VALUE_OPTIONS, KEYWORDS, SHELLS, WRAPPERS, commandsOf, isEscaped, nameOf, shellScriptOf } from './shell'
81
82export { commandsOf }
83
84export const COMMIT_REFUSAL =
85  'This git commit was refused: no unused "Commit it" pick from Mike approves ' +
86  'it. Ask first. Once he says his review is done, ask the commit question ' +
87  'through AskUserQuestion, alone, with the branch and the proposed message, ' +
88  'and run the commit again after he picks "Commit it". One pick approves one ' +
89  'commit and the push of that commit. Any prompt ends a pick not yet used ' +
90  'for its commit. A typed ' +
91  '"commit it" in chat does not count.'
92
93export const PUSH_REFUSAL =
94  'This git push was refused: it does not follow a commit Mike approved with ' +
95  'a "Commit it" pick. Ask first. One pick approves one commit and the push of ' +
96  'that commit, in that order. Mike\'s next prompt ends a push left by an ' +
97  'approved commit.'
98
99export const ONE_REFUSAL =
100  'This line runs more than one git commit, or more than one git push. One ' +
101  '"Commit it" pick approves one commit and the push of that commit. Run them ' +
102  'as separate lines, and ask first for each further commit.'
103
104export const BUNDLE_REFUSAL =
105  'AskUserQuestion was refused: it offers a commit option beside another ' +
106  'question. Ask the commit question alone, in an AskUserQuestion call of its ' +
107  'own, and ask the other questions in a separate call.'
108
109// The label that approves, as the git-commit skill prescribes it.
110const APPROVAL = 'commit it'
111
112// An option that offers a commit: its label leads with the word.
113const COMMIT_OPTION = /^\s*commit\b/i
114
115// Words that stand before the command they run: the shell reader's wrappers
116// and keywords. After one, the command is the first later word named git, or
117// a shell or eval to read again, so a wrapper's options and their values never
118// hide it.
119const WRAPPER_WORDS: ReadonlySet<string> = new Set([...Object.keys(WRAPPERS), ...KEYWORDS])
120
121// How many commits and pushes a command or a line runs.
122export type Writes = { commits: number; pushes: number }
123
124const NONE: Writes = { commits: 0, pushes: 0 }
125const sum = (a: Writes, b: Writes): Writes => ({ commits: a.commits + b.commits, pushes: a.pushes + b.pushes })
126
127// Past this depth of nested scripts, a line is taken as a commit.
128const MAX_DEPTH = 4
129
130// What a line runs, as the reader walks it: the commits and pushes, the
131// directories `cd` and `pushd` moved to so far, and where the first commit or
132// push runs: the steps from the session's directory (`cd` targets, then git's
133// -C values), null when a step is a substitution or a variable the reader
134// cannot follow, and undefined until a commit or push is found.
135export type Line = { writes: Writes; dir: readonly string[] | null | undefined }
136// `isUnfollowed` is set by any directory change the walk does not record.
137// `isCompat` reads the line as the gate before the shared reader did (readLine).
138type Walk = { writes: Writes; cds: string[]; dir: string[] | null | undefined; isUnfollowed: boolean; isCompat: boolean }
139
140// The command words that move the shell or change its environment: a
141// directory change, or a sourced file, which may set GIT_DIR.
142const PLACE_WORDS: ReadonlySet<string> = new Set(['cd', 'pushd', 'popd', 'source', '.'])
143
144// Whether words[i] is a command word: every word before it is an assignment,
145// a wrapper or keyword (if, {, !, command, ...), or an option.
146const isCommandWord = (words: readonly string[], i: number): boolean =>
147  words.slice(0, i).every(word => ASSIGNMENT.test(word) || WRAPPER_WORDS.has(nameOf(word)) || word.startsWith('-'))
148
149// Whether every command of a line runs in this shell, one after another: no
150// pipe and no lone & (bash runs those stages in a child, and zsh runs the
151// last one here), and no ( or backtick, which may open a subshell or a
152// substitution, and no heredoc, whose body may feed a child shell. A | or &
153// inside quotes counts too, which only costs a recorded cd.
154const isSimpleLine = (line: string): boolean =>
155  !/[(`]/.test(line) && !line.includes('<<') && !/[|&]/.test(line.replace(/\|\||&&|[<>]&|&>/g, ''))
156
157const BOTH: Writes = { commits: 1, pushes: 1 }
158
159// A step the reader cannot follow: the directory is then unknown, which counts
160// as done.
161const UNKNOWN = '$'
162
163// What moves git to a repository the steps do not show.
164const GIT_PLACE = /^GIT_(DIR|WORK_TREE)=/
165const GIT_PLACE_OPTION = /^--(git-dir|work-tree)(=|$)/
166const ENV_CHDIR = /^(-[A-Za-z]*C|--chdir(=|$))/
167
168const stepsOf = (steps: string[]): string[] | null =>
169  steps.some(step => step.includes('$') || step === '-' || isEscaped(step)) ? null : steps
170
171function found(walk: Walk, writes: Writes, steps: string[]): void {
172  walk.writes = sum(walk.writes, writes)
173  if (walk.dir === undefined) walk.dir = stepsOf(steps)
174}
175
176// One simple command, behind any wrapper, assignment, shell script or global
177// option. `isHere` says the command surely runs in this shell, in order: its
178// line is simple (isSimpleLine) and not inside a shell -c script. eval keeps
179// it, as eval runs in this shell.
180function walkCommand(words: readonly string[], depth: number, isHere: boolean, walk: Walk): void {
181  // GIT_DIR or GIT_WORK_TREE, as a prefix, through env, or exported, points
182  // git elsewhere for this command or for the rest of the line.
183  if (words.some(word => GIT_PLACE.test(word))) walk.cds.push(UNKNOWN)
184  // THE DIRECTORY RULE. A cd or pushd is recorded only as the first word of a
185  // command that surely runs in this shell, before any && or || on its line,
186  // with a target that is absolute, under ~, or starts with ./ or ../. A bare
187  // relative target (`cd sub`) is always unknown, because CDPATH can redirect
188  // it and the line can set CDPATH under a name built at run time. A pushd
189  // that is not `pushd <dir>` (no target, +N, -N or an option rotates the
190  // stack or stays put) is unknown too. Every other cd, pushd, popd, source or . in a
191  // command-word place leaves the directory unknown: behind if, {, !, command
192  // or an assignment, in a pipeline, behind &, in a subshell, a substitution,
193  // a heredoc or a shell -c script.
194  const placeAt = words.findIndex((word, j) => PLACE_WORDS.has(nameOf(word)) && isCommandWord(words, j))
195  const head = nameOf(words[0] ?? '')
196  if (placeAt === 0 && isHere && !isEscaped(words[0] ?? '') && (head === 'cd' || head === 'pushd')) {
197    const target = words.slice(1).find(word => !word.startsWith('-') || word === '-') ?? '~'
198    const isPlainPushd = words.length === 2 && !/^[+-]/.test(words[1] ?? '')
199    const isUnknown = isBareRelative(target) || (head === 'pushd' && !isPlainPushd)
200    walk.cds.push(isUnknown ? UNKNOWN : target)
201    return
202  }
203  if (placeAt !== -1) walk.isUnfollowed = true
204  let i = 0
205  while (i < words.length) {
206    const word = words[i] as string
207    const name = nameOf(word)
208    if (ASSIGNMENT.test(word)) {
209      i++
210    } else if (isEscaped(word)) {
211      // A command name with an escape may be git, a shell, or a cd.
212      walk.isUnfollowed = true
213      found(walk, BOTH, [...walk.cds])
214      return
215    } else if (name === 'eval') {
216      walkLine(words.slice(i + 1).join(' '), depth + 1, isHere, walk)
217      return
218    } else if (SHELLS.has(name)) {
219      // The script is the first operand after the options, when an option
220      // cluster holds `c` (shellScriptOf, which `--` and -o values do not
221      // fool). A shell with none runs a file. The script runs in a child
222      // shell, so no cd in it moves this one. The compat reading takes the
223      // word after the first option holding `c`, as the gate before did.
224      const at = words.findIndex((option, j) => j > i && /^-[^-]*c/.test(option))
225      // FROZEN compat branch: delete only, once the bash/zsh check retires compat.
226      const script = walk.isCompat ? (at === -1 ? undefined : (words[at + 1] ?? '')) : shellScriptOf(words.slice(i + 1)).script
227      if (script !== undefined) walkLine(script, depth + 1, false, walk)
228      return
229    } else if (WRAPPER_WORDS.has(name)) {
230      const at = words.findIndex((later, j) => j > i && (isEscaped(later) || ['git', 'eval', ...SHELLS].includes(nameOf(later))))
231      if (at === -1) return
232      if (name === 'env' && words.slice(i + 1, at).some(option => ENV_CHDIR.test(option))) walk.cds.push(UNKNOWN)
233      i = at
234    } else {
235      break
236    }
237  }
238  if (nameOf(words[i] ?? '') !== 'git') return
239  const steps = [...walk.cds]
240  let aliased: Writes = NONE
241  for (i++; i < words.length; i++) {
242    const word = words[i] as string
243    // An escaped option or subcommand may spell commit, push, or -C.
244    if (isEscaped(word)) {
245      found(walk, BOTH, steps)
246      return
247    }
248    if (!word.startsWith('-')) {
249      const sub = word.toLowerCase()
250      if (sub === 'commit') found(walk, { commits: 1, pushes: 0 }, steps)
251      else if (sub === 'push') found(walk, { commits: 0, pushes: 1 }, steps)
252      else if (aliased !== NONE) found(walk, aliased, steps)
253      return
254    }
255    if (word === '-C') steps.push(words[i + 1] ?? UNKNOWN)
256    if (GIT_PLACE_OPTION.test(word)) steps.push(UNKNOWN)
257    if (word === '-c') {
258      // `-c alias.ci=commit` makes `ci` a commit. The alias's name is not
259      // matched against the subcommand: naming commit or push is enough.
260      const value = /^alias\.[^=]*=(.*)$/is.exec(words[i + 1] ?? '')?.[1] ?? ''
261      if (/\bcommit\b/i.test(value)) aliased = sum(aliased, { commits: 1, pushes: 0 })
262      if (/\bpush\b/i.test(value)) aliased = sum(aliased, { commits: 0, pushes: 1 })
263    }
264    if (GIT_VALUE_OPTIONS.has(word)) i++
265  }
266  if (aliased !== NONE) found(walk, aliased, steps)
267}
268
269// How many commands of a line come before its first && or ||: those surely
270// run. A command after one may be skipped, so a cd there is not certain. The
271// cut is read on the raw text, so a && inside quotes cuts early, which only
272// costs a recorded cd: a cut text never holds more commands than the line.
273// FROZEN compat branch: delete only, once the bash/zsh check retires compat.
274function certainCount(line: string, isCompat: boolean): number {
275  const cut = line.search(/&&|\|\|/)
276  // FROZEN compat branch: delete only, once the bash/zsh check retires compat.
277  return cut === -1 ? Infinity : commandsOf(line.slice(0, cut), isCompat).length
278}
279
280function walkLine(line: string, depth: number, isHere: boolean, walk: Walk): void {
281  if (depth > MAX_DEPTH) {
282    found(walk, { commits: 1, pushes: 0 }, [UNKNOWN])
283    return
284  }
285  const isHereLine = isHere && isSimpleLine(line)
286  // FROZEN compat branch: delete only, once the bash/zsh check retires compat.
287  const certain = certainCount(line, walk.isCompat)
288  // FROZEN compat branch: delete only, once the bash/zsh check retires compat.
289  commandsOf(line, walk.isCompat).forEach((words, at) => walkCommand(words, depth, isHereLine && at < certain, walk))
290}
291
292// Whether a cd target may resolve through CDPATH: a relative path that does
293// not start with /, ~, ./ or ../ (`-`, the previous directory, is unknown
294// through stepsOf).
295const isBareRelative = (target: string): boolean => !/^(\/|~|\.\.?(\/|$)|-$)/.test(target)
296
297// What a Bash command line runs: its commits and pushes, and where. A
298// directory change the walk did not record (walkCommand's directory rule)
299// leaves the directory unknown, which uses the approval up.
300//
301// The line is read twice: as bash reads it, and as the gate of 77bb2f3 read
302// it, before the shared reader read bash more exactly (commandsOf's
303// isCompat). The larger count of each is kept, so the gate never finds fewer
304// commits than that one did. When the two readings differ, the directory is
305// unknown.
306export function readLine(line: string): Line {
307  // FROZEN compat branch: delete only, once the bash/zsh check retires compat.
308  const [exact, compat] = [false, true].map(isCompat => {
309    // FROZEN compat branch: delete only, once the bash/zsh check retires compat.
310    const walk: Walk = { writes: NONE, cds: [], dir: undefined, isUnfollowed: false, isCompat }
311    walkLine(line, 0, true, walk)
312    return { writes: walk.writes, dir: walk.isUnfollowed && walk.dir !== undefined ? null : walk.dir }
313  }) as [Line, Line]
314  const writes = { commits: Math.max(exact.writes.commits, compat.writes.commits), pushes: Math.max(exact.writes.pushes, compat.writes.pushes) }
315  const isSame = JSON.stringify(exact) === JSON.stringify(compat)
316  return { writes, dir: isSame ? exact.dir : writes.commits + writes.pushes > 0 ? null : undefined }
317}
318
319// How many commits and pushes a Bash command line runs.
320export const writesOf = (line: string): Writes => readLine(line).writes
321
322// The directory `steps` lead to from the session's `cwd`, or undefined when
323// they cannot be followed: a substitution, a variable, `cd -`, a `~` with no
324// home to expand it, or any `..`. The shell resolves `..` by the path as
325// written (`./link/..` is `.`), while git -C, and so the refs read there,
326// resolves it through the real path, so the two can land in different
327// repositories whenever the directory before it is a symlink: a named step,
328// the session's directory (a session in /tmp is in /private/tmp), or $HOME.
329export function dirOf(steps: readonly string[] | null | undefined, cwd: string, home: string | undefined): string | undefined {
330  if (steps === null) return undefined
331  let dir = cwd
332  for (const step of steps ?? []) {
333    let path = step
334    if (path === '~' || path.startsWith('~/')) {
335      if (!home) return undefined
336      path = `${home}${path.slice(1)}`
337    } else if (path.startsWith('~')) {
338      return undefined
339    }
340    if (step.split('/').includes('..')) return undefined
341    dir = path.startsWith('/') ? path : `${dir.replace(/\/+$/, '')}/${path}`
342  }
343  return dir
344}
345
346// Where the approval stands: `none`, a pick not yet used (`commit`), or a
347// commit made under the pick, whose push is still allowed (`push`).
348export type Approval = 'none' | 'commit' | 'push'
349
350// Why a line that commits or pushes is refused at this approval, or undefined
351// when it may run.
352export function refusalOf(writes: Writes, approval: Approval): string | undefined {
353  if (writes.commits > 1 || writes.pushes > 1) return ONE_REFUSAL
354  if (writes.commits === 1) return approval === 'commit' ? undefined : COMMIT_REFUSAL
355  return approval === 'push' ? undefined : PUSH_REFUSAL
356}
357
358// The repository's state around a line: HEAD, and what the branch's push
359// target (@{push}) points at. A field is undefined when git could not say.
360export type Refs = { head?: string; pushed?: string }
361
362// Where the approval stands once an allowed line has run. Neither the exit
363// status (`| tail`, `; echo`, `|| true` and a background run hide it) nor the
364// refs (the commit may land in a repository the reader did not read) is
365// enough alone, so the pick is kept only when both say the commit failed: HEAD
366// did not move and the line reported an error. Otherwise the commit counts as
367// landed, and its push is left, or used up too when the push target now holds
368// HEAD. A push is done when the push target holds HEAD. What cannot be seen
369// (no refs, a background run, a directory the reader could not follow, no
370// push target) is taken as done, so a doubt uses the approval up and never
371// stretches it.
372export function approvalAfter(writes: Writes, before: Refs | undefined, after: Refs | undefined, isError: boolean): Approval {
373  const isSeen = before?.head !== undefined && after?.head !== undefined
374  if (writes.commits === 1 && isSeen && isError && after?.head === before?.head) return 'commit'
375  if (writes.pushes === 0) return 'push'
376  const isPushed = !isSeen || after?.pushed === undefined || after.pushed === after.head
377  return isPushed ? 'none' : 'push'
378}
379
380type Question = { question?: unknown; options?: readonly { label?: unknown }[] }
381
382// Whether one AskUserQuestion call offers a commit option beside another
383// question.
384export function bundlesCommit(questions: unknown): boolean {
385  if (!Array.isArray(questions) || questions.length < 2) return false
386  return (questions as Question[]).some(
387    question => Array.isArray(question?.options) && question.options.some(option => typeof option?.label === 'string' && COMMIT_OPTION.test(option.label)),
388  )
389}
390
391// Whether a resolved AskUserQuestion dialog carries Mike's "Commit it" pick.
392// `questions` is the call's own input. The answer must be, character for
393// character, the label of a "Commit it" option of the question it answers.
394// Text typed under Other comes back as the answer too, so this is how a pick
395// is told from typing: "commit it" typed in any other spelling is refused. Text
396// typed to match the label exactly cannot be told from a pick. A dialog that
397// resolved while Mike was away (afkTimeoutMs), and a reply typed instead of
398// choosing (response), carry no pick.
399export function isCommitPick(questions: unknown, result: unknown): boolean {
400  if (!Array.isArray(questions) || typeof result !== 'object' || result === null) return false
401  const { answers, afkTimeoutMs, response } = result as { answers?: unknown; afkTimeoutMs?: unknown; response?: unknown }
402  if (afkTimeoutMs !== undefined || (typeof response === 'string' && response.trim() !== '')) return false
403  if (typeof answers !== 'object' || answers === null) return false
404  return (questions as Question[]).some(question => {
405    const answer = typeof question?.question === 'string' ? (answers as Record<string, unknown>)[question.question] : undefined
406    return (
407      typeof answer === 'string' &&
408      Array.isArray(question.options) &&
409      question.options.some(option => option?.label === answer && answer.trim().toLowerCase() === APPROVAL)
410    )
411  })
412}
413
hooks/mods/lane.ts 58 lines
1// Who is at the other end of a turn. The question rule enforces only where Mike
2// is there to answer AskUserQuestion. Everywhere else a re-prompt or a refusal
3// would loop, because nobody can answer the dialog.
4//
5// Three levels, each with its own signal:
6//
7//   session   isInteractive is false for `claude -p` and the SDK. A top-level
8//             `claude -p --agent` run (the Index pipeline) also sets
9//             CLAUDE_CODE_AGENT. bin/dispatch-agent.sh exports
10//             WORKBENCH_DEV_TEAM_PIPELINE=1, which is read as a backstop.
11//   turn      The prompt that opened the turn: its origin, and the
12//             `<scheduled-task ` wrapper a scheduled fire carries, since a
13//             desktop scheduled task can run in a session that looks
14//             interactive.
15//             A task notification counts as attended: in an interactive
16//             session it is where a commit question or a relayed result is
17//             asked, and stop_hook_active already caps its re-prompt at one.
18//   loop      A sub-agent's tool.call carries agentId, and its Stop is a
19//             SubagentStop. A Stop from a top-level --agent run carries
20//             agent_type.
21//
22// Every unknown reads as unattended, so the rule never fires on a guess.
23//
24// $.workbench.isUnattended() and callerLane() answer from these same functions,
25// so the question rule and every dependent plugin read one lane definition.
26//
27// Pure functions only: the engine follows `$` into no imported function, so the
28// hooks that read the signals live in hooks/register.ts.
29
30import type { PromptOrigin } from 'claude-code'
31
32import type { WorkbenchCallerLane } from '../../types'
33
34// The prompt origins that open a turn a person in an interactive session
35// answers: what they typed or clicked, and a background task's notification. A
36// peer, a channel, an SDK host, or a scheduled trigger opens a turn nobody may
37// be watching, so it is left out.
38const ATTENDED_ORIGINS: ReadonlySet<PromptOrigin['kind']> = new Set([
39  'composer',
40  'bridge',
41  'auto-continuation',
42  'task-notification',
43])
44
45export const isScheduledFire = (text: string): boolean => text.trimStart().startsWith('<scheduled-task ')
46
47export const isAttendedPrompt = (origin: PromptOrigin, text: string): boolean =>
48  ATTENDED_ORIGINS.has(origin.kind) && !isScheduledFire(text)
49
50export const isAttendedSession = (isInteractive: boolean, agent: string | undefined, pipeline: string | undefined): boolean =>
51  isInteractive && !agent && pipeline !== '1'
52
53// Who makes a call, from the agentId the call's event carries and the session's
54// CLAUDE_CODE_AGENT. A sub-agent's events carry agentId. A top-level
55// `claude -p --agent` run carries none, and is told apart by CLAUDE_CODE_AGENT.
56export const laneOf = (agentId: string | undefined, agent: string | undefined): WorkbenchCallerLane =>
57  agentId ? 'sub-agent' : agent ? 'top-level-agent' : 'main'
58
hooks/mods/shell.ts 1160 lines
1// The shell reader: one reading of a Bash command line, shared by every check
2// that reads one. $.workbench.parseShell answers with it, so workbench-dev-team's
3// guard ports read lines the way core's commit gate does. hooks/mods/
4// commit-approval.ts reads its lines through commandsOf() below.
5//
6// It is a reading of a line, not a shell. Where it is unsure it says so: the
7// result's `unknowns` names every part it could not read, and a statement's
8// `isPlaced` is false when its command name is a guess. A caller that must not
9// let an unread command through refuses a line with any unknown. The vault
10// lessons behind it: insights/2026-10-07-commit-gate-shell-reading-lessons.md
11// and insights/2026-09-26-shell-parsing-guards-lose-arms-race-use-allowlist.md.
12//
13// WHAT IT READS:
14//   - blanks: a word ends only at a space, a tab or a newline, as in bash. Any
15//     other space, such as U+00A0, is part of the word
16//   - quotes: '…', "…", $'…' with its escapes, and $"…", read as "…"
17//   - backslashes, and a backslash-newline, which bash deletes
18//   - the separators ; & | && || newline ( ), and comments
19//   - redirects anywhere in a command (`>out`, `2>&1`, `&>x`, `<in`), which
20//     are taken out of the words with their targets
21//   - $( ), backticks and <( ) >( ): their commands are statements of their
22//     own, and the outer word goes on with `$_` where the substitution stood
23//   - heredocs: one scanner over the whole text takes each body out as bash
24//     does (heredocBodiesOut). A body is text, not commands. It is read as a
25//     script only when it feeds a shell or eval, and the substitutions of a
26//     body with an unquoted delimiter are read, as bash runs them.
27//   - arithmetic ($(( )), $[ ], (( ))) and [[ ]] tests, whose < > are text
28//   - case…esac, whose patterns are text and whose `)` closes no $( ). A
29//     pattern that starts a line, or follows `;;`, is no statement
30//   - array assignments, `x=(a b)` and `x+=(c)`: one assignment word, whose
31//     substitutions alone run
32//   - prefixes: assignments, the keywords if then else elif do while until
33//     ! { coproc, and the wrappers in WRAPPERS with their own options
34//   - nested scripts: `bash -c`, `sh -c` and the other shells, `eval`, a trap
35//     handler, and a heredoc or here-string a shell reads as its script
36//
37// A $'…' string with any backslash escape marks its word ESCAPED. Bash may
38// decode such a word to any name (`$'\x67it'` is git), so a caller treats an
39// escaped command name as possibly anything.
40//
41// WHAT IT CANNOT SEE, because the line does not say: an alias or a function,
42// text piped into a shell (`echo x | sh`, flagged as the `stdin` unknown), a
43// script file, an interpreter (`python -c`), and what a variable holds (a
44// command name from one is the `expansion` unknown). A `watch` without -x, a
45// `sudo -s` and a `flock <file> -c` hand their words to a shell, so their
46// statements are not placed.
47//
48// Pure functions only: the engine follows `$` into no imported function.
49
50import type {
51  WorkbenchShellHeredoc as ShellHeredoc,
52  WorkbenchShellParse as ShellParse,
53  WorkbenchShellRedirect as ShellRedirect,
54  WorkbenchShellSource as ShellSource,
55  WorkbenchShellStatement as ShellStatement,
56  WorkbenchShellUnknown as ShellUnknown,
57} from '../../types'
58
59// The mark a word carries when a $'…' string in it held any backslash escape.
60const ESCAPED = '\uE000'
61
62// The mark the lexer puts before a `$` written outside any quote. Such an
63// expansion is split into words by bash, so `$P/x` may run any command,
64// where `"$P"/x` names one path.
65const UNQUOTED = '\uE002'
66
67// What a substitution leaves in the word it stood in. It carries the
68// UNQUOTED mark, quoted or not, so `$(cmd)x/y` is never read as the plain
69// variable `$_x` before a path: what a substitution prints is known only at
70// run time.
71const SUBSTITUTED = UNQUOTED + '$_'
72
73// The heredoc a `<` redirect target stands for: the mark, then its index.
74const HEREDOC = '\uE001'
75
76export const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*\+?=/
77
78export const SHELLS: ReadonlySet<string> = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'fish'])
79
80// Reserved words that stand before a command and run nothing themselves.
81export const KEYWORDS: ReadonlySet<string> = new Set(['if', 'then', 'else', 'elif', 'do', 'while', 'until', '!', '{', 'coproc'])
82
83// Reserved words that close a compound command. They run nothing, and a
84// redirect after one (`{ x; } >out`) is the group's.
85const CLOSERS: ReadonlySet<string> = new Set(['}', 'fi', 'done', 'esac'])
86
87// Reserved words after which a statement may not run.
88const BRANCHES: ReadonlySet<string> = new Set(['if', 'then', 'else', 'elif', 'do', 'while', 'until', 'case', 'for', 'select', 'function'])
89
90// How a wrapper's own options are read, so the command after them is placed.
91// Short letters: `value` takes the next word (or the rest of its cluster),
92// `flags` take none, and `stops` mean the wrapper runs no command, so the
93// wrapper is the name (`command -v git`). Long names the same, without the
94// dashes. `positional` counts the words the wrapper takes before the command
95// (timeout's duration, flock's lock file). An option not listed cannot be
96// placed: the statement is not placed, and the reader goes on as though the
97// option took no value.
98//
99// commit-approval.ts reads the names as its wrapper words too, so a wrapper
100// added here is one more word the commit gate looks past for a git.
101type WrapperOptions = {
102  value?: string
103  flags?: string
104  stops?: string
105  longValue?: readonly string[]
106  longFlags?: readonly string[]
107  longStops?: readonly string[]
108  positional?: number
109  // nice takes `-5` as its adjustment.
110  numeric?: boolean
111}
112
113const TIMEOUT: WrapperOptions = { value: 'sk', flags: 'fpv', longValue: ['signal', 'kill-after'], longFlags: ['preserve-status', 'foreground', 'verbose'], positional: 1 }
114
115export const WRAPPERS: Readonly<Record<string, WrapperOptions>> = {
116  env: { value: 'uCP', flags: 'iv0', longValue: ['unset', 'chdir'], longFlags: ['ignore-environment', 'null', 'debug'] },
117  exec: { value: 'a', flags: 'cl' },
118  command: { flags: 'p', stops: 'vV' },
119  builtin: {},
120  nohup: {},
121  sudo: {
122    value: 'CDgpRrTtUu',
123    // -s and -i hand the command to a shell's -c, so they are left out: the
124    // statement is then not placed.
125    flags: 'ABbEHknPS',
126    stops: 'eKlVv',
127    longValue: ['user', 'group', 'prompt', 'chdir', 'chroot', 'close-from', 'host', 'other-user', 'role', 'type', 'command-timeout'],
128    longFlags: [
129      'askpass', 'background', 'bell', 'preserve-env', 'set-home', 'non-interactive', 'preserve-groups', 'stdin', 'remove-timestamp',
130      'reset-timestamp',
131    ],
132    longStops: ['edit', 'list', 'validate', 'version', 'help'],
133  },
134  doas: { value: 'Cu', flags: 'ns', stops: 'L' },
135  time: { value: 'fo', flags: 'ahlpqv', longValue: ['format', 'output'], longFlags: ['portability', 'verbose', 'append', 'quiet'] },
136  nice: { value: 'n', longValue: ['adjustment'], numeric: true },
137  ionice: { value: 'cn', flags: 't', stops: 'pPu' },
138  xargs: {
139    value: 'EILJRSPdnsa',
140    flags: '0oprtx',
141    longValue: ['max-args', 'max-procs', 'max-chars', 'max-lines', 'delimiter', 'arg-file', 'process-slot-var', 'eof'],
142    longFlags: ['null', 'no-run-if-empty', 'verbose', 'interactive', 'exit', 'open-tty'],
143  },
144  timeout: TIMEOUT,
145  gtimeout: TIMEOUT,
146  stdbuf: { value: 'ioe', longValue: ['input', 'output', 'error'] },
147  caffeinate: { value: 'tw', flags: 'dimsu' },
148  chronic: { flags: 'ev' },
149  unbuffer: { flags: 'p' },
150  flock: {
151    value: 'wE',
152    flags: 'sxunoF',
153    longValue: ['timeout', 'wait', 'conflict-exit-code'],
154    longFlags: ['shared', 'exclusive', 'unlock', 'nonblock', 'nb', 'close', 'no-fork', 'verbose'],
155    positional: 1,
156  },
157  setsid: { flags: 'cfw', longFlags: ['ctty', 'fork', 'wait'] },
158  watch: {
159    value: 'n',
160    flags: 'bcdegprtwx',
161    longValue: ['interval'],
162    longFlags: ['beep', 'color', 'no-color', 'differences', 'errexit', 'chgexit', 'precise', 'no-title', 'no-wrap', 'exec'],
163  },
164}
165
166// git's and gh's global options that take their value as the next word.
167export const GIT_VALUE_OPTIONS: ReadonlySet<string> = new Set([
168  '-C', '-c', '--git-dir', '--work-tree', '--namespace', '--super-prefix', '--config-env', '--exec-path',
169])
170const GLOBAL_VALUE_OPTIONS: Readonly<Record<string, ReadonlySet<string>>> = {
171  git: GIT_VALUE_OPTIONS,
172  gh: new Set(['-R', '--repo']),
173}
174
175// A script in a statement this deep (substitutions, scripts and heredoc
176// bodies counted) is not read, and the `depth` unknown is set.
177const MAX_DEPTH = 4
178
179// A word's command name: its last path part, lowercased, the escape mark
180// removed.
181export const nameOf = (word: string): string => {
182  const plain = word.replaceAll(ESCAPED, '').replaceAll(UNQUOTED, '')
183  return plain.slice(plain.lastIndexOf('/') + 1).toLowerCase()
184}
185
186export const isEscaped = (word: string): boolean => word.includes(ESCAPED)
187
188// Whether a command word's text comes, even in part, from an expansion or a
189// substitution, other than a plain variable prefix before a literal path,
190// written inside double quotes. `$_` is where a substitution stood, so it is
191// never a plain prefix. `word` carries the lexer's UNQUOTED marks: an
192// unquoted expansion is split into words, and a substitution's text is known
193// only at run time, so neither is ever plain.
194const PLAIN_PREFIX = /^(\$(?!_\/)[A-Za-z_][A-Za-z0-9_]*|\$\{[A-Za-z_][A-Za-z0-9_]*\})\/[^$`]*$/
195export const isExpanded = (word: string): boolean => /[$`]/.test(word) && (word.includes(UNQUOTED) || !PLAIN_PREFIX.test(word))
196
197// What one read of a line collects: every heredoc, by index, and every unknown.
198// `isCompat` reads as the commit gate of 77bb2f3 did (commandsOf).
199type Reading = { heredocs: ShellHeredoc[]; unknowns: Set<ShellUnknown>; isCompat?: boolean }
200
201const ANSI_CONTROLS: Record<string, string> = { a: '\x07', b: '\b', e: '\x1b', E: '\x1b', f: '\f', n: '\n', r: '\r', t: '\t', v: '\v' }
202
203// The text of a $'…' string, its \xHH, \NNN and one-letter escapes decoded,
204// and any other escape (\u, \U, \c, ...) kept as written, which `onKept`
205// hears. Decoding is only for matching: a word with any escape is marked
206// ESCAPED anyway.
207function decodeAnsi(raw: string, onKept: () => void = () => undefined): string {
208  return raw.replace(/\\(x[0-9A-Fa-f]{1,2}|[0-7]{1,3}|.)/gs, (whole, escape: string) => {
209    if (escape.startsWith('x')) {
210      if (escape === 'x') onKept()
211      return String.fromCharCode(parseInt(escape.slice(1), 16))
212    }
213    if (/^[0-7]/.test(escape)) return String.fromCharCode(parseInt(escape, 8))
214    if (escape in ANSI_CONTROLS) return ANSI_CONTROLS[escape] as string
215    if (/^['"\\?]$/.test(escape)) return escape
216    onKept()
217    return whole
218  })
219}
220
221// The index of the `'` that closes a $'…' string whose text starts at `from`,
222// or the end of the text. In $'…' a backslash escapes the character after it,
223// \' included, unlike in '…'. Every reader here finds the close through this
224// one function, so none can read `$'it\'s'` as ending at the backslash.
225function closingAnsiQuote(text: string, from: number): number {
226  for (let i = from; i < text.length; i++) {
227    if (text[i] === '\\') i++
228    else if (text[i] === "'") return i
229  }
230  return text.length
231}
232
233// The index of the `)` that closes a `(` opened just before `from`, quotes and
234// backslashes read, or the end of the line when none does.
235//
236// Inside case…esac a `)` ends a pattern, not the substitution, so the case
237// words are counted (`$(case x in x) cmd;; esac)`).
238// FROZEN compat branch: delete only, once the bash/zsh check retires compat.
239function closingParen(line: string, from: number, countsCases = true): number {
240  let depth = 1
241  let cases = 0
242  for (let i = from; i < line.length; i++) {
243    const c = line[i]
244    const isWord = (name: string) => line.startsWith(name, i) && isWordStart(line, i) && /[ \t\n;&|()]/.test(line[i + name.length] ?? ' ')
245    // A case counts only where a command starts, as `case WORD in`.
246    const isCase = () => /^case[ \t\n]+[^ \t\n]+[ \t\n]+in([ \t\n]|$)/.test(line.slice(i)) && /(^|[;&|(\n])[ \t\n]*$/.test(line.slice(from, i))
247    // The compat reading (commandsOf) counts no case.
248    // FROZEN compat branch: delete only, once the bash/zsh check retires compat.
249    if (countsCases && isWord('case') && isCase()) cases++
250    // FROZEN compat branch: delete only, once the bash/zsh check retires compat.
251    else if (countsCases && isWord('esac') && cases > 0) cases--
252    if (c === '\\') i++
253    else if (c === '$' && line[i + 1] === "'") i = closingAnsiQuote(line, i + 2)
254    else if (c === "'") i = line.indexOf("'", i + 1) === -1 ? line.length : line.indexOf("'", i + 1)
255    else if (c === '"') {
256      for (i++; i < line.length && line[i] !== '"'; i++) if (line[i] === '\\') i++
257    } else if (c === '(') depth++
258    else if (c === ')' && !(depth === 1 && cases > 0) && --depth === 0) return i
259  }
260  return line.length
261}
262
263// The index of the backtick that closes one opened just before `from`.
264function closingTick(line: string, from: number): number {
265  for (let i = from; i < line.length; i++) {
266    if (line[i] === '\\') i++
267    else if (line[i] === '`') return i
268  }
269  return line.length
270}
271
272// The text of a backtick substitution as bash reads it: a backslash before
273// ` \ or $ is removed first, so ``echo `echo \`cmd\``` runs cmd.
274const backtickText = (raw: string): string => raw.replace(/\\([\\`$])/g, '$1')
275
276// The commands of every $( ) and backtick in `text`, as lines of their own.
277// FROZEN compat branch: delete only, once the bash/zsh check retires compat.
278function substitutionsOf(text: string, isCompat = false): string[] {
279  const inner: string[] = []
280  for (let i = 0; i < text.length; i++) {
281    if (text[i] === '\\') i++
282    else if (text[i] === '$' && text[i + 1] === '(') {
283      // FROZEN compat branch: delete only, once the bash/zsh check retires compat.
284      const end = closingParen(text, i + 2, !isCompat)
285      inner.push(text.slice(i + 2, end))
286      i = end
287    } else if (text[i] === '`') {
288      const end = closingTick(text, i + 1)
289      // FROZEN compat branch: delete only, once the bash/zsh check retires compat.
290      inner.push(isCompat ? text.slice(i + 1, end) : backtickText(text.slice(i + 1, end)))
291      i = end
292    }
293  }
294  return inner
295}
296
297// What the heredoc scanner is inside: unquoted text (the line itself, a $( ),
298// or a backtick), a quoted string ('' or ""), or arithmetic, where << is a
299// shift. A $'…' string is skipped whole, through closingAnsiQuote.
300type Context = 'top' | '$(' | '`' | "'" | '"' | '(('
301
302// Whether a word starts at `i`: a `#` there opens a comment, and `((` there
303// opens arithmetic.
304const isWordStart = (text: string, i: number): boolean => i === 0 || /[ \t\n;&|()]/.test(text[i - 1] as string)
305
306// The delimiter of a heredoc whose word starts at `from`, built as bash builds
307// it: every part of the word joined, quoted or not, with the quotes and
308// backslashes removed (`E'OF'` is EOF). Any quoting at all means the body is
309// not expanded.
310function delimiterAt(text: string, from: number): { delimiter: string; isQuoted: boolean; end: number } {
311  let delimiter = ''
312  let isQuoted = false
313  let i = from
314  while (i < text.length && !/[ \t\n;&|<>()]/.test(text[i] as string)) {
315    const c = text[i] as string
316    if (c === "'") {
317      const close = text.indexOf("'", i + 1)
318      const end = close === -1 ? text.length : close
319      delimiter += text.slice(i + 1, end)
320      isQuoted = true
321      i = end + 1
322    } else if (c === '"') {
323      let j = i + 1
324      for (; j < text.length && text[j] !== '"'; j++) {
325        if (text[j] === '\\' && j + 1 < text.length) j++
326        delimiter += text[j]
327      }
328      isQuoted = true
329      i = j + 1
330    } else if (c === '\\') {
331      delimiter += text[i + 1] ?? ''
332      isQuoted = true
333      i += 2
334    } else {
335      delimiter += c
336      i++
337    }
338  }
339  return { delimiter, isQuoted, end: i }
340}
341
342// Whether a heredoc feeds a shell or eval, so its body is a script: a shell or
343// eval word in the text before its operator, back to the last ; & | or line
344// end. A substitution does not end that text, because `eval "$(cat <<EOF`
345// runs the body too.
346function feedsShell(before: string): boolean {
347  const segment = before.split(/;|&|\||\n/).pop() ?? ''
348  return segment
349    .replace(/["']/g, '')
350    .split(/[ \t\n]+/)
351    .some(word => SHELLS.has(nameOf(word)) || nameOf(word) === 'eval')
352}
353
354// The text kept from one heredoc body, and where the body stood in the line.
355type Body = { at: number; script: string }
356
357// `text` with each heredoc body taken out before it is read as shell, and the
358// text kept from bodies. A body is the text of a file or a
359// message, not commands: an apostrophe in it would open a quote that swallows
360// the commands after it, and a line in it that starts with `git push` is not
361// a push. So the body and its delimiter line go, and the operator becomes a
362// `<` redirect whose target marks the heredoc, which the reader takes out.
363// Bash still runs what a body feeds a shell, and the $( ) and backticks of a
364// body whose delimiter is unquoted, so those are kept as a script of their
365// own, with `at`, where the body stood in the line. The lexer reads each
366// script apart, so a quote, substitution, array or case left open in a body
367// never reaches the line after it.
368//
369// It scans the whole text as one command, as bash does, so an operator counts
370// only where bash would read one: never inside a quoted string, on any line it
371// spans; never in a comment; and never in arithmetic ($(( )), (( )) or let),
372// where << is a shift. A here-string (<<<) is not a heredoc. The bodies start
373// after the next unquoted line end, in the operators' order.
374function heredocBodiesOut(text: string, reading: Reading): { line: string; bodies: Body[] } {
375  let out = ''
376  const bodies: Body[] = []
377  const stack: Context[] = ['top']
378  // How deep each open arithmetic is in its own parentheses.
379  const depths: number[] = []
380  let pending: ShellHeredoc[] = []
381  for (let i = 0; i < text.length; i++) {
382    const c = text[i] as string
383    const next = text[i + 1]
384    const context = stack[stack.length - 1] as Context
385    if (context === "'") {
386      out += c
387      if (c === "'") stack.pop()
388      continue
389    }
390    if (context === '((') {
391      out += c
392      const top = depths.length - 1
393      if (c === '(') depths[top] = (depths[top] ?? 0) + 1
394      else if (c === ')' && (depths[top] ?? 0) > 0) depths[top] = (depths[top] ?? 0) - 1
395      else if (c === ')' && next === ')') {
396        out += next
397        i++
398        stack.pop()
399        depths.pop()
400      }
401      continue
402    }
403    if (c === '\\') {
404      out += c + (next ?? '')
405      i++
406      continue
407    }
408    // $[ ] is old arithmetic, where << is a shift.
409    if (c === '$' && next === '[' && closingBracket(text, i + 2) < text.length && isArithmetic(text.slice(i + 2, closingBracket(text, i + 2)))) {
410      const end = closingBracket(text, i + 2)
411      out += text.slice(i, end + 1)
412      i = end
413      continue
414    }
415    if (c === '$' && next === '(' && text[i + 2] === '(') {
416      out += '$(('
417      i += 2
418      stack.push('((')
419      depths.push(0)
420      continue
421    }
422    if (c === '$' && next === '(') {
423      out += '$('
424      i++
425      stack.push('$(')
426      continue
427    }
428    if (context === '"') {
429      out += c
430      if (c === '"') stack.pop()
431      else if (c === '`') stack.push('`')
432      continue
433    }
434    // Unquoted text: the line itself, a $( ), or a backtick.
435    if (c === '#' && isWordStart(text, i)) {
436      const end = text.indexOf('\n', i)
437      const stop = end === -1 ? text.length : end
438      out += text.slice(i, stop)
439      i = stop - 1
440    } else if (c === "'" || c === '"') {
441      out += c
442      stack.push(c)
443    } else if (c === '$' && next === "'") {
444      const end = closingAnsiQuote(text, i + 2)
445      out += text.slice(i, end + 1)
446      i = end
447    } else if (c === '(' && next === '(' && isWordStart(text, i)) {
448      out += '(('
449      i++
450      stack.push('((')
451      depths.push(0)
452    } else if (c === '`') {
453      out += c
454      if (context === '`') stack.pop()
455      else stack.push('`')
456    } else if (c === ')' && context === '$(') {
457      out += c
458      stack.pop()
459    } else if (c === '<' && next === '<' && text[i + 2] === '<') {
460      out += '<<<'
461      i += 2
462    } else if (c === '<' && next === '<' && !/^[ \t\n]*let([ \t\n]|$)/.test(out.split(/[;&|\n(]/).pop() ?? '')) {
463      let from = i + 2
464      const stripsTabs = text[from] === '-'
465      if (stripsTabs) from++
466      while (text[from] === ' ' || text[from] === '\t') from++
467      const { delimiter, isQuoted, end } = delimiterAt(text, from)
468      if (delimiter === '') {
469        out += '<<'
470        i++
471      } else {
472        const doc: ShellHeredoc = { delimiter, body: '', isQuoted, stripsTabs, feedsShell: feedsShell(out), isTerminated: false }
473        reading.heredocs.push(doc)
474        pending.push(doc)
475        out += `<${HEREDOC}${reading.heredocs.length - 1}`
476        i = end - 1
477      }
478    } else if (c === '\n' && pending.length > 0) {
479      out += '\n'
480      let at = i + 1
481      for (const doc of pending) {
482        const body: string[] = []
483        while (at < text.length) {
484          const stop = text.indexOf('\n', at)
485          const rowEnd = stop === -1 ? text.length : stop
486          const row = text.slice(at, rowEnd)
487          at = rowEnd + 1
488          if ((doc.stripsTabs ? row.replace(/^\t+/, '') : row) === doc.delimiter) {
489            doc.isTerminated = true
490            break
491          }
492          body.push(row)
493        }
494        doc.body = body.join('\n')
495        // FROZEN compat branch: delete only, once the bash/zsh check retires compat.
496        const kept = doc.feedsShell ? body : doc.isQuoted ? [] : substitutionsOf(doc.body, reading.isCompat)
497        const script = kept.map(row => `${row}\n`).join('')
498        // FROZEN compat branch: delete only, once the bash/zsh check retires compat.
499        // The gate of 77bb2f3 read a kept body inline, as part of the line.
500        if (reading.isCompat) out += script
501        else if (kept.length > 0) bodies.push({ at: out.length, script })
502      }
503      pending = []
504      i = at - 1
505    } else {
506      out += c
507    }
508  }
509  // A heredoc whose line ends the text has no body yet.
510  if (reading.heredocs.some(doc => !doc.isTerminated)) reading.unknowns.add('heredoc')
511  return { line: out, bodies }
512}
513
514// One simple command as the lexer reads it: its words, each escaped word
515// carrying the ESCAPED mark, its redirects and heredocs, and where it was read.
516// `pipedFrom` is the command whose output it reads through a pipe.
517type Raw = {
518  words: string[]
519  redirects: ShellRedirect[]
520  heredocs: ShellHeredoc[]
521  source: ShellSource
522  depth: number
523  isCertain: boolean
524  pipedFrom?: Raw
525}
526
527// The redirect operators, longest first. Only these shapes are read, so a
528// following `|`, `&` or `-` is never taken into an operator (`>&-|cmd`).
529const OPERATORS = ['&>>', '&>', '<<<', '<<-', '<<', '<>', '<&', '<', '>>', '>&', '>|', '>']
530
531// The index of the first `)` of the `))` that closes arithmetic whose text
532// starts at `from`, or -1 when a lone `)` closes it first, which makes it a
533// subshell rather than arithmetic.
534function closingArithmetic(line: string, from: number): number {
535  let depth = 0
536  for (let i = from; i < line.length; i++) {
537    const c = line[i]
538    if (c === '\\') i++
539    else if (c === '(') depth++
540    else if (c === ')' && depth > 0) depth--
541    else if (c === ')') return line[i + 1] === ')' ? i : -1
542  }
543  return line.length
544}
545
546// Whether text reads as arithmetic: no ; newline quote backslash or
547// backtick, and no two words side by side. Anything else is read as the old
548// reader read it, as commands, so text bash may run is never hidden as
549// arithmetic.
550// The text of each $( ) and backtick in it is read on its own (substitutionsOf),
551// so it is left out of the test.
552const isArithmetic = (body: string): boolean => {
553  let plain = body
554  for (const inner of substitutionsOf(body)) plain = plain.replace(inner, '')
555  return !/[;\n'"\\`]/.test(plain) && !/[\w$}\]][ \t\n]+[\w$]/.test(plain)
556}
557
558// closingArithmetic, or -1 when the text there does not read as arithmetic.
559function arithmeticEnd(line: string, from: number): number {
560  const end = closingArithmetic(line, from)
561  return end !== -1 && isArithmetic(line.slice(from, end)) ? end : -1
562}
563
564// The index of the `]` that closes a $[ ] whose text starts at `from`.
565function closingBracket(line: string, from: number): number {
566  let depth = 0
567  for (let i = from; i < line.length; i++) {
568    if (line[i] === '[') depth++
569    else if (line[i] === ']' && depth-- === 0) return i
570  }
571  return line.length
572}
573
574// `>` and `<` that bash reads as text in `text`, as redirects that are not real.
575const textual = (text: string): ShellRedirect[] => [...text.matchAll(/[<>]/g)].map(m => ({ op: m[0], fd: '', target: '', isReal: false }))
576
577// The simple commands of `text` in reading order, a substitution's before the
578// command it stands in. Heredoc bodies are taken out first (heredocBodiesOut).
579//
580// `isBody` is true for the text of a heredoc body, so a substitution read in
581// it keeps the `heredoc` source.
582function lex(text: string, reading: Reading, source: ShellSource, depth: number, isCertain: boolean, isBody = false, carried: Body[] = []): Raw[] {
583  const own = heredocBodiesOut(text, reading)
584  // The bodies still to read, in the order they stood.
585  const bodies = [...own.bodies, ...carried].sort((a, b) => a.at - b.at)
586  // Off in the compat reading, which reads as the gate of 77bb2f3 did.
587  const exact = !reading.isCompat
588  const line = own.line
589  const nestedSource: ShellSource = isBody ? 'heredoc' : 'substitution'
590  const commands: Raw[] = []
591  let words: string[] = []
592  let redirects: ShellRedirect[] = []
593  let heredocs: ShellHeredoc[] = []
594  let word = ''
595  let hasWord = false
596  // Where the command's first character stands in `line`, or -1.
597  let start = -1
598  // Once a && or || or a branch keyword is read, no later command is certain.
599  let isUncertain = !isCertain
600  // The redirect whose target is the next word, which is taken out.
601  let target: ShellRedirect | undefined
602  // Whether the word was written with no quote or backslash, as a reserved
603  // word must be.
604  let isBare = true
605  // Inside [[ ]], < and > are words of the test, not redirects. A && || | ( )
606  // there still splits the statement, as the old reader did, so a command
607  // is never hidden in a test, and the test goes on to its ]].
608  let isTest = false
609  // Whether every word so far is a bare reserved word with no redirect before
610  // it, so the next bare word stands where bash reads a reserved word.
611  let isHead = true
612  // The last command read at this level, and whether the next reads its output.
613  let last: Raw | undefined
614  let isPiped = false
615  // Inside `name=( … )` the items are one assignment word: bash runs no
616  // command there, only the substitutions in it.
617  let isArray = false
618  // How many case…esac are open, and whether a case pattern comes next, as
619  // after `case x in` and each `;;`. A pattern is text, never a command.
620  let cases = 0
621  let isPattern = false
622  // Where in the command's words a reserved `case` stands, or -1. A quoted
623  // or escaped `case` is an ordinary command name, and opens no case.
624  let caseAt = -1
625  const endWord = () => {
626    if (isPattern && hasWord && isBare && word === 'esac' && words.length === 0) {
627      isPattern = false
628      cases--
629    }
630    if (hasWord && target === undefined) {
631      const isReserved = isHead && isBare
632      words.push(word)
633      // FROZEN compat branch: delete only, once the bash/zsh check retires compat.
634      if (exact && isReserved && word === 'case') caseAt = words.length - 1
635      if (exact && isReserved && word === '[[') isTest = true
636      else if (isTest && isBare && word === ']]') isTest = false
637      isHead = isReserved && (KEYWORDS.has(word) || word === 'time' || (word === '-p' && words.at(-2) === 'time'))
638    }
639    if (hasWord && target !== undefined) {
640      const plain = word.replaceAll(ESCAPED, '').replaceAll(UNQUOTED, '')
641      const doc = plain.startsWith(HEREDOC) ? reading.heredocs[Number(plain.slice(1))] : undefined
642      if (doc !== undefined && target.op === '<') {
643        target.op = doc.stripsTabs ? '<<-' : '<<'
644        target.target = doc.delimiter
645        heredocs.push(doc)
646        redirects.push(...textual(doc.body))
647      } else {
648        target.target = plain
649      }
650      target = undefined
651    }
652    word = ''
653    hasWord = false
654    isBare = true
655  }
656  const endCommand = () => {
657    endWord()
658    target = undefined
659    isTest = false
660    isHead = true
661    if (words.length > 0 || redirects.length > 0) {
662      const head = nameOf(words.find(w => !ASSIGNMENT.test(w)) ?? '')
663      const command: Raw = {
664        words,
665        redirects,
666        heredocs,
667        source,
668        depth,
669        isCertain: !isUncertain && !['then', 'else', 'elif', 'do', 'case', 'for', 'select', 'function'].includes(head),
670        ...(isPiped && last !== undefined ? { pipedFrom: last } : {}),
671      }
672      commands.push(command)
673      last = command
674      if (BRANCHES.has(head)) isUncertain = true
675      // `case x in` ended here, so its first pattern is still to come. A
676      // pattern on the same line, as in `case x in y)`, is read with it.
677      if (caseAt !== -1 && words[caseAt + 2] === 'in') {
678        cases++
679        isPattern = words.length === caseAt + 3
680      }
681    }
682    caseAt = -1
683    isPiped = false
684    words = []
685    redirects = []
686    heredocs = []
687    start = -1
688  }
689  // A substitution's commands go in the list, and the outer word goes on.
690  // A body that stood inside the substitution, as in `eval "$(cat <<E`, is
691  // read there. `from` is where the inner text starts in `line`.
692  const substitute = (inner: string, from: number) => {
693    const moved = bodies.filter(b => b.at >= from && b.at <= from + inner.length)
694    bodies.splice(0, bodies.length, ...bodies.filter(b => !moved.includes(b)))
695    const shifted = moved.map(b => ({ at: b.at - from, script: b.script }))
696    commands.push(...lex(inner, reading, nestedSource, depth + 1, !isUncertain, isBody, shifted))
697    word += SUBSTITUTED
698    hasWord = true
699  }
700  // Arithmetic is no command: its < > are text, and only its substitutions
701  // run.
702  const arithmetic = (body: string) => {
703    for (const inner of substitutionsOf(body)) commands.push(...lex(inner, reading, nestedSource, depth + 1, !isUncertain, isBody))
704    redirects.push(...textual(body))
705  }
706  // The heredoc bodies kept from the line, each read as a script of its own
707  // once the lexer reaches where it stood.
708  const readBodies = (upTo: number) => {
709    while (bodies.length > 0 && (bodies[0] as Body).at <= upTo) {
710      endCommand()
711      commands.push(...lex((bodies.shift() as Body).script, reading, 'heredoc', depth + 1, !isUncertain, true))
712    }
713  }
714  for (let i = 0; i < line.length; i++) {
715    readBodies(i)
716    const c = line[i] as string
717    const next = line[i + 1]
718    if (start === -1 && !/[ \t\n]/.test(c) && !';&|()'.includes(c)) start = i
719    if (c === '\\') {
720      isBare = false
721      if (next !== '\n') {
722        word += next ?? ''
723        hasWord = true
724        if (next === '>' || next === '<') redirects.push(...textual(next))
725      }
726      i++
727    } else if (c === "'") {
728      isBare = false
729      const end = line.indexOf("'", i + 1)
730      if (end === -1) reading.unknowns.add('quote')
731      const quoted = line.slice(i + 1, end === -1 ? line.length : end)
732      word += quoted
733      redirects.push(...textual(quoted))
734      hasWord = true
735      i = end === -1 ? line.length : end
736    } else if (c === '"') {
737      hasWord = true
738      isBare = false
739      for (i++; i < line.length && line[i] !== '"'; i++) {
740        const d = line[i] as string
741        const close = d === '$' && line[i + 1] === '(' && line[i + 2] === '(' ? arithmeticEnd(line, i + 3) : -1
742        if (d === '\\' && i + 1 < line.length && '"\\$`'.includes(line[i + 1] as string)) {
743          word += line[++i]
744        } else if (close !== -1) {
745          if (close === line.length) reading.unknowns.add('substitution')
746          arithmetic(line.slice(i + 3, close))
747          word += SUBSTITUTED
748          i = close + 1
749        // FROZEN compat branch: delete only, once the bash/zsh check retires compat.
750        } else if (exact && d === '$' && line[i + 1] === '[' && isArithmetic(line.slice(i + 2, closingBracket(line, i + 2)))) {
751          const end = closingBracket(line, i + 2)
752          if (end === line.length) reading.unknowns.add('substitution')
753          arithmetic(line.slice(i + 2, end))
754          word += SUBSTITUTED
755          i = end
756        } else if (d === '$' && line[i + 1] === '(') {
757          // FROZEN compat branch: delete only, once the bash/zsh check retires compat.
758          const end = closingParen(line, i + 2, exact)
759          if (end === line.length) reading.unknowns.add('substitution')
760          substitute(line.slice(i + 2, end), i + 2)
761          i = end
762        } else if (d === '`') {
763          const end = closingTick(line, i + 1)
764          if (end === line.length) reading.unknowns.add('substitution')
765          // FROZEN compat branch: delete only, once the bash/zsh check retires compat.
766          substitute(exact ? backtickText(line.slice(i + 1, end)) : line.slice(i + 1, end), i + 1)
767          i = end
768        } else {
769          word += d
770          redirects.push(...textual(d))
771        }
772      }
773      if (i >= line.length) reading.unknowns.add('quote')
774    } else if (c === '$' && next === '"') {
775      // $"…" is bash's locale string: read as "…", which in practice it is.
776      continue
777    } else if (c === '$' && next === "'") {
778      isBare = false
779      const end = closingAnsiQuote(line, i + 2)
780      if (end === line.length) reading.unknowns.add('quote')
781      const raw = line.slice(i + 2, end)
782      word += decodeAnsi(raw, () => reading.unknowns.add('escape')) + (raw.includes('\\') ? ESCAPED : '')
783      redirects.push(...textual(raw))
784      hasWord = true
785      i = end
786    } else if (c === '$' && next === '(' && line[i + 2] === '(' && arithmeticEnd(line, i + 3) !== -1) {
787      const end = arithmeticEnd(line, i + 3)
788      if (end === line.length) reading.unknowns.add('substitution')
789      arithmetic(line.slice(i + 3, end))
790      word += SUBSTITUTED
791      hasWord = true
792      i = end + 1
793    // FROZEN compat branch: delete only, once the bash/zsh check retires compat.
794    } else if (exact && c === '$' && next === '[' && isArithmetic(line.slice(i + 2, closingBracket(line, i + 2)))) {
795      const end = closingBracket(line, i + 2)
796      if (end === line.length) reading.unknowns.add('substitution')
797      arithmetic(line.slice(i + 2, end))
798      word += SUBSTITUTED
799      hasWord = true
800      i = end
801    // FROZEN compat branch: delete only, once the bash/zsh check retires compat.
802    } else if (exact && c === '(' && next === '(' && !hasWord && !isTest && arithmeticEnd(line, i + 2) !== -1) {
803      // An arithmetic command, `(( … ))`, is the word `((`.
804      const end = arithmeticEnd(line, i + 2)
805      if (end === line.length) reading.unknowns.add('substitution')
806      words.push('((')
807      arithmetic(line.slice(i + 2, end))
808      i = end + 1
809      // Bash allows nothing after it but a redirect or a separator, so a word
810      // after it is read as a command of its own.
811      if (/^[ \t]*[^ \t\n;&|()<>]/.test(line.slice(i + 1))) endCommand()
812    } else if (isTest && (c === '<' || c === '>') && !(hasWord && isBare && word === ']]')) {
813      // A < or > in a test compares strings. A ]] right before it ends the
814      // test, so then it is a redirect.
815      endWord()
816      words.push(c)
817      redirects.push(...textual(c))
818    } else if ((c === '$' || c === '<' || c === '>') && next === '(') {
819      // FROZEN compat branch: delete only, once the bash/zsh check retires compat.
820      const end = closingParen(line, i + 2, exact)
821      if (end === line.length) reading.unknowns.add('substitution')
822      substitute(line.slice(i + 2, end), i + 2)
823      i = end
824    } else if (c === '`') {
825      const end = closingTick(line, i + 1)
826      if (end === line.length) reading.unknowns.add('substitution')
827      // FROZEN compat branch: delete only, once the bash/zsh check retires compat.
828      substitute(exact ? backtickText(line.slice(i + 1, end)) : line.slice(i + 1, end), i + 1)
829      i = end
830    } else if (c === '<' || c === '>' || (c === '&' && next === '>')) {
831      // A word of digits before it is the file descriptor, not a word.
832      let fd = ''
833      if (/^\d+$/.test(word)) {
834        fd = word
835        word = ''
836        hasWord = false
837      }
838      endWord()
839      isHead = false
840      let op = OPERATORS.find(shape => line.startsWith(shape, i)) as string
841      // FROZEN compat branch: delete only, once the bash/zsh check retires compat.
842      if (reading.isCompat) op = c + ((/^[<>&|-]*/.exec(line.slice(i + 1)) as RegExpExecArray)[0] ?? '')
843      i += op.length - 1
844      target = { op, fd, target: '', isReal: true }
845      redirects.push(target)
846    } else if (isArray && (c === ' ' || c === '\t' || c === '\n' || c === ')')) {
847      word += c
848      isArray = c !== ')'
849    } else if (exact && c === '(' && hasWord && /^[A-Za-z_][A-Za-z0-9_]*\+?=$/.test(word) && words.every(w => ASSIGNMENT.test(w))) {
850      // `x=(a b)` and `x+=(c)` assign an array.
851      word += c
852      isArray = true
853    } else if (isPattern && (c === '|' || c === '(' || c === ')')) {
854      // A pattern's words are dropped at its `)`, and the branch's commands
855      // follow. Its substitutions were read where they stood.
856      if (c === ')') {
857        word = ''
858        hasWord = false
859        isBare = true
860        words = []
861        redirects = []
862        start = -1
863        isPattern = false
864      } else {
865        endWord()
866      }
867    } else if (cases > 0 && c === ';' && (next === ';' || next === '&')) {
868      // `;;`, `;&` and `;;&` end a case branch, and a pattern comes next.
869      endCommand()
870      isPattern = true
871      i += line.startsWith(';;&', i) ? 2 : 1
872    } else if (c === '#' && !hasWord) {
873      const end = line.indexOf('\n', i)
874      i = end === -1 ? line.length : end - 1
875    } else if (c === ' ' || c === '\t' || c === '\n') {
876      // A pattern never spans a line, so its words are read as a command,
877      // and the reading goes on as if no pattern had begun.
878      // An `esac` there closes the case first.
879      endWord()
880      if (c === '\n' && isPattern && words.length > 0) isPattern = false
881      if (c === '\n') endCommand()
882      // `function f` ends the definition's own command: its body follows.
883      if (words.length === 2 && words[0] === 'function') endCommand()
884    } else if (c === '|' && next !== '|') {
885      // `|` and `|&` pipe this command's output to the next.
886      endWord()
887      const keep: boolean = isTest
888      endCommand()
889      isTest = keep
890      isPiped = true
891      // FROZEN compat branch: delete only, once the bash/zsh check retires compat.
892      if (exact && next === '&') i++
893    } else if ((c === '&' || c === '|') && next === c) {
894      endWord()
895      const keep: boolean = isTest
896      endCommand()
897      isTest = keep
898      isUncertain = true
899      i++
900    } else if (';&|()'.includes(c)) {
901      // `name()` defines a function, whose body may never run.
902      if (c === '(' && words.length + (hasWord ? 1 : 0) === 1 && /^[ \t\n]*\)/.test(line.slice(i + 1))) isUncertain = true
903      // A | ( ) in a test splits the statement, and the test goes on.
904      endWord()
905      const keep: boolean = isTest && c !== ';' && c !== '&'
906      endCommand()
907      isTest = keep
908    } else {
909      word += exact && c === '$' ? UNQUOTED + c : c
910      hasWord = true
911    }
912  }
913  endCommand()
914  readBodies(line.length)
915  // An array or case still open where its script ends is a syntax error bash
916  // runs nothing of, and the reader cannot tell where it was meant to end.
917  if (isArray || cases > 0) reading.unknowns.add('compound')
918  return commands
919}
920
921// The simple commands of a shell line, each as its words, quotes removed and
922// escaped words marked, and the commands of every substitution in it, read on
923// their own. A quoted word stays one word, so `echo "git push"` holds no git
924// command. A command of redirects alone is left out.
925//
926// `isCompat` reads the line as the commit gate of 77bb2f3 did, before this
927// reader read bash more exactly: a run of < > & | - after a redirect is one
928// operator and the next word its target, a case `)` closes a $( ), backtick
929// text keeps its backslashes, `|&` is a pipe then an `&`, and arithmetic and
930// [[ ]] tests are read as plain commands. Each of those can find fewer
931// commands than the old reading did. The `function f` split only adds a
932// command, so both readings share it. parseShell never reads the compat way.
933// The commit gate reads both ways and keeps the larger count, so it never
934// finds fewer commits than that gate did (tests/shell-gate-differential.test.ts
935// holds it to that).
936//
937// THE COMPAT READING IS FROZEN. Every branch that reads the flag is marked
938// FROZEN, and the only change allowed at one is its deletion. It exists only
939// because no check yet runs a line to see what bash really runs. Once an
940// executable check runs each line the exact reading counts lower in bash and
941// zsh, in a sandbox with a recording git shim, compat is deleted (README,
942// "The shell reader").
943export function commandsOf(text: string, isCompat = false): string[][] {
944  return lex(text, { heredocs: [], unknowns: new Set(), isCompat }, 'line', 0, true)
945    .filter(raw => raw.words.length > 0)
946    .map(raw => raw.words.map(word => word.replaceAll(UNQUOTED, '')))
947}
948
949// Where the command a wrapper runs starts, in words, from `at` (the wrapper
950// word). `words` carry their escape marks. `stops` is true when an option says
951// the wrapper runs no command, and `isPlaced` false when an option is not in
952// the wrapper's table.
953function pastWrapper(words: readonly string[], at: number): { next: number; stops: boolean; isPlaced: boolean } {
954  const options = WRAPPERS[nameOf(words[at] ?? '')] as WrapperOptions
955  // A watch without -x runs its words through `sh -c`.
956  let isPlaced = nameOf(words[at] ?? '') !== 'watch' || words.slice(at + 1).some(word => /^-[^-]*x/.test(word) || word === '--exec')
957  let i = at + 1
958  while (i < words.length) {
959    // An escaped option may decode to any option, so it is read as written
960    // and leaves the statement unplaced.
961    if (isEscaped(words[i] as string)) isPlaced = false
962    const word = (words[i] as string).replaceAll(ESCAPED, '').replaceAll(UNQUOTED, '')
963    if (word === '--') {
964      i++
965      break
966    }
967    if (word.startsWith('--')) {
968      const [name = '', value] = word.slice(2).split(/=(.*)/s)
969      if (options.longStops?.includes(name)) return { next: i, stops: true, isPlaced }
970      if (options.longValue?.includes(name) && value === undefined) i++
971      // A long name the table does not list exactly is not placed, with or
972      // without an `=value`. getopt_long takes any unambiguous prefix of a
973      // name, so `--spl=…` is env's --split-string, which runs its value as
974      // the command, and a prefix of a listed name may be another option.
975      else if (!options.longValue?.includes(name) && !options.longFlags?.includes(name)) isPlaced = false
976      i++
977      continue
978    }
979    if (!word.startsWith('-') || word === '-') {
980      if (word === '-' && nameOf(words[at] ?? '') === 'env') {
981        i++
982        continue
983      }
984      break
985    }
986    if (options.numeric && /^-\d+$/.test(word)) {
987      i++
988      continue
989    }
990    for (let j = 1; j < word.length; j++) {
991      const letter = word[j] as string
992      if (options.stops?.includes(letter)) return { next: i, stops: true, isPlaced }
993      if (options.value?.includes(letter)) {
994        // The rest of the cluster is the value, or else the next word is.
995        if (j === word.length - 1) i++
996        break
997      }
998      if (!options.flags?.includes(letter)) isPlaced = false
999    }
1000    i++
1001  }
1002  i = Math.min(i + (options.positional ?? 0), words.length)
1003  // An option after the positional words, such as flock's `-c <script>`
1004  // after its lock file, is not read.
1005  if ((options.positional ?? 0) > 0 && (words[i] ?? '').replaceAll(ESCAPED, '').startsWith('-')) isPlaced = false
1006  return { next: i, stops: false, isPlaced }
1007}
1008
1009// The index in args of a git or gh subcommand, past the global options.
1010function subcommandAt(name: string, args: readonly string[]): number {
1011  if (!Object.hasOwn(GLOBAL_VALUE_OPTIONS, name)) return -1
1012  const valueOptions = GLOBAL_VALUE_OPTIONS[name] as ReadonlySet<string>
1013  for (let i = 0; i < args.length; i++) {
1014    const arg = args[i] as string
1015    if (!arg.startsWith('-')) return i
1016    if (valueOptions.has(arg)) i++
1017  }
1018  return -1
1019}
1020
1021// A statement from one lexed command, or undefined for one that holds nothing
1022// but keywords.
1023function statementOf(raw: Raw, reading: Reading): ShellStatement | undefined {
1024  const marked = raw.words
1025  const words = marked.map(word => word.replaceAll(ESCAPED, '').replaceAll(UNQUOTED, ''))
1026  const assignments: string[] = []
1027  const wrappers: string[] = []
1028  let isPlaced = true
1029  let at = 0
1030  while (at < words.length) {
1031    const word = words[at] as string
1032    const name = nameOf(word)
1033    if (ASSIGNMENT.test(word)) {
1034      assignments.push(word)
1035      at++
1036    } else if (name === 'coproc' && words[at + 2] === '{') {
1037      // `coproc NAME { … }`: NAME names the coprocess, and the body runs.
1038      at += 2
1039    } else if (KEYWORDS.has(name) || CLOSERS.has(name)) {
1040      at++
1041    } else if (Object.hasOwn(WRAPPERS, name)) {
1042      const past = pastWrapper(marked, at)
1043      isPlaced &&= past.isPlaced
1044      // A wrapper that runs no command, or has none after it, is the command.
1045      if (past.stops || past.next >= words.length) break
1046      wrappers.push(name)
1047      at = past.next
1048    } else {
1049      break
1050    }
1051  }
1052  if (!isPlaced) reading.unknowns.add('wrapper')
1053  const nameAt = at < words.length ? at : -1
1054  const name = nameAt === -1 ? '' : nameOf(words[nameAt] as string)
1055  // A name from a variable or a substitution is only known at run time, and
1056  // so is a command word with an expansion anywhere in it: `${X:-/bin/rm}`
1057  // keeps a slash inside its braces, so its last path part reads as `rm}`.
1058  // The one shape allowed is a plain `$NAME/` or `${NAME}/`, inside double
1059  // quotes, in front of a literal path (`"$HOME"/bin/x`,
1060  // `"${CLAUDE_PLUGIN_ROOT}/scripts/x.sh"`).
1061  if (nameAt !== -1 && isExpanded((marked[nameAt] as string).replaceAll(ESCAPED, ''))) reading.unknowns.add('expansion')
1062  if (nameAt === -1 && assignments.length === 0 && raw.redirects.length === 0) return undefined
1063  const args = nameAt === -1 ? [] : words.slice(nameAt + 1)
1064  return {
1065    words,
1066    nameAt,
1067    name,
1068    args,
1069    assignments,
1070    wrappers,
1071    subcommandAt: subcommandAt(name, args),
1072    isPlaced,
1073    escaped: marked.flatMap((word, i) => (isEscaped(word) ? [i] : [])),
1074    isCertain: raw.isCertain,
1075    redirects: raw.redirects,
1076    heredocs: raw.heredocs,
1077    source: raw.source,
1078    depth: raw.depth,
1079  }
1080}
1081
1082// What a shell's arguments run, read as the shell reads its options: with -c
1083// (in any cluster), the first operand after the options is the script, `--`
1084// and `-` end the options, and -o, -O, --rcfile and --init-file take a value.
1085// `readsStdin` is true when there is no script and no script file, or -s.
1086export function shellScriptOf(args: readonly string[]): { script?: string; readsStdin: boolean } {
1087  let hasScript = false
1088  let readsStdin = false
1089  let i = 0
1090  for (; i < args.length; i++) {
1091    const arg = args[i] as string
1092    if (arg === '--' || arg === '-') {
1093      i++
1094      break
1095    }
1096    if (arg === '--rcfile' || arg === '--init-file') i++
1097    else if (/^[-+][^-]/.test(arg)) {
1098      if (arg.startsWith('-') && arg.includes('c')) hasScript = true
1099      if (arg.startsWith('-') && arg.includes('s')) readsStdin = true
1100      if (/[oO]/.test(arg)) i++
1101    } else if (!arg.startsWith('--')) break
1102  }
1103  if (hasScript) return { script: args[i] ?? '', readsStdin: false }
1104  return { readsStdin: readsStdin || i >= args.length }
1105}
1106
1107// The script a statement runs, and whether it surely runs with the statement:
1108// a shell's -c script, eval's words joined, or a trap's handler, which runs
1109// later, if ever.
1110function scriptOf(statement: ShellStatement): { script: string; isCertain: boolean } | undefined {
1111  if (statement.name === 'eval') return { script: statement.args.join(' '), isCertain: statement.isCertain }
1112  if (statement.name === 'trap') {
1113    const args = statement.args[0] === '--' ? statement.args.slice(1) : statement.args
1114    return args.length >= 2 && !/^-[lp]$/.test(args[0] as string) && args[0] !== '-' ? { script: args[0] as string, isCertain: false } : undefined
1115  }
1116  if (!SHELLS.has(statement.name)) return undefined
1117  const { script } = shellScriptOf(statement.args)
1118  return script === undefined ? undefined : { script, isCertain: statement.isCertain }
1119}
1120
1121function statementsOf(text: string, reading: Reading, source: ShellSource, depth: number, isCertain: boolean): ShellStatement[] {
1122  const statements: ShellStatement[] = []
1123  // Each lexed command's statement, so a pipe can find what feeds it.
1124  const of = new Map<Raw, ShellStatement>()
1125  const nested = (script: string, from: ShellStatement, nestedSource: ShellSource, nestedCertain: boolean) => {
1126    if (from.depth >= MAX_DEPTH) reading.unknowns.add('depth')
1127    else statements.push(...statementsOf(script, reading, nestedSource, from.depth + 1, nestedCertain))
1128  }
1129  for (const raw of lex(text, reading, source, depth, isCertain)) {
1130    const statement = statementOf(raw, reading)
1131    if (statement === undefined) continue
1132    of.set(raw, statement)
1133    statements.push(statement)
1134    const run = scriptOf(statement)
1135    if (run !== undefined) nested(run.script, statement, 'script', run.isCertain)
1136    else if (SHELLS.has(statement.name) && shellScriptOf(statement.args).readsStdin) {
1137      // A shell reading its script from stdin: a pipe or a here-string. A
1138      // heredoc of the command piped in feeds the shell, and is read as one.
1139      // What else a pipe carries is not seen, so it is unknown.
1140      const strings = statement.redirects.filter(r => r.isReal && r.op === '<<<')
1141      for (const string of strings) nested(string.target, statement, 'script', statement.isCertain)
1142      const feeder = raw.pipedFrom === undefined ? undefined : of.get(raw.pipedFrom)
1143      for (const doc of feeder?.heredocs ?? []) {
1144        doc.feedsShell = true
1145        nested(doc.body, statement, 'heredoc', statement.isCertain)
1146      }
1147      if (strings.length > 0 || raw.pipedFrom !== undefined) reading.unknowns.add('stdin')
1148    }
1149  }
1150  return statements
1151}
1152
1153// The reading of a Bash command line: its statements, and what could not be
1154// read.
1155export function parseShell(text: string): ShellParse {
1156  const reading: Reading = { heredocs: [], unknowns: new Set() }
1157  const statements = statementsOf(text, reading, 'line', 0, true)
1158  return { statements, unknowns: [...reading.unknowns].sort() }
1159}
1160