SLOPSHOPPER

witness

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…

newpanespinnerguardcommandprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · witness
│ ┃ witness ✕ › fix the failing auth test and add an audit log call │ ┃ witness is not connected. Sign in with │ ┃ /mcp. ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ABOUT ⏺ Update(src/auth.ts) │ ┃ feat/auth-refresh ⎿ Added 2 lines, removed 1 line │ ┃ app ⏺ 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 │ │ › /witness │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · witness
witness is not connected. Sign in with /mcp. ABOUT feat/auth-refresh app
README

witness for Claude Code

Claude Code works your witness project as it goes, and nothing goes to Done in your name until you say you watched it work.

Install

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.

What it does

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 the plugin does on your machine and network

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:

  • witness tools: input and result, for the cards the session holds (id, title, status), each project's read proof, and its page address. A write that puts a card Done with verifiedBy waits for your answer.
  • Bash: the command's text, only to see whether it holds gh pr or git push.
  • Artifact: a publish's result, for its URL and title.
  • Edit, Write, MultiEdit and NotebookEdit: the file's path, for the covers check. A covering card is added as a note to the edit's result, which waits at most two seconds

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.

Good to know

  • The rail opens by itself once, when the session takes its first card, in Claude Code's fullscreen layout. Elsewhere it waits for /witness, and the line under the prompt lists the cards meanwhile: ◕ GN-304 · ○ GN-433 · /witness.
  • In claude -p, or anywhere nobody can be asked, a Done passes through to witness as it would without the plugin.
  • If you added witness with claude mcp add before, remove it (claude mcp remove witness). The plugin brings its own, and with both the agent sees every tool twice.

witness.nu · docs

Source 9 files
hooks/register.tsx 628 lines
1import 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}
628
hooks/calls.ts 94 lines
1import 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 }
94
hooks/cards.ts 57 lines
1import 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}
57
hooks/covers.ts 57 lines
1import { 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}
57
hooks/done.ts 87 lines
1import { 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}
87
hooks/glyphs.ts 96 lines
1import { 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}
96
hooks/labels.ts 26 lines
1/**
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}
26
hooks/rail.ts 264 lines
1import 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}
264
types/index.d.ts 78 lines
1/**
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