SLOPSHOPPER

witness

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…

newpanebandguardcommandtoast
v1.1.0MITupdated 2026-10-09iniphi/witness
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · witness
│ ┃ Undone ✕ › fix the failing auth test and add an audit log call │ ┃ [x] witnessed [~] claimed, no read-back ye… │ ┃ No asks recorded this watch. ⏺ Read(src/auth.ts) │ ┃ Pale claims (said done, no read-back): 1 ⎿ Read 6 lines │ ┃ ~ Done. I made `refresh` reject expired c… ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /undone │ ⎿ witness: Witness asks 0/0 - the list is open in the Undone pane. │ │ ╭───────────────────────────────────────────────────────────────────────────────────╮ ._‿__. │ PRINT │ / .-. \ ○ │ No open session log from this session; the undone were kept for the next watch │> ~<. ( @ ) | ○ │ (asks 0/0 witnessed, 0 pale; claims 0/1 witnessed). 0: Dismiss │ ~\ `-' / ○ ╰───────────────────────────────────────────────────────────────────────────────────╯ ‾‾⁀‾‾ asks 0/0 · witnessed 0/1 ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭───────────────────────────────────────────────────────────────────────────────────╮ ._‿__. │ PRINT │ / .-. \ ○ │ No open session log from this session; the undone were kept for the next watch │> ~<. ( @ ) | ○ │ (asks 0/0 witnessed, 0 pale; claims 0/1 witnessed). 0: Dismiss │ ~\ `-' / ○ ╰───────────────────────────────────────────────────────────────────────────────────╯ ‾‾⁀‾‾ asks 0/0 · witnessed 0/1 ⟨Claude Code's own drawing⟩
Pane · Undone
[x] witnessed [~] claimed, no read-back yet [ ] open No asks recorded this watch. Pale claims (said done, no read-back): 1 ~ Done. I made `refresh` reject expired claims, added an …
Pane · witness-barnacles
Friction that recurs across sessions: the same problem on t… No barnacles: nothing has recurred.
README

witness

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.

What it does

  • The asks ledger. A Haiku side pass reads each prompt you type and records the asks in it. If the session keeps a log, the checkbox items under its Goals heading are added too; the log is the one this session itself created or opened, never another session's. The band shows 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.
  • Claims develop only on read-back. When the agent says something is done, the claim stays pale until a later read of the same target lands, such as a file read, a SELECT or a fetch. A claim whose matched asks are all witnessed develops with them. The agent cannot certify its own write. A subagent's final report is a claim too: it stays pale until the main session reads back what the report rests on, and a report is never evidence for the confirm tool.
  • The 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.
  • The confirm tool. The agent reports a finished ask through the 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.
  • The print. /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.
  • The companion. A nautilus (or a flask, by 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.

Install

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.

Configuration

Set these with /config or when you install. Every key is optional.

KeyDefaultWhat it does
bodyphiThe companion's body: phi (nautilus) or phial (round-bottom flask).
watchHoldfalseAt session start, and after five idle minutes, ask before the first prompt goes through.
ticketScriptemptyA 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.
watchScriptemptyA script that reads a work board for the watch and its poll. If it is empty and none is found, no board is polled.
writerCallsworkspace.close_todo, workspace.set_todo_status, workspace.pause_todo, workspace.crosstalk_*, workspace.record_board_attention, workspace.claim_item, workspace.release_claimSQL 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.
personyouHow the print and the confirm tool name you. The default prints "on your words", and the model reads "the user".
sessionsDir.claude/sessionsWhere session logs live, relative to the session folder.
goalsHeading## GoalsThe session log heading whose checkbox items are goals. The section ends at the next heading of the same level.
closeCommands/closeCommands that close a session, separated by commas. The print is written before they run.

Commands

  • /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. There is no /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.

Footprint

What it reads:

  • the prompts you type;
  • the tool calls the session makes and their results (to match claims against read-backs);
  • the open session log in sessionsDir;
  • your Claude Code settings;
  • the output of 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.
  • The Witness print, appended to the open session log at /wrap and at the close command.
  • Hook and skill candidates, appended to reports/witness-candidates.md when the session folder has a reports/ folder, and to .claude/witness-candidates.md otherwise.
  • The plugin's own store in Claude Code, which keeps the carried asks, friction, candidates and held-write notes from one session to the next.

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.

Cost

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.

Development

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.

Contributing

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.

Security

To report a vulnerability, see SECURITY.md. Please do not open a public issue for one.

License

MIT. Copyright (c) 2026 Bradley Higginson. See LICENSE.

Source 20 files
hooks/register.tsx 1196 lines
1import { 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}
1196
hooks/asks.ts 508 lines
1// 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}
508
hooks/bells.ts 32 lines
1import 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}
32
hooks/config.ts 151 lines
1// 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}
151
hooks/barnacles.ts 183 lines
1// 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}
183
hooks/sprites.ts 253 lines
1// 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}
253
hooks/evidence.ts 316 lines
1// 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}
316
hooks/notes.ts 257 lines
1// 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}
257
hooks/render.tsx 458 lines
1// 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}
458
hooks/sql.ts 336 lines
1// 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}
336
hooks/support.ts 328 lines
1// 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}
328
hooks/ticket.ts 76 lines
1// 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