Keeps a ledger of what you asked for and counts it done only on evidence: claims stay pale until a read-back confirms them, bare SQL UPDATE or DELETE writes…

A Claude Code mod that keeps track of what you asked for and what was proven done, and holds risky SQL writes for your answer.
witness is a plugin of function hooks. It sits in the session beside you, keeps a ledger of your asks, and counts an ask as done only when there is evidence: a read-back of what was written, or your own words. It draws one line above the prompt with a small companion beside it.
asks n/m, where n is the number witnessed. /undone lists every ask as witnessed, pale (claimed but not proven) or open. Standing instructions ("explain as we go") are not recorded. Work that spans several sessions is listed apart as builds N and is never counted. Text you quote or paste is not read as an ask. At the end of a watch, an open ask nobody claimed carries into the next one once, shown apart as carried N.execute_sql hold. A Supabase MCP execute_sql UPDATE or DELETE that has no RETURNING clause is held, and you choose: add RETURNING, let it through once, or cancel. A headless run (claude -p) or a subagent has nobody to ask, so there the write is refused. witness never approves a call past another guard.confirm tool and must cite evidence: a tool result already in the session, or your words. It can mark an ask dropped only on your words. A close paraphrase of an ask is accepted when exactly one live ask clearly matches. If a turn calls something done without proof, the next prompt carries one short line asking the agent to confirm it./wrap writes a Witness print into the open session log: asks witnessed, pale and open, writes held, friction, notes taken, and hook or skill candidates. It then hands the print to the main session, which settles what is done and says what each open line waits on, without starting new work. The close command (default /close) writes the same print before it runs. A project with no session log gets the print attached to the close prompt.body) at the right of the band, facing the line. Its colour deepens as more of the session's claims are proved. A chime of three bells hangs at its back, an hour of active work each, filling from the bottom with a half bell for thirty minutes; * barnacles on its shell are friction that recurs across sessions (a cancelled or refused write, a call that keeps failing, an MCP tool's error; never the edits of a file you are building), and /barnacles lists what they record and scrubs them off; its eye turns amber in a repository whose push would start a metered build, and the whole creature turns red while a write is held. The bells and the barnacles wear their own inks. /wear dresses it in a costume.From a terminal:
claude plugin marketplace add https://github.com/iniphi/witness
claude plugin install witness@iniphi-witness
Or at the prompt of a terminal session:
/plugin install witness --marketplace iniphi/witness
Answer y to add the marketplace and choose a scope. Install it once. Two copies loaded in one session would run every hook twice.
Set these with /config or when you install. Every key is optional.
| Key | Default | What it does |
|---|---|---|
body | phi | The companion's body: phi (nautilus) or phial (round-bottom flask). |
watchHold | false | At session start, and after five idle minutes, ask before the first prompt goes through. |
ticketScript | empty | A script that prints a status ticket as JSON, as a path relative to the session folder or absolute. If it is empty and no ticket script sits beside the mod, there is no ticket, and the line shows the companion's own counts. The mod looks beside itself from the folder Claude Code reports for it, or from its own file when that is blank; if it has nowhere to look from, the line shows estate: ? rather than nothing. |
watchScript | empty | A script that reads a work board for the watch and its poll. If it is empty and none is found, no board is polled. |
writerCalls | workspace.close_todo, workspace.set_todo_status, workspace.pause_todo, workspace.crosstalk_*, workspace.record_board_attention, workspace.claim_item, workspace.release_claim | SQL functions that a SELECT calls to write, written as schema.name, where a trailing * matches any suffix. These count as writes. The list does nothing where that schema does not exist. |
person | you | How the print and the confirm tool name you. The default prints "on your words", and the model reads "the user". |
sessionsDir | .claude/sessions | Where session logs live, relative to the session folder. |
goalsHeading | ## Goals | The session log heading whose checkbox items are goals. The section ends at the next heading of the same level. |
closeCommands | /close | Commands that close a session, separated by commas. The print is written before they run. |
/undone lists every ask in this watch: witnessed, pale or open./wrap ends the watch, writes the print into the session log and hands it to the main session to settle./tackle puts a prompt in the prompt box for a run over the undone asks. You read it and decide whether to send it. It never sends on its own./barnacles opens a pane with what each barnacle on the shell records, a scrub beside each and a scrub all. A scrubbed barnacle leaves the shell at once, and the moment the same friction is recorded again it is back with its old words, unspoken, so you hear it again: the scrub did not hold./wear razor, /wear compass, /wear horns dress the companion for the session; /wear none undresses it. A costume is how a later companion plugin shows which role is speaking, and another plugin can set one through the same command./dismiss command. While witness is showing a notice that can be dismissed, a 0: Dismiss button sits at its foot, bound to the 0 key. A held write is never dismissed, only answered. When more than one notice is waiting, <n/m> shows your place in the queue.What it reads:
sessionsDir;ticketScript and watchScript, if they are set or found.What it writes:
~/.claude/state/witness-usage/<day>/<session>.json: the token counts the API reported for each of witness's own side passes and each model turn. Nothing is written if neither USERPROFILE nor HOME is set./wrap and at the close command.reports/witness-candidates.md when the session folder has a reports/ folder, and to .claude/witness-candidates.md otherwise.What it sends to a model: the side passes are extract, match, candidates and barnacles. They run on Haiku at low effort. Each pass sees only what it is given: the prompt, with quoted and pasted text cut out, and short excerpts of answers and tool output. No other conversation content is sent. The only text witness adds to the main session's context is the one-line unproved-done note and the /wrap print.
witness's own spend is its Haiku side passes. Measured on the author's machine over one day of ordinary use (eleven sessions, API list prices), the side passes came to $0.07 in total, about $0.006 per session, which was 0.03% of what the model turns in those sessions cost. Per call, the extract pass reads about 1,000 tokens, match about 1,400, candidates about 8,200 and barnacles about 1,300.
The usage file records every model turn's tokens beside the side passes, so a session's whole spend can be read next to witness's share. Those turn figures are the session's cost, not witness's. witness does not price the work of subagents a session spawns: their tokens are recorded only as the host reports them. Since 1.1 a subagent's final report is a claim in the ledger, pale until the main session reads back what the report rests on.
claude plugin validate <path to witness>
claude plugin test <path to witness>
The pure modules in hooks/ each have a *.test.ts beside them. hooks/register.tsx is the hooks module, the only file that calls the engine.
Issues and pull requests are welcome. witness is developed in a separate source repository and published here at each release, so the maintainer applies an accepted pull request there, with credit in the CHANGELOG, rather than merging it here.
To report a vulnerability, see SECURITY.md. Please do not open a public issue for one.
MIT. Copyright (c) 2026 Bradley Higginson. See LICENSE.
hooks/register.tsx 1196 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type {
5 Activity,
6 Ask,
7 Barnacle,
8 BoardRow,
9 Body,
10 Candidate,
11 Costume,
12 Friction,
13 Heard,
14 Hold,
15 Ledger,
16 LogBinding,
17 NoticeQueue,
18 Observed,
19 Spoken,
20 Ticket,
21 UsagePass,
22 UsageRow,
23 Watch,
24 WatchInfo,
25} from '../types'
26import {
27 applyMatches,
28 applyTriage,
29 carryOver,
30 counts,
31 dropAsk,
32 extractPrompt,
33 matchPrompt,
34 MAX_PREVIOUS_CHARS,
35 mergeGoals,
36 nm,
37 paneRows,
38 parseExtraction,
39 parseGoals,
40 parseTriage,
41 parseMatch,
42 printSection,
43 resolveStates,
44 seedCarried,
45} from './asks'
46import { isIdle, touch } from './bells'
47import {
48 ESTATE_TACKLE_SKILL,
49 NO_ADAPTERS,
50 TICKET_SCRIPT,
51 WATCH_SCRIPT,
52 personRef,
53 readConfig,
54 rootFromModuleUrl,
55 scriptCandidates,
56 writerPattern,
57} from './config'
58import type { Adapters, Config } from './config'
59import {
60 afterScrub,
61 asScrubbed,
62 frictionFrom,
63 markSpoken,
64 mergeBarnacles,
65 nextToSpeak,
66 noteFriction,
67 parseRecur,
68 realFriction,
69 recurPrompt,
70 regrown,
71 scrub,
72 stampScrub,
73 unscrub,
74} from './barnacles'
75import type { RecentCall } from './barnacles'
76import { parseCostume } from './sprites'
77import {
78 CONFIRM_TOOL,
79 confirmDescription,
80 confirmSchema,
81 confirmAsks,
82 confirmReport,
83 hear,
84 inputText,
85 observe,
86 parseItems,
87 unprovedNote,
88} from './evidence'
89import {
90 CANDIDATE_MIN_NEW_CALLS,
91 HEADS_UP,
92 INSTALLED_GUARDS,
93 appendCandidates,
94 callDigest,
95 candidatesPath,
96 candidatesPrompt,
97 candidatesSection,
98 markFiled,
99 mergeCandidates,
100 notesSection,
101 noteHeard,
102 noteSpoken,
103 parseCandidates,
104 shouldScan,
105} from './notes'
106import { bandTree, barnaclesTree, beneathText, paneTree } from './render'
107import { needsHold, withReturning } from './sql'
108import {
109 HELPER_TIMEOUT_MS,
110 SIDE_PASS,
111 askText,
112 bindLog,
113 claimable,
114 freshAsks,
115 isCloseCommand,
116 isOpenLog,
117 isTacklePrompt,
118 isWrapReadPrompt,
119 logTouch,
120 needsOpenCheck,
121 newIds,
122 projectKey,
123 storeKeys,
124 tacklePrompt,
125 tally,
126 undone,
127 closePrintNote,
128 withPrint,
129 withoutQuoted,
130 wrapReadPrompt,
131} from './support'
132import type { LogTouch } from './support'
133import { TICKET_MS, UNAVAILABLE_TICKET, ticketFromRun } from './ticket'
134import { addUsage, turnRow, usageDir, usageFile, usagePath, usageRow } from './usage'
135import { EYE_ROW, HOLD_TEXT, buildView } from './view'
136import { dismiss, dismissable, enqueue, remove, settle, showing } from './notices'
137import { landedRows, parseRows, parseWatch, watchHeader, watchText } from './watch'
138import { claimLabel, classify, developByAsks, recordAgentTurn, recordCall, recordTurn } from './witness'
139
140const PANE = 'witness-undone'
141const BARNACLES_PANE = 'witness-barnacles'
142const SQL_TOOL = 'mcp__claude_ai_Supabase__execute_sql'
143const FRAME_MS = 1_500
144const FRAME_CYCLE = [0, 0, 0, 1, 0, 0, 2, 0]
145const HOUSEKEEP_MS = 15_000
146const POLL_MS = 5 * 60_000
147const SPEAK_MS = 90_000
148const PRINT_MS = 5 * 60_000
149const RECENT_CAP = 20
150const ADD = 'Add RETURNING'
151const ONCE = 'Let through once'
152const CANCEL = 'Cancel'
153const TAKE = 'Take the watch'
154const HOLD_FAILED =
155 'witness: the hold failed, so the write was NOT sent. Re-send it naming what it touches: WITH w AS (<statement> RETURNING *) SELECT * FROM w.'
156const SUBAGENT_HELD =
157 'witness: a subagent may not send an UPDATE or DELETE that names nothing it touched (no one is there to answer the hold). Re-send it as WITH w AS (<statement> RETURNING *) SELECT * FROM w.'
158// 1.1 (ruled 2026-10-09): a subagent's report is a claim the main session reads back.
159const SUBAGENT_CONFIRM =
160 "witness: a subagent may not confirm asks. Its report is a claim the main session must read back; say what you changed, and the main session confirms on its own read-back."
161
162// Every value the band and the pane draw from, held by the host for the session
163// (they survive a hot reload; module variables do not). Declared here, not
164// imported: the engine reads a state reference only where its literals are written.
165const EMPTY_LEDGER: Ledger = { writes: [], claims: [], turns: {} }
166const EMPTY_ACTIVITY: Activity = { activeMs: 0, lastAt: null }
167const PENDING_WATCH: Watch = { state: 'pending', info: null, takenAt: null }
168
169const ledgerAtom = atom({ plugin: 'witness', key: 'ledger' } as const, EMPTY_LEDGER)
170const asksAtom = atom({ plugin: 'witness', key: 'asks' } as const, [] as Ask[])
171const activityAtom = atom({ plugin: 'witness', key: 'activity' } as const, EMPTY_ACTIVITY)
172const noticesAtom = atom({ plugin: 'witness', key: 'notices' } as const, { items: [], seen: 0 } as NoticeQueue)
173const holdAtom = atom({ plugin: 'witness', key: 'hold' } as const, null as Hold | null)
174const watchAtom = atom({ plugin: 'witness', key: 'watch' } as const, PENDING_WATCH)
175const barnaclesAtom = atom({ plugin: 'witness', key: 'barnacles' } as const, [] as Barnacle[])
176const frameAtom = atom({ plugin: 'witness', key: 'frame' } as const, 0)
177const costumeAtom = atom({ plugin: 'witness', key: 'costume' } as const, 'none' as Costume)
178// The running turn survives a mid-turn hot reload, so its later writes stay in its claim.
179const turnAtom = atom({ plugin: 'witness', key: 'turn' } as const, 'turn-0')
180// What the confirm tool checks evidence against: calls the mod saw, and Bradley's own words.
181const observedAtom = atom({ plugin: 'witness', key: 'observed' } as const, [] as Observed[])
182const heardAtom = atom({ plugin: 'witness', key: 'heard' } as const, [] as Heard[])
183// For the print (S163): what the companion said, and the hook and skill candidates.
184const spokenAtom = atom({ plugin: 'witness', key: 'spoken' } as const, [] as Spoken[])
185const candidatesAtom = atom({ plugin: 'witness', key: 'candidates' } as const, [] as Candidate[])
186const candidateScanAtom = atom({ plugin: 'witness', key: 'candidateScanAt' } as const, 0)
187// The estate ticket the one line opens with (0.3.0, folded in from estate-status).
188const ticketAtom = atom({ plugin: 'witness', key: 'ticket' } as const, null as Ticket | null)
189// What each Haiku side pass cost (0.4.0), written to this session's usage file.
190const usageAtom = atom({ plugin: 'witness', key: 'usage' } as const, [] as UsageRow[])
191// The session log this watch follows (1.1): bound by the main loop's own file calls, kept across a hot reload.
192const logAtom = atom({ plugin: 'witness', key: 'log' } as const, null as LogBinding | null)
193
194// The session's plain values. The engine follows `$` only into functions declared at
195// the top of this file, so the helpers read this record rather than closures. It is
196// replaced, never mutated, and lost on a hot reload (register runs again), which
197// nothing here needs to survive: what must survive is in $.state and $.store.
198type Session = {
199 body: Body
200 watchHold: boolean
201 cwd: string
202 key: string
203 interactive: boolean
204 /** Epoch ms the session started: an older session log is not this session's. */
205 startedAt: number
206 tick: number
207 recent: RecentCall[]
208 friction: Friction[]
209 rowIds: string[]
210 /** A candidate pass is in flight; the housekeeping tick must not start a second. */
211 scanning: boolean
212 /** The session's id (its transcript's name): names this session's usage file. */
213 sessionId: string
214 /** A /tackle prompt went in: the main turn it starts is recorded as `tackle`. */
215 tackleTurn: boolean
216 /** Asks a turn called done without proof, to put to Claude on the next prompt. */
217 nudge: string[]
218 /** Asks already put to Claude this session: each is put once. */
219 nudged: string[]
220 /** The end of the last main answer: an approval on the next prompt is read against it. */
221 lastAnswer: string
222 /** Notes from beneath already recorded since this load, so a redraw does not record one again. */
223 headsUp: string[]
224 /** The userConfig fields, read once per load (1.0: the core/estate split). */
225 config: Config
226 /** The configured writer calls as a pattern; null: none. */
227 writers: RegExp | null
228 /** The estate adapters found at session start; null is off. */
229 adapters: Adapters
230 /** Where usage files go; null (none written) when the home is unknown. */
231 usageDir: string | null
232}
233
234let s: Session = {
235 body: 'phi',
236 watchHold: false,
237 cwd: '',
238 key: '',
239 interactive: false,
240 startedAt: 0,
241 tick: 0,
242 recent: [],
243 friction: [],
244 rowIds: [],
245 scanning: false,
246 sessionId: '',
247 tackleTurn: false,
248 nudge: [],
249 nudged: [],
250 lastAnswer: '',
251 headsUp: [],
252 config: readConfig({}),
253 writers: writerPattern(readConfig({}).writers),
254 adapters: NO_ADAPTERS,
255 usageDir: null,
256}
257
258function setSession(patch: Partial<Session>): void {
259 s = { ...s, ...patch }
260}
261
262function isHeldSql(e: any): boolean {
263 return e?.tool === SQL_TOOL && typeof e.query === 'string' && needsHold(e.query)
264}
265
266// ---- reaching out: helper script, session log, side passes, store ----
267
268async function say($: any, header: string, text: string, full: boolean, until: number | null): Promise<void> {
269 const at = await $.clock.now()
270 const id = newIds(at, 1, 'n')[0] ?? `n${at}`
271 await update($, noticesAtom, q => enqueue(q, { kind: 'own', header, text, full, until }, id, at))
272 await update($, spokenAtom, log => noteSpoken(log, { header, text, at }))
273}
274
275/** A note from beneath (Claude Code's own "Heads up"), into Notes taken once (to-do 3f23...c538). */
276async function hearBeneath($: any, text: string): Promise<void> {
277 const at = await $.clock.now()
278 await update($, spokenAtom, log => noteHeard(log, { header: HEADS_UP, text, at }))
279}
280
281/** The estate is present when its ticket script is: /tackle names /agile, the guard list is the estate's. */
282function estate(): boolean {
283 return s.adapters.ticket !== null
284}
285
286async function firstExisting($: any, paths: readonly string[]): Promise<string | null> {
287 for (const path of paths) {
288 try {
289 if ((await $.fs.exists(path)) === true) return path
290 } catch {}
291 }
292 return null
293}
294
295/**
296 * The mod's own folder: the engine's word (`$.plugin.root`), else this module's own
297 * location, which cannot be blank. Added after the estate line vanished for a morning
298 * (2026-10-09): the engine answered the lookup with nothing, the adapter read that as
299 * "no estate", and the band fell silent with no sign that it had not looked.
300 */
301function pluginRoot($: any): string {
302 let given: unknown = ''
303 try {
304 given = $.plugin.root
305 } catch {
306 given = ''
307 }
308 if (typeof given === 'string' && given !== '') return given
309 try {
310 return rootFromModuleUrl(import.meta.url)
311 } catch {
312 return ''
313 }
314}
315
316/** The adapters' scripts: configured, else found in the mod's own repository; absent, off. */
317async function findAdapters($: any, cwd: string, root: string): Promise<Adapters> {
318 return {
319 ticket: await firstExisting($, scriptCandidates(s.config.ticketScript, root, cwd, TICKET_SCRIPT)),
320 watch: await firstExisting($, scriptCandidates(s.config.watchScript, root, cwd, WATCH_SCRIPT)),
321 }
322}
323
324async function homeDir($: any): Promise<string | undefined> {
325 const profile = await $.env.get('USERPROFILE').catch(() => undefined)
326 return profile ?? (await $.env.get('HOME').catch(() => undefined))
327}
328
329async function runHelper($: any, extra: string[]): Promise<{ exitCode: number; stdout: string } | null> {
330 if (s.adapters.watch === null) return null
331 try {
332 return await $.process.run(['python', s.adapters.watch, '--json', ...extra, '--project-dir', s.cwd], {
333 timeoutMs: HELPER_TIMEOUT_MS,
334 })
335 } catch {
336 return null
337 }
338}
339
340/**
341 * Every Haiku side pass goes through here, so each one's cost is recorded as the API
342 * reported it (0.4.0, S165). Recording never fails the pass it measures.
343 */
344async function complete($: any, pass: UsagePass, prompt: string): Promise<any> {
345 const r = await $.model.complete({ ...SIDE_PASS, prompt })
346 await recordUsage($, pass, r).catch(() => undefined)
347 return r
348}
349
350async function recordUsage($: any, pass: UsagePass, r: any): Promise<void> {
351 await appendUsage($, usageRow(pass, await $.clock.now(), r))
352}
353
354/** Adds one row (a side pass or a turn) and rewrites this session's usage file. */
355async function appendUsage($: any, row: UsageRow): Promise<void> {
356 await update($, usageAtom, rows => addUsage(rows, row))
357 const sessionId = s.sessionId === '' ? `session-${s.startedAt}` : s.sessionId
358 const file = usageFile({
359 sessionId,
360 repo: s.key,
361 startedAt: s.startedAt,
362 model: SIDE_PASS.model,
363 rows: await read($, usageAtom),
364 })
365 if (s.usageDir !== null) await $.fs.write(usagePath(s.usageDir, s.startedAt, sessionId), file)
366}
367
368/**
369 * The note for Claude, once per ask (Bradley, S165, option B): asks a turn called done
370 * without proof that are still unproved now. Null when there is nothing to put.
371 */
372async function takeNudge($: any): Promise<string | null> {
373 if (s.nudge.length === 0) return null
374 const pending = s.nudge
375 setSession({ nudge: [], nudged: [...s.nudged, ...pending] })
376 const asks = await read($, asksAtom)
377 const still = asks.filter(a => pending.includes(a.id) && a.state === 'claimed')
378 return still.length === 0 ? null : unprovedNote(still.map(a => ({ id: a.id, text: a.text })))
379}
380
381/** Re-reads the estate ticket; a helper that cannot run leaves it unavailable, never zeros. */
382async function refreshTicket($: any): Promise<void> {
383 if (s.adapters.ticket === null) return
384 let run: { exitCode: number; stdout: string } | null
385 try {
386 run = await $.process.run(['python', s.adapters.ticket, '--json', '--project-dir', s.cwd], {
387 timeoutMs: HELPER_TIMEOUT_MS,
388 })
389 } catch {
390 run = null
391 }
392 const ticket = ticketFromRun(run)
393 await update($, ticketAtom, () => ticket)
394}
395
396/**
397 * The session log this watch is bound to (1.1), read by its one path: no folder listing
398 * and no mtime test, so another session's log in the same repo is never followed.
399 * Unbound (a session that has not touched its log yet): null.
400 */
401async function boundLog($: any): Promise<{ path: string; text: string } | null> {
402 const bound: LogBinding | null = await read($, logAtom)
403 if (bound === null) return null
404 try {
405 if (!(await $.fs.exists(bound.path))) return null
406 const text: string = await $.fs.read(bound.path)
407 return { path: bound.path, text }
408 } catch {
409 return null
410 }
411}
412
413/** The bound log while it is open: its Goals are synced and the print is written into it. */
414async function openLog($: any): Promise<{ path: string; text: string } | null> {
415 const log = await boundLog($)
416 return log !== null && isOpenLog(log.text) ? log : null
417}
418
419/** The bound log, closed or not: the close triage lands after the close. */
420async function sessionLog($: any): Promise<{ path: string; text: string } | null> {
421 return boundLog($)
422}
423
424/**
425 * Binds the watch to the session log a main-loop file call touched (1.1). A Write of another
426 * log over a Write rebinds only once the bound log is closed, so that log is read first.
427 */
428async function bindTouch($: any, touch: LogTouch, now: number): Promise<void> {
429 const current: LogBinding | null = await read($, logAtom)
430 const open = needsOpenCheck(current, touch) ? (await openLog($)) !== null : true
431 const next = bindLog(current, touch, now, open)
432 if (next === current) return
433 await update($, logAtom, () => next)
434 // The goals' source moved: read its Goals now rather than at the next tick.
435 if (s.interactive && (current === null || current.path !== next?.path)) $.clock.after(0, () => syncGoals($, now))
436}
437
438/** What the close triage settled leaves the undone (0.8.0, a field report). */
439async function syncTriage($: any): Promise<void> {
440 const log = await sessionLog($)
441 if (log === null) return
442 const triage = parseTriage(log.text)
443 if (triage.dismissed.length + triage.todo.length === 0) return
444 await update($, asksAtom, asks => applyTriage(asks, triage))
445}
446
447/**
448 * Reads the open log's Goals into the asks ledger. Runs at session start, at every turn's
449 * end, AND on the housekeeping tick and every prompt: one long turn (S163, an hour of work
450 * with Bradley's messages arriving mid-turn) never reached a turn's end, so the log's goals
451 * never arrived and the band read 0/7 while the work landed.
452 *
453 * A goal is dated from the session's start, not from when it was synced: synced late,
454 * it would have refused every read-back made before the sync as evidence.
455 */
456async function syncGoals($: any, now: number): Promise<void> {
457 await syncTriage($).catch(() => undefined)
458 const log = await openLog($)
459 const goals = log === null ? [] : parseGoals(log.text, s.config.goalsHeading)
460 if (goals.length === 0) return
461 const ids = newIds(now, goals.length, 'g')
462 const at = s.startedAt > 0 ? s.startedAt : now
463 const ledger = await read($, ledgerAtom)
464 await update($, asksAtom, asks => resolveStates(mergeGoals(asks, goals, { at, ids }), ledger))
465}
466
467/** The Haiku pass over one of Bradley's prompts. Adds nothing to Claude's context. */
468async function extractAsks($: any, text: string, now: number, previous: string): Promise<void> {
469 const before: Ask[] = await read($, asksAtom)
470 const r = await complete($, 'extract', extractPrompt(text, before, previous))
471 if (!r.isAnswered) return
472 const found = parseExtraction(r.text, before)
473 if (found.asks.length + found.builds.length === 0) return
474 // A multi-session build is held apart from this session's asks (0.6.0).
475 const fresh = [
476 ...freshAsks(found.asks, newIds(now, found.asks.length, 'a'), now),
477 ...freshAsks(found.builds, newIds(now, found.builds.length, 'b'), now, 'build'),
478 ]
479 await update($, asksAtom, list => [...list, ...fresh])
480}
481
482/** Which open asks a "done" answer claims; they ride on that claim from here. */
483async function matchAsks($: any, claimId: string, answer: string): Promise<void> {
484 const open = claimable(await read($, asksAtom))
485 if (open.length === 0) return
486 const r = await complete($, 'match', matchPrompt(open, answer))
487 if (!r.isAnswered) return
488 const ids = parseMatch(r.text, open.length).flatMap(n => open[n - 1]?.id ?? [])
489 if (ids.length === 0) return
490 const ledger = await read($, ledgerAtom)
491 await update($, asksAtom, list => resolveStates(applyMatches(list, ids, claimId), ledger))
492 await developFromAsks($)
493}
494
495/** A claim whose matched asks are all witnessed develops (to-do 3f33...1b36); its asks follow it. */
496async function developFromAsks($: any): Promise<void> {
497 const asks: Ask[] = await read($, asksAtom)
498 await update($, ledgerAtom, l => developByAsks(l, asks))
499 const ledger = await read($, ledgerAtom)
500 await update($, asksAtom, a => resolveStates(a, ledger))
501}
502
503async function saveCarry($: any): Promise<void> {
504 await $.store.set(storeKeys(s.key).carry, carryOver(await read($, asksAtom)))
505}
506
507async function saveFriction($: any): Promise<void> {
508 await $.store.set(storeKeys(s.key).friction, s.friction)
509}
510
511async function loadStored($: any, now: number): Promise<void> {
512 const keys = storeKeys(s.key)
513 const carried = await $.store.get(keys.carry)
514 if (Array.isArray(carried) && carried.length > 0) {
515 const seeded = seedCarried(carried as Ask[], { at: now, ids: newIds(now, carried.length, 'k') })
516 await update($, asksAtom, a => (a.length === 0 ? seeded : a))
517 }
518 const log = await $.store.get(keys.friction)
519 // Legacy third-edit entries are dropped: a file under build is work, not friction (1.1).
520 setSession({ friction: realFriction(Array.isArray(log) ? (log as Friction[]) : []) })
521 const stored = await $.store.get(keys.candidates)
522 if (Array.isArray(stored)) await update($, candidatesAtom, () => stored as Candidate[])
523}
524
525/**
526 * The hook and skill candidate pass (S163). A Haiku side pass over this session's call
527 * digest and the project's friction; adds nothing to Claude's context. `force` (the print)
528 * skips the interval but still needs enough new calls to be worth reading.
529 */
530async function scanCandidates($: any, now: number, force: boolean): Promise<void> {
531 if (s.scanning || !s.interactive) return
532 const lastScanAt: number = await read($, candidateScanAtom)
533 const observed: Observed[] = await read($, observedAtom)
534 const newCalls = observed.filter(o => o.at > lastScanAt && !o.agent).length
535 const due = force ? newCalls >= CANDIDATE_MIN_NEW_CALLS : shouldScan({ now, lastScanAt, newCalls })
536 if (!due) return
537 setSession({ scanning: true })
538 try {
539 await update($, candidateScanAtom, () => now)
540 const existing: Candidate[] = await read($, candidatesAtom)
541 // The installed commands and skills, so a candidate never re-proposes one (a field report).
542 const commands: any[] = await $.command.list().catch(() => [])
543 const installed = commands
544 .filter(c => c?.source === 'user' || c?.source === 'plugin')
545 .map(c => ({ name: String(c.name), description: String(c.description ?? '') }))
546 const guards = estate() ? INSTALLED_GUARDS : []
547 const prompt = candidatesPrompt({ friction: s.friction, calls: callDigest(observed), existing, installed, guards })
548 const r = await complete($, 'candidates', prompt)
549 if (!r.isAnswered) return
550 const fresh = parseCandidates(r.text, existing, now, guards)
551 if (fresh.length === 0) return
552 const next = mergeCandidates(existing, fresh)
553 await update($, candidatesAtom, () => next)
554 await $.store.set(storeKeys(s.key).candidates, next)
555 } finally {
556 setSession({ scanning: false })
557 }
558}
559
560/** Appends this watch's unfiled candidates to the running file, then marks them filed. */
561async function fileCandidates($: any, now: number, source: string): Promise<void> {
562 const candidates: Candidate[] = await read($, candidatesAtom)
563 const unfiled = candidates.filter(c => !c.filed)
564 if (unfiled.length === 0) return
565 const root = s.cwd.replace(/\\/g, '/').replace(/\/+$/, '')
566 const path = candidatesPath(s.cwd, await $.fs.exists(`${root}/reports`))
567 const before: string = (await $.fs.exists(path)) ? await $.fs.read(path) : ''
568 const date = new Date(now).toISOString().slice(0, 10)
569 await $.fs.write(path, appendCandidates(before, candidates, { date, source }))
570 const next = markFiled(candidates, unfiled.map(c => c.key))
571 await update($, candidatesAtom, () => next)
572 await $.store.set(storeKeys(s.key).candidates, next)
573}
574
575/** Between sessions: keep the friction that recurs, as barnacles. */
576async function recurBarnacles($: any): Promise<void> {
577 const keys = storeKeys(s.key)
578 const stored = await $.store.get(keys.barnacles)
579 const stored_: Barnacle[] = Array.isArray(stored) ? (stored as Barnacle[]) : []
580 // A scrubbed key's older friction is forgotten (/barnacles, 1.1): it grows back only from new friction.
581 const log = afterScrub(s.friction, asScrubbed(await $.store.get(keys.scrubbed)))
582 // A barnacle with no real friction behind it any more comes off (1.1, Bradley 2026-10-09:
583 // the third-edit barnacles were never friction), whether or not the pass runs.
584 const existing = stored_.filter(b => log.some(f => f.key === b.key))
585 let next = existing
586 if (log.length > 0) {
587 const r = await complete($, 'barnacles', recurPrompt(log, existing))
588 if (r.isAnswered) next = mergeBarnacles(existing, parseRecur(r.text))
589 }
590 await $.store.set(keys.barnacles, next)
591 await update($, barnaclesAtom, () => next)
592}
593
594/**
595 * Scrubs one barnacle (or every one, `key` null) off the shell: the list and the store lose
596 * it, and the key is stamped scrubbed so the next recurrence pass reads only friction logged
597 * after now (/barnacles, 1.1, Bradley 2026-10-09).
598 */
599async function scrubBarnacles($: any, key: string | null): Promise<void> {
600 const now = await $.clock.now()
601 const keys = storeKeys(s.key)
602 const list: Barnacle[] = await read($, barnaclesAtom)
603 const gone = key === null ? list : list.filter(b => b.key === key)
604 const next = scrub(list, key)
605 await update($, barnaclesAtom, () => next)
606 await $.store.set(keys.barnacles, next)
607 await $.store.set(keys.scrubbed, stampScrub(asScrubbed(await $.store.get(keys.scrubbed)), gone, now))
608}
609
610/**
611 * A scrubbed barnacle regrows the moment its friction is recorded again (Bradley, 2026-10-09:
612 * the scrub "hasn't worked properly" if it comes back, so say so): back on the shell with its
613 * old words, unspoken, its scrub lifted so the recurrence pass sees the whole history again.
614 */
615async function regrowBarnacles($: any, found: readonly Friction[]): Promise<void> {
616 if (found.length === 0) return
617 const keys = storeKeys(s.key)
618 const scrubbed = asScrubbed(await $.store.get(keys.scrubbed))
619 const shell: Barnacle[] = await read($, barnaclesAtom)
620 const back = found
621 .map(f => regrown(scrubbed, f))
622 .filter((b): b is Barnacle => b !== null && !shell.some(x => x.key === b.key))
623 .filter((b, i, all) => all.findIndex(x => x.key === b.key) === i)
624 if (back.length === 0) return
625 const next = [...shell, ...back]
626 await update($, barnaclesAtom, () => next)
627 await $.store.set(keys.barnacles, next)
628 await $.store.set(keys.scrubbed, back.reduce((acc, b) => unscrub(acc, b.key), scrubbed))
629}
630
631/** End of watch: the print into the open session log, the undone carried forward. */
632/** What a print came to: the line said to Bradley, the print itself, and where it was written. */
633type Printed = { summary: string; print: string; path: string | null }
634
635async function writePrint($: any): Promise<Printed> {
636 const now = await $.clock.now()
637 const asks: Ask[] = await read($, asksAtom)
638 const ledger = await read($, ledgerAtom)
639 await saveCarry($)
640 const counted = tally(asks, ledger)
641 const log = await openLog($)
642 if (log === null) {
643 const summary = `No open session log from this session; the undone were kept for the next watch (${counted}).`
644 return { summary, print: printSection(asks, ledger), path: null }
645 }
646 await scanCandidates($, now, true).catch(() => undefined)
647 const spoken: Spoken[] = await read($, spokenAtom)
648 const candidates: Candidate[] = await read($, candidatesAtom)
649 const extra = [
650 ...notesSection({ friction: s.friction, spoken, since: s.startedAt }),
651 '',
652 ...candidatesSection(candidates, s.startedAt),
653 ]
654 const print = printSection(asks, ledger, extra)
655 await $.fs.write(log.path, withPrint(log.text, print))
656 const name = log.path.split('/').pop() ?? log.path
657 const filed = await fileCandidates($, now, name).then(() => '', () => ' The candidates file could not be written.')
658 const summary = `Witness print written to ${log.path} (${counted}). Pale and open asks are listed there as open, never as done.${filed}`
659 return { summary, print, path: log.path }
660}
661
662/**
663 * /tackle: the undone asks as an /agile run, put in the prompt box for Bradley to
664 * read and send. Never over a draft he is writing, and never sent by the mod.
665 */
666async function tackle($: any): Promise<string> {
667 // A /tackle right after /close must see the triage the close just wrote.
668 await syncTriage($).catch(() => undefined)
669 const asks: Ask[] = await read($, asksAtom)
670 const skill = estate() ? ESTATE_TACKLE_SKILL : null
671 const text = tacklePrompt(asks, skill)
672 if (text === null) return 'Nothing undone: every ask this watch is witnessed or dropped.'
673 const box: any = await $.prompt.read()
674 if (typeof box?.text === 'string' && box.text.trim().length > 0) {
675 return 'Your prompt box holds a draft; clear it, then /tackle again.'
676 }
677 const filled: any = await $.prompt.fill({ text, mode: 'replace' })
678 const n = undone(asks).length
679 return filled?.isFilled === false
680 ? `The prompt box would not take the ${skill ?? 'tackle'} run.`
681 : `${skill ?? 'A run'} over ${n} undone ask${n === 1 ? '' : 's'} is in your prompt box: read it, then press Enter.`
682}
683
684/**
685 * The confirm tool: Claude says asks are done, and only the evidence the mod checks
686 * against its own observations counts. Answered here; it never reaches a tool.
687 */
688async function confirm($: any, input: unknown): Promise<string> {
689 const items = parseItems(input)
690 if (items.length === 0) return 'Nothing to confirm: pass items of { ask, evidence: [...] }.'
691 const now = await $.clock.now()
692 const observed = await read($, observedAtom)
693 const heard = await read($, heardAtom)
694 const before: Ask[] = await read($, asksAtom)
695 const { asks, verdicts } = confirmAsks(before, items, observed, heard, now, s.config.person)
696 await update($, asksAtom, () => asks)
697 await developFromAsks($)
698 await saveCarry($)
699 const won = verdicts.filter(v => v.ok && v.reason === 'witnessed').length
700 if (won > 0) await say($, 'WITNESSED', `${won} ask${won === 1 ? '' : 's'} done: a read-back proved it.`, false, now + 30_000)
701 return confirmReport(verdicts, asks, s.config.closeCommands[0])
702}
703
704// ---- the watch ----
705
706function taken(w: Watch, now: number): Watch {
707 return { ...w, state: 'taken', takenAt: now }
708}
709
710async function relieve($: any): Promise<void> {
711 const run = await runHelper($, [])
712 const info: WatchInfo | null = run === null ? null : parseWatch(run)
713 setSession({ rowIds: info?.inbox?.map(r => r.id) ?? [] })
714 await update($, watchAtom, w => ({ ...w, info }))
715 const w = await read($, watchAtom)
716 if (w.state !== 'pending') return
717 // No watch script (1.0): nothing to measure or say; taken quietly so a barnacle may speak.
718 if (s.adapters.watch === null && !s.watchHold) {
719 const at = await $.clock.now()
720 await update($, watchAtom, x => taken(x, at))
721 return
722 }
723 // With the hold on, an unmeasured watch waits for the first prompt, which asks and
724 // says so (takeWatch). With it off, nothing would ever take it, and a pending watch
725 // never lets a barnacle speak (S165): so it is said and taken here, measured or not.
726 if (info === null && s.watchHold) return
727 const now = await $.clock.now()
728 const header = info ? watchHeader(info) : 'WATCH'
729 const text = info ? watchText(info) : 'Watch not measured.'
730 await say($, header, text, true, s.watchHold ? null : now + SPEAK_MS)
731 if (!s.watchHold) await update($, watchAtom, x => taken(x, now))
732}
733
734async function takeWatch($: any, now: number): Promise<void> {
735 const w = await read($, watchAtom)
736 if (w.state !== 'pending' && !isIdle(await read($, activityAtom), now)) return
737 const info = w.info
738 await say($, info ? watchHeader(info) : 'WATCH', info ? watchText(info) : 'Watch not measured.', true, null)
739 let answer = ''
740 try {
741 answer = await $.ui.ask('Take the watch?', { header: 'Watch', options: [TAKE, 'Not now'] })
742 } catch {
743 answer = 'dismissed'
744 }
745 await update($, watchAtom, x => taken(x, now))
746 await say($, 'WATCH', answer === TAKE ? 'Watch taken.' : 'Watch left untaken.', false, now + 20_000)
747}
748
749async function poll($: any): Promise<void> {
750 const run = await runHelper($, ['--rows'])
751 const rows: BoardRow[] | null = run === null ? null : parseRows(run)
752 if (rows === null) return
753 const w = await read($, watchAtom)
754 const info = w.info ? { ...w.info, inbox: rows } : null
755 const landed = info ? landedRows(s.rowIds, info) : []
756 setSession({ rowIds: rows.map(r => r.id) })
757 if (info) await update($, watchAtom, x => ({ ...x, info }))
758 const first = landed[0]
759 if (first === undefined) return
760 const now = await $.clock.now()
761 // The sender, said as one: "501_blog: ..." in an Overseer session read as 501_blog's
762 // own board leaking in (S165), when it is a row 501_blog sent to this board.
763 $.ui.toast(`Board: ${landed.length} new, from ${first.from}: ${first.subject}`)
764 await say($, 'BOARD', `from ${first.from}: ${first.subject}`, false, now + 120_000)
765}
766
767async function housekeep($: any): Promise<void> {
768 const now = await $.clock.now()
769 await syncGoals($, now).catch(() => undefined)
770 $.clock.after(0, () => scanCandidates($, now, false).catch(() => undefined))
771 await update($, noticesAtom, q => settle(q, now))
772 const w = await read($, watchAtom)
773 // One barnacle at a time: it waits until the queue has emptied.
774 if (w.state !== 'taken' || (await read($, noticesAtom)).items.length > 0) return
775 const barnacles = await read($, barnaclesAtom)
776 const next = nextToSpeak(barnacles)
777 if (next === null) return
778 await say($, 'YOU SHOULD KNOW', next.text, true, now + SPEAK_MS)
779 const marked = markSpoken(barnacles, next.key)
780 await update($, barnaclesAtom, () => marked)
781 await $.store.set(storeKeys(s.key).barnacles, marked)
782}
783
784/** One idle-animation step; writes (and so redraws) only when the frame changes. */
785async function stepFrame($: any): Promise<void> {
786 setSession({ tick: s.tick + 1 })
787 const next = FRAME_CYCLE[s.tick % FRAME_CYCLE.length] ?? 0
788 if ((await read($, frameAtom)) !== next) await update($, frameAtom, () => next)
789}
790
791// ---- the witness: the hold and the ledger ----
792
793async function holdWrite($: any, e: any): Promise<{ pass: boolean; e: any; reason: string }> {
794 const now = await $.clock.now()
795 const fixed = withReturning(e.query)
796 await update($, holdAtom, () => ({ toolUseId: e.tool_use_id, sql: e.query, fixed, at: now }))
797 // First in the queue until answered; never dismissed.
798 const heldId = `h${now}`
799 const held = { kind: 'held' as const, header: 'HELD', text: HOLD_TEXT, full: true, until: null }
800 await update($, noticesAtom, q => enqueue(q, held, heldId, now))
801 let answer = CANCEL
802 try {
803 const question =
804 fixed === null
805 ? 'This write names nothing it touched, and no safe RETURNING form exists. What should happen to it?'
806 : `This write names nothing it touched. Add RETURNING would send: ${fixed} - what should happen to it?`
807 answer = await $.ui.ask(question, { header: 'Held', options: fixed === null ? [ONCE, CANCEL] : [ADD, ONCE, CANCEL] })
808 } catch {
809 answer = CANCEL
810 } finally {
811 await update($, holdAtom, () => null)
812 await update($, noticesAtom, q => remove(q, heldId))
813 }
814 // Every answer but a cancel is noted here; a cancel is noted by the deny's classification.
815 const noted = (kind: Friction['kind']): void => {
816 const f: Friction = { kind, key: SQL_TOOL, at: now, detail: askText(e.query).slice(0, 160) }
817 setSession({ friction: noteFriction(s.friction, f) })
818 }
819 if (answer === ADD && fixed !== null) {
820 noted('returning-added')
821 return { pass: true, e: { ...e, query: fixed }, reason: '' }
822 }
823 if (answer === ONCE) {
824 noted('let-through')
825 return { pass: true, e, reason: '' }
826 }
827 const who = personRef(s.config.person)
828 const said = answer === CANCEL ? '' : ` ${who.charAt(0).toUpperCase()}${who.slice(1)} said: ${answer}`
829 return {
830 pass: false,
831 e,
832 reason: `witness: held and cancelled - an UPDATE or DELETE must name what it touched; use WITH w AS (<statement> RETURNING *) SELECT * FROM w.${said}`,
833 }
834}
835
836async function recordRun($: any, e: any, ran: any): Promise<void> {
837 const now = await $.clock.now()
838 const { tool, tool_use_id: _id, agentId, ...input } = e
839 const resultText = ran.deny !== undefined ? String(ran.deny) : String(ran.text ?? '')
840 const isError = ran.deny !== undefined || ran.isError === true
841 const classified = classify({ tool, input, resultText, isError, isReadOnly: ran.isReadOnly === true, writers: s.writers })
842 const ids = newIds(now, classified.writes.length, 'w')
843 const turnId = await read($, turnAtom)
844 const subagent = agentId !== undefined
845 // An agent's writes ride the main turn's id, attributed to the agent so its report can claim them (1.1).
846 const by = subagent ? { agentId: String(agentId) } : {}
847 await update($, ledgerAtom, l => recordCall(l, { turnId, at: now, ids, classified, subagent, ...by }))
848 const ledger = await read($, ledgerAtom)
849 await update($, asksAtom, a => resolveStates(a, ledger))
850 const seen: Observed = {
851 toolUseId: String(_id ?? ''),
852 tool,
853 input: inputText(input),
854 result: resultText,
855 isError,
856 agent: subagent,
857 at: now,
858 }
859 await update($, observedAtom, log => observe(log, seen))
860 // Friction is the main session's own: a sub-agent's failures, interleaved across agents,
861 // are neither its friction nor part of its "in a row" (a field report).
862 if (!subagent) {
863 const found = frictionFrom({ tool, input, resultText, isError, recent: s.recent, at: now })
864 setSession({
865 friction: found.reduce(noteFriction, s.friction),
866 recent: [...s.recent, { tool, input, isError, at: now }].slice(-RECENT_CAP),
867 })
868 await regrowBarnacles($, found)
869 }
870 await update($, activityAtom, a => touch(a, now))
871 // The watch follows the log the main loop itself writes, edits or reads (1.1); an agent's binds nothing.
872 if (!subagent && !isError) {
873 const logged = logTouch(tool, input, s.cwd, s.config.sessionsDir)
874 if (logged !== null) await bindTouch($, logged, now)
875 }
876}
877
878/** Asks called done on this claim and still unproved: put to Claude on the next prompt, once each. */
879function noteUnproved(asks: readonly Ask[], claimId: string): void {
880 const unproved = asks
881 .filter(a => a.state === 'claimed' && a.claimId === claimId && !s.nudged.includes(a.id))
882 .map(a => a.id)
883 if (unproved.length > 0) setSession({ nudge: [...new Set([...s.nudge, ...unproved])] })
884}
885
886async function afterTurn($: any, now: number, claimId: string | null, answer: string): Promise<void> {
887 await syncGoals($, now)
888 if (claimId !== null) {
889 await matchAsks($, claimId, answer)
890 noteUnproved(await read($, asksAtom), claimId)
891 }
892 await saveCarry($)
893 await saveFriction($)
894}
895
896/**
897 * A subagent's final report (1.1, ruled 2026-10-09): a pale claim the main session must read
898 * back before it develops. Minted only for an agent this mod saw make a call; its asks are
899 * matched, and an unproved one put to Claude, exactly as for any claim.
900 */
901async function agentReport($: any, agentId: string, answer: string, now: number): Promise<void> {
902 const cid = newIds(now, 1, 'c')[0] ?? `c${now}`
903 let minted: string | null = null
904 await update($, ledgerAtom, l => {
905 const r = recordAgentTurn(l, { agentId, at: now, answer, id: cid })
906 minted = r.claim?.id ?? null
907 return r.ledger
908 })
909 const claimId: string | null = minted
910 if (claimId === null) return
911 const ledger = await read($, ledgerAtom)
912 await update($, asksAtom, a => resolveStates(a, ledger))
913 $.clock.after(0, () => afterAgentReport($, claimId, answer))
914}
915
916async function afterAgentReport($: any, claimId: string, answer: string): Promise<void> {
917 await matchAsks($, claimId, answer)
918 noteUnproved(await read($, asksAtom), claimId)
919 await saveCarry($)
920}
921
922/** A slash command's arguments can carry an ask (`/task fix the walker`); the command name cannot. */
923function askableText(text: string): string {
924 const t = text.trim()
925 if (isTacklePrompt(t) || isWrapReadPrompt(t)) return ''
926 // What he quoted or pasted is not what he asked (seen live, S166).
927 if (!t.startsWith('/')) return withoutQuoted(t).trim()
928 const rest = withoutQuoted(t.replace(/^\/\S+\s*/, '')).trim()
929 return rest.length >= 12 ? rest : ''
930}
931
932// ---- events ----
933
934export const register: Register = (on, options) => {
935 const opts = (options ?? {}) as { body?: string; watchHold?: boolean }
936 const config = readConfig(options)
937 setSession({ config, writers: writerPattern(config.writers) })
938 // Off unless turned on (S164, ruled with the user-scope install): no project is held at every start.
939 setSession({ body: opts.body === 'phial' ? 'phial' : 'phi', watchHold: opts.watchHold === true })
940
941 on('session.start', async ($, e, next) => {
942 const now = await $.clock.now()
943 const usage: any = await $.session.usage().catch(() => null)
944 const sessionId: unknown = await $.session.id().catch(() => '')
945 setSession({
946 cwd: e.cwd,
947 key: projectKey(e.cwd),
948 interactive: e.isInteractive,
949 startedAt: typeof usage?.startedAt === 'number' ? usage.startedAt : now,
950 sessionId: typeof sessionId === 'string' ? sessionId : '',
951 })
952 // The estate adapters (1.0): each off when its script is absent.
953 const root = pluginRoot($)
954 const adapters = await findAdapters($, e.cwd, root)
955 setSession({ adapters, usageDir: usageDir(await homeDir($)) })
956 // With nothing to look from, the band says `estate: ?` rather than drawing the companion's
957 // own counts as if there were no estate (1.1): a lookup that could not run is not an absence.
958 if (adapters.ticket === null && s.config.ticketScript === '' && root === '') {
959 await update($, ticketAtom, () => UNAVAILABLE_TICKET)
960 }
961 await $.command.register({ name: 'undone', description: 'Witness: every ask this watch - witnessed, pale or open' })
962 await $.command.register({
963 name: 'wrap',
964 description: 'Witness: end the watch and print what was and was not witnessed into the session log',
965 })
966 await $.command.register({
967 name: 'tackle',
968 description: `Witness: put ${estate() ? `an ${ESTATE_TACKLE_SKILL}` : 'a'} run over the undone asks in the prompt box, to read and send`,
969 })
970 // The costume seam (1.1, Bradley 2026-10-09): a later organ dresses Phi through $.command.run.
971 await $.command.register({ name: 'wear', description: 'Witness: dress Phi - razor, compass, horns, or none' })
972 await $.command.register({
973 name: 'barnacles',
974 description: 'Witness: what the barnacles on the shell record, and scrub them off',
975 })
976 // A refused registration costs the confirm tool, never the rest of the start.
977 const person = s.config.person
978 await $.tool
979 .register({ name: 'confirm', description: confirmDescription(person), inputSchema: confirmSchema(person) })
980 .catch(() => undefined)
981 await loadStored($, now)
982 if (!e.isInteractive) return next(e)
983 const settings: any = await $.settings.read()
984 if (estate()) {
985 $.clock.after(0, () => refreshTicket($))
986 $.clock.every(TICKET_MS, () => refreshTicket($))
987 }
988 $.clock.after(0, () => relieve($))
989 $.clock.after(0, () => recurBarnacles($))
990 $.clock.after(0, () => syncGoals($, now))
991 if (settings?.prefersReducedMotion !== true) $.clock.every(FRAME_MS, () => stepFrame($))
992 $.clock.every(HOUSEKEEP_MS, () => housekeep($))
993 if (s.adapters.watch !== null) $.clock.every(POLL_MS, () => poll($))
994 return next(e)
995 })
996
997 on('prompt.submit', async ($, e, next) => {
998 const now = await $.clock.now()
999 const mine = e.origin.kind === 'composer' || e.origin.kind === 'bridge'
1000 if (mine && s.watchHold && s.interactive) await takeWatch($, now)
1001 await update($, activityAtom, a => touch(a, now))
1002 if (mine) await update($, heardAtom, log => hear(log, { text: e.text, at: now }))
1003 // The print lands in the session log before the close skill reads it.
1004 // A project with no session log gets its print on the /close prompt instead.
1005 let closeNote: string | null = null
1006 if (mine && isCloseCommand(e.text, s.config.closeCommands)) {
1007 const printed = await writePrint($).catch(() => null)
1008 const summary = printed?.summary ?? 'The witness print could not be written.'
1009 await say($, 'PRINT', summary, true, now + PRINT_MS)
1010 if (printed !== null && printed.path === null) closeNote = closePrintNote(printed.print, estate())
1011 }
1012 const text = askableText(e.text)
1013 // The answer this prompt replies to, read now: the pass runs after the next turn may have started.
1014 const previous = s.lastAnswer
1015 if (mine && s.interactive && text.length > 0) $.clock.after(0, () => extractAsks($, text, now, previous))
1016 if (s.interactive) $.clock.after(0, () => syncGoals($, now))
1017 // A /tackle prompt lists the undone asks itself, so it carries no note; its turn is
1018 // recorded as `tackle` (0.5.0).
1019 const tackle = mine && isTacklePrompt(e.text)
1020 if (tackle) setSession({ tackleTurn: true })
1021 const note = mine && s.interactive && !tackle ? await takeNudge($) : null
1022 const notes = [closeNote, note].filter((n): n is string => n !== null)
1023 return next(notes.length === 0 ? e : { ...e, context: [...(e.context ?? []), ...notes] })
1024 })
1025
1026 on('turn.start', async ($, e, next) => {
1027 await update($, turnAtom, () => e.turnId)
1028 return next(e)
1029 })
1030
1031 on('tool.call', async ($, e: any, next) => {
1032 if (e.tool === CONFIRM_TOOL) {
1033 // A report is a claim, never its own read-back (1.1): only the main session confirms.
1034 if (e.agentId !== undefined) return { deny: SUBAGENT_CONFIRM }
1035 const { tool: _t, tool_use_id: _u, agentId: _a, ...input } = e
1036 return { result: await confirm($, input) }
1037 }
1038 let call = e
1039 if (isHeldSql(e)) {
1040 if (e.agentId !== undefined || !s.interactive) {
1041 try {
1042 await recordRun($, e, { deny: SUBAGENT_HELD })
1043 } catch {}
1044 return { deny: SUBAGENT_HELD }
1045 }
1046 const held = await holdWrite($, e)
1047 if (!held.pass) {
1048 try {
1049 await recordRun($, e, { deny: held.reason })
1050 } catch {}
1051 return { deny: held.reason }
1052 }
1053 call = held.e
1054 }
1055 const ran = await next(call)
1056 await recordRun($, call, ran)
1057 return ran
1058 }).catch(($, e, next) => (next.called ? next(e) : isHeldSql(e) ? { deny: HOLD_FAILED } : next(e)))
1059
1060 on('turn.complete', async ($, e, next) => {
1061 const done = await next(e)
1062 const now = await $.clock.now()
1063 // What the turn cost (0.5.0): the denominator for the side passes' own cost.
1064 if (s.interactive) {
1065 const pass: UsagePass = e.agentId !== undefined ? 'agent' : s.tackleTurn ? 'tackle' : 'turn'
1066 const row = turnRow(pass, now, e.usage)
1067 if (row !== null) await appendUsage($, row).catch(() => undefined)
1068 if (e.agentId === undefined) setSession({ tackleTurn: false })
1069 }
1070 // A subagent's own turn: its final report is a claim of its own (1.1).
1071 if (e.agentId !== undefined) {
1072 await agentReport($, String(e.agentId), String(e.answer ?? ''), now)
1073 return done
1074 }
1075 setSession({ lastAnswer: String(e.answer ?? '').slice(-MAX_PREVIOUS_CHARS) })
1076 const cid = newIds(now, 1, 'c')[0] ?? `c${now}`
1077 let claimId: string | null = null
1078 await update($, ledgerAtom, l => {
1079 const r = recordTurn(l, { turnId: e.turnId, at: now, answer: e.answer, id: cid })
1080 claimId = r.claim?.id ?? null
1081 return r.ledger
1082 })
1083 const ledger = await read($, ledgerAtom)
1084 await update($, asksAtom, a => resolveStates(a, ledger))
1085 await update($, activityAtom, a => touch(a, now))
1086 const answer = e.answer
1087 const claimed = claimId
1088 $.clock.after(0, () => afterTurn($, now, claimed, answer))
1089 return done
1090 })
1091
1092 on('session.end', async ($, e, next) => {
1093 await saveCarry($)
1094 await saveFriction($)
1095 return next(e)
1096 })
1097
1098 on('command.run', { command: 'undone' }, async $ => {
1099 await $.ui.open({ id: PANE, title: 'Undone' })
1100 const asks: Ask[] = await read($, asksAtom)
1101 const c = counts(asks)
1102 const carried = c.carried > 0 ? `, ${c.carried} carried in` : ''
1103 const builds = c.builds > 0 ? `, ${c.builds} build${c.builds === 1 ? '' : 's'} for the to-do list` : ''
1104 return { text: `Witness asks ${nm(asks)}${carried}${builds} - the list is open in the Undone pane.` }
1105 })
1106
1107 on('command.run', { command: 'wrap' }, async $ => {
1108 const now = await $.clock.now()
1109 const printed = await writePrint($)
1110 await say($, 'PRINT', printed.summary, true, now + PRINT_MS)
1111 // The main session reads what was printed and settles it, as a turn of its own (Bradley,
1112 // 2026-10-08). /close does not: the close skill reads the log itself (Step 4b).
1113 if (s.interactive) $.clock.after(0, () => $.prompt.submit({ text: wrapReadPrompt(printed.print, printed.path, s.config.person, s.config.closeCommands[0]) }))
1114 return { text: printed.summary }
1115 })
1116
1117 on('command.run', { command: 'tackle' }, async $ => ({ text: await tackle($) }))
1118
1119 // /wear razor|compass|horns|none: what Phi holds, until changed or the session ends (1.1).
1120 on('command.run', { command: 'wear' }, async ($, e) => {
1121 const costume = parseCostume(e.args)
1122 if (costume === null) return { text: 'Usage: /wear razor | compass | horns | none' }
1123 await update($, costumeAtom, () => costume)
1124 return { text: costume === 'none' ? 'Phi wears nothing.' : `Phi wears the ${costume}.` }
1125 })
1126
1127 // /barnacles: the problems the barnacles record, each with a scrub (1.1, Bradley 2026-10-09).
1128 on('command.run', { command: 'barnacles' }, async $ => {
1129 const list: Barnacle[] = await read($, barnaclesAtom)
1130 if (list.length === 0) return { text: 'No barnacles on the shell: nothing has recurred across sessions.' }
1131 await $.ui.open({ id: BARNACLES_PANE, title: 'Barnacles' })
1132 return { text: `${list.length} barnacle${list.length === 1 ? '' : 's'} on the shell - the list is open in the Barnacles pane.` }
1133 })
1134
1135 on('ui.render', { component: 'Pane', requestId: BARNACLES_PANE }, async ($, e) => {
1136 const list: Barnacle[] = await read($, barnaclesAtom)
1137 const onScrub = (key: string) => scrubBarnacles($, key)
1138 const onScrubAll = () => scrubBarnacles($, null)
1139 return barnaclesTree($.ui.resolve(e), list, onScrub, onScrubAll)
1140 })
1141
1142 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1143 const asks = await read($, asksAtom)
1144 const ledger = await read($, ledgerAtom)
1145 // An agent's report is labelled as one (1.1).
1146 const pale = ledger.claims.filter(c => c.state === 'pale').map(c => ({ ...c, text: claimLabel(c) }))
1147 const onDrop = (id: string) => update($, asksAtom, list => dropAsk(list, id))
1148 const onTackle = async () => $.ui.toast(await tackle($))
1149 return paneTree($.ui.resolve(e), e.viewport?.rows ?? 24, paneRows(asks), pale, onDrop, onTackle)
1150 })
1151
1152 // The band stays low over the prompt (Bradley, S162): the compact sprite always
1153 // (maxRows 0 keeps it compact), at the right edge facing its bubble.
1154 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1155 const below = await next(e)
1156 if (e.props.hasSurvey) return below
1157 // Recorded from here, never from the pure render; written after the draw, not during it.
1158 const heads = beneathText(below)
1159 if (heads !== '' && !s.headsUp.includes(heads)) {
1160 setSession({ headsUp: [...s.headsUp, heads] })
1161 $.clock.after(0, () => hearBeneath($, heads))
1162 }
1163 // The notice queue (Bradley, 2026-10-09): one shown, `<n/m>` when more wait; a note from beneath is counted.
1164 const now = await $.clock.now()
1165 const beneath = heads !== ''
1166 const shown = showing(await read($, noticesAtom), beneath, now)
1167 const speech = shown.notice
1168 const onDismiss = dismissable(speech)
1169 ? async () => {
1170 const at = await $.clock.now()
1171 await update($, noticesAtom, q => dismiss(q, beneath, at))
1172 }
1173 : null
1174 const view = buildView({
1175 body: s.body,
1176 frameIndex: await read($, frameAtom),
1177 costume: await read($, costumeAtom),
1178 ledger: await read($, ledgerAtom),
1179 asks: await read($, asksAtom),
1180 activity: await read($, activityAtom),
1181 now,
1182 speech,
1183 hold: await read($, holdAtom),
1184 barnacles: await read($, barnaclesAtom),
1185 working: e.props.isWorking,
1186 rowsLanded: speech?.header === 'BOARD',
1187 maxRows: 0,
1188 bodyColumns: e.props.bodyColumns,
1189 facing: 'left',
1190 })
1191 const ticket = await read($, ticketAtom)
1192 const footer = { n: shown.n, m: shown.m, onDismiss }
1193 return bandTree($.ui.resolve(e), e.props.bodyColumns, view, below, EYE_ROW[s.body][view.size], ticket, footer)
1194 })
1195}
1196hooks/asks.ts 508 lines1// The asks ledger: Bradley's done/undone tracker. Pure functions over plain data.
2// An ask counts as done only when witnessed; a claimed-but-unwitnessed ask is pale.
3import type { Ask, Ledger } from '../types'
4
5/** `dropped`: a `- [-]` line, a goal the session set aside. */
6export type Goal = { text: string; ticked: boolean; dropped?: boolean }
7/**
8 * done/total/pale count this session's asks only; `carried` the undone carried in from the
9 * last watch; `builds` the live multi-session builds.
10 */
11export type Counts = { done: number; total: number; pale: number; carried: number; builds: number }
12export type Extraction = { asks: string[]; builds: string[] }
13export type PaneRow = { id: string; marker: '[ ]' | '[~]' | '[x]'; text: string; source: Ask['source']; settled?: Ask['settled'] }
14
15export const MAX_ASK_CHARS = 90
16export const MAX_EXTRACT = 5
17export const MAX_BUILDS = 3
18/** How much of the previous answer's end the extract pass reads beside an approval. */
19export const MAX_PREVIOUS_CHARS = 800
20export const MAX_CARRIED = 30
21export const MAX_PROMPT_ASKS = 40
22export const PRINT_END = '<!-- /witness print -->'
23
24/** Every whitespace run (newlines included) becomes one space: no ask can inject a markdown line. */
25export function cleanText(text: string): string {
26 return String(text ?? '').replace(/\s+/g, ' ').trim()
27}
28
29/** The n newest entries (by `at`, later index wins a tie), kept in original order with original indices. */
30function newestEntries(asks: Ask[], n: number): Array<{ a: Ask; i: number }> {
31 const all = asks.map((a, i) => ({ a, i }))
32 if (all.length <= n) return all
33 const keep = [...all].sort((x, y) => y.a.at - x.a.at || y.i - x.i).slice(0, n)
34 return keep.sort((x, y) => x.i - y.i)
35}
36
37/** Lower-case, links and punctuation stripped, first 60 chars: the dedupe key. */
38export function normalise(text: string): string {
39 return text
40 .toLowerCase()
41 .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
42 .replace(/https?:\/\/\S+/g, ' ')
43 .replace(/[^\p{L}\p{N}\s]/gu, ' ')
44 .replace(/\s+/g, ' ')
45 .trim()
46 .slice(0, 60)
47 .trim()
48}
49
50/** First balanced {...} object in the text that parses as JSON; null on anything else. */
51export function parseJsonObject(reply: string): Record<string, unknown> | null {
52 if (typeof reply !== 'string') return null
53 const text = reply.replace(/```[a-zA-Z]*/g, ' ')
54 for (let start = text.indexOf('{'); start !== -1; start = text.indexOf('{', start + 1)) {
55 const end = balancedEnd(text, start)
56 if (end === -1) continue
57 try {
58 const value: unknown = JSON.parse(text.slice(start, end + 1))
59 if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
60 return value as Record<string, unknown>
61 }
62 } catch {
63 // try the next opening brace
64 }
65 }
66 return null
67}
68
69function balancedEnd(text: string, start: number): number {
70 let depth = 0
71 let inString = false
72 for (let i = start; i < text.length; i++) {
73 const c = text[i]
74 if (inString) {
75 if (c === '\\') i++
76 else if (c === '"') inString = false
77 } else if (c === '"') inString = true
78 else if (c === '{') depth++
79 else if (c === '}' && --depth === 0) return i
80 }
81 return -1
82}
83
84/**
85 * `- [ ]` / `- [x]` / `- [-]` items under the goals heading (config `goalsHeading`,
86 * default `## Goals`), up to the next heading of the same level.
87 */
88export function parseGoals(markdown: string, heading = '## Goals'): Goal[] {
89 const h = /^(#+)\s+(.*\S)\s*$/.exec(heading.trim())
90 const level = h?.[1]?.length ?? 2
91 const title = (h?.[2] ?? 'Goals').toLowerCase()
92 const goals: Goal[] = []
93 let inGoals = false
94 for (const line of String(markdown ?? '').split(/\r?\n/)) {
95 const hd = /^(#+)\s+(.*?)\s*$/.exec(line)
96 if (hd !== null && hd[1]?.length === level) {
97 inGoals = (hd[2] ?? '').toLowerCase() === title
98 continue
99 }
100 if (!inGoals) continue
101 const m = /^\s*[-*]\s+\[( |x|X|-)\]\s+(.*\S)\s*$/.exec(line)
102 if (m) goals.push({ text: (m[2] ?? '').trim(), ticked: m[1] === 'x' || m[1] === 'X', dropped: m[1] === '-' })
103 }
104 return goals
105}
106
107/** Shortest dedupe key a goal edit may extend and still be the same goal. */
108const MIN_EDIT_KEY = 12
109
110/**
111 * The live ask a goal names: the same key, else (for a goal-sourced ask only) a key one
112 * extends, as when an outcome is appended to the line in place. Each ask matches once.
113 */
114function goalMatch(asks: Ask[], key: string, taken: ReadonlySet<string>): number {
115 const live = (a: Ask) => a.state !== 'dropped' && !taken.has(a.id)
116 const same = asks.findIndex((a) => live(a) && normalise(a.text) === key)
117 if (same !== -1) return same
118 return asks.findIndex((a) => {
119 if (!live(a) || a.source !== 'goal') return false
120 const old = normalise(a.text)
121 const short = old.length < key.length ? old : key
122 return short.length >= MIN_EDIT_KEY && (key.startsWith(old) || old.startsWith(key))
123 })
124}
125
126const STOP_WORDS = new Set(
127 'the a an to of and or in on for with that this it its be is are was us we our my me as at by from so then than into up out about can could should would will just also please'.split(' '),
128)
129
130/** A word's plain form: a trailing plural `s`, or `ing` / `ed` on a longer word, cut. */
131function stem(w: string): string {
132 if (w.length > 5 && /(ing|ed)$/.test(w)) return w.replace(/(ing|ed)$/, '')
133 if (w.length > 3 && w.endsWith('s') && !w.endsWith('ss')) return w.slice(0, -1)
134 return w
135}
136
137function contentWords(text: string): Set<string> {
138 const words = text
139 .toLowerCase()
140 .replace(/[^\p{L}\p{N}\s]/gu, ' ')
141 .split(/\s+/)
142 .filter((w) => w.length > 1 && !STOP_WORDS.has(w))
143 return new Set(words.map(stem))
144}
145
146/** Shared content words a restatement needs, and their share of the shorter text. */
147export const RESTATE_SHARED = 3
148export const RESTATE_SHARE = 0.6
149
150/** Content words two texts share, and their share of the shorter text's words. */
151export function wordOverlap(a: string, b: string): { shared: number; share: number } {
152 const wa = contentWords(a)
153 const wb = contentWords(b)
154 const shared = [...wa].filter((w) => wb.has(w)).length
155 return { shared, share: shared / Math.max(1, Math.min(wa.size, wb.size)) }
156}
157
158/** Is `b` close enough to `a` to be the same request in other words? */
159export function restates(a: string, b: string): number | null {
160 const o = wordOverlap(a, b)
161 return o.shared >= RESTATE_SHARED && o.share >= RESTATE_SHARE ? o.share : null
162}
163
164/**
165 * The typed ask a goal restates (a field report: /start wrote Goals rephrasing Bradley's
166 * prompt, and each request counted twice). Most shared content words wins; -1 when none qualifies.
167 */
168function restatedAsk(asks: Ask[], text: string, taken: ReadonlySet<string>): number {
169 let best = -1
170 let bestShare = 0
171 asks.forEach((a, i) => {
172 if (a.source !== 'ask' || a.state === 'dropped' || taken.has(a.id)) return
173 const share = restates(a.text, text)
174 if (share !== null && share > bestShare) {
175 best = i
176 bestShare = share
177 }
178 })
179 return best
180}
181
182/**
183 * The log's Goals are the session's own record, so the ledger follows them:
184 * a goal rescoped in place supersedes its old ask, a `[-]` goal drops it, and a goal
185 * gone from the log is dropped unless witnessed. Asks Bradley typed are never touched.
186 * No goals at all changes nothing: a Goals section that vanished is not a ruling.
187 */
188export function mergeGoals(
189 asks: Ask[],
190 goals: Goal[],
191 opts: { at: number; ids: string[] },
192): Ask[] {
193 if (goals.length === 0) return asks.map((a) => ({ ...a }))
194 let next = asks.map((a) => ({ ...a }))
195 const named = new Set<string>()
196 let used = 0
197 for (const goal of goals) {
198 const text = cleanText(goal.text)
199 const key = normalise(text)
200 if (key === '') continue
201 const matched = goalMatch(next, key, named)
202 // A goal restating a typed ask takes that ask over: one request, one count.
203 const restated = matched === -1 && !goal.dropped ? restatedAsk(next, text, named) : -1
204 const idx = matched !== -1 ? matched : restated
205 if (idx !== -1) {
206 const found = next[idx]!
207 named.add(found.id)
208 const undone = found.state === 'open' || found.state === 'claimed'
209 const patch: Partial<Ask> = goal.dropped
210 ? undone
211 ? { state: 'dropped' }
212 : {}
213 : {
214 ...((found.source === 'goal' || idx === restated) && found.text !== text ? { text } : {}),
215 ...(found.source === 'carried' || found.source === 'build' || idx === restated
216 ? { source: 'goal' as const }
217 : {}),
218 ...(goal.ticked && found.state === 'open' ? { state: 'claimed' as const, claimId: null } : {}),
219 }
220 next = next.map((a, i) => (i === idx ? { ...a, ...patch } : a))
221 continue
222 }
223 if (goal.dropped) continue
224 const id = opts.ids[used]
225 if (id === undefined) continue
226 used++
227 named.add(id)
228 next = [
229 ...next,
230 {
231 id,
232 text,
233 source: 'goal',
234 state: goal.ticked ? 'claimed' : 'open',
235 claimId: null,
236 at: opts.at,
237 },
238 ]
239 }
240 return next.map((a) =>
241 a.source === 'goal' && !named.has(a.id) && (a.state === 'open' || a.state === 'claimed')
242 ? { ...a, state: 'dropped' }
243 : a,
244 )
245}
246
247/**
248 * `previous`: the end of the assistant's last answer, so an approval ("greenlight") can be
249 * read as the proposal it commissions. Only its last MAX_PREVIOUS_CHARS are
250 * sent, where a proposal sits, and the section is left out when there is none.
251 */
252export function extractPrompt(promptText: string, asks: Ask[], previous = ''): string {
253 const existing = newestEntries(
254 asks.filter((a) => a.state !== 'dropped'),
255 MAX_PROMPT_ASKS,
256 )
257 .map(({ a }, i) => `${i + 1}. ${cleanText(a.text)}`)
258 .join('\n')
259 const before = cleanText(previous).slice(-MAX_PREVIOUS_CHARS).trim()
260 return [
261 'You read one message a person sent to an AI coding assistant and list the asks in it, in two kinds.',
262 'asks: things the person wants the assistant to DO and finish in this session (build, fix, add, change, write, run, check and report).',
263 'builds: work the person sets for several sessions or days, a whole project, or many deliverables at once (e.g. "over the next few days", "rebuild X to a shippable standard", "build 18 apps"). Put each item in one kind only.',
264 'When the message approves a concrete proposal at the end of the assistant\'s previous message ("yes", "go ahead", "greenlight", "do it"), list that proposal as an ask, in its own terms. Never list anything from the previous message that this message does not approve.',
265 'Exclude: questions asking for information, questions whether something could or should be done ("can we also have X?", "would X be hard?") until the person asks for it, approvals that commission nothing, opinions, thanks, background context, any standing instruction about how to work, conditional ones included ("explain as we go", "always do X", "if you find a bug, report it"), the method steps of a plan rather than the work it does ("sweep first", "verify each item", "close each with a read-back"), anything the person quotes or pastes to talk about (another prompt, a list, a log) unless they ask for it to be done, and anything already in the existing list.',
266 `Write each item as an imperative of at most ${MAX_ASK_CHARS} characters, in the person's own terms. Never add a word that changes what was asked: "build an /agile ultracode" is not "build an /agile ultracode skill".`,
267 `At most ${MAX_EXTRACT} asks and ${MAX_BUILDS} builds. Use empty lists when there are none.`,
268 'Output shape: {"asks": ["imperative"], "builds": ["imperative"]}',
269 'Reply with the JSON only.',
270 '',
271 'Existing list:',
272 existing === '' ? '(none)' : existing,
273 ...(before === '' ? [] : ['', "The end of the assistant's previous message:", before]),
274 '',
275 'Message:',
276 promptText,
277 ].join('\n')
278}
279
280/** Clean texts from one list of the reply, skipping any key already seen; `seen` is extended. */
281function takeTexts(raw: unknown, seen: Set<string>, cap: number): string[] {
282 if (!Array.isArray(raw)) return []
283 const out: string[] = []
284 for (const item of raw) {
285 if (typeof item !== 'string') continue
286 const text = cleanText(item).slice(0, MAX_ASK_CHARS).trim()
287 const key = normalise(text)
288 if (key === '' || seen.has(key)) continue
289 seen.add(key)
290 out.push(text)
291 if (out.length === cap) break
292 }
293 return out
294}
295
296/** The side pass's two lists, deduped against the existing asks and across each other (asks first). */
297export function parseExtraction(reply: string, asks: Ask[] = []): Extraction {
298 const obj = parseJsonObject(reply)
299 const seen = new Set(asks.map((a) => normalise(a.text)))
300 const found = takeTexts(obj?.asks, seen, MAX_EXTRACT)
301 return { asks: found, builds: takeTexts(obj?.builds, seen, MAX_BUILDS) }
302}
303
304export function parseExtract(reply: string, asks: Ask[] = []): string[] {
305 return parseExtraction(reply, asks).asks
306}
307
308export function matchPrompt(openAsks: Ask[], answer: string): string {
309 // Numbers stay the original 1-based positions in openAsks, so parseMatch(reply, openAsks.length) maps back.
310 const list = newestEntries(openAsks, MAX_PROMPT_ASKS)
311 .map(({ a, i }) => `${i + 1}. ${cleanText(a.text)}`)
312 .join('\n')
313 return [
314 'You compare a numbered list of asks with the answer an AI assistant just gave.',
315 'List the numbers of the asks the answer claims to have COMPLETED. Leave out asks it only discusses, plans, or partly does.',
316 'Output shape: {"done": [1, 3]}',
317 'Use {"done": []} when none. Reply with the JSON only.',
318 '',
319 'Asks:',
320 list === '' ? '(none)' : list,
321 '',
322 'Answer:',
323 answer,
324 ].join('\n')
325}
326
327/** 1-based item numbers from the reply, in range, no duplicates. */
328export function parseMatch(reply: string, n: number): number[] {
329 const raw = parseJsonObject(reply)?.done
330 if (!Array.isArray(raw)) return []
331 const out: number[] = []
332 for (const v of raw) {
333 if (typeof v !== 'number' || !Number.isInteger(v)) continue
334 if (v < 1 || v > n || out.includes(v)) continue
335 out.push(v)
336 }
337 return out
338}
339
340export function applyMatches(asks: Ask[], askIds: string[], claimId: string): Ask[] {
341 return asks.map((a) =>
342 askIds.includes(a.id) && (a.state === 'open' || a.state === 'claimed')
343 ? { ...a, state: 'claimed', claimId }
344 : { ...a },
345 )
346}
347
348export function resolveStates(asks: Ask[], ledger: Ledger): Ask[] {
349 return asks.map((ask) => {
350 if (ask.state === 'dropped' || ask.state === 'witnessed') return { ...ask }
351 // Only an ask's OWN claim counts; a ticked goal with no claim stays pale until applyMatches attaches one.
352 const claimId = ask.claimId
353 const claim = claimId === null ? undefined : ledger.claims.find((c) => c.id === claimId)
354 if (claim === undefined) return { ...ask, claimId }
355 return { ...ask, claimId, state: claim.state === 'developed' ? 'witnessed' : 'claimed' }
356 })
357}
358
359/** This session's own work: not carried in from the last watch, not a multi-session build. */
360export function isSessionAsk(a: Ask): boolean {
361 return a.source !== 'carried' && a.source !== 'build'
362}
363
364/** Carried asks and builds are not Bradley's asks for this session: never in n/m. */
365export function counts(asks: Ask[]): Counts {
366 const live = asks.filter((a) => a.state !== 'dropped' && isSessionAsk(a))
367 return {
368 done: live.filter((a) => a.state === 'witnessed').length,
369 total: live.length,
370 pale: live.filter((a) => a.state === 'claimed').length,
371 carried: asks.filter((a) => a.source === 'carried' && (a.state === 'open' || a.state === 'claimed')).length,
372 builds: asks.filter((a) => a.source === 'build' && a.state !== 'dropped').length,
373 }
374}
375
376export function nm(asks: Ask[]): string {
377 const c = counts(asks)
378 return `${c.done}/${c.total}${c.pale > 0 ? ` ~${c.pale}` : ''}`
379}
380
381const ORDER: Record<string, number> = { open: 0, claimed: 1, witnessed: 2 }
382const MARKER: Record<string, PaneRow['marker']> = { open: '[ ]', claimed: '[~]', witnessed: '[x]' }
383
384/** This session's asks first (open, pale, done), then the carried ones, then the builds, each under its own heading. */
385export function paneRows(asks: Ask[]): PaneRow[] {
386 const group = (a: Ask) => (a.source === 'build' ? 2 : a.source === 'carried' ? 1 : 0)
387 return asks
388 .map((a, i) => ({ a, i }))
389 .filter(({ a }) => a.state !== 'dropped')
390 .sort((x, y) => group(x.a) - group(y.a) || (ORDER[x.a.state] ?? 0) - (ORDER[y.a.state] ?? 0) || x.i - y.i)
391 .map(({ a }) => ({
392 id: a.id,
393 marker: MARKER[a.state] ?? '[ ]',
394 text: a.text,
395 source: a.source,
396 ...(a.settled === undefined ? {} : { settled: a.settled }),
397 }))
398}
399
400export function dropAsk(asks: Ask[], id: string): Ask[] {
401 return asks.map((a) => (a.id === id ? { ...a, state: 'dropped' } : { ...a }))
402}
403
404/**
405 * What may carry (Bradley, 2026-10-08): an open ask nobody claimed, one hop only. A pale
406 * claim or a goal the log ticked stays in the print as pale and is not reopened, and a
407 * carried ask still untouched at the end is not carried again.
408 */
409function carries(a: Ask): boolean {
410 // A build is never carried: /close makes it a to-do (0.6.0); nor is what the close triage settled (0.8.0).
411 return a.state === 'open' && isSessionAsk(a) && a.settled === undefined
412}
413
414export type Triage = { dismissed: string[]; todo: string[] }
415
416/** `- dismissed: <item> - <evidence>` and `- to-do: <item> -> <url>` under `## Witness triage` (/close Step 4b). */
417export function parseTriage(markdown: string): Triage {
418 const dismissed: string[] = []
419 const todo: string[] = []
420 let inTriage = false
421 for (const line of String(markdown ?? '').split(/\r?\n/)) {
422 if (/^##\s+/.test(line)) {
423 inTriage = /^##\s+Witness triage\s*$/i.test(line)
424 continue
425 }
426 if (!inTriage) continue
427 const m = /^\s*[-*]\s+(dismissed|to-do):\s*(.*\S)\s*$/i.exec(line)
428 if (m) (m[1]!.toLowerCase() === 'dismissed' ? dismissed : todo).push((m[2] ?? '').trim())
429 }
430 return { dismissed, todo }
431}
432
433/** Does a triage line quote this ask? It opens with the ask's text (a suffix and the evidence follow). */
434function quotes(line: string, a: Ask): boolean {
435 const k = normalise(a.text)
436 const l = normalise(line)
437 return k !== '' && (l === k || (k.length >= MIN_EDIT_KEY && l.startsWith(k)))
438}
439
440/**
441 * The close triage settles what it quotes (a field report: a /tackle after /close offered
442 * the eight carried asks the triage had just dismissed with evidence). Dismissed: dropped.
443 * To-do: routed to the board, so no longer this session's undone work. Witnessed asks and
444 * anything the triage does not quote are left as they are.
445 */
446export function applyTriage(asks: Ask[], triage: Triage): Ask[] {
447 return asks.map((a): Ask => {
448 if (a.state === 'dropped' || a.state === 'witnessed' || a.settled !== undefined) return { ...a }
449 if (triage.dismissed.some((l) => quotes(l, a))) return { ...a, state: 'dropped', settled: 'dismissed' }
450 if (triage.todo.some((l) => quotes(l, a))) return { ...a, settled: 'to-do' }
451 return { ...a }
452 })
453}
454
455export function carryOver(asks: Ask[]): Ask[] {
456 return newestEntries(asks.filter(carries), MAX_CARRIED).map(({ a }) => ({ ...a }))
457}
458
459/** The same rule again at load: a store written before it may hold anything. */
460export function seedCarried(carried: Ask[], opts: { at: number; ids: string[] }): Ask[] {
461 return carried
462 .filter(carries)
463 .flatMap((a, i) => {
464 const id = opts.ids[i]
465 return id === undefined
466 ? []
467 : [{ id, text: cleanText(a.text), source: 'carried' as const, state: 'open' as const, claimId: null, at: opts.at }]
468 })
469}
470
471/** `extra`: further sections (Notes taken, candidates) placed before the closing marker. */
472export function printSection(asks: Ask[], ledger: Ledger, extra: readonly string[] = []): string {
473 const c = counts(asks)
474 const open = c.total - c.done - c.pale
475 const live = asks.filter((a) => a.state !== 'dropped')
476 const listed = (group: Ask[], tag: string) => {
477 const of = (s: Ask['state']) => group.filter((a) => a.state === s)
478 return [
479 ...of('witnessed').map((a) => `- [x] ${cleanText(a.text)}${tag}`),
480 ...of('claimed').map((a) => `- [ ] ${cleanText(a.text)}${tag} (pale: claimed, never witnessed)`),
481 ...of('open').map((a) => `- [ ] ${cleanText(a.text)}${tag}`),
482 ]
483 }
484 const items = listed(live.filter(isSessionAsk), '')
485 const carriedItems = listed(live.filter((a) => a.source === 'carried'), ' (carried)')
486 const builds = live.filter((a) => a.source === 'build').map((a) => `- ${cleanText(a.text)} (build)`)
487 // A subagent's report is labelled as one (1.1): the main session has still to read it back.
488 const paleClaims = ledger.claims
489 .filter((cl) => cl.state === 'pale')
490 .map((cl) => `- pale ${cl.agentId === undefined ? 'claim' : 'agent claim'}: ${cleanText(cl.text)}`)
491 const lines = [
492 '## Witness print',
493 '',
494 `Asks: ${c.done} of ${c.total} witnessed, ${c.pale} pale, ${open} open.`,
495 ]
496 if (items.length > 0) lines.push('', ...items)
497 if (carriedItems.length > 0) {
498 lines.push('', `Carried in from the last watch: ${c.carried} undone, not carried again.`, '', ...carriedItems)
499 }
500 if (builds.length > 0) {
501 lines.push('', `Builds (multi-session; each becomes a to-do at /close): ${builds.length}.`, '', ...builds)
502 }
503 if (paleClaims.length > 0) lines.push('', ...paleClaims)
504 if (extra.length > 0) lines.push('', ...extra.map((l) => l.replace(/\r?\n/g, ' ')))
505 lines.push('', PRINT_END)
506 return lines.join('\n')
507}
508hooks/bells.ts 32 lines1import type { Activity } from '../types'
2
3export const BREAK_MS = 5 * 60_000
4/** Half a bell: thirty minutes of active time (1.1, Bradley's sketch of 2026-10-09). */
5export const HALF_BELL_MS = 30 * 60_000
6/** A whole bell: an hour of active time. */
7export const BELL_MS = 2 * HALF_BELL_MS
8/** Three bells on the chime, an hour each, filling from the bottom. */
9export const BELLS = 3
10export const MAX_HALVES = 2 * BELLS
11
12/** Count an interaction at `at`: a gap of five minutes or less is active time. */
13export function touch(activity: Activity, at: number): Activity {
14 const gap = activity.lastAt === null ? 0 : at - activity.lastAt
15 const counted = activity.lastAt !== null && gap >= 0 && gap <= BREAK_MS ? gap : 0
16 return { activeMs: activity.activeMs + counted, lastAt: at }
17}
18
19/** Half-bells of active time, one per thirty minutes, capped at the chime's six. */
20export function bellHalves(activity: Activity): number {
21 return Math.min(MAX_HALVES, Math.floor(activity.activeMs / HALF_BELL_MS))
22}
23
24/** Whole bells rung: one per hour of active time, capped at three. */
25export function bellsRung(activity: Activity): number {
26 return Math.floor(bellHalves(activity) / 2)
27}
28
29export function isIdle(activity: Activity, now: number): boolean {
30 return activity.lastAt !== null && now - activity.lastAt > BREAK_MS
31}
32hooks/config.ts 151 lines1// The core/estate split (1.0, ruled 2026-10-09): one source that runs in the iniphi
2// estate unchanged and in a Claude Code with no estate at all. Every estate-specific
3// input is a userConfig field whose default keeps the estate working with no
4// configuration, and each adapter is off when its input is absent. Pure: register.tsx
5// does the file checks; what can be decided from plain data lives here.
6
7export const TICKET_SCRIPT = 'scripts/estate-status.py'
8export const WATCH_SCRIPT = 'scripts/witness-watch.py'
9/** The estate's SELECT-led write functions; harmless where that schema does not exist. */
10export const DEFAULT_WRITERS = [
11 'workspace.close_todo',
12 'workspace.set_todo_status',
13 'workspace.pause_todo',
14 'workspace.crosstalk_*',
15 'workspace.record_board_attention',
16 'workspace.claim_item',
17 'workspace.release_claim',
18] as const
19export const DEFAULT_SESSIONS_DIR = '.claude/sessions'
20export const DEFAULT_GOALS_HEADING = '## Goals'
21export const DEFAULT_CLOSE_COMMANDS = ['/close'] as const
22export const DEFAULT_PERSON = 'you'
23/** The estate's run-a-board skill; named in /tackle only where the estate is present. */
24export const ESTATE_TACKLE_SKILL = '/agile'
25/** How far above the mod's folder a default script is looked for (mods/<mod> -> repo root). */
26export const SCRIPT_LEVELS = 3
27
28export type Config = {
29 ticketScript: string
30 watchScript: string
31 writers: readonly string[]
32 person: string
33 sessionsDir: string
34 goalsHeading: string
35 closeCommands: readonly string[]
36}
37
38/** The adapters found at session start: null is off. */
39export type Adapters = { ticket: string | null; watch: string | null }
40
41export const NO_ADAPTERS: Adapters = { ticket: null, watch: null }
42
43const text = (v: unknown): string => (typeof v === 'string' ? v.trim() : '')
44
45/** A comma- or newline-separated list, blanks dropped. */
46export function listOf(v: unknown): string[] {
47 return text(v)
48 .split(/[,\n]/)
49 .map(x => x.trim())
50 .filter(x => x.length > 0)
51}
52
53export function readConfig(options: unknown): Config {
54 const o = (options ?? {}) as Record<string, unknown>
55 const writers = listOf(o.writerCalls)
56 const close = listOf(o.closeCommands)
57 return {
58 ticketScript: text(o.ticketScript),
59 watchScript: text(o.watchScript),
60 writers: writers.length > 0 ? writers : [...DEFAULT_WRITERS],
61 person: text(o.person) || DEFAULT_PERSON,
62 sessionsDir: text(o.sessionsDir) || DEFAULT_SESSIONS_DIR,
63 goalsHeading: text(o.goalsHeading) || DEFAULT_GOALS_HEADING,
64 closeCommands: close.length > 0 ? close : [...DEFAULT_CLOSE_COMMANDS],
65 }
66}
67
68export function isAbsolute(path: string): boolean {
69 return /^([A-Za-z]:)?[\\/]/.test(path)
70}
71
72/** Forward slashes, `.` and `..` segments resolved, no trailing slash. */
73export function normalizePath(path: string): string {
74 const raw = path.replace(/\\/g, '/')
75 const lead = raw.startsWith('/') ? '/' : ''
76 const parts = raw.split('/').reduce<string[]>((acc, seg) => {
77 if (seg === '' || seg === '.') return acc
78 if (seg !== '..') return [...acc, seg]
79 const last = acc[acc.length - 1]
80 // Never above a root: `/..` and `C:/..` stay at the root; a relative path keeps its `..`.
81 if (last === undefined) return lead === '/' ? acc : [...acc, seg]
82 if (/^[A-Za-z]:$/.test(last)) return acc
83 return last === '..' ? [...acc, seg] : acc.slice(0, -1)
84 }, [])
85 return `${lead}${parts.join('/')}`
86}
87
88/** `rel` under `base`, unless `rel` is absolute. */
89export function under(base: string, rel: string): string {
90 return normalizePath(isAbsolute(rel) ? rel : `${base}/${rel}`)
91}
92
93/**
94 * The mod's folder from its hooks module's own `import.meta.url`, a second word on where
95 * the mod is for when the engine's `$.plugin.root` is blank (the estate line vanished for a
96 * morning on 2026-10-09 when it was): `file:///C:/x/mods/witness/hooks/register.tsx` gives
97 * `C:/x/mods/witness`, and so does the POSIX form. '' for anything that is not a file URL
98 * of a module under a `hooks/` folder.
99 */
100export function rootFromModuleUrl(url: unknown): string {
101 if (typeof url !== 'string' || !url.startsWith('file://')) return ''
102 const path = decodeURIComponent(url.slice('file://'.length))
103 const local = /^\/[A-Za-z]:\//.test(path) ? path.slice(1) : path
104 const m = /^(.+)\/hooks\/[^/]+$/.exec(local)
105 return m === null ? '' : normalizePath(m[1] ?? '')
106}
107
108/**
109 * Where to look for a script, in order. A configured path is the only candidate
110 * (relative to the session's folder); unset, `rel` under the mod's own folder and up
111 * to SCRIPT_LEVELS folders above it, so the mod's own repository supplies it (in the
112 * estate, <repo>/mods/witness -> <repo>/scripts). Never the session's
113 * folder by default: a repo opened is not trusted to run a script on every start.
114 */
115export function scriptCandidates(configured: string, pluginRoot: string, cwd: string, rel: string): string[] {
116 if (configured !== '') return [under(cwd, configured)]
117 if (pluginRoot === '') return []
118 return Array.from({ length: SCRIPT_LEVELS }, (_, i) => under(pluginRoot, `${'../'.repeat(i)}${rel}`))
119}
120
121const escape = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
122
123/** `schema.name` or `name`; a trailing `*` matches any further name characters. */
124function writerSource(name: string): string {
125 const star = name.endsWith('*')
126 const parts = (star ? name.slice(0, -1) : name).split('.').map(p => escape(p.trim()))
127 return `${parts.join('\\s*\\.\\s*')}${star ? '[a-z_0-9]*' : ''}`
128}
129
130/** The pattern sql.ts matches writer calls with; null when the list is empty. */
131export function writerPattern(names: readonly string[]): RegExp | null {
132 const valid = names.filter(n => /^[A-Za-z_][\w.]*\*?$/.test(n))
133 if (valid.length === 0) return null
134 return new RegExp(`\\b(${valid.map(writerSource).join('|')})\\s*\\(`, 'gi')
135}
136
137export function isCloseCommand(prompt: string, commands: readonly string[]): boolean {
138 const t = prompt.trim()
139 return commands.some(c => t === c || t.startsWith(`${c} `) || t.startsWith(`${c}\n`))
140}
141
142/** The person as the model reads it: "the user" by default, else the configured name. */
143export function personRef(person: string): string {
144 return person === DEFAULT_PERSON ? 'the user' : person
145}
146
147/** Whose words called an ask off, as the print and the confirm tool say it. */
148export function onWords(person: string): string {
149 return person === DEFAULT_PERSON ? 'on your words' : `on ${person}'s words`
150}
151hooks/barnacles.ts 183 lines1// Barnacles: friction that recurs across sessions. Pure functions over plain data.
2import type { Barnacle, Friction } from '../types'
3import { parseJsonObject } from './asks'
4
5export const FRICTION_CAP = 200
6export const MAX_BARNACLES = 6
7
8export type RecentCall = { tool: string; input: unknown; isError: boolean; at: number }
9export type FrictionInput = {
10 tool: string
11 input: unknown
12 resultText: string
13 isError: boolean
14 recent: RecentCall[]
15 /** Epoch ms of this call; defaults to the newest recent call's time, else 0. */
16 at?: number
17}
18
19export function noteFriction(log: Friction[], f: Friction): Friction[] {
20 return [...log, f].slice(-FRICTION_CAP)
21}
22
23/**
24 * Friction worth keeping (Bradley, 2026-10-09): a cancelled or refused write, a failing call,
25 * an MCP misuse. Editing a file under build is the work itself, never friction, so the
26 * third-edit kind is retired and its legacy entries are dropped on load.
27 */
28export function realFriction(log: Friction[]): Friction[] {
29 return log.filter((f) => f.kind !== 're-edit')
30}
31
32/** The Supabase dialog's own answer when a write is declined, and nothing else (no row data). */
33const CANCEL_STATUS = /^\s*\{\s*"status"\s*:\s*"cancelled"\s*\}\s*$/
34
35function cancelled(c: FrictionInput, at: number): Friction[] {
36 // A cancel is a refusal (an error naming it, as the witness hold's is) or the dialog's bare
37 // status; a successful result whose rows say "cancelled" is data (a field report).
38 const sqlCancelled =
39 /execute_sql/.test(c.tool) && (c.isError ? /cancel/i.test(c.resultText) : CANCEL_STATUS.test(c.resultText))
40 const denied = c.isError && /\b(denied|permission to use|blocked by|not permitted)\b/i.test(c.resultText)
41 if (!sqlCancelled && !denied) return []
42 const detail = sqlCancelled ? 'write cancelled at the prompt' : 'call denied'
43 return [{ kind: 'cancelled-write', key: c.tool, at, detail }]
44}
45
46/** The first line of an error, cut short, for a friction's detail. */
47const gist = (text: string): string => text.replace(/\s+/g, ' ').trim().slice(0, 80)
48
49/**
50 * A failing call. An MCP tool's error counts at once (a wrong column, a bad id, a misused
51 * server: a misremembering, Bradley 2026-10-09); any other tool's counts on its second
52 * failure in a row, since a shell command that fails once is often exploration.
53 */
54function callFailure(c: FrictionInput, at: number): Friction[] {
55 if (!c.isError) return []
56 if (c.tool.startsWith('mcp__')) return [{ kind: 'tool-failure', key: c.tool, at, detail: `${c.tool} failed: ${gist(c.resultText)}` }]
57 const last = [...c.recent].reverse().find((r) => r.tool === c.tool)
58 if (last === undefined || !last.isError) return []
59 return [{ kind: 'tool-failure', key: c.tool, at, detail: `${c.tool} failed twice in a row` }]
60}
61
62export function frictionFrom(c: FrictionInput): Friction[] {
63 const newest = c.recent.length > 0 ? c.recent[c.recent.length - 1]?.at ?? 0 : 0
64 const at = c.at ?? newest
65 // A cancelled or refused write is a refusal, never also a failing call.
66 const refused = cancelled(c, at)
67 return refused.length > 0 ? refused : callFailure(c, at)
68}
69
70const day = (at: number): string => new Date(at).toISOString().slice(0, 10)
71
72export function recurPrompt(log: Friction[], barnacles: Barnacle[]): string {
73 const groups = new Map<string, { kind: string; days: Set<string>; count: number; detail: string }>()
74 for (const f of log) {
75 const g = groups.get(f.key) ?? { kind: f.kind, days: new Set<string>(), count: 0, detail: f.detail }
76 g.days.add(day(f.at))
77 g.count++
78 groups.set(f.key, g)
79 }
80 const lines = [...groups.entries()].map(
81 ([key, g]) => `- key=${key} kind=${g.kind} count=${g.count} days=${[...g.days].sort().join(',')} detail=${g.detail}`,
82 )
83 const known = barnacles.map((b) => `- ${b.key}: ${b.text}`)
84 return [
85 'You read a log of friction an AI coding assistant hit (cancelled or refused writes, failing calls, MCP tool errors).',
86 'Keep only friction that recurs: the same key on 2 or more distinct days. Drop the rest, and drop any known barnacle whose key no longer recurs.',
87 'For each kept key write one plain sentence saying what keeps going wrong. Reuse the key exactly.',
88 `At most ${MAX_BARNACLES} entries. Output shape: {"barnacles": [{"key": "...", "text": "..."}]}`,
89 'Use {"barnacles": []} when nothing recurs. Reply with the JSON only.',
90 '',
91 'Known barnacles:',
92 known.length === 0 ? '(none)' : known.join('\n'),
93 '',
94 'Friction by key:',
95 lines.length === 0 ? '(none)' : lines.join('\n'),
96 ].join('\n')
97}
98
99export function parseRecur(reply: string): Barnacle[] {
100 const raw = parseJsonObject(reply)?.barnacles
101 if (!Array.isArray(raw)) return []
102 const out: Barnacle[] = []
103 for (const item of raw) {
104 if (item === null || typeof item !== 'object') continue
105 const { key, text } = item as Record<string, unknown>
106 if (typeof key !== 'string' || typeof text !== 'string') continue
107 if (key.trim() === '' || text.trim() === '' || out.some((b) => b.key === key.trim())) continue
108 out.push({ key: key.trim(), text: text.trim(), spoken: false })
109 if (out.length === MAX_BARNACLES) break
110 }
111 return out
112}
113
114/** Parsed barnacles keep the spoken flag of a known one with the same key. */
115export function mergeBarnacles(existing: Barnacle[], parsed: Barnacle[]): Barnacle[] {
116 return parsed.map((p) => {
117 const known = existing.find((e) => e.key === p.key)
118 return { ...p, spoken: known?.spoken ?? false }
119 })
120}
121
122/** A scrub on record: when the key came off the shell, and what its barnacle said, so it can regrow at once. */
123export type ScrubRecord = { at: number; text: string }
124/** The scrubs on record, by barnacle key. */
125export type Scrubbed = Readonly<Record<string, ScrubRecord>>
126
127/** A stored scrub record; a bare number (the first cut's shape) is a scrub with no words kept. */
128export function asScrubbed(v: unknown): Scrubbed {
129 if (v === null || typeof v !== 'object' || Array.isArray(v)) return {}
130 const out: Record<string, ScrubRecord> = {}
131 for (const [k, rec] of Object.entries(v as Record<string, unknown>)) {
132 if (typeof rec === 'number') out[k] = { at: rec, text: '' }
133 else if (rec !== null && typeof rec === 'object' && typeof (rec as ScrubRecord).at === 'number') {
134 const text = (rec as ScrubRecord).text
135 out[k] = { at: (rec as ScrubRecord).at, text: typeof text === 'string' ? text : '' }
136 }
137 }
138 return out
139}
140
141/**
142 * The friction the recurrence pass may read after a scrub (/barnacles, 1.1): a scrubbed key
143 * forgets everything logged at or before its scrub. (New friction on that key regrows the
144 * barnacle at once through `regrown`, which also lifts the scrub.)
145 */
146export function afterScrub(log: Friction[], scrubbed: Scrubbed): Friction[] {
147 return log.filter((f) => f.at > (scrubbed[f.key]?.at ?? Number.NEGATIVE_INFINITY))
148}
149
150/** The shell without the barnacle `key` (every barnacle when `key` is null); a new list. */
151export function scrub(barnacles: Barnacle[], key: string | null): Barnacle[] {
152 return key === null ? [] : barnacles.filter((b) => b.key !== key).map((b) => ({ ...b }))
153}
154
155/** The scrub record with each of `gone` stamped `at`, its words kept for the regrowth; a new object. */
156export function stampScrub(scrubbed: Scrubbed, gone: readonly Barnacle[], at: number): Scrubbed {
157 return gone.reduce<Record<string, ScrubRecord>>((acc, b) => ({ ...acc, [b.key]: { at, text: b.text } }), { ...scrubbed })
158}
159
160/**
161 * The barnacle that regrows the moment friction `f` lands on a scrubbed key after its scrub
162 * (Bradley, 2026-10-09: "if the friction occurs again it should be an instant barnacle"):
163 * the old words, unspoken so it speaks again. Null when nothing regrows.
164 */
165export function regrown(scrubbed: Scrubbed, f: Friction): Barnacle | null {
166 const rec = scrubbed[f.key]
167 if (rec === undefined || f.at <= rec.at) return null
168 return { key: f.key, text: rec.text !== '' ? rec.text : `${f.detail}, again after a scrub.`, spoken: false }
169}
170
171/** The scrub record without `key`: a regrown barnacle's whole history is in view again. */
172export function unscrub(scrubbed: Scrubbed, key: string): Scrubbed {
173 return Object.fromEntries(Object.entries(scrubbed).filter(([k]) => k !== key))
174}
175
176export function nextToSpeak(barnacles: Barnacle[]): Barnacle | null {
177 return barnacles.find((b) => !b.spoken) ?? null
178}
179
180export function markSpoken(barnacles: Barnacle[], key: string): Barnacle[] {
181 return barnacles.map((b) => (b.key === key ? { ...b, spoken: true } : { ...b }))
182}
183hooks/sprites.ts 253 lines1// The companion's frames and the costumes it can wear. Pure: rows in, rows out.
2//
3// 1.1 (2026-10-09, Bradley's rulings in ../FAMILY.md section 2): the four watch bells hang
4// as a chime at the creature's back, the side away from its face, one per row from the
5// second row down (his live look: "one rung down"), with one blank column from the shell.
6// The band mirrors the art, so the back is the LEFT edge here and the right edge on screen.
7// The compact sprite is 14 columns for the chime; the full sprite keeps 28 by shifting the
8// art two columns right. Phi's shell top is low lines with an undertie wave (.__‿_., the
9// wave keeping the three animation positions) and its rim overlines with the tie ⁀ under
10// the wave, its mirror image, moving with it. Phial carries the same chime; its base is
11// plain. The props are masks over Phi's compact frame, ported from the ammonite
12// lab: razor \== and compass (<+) on the tentacle row, horn tips 6 and 6 on the shell's
13// corners. Each mask is stored facing right like the art, so the compass is (+>) here and
14// the horns 9 9: the band's mirror turns them. Preview outside Claude Code with
15// python scripts/sprite-preview.py [--mirror --band 80]
16// which reads one frame per line, so every frame below stays on its own line.
17
18import type { Body, Costume, Mood, Prop, Size } from '../types'
19
20type Frames = string[][]
21type Cell = [number, number]
22type PerFrame = Cell[][]
23type Table<T> = Record<Body, Record<Size, T>>
24
25/**
26 * Three animation frames per body and size: full 9 rows x 28, compact 5 rows x 14. The
27 * props follow as one-frame compact masks (spaces except their glyphs), which is how the
28 * preview tool tells a prop from a body.
29 */
30export const FRAMES: Table<Frames> & Record<Prop, { compact: Frames }> = {
31 phi: {
32 full: [
33 [' .________. ', ' .\' .__‿_. \'. ', '○ / .\' .-. \'. \\ ', '○ | / ( @ ) \\ |/\'o\\ ', '○ | \\ \'-\' / |\\__/~~~ ', ' \\ \'-.__.-\' /)))~~~~ ', ' \'. .\'))~~~ ~ ', ' ‾‾‾‾⁀‾‾‾ ~ ~~ ~ ', ' ~ ~ '],
34 [' .________. ', ' .\' .__‿_. \'. ', '○ / .\' .-. \'. \\ ', '○ | / ( @ ) \\ |/\'-\\ ', '○ | \\ \'-\' / |\\__/-~~ ', ' \\ \'-.__.-\' /)))-~~~ ', ' \'. .\'))~~~ ~ ', ' ‾‾‾‾⁀‾‾‾ ~ ~~ ~ ', ' ~ ~ '],
35 [' .________. ', ' .\' ._‿__. \'. ', '○ / .\' .-. \'. \\ ', '○ | / ( @ ) \\ |/\'o\\ ', '○ | \\ \'-\' / |\\__/≈≈~ ', ' \\ \'-.__.-\' /)))≈~~~ ', ' \'. .\'))~~~ ~ ', ' ‾‾‾⁀‾‾‾‾ ~ ~~ ~ ', ' ~ ~ ~ '],
36 ],
37 compact: [
38 [' .__‿_. ', '○ / .-. \\ ', '○ | ( @ ) o>~ ', '○ \\ `-\' /~ ', ' ‾‾⁀‾‾ '],
39 [' .__‿_. ', '○ / .-. \\ ', '○ | ( @ ) ->- ', '○ \\ `-\' /- ', ' ‾‾⁀‾‾ '],
40 [' ._‿__. ', '○ / .-. \\ ', '○ | ( @ ) o>≈ ', '○ \\ `-\' /≈ ', ' ‾⁀‾‾‾ '],
41 ],
42 },
43 phial: {
44 full: [
45 [' |~~~~| o ', ' | | o ', '○ | | . ', '○ / \\ ', '○ / o o \\ ', ' | ‿‿ | ', ' |~~~~~~~~~~~~| ', ' \\ / ', ' `--------\' '],
46 [' |~~~~| o ', ' | | . ', '○ | o | ', '○ / \\ ', '○ / - - \\ ', ' | ‿‿ | ', ' |~~~~~~~~~~~~| ', ' \\ / ', ' `--------\' '],
47 [' |~~~~| ', ' | o | o ', '○ | | o ', '○ / \\ ', '○ / o o \\ ', ' | ‿‿ | ', ' |~-~~~~~~~-~~| ', ' \\ / ', ' `--------\' '],
48 ],
49 compact: [
50 [' |~~| ', '○ | | ', '○ / o o \\ ', '○ ( ~‿‿~ ) ', ' `------\' '],
51 [' |~~| ', '○ | o| ', '○ / - - \\ ', '○ ( ~‿‿~ ) ', ' `------\' '],
52 [' |o~| ', '○ | | ', '○ / o o \\ ', '○ ( ~‿‿~ ) ', ' `------\' '],
53 ],
54 },
55 razor: {
56 compact: [
57 // Bradley, 2026-10-09: '\==' on screen, on the tentacle row ahead of the tentacle's tip.
58 [' ', ' ', ' ', ' ==/', ' '],
59 ],
60 },
61 compass: {
62 compact: [
63 // '(<+)' on screen, its closing bracket taking the tentacle's tip (GRIP) so Phi holds it.
64 [' ', ' ', ' ', ' (+>)', ' '],
65 ],
66 },
67 horns: {
68 compact: [
69 // The tips read 6 and 6 on screen; the band's mirror turns a 9 into a 6.
70 [' 9 9 ', ' ', ' ', ' ', ' '],
71 ],
72 },
73}
74
75/** Eye cells per frame ([row, col]); Phi has one, Phial two (left then right). */
76const EYES: Table<PerFrame> = {
77 phi: {
78 full: [[[3, 21]], [[3, 21]], [[3, 21]]],
79 compact: [[[2, 10]], [[2, 10]], [[2, 10]]],
80 },
81 phial: {
82 full: [[[4, 7], [4, 10]], [[4, 7], [4, 10]], [[4, 7], [4, 10]]],
83 compact: [[[2, 5], [2, 8]], [[2, 5], [2, 8]], [[2, 5], [2, 8]]],
84 },
85}
86
87/**
88 * The chime: three bell cells per frame, top to bottom, down the creature's back, the
89 * bottom bell on the tentacle row above the rim (Bradley, 2026-10-09: an hour a bell,
90 * filling from the bottom, and the bells start on the tentacle row, not the rim).
91 */
92const BELLS: Table<PerFrame> = {
93 phi: {
94 full: [[[2, 0], [3, 0], [4, 0]], [[2, 0], [3, 0], [4, 0]], [[2, 0], [3, 0], [4, 0]]],
95 compact: [[[1, 0], [2, 0], [3, 0]], [[1, 0], [2, 0], [3, 0]], [[1, 0], [2, 0], [3, 0]]],
96 },
97 phial: {
98 full: [[[2, 0], [3, 0], [4, 0]], [[2, 0], [3, 0], [4, 0]], [[2, 0], [3, 0], [4, 0]]],
99 compact: [[[1, 0], [2, 0], [3, 0]], [[1, 0], [2, 0], [3, 0]], [[1, 0], [2, 0], [3, 0]]],
100 },
101}
102
103/** Interior cells that are spaces in all three frames, in fill order. */
104const BARNACLES: Table<Cell[]> = {
105 phi: {
106 full: [[2, 8], [2, 12], [4, 6], [4, 14]],
107 compact: [[1, 4], [1, 8], [3, 4], [3, 8]],
108 },
109 phial: {
110 full: [[1, 7], [1, 10], [2, 7], [2, 10]],
111 compact: [[3, 3], [3, 4], [3, 9], [3, 10]],
112 },
113}
114
115/**
116 * The tentacle tip a held prop may take: the compass's bracket sits here so Phi grips it.
117 * The only tentacle cell any prop replaces.
118 */
119export const GRIP: Cell = [3, 10]
120
121/** The shell's two top corners, which the horns replace. */
122export const CORNERS: Cell[] = [[0, 3], [0, 8]]
123
124const FILLED = '●'
125const EMPTY = '○'
126/** Half a bell: thirty minutes. Mirrored to the right-half glyph by the band. */
127const HALF = '◐'
128
129const EYE_GLYPH: Record<Mood, string> = {
130 calm: 'o',
131 resting: '-',
132 alert: 'O',
133 shielding: '>',
134 doubtful: '.',
135 proud: '^',
136}
137
138export function frame(body: Body, size: Size, index: number): string[] {
139 const frames = FRAMES[body][size]
140 const i = ((Math.trunc(index) % frames.length) + frames.length) % frames.length
141 return frames[i] ?? []
142}
143
144function diffCount(a: string[], b: string[]): number {
145 let n = 0
146 a.forEach((row, r) => {
147 for (let c = 0; c < row.length; c++) if (row[c] !== (b[r] ?? '')[c]) n++
148 })
149 return n
150}
151
152/** Which frame these rows are: the nearest by cell difference, so edited rows still resolve. */
153function frameIndexOf(rows: string[], body: Body, size: Size): number {
154 const diffs = FRAMES[body][size].map((f) => diffCount(f, rows))
155 return diffs.indexOf(Math.min(...diffs))
156}
157
158function put(rows: string[], cells: Cell[], glyphs: string[]): string[] {
159 const out = rows.slice()
160 cells.forEach(([r, c], k) => {
161 const row = out[r] ?? ''
162 out[r] = row.slice(0, c) + glyphs[k] + row.slice(c + 1)
163 })
164 return out
165}
166
167const copyCells = (cells: Cell[]): Cell[] => cells.map(([r, c]) => [r, c] as Cell)
168
169function eyeGlyphs(body: Body, mood: Mood, n: number): string[] {
170 if (mood === 'shielding' && body === 'phial') return ['>', '<']
171 return Array.from({ length: n }, () => EYE_GLYPH[mood])
172}
173
174/** Where the eyes are in these rows, as drawn (facing right): [row, column] per eye. */
175export function eyeCellsOf(rows: string[], body: Body, size: Size): Cell[] {
176 return copyCells(EYES[body][size][frameIndexOf(rows, body, size)] ?? [])
177}
178
179/** The chime's three bell cells in these rows, top to bottom, as drawn (facing right). */
180export function bellCellsOf(rows: string[], body: Body, size: Size): Cell[] {
181 return copyCells(BELLS[body][size][frameIndexOf(rows, body, size)] ?? [])
182}
183
184/** The cells the first `count` barnacles occupy, as drawn (facing right). */
185export function barnacleCellsOf(body: Body, size: Size, count: number): Cell[] {
186 const all = BARNACLES[body][size]
187 const n = Math.max(0, Math.min(all.length, Math.trunc(count)))
188 return copyCells(all.slice(0, n))
189}
190
191export function withMood(rows: string[], body: Body, size: Size, mood: Mood): string[] {
192 const cells = EYES[body][size][frameIndexOf(rows, body, size)] ?? []
193 return put(rows, cells, eyeGlyphs(body, mood, cells.length))
194}
195
196/**
197 * The chime filled to `halves` half-bells from the bottom (1.1): the lowest bell takes the
198 * first two halves, then the one above it. A half is ◐, a whole ●, an empty bell ○.
199 */
200export function withBells(rows: string[], body: Body, size: Size, halves: number): string[] {
201 const cells = BELLS[body][size][frameIndexOf(rows, body, size)] ?? []
202 const n = Math.max(0, Math.min(2 * cells.length, Math.trunc(halves)))
203 const glyphs = cells.map((_, k) => {
204 const fromBottom = cells.length - 1 - k
205 const fill = Math.max(0, Math.min(2, n - 2 * fromBottom))
206 return fill === 2 ? FILLED : fill === 1 ? HALF : EMPTY
207 })
208 return put(rows, cells, glyphs)
209}
210
211export function withBarnacles(rows: string[], body: Body, size: Size, count: number): string[] {
212 const cells = barnacleCellsOf(body, size, count)
213 return put(rows, cells, cells.map(() => '*'))
214}
215
216export const PROPS: readonly Prop[] = ['razor', 'compass', 'horns']
217
218/** The word after /wear: a prop, `none` (or `off`) to undress, null for anything else or nothing. */
219export function parseCostume(args: unknown): Costume | null {
220 const word = (typeof args === 'string' ? args : '').trim().toLowerCase()
221 if (word === 'none' || word === 'off') return 'none'
222 return PROPS.find((p) => p === word) ?? null
223}
224
225/** A prop's cells: every non-space cell of its mask, with the glyph it draws. */
226export function propCells(prop: Prop): Array<[number, number, string]> {
227 const mask = FRAMES[prop].compact[0] ?? []
228 const out: Array<[number, number, string]> = []
229 mask.forEach((row, r) => {
230 Array.from(row).forEach((ch, c) => {
231 if (ch !== ' ') out.push([r, c, ch])
232 })
233 })
234 return out
235}
236
237/**
238 * Phi's compact rows with the prop drawn over them; a new list, the input untouched. A
239 * prop is a costume on Phi only (one companion, one identity, the role shown by what it
240 * holds): on Phial or at full size the rows come back unchanged.
241 */
242export function withProp(rows: string[], body: Body, size: Size, prop: Prop): string[] {
243 if (body !== 'phi' || size !== 'compact') return rows.slice()
244 const out = rows.slice()
245 for (const [r, c, glyph] of propCells(prop)) {
246 const chars = Array.from(out[r] ?? '')
247 if (c >= chars.length) continue
248 chars[c] = glyph
249 out[r] = chars.join('')
250 }
251 return out
252}
253hooks/evidence.ts 316 lines1// The confirm channel (Bradley, S162): Claude tells the companion an ask is done,
2// and the companion believes it only on evidence it checks itself. A citation is a
3// tool call the companion observed in this session - a piece of its input, and
4// words found in its OUTPUT but not in its input, so a command cannot manufacture
5// its own proof (an `echo "verified"` cites itself and fails) - or words Bradley
6// typed himself, for what only he can witness, such as a layout. Pure.
7
8import type { Ask, AskWitness, Heard, Observed } from '../types'
9import { cleanText, isSessionAsk, restates } from './asks'
10import { DEFAULT_PERSON, onWords, personRef } from './config'
11
12export const CONFIRM_TOOL = 'mcp__witness__confirm'
13/**
14 * Calls kept as evidence. 200 ran out in one long turn (S163): the read-back an early
15 * ask rested on had aged out before the work was confirmed. Each entry is bounded
16 * (INPUT_CAP + RESULT_HEAD + RESULT_TAIL), so 600 is at most a few MB.
17 */
18export const OBSERVED_CAP = 600
19export const HEARD_CAP = 50
20/** The head and tail of a result are kept: a test run's verdict is at its end. */
21export const RESULT_HEAD = 1_500
22export const RESULT_TAIL = 2_500
23export const INPUT_CAP = 2_000
24export const MIN_EXCERPT = 6
25export const MIN_COMMAND = 3
26
27export type Evidence =
28 | { source: 'call'; command: string; excerpt: string }
29 | { source: 'user'; excerpt: string }
30
31/** `outcome: 'dropped'` (0.6.2, a field report): the user called the ask off; absent means done. */
32export type ConfirmItem = { ask: string; evidence: Evidence[]; outcome?: 'dropped' }
33
34export type Verdict = {
35 ask: string
36 askId: string | null
37 ok: boolean
38 reason: string
39 refs: string[]
40 dropped?: boolean
41}
42
43const INPUT_KEYS = ['command', 'query', 'file_path', 'path', 'pattern', 'id', 'page_id', 'url', 'name']
44
45/** A tool call's input as text: its command, query or path, else the whole input as JSON. */
46export function inputText(input: Readonly<Record<string, unknown>>): string {
47 const picked = INPUT_KEYS.map(k => input[k]).filter((v): v is string => typeof v === 'string')
48 const text = picked.length > 0 ? picked.join(' ') : JSON.stringify(input)
49 return text.slice(0, INPUT_CAP)
50}
51
52export function trimResult(text: string): string {
53 if (text.length <= RESULT_HEAD + RESULT_TAIL) return text
54 return `${text.slice(0, RESULT_HEAD)}\n...\n${text.slice(-RESULT_TAIL)}`
55}
56
57/** Tools whose result is a subagent's report, or its launch notice (1.1). */
58export const REPORT_TOOLS: ReadonlySet<string> = new Set(['Agent', 'Task', 'TaskOutput', 'Workflow'])
59
60/**
61 * A call whose output may never be cited (1.1, ruled 2026-10-09): a subagent's own call,
62 * or an Agent, Task, TaskOutput or Workflow result (a Workflow's result relays its agents'
63 * reports). A report is a claim the main session reads
64 * back, never the read-back itself.
65 */
66export function isReport(o: Pick<Observed, 'tool' | 'agent'>): boolean {
67 return o.agent || REPORT_TOOLS.has(o.tool)
68}
69
70/**
71 * Appends one observed call, newest kept. Never the confirm tool's own, and never a
72 * subagent's (1.1): nothing a subagent ran is evidence, so it would only push the main
73 * loop's read-backs out of the window.
74 */
75export function observe(log: readonly Observed[], o: Observed): Observed[] {
76 if (o.tool === CONFIRM_TOOL || o.agent) return [...log]
77 const kept: Observed = { ...o, input: o.input.slice(0, INPUT_CAP), result: trimResult(o.result) }
78 return [...log, kept].slice(-OBSERVED_CAP)
79}
80
81export function hear(log: readonly Heard[], h: Heard): Heard[] {
82 return [...log, h].slice(-HEARD_CAP)
83}
84
85const norm = (s: string): string => cleanText(s).toLowerCase()
86
87/**
88 * Text as the model read it: an MCP result arrives JSON-encoded inside the tool-result
89 * envelope, so its quotes are stored as \" or \\\" (two or three escapes deep; one level
90 * was not enough, seen live in S163) and a quoted excerpt would never match. Any run of
91 * backslashes before a quote or an n is undone. Applied to input and output alike, so the
92 * input-must-not-contain-the-excerpt rule still binds.
93 */
94const unescaped = (s: string): string => s.replace(/\\+"/g, '"').replace(/\\+n/g, ' ')
95
96/** Does `text` contain `excerpt`, as stored or with its JSON-escaped quotes undone? */
97const holds = (text: string, excerpt: string): boolean =>
98 norm(text).includes(excerpt) || norm(unescaped(text)).includes(excerpt)
99
100/** The ref of the observed call or heard prompt that bears this evidence out, made after `since`; else null. */
101export function bearsOut(e: Evidence, observed: readonly Observed[], heard: readonly Heard[], since: number): string | null {
102 const excerpt = norm(e.excerpt)
103 if (excerpt.length < MIN_EXCERPT) return null
104 if (e.source === 'user') {
105 const h = heard.find(x => x.at >= since && norm(x.text).includes(excerpt))
106 return h === undefined ? null : `user@${h.at}`
107 }
108 const command = norm(e.command)
109 if (command.length < MIN_COMMAND) return null
110 const o = observed.find(
111 x =>
112 x.at >= since &&
113 !x.isError &&
114 x.tool !== CONFIRM_TOOL &&
115 !isReport(x) &&
116 holds(x.input, command) &&
117 holds(x.result, excerpt) &&
118 !holds(x.input, excerpt),
119 )
120 return o === undefined ? null : o.toolUseId
121}
122
123/** An ask by id, else by its text (exact, then a unique containment either way), ignoring dropped asks. */
124export function findAsk(asks: readonly Ask[], key: string): Ask | null {
125 const live = asks.filter(a => a.state !== 'dropped')
126 const byId = live.find(a => a.id === key)
127 if (byId) return byId
128 const k = norm(key)
129 if (k.length === 0) return null
130 const exact = live.find(a => norm(a.text) === k)
131 if (exact) return exact
132 const near = live.filter(a => norm(a.text).includes(k) || k.includes(norm(a.text)))
133 if (near.length > 0) return near.length === 1 ? (near[0] ?? null) : null
134 // A close paraphrase, when one live ask clearly wins (a field report: five of five
135 // free-text confirms missed and needed a second call). A tie at the top is never guessed.
136 const scored = live.flatMap(a => {
137 const share = restates(a.text, key)
138 return share === null ? [] : [{ a, share }]
139 })
140 const top = Math.max(0, ...scored.map(x => x.share))
141 const best = scored.filter(x => x.share === top)
142 return best.length === 1 ? (best[0]?.a ?? null) : null
143}
144
145function judge(item: ConfirmItem, ask: Ask, observed: readonly Observed[], heard: readonly Heard[], person: string): Verdict {
146 const base = { ask: item.ask, askId: ask.id, refs: [] as string[] }
147 if (ask.state === 'witnessed') return { ...base, ok: true, reason: 'already witnessed' }
148 if (item.evidence.length === 0) return { ...base, ok: false, reason: 'no evidence cited' }
149 // A drop rests on the user alone: the model may not call off an ask on its own say.
150 if (item.outcome === 'dropped' && !item.evidence.some(e => e.source === 'user')) {
151 return { ...base, ok: false, reason: `a drop needs ${personRef(person)}'s own words calling the ask off (source user)` }
152 }
153 const refs = item.evidence.map(e => bearsOut(e, observed, heard, ask.at))
154 const missing = refs.findIndex(r => r === null)
155 if (missing !== -1) {
156 const e = item.evidence[missing]
157 const why =
158 e?.source === 'user'
159 ? `those words are not in any of ${personRef(person)}'s prompts since the ask was raised`
160 : 'no call since the ask was raised has that input with that excerpt in its output (and not in its input)'
161 return { ...base, ok: false, reason: `evidence ${missing + 1} did not check out: ${why}` }
162 }
163 const proved = refs.filter((r): r is string => r !== null)
164 if (item.outcome === 'dropped') return { ...base, ok: true, reason: `called off ${onWords(person)}`, refs: proved, dropped: true }
165 return { ...base, ok: true, reason: 'witnessed', refs: proved }
166}
167
168/** Checks every item; an ask whose every citation checks out becomes witnessed. */
169export function confirmAsks(
170 asks: readonly Ask[],
171 items: readonly ConfirmItem[],
172 observed: readonly Observed[],
173 heard: readonly Heard[],
174 now: number,
175 person: string = DEFAULT_PERSON,
176): { asks: Ask[]; verdicts: Verdict[] } {
177 const verdicts: Verdict[] = items.map(item => {
178 const ask = findAsk(asks, item.ask)
179 if (ask === null) return { ask: item.ask, askId: null, ok: false, reason: 'no single live ask matches that', refs: [] }
180 return judge(item, ask, observed, heard, person)
181 })
182 const passed = new Map(verdicts.filter(v => v.ok && v.askId !== null && v.refs.length > 0).map(v => [v.askId, v]))
183 const next = asks.map((a): Ask => {
184 const v = passed.get(a.id)
185 if (v === undefined) return { ...a }
186 if (v.dropped) return { ...a, state: 'dropped', calledOff: { refs: v.refs, at: now } }
187 const by: AskWitness['by'] = v.refs.some(r => r.startsWith('user@')) ? 'user' : 'evidence'
188 return { ...a, state: 'witnessed', witness: { by, refs: v.refs, at: now } }
189 })
190 return { asks: next, verdicts }
191}
192
193/** Parses the tool's input defensively; anything malformed is dropped, never thrown. */
194export function parseItems(input: unknown): ConfirmItem[] {
195 const raw = (input as { items?: unknown })?.items
196 if (!Array.isArray(raw)) return []
197 return raw.flatMap(item => {
198 const ask = (item as { ask?: unknown })?.ask
199 const ev = (item as { evidence?: unknown })?.evidence
200 if (typeof ask !== 'string' || !Array.isArray(ev)) return []
201 const evidence = ev.flatMap((e): Evidence[] => {
202 const x = e as { source?: unknown; command?: unknown; excerpt?: unknown }
203 if (typeof x?.excerpt !== 'string') return []
204 if (x.source === 'user') return [{ source: 'user', excerpt: x.excerpt }]
205 if (x.source === 'call' && typeof x.command === 'string') return [{ source: 'call', command: x.command, excerpt: x.excerpt }]
206 return []
207 })
208 const dropped = (item as { outcome?: unknown })?.outcome === 'dropped'
209 return [dropped ? { ask, outcome: 'dropped' as const, evidence } : { ask, evidence }]
210 })
211}
212
213/** The tool's answer to the model: one line per item, then the asks still undone with their ids. */
214export function confirmReport(verdicts: readonly Verdict[], asks: readonly Ask[], closeName = '/close'): string {
215 const head = (v: Verdict) => (!v.ok ? 'NOT WITNESSED' : v.dropped ? 'DROPPED' : 'WITNESSED')
216 const lines = verdicts.map(v => `${head(v)} - ${v.ask}: ${v.reason}`)
217 // A build is held for /close, never listed as this session's undone work (0.6.0), and a
218 // carried ask is the last watch's, listed apart so it is never read as this session's.
219 const undoneHere = (a: Ask) => a.settled === undefined && (a.state === 'open' || a.state === 'claimed')
220 const open = asks.filter(a => isSessionAsk(a) && undoneHere(a))
221 const carried = asks.filter(a => a.source === 'carried' && undoneHere(a))
222 const builds = asks.filter(a => a.source === 'build' && a.state !== 'dropped').length
223 const row = (a: Ask) => ` ${a.id}: ${a.text}`
224 const tail =
225 open.length > 0
226 ? ['Still undone:', ...open.map(row)]
227 : [carried.length > 0 ? 'Nothing undone this session.' : 'Nothing undone.']
228 const fromLast = carried.length === 0 ? [] : ['Carried in from the last watch (not counted in asks n/m):', ...carried.map(row)]
229 const held = builds === 0 ? [] : [`(${builds} build${builds === 1 ? '' : 's'} held for ${closeName}, to become to-dos)`]
230 return [...lines, ...tail, ...fromLast, ...held].join('\n')
231}
232
233/** The confirm tool's input schema, naming the person as the model reads it. */
234export function confirmSchema(person: string): Record<string, unknown> {
235 const who = personRef(person)
236 return {
237 type: 'object',
238 properties: {
239 items: {
240 type: 'array',
241 items: {
242 type: 'object',
243 properties: {
244 ask: { type: 'string', description: 'The ask id, or its text as /undone shows it.' },
245 outcome: {
246 type: 'string',
247 enum: ['done', 'dropped'],
248 description:
249 `done (the default): the work is done, cite read-backs. dropped: ${who} called the ask off; cite those words (source user), the only evidence a drop takes.`,
250 },
251 evidence: {
252 type: 'array',
253 items: {
254 type: 'object',
255 properties: {
256 source: { type: 'string', enum: ['call', 'user'] },
257 command: {
258 type: 'string',
259 description: 'source call: a piece of the input of a tool call made in this session (the Bash command, the SQL, the path).',
260 },
261 excerpt: {
262 type: 'string',
263 description: `call: words from that call's OUTPUT that are not in its input. user: words ${who} typed in one of their prompts.`,
264 },
265 },
266 required: ['source', 'excerpt'],
267 },
268 },
269 },
270 required: ['ask', 'evidence'],
271 },
272 },
273 },
274 required: ['items'],
275 }
276}
277
278export const CONFIRM_SCHEMA: Record<string, unknown> = confirmSchema(DEFAULT_PERSON)
279
280/** The confirm tool's description, naming the person as the model reads it. */
281export function confirmDescription(person: string): string {
282 const who = personRef(person)
283 return (
284 'Tell the witness companion that undone asks are done, with evidence it checks itself. ' +
285 'Confirm an ask in the same turn as its read-back, before calling it done: a later bulk confirm redoes the read-backs. ' +
286 'For each ask cite evidence from a read-back made AFTER the ask was raised: ' +
287 '{source:"call", command:<a piece of a tool call\'s input from this session>, excerpt:<words in that call\'s output, not in its input>}, ' +
288 `or, for what only ${who} can witness (a layout), {source:"user", excerpt:<words ${who} typed>}. ` +
289 `Only the first ${RESULT_HEAD} and last ${RESULT_TAIL} characters of an output are kept, ` +
290 'so cite a short read-back (a grep or narrow query of the passage), never the middle of a long one. ' +
291 'Every citation must check out or the ask stays undone; a claim is not evidence, ' +
292 "nor is a subagent's report or its own calls (an Agent, Task or TaskOutput result): read back what it changed. " +
293 `When ${who} called an ask off, pass outcome "dropped" with those words as {source:"user"} evidence: ` +
294 'the ask is then neither done nor undone, and never carried. Never drop an ask on your own judgement.'
295 )
296}
297
298export const CONFIRM_DESCRIPTION = confirmDescription(DEFAULT_PERSON)
299
300/** Most asks one note names; the rest wait for the next. */
301export const NOTE_MAX_ASKS = 5
302
303/**
304 * The one line put to Claude on Bradley's next prompt when a turn ended with asks it
305 * called done but never proved (Bradley, S165, option B): about 40 tokens, once per ask,
306 * against a /tackle sweep that redoes the read-backs later.
307 */
308export function unprovedNote(asks: ReadonlyArray<{ id: string; text: string }>): string {
309 // Each with its id, so one confirm call closes it (a field report: the model cannot run /undone).
310 const list = asks
311 .slice(0, NOTE_MAX_ASKS)
312 .map(a => `${a.id}: "${a.text}"`)
313 .join('; ')
314 return `witness: claimed done but not proved by a read-back: ${list}. Read each back and confirm it with ${CONFIRM_TOOL} in this turn, citing the id.`
315}
316hooks/notes.ts 257 lines1// Two sections of the Witness print, ruled by Bradley in S163 (2026-10-07):
2//
3// - "Notes taken": what the witness itself SAW this watch - writes it held and how they
4// were answered, friction (cancelled writes, repeated failures, re-edits), and what it
5// said in its bubble. Built only from ledgers it already keeps; nothing is extracted.
6// - "Hook and skill candidates": a side pass reads recurring friction and the session's
7// call digest and proposes "this could be a hook" (an event-bound guard or check) or
8// "this could be a skill" (a repeated multi-step procedure), each with its evidence.
9// Listed in the print and appended to one running file. Proposals only: the witness
10// never builds a hook or a skill. Pure.
11
12import type { Candidate, Friction, Observed, Spoken } from '../types'
13import { cleanText, normalise, parseJsonObject } from './asks'
14
15export const SPOKEN_CAP = 60
16export const NOTES_CAP = 25
17export const MAX_CANDIDATES = 5
18export const CANDIDATES_KEPT = 40
19/** The candidate pass runs at most this often, and only after this many new calls. */
20export const CANDIDATE_SCAN_MS = 30 * 60_000
21export const CANDIDATE_MIN_NEW_CALLS = 25
22export const DIGEST_CALLS = 150
23const DIGEST_CHARS = 100
24const TITLE_CHARS = 80
25const WHY_CHARS = 160
26const EVIDENCE_CHARS = 200
27
28/** Headers that are not notes: the print announcing itself. */
29const NOT_A_NOTE = new Set(['PRINT'])
30
31const one = (text: string, max: number): string => {
32 const t = cleanText(text)
33 return t.length <= max ? t : `${t.slice(0, max - 3)}...`
34}
35
36/** Records one thing the companion said; an immediate repeat collapses, the newest are kept. */
37export function noteSpoken(log: readonly Spoken[], said: Spoken): Spoken[] {
38 const last = log[log.length - 1]
39 if (last !== undefined && last.header === said.header && last.text === said.text) return [...log]
40 return [...log, { ...said }].slice(-SPOKEN_CAP)
41}
42
43/** The header a note from beneath is recorded under. */
44export const HEADS_UP = 'HEADS UP'
45
46/** Records a note heard from beneath once per watch: one already in the log, anywhere, is not added again. */
47export function noteHeard(log: readonly Spoken[], said: Spoken): Spoken[] {
48 const seen = log.some(x => x.header === said.header && x.text === said.text)
49 return seen ? [...log] : [...log, { ...said }].slice(-SPOKEN_CAP)
50}
51
52function frictionLine(f: Friction): string {
53 const key = one(f.key, 120)
54 const detail = one(f.detail, 160)
55 switch (f.kind) {
56 case 'cancelled-write':
57 return `- held or cancelled write: ${key} - ${detail}`
58 case 'let-through':
59 return `- let through once: ${key} - ${detail}`
60 case 'returning-added':
61 return `- held, RETURNING added: ${key} - ${detail}`
62 case 'tool-failure':
63 return `- repeated failure: ${key} - ${detail}`
64 case 're-edit':
65 return `- re-edited three times: ${key}`
66 }
67}
68
69/** The "Notes taken" lines for the print: this watch only, one line per note. */
70export function notesSection(o: { friction: readonly Friction[]; spoken: readonly Spoken[]; since: number }): string[] {
71 const events = [
72 ...o.friction.filter(f => f.at >= o.since).map(f => ({ at: f.at, line: frictionLine(f) })),
73 ...o.spoken
74 .filter(x => x.at >= o.since && !NOT_A_NOTE.has(x.header))
75 .map(x => ({ at: x.at, line: `- said (${one(x.header, 40)}): ${one(x.text, 200)}` })),
76 ].sort((a, b) => a.at - b.at)
77 const lines = [...new Set(events.map(e => e.line))]
78 const head = ['### Notes taken', '']
79 if (lines.length === 0) return [...head, '- Nothing noted.']
80 const kept = lines.slice(0, NOTES_CAP)
81 const more = lines.length - kept.length
82 return more > 0 ? [...head, ...kept, `- ... and ${more} more`] : [...head, ...kept]
83}
84
85/** This session's main-session calls, one short line each, newest last. */
86export function callDigest(observed: readonly Observed[], max = DIGEST_CALLS): string[] {
87 return observed
88 .filter(o => !o.agent)
89 .slice(-max)
90 .map(o => one(`${o.tool}: ${o.input}`, DIGEST_CHARS))
91}
92
93/** Installed slash commands the candidate pass is shown, at most, and each description's length. */
94export const MAX_INSTALLED = 80
95const INSTALLED_DESC = 90
96
97/**
98 * One installed guard or mod. `covers`: term sets, any one of which, all its terms found
99 * (lowercase substrings) in a hook candidate's title and why, means the guard already does it.
100 */
101export type Guard = {
102 name: string
103 where: string
104 does: string
105 covers: ReadonlyArray<readonly string[]>
106 disabled?: boolean
107}
108
109/**
110 * The installed guards and mods (to-do 3f23...b0be, second half), from the wired-hooks
111 * table in ~/.claude/rules/common/hooks.md and the estate's CLAUDE.md "Supabase write
112 * paths". Static: edit it when a guard or a mod is wired, unwired or retired.
113 */
114export const INSTALLED_GUARDS: readonly Guard[] = [
115 { name: 'shell-guard.py', where: 'PreToolUse Bash|PowerShell (managed)', does: 'blocks heredoc and here-string writes to a file, .env clobbers, and git add -A / git add . / git commit -a', covers: [['heredoc'], ['here-string'], ['.env'], ['git add -a'], ['git add .'], ['commit -a']] },
116 { name: 'agent-contract.py', where: 'PreToolUse, all tools (managed)', does: "binds a subagent to its contract in agent-contracts.json: paths, tools, call and wall-clock budgets", covers: [['agent contract'], ['subagent', 'path'], ['subagent', 'budget'], ['subagent', 'forbidden']] },
117 { name: 'agent-contract.py', where: 'SubagentStart, SubagentStop (managed)', does: 'starts the budget clock; reports a zero-tool-call fork and a missing exit artifact', covers: [['silent fork'], ['zero tool'], ['zero-tool'], ['exit artifact']] },
118 { name: 'hook-show-me-hint.py', where: 'UserPromptSubmit', does: 'hints the show-me staging viewer when a prompt asks to see a file', covers: [['show-me'], ['show me', 'file'], ['staging viewer']] },
119 { name: 'hook-crosstalk-nudge.py', where: 'UserPromptSubmit', does: 'nudges toward /crosstalk when a prompt reads like a cross-agent handoff', covers: [['crosstalk', 'nudge'], ['crosstalk', 'remind'], ['crosstalk', 'prompt'], ['handoff', 'prompt']] },
120 { name: 'supabase-sql-verdict.py', where: 'PreToolUse mcp__claude_ai_Supabase__.*', does: 'judges every Supabase MCP call; only one keyed UPDATE or DELETE on a known workspace table, with RETURNING, passes', covers: [['execute_sql', 'write'], ['execute_sql', 'confirm'], ['execute_sql', 'destructive'], ['execute_sql', 'update'], ['execute_sql', 'delete'], ['update', 'returning'], ['delete', 'returning'], ['bare update'], ['bare delete'], ['destructive sql'], ['destructive-sql']] },
121 { name: 'supabase-elicitation-accept.py', where: 'Elicitation', does: "auto-accepts the Supabase destructive-SQL dialog only when the session's recent Supabase verdicts all passed", covers: [['elicitation'], ['destructive', 'dialog'], ['auto-accept'], ['auto accept']] },
122 { name: 'trap-annotator', where: 'mod, tool.call', does: 'notes a tool result that reads like proof but is not (Notion update no-ops, the reconcile request id, multi-statement execute_sql, first-write stamps)', covers: [['no-op'], ['silent success'], ['request id'], ['multi-statement']] },
123 { name: 'subagent-model-guard', where: 'mod, agent.spawn', does: 'gives a spawn with no model Sonnet; toasts an Opus or Fable spawn or fork', covers: [['subagent', 'model'], ['agent', 'sonnet'], ['opus'], ['fable']] },
124 { name: 'witness', where: 'mod', does: 'holds a bare Supabase UPDATE or DELETE with no RETURNING; keeps "done" claims pale until a read-back; tracks asks and the confirm tool', covers: [['read-back'], ['read back'], ['claim', 'verif'], ['done', 'verif'], ['returning']] },
125 { name: 'estate-status', where: 'mod, disabled (folded into witness 0.3.0)', does: 'drew the estate ticket line; witness draws it now', covers: [['estate ticket'], ['ticket line']], disabled: true },
126]
127
128/** The guard or mod a hook candidate restates, or null; skills are never filtered here. */
129export function coveringGuard(
130 c: { kind: string; title: string; why: string },
131 guards: readonly Guard[] = INSTALLED_GUARDS,
132): Guard | null {
133 if (c.kind !== 'hook') return null
134 const text = `${c.title} ${c.why}`.toLowerCase()
135 return guards.find(g => g.covers.some(terms => terms.every(t => text.includes(t)))) ?? null
136}
137
138const guardLine = (g: Guard): string => `- ${g.name} [${g.where}]: ${g.does}`
139
140/** The side pass's prompt: candidates only on evidence, strict JSON back. */
141export function candidatesPrompt(o: {
142 friction: readonly Friction[]
143 calls: readonly string[]
144 existing: readonly Candidate[]
145 /** Installed commands and skills (a field report): never proposed again as skills. */
146 installed?: ReadonlyArray<{ name: string; description: string }>
147 /** The guards to name (1.0): the estate's list where the estate is present, else none. */
148 guards?: readonly Guard[]
149}): string {
150 const guards = (o.guards ?? INSTALLED_GUARDS).map(guardLine)
151 const friction = o.friction.slice(-60).map(f => `- ${f.kind} ${one(f.key, 80)}: ${one(f.detail, 100)}`)
152 const existing = o.existing.map(c => `- [${c.kind}] ${c.title}`)
153 const installed = (o.installed ?? []).slice(0, MAX_INSTALLED).map(c => `- /${c.name}: ${one(c.description, INSTALLED_DESC)}`)
154 return [
155 'You read what one Claude Code session did: its recorded friction and a digest of its tool calls.',
156 'Propose things that could be automated, ONLY where the log itself is the evidence:',
157 '- HOOK: something that should happen, or be prevented, every time an event occurs (a guard, a check, a reminder). Evidence: friction that recurs, or a mistake corrected more than once.',
158 '- SKILL: a multi-step manual sequence (three or more steps) done two or more times, that a named procedure could run. Evidence: the repeated calls.',
159 'Never propose a skill that an installed command already covers, even if the session did the steps by hand.',
160 'Never propose a hook or guard that an installed guard or mod already covers, even if the friction shows it firing: that is the guard working.',
161 'Quote the log in "evidence". Skip anything already proposed. If nothing qualifies, return {"candidates": []}.',
162 `At most ${MAX_CANDIDATES}. Reply with strict JSON only:`,
163 '{"candidates": [{"kind": "hook" | "skill", "title": "<= 80 chars", "why": "<= 160 chars", "evidence": "<= 200 chars"}]}',
164 '',
165 'Already proposed:',
166 ...(existing.length > 0 ? existing : ['- (none)']),
167 '',
168 'Installed commands:',
169 ...(installed.length > 0 ? installed : ['- (none listed)']),
170 '',
171 'Installed guards and mods:',
172 ...(guards.length > 0 ? guards : ['- (none listed)']),
173 '',
174 'Friction:',
175 ...(friction.length > 0 ? friction : ['- (none)']),
176 '',
177 'Tool calls, oldest first:',
178 ...(o.calls.length > 0 ? o.calls.map(c => `- ${c}`) : ['- (none)']),
179 ].join('\n')
180}
181
182const candidateKey = (kind: string, title: string): string => `${kind}:${normalise(title)}`
183
184/** The side pass's reply as candidates: tolerant of junk, validated, deduped, capped. */
185export function parseCandidates(
186 reply: string,
187 existing: readonly Candidate[],
188 at: number,
189 guards: readonly Guard[] = INSTALLED_GUARDS,
190): Candidate[] {
191 const raw = parseJsonObject(reply)?.candidates
192 if (!Array.isArray(raw)) return []
193 const seen = new Set(existing.map(c => c.key))
194 const out: Candidate[] = []
195 for (const item of raw) {
196 const x = item as { kind?: unknown; title?: unknown; why?: unknown; evidence?: unknown }
197 if (x?.kind !== 'hook' && x?.kind !== 'skill') continue
198 const title = one(typeof x.title === 'string' ? x.title : '', TITLE_CHARS)
199 if (title === '') continue
200 const key = candidateKey(x.kind, title)
201 if (seen.has(key)) continue
202 seen.add(key)
203 const why = one(typeof x.why === 'string' ? x.why : '', WHY_CHARS)
204 if (coveringGuard({ kind: x.kind, title, why }, guards) !== null) continue
205 const evidence = one(typeof x.evidence === 'string' ? x.evidence : '', EVIDENCE_CHARS)
206 out.push({ key, kind: x.kind, title, why, evidence, at, filed: false })
207 if (out.length >= MAX_CANDIDATES) break
208 }
209 return out
210}
211
212export function mergeCandidates(existing: readonly Candidate[], fresh: readonly Candidate[]): Candidate[] {
213 return [...existing, ...fresh].slice(-CANDIDATES_KEPT)
214}
215
216export function markFiled(candidates: readonly Candidate[], keys: readonly string[]): Candidate[] {
217 const filed = new Set(keys)
218 return candidates.map(c => (filed.has(c.key) ? { ...c, filed: true } : { ...c }))
219}
220
221/** Is a candidate pass due? Both the interval and enough new calls since the last one. */
222export function shouldScan(o: { now: number; lastScanAt: number; newCalls: number }): boolean {
223 return o.now - o.lastScanAt >= CANDIDATE_SCAN_MS && o.newCalls >= CANDIDATE_MIN_NEW_CALLS
224}
225
226const candidateLine = (c: Candidate): string =>
227 `- [${c.kind}] ${c.title}${c.why ? ` - ${c.why}` : ''}${c.evidence ? ` (evidence: ${c.evidence})` : ''}`
228
229/** The print's candidates section: this watch's proposals. */
230export function candidatesSection(candidates: readonly Candidate[], since: number): string[] {
231 const now = candidates.filter(c => c.at >= since)
232 const head = ['### Hook and skill candidates', '']
233 return now.length === 0 ? [...head, '- None proposed.'] : [...head, ...now.map(candidateLine)]
234}
235
236const FILE_HEAD = [
237 '# Witness candidates',
238 '',
239 'Hook and skill candidates the witness companion proposed from friction and repeated',
240 'sequences it saw. Proposals only: nothing here was built. Strike a line once it is ruled.',
241 '',
242].join('\n')
243
244/** The running file with this watch's unfiled candidates appended; unchanged when there are none. */
245export function appendCandidates(fileText: string, candidates: readonly Candidate[], o: { date: string; source: string }): string {
246 const fresh = candidates.filter(c => !c.filed)
247 if (fresh.length === 0) return fileText
248 const base = fileText.trim() === '' ? FILE_HEAD : fileText.replace(/\s*$/, '\n')
249 return `${base}\n## ${o.date} - ${one(o.source, 80)}\n\n${fresh.map(candidateLine).join('\n')}\n`
250}
251
252/** Where the running file lives: reports/ when the project has one, else beside the session logs. */
253export function candidatesPath(cwd: string, hasReports: boolean): string {
254 const root = cwd.replace(/\\/g, '/').replace(/\/+$/, '')
255 return hasReports ? `${root}/reports/witness-candidates.md` : `${root}/.claude/witness-candidates.md`
256}
257hooks/render.tsx 458 lines1// The trees: the band above the prompt, and the /undone pane. These take the
2// surface's element table (`$.ui.resolve(e)`), never `$` itself: the engine follows
3// `$` only within the hooks module's own file.
4
5import type { Barnacle, Claim, Ticket } from '../types'
6import type { PaneRow } from './asks'
7import { DISMISS_KEY, DISMISS_LABEL, countText, footerRow, footerText, wrapLines } from './notices'
8import { INKS } from './body'
9import { BAR_GLYPH } from './ticket'
10import { SAFELIGHT, SPRITE_WIDTH } from './view'
11import type { View } from './view'
12
13/** The estate-status ticket marks its trees with these keys (estate-status 0.2.4+). */
14export const TICKET_BAND_KEY = 'estate-band'
15
16type Beneath = { ticket: unknown; note: unknown; engine: unknown }
17
18function isEngine(el: any): boolean {
19 return el === null || el === undefined || el.type === 'engine'
20}
21
22/**
23 * Pulls apart what stood beneath the companion. When the ticket's band is beneath
24 * (witness above estate-status), its first child is the ticket line and its second
25 * whatever stood beneath the ticket. Anything else beneath that draws (Claude Code's
26 * own "Heads up" note) is a note the companion voices in its bubble. The engine's
27 * own element is kept, drawn last, so nothing of the engine's is ever dropped.
28 */
29export function splitBeneath(below: any): Beneath {
30 if (below?.props?.key === TICKET_BAND_KEY && Array.isArray(below.children)) {
31 const [ticket, rest] = below.children
32 return isEngine(rest) ? { ticket, note: null, engine: rest } : { ticket, note: rest, engine: null }
33 }
34 return isEngine(below) ? { ticket: null, note: null, engine: below } : { ticket: null, note: below, engine: null }
35}
36
37/** The words of a tree: a Text's runs joined as drawn, a Box's children apart; the engine's own element has none. */
38function treeWords(el: any, inText: boolean): string {
39 if (typeof el === 'string' || typeof el === 'number') return String(el)
40 if (Array.isArray(el)) return el.map(child => treeWords(child, inText)).join(inText ? '' : ' ')
41 if (el === null || typeof el !== 'object' || el.type === 'engine') return ''
42 if (el.type === 'Button') return typeof el.props?.label === 'string' ? ` ${el.props.label} ` : ''
43 const children = el.children ?? el.props?.children
44 return treeWords(children, inText || el.type === 'Text')
45}
46
47/**
48 * The text of the note from beneath (Claude Code's own "Heads up"), whitespace collapsed;
49 * '' when nothing but the ticket and the engine stands beneath. Render trees are plain
50 * data, strings as children (RenderElement), so the words are readable as drawn.
51 */
52export function beneathText(below: unknown): string {
53 return treeWords(splitBeneath(below).note, false).replace(/\s+/g, ' ').trim()
54}
55
56/** Border (2) and padding (2) around a bubble's contents. */
57const BUBBLE_FRAME = 4
58
59/**
60 * A note from beneath sized itself to the whole band (`width: bodyColumns`); set
61 * inside the narrower bubble it spilled off the band's left edge and was cut (seen
62 * live, S162). Every numeric width wider than `max` comes down to `max`; strings
63 * (a Text's words), percentages, the engine's own element and every other prop,
64 * a Button's press handle included, pass through untouched.
65 */
66export function fitWidth(el: any, max: number): any {
67 if (Array.isArray(el)) return el.map(child => fitWidth(child, max))
68 if (el === null || typeof el !== 'object' || el.type === 'engine') return el
69 const props = el.props ?? {}
70 const w = props.width
71 const next = typeof w === 'number' && w > max ? { ...props, width: max } : props
72 const out = next === props ? { ...el } : { ...el, props: next }
73 return el.children === undefined ? out : { ...out, children: fitWidth(el.children, max) }
74}
75
76/**
77 * The bubble's outer width: a note takes the room there is; the companion's own words, what
78 * they need, with the footer (`0: Dismiss <n/m>`) beside the text when there is one.
79 */
80export function bubbleWidth(room: number, voice: { header: string; text: string } | null, footer = ''): number {
81 if (voice === null) return Math.max(BUBBLE_FRAME + 1, room)
82 const text = footer === '' ? voice.text.length : voice.text.length + 1 + footer.length
83 const words = Math.max(voice.header.length, text) + BUBBLE_FRAME
84 return Math.max(BUBBLE_FRAME + 1, Math.min(room, words))
85}
86
87/** What the queue puts in the bubble's footer; `onDismiss` null when the notice showing is not the witness's to dismiss. */
88export type QueueFooter = { n: number; m: number; onDismiss: (() => unknown) | null }
89
90export const NO_FOOTER: QueueFooter = { n: 0, m: 0, onDismiss: null }
91
92function isDismissButton(el: any): boolean {
93 if (el?.type !== 'Button') return false
94 const p = el.props ?? {}
95 return p.role === 'dismiss' || p.hotkey === DISMISS_KEY || /dismiss/i.test(String(p.label ?? ''))
96}
97
98function isRowBox(el: any): boolean {
99 const d = el?.props?.flexDirection
100 return el?.type === 'Box' && (d === undefined || d === 'row')
101}
102
103/**
104 * The note from beneath with `count` set just after its own Dismiss control, on the same
105 * row, so it never grows taller. Placed only where that control sits in a row Box; anywhere
106 * else the note is returned untouched and `placed` is false.
107 */
108export function withCount(note: any, count: any): { tree: any; placed: boolean } {
109 if (Array.isArray(note)) {
110 let placed = false
111 const tree = note.map(child => {
112 if (placed) return child
113 const r = withCount(child, count)
114 placed = r.placed
115 return r.tree
116 })
117 return { tree, placed }
118 }
119 if (note === null || typeof note !== 'object' || note.type === 'engine') return { tree: note, placed: false }
120 const children = note.children
121 if (isRowBox(note) && Array.isArray(children) && children.some(isDismissButton)) {
122 return { tree: { ...note, children: [...children, count] }, placed: true }
123 }
124 if (children === undefined) return { tree: note, placed: false }
125 const r = withCount(children, count)
126 return r.placed ? { tree: { ...note, children: r.tree }, placed: true } : { tree: note, placed: false }
127}
128
129/** `0: Dismiss` (the key bound only while it is drawn) and `<n/m>`, either absent when it does not apply. */
130function footerTree(t: any, footer: QueueFooter, ink: string | undefined): any {
131 const { Box, Button, Text } = t
132 const count = countText(footer.n, footer.m)
133 const onDismiss = footer.onDismiss
134 return (
135 <Box flexDirection="row" flexShrink={0} marginLeft={1}>
136 {onDismiss !== null && (
137 <Button key="witness-dismiss" label={DISMISS_LABEL} hotkey={DISMISS_KEY} plain onPress={() => onDismiss()} />
138 )}
139 {count !== '' && <Text dimColor={ink === undefined} color={ink}>{onDismiss !== null ? ` ${count}` : count}</Text>}
140 </Box>
141 )
142}
143
144/** The companion's own words, the footer on the last text row or, where it does not fit, the header row. */
145function voiceTree(t: any, voice: { header: string; text: string }, inner: number, footer: QueueFooter, ink: string | undefined): any {
146 const { Box, Text } = t
147 const foot = footerText(footer.onDismiss !== null, footer.n, footer.m)
148 if (foot === '') {
149 return (
150 <Box flexDirection="column">
151 <Text bold color={ink} wrap="truncate-end">
152 {voice.header}
153 </Text>
154 <Text color={ink}>{voice.text}</Text>
155 </Box>
156 )
157 }
158 // Pre-wrapped, each row cut rather than rewrapped, so the footer adds no row.
159 const lines = wrapLines(voice.text, inner)
160 const place = footerRow(lines, inner, foot)
161 const row = (key: string, text: string, bold: boolean, withFooter: boolean) => (
162 <Box key={key} flexDirection="row">
163 <Box flexGrow={1} flexShrink={1}>
164 <Text bold={bold} color={ink} wrap="truncate-end">
165 {text}
166 </Text>
167 </Box>
168 {withFooter && footerTree(t, footer, ink)}
169 </Box>
170 )
171 return (
172 <Box flexDirection="column">
173 {row('h', voice.header, true, place === 'header')}
174 {lines.map((line, i) => row(`l${i}`, line, false, place === 'last' && i === lines.length - 1))}
175 </Box>
176 )
177}
178
179
180/** The speech-bubble tail column: `>` on the creature's eye row, blank elsewhere. */
181function tail(t: any, rows: number, eyeRow: number, show: boolean, ink: string | undefined): any {
182 const { Box, Text } = t
183 return (
184 <Box flexDirection="column" flexShrink={0} width={1}>
185 {Array.from({ length: rows }, (_, i) => (
186 <Text key={`t${i}`} color={ink}>
187 {show && i === eyeRow ? '>' : ' '}
188 </Text>
189 ))}
190 </Box>
191 )
192}
193
194/**
195 * Bradley's one line (S162): the estate ticket cut to inbox and Code to-dos, the
196 * companion's counts appended. No push verdict and no log name: a metered repo shows
197 * as the creature's eyes in warning colour instead (S165, the line had no room). Before
198 * the ticket is first read, the counts alone; one that could not be read shows as
199 * `estate: ?`, never zeros.
200 */
201function ticketLine(t: any, ticket: Ticket | null, status: string, ink: string | undefined): any {
202 const { Text } = t
203 if (ticket === null) {
204 return (
205 <Text color={ink} dimColor={ink === undefined} wrap="truncate-end">
206 {status}
207 </Text>
208 )
209 }
210 if (ticket.kind === 'unavailable') {
211 return (
212 <Text wrap="truncate-end">
213 <Text dimColor>estate: ?</Text>
214 <Text color={ink}>{` · ${status}`}</Text>
215 </Text>
216 )
217 }
218 // ONE text run with inline spans, cut at the edge rather than wrapped: sibling Text
219 // elements lay out as flex columns, each wrapping on its own (seen 2026-10-06).
220 return (
221 <Text wrap="truncate-end">
222 {ticket.colour !== null ? <Text color={ticket.colour}>{`${BAR_GLYPH} `}</Text> : null}
223 <Text color={ticket.colour ?? undefined} bold>
224 {ticket.tag}
225 </Text>
226 {': inbox '}
227 <Text color={ticket.warnInbox ? 'warning' : undefined}>{ticket.inbox}</Text>
228 {` · Code to-dos ${ticket.todos}`}
229 <Text color={ink}>{` · ${status}`}</Text>
230 </Text>
231 )
232}
233
234/** A cell on a sprite row that takes its own ink. */
235export type InkSpan = { col: number; color: string }
236
237/**
238 * The cells on sprite row `row` that take their own ink, left to right: the worn costume in
239 * red, the chime in the bells' ink, the barnacles in theirs (1.1), and the eyes in the
240 * warning colour when the repo is metered, so a push would be held (S165). An eye drawn
241 * over by a costume still counts as an eye: the cell table, not the glyph, says where it is.
242 */
243export function rowInks(view: View, row: number, meteredEyes: boolean): InkSpan[] {
244 const at = (cells: Array<[number, number]>, color: string): InkSpan[] =>
245 cells.filter(([r]) => r === row).map(([, c]) => ({ col: c, color }))
246 return [
247 ...at(view.costume, INKS.costume),
248 ...at(view.bells, INKS.bells),
249 ...at(view.barnacles, INKS.barnacles),
250 ...(meteredEyes ? at(view.eyes, 'warning') : []),
251 ].sort((a, b) => a.col - b.col)
252}
253
254/**
255 * A sprite row with its inked cells as spans of their own colour; the rest of the row
256 * keeps the creature's own colour. A column named twice is inked once, by the first span.
257 */
258function inkSpans(t: any, row: string, inks: InkSpan[]): any {
259 if (inks.length === 0) return row
260 const { Text } = t
261 const chars = Array.from(row)
262 const out: any[] = []
263 let from = 0
264 for (const { col, color } of inks) {
265 if (col < from || col >= chars.length) continue
266 if (col > from) out.push(chars.slice(from, col).join(''))
267 out.push(
268 <Text key={`i${col}`} color={color}>
269 {chars[col] ?? ''}
270 </Text>,
271 )
272 from = col + 1
273 }
274 if (from < chars.length) out.push(chars.slice(from).join(''))
275 return out
276}
277
278/**
279 * The band: the bubble above, the one line beneath it on the left and the creature at
280 * the right edge facing it, bottom-aligned so it sits low over the prompt. The bubble
281 * voices, in order: a held write, a note from beneath, the companion's speech.
282 */
283export function bandTree(
284 t: any,
285 bodyColumns: number,
286 view: View,
287 below: unknown,
288 eyeRow: number,
289 ticket: Ticket | null,
290 footer: QueueFooter = NO_FOOTER,
291): any {
292 const { Box, Text } = t
293 const ink = view.safelight ? SAFELIGHT : undefined
294 const parts = splitBeneath(below)
295 const own = view.bubble
296 const voice = view.safelight || parts.note === null ? own : null
297 const speaking = voice !== null || parts.note !== null
298 // The room left of the creature and its tail column.
299 const room = Math.max(BUBBLE_FRAME + 1, bodyColumns - SPRITE_WIDTH[view.size] - 1)
300 const width = bubbleWidth(room, voice, footerText(footer.onDismiss !== null, footer.n, footer.m))
301 // The note from beneath keeps its own controls; the queue's count goes just after them.
302 const count = countText(footer.n, footer.m)
303 const note = fitWidth(parts.note, width - BUBBLE_FRAME)
304 const counted = count === '' ? note : withCount(note, <Text dimColor>{` ${count}`}</Text>).tree
305 // No width on the root: it holds the engine's own node (`parts.engine`), and an
306 // engine node under a Box with a width is refused, blanking the whole band
307 // (seen live seven times in S162 whenever no note stood beneath). The root takes
308 // the band's width by itself; only the rows inside it are sized.
309 // An estate-status ticket still standing beneath (that mod enabled beside 0.3.0) is
310 // dropped: the witness draws the ticket itself, so it is never drawn twice.
311 const line = ticketLine(t, ticket, view.status, ink)
312 // A held write's safelight colours the whole creature and wins over this.
313 const meteredEyes = ink === undefined && ticket?.kind === 'ok' && ticket.warnPush
314 return (
315 <Box flexDirection="column">
316 <Box flexDirection="row" alignItems="flex-end">
317 <Box flexDirection="column" width={room} flexShrink={0}>
318 {speaking && (
319 <Box
320 alignSelf="flex-end"
321 borderStyle="round"
322 borderColor={ink}
323 flexDirection="column"
324 paddingX={1}
325 width={width}
326 overflow="hidden"
327 >
328 {voice !== null ? voiceTree(t, voice, width - BUBBLE_FRAME, footer, ink) : counted}
329 </Box>
330 )}
331 {line}
332 </Box>
333 {tail(t, view.sprite.length, eyeRow, speaking, ink)}
334 <Box flexDirection="column" flexShrink={0} width={SPRITE_WIDTH[view.size]}>
335 {view.sprite.map((row, i) => (
336 <Text key={`s${i}`} color={ink ?? view.colour} wrap="truncate-end">
337 {ink === undefined ? inkSpans(t, row, rowInks(view, i, meteredEyes)) : row}
338 </Text>
339 ))}
340 </Box>
341 </Box>
342 {parts.engine}
343 </Box>
344 )
345}
346
347/** Every ask with its state; an undone one can be dropped (never ticked: done means witnessed). */
348export function paneTree(
349 t: any,
350 viewportRows: number,
351 rows: PaneRow[],
352 pale: Claim[],
353 onDrop: (id: string) => unknown,
354 onTackle: () => unknown,
355): any {
356 const { Box, Button, Text } = t
357 const room = Math.max(3, viewportRows - 6)
358 // A build is never tackled in a session: /close makes it a to-do (0.6.0).
359 // Nor an ask the close triage made a to-do (0.8.0): it is on the board.
360 const open = rows.filter(r => r.marker !== '[x]' && r.source !== 'build' && r.settled === undefined).length
361 const heading = (row: PaneRow, prev: PaneRow | undefined, source: PaneRow['source']) =>
362 row.source === source && prev?.source !== source
363 return (
364 <Box flexDirection="column">
365 <Box flexDirection="row">
366 <Box flexGrow={1} flexShrink={1}>
367 <Text dimColor wrap="truncate-end">
368 [x] witnessed [~] claimed, no read-back yet [ ] open
369 </Text>
370 </Box>
371 {open > 0 && (
372 <Box flexShrink={0} marginLeft={2}>
373 <Button key="tackle" label={`tackle ${open}`} variant="primary" onPress={() => onTackle()} />
374 </Box>
375 )}
376 </Box>
377 {rows.length === 0 && <Text dimColor>No asks recorded this watch.</Text>}
378 {rows.slice(0, room).map((row, i, shown) => [
379 heading(row, shown[i - 1], 'carried') && (
380 <Text key="carried-heading" dimColor>
381 Carried from the last watch (not counted in asks n/m):
382 </Text>
383 ),
384 heading(row, shown[i - 1], 'build') && (
385 <Text key="build-heading" dimColor>
386 Builds (multi-session; made to-dos at /close):
387 </Text>
388 ),
389 <Box key={`r${row.id}`} flexDirection="row">
390 <Box flexGrow={1} flexShrink={1}>
391 <Text wrap="truncate-end">
392 {row.source === 'build' ? '- ' : `${row.marker} `}
393 <Text dimColor>{`${row.source === 'goal' ? '(goal) ' : ''}${row.settled === 'to-do' ? '(to-do) ' : ''}`}</Text>
394 {row.text}
395 </Text>
396 </Box>
397 {row.marker !== '[x]' && (
398 <Box flexShrink={0} marginLeft={2}>
399 <Button key={`drop-${row.id}`} label="drop" plain onPress={() => onDrop(row.id)} />
400 </Box>
401 )}
402 </Box>,
403 ])}
404 {rows.length > room && <Text dimColor>{`${rows.length - room} more`}</Text>}
405 {pale.length > 0 && <Text dimColor>{`Pale claims (said done, no read-back): ${pale.length}`}</Text>}
406 {pale.slice(0, 5).map(c => (
407 <Text key={`c${c.id}`} dimColor wrap="truncate-end">
408 {` ~ ${c.text}`}
409 </Text>
410 ))}
411 </Box>
412 )
413}
414
415/**
416 * The Barnacles pane (1.1): what each barnacle records, a scrub beside each, and one for all.
417 * A scrubbed barnacle leaves the shell at once and grows back only from new friction.
418 */
419export function barnaclesTree(
420 t: any,
421 barnacles: Barnacle[],
422 onScrub: (key: string) => unknown,
423 onScrubAll: () => unknown,
424): any {
425 const { Box, Button, Text } = t
426 return (
427 <Box flexDirection="column">
428 <Box flexDirection="row">
429 <Box flexGrow={1} flexShrink={1}>
430 <Text dimColor wrap="truncate-end">
431 Friction that recurs across sessions: the same problem on two or more days. Scrub one off, or all.
432 </Text>
433 </Box>
434 {barnacles.length > 0 && (
435 <Box flexShrink={0} marginLeft={2}>
436 <Button key="scrub-all" label="scrub all" variant="primary" onPress={() => onScrubAll()} />
437 </Box>
438 )}
439 </Box>
440 {barnacles.length === 0 && <Text dimColor>No barnacles: nothing has recurred.</Text>}
441 {barnacles.map(b => (
442 <Box key={`b${b.key}`} flexDirection="row">
443 <Box flexGrow={1} flexShrink={1}>
444 <Text wrap="truncate-end">
445 {'* '}
446 {b.text}
447 <Text dimColor>{` (${b.key})`}</Text>
448 </Text>
449 </Box>
450 <Box flexShrink={0} marginLeft={2}>
451 <Button key={`scrub-${b.key}`} label="scrub" plain onPress={() => onScrub(b.key)} />
452 </Box>
453 </Box>
454 ))}
455 </Box>
456 )
457}
458hooks/sql.ts 336 lines1// SQL as text for witness: pure string work, no DOM, no Node, no imports.
2// Aware of single-quoted strings, double-quoted identifiers, dollar-quoted bodies
3// and comments. Analysis runs on a same-length "masked" copy of the SQL in which
4// comments, string contents and dollar bodies are blank and quoted identifiers
5// are lower-cased with their quotes blanked.
6
7export interface BareWrite {
8 readonly verb: 'UPDATE' | 'DELETE'
9 readonly table: string
10 readonly statement: string
11}
12
13interface Stmt {
14 readonly original: string
15 readonly masked: string
16}
17
18interface Found {
19 readonly verb: 'UPDATE' | 'DELETE'
20 readonly table: string
21 readonly from: number
22 readonly to: number
23 readonly top: boolean
24 readonly atStart: boolean
25}
26
27const NAME = '[\\w$]+(?:\\s*\\.\\s*[\\w$]+)*'
28const UPDATE_RE = new RegExp(`\\bupdate\\s+(?:only\\s+)?(${NAME})(?:\\s+(?:as\\s+)?[\\w$]+)?\\s+set\\b`, 'gi')
29const DELETE_RE = new RegExp(`\\bdelete\\s+from\\s+(?:only\\s+)?(${NAME})`, 'gi')
30const INSERT_RE = new RegExp(`\\binsert\\s+into\\s+(${NAME})`, 'gi')
31const MERGE_RE = new RegExp(`\\bmerge\\s+into\\s+(?:only\\s+)?(${NAME})`, 'gi')
32const TRUNCATE_RE = /\btruncate\s+(?:table\s+)?(?:only\s+)?/gi
33// The estate's list, as config.ts DEFAULT_WRITERS builds it; the default when none is passed.
34const WRITER_RE =
35 /\b(workspace\s*\.\s*(?:close_todo|set_todo_status|pause_todo|crosstalk_[a-z_]+|record_board_attention|claim_item|release_claim))\s*\(/gi
36const FUNCTION_FORMS = new Set(['extract', 'substring', 'trim', 'overlay', 'position'])
37const KEYWORDS = new Set([
38 'where', 'join', 'left', 'right', 'inner', 'outer', 'full', 'cross', 'natural', 'on', 'using',
39 'group', 'order', 'limit', 'union', 'returning', 'set', 'having', 'window', 'for', 'offset',
40 'fetch', 'except', 'intersect', 'lateral', 'tablesample', 'with', 'restart', 'cascade',
41 'restrict', 'continue', 'when', 'values', 'select',
42])
43
44function skipLine(sql: string, i: number): number {
45 let j = i
46 while (j < sql.length && sql[j] !== '\n') j++
47 return j
48}
49
50function blank(len: number): string {
51 return ' '.repeat(len)
52}
53
54/** An E'...' string: a backslash escapes the next character, `\'` included. */
55function isEscapeString(sql: string, i: number): boolean {
56 const prev = sql[i - 1] ?? ''
57 const before = sql[i - 2] ?? ' '
58 return (prev === 'E' || prev === 'e') && !/[A-Za-z0-9_$]/.test(before)
59}
60
61function maskQuoted(sql: string, i: number, q: string): { out: string; end: number } {
62 const escapes = q === "'" && isEscapeString(sql, i)
63 let j = i + 1
64 while (j < sql.length) {
65 if (escapes && sql[j] === '\\') {
66 j += 2
67 continue
68 }
69 if (sql[j] === q) {
70 if (sql[j + 1] === q) {
71 j += 2
72 continue
73 }
74 break
75 }
76 j++
77 }
78 const end = Math.min(j + 1, sql.length)
79 const inner = sql.slice(i + 1, Math.min(j, sql.length))
80 if (q === "'") return { out: `'${blank(inner.length)}${j < sql.length ? "'" : ''}`, end }
81 const ident = inner.toLowerCase().replace(/[\s"]/g, '_')
82 return { out: ` ${ident}${j < sql.length ? ' ' : ''}`, end }
83}
84
85export function maskSql(sql: string): string {
86 let out = ''
87 let i = 0
88 while (i < sql.length) {
89 const c = sql[i] ?? ''
90 const nx = sql[i + 1]
91 if (c === '-' && nx === '-') {
92 const e = skipLine(sql, i)
93 out += blank(e - i)
94 i = e
95 } else if (c === '/' && nx === '*') {
96 const k = sql.indexOf('*/', i + 2)
97 const e = k === -1 ? sql.length : k + 2
98 out += sql.slice(i, e).replace(/[^\n]/g, ' ')
99 i = e
100 } else if (c === "'" || c === '"') {
101 const r = maskQuoted(sql, i, c)
102 out += r.out
103 i = r.end
104 } else if (c === '$' && /^\$([A-Za-z_][A-Za-z0-9_]*)?\$/.test(sql.slice(i))) {
105 const tag = (/^\$([A-Za-z_][A-Za-z0-9_]*)?\$/.exec(sql.slice(i)) as RegExpExecArray)[0]
106 const k = sql.indexOf(tag, i + tag.length)
107 const e = k === -1 ? sql.length : k + tag.length
108 out += sql.slice(i, e).replace(/[^\n]/g, ' ')
109 i = e
110 } else {
111 out += c
112 i++
113 }
114 }
115 return out
116}
117
118function splitStmts(sql: string): Stmt[] {
119 const masked = maskSql(sql)
120 const result: Stmt[] = []
121 let start = 0
122 for (let i = 0; i <= masked.length; i++) {
123 if (i === masked.length || masked[i] === ';') {
124 const m = masked.slice(start, i)
125 if (m.trim() !== '') result.push({ original: sql.slice(start, i), masked: m })
126 start = i + 1
127 }
128 }
129 return result
130}
131
132export function statements(sql: string): string[] {
133 return splitStmts(sql).map((s) => s.original.trim())
134}
135
136function normName(raw: string): string {
137 return raw.replace(/\s+/g, '').toLowerCase()
138}
139
140function depthsOf(masked: string): number[] {
141 const d: number[] = []
142 let cur = 0
143 for (const c of masked) {
144 d.push(cur)
145 if (c === '(') cur++
146 else if (c === ')') cur--
147 }
148 d.push(cur)
149 return d
150}
151
152function prevContentChar(masked: string, at: number): string {
153 for (let i = at - 1; i >= 0; i--) {
154 const c = masked[i] ?? ''
155 if (!/\s/.test(c)) return c
156 }
157 return ''
158}
159
160function bodyEnd(masked: string, depths: readonly number[], from: number): number {
161 for (let i = from; i < masked.length; i++) {
162 if (masked[i] === ')' && depths[i] === depths[from]) return i
163 }
164 return masked.length
165}
166
167function findWrites(stmt: Stmt): Found[] {
168 const { masked } = stmt
169 const depths = depthsOf(masked)
170 const first = masked.search(/\S/)
171 const found: Found[] = []
172 const scan = (re: RegExp, verb: 'UPDATE' | 'DELETE') => {
173 for (const m of masked.matchAll(re)) {
174 const from = m.index ?? 0
175 const prev = prevContentChar(masked, from)
176 const top = prev === '' || (prev === ')' && depths[from] === 0)
177 if (!(top || prev === '(')) continue
178 if (verb === 'UPDATE' && normName(m[1] ?? '') === 'set') continue
179 const to = top ? masked.length : bodyEnd(masked, depths, from)
180 found.push({ verb, table: normName(m[1] ?? ''), from, to, top, atStart: from === first })
181 }
182 }
183 scan(UPDATE_RE, 'UPDATE')
184 scan(DELETE_RE, 'DELETE')
185 return found.sort((a, b) => a.from - b.from)
186}
187
188function hasReturning(masked: string, f: Found): boolean {
189 return /\breturning\b/i.test(masked.slice(f.from, f.to))
190}
191
192function bareIn(stmt: Stmt): BareWrite[] {
193 return findWrites(stmt)
194 .filter((f) => !hasReturning(stmt.masked, f))
195 .map((f) => ({
196 verb: f.verb,
197 table: f.table,
198 statement: stmt.original.slice(f.from, f.to).trim(),
199 }))
200}
201
202export function bareWrites(sql: string): BareWrite[] {
203 return splitStmts(sql).flatMap(bareIn)
204}
205
206export function needsHold(sql: string): boolean {
207 return bareWrites(sql).length > 0
208}
209
210/**
211 * The CTE form of a single bare top-level UPDATE or DELETE, or null whenever the
212 * rewrite cannot be proved to carry the whole statement: an unclosed string,
213 * identifier, comment or dollar body (the mask then no longer lines up with the
214 * text), or a body that is not the statement minus trailing blanks. A null offers
215 * only "let through" and "cancel": never guess at a cut that could drop a WHERE.
216 */
217export function withReturning(sql: string): string | null {
218 if (maskSql(sql).length !== sql.length) return null
219 const stmts = splitStmts(sql)
220 if (stmts.length !== 1) return null
221 const stmt = stmts[0] as Stmt
222 if (stmt.masked.length !== stmt.original.length) return null
223 const writes = findWrites(stmt)
224 const w = writes[0]
225 if (writes.length !== 1 || !w || !w.atStart || !w.top || hasReturning(stmt.masked, w)) return null
226 const end = stmt.masked.trimEnd().length
227 const body = stmt.original.slice(w.from, end)
228 const whole = stmt.original.slice(0, end).trim()
229 if (body.trim() !== whole || !/\bwhere\b/i.test(stmt.masked)) return null
230 return `WITH w AS (${body} RETURNING *) SELECT * FROM w`
231}
232
233function ctesIn(masked: string): Set<string> {
234 const names = new Set<string>()
235 for (const m of masked.matchAll(/([\w$]+)\s+as\s+(?:not\s+)?(?:materialized\s+)?\(/gi)) {
236 names.add((m[1] ?? '').toLowerCase())
237 }
238 return names
239}
240
241function parseList(masked: string, start: number, commas: boolean): string[] {
242 const names: string[] = []
243 let pos = start
244 const nameRe = new RegExp(`(?:only\\s+)?(?:lateral\\s+)?(${NAME})`, 'iy')
245 const aliasRe = /\s*(?:as\s+)?([\w$]+)/iy
246 for (;;) {
247 nameRe.lastIndex = pos
248 const m = nameRe.exec(masked)
249 if (!m) break
250 pos = nameRe.lastIndex
251 if (/^\s*\(/.test(masked.slice(pos))) break
252 names.push(normName(m[1] ?? ''))
253 aliasRe.lastIndex = pos
254 const a = aliasRe.exec(masked)
255 if (a && !KEYWORDS.has((a[1] ?? '').toLowerCase())) pos = aliasRe.lastIndex
256 const rest = /^\s*,\s*/.exec(masked.slice(pos))
257 if (!commas || !rest) break
258 pos += rest[0].length
259 }
260 return names
261}
262
263function inFunctionForm(masked: string, at: number): boolean {
264 let depth = 0
265 for (let i = at - 1; i >= 0; i--) {
266 const c = masked[i]
267 if (c === ')') depth++
268 else if (c === '(') {
269 if (depth === 0) {
270 const w = /([\w$]+)\s*$/.exec(masked.slice(0, i))
271 return !!w && FUNCTION_FORMS.has((w[1] ?? '').toLowerCase())
272 }
273 depth--
274 }
275 }
276 return false
277}
278
279function listsAfter(masked: string, re: RegExp, commas: boolean): string[] {
280 return [...masked.matchAll(re)].flatMap((m) =>
281 parseList(masked, (m.index ?? 0) + m[0].length, commas),
282 )
283}
284
285function readsIn(masked: string): string[] {
286 const ctes = ctesIn(masked)
287 const out: string[] = []
288 for (const m of masked.matchAll(/\b(from|join)\s+/gi)) {
289 const at = m.index ?? 0
290 const before = masked.slice(0, at)
291 if (/\bdelete\s+$/i.test(before) || /\bdistinct\s+$/i.test(before)) continue
292 if (inFunctionForm(masked, at)) continue
293 out.push(...parseList(masked, at + m[0].length, (m[1] ?? '').toLowerCase() === 'from'))
294 }
295 if (/\b(delete\s+from|merge\s+into)\b/i.test(masked)) {
296 out.push(...listsAfter(masked, /\busing\s+/gi, true))
297 }
298 return out.filter((n) => !ctes.has(n))
299}
300
301function writesIn(masked: string): string[] {
302 const out: string[] = []
303 for (const re of [UPDATE_RE, DELETE_RE, INSERT_RE, MERGE_RE]) {
304 for (const m of masked.matchAll(re)) {
305 const n = normName(m[1] ?? '')
306 if (n !== 'set') out.push(n)
307 }
308 }
309 out.push(...listsAfter(masked, TRUNCATE_RE, true))
310 return out
311}
312
313function unique(items: readonly string[]): string[] {
314 return [...new Set(items)]
315}
316
317export function tablesWritten(sql: string): string[] {
318 return unique(splitStmts(sql).flatMap((s) => writesIn(s.masked)))
319}
320
321export function tablesRead(sql: string): string[] {
322 return unique(splitStmts(sql).flatMap((s) => readsIn(s.masked)))
323}
324
325/** Writer calls in a SELECT; `writers` is the configured pattern (default: the estate's list). */
326export function writerCalls(sql: string, writers: RegExp | null = WRITER_RE): string[] {
327 if (writers === null) return []
328 const calls: string[] = []
329 for (const s of splitStmts(sql)) {
330 for (const m of s.masked.matchAll(writers)) {
331 if (/\bselect\b/i.test(s.masked.slice(0, m.index ?? 0))) calls.push(normName(m[1] ?? ''))
332 }
333 }
334 return unique(calls)
335}
336hooks/support.ts 328 lines1// Pure helpers for register.tsx. The engine follows `$` only into functions of the
2// hooks module's own file, so everything that calls `$` lives there; what can be
3// decided from plain data lives here, where tests reach it.
4
5import type { Ask, Ledger, LogBinding, LogTouchKind } from '../types'
6import { PRINT_END, cleanText, counts } from './asks'
7import {
8 DEFAULT_CLOSE_COMMANDS,
9 DEFAULT_PERSON,
10 DEFAULT_SESSIONS_DIR,
11 ESTATE_TACKLE_SKILL,
12 isCloseCommand as isConfiguredClose,
13 personRef,
14 under,
15} from './config'
16import { CONFIRM_TOOL } from './evidence'
17import { normPath, paleCount } from './witness'
18
19export const HELPER_TIMEOUT_MS = 45_000
20export const SIDE_PASS = { model: 'haiku', effort: 'low', timeoutMs: 20_000 } as const
21export const PRINT_HEADING = '## Witness print'
22
23let seq = 0
24
25/** Ids unique within the session: time, a counter, and a little noise. */
26export function newIds(now: number, n: number, prefix: string): string[] {
27 return Array.from({ length: n }, () => {
28 seq += 1
29 const noise = Math.random().toString(36).slice(2, 6)
30 return `${prefix}${now.toString(36)}${seq.toString(36)}${noise}`
31 })
32}
33
34/** The store key for a project: its folder, lower-case, forward slashes. */
35export function projectKey(cwd: string): string {
36 return cwd.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
37}
38
39export function storeKeys(key: string): {
40 carry: string
41 friction: string
42 barnacles: string
43 candidates: string
44 scrubbed: string
45} {
46 return {
47 carry: `carry:${key}`,
48 friction: `friction:${key}`,
49 barnacles: `barnacles:${key}`,
50 candidates: `candidates:${key}`,
51 // When each barnacle key was last scrubbed off the shell (/barnacles, 1.1): epoch ms by key.
52 scrubbed: `scrubbed:${key}`,
53 }
54}
55
56/** The session-log folder: config `sessionsDir` under the session's folder (default .claude/sessions). */
57export function sessionsDir(cwd: string, dir: string = DEFAULT_SESSIONS_DIR): string {
58 return under(cwd, dir)
59}
60
61/** Text that may reach the session log or a prompt: one line, whitespace collapsed. */
62export function askText(text: string): string {
63 return cleanText(text)
64}
65
66// ---- the session log this watch follows (1.1) ----
67//
68// Until 1.1 the log was found by listing the sessions folder: every log touched since the
69// session started, newest name first, the first one not closed. Two sessions in one repo
70// then converged on the newest open log (seen 2026-10-09: a side session took the main
71// session's Goals as its own asks and wrote its print into the main session's log). Now
72// the watch binds to a log only by the main loop's own file calls.
73
74/** One session-log touch: the log's path as the fs takes it, and what touched it. */
75export type LogTouch = { path: string; by: LogTouchKind }
76
77const LOG_TOOLS: ReadonlyMap<string, LogTouchKind> = new Map([
78 ['Write', 'write'],
79 ['Edit', 'edit'],
80 ['Read', 'read'],
81])
82const LOG_RANK: Readonly<Record<LogTouchKind, number>> = { read: 1, edit: 2, write: 3 }
83
84/**
85 * A file call's path as the fs takes it: forward slashes, `.` and `..` resolved, relative
86 * under the session's folder, and on Windows Git Bash's `/c/x` as `C:/x`.
87 */
88export function logPath(raw: string, cwd: string): string {
89 const slashed = raw.trim().replace(/\\/g, '/')
90 const windows = /^[A-Za-z]:/.test(cwd.trim())
91 const drive = windows ? slashed.replace(/^\/([A-Za-z])(?=\/|$)/, (_m: string, d: string) => `${d.toUpperCase()}:`) : slashed
92 return under(cwd, drive)
93}
94
95/** Two paths name one file: case, slashes and Git Bash's `/c/` aside (normPath). */
96export function sameFile(a: string, b: string): boolean {
97 return normPath(a) === normPath(b)
98}
99
100/**
101 * The session log a main-loop file call touched: a `.md` file directly under the sessions
102 * folder, by a Write (it created the log), an Edit or a Read (it opened one). Any other
103 * tool, a nested folder, another folder or another kind of file is null. The caller passes
104 * only the main loop's own calls that did not fail: a subagent's call binds nothing.
105 */
106export function logTouch(
107 tool: string,
108 input: Readonly<Record<string, unknown>>,
109 cwd: string,
110 dir: string = DEFAULT_SESSIONS_DIR,
111): LogTouch | null {
112 const by = LOG_TOOLS.get(tool)
113 const raw = input.file_path
114 if (by === undefined || typeof raw !== 'string' || raw.trim() === '') return null
115 const path = logPath(raw, cwd)
116 const cut = path.lastIndexOf('/')
117 if (cut <= 0 || !/.\.md$/i.test(path.slice(cut + 1))) return null
118 return sameFile(path.slice(0, cut), sessionsDir(cwd, dir)) ? { path, by } : null
119}
120
121/**
122 * The binding after one log touch. Unbound, the touch binds. The same log keeps the
123 * stronger touch. Another log displaces the binding only with a stronger touch (a Write
124 * outranks an Edit, which outranks a Read), or with a Write once the bound log is closed
125 * (a /task after a /close); so a Read of another log never displaces a Write. Returns
126 * `current` itself when nothing changes.
127 */
128export function bindLog(current: LogBinding | null, touch: LogTouch, at: number, boundOpen = true): LogBinding | null {
129 if (current === null) return { path: touch.path, by: touch.by, at }
130 const stronger = LOG_RANK[touch.by] > LOG_RANK[current.by]
131 if (sameFile(current.path, touch.path)) return stronger ? { ...current, by: touch.by, at } : current
132 if (stronger || (touch.by === 'write' && !boundOpen)) return { path: touch.path, by: touch.by, at }
133 return current
134}
135
136/** Whether bindLog's answer turns on the bound log being open: a Write of another log over a Write. */
137export function needsOpenCheck(current: LogBinding | null, touch: LogTouch): boolean {
138 return current !== null && current.by === 'write' && touch.by === 'write' && !sameFile(current.path, touch.path)
139}
140
141export function isOpenLog(text: string): boolean {
142 return !text.includes('## Session Closed')
143}
144
145/** New asks from the side pass's texts; `source` 'build' for its multi-session list (0.6.0). */
146export function freshAsks(
147 texts: readonly string[],
148 ids: readonly string[],
149 at: number,
150 source: 'ask' | 'build' = 'ask',
151): Ask[] {
152 return texts.map((text, i) => ({
153 id: ids[i] ?? `a${at}-${i}`,
154 text: cleanText(text),
155 source,
156 state: 'open',
157 claimId: null,
158 at,
159 }))
160}
161
162/**
163 * The asks a "done" answer may claim: open ones, and ticked goals still without a claim.
164 * Never a build: a turn does not finish a multi-session build, and /close makes it a to-do.
165 */
166export function claimable(asks: readonly Ask[]): Ask[] {
167 return asks.filter(
168 a => a.source !== 'build' && (a.state === 'open' || (a.state === 'claimed' && a.claimId === null)),
169 )
170}
171
172/**
173 * Replaces the log's witness print, or appends one. The print ends with PRINT_END,
174 * and only the text up to that marker is replaced, so notes written after a first
175 * /wrap survive a second. A print without the marker (none should exist) is
176 * replaced only up to the next heading.
177 */
178export function withPrint(text: string, section: string): string {
179 const at = text.indexOf(PRINT_HEADING)
180 if (at === -1) return `${text.replace(/\s*$/, '')}\n\n${section.trim()}\n`
181 const marker = text.indexOf(PRINT_END, at)
182 if (marker !== -1) {
183 const tail = text.slice(marker + PRINT_END.length)
184 return `${text.slice(0, at)}${section.trim()}${tail}`
185 }
186 const after = text.indexOf('\n## ', at + PRINT_HEADING.length)
187 const tail = after === -1 ? '' : text.slice(after)
188 return `${text.slice(0, at)}${section.trim()}\n${tail}`
189}
190
191/** The undone: open, or claimed but never witnessed. A build is not undone work for this session. */
192export function undone(asks: readonly Ask[]): Ask[] {
193 // Nor is an ask the close triage made a to-do (0.8.0): it lives on the board now.
194 return asks.filter(
195 a => a.source !== 'build' && a.settled === undefined && (a.state === 'open' || a.state === 'claimed'),
196 )
197}
198
199/**
200 * `/close` (Bradley, S162: "why on wrap and not on close as well?"): the close skill
201 * reads the session log, so the companion writes its print there first, as /wrap does.
202 */
203export function isCloseCommand(text: string, commands: readonly string[] = DEFAULT_CLOSE_COMMANDS): boolean {
204 return isConfiguredClose(text, commands)
205}
206
207/** The title every /tackle prompt opens with; its text is the companion's own list, never new asks. */
208export const TACKLE_TITLE = 'Tackle the undone asks the witness holds for this session:'
209/** The estate's first line: the title run as an /agile board (1.0: only where the estate is present). */
210export const TACKLE_HEADER = `${ESTATE_TACKLE_SKILL} ${TACKLE_TITLE}`
211
212/** The first line: the title, after the skill that runs it when one is named. */
213export function tackleHeader(skill: string | null): string {
214 return skill === null || skill === '' ? TACKLE_TITLE : `${skill} ${TACKLE_TITLE}`
215}
216
217/**
218 * Whether a prompt is a /tackle run the companion wrote. Mining it re-recorded the
219 * asks it lists as new ones (seen live S162: the three finished goals came back as
220 * "Verify or finish ..." duplicates), so the side pass skips it.
221 */
222export function isTacklePrompt(text: string): boolean {
223 return text.trim().replace(/^\/\S+\s+/, '').startsWith(TACKLE_TITLE)
224}
225
226/**
227 * /tackle (Bradley, S162): the undone asks as an /agile run for this session to
228 * direct. It goes into the prompt box for Bradley to read and send, never straight
229 * to the model, and done still means witnessed. Null when nothing is undone.
230 */
231/**
232 * The run opens with a sweep (Bradley, S163): much of what the witness holds is already
233 * done and its evidence is often already in the context window, so dismissing on evidence
234 * comes before slicing, and an agent is only for what survives it.
235 */
236export const SWEEP_FIRST =
237 'First, before any agent: sweep. For every item, gather evidence from what is already in the context window and from the live stores, and dismiss straight off, with mcp__witness__confirm, every item that is already done; only what survives the sweep is sliced, and only it may get an agent.'
238
239export function tacklePrompt(asks: readonly Ask[], skill: string | null = ESTATE_TACKLE_SKILL): string | null {
240 const todo = undone(asks)
241 if (todo.length === 0) return null
242 const items = todo.map((a, i) => {
243 const pale = a.state === 'claimed' ? ' (claimed done, never verified: verify it or finish it)' : ''
244 return `${i + 1}. ${cleanText(a.text)}${pale}`
245 })
246 return [
247 tackleHeader(skill),
248 SWEEP_FIRST,
249 ...items,
250 'Done means witnessed: close each item with a read-back of what it changed (a test run, a fetch, a re-read). Leave anything only I can witness, such as a layout, for my look, and anything that needs my own hands (a login, a payment, a recording) for me: list it as mine, never give it to an agent.',
251 ].join('\n')
252}
253
254const PASTED = /<pasted_content\b[^>]*>[\s\S]*?<\/pasted_content\b[^>]*>/g
255const TACKLE_END = 'Done means witnessed'
256
257/**
258 * A prompt with what Bradley quoted cut out: a pasted block, and a /tackle prompt quoted
259 * inside his own words (seen live, S166: pasting 405_85labs' /tackle to talk about it minted
260 * five of 85labs' asks as his). A /tackle prompt sent whole is skipped before this.
261 */
262export function withoutQuoted(text: string): string {
263 const unpasted = text.replace(PASTED, ' [pasted text] ')
264 const title = unpasted.indexOf(TACKLE_TITLE)
265 if (title === -1) return unpasted
266 const skill = /\/\S+\s+$/.exec(unpasted.slice(0, title))
267 const at = skill === null ? title : title - skill[0].length
268 const end = unpasted.indexOf(TACKLE_END, at)
269 const lineEnd = end === -1 ? -1 : unpasted.indexOf('\n', end)
270 const tail = end === -1 || lineEnd === -1 ? '' : unpasted.slice(lineEnd)
271 return `${unpasted.slice(0, at)} [a quoted /tackle prompt] ${tail}`
272}
273
274/**
275 * The print, for a project that keeps no session log, on the /close prompt itself as a note
276 * the close skill reads (a field report: 604_wasgenring's light protocol keeps none, so
277 * Step 4b had nothing to triage).
278 */
279export function closePrintNote(print: string, estate = true): string {
280 const settle = estate ? 'triage it in /close Step 4b' : 'settle it'
281 return [
282 `witness: this project keeps no session log, so the watch print could not be written into one. It follows here: ${settle} as if it stood in the log, and give the verdicts in the close summary.`,
283 '',
284 print.trim(),
285 ].join('\n')
286}
287
288/** The first line of the prompt /wrap submits; its text is the companion's own print, never new asks. */
289export const WRAP_READ_HEADER = 'witness /wrap: the watch is printed. Read it and settle what it lists.'
290
291export function isWrapReadPrompt(text: string): boolean {
292 return text.trim().startsWith(WRAP_READ_HEADER)
293}
294
295/**
296 * What /wrap hands the main session (Bradley, 2026-10-08: "it would also be nice for the
297 * main session to automatically read the printed work"): the print itself, and what to do
298 * with each line. Submitted as a turn of its own; settling only, no new work.
299 */
300export function wrapReadPrompt(
301 print: string,
302 logPath: string | null,
303 person: string = DEFAULT_PERSON,
304 closeName = '/close',
305): string {
306 const who = personRef(person)
307 const where =
308 logPath === null
309 ? 'It was not written to a session log (none is open for this session); the undone were kept for the next watch.'
310 : `It is written into ${logPath}.`
311 return [
312 WRAP_READ_HEADER,
313 where,
314 `For every line not marked [x]: if it is done, read it back now and confirm it with ${CONFIRM_TOOL}; if ${who} called it off, drop it with ${CONFIRM_TOOL} and outcome "dropped", citing those words; otherwise leave it open and say in one line what it waits on. Builds stay for ${closeName} to make to-dos.`,
315 'Start no new work. Then report in a few lines: what was confirmed, what was dropped, what stays open.',
316 '',
317 print.trim(),
318 ].join('\n')
319}
320
321export function tally(asks: readonly Ask[], ledger: Ledger): string {
322 const c = counts([...asks])
323 const claims = ledger.claims.length
324 const carried = c.carried > 0 ? `, ${c.carried} carried undone` : ''
325 const builds = c.builds > 0 ? `, ${c.builds} build${c.builds === 1 ? '' : 's'} for the to-do list` : ''
326 return `asks ${c.done}/${c.total} witnessed, ${c.pale} pale${carried}${builds}; claims ${claims - paleCount(ledger)}/${claims} witnessed`
327}
328hooks/ticket.ts 76 lines1// The estate ticket: the project tag, its board inbox, open Code to-dos and the push
2// verdict, read from scripts/estate-status.py (1.0: an adapter, found or configured at
3// session start; with no script there is no ticket, never `estate: ?`). Folded in from the estate-status mod
4// (0.2.5) in 0.3.0 (Bradley, S165): two mods composing one band depended on which
5// loaded first, and the engine gives no say over that, so the witness draws it alone.
6
7import type { Ticket } from '../types'
8
9export const TICKET_MS = 10 * 60_000
10export const BAR_GLYPH = '\u258c'
11
12export const UNAVAILABLE_TICKET: Ticket = { kind: 'unavailable' }
13
14export type EstateStatus = {
15 board_id?: string | null
16 inbox_open?: number | null
17 code_todos_open?: number | null
18 metered?: string | null
19}
20
21/**
22 * Project colours by board_id. Source: the Notion Global To-Do "Project" select
23 * (Overseer red, Project Manager blue, Brand Manager yellow, The Stacks green,
24 * Voice purple, HomeLab gray), ruled by Bradley 2026-10-06. Mid-tones that read on
25 * light and dark terminals. Router is "default" there, so it carries no entry; any
26 * other or null board_id gets no bar.
27 */
28export const BOARD_COLOURS: Readonly<Record<string, string>> = {
29 overseer: '#D44C47',
30 pm: '#447ACB',
31 brand: '#CB912F',
32 stacks: '#4C8F69',
33 voice: '#9065B0',
34 homelab: '#9B9A97',
35}
36
37/** The colour for a board, or null (no bar, default-colour tag). */
38export function colourFor(board: string | null | undefined): string | null {
39 if (board === null || board === undefined) return null
40 return Object.prototype.hasOwnProperty.call(BOARD_COLOURS, board) ? (BOARD_COLOURS[board] ?? null) : null
41}
42
43const show = (v: string | number | null | undefined): string =>
44 v === null || v === undefined ? '?' : String(v)
45
46/** The ticket's parts, every count already display text. A null shows as `?`, never 0. */
47export function ticketFromStatus(st: EstateStatus): Ticket {
48 const inbox = typeof st.inbox_open === 'number' ? st.inbox_open : null
49 return {
50 kind: 'ok',
51 colour: colourFor(st.board_id),
52 tag: show(st.board_id),
53 inbox: show(st.inbox_open),
54 todos: show(st.code_todos_open),
55 push: show(st.metered),
56 warnInbox: inbox !== null && inbox > 0,
57 warnPush: st.metered === 'METERED',
58 }
59}
60
61/**
62 * Parses the helper's stdout. Exit 2 means the database was unreachable but the JSON
63 * is still printed (partial); no run, any other exit, or output that is not a JSON
64 * object, is a failure and yields the unavailable ticket.
65 */
66export function ticketFromRun(run: { exitCode: number; stdout: string } | null): Ticket {
67 if (run === null || (run.exitCode !== 0 && run.exitCode !== 2)) return UNAVAILABLE_TICKET
68 try {
69 const parsed: unknown = JSON.parse(run.stdout)
70 if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return UNAVAILABLE_TICKET
71 return ticketFromStatus(parsed as EstateStatus)
72 } catch {
73 return UNAVAILABLE_TICKET
74 }
75}
76