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

hooks/register.ts 1409 lines1// 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 lines1// 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}
59hooks/mods/capture.ts 172 lines1// 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(', ')}.`
172hooks/mods/checkpoint.ts 48 lines1// 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}
48hooks/mods/intake.ts 40 lines1// 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}
40hooks/mods/learnings.ts 35 lines1// 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}
35hooks/mods/prompt-rules.ts 226 lines1// 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}
226hooks/mods/recall.ts 161 lines1// 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)
161hooks/mods/cache-meter.ts 119 lines1// 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}
119hooks/mods/commit-approval.ts 413 lines1// 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}
413hooks/mods/lane.ts 58 lines1// 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'
58hooks/mods/shell.ts 1160 lines1// 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