Connects witness to Claude Code: a question before a Done goes in your name, the cards that cover a file named before the agent edits it, and the session's…

Claude Code works your witness project as it goes, and nothing goes to Done in your name until you say you watched it work.
claude plugin marketplace add jobbin-ab/witness-plugins
claude plugin install witness
Then /mcp in Claude Code and sign in. There is no key to copy. Needs Claude Code 2.1.289 or later.
Connects witness. Your projects, their working rules and their cards, through witness's MCP server. The agent reads the rules before its first write and files cards as it works.
Asks before a Done goes in your name. When the agent is about to mark a card Done, verified by you, Claude Code asks you first. "Not yet" stops it, and the agent is told to send the card to Needs verification instead.
☐ witness
GN-412 goes to Done, verified by Robin. Did you watch it work?
❯ 1. I watched it work
2. Not yet
Names the cards that cover a file. The first time the agent edits a file, it is told which cards cover that file and where each stands.
Shows the session's rail. Beside the transcript: the pull request, the cards this session holds with their status, the artifacts it published, and the branch. Each row opens what it names. /witness shows or hides it.
│ PULL REQUEST
│ Sessions: a pasted screenshot is kept o…
│ #365 · Draft
│
❯ Take GN-304 and GN-433. │ CARDS
│ ◕ GN-304 Sessions: a pasted screenshot …
Called witness 2 times │ ○ GN-433 Sessions: how long an archived…
│
● Claimed both. Starting with GN-304. │ ABOUT
│ claude/gn-304-6355
│ witness from main
What it sends. Its own calls go only to witness, through the MCP connection your session's witness tools use. It makes two calls there without the agent asking:
witness_list_cards with covers, the first time the agent edits a file in a session, once the session has read a project. It sends the file's path relative to the repository, with the project id and read proof the agent's own witness calls used.witness_agent_md, once per project, when a card arrives before the session knows the project's page (a resumed session, or one past a compaction). It sends the project id, and keeps the page address for the rail's links.Nothing else leaves your machine: no file contents, no command output, nothing else from the conversation.
What it runs. Two fixed commands in the session's folder, for the rail's pull request and branch: git rev-parse --abbrev-ref HEAD and gh pr view --json number,title,url,state,isDraft,baseRefName, which asks GitHub for the branch's pull request with your own gh login. They run at session start, after a Bash call holding gh pr or git push, and at /witness. Never polled, and never where nothing can show the rail (claude -p). No gh, no pull request section.
What it reads. These tool calls, keeping what the rail and the questions need for the session:
verifiedBy waits for your answer.gh pr or git push.for the answer.
It changes no tool's input, and the only call it stops is a Done you did not confirm. /witness is the one command it answers: it opens or closes the rail, and opening reads the pull request and branch as above.
/witness, and the line under the prompt lists the cards meanwhile: ◕ GN-304 · ○ GN-433 · /witness.claude -p, or anywhere nobody can be asked, a Done passes through to witness as it would without the plugin.claude mcp add before, remove it (claude mcp remove witness). The plugin brings its own, and with both the agent sees every tool twice.hooks/register.tsx 628 lines1import type { EngineInterface, Register, RenderInput, ToolCallResult } from 'claude-code'
2
3import type { WitnessArtifact, WitnessHeldCard, WitnessProof, WitnessRailHistory, WitnessTree } from '../types'
4import { WITNESS_TOOL, isRecord, pageLink, parse, payload, witnessCall, type WitnessCall } from './calls'
5import { cardsChange } from './cards'
6import { askedCovers, coveringCards, filePathOf, note, relativeTo } from './covers'
7import { HEADER, NOT_YET, WATCHED, doneWrite, question, refusal, type DoneItem } from './done'
8import { glyphCell, glyphColour, glyphSvg } from './glyphs'
9import { statusLabel } from './labels'
10import {
11 RAIL,
12 RAIL_COLUMNS,
13 cells,
14 cut,
15 fitRows,
16 hintTail,
17 inlineRows,
18 parsePullRequest,
19 publishedArtifact,
20 pageKey,
21 touchesPullRequest,
22 withArtifact,
23 type RailFacts,
24 type RailRow,
25} from './rail'
26
27/**
28 * The witness mod (docs/mod-concept.md): a Done in a person's name passes through the
29 * person's hand; a file a card covers is named before it is changed; and the session has
30 * the Mac app's rail, as a pane. It carries no sentence of the method: that is the
31 * project document's, read through the MCP tools.
32 *
33 * Everything that touches `$` is in this file, written so a reader of the source can
34 * follow it: every use is a literal `$.noun.method(...)`, every registration its own
35 * `on("event", ...)` line, and `$` is passed only whole, to a function declared at the
36 * top of this file; never into an import (the state library's `read` and `update`
37 * included), a nested function, a variable or a destructuring. The other files are
38 * plain logic.
39 */
40
41const cards = { plugin: 'witness', key: 'cards' } as const
42const proofs = { plugin: 'witness', key: 'proofs' } as const
43const pages = { plugin: 'witness', key: 'pages' } as const
44const asked = { plugin: 'witness', key: 'asked' } as const
45const artifacts = { plugin: 'witness', key: 'artifacts' } as const
46const tree = { plugin: 'witness', key: 'tree' } as const
47const rail = { plugin: 'witness', key: 'rail' } as const
48
49const NO_TREE: WitnessTree = { branch: null, repository: null, pullRequest: null }
50const NO_HISTORY: WitnessRailHistory = { shown: false, autoOpened: false, closedByPerson: false }
51
52/**
53 * How often a change is tried against a value another write keeps beating, as the state
54 * library's `update` bounds it. Each change below reads, applies, and writes with
55 * `ifVersion`, again on a miss, so two changes in flight both land.
56 */
57const TRIES = 64
58const BEATEN = 'witness: the value was written by another every time it was read; nothing was written'
59
60async function heldCards($: EngineInterface): Promise<WitnessHeldCard[]> {
61 return (await $.state.get(cards)).value ?? []
62}
63
64async function changeCards($: EngineInterface, change: (list: WitnessHeldCard[]) => WitnessHeldCard[]): Promise<WitnessHeldCard[]> {
65 for (let i = 0; i < TRIES; i += 1) {
66 const held = await $.state.get(cards)
67 const value = change(held.value ?? [])
68 if ((await $.state.set(cards, value, { ifVersion: held.version })).isSet) return value
69 }
70 throw new Error(BEATEN)
71}
72
73async function heldProofs($: EngineInterface): Promise<Record<string, WitnessProof>> {
74 return (await $.state.get(proofs)).value ?? {}
75}
76
77async function changeProofs(
78 $: EngineInterface,
79 change: (all: Record<string, WitnessProof>) => Record<string, WitnessProof>,
80): Promise<void> {
81 for (let i = 0; i < TRIES; i += 1) {
82 const held = await $.state.get(proofs)
83 if ((await $.state.set(proofs, change(held.value ?? {}), { ifVersion: held.version })).isSet) return
84 }
85 throw new Error(BEATEN)
86}
87
88async function heldPages($: EngineInterface): Promise<Record<string, string>> {
89 return (await $.state.get(pages)).value ?? {}
90}
91
92async function changePages($: EngineInterface, change: (all: Record<string, string>) => Record<string, string>): Promise<void> {
93 for (let i = 0; i < TRIES; i += 1) {
94 const held = await $.state.get(pages)
95 if ((await $.state.set(pages, change(held.value ?? {}), { ifVersion: held.version })).isSet) return
96 }
97 throw new Error(BEATEN)
98}
99
100async function changeAsked($: EngineInterface, change: (list: string[]) => string[]): Promise<void> {
101 for (let i = 0; i < TRIES; i += 1) {
102 const held = await $.state.get(asked)
103 if ((await $.state.set(asked, change(held.value ?? []), { ifVersion: held.version })).isSet) return
104 }
105 throw new Error(BEATEN)
106}
107
108async function heldArtifacts($: EngineInterface): Promise<WitnessArtifact[]> {
109 return (await $.state.get(artifacts)).value ?? []
110}
111
112async function changeArtifacts($: EngineInterface, change: (list: WitnessArtifact[]) => WitnessArtifact[]): Promise<void> {
113 for (let i = 0; i < TRIES; i += 1) {
114 const held = await $.state.get(artifacts)
115 if ((await $.state.set(artifacts, change(held.value ?? []), { ifVersion: held.version })).isSet) return
116 }
117 throw new Error(BEATEN)
118}
119
120async function heldTree($: EngineInterface): Promise<WitnessTree> {
121 return (await $.state.get(tree)).value ?? NO_TREE
122}
123
124async function keepTree($: EngineInterface, value: WitnessTree): Promise<void> {
125 await $.state.set(tree, value)
126}
127
128async function railHistory($: EngineInterface): Promise<WitnessRailHistory> {
129 return (await $.state.get(rail)).value ?? NO_HISTORY
130}
131
132async function changeRail($: EngineInterface, change: (history: WitnessRailHistory) => WitnessRailHistory): Promise<void> {
133 for (let i = 0; i < TRIES; i += 1) {
134 const held = await $.state.get(rail)
135 if ((await $.state.set(rail, change(held.value ?? NO_HISTORY), { ifVersion: held.version })).isSet) return
136 }
137 throw new Error(BEATEN)
138}
139
140/** Where the session draws; none on error. */
141async function surfaces($: EngineInterface): Promise<readonly string[]> {
142 try {
143 return await $.session.surfaces()
144 } catch {
145 return []
146 }
147}
148
149/** Whether the rail is open; `placed` asks that it is also seated. */
150async function railOpen($: EngineInterface, placed: boolean): Promise<boolean> {
151 try {
152 return (await $.ui.panes()).some((p) => p.id === RAIL && (!placed || p.isPlaced))
153 } catch {
154 return false
155 }
156}
157
158/** Resolves undefined after `ms`, or at once when `signal` aborts: the bound a lookup races. */
159async function waitFor($: EngineInterface, ms: number, signal: AbortSignal): Promise<undefined> {
160 try {
161 await $.clock.sleep(ms, { signal })
162 } catch {
163 // Aborted: the lookup answered first.
164 }
165 return undefined
166}
167
168/**
169 * Whether the terminal draws the fullscreen layout, learnt from its own hint line (the
170 * engine says it on a render's viewport and on a command, nowhere else). Still unknown at
171 * the first card, the rail does not open unasked and the hint tail stands in.
172 */
173let fullscreen: boolean | undefined
174
175/** A connected witness, once seen: tools do not disconnect often enough to ask again per draw. */
176let connected = false
177
178/** Where the rail was last drawn, so a change while it sits inline can ask for its new height. */
179let placement: 'dock' | 'inline' | undefined
180
181/**
182 * Asks the person in the engine's own question dialog. Undefined lets the call through;
183 * a string is the refusal. Nobody to ask (a `-p` run, an SDK host with no surface, a Mac
184 * app session whose form the question does not reach): the ask rejects and the call
185 * passes unchanged, so the server's own guard stands and the mod is never stricter than
186 * witness without it. A person there who closes the dialog, or types something else
187 * under Other, has not said yes.
188 */
189async function askDone($: EngineInterface, call: WitnessCall, items: DoneItem[]): Promise<string | undefined> {
190 const where = await surfaces($)
191 let answer: string
192 try {
193 answer = await $.ui.ask(question(call, items), { options: [WATCHED, NOT_YET], header: HEADER })
194 } catch {
195 return where.length === 0 ? undefined : refusal(call, items, false)
196 }
197 if (answer === WATCHED) return undefined
198 return refusal(call, items, answer === NOT_YET)
199}
200
201/** Keeps what an answered witness call says: the project's proof, a `covers` asked, the cards. */
202async function recordCall($: EngineInterface, call: WitnessCall, answer: Record<string, unknown>): Promise<void> {
203 const projectId = typeof call.input.projectId === 'string' ? call.input.projectId : ''
204 const readProof = call.input.readProof
205 if (projectId && typeof readProof === 'string') {
206 await changeProofs($, (all) => ({ ...all, [projectId]: { server: call.server, readProof } }))
207 }
208 const path = askedCovers(call)
209 if (path) await changeAsked($, (list) => (list.includes(path) ? list : [...list, path]))
210 connected = true
211 const change = cardsChange(call, answer)
212 if (!change) return
213 const before = (await heldCards($)).length
214 const after = (await changeCards($, change)).length
215 if (before === 0 && after > 0) await autoOpen($)
216 else await growInline($)
217 if (projectId && after > 0) await learnPage($, call.server, projectId)
218}
219
220/** Keeps a project's page address under its server. The rail reads it while drawing, so a card already held gains its link when it lands. */
221async function keepPage($: EngineInterface, server: string, projectId: string, page: string): Promise<void> {
222 const key = pageKey(server, projectId)
223 await changePages($, (all) => (all[key] === page ? all : { ...all, [key]: page }))
224}
225
226/** The page address the model's own `agent_md` call was answered with, in whatever shape the engine hands it on. */
227async function recordPage($: EngineInterface, call: WitnessCall, ran: ToolCallResult): Promise<void> {
228 if (ran.deny !== undefined || ran.isError) return
229 const projectId = typeof call.input.projectId === 'string' ? call.input.projectId : ''
230 const page = pageLink(ran.result, projectId, ran.text)
231 if (page) await keepPage($, call.server, projectId, page)
232}
233
234/** How long a card write waits on the mod's own `agent_md` before it goes on without a link. */
235const PAGE_WAIT_MS = 2000
236
237/** Server and project pairs whose page the mod has asked for itself: once each per session. */
238const pagesAsked = new Set<string>()
239
240/** The page `agent_md` names for one project on one server; undefined on any failure. */
241async function askPage($: EngineInterface, server: string, projectId: string): Promise<string | undefined> {
242 try {
243 return pageLink(await $.mcp.call(server, 'witness_agent_md', { projectId }), projectId)
244 } catch {
245 return undefined
246 }
247}
248
249/**
250 * A session that writes with a proof but never called `agent_md` itself (one resumed, or
251 * past a compaction) has no page for the project: once per server and project, the mod
252 * calls `agent_md` on the server the proof was taken by, and keeps the page it names.
253 * `$.mcp.call` is seen by `mcp.call` hooks, never by this mod's `tool.call` hook, so the
254 * page is kept here rather than by `recordPage`. Silent on failure and after two seconds,
255 * as `coversNote` is.
256 */
257async function learnPage($: EngineInterface, server: string, projectId: string): Promise<void> {
258 const key = pageKey(server, projectId)
259 if (pagesAsked.has(key) || (await heldPages($))[key]) return
260 const proof = (await heldProofs($))[projectId]
261 if (!proof || proof.server !== server) return
262 pagesAsked.add(key)
263 const stop = new AbortController()
264 const page = await Promise.race([askPage($, server, projectId), waitFor($, PAGE_WAIT_MS, stop.signal)])
265 stop.abort()
266 if (page) await keepPage($, server, projectId, page)
267}
268
269async function isConnected($: EngineInterface): Promise<boolean> {
270 if (connected) return true
271 try {
272 connected = (await $.tool.list()).some((t) => WITNESS_TOOL.test(t.name))
273 } catch {
274 connected = false
275 }
276 return connected
277}
278
279/** What the rail draws from; read while drawing, so a later write draws it again. */
280async function railFacts($: EngineInterface): Promise<RailFacts> {
281 const held = await heldCards($)
282 return {
283 cards: held,
284 artifacts: await heldArtifacts($),
285 tree: await heldTree($),
286 pages: await heldPages($),
287 isConnected: held.length > 0 || (await isConnected($)),
288 }
289}
290
291/** Opens the rail: docked 44 columns wide, inline as tall as its lines with the folds that fit fourteen. */
292async function openRail($: EngineInterface): Promise<void> {
293 const rows = inlineRows(await railFacts($))
294 const opened = await $.ui.open({ id: RAIL, title: RAIL, columns: RAIL_COLUMNS, rows })
295 if (opened.isPlaced) await changeRail($, (history) => ({ ...history, shown: true }))
296 // The hint tail reads whether the rail is placed, which no state write announces.
297 $.ui.invalidate('ui.render')
298}
299
300/**
301 * The one unasked open of a session, at its first card. Only where the pane docks beside
302 * the transcript (the terminal's fullscreen layout, desktop, VS Code); never with no
303 * surface; never after the person closed it. A terminal whose layout is not yet known
304 * gets the hint tail instead.
305 */
306async function autoOpen($: EngineInterface): Promise<void> {
307 const history = await railHistory($)
308 if (history.autoOpened || history.closedByPerson) return
309 const where = await surfaces($)
310 if (where.length === 0) return
311 if (!(where.some((s) => s === 'desktop' || s === 'vscode') || fullscreen === true)) return
312 await changeRail($, (history) => ({ ...history, autoOpened: true }))
313 await openRail($)
314}
315
316/**
317 * While the rail sits inline, a card or an artifact that arrives asks the engine for the
318 * new height with another open of the same id; the engine may grow the pane or not.
319 */
320async function growInline($: EngineInterface): Promise<void> {
321 if (placement !== 'inline') return
322 if (!(await railOpen($, false))) return
323 const rows = inlineRows(await railFacts($))
324 await $.ui.open({ id: RAIL, title: RAIL, columns: RAIL_COLUMNS, rows })
325}
326
327/** The session's repository root, or null. */
328async function repoRoot($: EngineInterface): Promise<string | null> {
329 try {
330 return (await $.session.repo())?.root ?? null
331 } catch {
332 return null
333 }
334}
335
336/** The session's own folder, or undefined. */
337async function sessionRoot($: EngineInterface): Promise<string | undefined> {
338 try {
339 return await $.session.root()
340 } catch {
341 return undefined
342 }
343}
344
345/** The branch `git rev-parse --abbrev-ref HEAD` names; null for a detached head, no git, or an error. */
346async function readBranch($: EngineInterface): Promise<string | null> {
347 try {
348 const ran = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { timeoutMs: 10_000 })
349 const out = ran.stdout.trim()
350 // A detached head reads `HEAD`: no branch.
351 return ran.exitCode === 0 && out && out !== 'HEAD' ? out : null
352 } catch {
353 return null
354 }
355}
356
357/** The branch's pull request as `gh pr view` prints it; null for none, no gh, or an error. */
358async function readPullRequest($: EngineInterface): Promise<WitnessTree['pullRequest']> {
359 try {
360 const ran = await $.process.run(['gh', 'pr', 'view', '--json', 'number,title,url,state,isDraft,baseRefName'], {
361 timeoutMs: 15_000,
362 })
363 return ran.exitCode === 0 ? parsePullRequest(ran.stdout) : null
364 } catch {
365 return null
366 }
367}
368
369/**
370 * The working tree, read at session start, after a Bash call that may have moved the
371 * pull request, and at each `/witness`; never polled. No git, no `gh`, no pull request,
372 * or an error: that part is absent. Never throws.
373 */
374async function refreshTree($: EngineInterface): Promise<void> {
375 try {
376 // No surface (\`-p\`, an SDK host, a Mac app session): nothing draws the rail, so no gh, no git.
377 if ((await surfaces($)).length === 0) return
378 const root = await repoRoot($)
379 const branch = await readBranch($)
380 const pullRequest = await readPullRequest($)
381 const repository = root ? (root.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? null) : null
382 await keepTree($, { branch, repository, pullRequest })
383 } catch {
384 // The rail keeps what it had.
385 }
386}
387
388/** How long the edit's result waits on a `covers` answer before it goes without a note. */
389const COVERS_WAIT_MS = 2000
390
391/** The cards one project says cover `path`; none when not connected or the server failed. */
392async function askCovers($: EngineInterface, projectId: string, proof: WitnessProof, path: string): Promise<string[]> {
393 try {
394 const answer = await $.mcp.call(proof.server, 'witness_list_cards', {
395 projectId,
396 readProof: proof.readProof,
397 covers: path,
398 })
399 return coveringCards(answer)
400 } catch {
401 // Not connected, or the server failed: silent, the edit runs.
402 return []
403 }
404}
405
406/**
407 * The note for one file, or undefined: once per path per session, on each project the
408 * session has read with a proof, never for a path the agent asked `covers` about itself.
409 * Nothing covers it, no answer within two seconds, or anything fails: undefined.
410 */
411async function coversNote($: EngineInterface, filePath: string): Promise<string | undefined> {
412 const known = await heldProofs($)
413 const projects = Object.keys(known)
414 if (projects.length === 0) return undefined
415
416 const repo = await repoRoot($)
417 const root = await sessionRoot($)
418 const path = relativeTo(filePath, [...(repo ? [repo] : []), ...(root ? [root] : [])])
419 if (!path) return undefined
420
421 let first = false
422 await changeAsked($, (list) => {
423 first = !list.includes(path)
424 return first ? [...list, path] : list
425 })
426 if (!first) return undefined
427
428 const lookups: Promise<string[]>[] = []
429 for (const projectId of projects) lookups.push(askCovers($, projectId, known[projectId]!, path))
430 const stop = new AbortController()
431 const found = await Promise.race([
432 Promise.all(lookups).then((each) => each.flat()),
433 waitFor($, COVERS_WAIT_MS, stop.signal),
434 ])
435 stop.abort()
436 return note(found ?? [], path)
437}
438
439/**
440 * The rail's tree on one surface: every row one line, two cells in under its header, cut
441 * with `…` at the body's width. The glyph is a terminal cell on the terminal and the
442 * phone, and the page's own SVG on desktop and VS Code.
443 */
444function drawRail($: EngineInterface, e: RenderInput<'Pane'>, rows: readonly RailRow[]) {
445 const elements = $.ui.resolve(e)
446 const { Box, Text, Link } = elements
447 const Svg = (e.surface === 'desktop' || e.surface === 'vscode') && 'Svg' in elements ? elements.Svg : undefined
448 // Docked, the mod draws the frame's gutter: one cell on the left, one under the close
449 // mark, so a header never touches the divider and the \`…\` sits under the \`✕\`.
450 const docked = e.props.placement === 'dock'
451 const body = (e.props.bodyColumns || RAIL_COLUMNS) - (docked ? 2 : 0)
452 const width = Math.max(8, body - 2)
453 const line = (row: RailRow) => {
454 switch (row.kind) {
455 case 'header':
456 return <Text dimColor>{row.text}</Text>
457 case 'gap':
458 return <Text> </Text>
459 case 'note':
460 // A cause cut short says nothing: a note wraps rather than lose its end.
461 return <Text dimColor>{row.text}</Text>
462 case 'more':
463 return (
464 <Box paddingLeft={2}>
465 <Text dimColor>{row.count + ' more'}</Text>
466 </Box>
467 )
468 case 'about':
469 return (
470 <Box paddingLeft={2}>
471 <Text dimColor>{cut(row.text, width)}</Text>
472 </Box>
473 )
474 case 'link':
475 return (
476 <Box paddingLeft={2}>
477 <Text dimColor={row.dim === true}>
478 <Link href={row.href}>{cut(row.text, width)}</Link>
479 </Text>
480 </Box>
481 )
482 case 'card': {
483 const { card } = row
484 const title = cut(card.title, width - cells(card.id) - 3)
485 const words = row.href ? (
486 <Text>
487 <Link href={row.href}>
488 <Text dimColor>{card.id}</Text>
489 {title ? ' ' + title : ''}
490 </Link>
491 </Text>
492 ) : (
493 <Text>
494 <Text dimColor>{card.id}</Text>
495 {title ? ' ' + title : ''}
496 </Text>
497 )
498 const colour = glyphColour(card.status)
499 const glyph = Svg ? (
500 <Svg source={glyphSvg(card.status)} alt={statusLabel(card.status)} width={14} height={14} />
501 ) : colour ? (
502 <Text color={colour}>{glyphCell(card.status)}</Text>
503 ) : (
504 <Text>{glyphCell(card.status)}</Text>
505 )
506 return (
507 <Box paddingLeft={2} flexDirection="row">
508 {glyph}
509 <Text> </Text>
510 {words}
511 </Box>
512 )
513 }
514 }
515 }
516 return (
517 <Box flexDirection="column" paddingLeft={docked ? 1 : 0}>
518 {rows.map(line)}
519 </Box>
520 )
521}
522
523export const register: Register = (on) => {
524 on('session.start', async ($, e, next) => {
525 await $.command.register({
526 name: 'witness',
527 description: "Shows or hides this session's witness rail: its pull request, cards, artifacts and branch.",
528 immediate: true,
529 })
530 const started = await next(e)
531 void refreshTree($)
532 return started
533 })
534
535 /** `/witness` opens the rail at any width, docked or inline as the screen allows, and closes it when open. */
536 on('command.run', { command: 'witness' }, async ($, e) => {
537 fullscreen = e.presentation.isFullscreen
538 if (await railOpen($, false)) {
539 await $.ui.close({ id: RAIL })
540 return {}
541 }
542 // Open from what is held, at once; the tree is read after, and the rail redraws when
543 // it lands. gh can take many seconds, and a command waits on nothing it does not need.
544 await openRail($)
545 void refreshTree($)
546 return {}
547 })
548
549 /** A rail the person closed stays closed: nothing reopens it unasked. */
550 on('ui.close', { id: 'witness' }, async ($, e, next) => {
551 const closed = await next(e)
552 if (e.origin.kind === 'person') await changeRail($, (history) => ({ ...history, closedByPerson: true }))
553 $.ui.invalidate('ui.render')
554 return closed
555 })
556
557 on('ui.render', { component: 'Pane', requestId: 'witness' }, async ($, e) => {
558 placement = e.props.placement
559 const facts = await railFacts($)
560 // Docked, nothing folds: the column scrolls. Inline, the rows are what the engine gave.
561 return drawRail($, e, fitRows(facts, e.props.placement === 'inline' ? e.props.scroll.bodyRows : Number.POSITIVE_INFINITY))
562 })
563
564 /**
565 * The hint tail: on the terminal, while cards are held and the rail is not placed, the
566 * dim line under the prompt ends with each card's glyph and id.
567 */
568 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
569 if (e.surface !== 'terminal') return next(e)
570 if (e.viewport?.isFullscreen !== undefined) fullscreen = e.viewport.isFullscreen
571 const held = await heldCards($)
572 if (held.length === 0) return next(e)
573 if (await railOpen($, true)) return next(e)
574 const tail = hintTail(held, (await railHistory($)).shown, e.props.hint, e.viewport?.columns)
575 return tail ? next({ ...e, props: { ...e.props, tail } }) : next(e)
576 })
577
578 on('tool.call', { tool: /^mcp__.+__witness_[a-z_]+$/ }, async ($, e, next) => {
579 const call = witnessCall(e as { tool: string } & Record<string, unknown>)
580 if (!call) return next(e)
581
582 const items = doneWrite(call)
583 if (items) {
584 const refused = await askDone($, call, items)
585 if (refused !== undefined) return { deny: refused }
586 }
587
588 const ran = await next(e)
589 if (call.op === 'agent_md') await recordPage($, call, ran).catch(() => {})
590 const answer = payload(ran)
591 if (answer) await recordCall($, call, answer).catch(() => {})
592 return ran
593 })
594
595 /** A Bash call that may have opened, pushed or changed the pull request reads the tree again. */
596 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
597 const ran = await next(e)
598 if (touchesPullRequest(e.command)) void refreshTree($)
599 return ran
600 })
601
602 /** An artifact this session published joins the rail. */
603 on('tool.call', { tool: /^Artifact$/ }, async ($, e, next) => {
604 const ran = await next(e)
605 if (ran.deny !== undefined || ran.isError) return ran
606 const result = isRecord(ran.result) ? ran.result : parse(ran.text)
607 const published = publishedArtifact(e as Record<string, unknown>, result)
608 if (published) {
609 await changeArtifacts($, (list) => withArtifact(list, published)).catch(() => {})
610 await growInline($).catch(() => {})
611 }
612 return ran
613 })
614
615 /**
616 * The covers note rides on the edit's own result, as `context`: the text the model reads
617 * right after that result. The lookup starts before the edit runs and never holds it back.
618 */
619 on('tool.call', { tool: /^(Edit|Write|MultiEdit|NotebookEdit)$/ }, async ($, e, next) => {
620 const path = filePathOf(e as Record<string, unknown>)
621 const pending = path ? coversNote($, path).catch(() => undefined) : Promise.resolve(undefined)
622 const ran = await next(e)
623 const text = await pending
624 if (!text || ran.deny !== undefined) return ran
625 return { ...ran, context: [...(ran.context ?? []), text] }
626 })
627}
628hooks/calls.ts 94 lines1import type { ToolCallResult } from 'claude-code'
2
3/**
4 * A witness tool is known by the `witness_` prefix after `mcp__<server>__`, never by the
5 * server's name: `witness`, `witness-dev`, `witness-2`, or this plugin's own
6 * `plugin_witness_witness`.
7 */
8export const WITNESS_TOOL = /^mcp__(.+)__witness_([a-z_]+)$/
9
10/** One witness call: the server it went to, the operation, and its arguments. */
11export type WitnessCall = {
12 server: string
13 op: string
14 input: Record<string, unknown>
15}
16
17function isRecord(v: unknown): v is Record<string, unknown> {
18 return typeof v === 'object' && v !== null && !Array.isArray(v)
19}
20
21export function witnessCall(e: { tool: string } & Record<string, unknown>): WitnessCall | undefined {
22 const m = WITNESS_TOOL.exec(String(e.tool))
23 if (!m) return undefined
24 const { tool: _tool, tool_use_id: _id, agentId: _agent, consent: _consent, ...input } = e
25 return { server: m[1]!, op: m[2]!, input }
26}
27
28/**
29 * The JSON a witness result carries. Every row-driven witness tool returns its
30 * structured result pretty-printed as the one text block, so the text is enough; the
31 * structured copy is read first where the engine kept it.
32 */
33export function payload(ran: ToolCallResult): Record<string, unknown> | undefined {
34 if (ran.deny !== undefined || ran.isError) return undefined
35 const result: unknown = ran.result
36 if (isRecord(result) && isRecord(result.structuredContent)) return result.structuredContent
37 const text =
38 ran.text ??
39 (isRecord(result) && Array.isArray(result.content)
40 ? result.content
41 .map((b: unknown) => (isRecord(b) && b.type === 'text' && typeof b.text === 'string' ? b.text : ''))
42 .join('')
43 : typeof result === 'string'
44 ? result
45 : undefined)
46 return parse(text)
47}
48
49/** The line Claude Code flattens a `resource_link` block named `page` into: `[Resource link: page] <uri>`. */
50const FLATTENED_PAGE_LINK = /^\[Resource link: page\] (\S+)$/gm
51
52/**
53 * The project's page address from an `agent_md` answer: the `resource_link` named `page`.
54 * Read in every shape it arrives in: a real block (a host that passes blocks through, and
55 * `$.mcp.call`'s raw result), or the text line Claude Code flattens it into before a
56 * `tool.call` hook sees it (`result` the block array, `text` the blocks joined). `result`
57 * may be the block array, `{ content: [...] }` or a string. Taken only when the address
58 * ends in `/r/<projectId>`, the page of the project asked about, so a stray link is never
59 * trusted.
60 */
61export function pageLink(result: unknown, projectId: string, text?: string): string | undefined {
62 if (!projectId) return undefined
63 if (isRecord(result) && result.isError === true) return undefined
64 const blocks: unknown[] = Array.isArray(result)
65 ? result
66 : isRecord(result) && Array.isArray(result.content)
67 ? result.content
68 : []
69 const candidates: string[] = []
70 const scan = (t: string) => {
71 for (const m of t.matchAll(FLATTENED_PAGE_LINK)) candidates.push(m[1]!)
72 }
73 for (const b of blocks) {
74 if (!isRecord(b)) continue
75 if (b.type === 'resource_link' && b.name === 'page' && typeof b.uri === 'string') candidates.push(b.uri)
76 if (b.type === 'text' && typeof b.text === 'string') scan(b.text)
77 }
78 if (typeof result === 'string') scan(result)
79 if (text) scan(text)
80 return candidates.find((uri) => uri.endsWith(`/r/${projectId}`))
81}
82
83export function parse(text: string | undefined): Record<string, unknown> | undefined {
84 if (!text) return undefined
85 try {
86 const value: unknown = JSON.parse(text)
87 return isRecord(value) ? value : undefined
88 } catch {
89 return undefined
90 }
91}
92
93export { isRecord }
94hooks/cards.ts 57 lines1import type { WitnessHeldCard } from '../types'
2import { isRecord, type WitnessCall } from './calls'
3
4/** Adds the card at the end, or updates it in place; a title it does not carry keeps the one held. */
5function upsert(list: WitnessHeldCard[], card: WitnessHeldCard): WitnessHeldCard[] {
6 const held = list.find((c) => c.id === card.id)
7 if (!held) return [...list, card]
8 const merged = { ...card, title: card.title || held.title }
9 return list.map((c) => (c.id === card.id ? merged : c))
10}
11
12function cardOf(v: unknown, projectId: string, server: string): WitnessHeldCard | undefined {
13 if (!isRecord(v) || typeof v.id !== 'string' || typeof v.status !== 'string') return undefined
14 return { id: v.id, projectId, status: v.status, title: typeof v.title === 'string' ? v.title : '', server }
15}
16
17/**
18 * How one answered witness call changes the session's cards: what the server holds,
19 * never what the agent asked. A claim or a write adds the card; a release removes it; a
20 * version conflict's `current` corrects a card already held. A batch answers with ids
21 * only, so a landed item that sent a status takes that status, and one that sent none
22 * keeps what was held. Undefined when the call changes nothing.
23 */
24export function cardsChange(
25 call: WitnessCall,
26 answer: Record<string, unknown>,
27): ((list: WitnessHeldCard[]) => WitnessHeldCard[]) | undefined {
28 const projectId = typeof call.input.projectId === 'string' ? call.input.projectId : ''
29 switch (call.op) {
30 case 'claim_card':
31 case 'create_card':
32 case 'update_card': {
33 const card = cardOf(answer.card, projectId, call.server)
34 if (card) return (list) => upsert(list, card)
35 const current = cardOf(answer.current, projectId, call.server)
36 if (current) return (list) => (list.some((c) => c.id === current.id) ? upsert(list, current) : list)
37 return undefined
38 }
39 case 'release_card': {
40 const card = cardOf(answer.card, projectId, call.server)
41 return card ? (list) => list.filter((c) => c.id !== card.id) : undefined
42 }
43 case 'update_cards': {
44 const landed = Array.isArray(answer.landed) ? answer.landed.filter(isRecord) : []
45 const sent = Array.isArray(call.input.cards) ? call.input.cards.filter(isRecord) : []
46 const known = landed.flatMap((l) => {
47 const item = sent.find((s) => String(s.id ?? '').toUpperCase() === l.id)
48 return typeof l.id === 'string' && item && typeof item.status === 'string'
49 ? [{ id: l.id, projectId, status: item.status, title: typeof item.title === 'string' ? item.title : '', server: call.server }]
50 : []
51 })
52 return known.length ? (list) => known.reduce(upsert, list) : undefined
53 }
54 }
55 return undefined
56}
57hooks/covers.ts 57 lines1import { isRecord, parse, type WitnessCall } from './calls'
2
3function clean(path: string): string {
4 return path.replace(/\\/g, '/').replace(/^\.\//, '')
5}
6
7/** The path a `list_cards` call asked `covers` about itself, if it did. */
8export function askedCovers(call: WitnessCall): string | undefined {
9 if (call.op !== 'list_cards' || typeof call.input.covers !== 'string' || !call.input.covers.trim()) return undefined
10 return clean(call.input.covers.trim())
11}
12
13/** The path as cards name it: the shortest relative path under any of the roots (the repository's, the session's). */
14export function relativeTo(path: string, roots: readonly string[]): string | undefined {
15 const file = clean(path)
16 let best: string | undefined
17 for (const r of roots) {
18 const base = clean(r).replace(/\/$/, '')
19 if (!file.startsWith(`${base}/`)) continue
20 const rel = file.slice(base.length + 1)
21 if (best === undefined || rel.length < best.length) best = rel
22 }
23 return best
24}
25
26/** The file a write is about to change, from whichever field its tool names it in. */
27export function filePathOf(e: Record<string, unknown>): string | undefined {
28 const path = e.file_path ?? e.notebook_path
29 return typeof path === 'string' && path ? path : undefined
30}
31
32/** The cards a `list_cards` answer lists, each as the note names it: `GN-07 (done · “title”)`, the status as its key. */
33export function coveringCards(answer: {
34 isError: boolean
35 structuredContent?: unknown
36 content: readonly { type: string; text?: unknown }[]
37}): string[] {
38 if (answer.isError) return []
39 const body = isRecord(answer.structuredContent)
40 ? answer.structuredContent
41 : parse(answer.content.map((b) => (b.type === 'text' && typeof b.text === 'string' ? b.text : '')).join(''))
42 const listed = body && Array.isArray(body.cards) ? body.cards.filter(isRecord) : []
43 return listed.flatMap((c) => {
44 if (typeof c.id !== 'string') return []
45 const status = typeof c.status === 'string' ? c.status : undefined
46 const title = typeof c.title === 'string' && c.title ? ` · “${c.title}”` : ''
47 return [status ? `${c.id} (${status}${title})` : c.id]
48 })
49}
50
51/** `witness: GN-07 (done · “…”) and GN-41 (investigating · “…”) cover src/cart/total.ts.` */
52export function note(found: readonly string[], path: string): string | undefined {
53 if (found.length === 0) return undefined
54 const named = found.length < 2 ? found[0] : `${found.slice(0, -1).join(', ')} and ${found.at(-1)}`
55 return `witness: ${named} ${found.length === 1 ? 'covers' : 'cover'} ${path}.`
56}
57hooks/done.ts 87 lines1import { isRecord, type WitnessCall } from './calls'
2
3export const WATCHED = 'I watched it work'
4export const NOT_YET = 'Not yet'
5export const HEADER = 'witness'
6
7/** One card a write would close in a person's name: its id (or quoted title) and that name. */
8export type DoneItem = { name: string; verifiedBy: string }
9
10function named(v: unknown): v is string {
11 return typeof v === 'string' && v.trim() !== ''
12}
13
14/**
15 * A Done in a person's name: `status: done` with `verifiedBy`. The server checks the
16 * witness only on a status change, so a `verifiedBy` without a status is a stored name,
17 * not a Done.
18 */
19function putsDone(change: Record<string, unknown>): boolean {
20 return change.status === 'done' && named(change.verifiedBy)
21}
22
23/** What a Done write closes, for the question; undefined when the call closes nothing. */
24export function doneWrite(call: WitnessCall): DoneItem[] | undefined {
25 const { op, input } = call
26 const item = (c: Record<string, unknown>, name: string): DoneItem => ({ name, verifiedBy: String(c.verifiedBy).trim() })
27 if (op === 'update_card') {
28 return putsDone(input) ? [item(input, String(input.id ?? '').toUpperCase())] : undefined
29 }
30 if (op === 'update_cards') {
31 const cards = Array.isArray(input.cards) ? input.cards.filter(isRecord) : []
32 const items = cards.filter(putsDone).map((c) => item(c, String(c.id ?? '').toUpperCase()))
33 return items.length ? items : undefined
34 }
35 if (op === 'create_card') {
36 return putsDone(input) ? [item(input, `“${String(input.title ?? '').trim()}”`)] : undefined
37 }
38 return undefined
39}
40
41export function list(items: readonly string[]): string {
42 return items.length < 2 ? (items[0] ?? '') : `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`
43}
44
45/**
46 * The question, carrying the name the write carries:
47 * `GN-412 goes to Done, verified by Robin. Did you watch it work?`
48 */
49export function question(call: WitnessCall, items: readonly DoneItem[]): string {
50 if (call.op === 'create_card') {
51 return `${items[0]!.name} is filed Done, verified by ${items[0]!.verifiedBy}. Did you watch it work?`
52 }
53 if (items.length === 1) {
54 return `${items[0]!.name} goes to Done, verified by ${items[0]!.verifiedBy}. Did you watch it work?`
55 }
56 const names = new Set(items.map((i) => i.verifiedBy))
57 if (names.size === 1) {
58 return `${list(items.map((i) => i.name))} go to Done, verified by ${items[0]!.verifiedBy}. Did you watch them work?`
59 }
60 return `${list(items.map((i) => `${i.name} (${i.verifiedBy})`))} go to Done. Did you watch them work?`
61}
62
63/**
64 * The refusal the agent reads: `DONE_NEEDS_WITNESS` (`src/contract.ts`) one step earlier
65 * and from the person. `said` is true when the person answered Not yet, false when they
66 * closed the dialog or typed something else. A refusal, not a rewrite, so the agent knows
67 * why and makes the `verify` write itself.
68 */
69export function refusal(call: WitnessCall, items: readonly DoneItem[], said: boolean): string {
70 const ids = items.map((i) => i.name)
71 const it = ids.length === 1 ? 'it' : 'those'
72 const who = (what: string) =>
73 said ? `The person says they have not watched ${what} work` : `The person did not confirm they watched ${what} work`
74 if (call.op === 'create_card') {
75 return `${who('it')}. Put what you ran in \`verification\` and file it with \`status: verify\`; a person takes it from there.`
76 }
77 if (call.op === 'update_cards') {
78 const all = Array.isArray(call.input.cards) ? call.input.cards.length : ids.length
79 const rest = all > ids.length ? ', and resend the other cards as they were' : ''
80 return (
81 `${who(list(ids).replace(/ and ([^ ]+)$/, ' or $1'))}, so nothing in this call was written. ` +
82 `Put what you ran in \`verification\` and send \`status: verify\` for ${it}${rest}; a person takes it from there.`
83 )
84 }
85 return `${who('it')}. Put what you ran in \`verification\` and send \`status: verify\`; a person takes it from there.`
86}
87hooks/glyphs.ts 96 lines1import { statusLabel } from './labels'
2
3/**
4 * The page's status ring (`ui/src/glyphs.tsx`) in one terminal cell. Retired keys take the
5 * shape of what they meant, as the page draws them. Done and By design share the full
6 * cell: a one-cell ring cannot hold a mark inside a fill, and the page says which.
7 */
8const SHAPE_OF_RETIRED: Readonly<Record<string, string>> = { open: 'investigating', fixed: 'done', inbox: 'triage' }
9
10const CELLS: Readonly<Record<string, string>> = {
11 triage: '⊙',
12 investigating: '○',
13 decision: '◌',
14 ready: '◐',
15 verify: '◕',
16 done: '●',
17 byDesign: '●',
18 parked: '⊖',
19 blocked: '⊗',
20 cancelled: '⊘',
21}
22
23/**
24 * The four statuses that owe someone a move carry the terminal's own ANSI colour, so
25 * the person's theme decides the shade; the rest are the default ink.
26 */
27const TERMINAL_COLOURS: Readonly<Record<string, string>> = {
28 decision: 'yellow',
29 ready: 'blue',
30 verify: 'magenta',
31 blocked: 'red',
32}
33
34export function shapeOf(status: string): string {
35 return SHAPE_OF_RETIRED[status] ?? status
36}
37
38export function glyphCell(status: string): string {
39 return CELLS[shapeOf(status)] ?? '○'
40}
41
42export function glyphColour(status: string): string | undefined {
43 return TERMINAL_COLOURS[shapeOf(status)]
44}
45
46/**
47 * The page's hues (`ui/src/tokens.css`, `ui/src/project.css` `.glyph--*`), dark then light,
48 * and the page colour the Done check is cut out of.
49 */
50const HUES: Readonly<Record<string, readonly [string, string]>> = {
51 triage: ['#9b958a', '#8a847a'],
52 investigating: ['#9b958a', '#8a847a'],
53 decision: ['#dba23f', '#ac710d'],
54 ready: ['#6ea6e4', '#24619f'],
55 verify: ['#ae8cec', '#6e43be'],
56 done: ['#918b81', '#6d685f'],
57 byDesign: ['#9b958a', '#8a847a'],
58 parked: ['#9b958a', '#8a847a'],
59 blocked: ['#e8796d', '#ba392e'],
60 cancelled: ['#878581', '#6b6864'],
61}
62const PAGE: readonly [string, string] = ['#191918', '#fbfbfa']
63
64/** The page's own glyph as an SVG document, 16-unit box, both themes by the reader's scheme. */
65export function glyphSvg(status: string): string {
66 const shape = shapeOf(status)
67 const [dark, light] = HUES[shape] ?? HUES.investigating!
68 const style =
69 `<style>.g{color:${dark}}.p{stroke:${PAGE[0]}}` +
70 `@media (prefers-color-scheme: light){.g{color:${light}}.p{stroke:${PAGE[1]}}}</style>`
71 let body: string
72 if (shape === 'done' || shape === 'byDesign') {
73 const mark =
74 shape === 'done'
75 ? '<path class="p" d="M4.7 8.3 6.9 10.5 11.3 5.9" fill="none" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>'
76 : '<path class="p" d="M5.2 8h5.6" stroke-width="1.8" stroke-linecap="round"/>'
77 body = `<circle cx="8" cy="8" r="7" fill="currentColor"/>${mark}`
78 } else {
79 const dash = shape === 'decision' ? ' stroke-dasharray="2.4 2.3"' : shape === 'triage' ? ' stroke-dasharray="1.1 2.4"' : ''
80 const ring = `<circle cx="8" cy="8" r="6.1" fill="none" stroke="currentColor" stroke-width="1.7"${dash}/>`
81 const inner: Record<string, string> = {
82 ready: '<path d="M8 1.8a6.2 6.2 0 0 1 0 12.4z" fill="currentColor"/>',
83 verify: '<path d="M8 8V1.8A6.2 6.2 0 1 1 1.8 8z" fill="currentColor"/>',
84 parked: '<path d="M5.2 8h5.6" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/>',
85 triage: '<circle cx="8" cy="8" r="1.7" fill="currentColor"/>',
86 cancelled: '<path d="M4.6 11.4 11.4 4.6" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/>',
87 blocked: '<path d="M5.8 5.8 10.2 10.2 M10.2 5.8 5.8 10.2" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/>',
88 }
89 body = ring + (inner[shape] ?? '')
90 }
91 return (
92 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="14" height="14">` +
93 `<title>${statusLabel(status)}</title>${style}<g class="g">${body}</g></svg>`
94 )
95}
96hooks/labels.ts 26 lines1/**
2 * The page's names for the statuses, retired keys included: a copy of `STATUSES` and
3 * `RETIRED_STATUSES` in witness's `src/vocabulary.ts`, which the plugin cannot import.
4 * `test/plugin-labels.test.ts` in the witness repository holds the two together.
5 * The status line alone uses it: a person reads that line; the agent reads keys.
6 */
7export const STATUS_LABELS: Readonly<Record<string, string>> = {
8 triage: 'Triage',
9 investigating: 'Investigating',
10 decision: 'Awaiting decision',
11 ready: 'Ready to build',
12 blocked: 'Blocked',
13 verify: 'Needs verification',
14 done: 'Done',
15 byDesign: 'By design',
16 parked: 'Parked',
17 cancelled: 'Cancelled',
18 open: 'Open',
19 fixed: 'Fixed',
20 inbox: 'Inbox',
21}
22
23export function statusLabel(key: string): string {
24 return STATUS_LABELS[key] ?? key
25}
26hooks/rail.ts 264 lines1import type { WitnessArtifact, WitnessHeldCard, WitnessPullRequest, WitnessTree } from '../types'
2import { isRecord } from './calls'
3import { glyphCell } from './glyphs'
4
5/** The pane's id and title, which `/witness` names too. */
6export const RAIL = 'witness'
7/** The app's rail width in characters at its type size: what the dock asks for. */
8export const RAIL_COLUMNS = 44
9/** The rows the pane asks for inline: all four sections at their usual size. */
10export const RAIL_MAX_ROWS = 14
11
12export const NOT_CONNECTED = 'witness is not connected. Sign in with /mcp.'
13/** Two rows, said only when the rail has nothing else to draw. */
14export const NO_CARD = ['No card yet.', "The agent's first card appears here."] as const
15
16/** Where a project's page is kept: by server and project id, so two servers that hold the same id never cross-link. */
17export function pageKey(server: string, projectId: string): string {
18 return `${server}\u0000${projectId}`
19}
20
21/** `<page>#/cards/<id>` (`ui/src/router.ts`), with the page `agent_md` named for the card's server and project; undefined before it has. */
22export function cardUrl(
23 card: Pick<WitnessHeldCard, 'server' | 'projectId' | 'id'>,
24 pages: Readonly<Record<string, string>>,
25): string | undefined {
26 const page = pages[pageKey(card.server, card.projectId)]
27 return page ? `${page}#/cards/${encodeURIComponent(card.id)}` : undefined
28}
29
30/**
31 * The cells one character takes in a monospace terminal: none for a combining mark or a
32 * joiner, two for East Asian wide and fullwidth forms and for emoji, else one. The engine
33 * offers no measure of its own.
34 */
35function cellsOf(ch: string): number {
36 const cp = ch.codePointAt(0) ?? 0
37 if (/^[\p{Mn}\p{Me}\u200b-\u200f\ufe00-\ufe0f]$/u.test(ch)) return 0
38 if (/^\p{Extended_Pictographic}$/u.test(ch) && cp > 0x2bff) return 2
39 if (
40 (cp >= 0x1100 && cp <= 0x115f) ||
41 (cp >= 0x2e80 && cp <= 0x303e) ||
42 (cp >= 0x3041 && cp <= 0x33ff) ||
43 (cp >= 0x3400 && cp <= 0x4dbf) ||
44 (cp >= 0x4e00 && cp <= 0x9fff) ||
45 (cp >= 0xa000 && cp <= 0xa4cf) ||
46 (cp >= 0xac00 && cp <= 0xd7a3) ||
47 (cp >= 0xf900 && cp <= 0xfaff) ||
48 (cp >= 0xfe30 && cp <= 0xfe4f) ||
49 (cp >= 0xff00 && cp <= 0xff60) ||
50 (cp >= 0xffe0 && cp <= 0xffe6) ||
51 (cp >= 0x1f300 && cp <= 0x1faff) ||
52 (cp >= 0x20000 && cp <= 0x3fffd)
53 ) {
54 return 2
55 }
56 return 1
57}
58
59/** The cells a string takes. */
60export function cells(text: string): number {
61 let n = 0
62 for (const ch of text) n += cellsOf(ch)
63 return n
64}
65
66/** Cuts to `width` cells, `…` the last cell when cut; a wide character never straddles the edge. */
67export function cut(text: string, width: number): string {
68 const flat = text.replace(/\s+/g, ' ').trim()
69 if (width <= 0) return ''
70 if (cells(flat) <= width) return flat
71 let out = ''
72 let used = 0
73 for (const ch of flat) {
74 const w = cellsOf(ch)
75 if (used + w > width - 1) break
76 out += ch
77 used += w
78 }
79 return `${out}…`
80}
81
82/** One row of the rail, as every surface draws it. */
83export type RailRow =
84 | { kind: 'header'; text: string }
85 | { kind: 'gap' }
86 | { kind: 'note'; text: string }
87 | { kind: 'card'; card: WitnessHeldCard; href?: string }
88 | { kind: 'more'; count: number }
89 | { kind: 'link'; text: string; href: string; dim?: boolean }
90 | { kind: 'about'; text: string }
91
92export type RailFacts = {
93 cards: readonly WitnessHeldCard[]
94 artifacts: readonly WitnessArtifact[]
95 tree: WitnessTree
96 /** Per `pageKey`, the project's page address, as `agent_md` named it: the card rows' links. */
97 pages: Readonly<Record<string, string>>
98 /** Whether a witness server is connected: only the empty state reads it. */
99 isConnected: boolean
100}
101
102/** How many of CARDS and ARTIFACTS to show before `n more`; absent shows all. */
103export type Fold = { cards?: number; artifacts?: number }
104
105/**
106 * The rail's rows: PULL REQUEST, CARD or CARDS, ARTIFACT or ARTIFACTS, ABOUT, a section
107 * with nothing in it not drawn, one gap between sections. Not connected is said where
108 * CARDS would stand, whatever else is drawn; connected with no card yet says so only
109 * when there is nothing else to draw.
110 */
111export function railRows(facts: RailFacts, fold: Fold = {}): RailRow[] {
112 const sections: RailRow[][] = []
113 const pr = facts.tree.pullRequest
114 if (pr) {
115 sections.push([
116 { kind: 'header', text: 'PULL REQUEST' },
117 { kind: 'link', text: pr.title, href: pr.url },
118 { kind: 'link', text: `#${pr.number} · ${pr.state}`, href: pr.url, dim: true },
119 ])
120 }
121 const folded = <T,>(list: readonly T[], shown: number | undefined, row: (x: T) => RailRow): RailRow[] =>
122 shown === undefined || shown >= list.length
123 ? list.map(row)
124 : [...list.slice(0, shown).map(row), { kind: 'more', count: list.length - shown }]
125 if (facts.cards.length) {
126 sections.push([
127 { kind: 'header', text: facts.cards.length === 1 ? 'CARD' : 'CARDS' },
128 ...folded(facts.cards, fold.cards, (card): RailRow => {
129 const href = cardUrl(card, facts.pages)
130 return href ? { kind: 'card', card, href } : { kind: 'card', card }
131 }),
132 ])
133 } else if (!facts.isConnected) {
134 sections.push([{ kind: 'note', text: NOT_CONNECTED }])
135 }
136 if (facts.artifacts.length) {
137 sections.push([
138 { kind: 'header', text: facts.artifacts.length === 1 ? 'ARTIFACT' : 'ARTIFACTS' },
139 ...folded(facts.artifacts, fold.artifacts, (a): RailRow => ({ kind: 'link', text: a.title, href: a.url })),
140 ])
141 }
142 const about: RailRow[] = []
143 if (facts.tree.branch) about.push({ kind: 'about', text: facts.tree.branch })
144 if (facts.tree.repository) {
145 about.push({ kind: 'about', text: pr?.base ? `${facts.tree.repository} from ${pr.base}` : facts.tree.repository })
146 }
147 if (about.length) sections.push([{ kind: 'header', text: 'ABOUT' }, ...about])
148 if (sections.length === 0) sections.push(NO_CARD.map((text): RailRow => ({ kind: 'note', text })))
149 return sections.flatMap((rows, i) => (i === 0 ? rows : [{ kind: 'gap' } as RailRow, ...rows]))
150}
151
152/**
153 * The fold that fits `budget` rows: CARDS first, to as many rows as fit (at least one)
154 * and `n more`, then ARTIFACTS the same way; PULL REQUEST and ABOUT never fold. A fold
155 * that saves no row is not made (hiding one row behind `1 more` saves nothing). What
156 * still overflows with both at their floor is returned as it is.
157 */
158export function fitRows(facts: RailFacts, budget: number): RailRow[] {
159 const fold: Fold = {}
160 let rows = railRows(facts)
161 for (const [key, list] of [
162 ['cards', facts.cards],
163 ['artifacts', facts.artifacts],
164 ] as const) {
165 const over = rows.length - budget
166 if (over <= 0) break
167 const shown = Math.max(1, list.length - 1 - over)
168 if (list.length - shown < 2) continue
169 fold[key] = shown
170 rows = railRows(facts, fold)
171 }
172 return rows
173}
174
175/** The rows the pane asks for inline: its lines with the folds that fit fourteen, which may be more. */
176export function inlineRows(facts: RailFacts): number {
177 return Math.max(1, fitRows(facts, RAIL_MAX_ROWS).length)
178}
179
180/**
181 * The cells the engine draws before `hint` on the same row and does not hand over: the
182 * mode pill (`⏸ manual mode on · `, `⏵⏵ accept edits on `), seen in a real terminal at
183 * 19 cells and in neither `PromptHint`'s `hint` nor `SessionMode`'s `modes`. Reserved
184 * whether or not a pill is drawn, so a tail is never cut by one.
185 */
186export const MODE_PILL_CELLS = 19
187
188/** The cells the engine keeps clear at the row's end: it cuts a hint line at `columns - 3`. */
189const ROW_END_CELLS = 3
190
191/**
192 * The tail of the dim hint line while cards are held and the rail is not placed:
193 * `◕ GN-304 · ○ GN-433`, and ` · /witness` until the rail has been shown once. The
194 * engine joins a tail to its own line with ` · ` (seen in a real terminal), so the tail
195 * carries no separator of its own. Fitted, never cut: whole parts go from the end,
196 * `/witness` first and then the last card, until the two-cell indent, a mode pill, the
197 * engine's line, the joining ` · ` and the tail fit the row the engine draws (three cells
198 * short of the terminal) with one cell to spare.
199 */
200export function hintTail(
201 cards: readonly WitnessHeldCard[],
202 shown: boolean,
203 hint = '',
204 columns = Number.POSITIVE_INFINITY,
205): string | undefined {
206 const parts = cards.map((c) => `${glyphCell(c.status)} ${c.id}`)
207 if (!shown && parts.length) parts.push('/witness')
208 const room = columns - ROW_END_CELLS - 1 - 2 - MODE_PILL_CELLS - cells(hint) - 3
209 while (parts.length && cells(parts.join(' · ')) > room) parts.pop()
210 return parts.length ? parts.join(' · ') : undefined
211}
212
213/**
214 * `gh pr view --json number,title,url,state,isDraft,baseRefName`, as a pull request.
215 * `state` is gh's `OPEN`, `CLOSED` or `MERGED`; an open draft reads `Draft`.
216 */
217export function parsePullRequest(stdout: string): WitnessPullRequest | null {
218 let v: unknown
219 try {
220 v = JSON.parse(stdout)
221 } catch {
222 return null
223 }
224 if (!isRecord(v) || typeof v.number !== 'number' || typeof v.url !== 'string') return null
225 const raw = typeof v.state === 'string' ? v.state.toUpperCase() : ''
226 const state =
227 raw === 'MERGED' ? 'Merged' : raw === 'CLOSED' ? 'Closed' : v.isDraft === true ? 'Draft' : 'Open'
228 return {
229 number: v.number,
230 title: typeof v.title === 'string' ? v.title : '',
231 url: v.url,
232 state,
233 base: typeof v.baseRefName === 'string' ? v.baseRefName : '',
234 }
235}
236
237/** Whether a Bash command may have changed the pull request: `gh pr …` or `git push`. */
238export function touchesPullRequest(command: string): boolean {
239 return /\bgh\s+pr\b/.test(command) || /\bgit\b[^|;&\n]*\spush\b/.test(command)
240}
241
242/**
243 * The artifact a successful Artifact call published, from its result: a publish (the
244 * default action) or a create from a type, never an asset upload, a read or a listing.
245 */
246export function publishedArtifact(input: Record<string, unknown>, result: unknown): WitnessArtifact | undefined {
247 const action = input.action ?? 'publish'
248 if (action !== 'publish' || input.asset === true || !isRecord(result)) return undefined
249 const url = result.url
250 if (typeof url !== 'string' || !/^https:\/\//.test(url)) return undefined
251 const path = typeof result.path === 'string' ? result.path : typeof input.file_path === 'string' ? input.file_path : ''
252 const title =
253 (typeof result.title === 'string' && result.title) ||
254 (typeof input.title === 'string' && input.title) ||
255 path.split('/').pop() ||
256 url
257 return { url, title }
258}
259
260/** Adds an artifact at the end, or retitles the one at that URL in place. */
261export function withArtifact(list: readonly WitnessArtifact[], a: WitnessArtifact): WitnessArtifact[] {
262 return list.some((x) => x.url === a.url) ? list.map((x) => (x.url === a.url ? a : x)) : [...list, a]
263}
264types/index.d.ts 78 lines1/**
2 * What the witness mod keeps for the session in `$.state`. Nothing here is read by the
3 * server, the page or the Mac app: it is this session's own memory of its own calls and
4 * of its own working tree.
5 */
6
7/** A card this session holds a claim on or wrote, as the server last answered it. */
8export type WitnessHeldCard = {
9 id: string
10 projectId: string
11 status: string
12 title: string
13 /** The MCP server the answer came through, as tool names spell it: its page is the one the link takes. */
14 server: string
15}
16
17/** The last read proof a call to a project carried, and the server that took it. */
18export type WitnessProof = {
19 server: string
20 readProof: string
21}
22
23/** One artifact this session published, from the Artifact tool's own result. */
24export type WitnessArtifact = {
25 url: string
26 title: string
27}
28
29/** The pull request for the session's branch, as `gh pr view` answered. */
30export type WitnessPullRequest = {
31 number: number
32 title: string
33 url: string
34 /** `Draft`, `Open`, `Merged` or `Closed`. */
35 state: string
36 base: string
37}
38
39/** The session's working tree: its branch, its repository's name and its pull request. */
40export type WitnessTree = {
41 branch: string | null
42 repository: string | null
43 pullRequest: WitnessPullRequest | null
44}
45
46/** The rail's history in this session, which the open rules read. */
47export type WitnessRailHistory = {
48 /** The rail has been placed at least once (the hint tail drops its ` · /witness`). */
49 shown: boolean
50 /** The one unasked open this session has been spent. */
51 autoOpened: boolean
52 /** The person closed it; nothing reopens it unasked. */
53 closedByPerson: boolean
54}
55
56declare module 'claude-code' {
57 interface PluginState {
58 witness: {
59 /** The session's cards, in the order it first took or wrote them. */
60 cards: WitnessHeldCard[]
61 /** Per project id, the proof and server of the session's last call that landed there. */
62 proofs: Record<string, WitnessProof>
63 /**
64 * Per server and project id (`pageKey`), the project's page address, from the
65 * `resource_link` named `page` that `agent_md` answers with: what a card row links to.
66 */
67 pages: Record<string, string>
68 /** Repository-relative paths already asked about by `covers`, by the mod or by the agent. */
69 asked: string[]
70 /** The artifacts this session published, oldest first. */
71 artifacts: WitnessArtifact[]
72 /** The working tree, read at the moments the rail names. */
73 tree: WitnessTree
74 rail: WitnessRailHistory
75 }
76 }
77}
78