A live view of Claude's work: the goal, a ribbon of what it spent its time on, what it is doing now, and each step marked proven, unproven, stale or flagged.

A live view of Claude's work, and proof of it. Tested with Claude Code 2.1.287, terminal and desktop.
<!-- the GIF from Task 11 goes here -->
Other mods show what Claude is doing. witness also marks each step of Claude's task list with whether a test, build or typecheck proved it, and keeps that mark honest as the session goes on: an edit after the proof makes it stale, and a lowered bar that goalpost-guard let through means the step is never counted as proven.
A pane, top to bottom:
/goal in this session. Only your own prompts count: a task notification or a message from another session never becomes the goal.| Phase | When |
|---|---|
| explore | reads, searches and read-only shell commands (rg, grep, ls, cat, find, git log, git diff, git status…) |
| implement | Edit, Write, NotebookEdit |
| debug | an edit after a test, build, lint or typecheck failed, until one passes again |
| verify | a test, build, lint or typecheck run, as emcc-core classifies the command |
| waiting | the turn ended and it is your move |
Any other tool counts toward the phase in progress.
edits src/cart.js +1 −1, runs npm test), with a dot that pulses once a second. Now idle between actions./goal set some (see below).TaskCreate and TaskUpdate calls, each marked:| Mark | Means |
|---|---|
✓ | Proven: a test, build or typecheck passed since the step began, with no edit after it to the step's files (to any file, for a step that edited none). Shows which command and how long ago (npm test passed 3m ago) |
○ | No proof: nothing passed since the step began |
◌ | Proven before N edits: those edits came after its last passing check |
▲ | A lowered bar was flagged while the step was in progress (goalpost-guard). It never counts as proven, even if checks pass later. Shows the flag's text |
· | Pending, or in progress (in bold) |
A step's edits are the ones made while it was in progress; with several in progress, the one started last. Marks are worked out each time the pane draws, from emcc-core's evidence, never stored. Without emcc-core the pane still shows the goal, the ribbon, Now and the steps, and says install emcc-core to see proof where the marks would be.
witness 2/4 proven, /live, or, without emcc-core (or with its profile off), as its own line witness 2/4 proven with a Live button. Criteria count too, proven or ticked./plugin marketplace add Xabilimon1/emcc-claude-code-mods
/plugin install witness@emcc
/plugin install goalpost-guard@emcc
witness needs emcc-core, which keeps the evidence it marks steps with; installing witness installs it. goalpost-guard is optional: with it, a lowered bar shows as ▲.
/live opens the pane, at any width. /live pause stops watching for this session, and /live resume starts again; the ribbon picks up from the resume, so paused time belongs to no phase./goal is Claude Code's own command; witness does not register one. It reads what you type after it in this session and lets the built-in run exactly as it would without witness, so the answer you see is the built-in's./goal <goal>; done when <criterion>; <criterion> sets the goal and its criteria in witness (hecho cuando works too). A criterion with a command in backticks is proven when that command passes with no edit after it; it shows ✗ if its newest run failed, ◌ after an edit, and ▲ if a lowered bar was flagged after /goal and before the run that passed. A criterion without a command is ticked by you, with its number key./goal done when <criterion> sets criteria only, and the goal keeps following your prompt./goal alone changes nothing in witness (it is the built-in's to answer).p pauses and resumes, 1 to 9 tick or untick the criteria without a command, Esc closes.session.start, prompt.submit, tool.call (every tool, before and after it runs, never changing it), turn.complete, command.run (live, and goal to read the built-in's, which always runs on), ui.close (its pane), ui.render (the witness pane, AbovePrompt)$.clock.now, $.clock.every, $.session.cwd, $.command.register, $.ui.open, $.ui.panes, $.ui.resolve, $.state.get, $.state.set, $.emcc.evidenceSince, $.emcc.flagsSince, $.emcc.kindsOf, $.emcc.badge, $.emcc.profile, $.emcc.isHaltedclaude plugin test mods/witness
116 tests: goal parsing (8), phases (14), marks (15), watching a session (45), and the pane, the band and the pulse on both terminal and desktop (34). Each one was seen failing before it passed.
hooks/register.tsx 943 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { WitnessCriterion, WitnessPhase, WitnessSegment, WitnessStep } from '../types'
5import { collapsed, goalFromPrompt, parseGoal } from './goal'
6import { criterionMarkOf, markOf } from './marks'
7import type { Check, CriterionMark, Flag, Mark } from './marks'
8import { addedTotal, appended, durationText, phaseOf, ribbonOf } from './phases'
9
10const PANE = 'witness'
11const KEEP_EDITS = 500
12const KEEP_RUNS = 100
13const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
14/** The calls after which a mark can change, so the badge is worth redrawing. */
15const MARKING_TOOLS = new Set([...EDIT_TOOLS, 'Bash', 'TaskCreate', 'TaskUpdate'])
16/** Where a prompt the person sent comes from; anything else (a notification, a peer, a schedule) is not their goal. */
17const PERSON_ORIGINS = new Set(['composer', 'bridge', 'sdk'])
18
19// Anthropic's palette, one colour per phase (the pane draws with them).
20const SKY = '#6a9bcc'
21const OLIVE = '#788c5d'
22const CLAY = '#d97757'
23const LIGHT = '#e8e6dc'
24const STONE = '#b0aea5'
25const PHASES: readonly WitnessPhase[] = ['explore', 'implement', 'debug', 'verify', 'waiting']
26const PHASE_COLOR: Record<WitnessPhase, string> = { explore: SKY, implement: OLIVE, debug: CLAY, verify: LIGHT, waiting: STONE }
27
28// ---------------------------------------------------------------- state
29
30const goal = atom({ plugin: 'witness', key: 'goal' } as const, '')
31const promptGoal = atom({ plugin: 'witness', key: 'promptGoal' } as const, '')
32const isGoalSet = atom({ plugin: 'witness', key: 'isGoalSet' } as const, false)
33const goalSetAt = atom({ plugin: 'witness', key: 'goalSetAt' } as const, null)
34const criteria = atom({ plugin: 'witness', key: 'criteria' } as const, [])
35const steps = atom({ plugin: 'witness', key: 'steps' } as const, [])
36const edits = atom({ plugin: 'witness', key: 'edits' } as const, [])
37const runs = atom({ plugin: 'witness', key: 'runs' } as const, [])
38const segments = atom({ plugin: 'witness', key: 'segments' } as const, [])
39const totals = atom({ plugin: 'witness', key: 'totals' } as const, {})
40const cursor = atom({ plugin: 'witness', key: 'cursor' } as const, null)
41const waitingSince = atom({ plugin: 'witness', key: 'waitingSince' } as const, null)
42const isDebugging = atom({ plugin: 'witness', key: 'isDebugging' } as const, false)
43const now = atom({ plugin: 'witness', key: 'now' } as const, null)
44const beat = atom({ plugin: 'witness', key: 'beat' } as const, false)
45const hasOpened = atom({ plugin: 'witness', key: 'hasOpened' } as const, false)
46const isPlaced = atom({ plugin: 'witness', key: 'isPlaced' } as const, false)
47const isPaused = atom({ plugin: 'witness', key: 'isPaused' } as const, false)
48const lostAt = atom({ plugin: 'witness', key: 'lostAt' } as const, null)
49
50/** A tool call's input, as much of it as witness reads. */
51type ToolCall = {
52 tool: string
53 command?: string
54 file_path?: string
55 notebook_path?: string
56 old_string?: string
57 new_string?: string
58 subject?: string
59 taskId?: string
60 status?: string
61}
62
63/** A tool call's result, as much of it as witness reads. */
64type Ran = { deny?: string; isError?: boolean; text?: string; result?: unknown }
65
66let beatTimer: Timer | null = null
67/** Tool calls started and not yet finished: Now and the beat end with the last. */
68let inFlight = 0
69/** The ribbon's reads and writes, one stretch at a time, so calls in parallel never share a cursor. */
70let queue: Promise<unknown> = Promise.resolve()
71/** The badge last written, so the same one is not written again after every call. */
72let lastBadge = ''
73/** Counts the queue's steps; each writes it, see `queued`. */
74let ticks = 0
75
76function serially<T>(work: () => Promise<T>): Promise<T> {
77 const run = queue.then(work)
78 queue = run.catch(() => undefined)
79
80 return run
81}
82
83/**
84 * Queues `work` without waiting for it: a hook hands its event on at once, and
85 * a failure in the work is recorded as `lostAt`. witness never holds a tool
86 * call, a prompt or a turn end on its own bookkeeping.
87 */
88function queued($: EngineInterface, where: string, work: () => Promise<void>): void {
89 serially(async () => {
90 // A dispatch's reads are pinned to one moment (documented), and in this
91 // engine a write refreshes them (observed, not documented; pinned by the
92 // test "a pause takes effect at once"). This step may run long after its
93 // hook began, so it writes first to see what the steps before it wrote.
94 ticks += 1
95 await $.state.set({ plugin: 'witness', key: 'queueTick' }, ticks)
96 await work()
97 }).catch(() => lose($, where))
98}
99
100/** The time now, asked at once and awaited later in the queue; a failure surfaces where it is awaited. */
101function timeNow($: EngineInterface): Promise<number> {
102 const asked = $.clock.now()
103 asked.catch(() => undefined)
104
105 return asked
106}
107
108async function lose($: EngineInterface, where: string): Promise<void> {
109 try {
110 await update($, lostAt, () => where)
111 } catch {
112 // Nothing left to record with.
113 }
114}
115
116/** Whether witness is watching; a state it cannot read counts as paused, so the call goes on untouched. */
117async function isWatching($: EngineInterface, where: string): Promise<boolean> {
118 try {
119 return !(await read($, isPaused))
120 } catch {
121 await lose($, where)
122
123 return false
124 }
125}
126
127// ---------------------------------------------------------------- labels
128
129function relative(path: string, cwd: string): string {
130 return cwd !== '' && path.startsWith(`${cwd}/`) ? path.slice(cwd.length + 1) : path
131}
132
133function lineCount(text: string | undefined): number {
134 return text === undefined || text === '' ? 0 : text.replace(/\n$/, '').split('\n').length
135}
136
137/** What Now says of a call; `cwd` is '' when the engine could not tell it, and paths then show whole. */
138function labelOf(call: ToolCall, cwd: string): string {
139 const file = call.file_path ?? call.notebook_path
140 if (call.tool === 'Edit' && file !== undefined) {
141 return `edits ${relative(file, cwd)} +${lineCount(call.new_string)} −${lineCount(call.old_string)}`
142 }
143 if (call.tool === 'Write' && file !== undefined) {
144 return `writes ${relative(file, cwd)}`
145 }
146 if (call.tool === 'Read' && file !== undefined) {
147 return `reads ${relative(file, cwd)}`
148 }
149 if (call.tool === 'Bash' && call.command !== undefined) {
150 const shown = collapsed(call.command)
151
152 return `runs ${shown.length > 60 ? `${shown.slice(0, 59)}…` : shown}`
153 }
154 if (call.tool === 'TaskCreate' || call.tool === 'TaskUpdate') {
155 return 'plans'
156 }
157
158 return `uses ${call.tool}`
159}
160
161// ---------------------------------------------------------------- emcc-core
162
163/** emcc-core's checks and flags, or null when it is not installed. */
164async function emccFacts($: EngineInterface): Promise<{ checks: Check[]; flags: Flag[] } | null> {
165 try {
166 const evidence = await $.emcc.evidenceSince({ at: 0 })
167 const flags = await $.emcc.flagsSince({ at: 0 })
168
169 return { checks: evidence.filter(item => item.kind !== 'edit'), flags }
170 } catch {
171 return null
172 }
173}
174
175async function isCheck($: EngineInterface, command: string): Promise<boolean> {
176 try {
177 return (await $.emcc.kindsOf({ command })).length > 0
178 } catch {
179 return false
180 }
181}
182
183async function hasEmcc($: EngineInterface): Promise<boolean> {
184 try {
185 await $.emcc.profile()
186
187 return true
188 } catch {
189 return false
190 }
191}
192
193/** Whether emcc-core draws its band (where the badge shows): installed, and not off unless halted, as its own band decides. */
194async function emccDrawsBand($: EngineInterface): Promise<boolean> {
195 try {
196 return (await $.emcc.profile()) !== 'off' || (await $.emcc.isHalted())
197 } catch {
198 return false
199 }
200}
201
202// ---------------------------------------------------------------- proof, badge and pane
203
204/**
205 * What the engine says of the pane: placed or not (a pane opened narrow is
206 * placed once the terminal widens), or null when it cannot be asked.
207 */
208async function panesSay($: EngineInterface): Promise<boolean | null> {
209 try {
210 return (await $.ui.panes()).some(pane => pane.id === PANE && pane.isPlaced)
211 } catch {
212 return null
213 }
214}
215
216/** Whether the pane is on screen: the engine's word, else what witness last knew. */
217async function isPanePlaced($: EngineInterface): Promise<boolean> {
218 return (await panesSay($)) ?? (await read($, isPlaced))
219}
220
221type Counted = { proven: number; total: number }
222
223/** Every step and criterion with its mark, computed now; `hasProof` is false without emcc-core. */
224type Marked = {
225 hasProof: boolean
226 steps: { step: WitnessStep; mark: Mark }[]
227 criteria: { criterion: WitnessCriterion; mark: CriterionMark }[]
228}
229
230async function marksNow($: EngineInterface): Promise<Marked> {
231 const facts = await emccFacts($)
232 const editList = await read($, edits)
233 const runList = await read($, runs)
234 const stepMarks = (await read($, steps)).map(step => ({ step, mark: markOf(step, editList, facts?.checks ?? null, facts?.flags ?? null) }))
235 // A criterion answers only for the flags raised since /goal set it.
236 const setAt = (await read($, goalSetAt)) ?? 0
237 const criterionFlags = facts === null ? null : facts.flags.filter(flag => flag.at >= setAt)
238 const criterionMarks = (await read($, criteria)).map(criterion => ({
239 criterion,
240 mark: criterionMarkOf(criterion, runList, editList, criterionFlags),
241 }))
242
243 return { hasProof: facts !== null, steps: stepMarks, criteria: criterionMarks }
244}
245
246function countOf(marked: Marked): Counted {
247 const proven =
248 marked.steps.filter(one => one.mark.kind === 'proven').length +
249 marked.criteria.filter(one => one.mark.kind === 'proven' || one.mark.kind === 'ticked').length
250
251 return { proven, total: marked.steps.length + marked.criteria.length }
252}
253
254async function provenCount($: EngineInterface): Promise<Counted> {
255 return countOf(await marksNow($))
256}
257
258async function summaryText($: EngineInterface): Promise<string> {
259 const counted = await provenCount($)
260
261 return `Goal: ${(await read($, goal)) || 'none yet'}\n${counted.proven}/${counted.total} proven`
262}
263
264/** The EMCC badge: cleared while the pane is placed, the count while it is not; written only when it changed. */
265async function refreshBadge($: EngineInterface): Promise<void> {
266 try {
267 if (!(await hasEmcc($))) {
268 // Without emcc-core the line above the prompt is witness's own.
269 return
270 }
271 let badge: { id: string; text?: string; tone?: 'good' | 'neutral' } = { id: 'witness' }
272 if (!(await isPanePlaced($))) {
273 const counted = await provenCount($)
274 badge = {
275 id: 'witness',
276 text: `witness ${counted.proven}/${counted.total} proven, /live`,
277 tone: counted.total > 0 && counted.proven === counted.total ? 'good' : 'neutral',
278 }
279 }
280 const written = JSON.stringify(badge)
281 if (written === lastBadge) {
282 return
283 }
284 await $.emcc.badge(badge)
285 lastBadge = written
286 } catch {
287 // The badge is a convenience; the pane and /live still answer.
288 }
289}
290
291async function openPane($: EngineInterface, isAsked: boolean): Promise<boolean> {
292 const opened = await $.ui.open(isAsked ? { id: PANE, title: 'witness', focus: true, closeOnEscape: true } : { id: PANE, title: 'witness', closeOnEscape: true })
293 await update($, isPlaced, () => opened.isPlaced)
294
295 return opened.isPlaced
296}
297
298function startBeat($: EngineInterface): void {
299 if (beatTimer !== null) {
300 return
301 }
302 beatTimer = $.clock.every(1000, () => {
303 void update($, beat, value => !value)
304 })
305}
306
307function endBeat(): void {
308 beatTimer?.cancel()
309 beatTimer = null
310}
311
312/**
313 * The pane opened because the person asked (`/live`, the band's Live): the
314 * badge follows, and an action still running pulses again (closing stopped it).
315 */
316async function showPane($: EngineInterface, where: string): Promise<boolean> {
317 const placed = await openPane($, true)
318 if (placed && inFlight > 0 && !(await read($, isPaused))) {
319 startBeat($)
320 }
321 queued($, where, async () => refreshBadge($))
322
323 return placed
324}
325
326// ---------------------------------------------------------------- the looks
327
328type Look = { glyph: string; color?: string; note: string }
329
330/** The share of a row a note may take beside its step; a longer one goes on its own line under it. */
331const NOTE_SHARE = 0.4
332
333function editsText(count: number): string {
334 return `proven before ${count} ${count === 1 ? 'edit' : 'edits'}`
335}
336
337function stepLook(mark: Mark, nowAt: number): Look {
338 switch (mark.kind) {
339 case 'proven':
340 return { glyph: '✓', color: OLIVE, note: `${mark.by} passed ${durationText(nowAt - mark.at)} ago` }
341 case 'unproven':
342 return { glyph: '○', color: STONE, note: 'no proof' }
343 case 'stale':
344 return { glyph: '◌', color: STONE, note: editsText(mark.editsAfter) }
345 case 'flagged':
346 return { glyph: '▲', color: CLAY, note: mark.text }
347 case 'pending':
348 return { glyph: '·', color: mark.isActive ? undefined : STONE, note: mark.isActive ? 'in progress' : '' }
349 case 'unknown':
350 return { glyph: '○', color: STONE, note: '' }
351 }
352}
353
354function criterionLook(mark: CriterionMark, nowAt: number): Look {
355 switch (mark.kind) {
356 case 'proven':
357 return { glyph: '✓', color: OLIVE, note: `passed ${durationText(nowAt - mark.at)} ago` }
358 case 'failing':
359 return { glyph: '✗', color: CLAY, note: `failed ${durationText(nowAt - mark.at)} ago` }
360 case 'stale':
361 return { glyph: '◌', color: STONE, note: editsText(mark.editsAfter) }
362 case 'flagged':
363 return { glyph: '▲', color: CLAY, note: mark.text }
364 case 'ticked':
365 return { glyph: '✓', color: OLIVE, note: 'ticked by you' }
366 case 'open':
367 return { glyph: '·', color: STONE, note: '' }
368 }
369}
370
371// ---------------------------------------------------------------- the ribbon
372
373/** The phase in progress for a call that has none: the last work done, never the waiting. */
374function workPhase(list: readonly WitnessSegment[]): WitnessPhase {
375 return list.findLast(one => one.phase !== 'waiting')?.phase ?? 'explore'
376}
377
378/** Adds a finished stretch to the ribbon and to the running totals, which always move together. */
379async function addStretch($: EngineInterface, phase: WitnessPhase, start: number, end: number): Promise<void> {
380 if (end <= start) {
381 return
382 }
383 await update($, segments, list => appended(list, phase, start, end))
384 await update($, totals, sums => addedTotal(sums, phase, end - start))
385}
386
387/**
388 * Ends the person's move at `at`: the waiting so far goes to the ribbon, and
389 * work is counted from here. A call sent to the background that closes the
390 * waiting counts its wait as work; witness accepts that small undercount.
391 */
392async function closeWaiting($: EngineInterface, at: number): Promise<void> {
393 const since = await read($, waitingSince)
394 if (since !== null) {
395 await addStretch($, 'waiting', since, at)
396 await update($, waitingSince, () => null)
397 await update($, cursor, () => at)
398 }
399}
400
401/**
402 * Pauses watching at once: Now and the beat stop now, and steps already queued
403 * find witness paused and record nothing. The waiting so far is kept, closed in
404 * the queue at the pause's time. `/live pause` and the pane's `p` both come here.
405 */
406async function pause($: EngineInterface): Promise<void> {
407 endBeat()
408 const at = timeNow($)
409 await update($, isPaused, () => true)
410 await update($, now, () => null)
411 queued($, 'command.run', async () => closeWaiting($, await at))
412}
413
414/**
415 * Watches again, in the queue, so that nothing queued while paused is recorded;
416 * the ribbon picks up from the resume, so paused time belongs to no phase.
417 * Nothing opens a waiting stretch while paused (pause closed the open one), so
418 * there is none to clear. Resumed during the person's move, the rest of that
419 * move is counted as work by the next call; witness accepts that undercount.
420 */
421function resume($: EngineInterface): void {
422 const at = timeNow($)
423 queued($, 'command.run', async () => {
424 const resumedAt = await at
425 await update($, cursor, () => resumedAt)
426 await update($, isPaused, () => false)
427 })
428}
429
430// ---------------------------------------------------------------- tool calls
431
432/** Before the call runs, in the queue: the waiting ends and the cursor is set first, before anything that can fail. */
433async function before($: EngineInterface, call: ToolCall, at: number, live: { isRunning: boolean }): Promise<void> {
434 if (await read($, isPaused)) {
435 return
436 }
437 await closeWaiting($, at)
438 if ((await read($, cursor)) === null) {
439 await update($, cursor, () => at)
440 }
441 let cwd = ''
442 try {
443 cwd = await $.session.cwd()
444 } catch {
445 // The label shows the whole path.
446 }
447 await update($, now, () => ({ label: labelOf(call, cwd), since: at }))
448 if (!(await read($, hasOpened))) {
449 await update($, hasOpened, () => true)
450 await openPane($, false)
451 }
452 // The dot pulses only in a pane on screen: not one closed by hand, nor one waiting for a wider terminal.
453 const placed = await isPanePlaced($)
454 // Checked with nothing awaited before startBeat: a call that already ended must not restart the beat.
455 if (live.isRunning && placed) {
456 startBeat($)
457 }
458}
459
460/** The in-progress step an edit belongs to: the one that went in progress last (a reopened step counts from its reopening). */
461function activeStep(list: readonly WitnessStep[]): WitnessStep | undefined {
462 return list
463 .filter(one => one.status === 'in_progress' && one.id !== null)
464 .reduce<WitnessStep | undefined>(
465 (best, one) => (best === undefined || (one.lastStartedAt ?? 0) > (best.lastStartedAt ?? 0) ? one : best),
466 undefined,
467 )
468}
469
470/** After the call ran: queued, never awaited by the call. */
471function after($: EngineInterface, call: ToolCall, ran: Ran): void {
472 // The beat and Now end with the last call in flight, before anything that can fail.
473 inFlight = Math.max(0, inFlight - 1)
474 if (inFlight === 0) {
475 endBeat()
476 }
477 // When the call ended, asked now: a call recorded late still ends where it ended.
478 const endedAt = timeNow($)
479 queued($, 'tool.call', async () => {
480 const at = await endedAt
481 if (inFlight === 0) {
482 await update($, now, () => null)
483 }
484 if (await read($, isPaused)) {
485 return
486 }
487 const isDenied = ran.deny !== undefined
488 const isFailed = ran.isError === true
489 const isBackground = !isDenied && !isFailed && (ran.result as { backgroundTaskId?: unknown } | undefined)?.backgroundTaskId !== undefined
490 // A denied or backgrounded command has no outcome yet: it is no check, and debugging does not change.
491 const checked = !isDenied && !isBackground && call.tool === 'Bash' && call.command !== undefined && (await isCheck($, call.command))
492 const step = phaseOf({ tool: call.tool, command: call.command, isCheck: checked, isError: isFailed }, await read($, isDebugging))
493 await update($, isDebugging, () => step.isDebugging)
494 // Even a denied call took its time, in its phase.
495 const phase = step.phase ?? workPhase(await read($, segments))
496 await addStretch($, phase, (await read($, cursor)) ?? at, at)
497 await update($, cursor, () => at)
498 // The main turn may have ended while this call ran: the person's move starts when the call ended, not before.
499 const since = await read($, waitingSince)
500 if (since !== null && since < at) {
501 await update($, waitingSince, () => at)
502 }
503 if (isDenied) {
504 return
505 }
506
507 const file = call.file_path ?? call.notebook_path
508 if (EDIT_TOOLS.has(call.tool) && file !== undefined && !isFailed) {
509 const active = activeStep(await read($, steps))
510 await update($, edits, list => [...list, { file, at, stepId: active?.id ?? null }].slice(-KEEP_EDITS))
511 if (active !== undefined) {
512 await update($, steps, list =>
513 list.map(one => (one.id === active.id && !one.files.includes(file) ? { ...one, files: [...one.files, file] } : one)),
514 )
515 }
516 }
517 if (call.tool === 'Bash' && call.command !== undefined && !isBackground) {
518 const command = collapsed(call.command)
519 await update($, runs, list => [...list, { command, at, isOk: !isFailed }].slice(-KEEP_RUNS))
520 }
521 if (call.tool === 'TaskCreate' && !isFailed) {
522 const structured = (ran.result as { task?: { id?: unknown } } | undefined)?.task?.id
523 const id = typeof structured === 'string' ? structured : (/Task #(\S+) created/.exec(ran.text ?? '')?.[1] ?? null)
524 const added: WitnessStep = {
525 id,
526 subject: call.subject ?? '',
527 status: 'pending',
528 createdAt: at,
529 startedAt: null,
530 lastStartedAt: null,
531 completedAt: null,
532 files: [],
533 }
534 await update($, steps, list => [...list, added])
535 }
536 if (call.tool === 'TaskUpdate' && !isFailed && call.taskId !== undefined) {
537 const id = call.taskId
538 await update($, steps, list =>
539 list.flatMap(one => {
540 if (one.id !== id) {
541 return [one]
542 }
543 if (call.status === 'deleted') {
544 return []
545 }
546 const status = call.status === 'pending' || call.status === 'in_progress' || call.status === 'completed' ? call.status : one.status
547
548 return [
549 {
550 ...one,
551 subject: call.subject ?? one.subject,
552 status,
553 startedAt: status === 'in_progress' && one.startedAt === null ? at : one.startedAt,
554 lastStartedAt: status === 'in_progress' && one.status !== 'in_progress' ? at : one.lastStartedAt,
555 completedAt: status === 'completed' ? (one.completedAt ?? at) : null,
556 },
557 ]
558 }),
559 )
560 }
561 if (MARKING_TOOLS.has(call.tool)) {
562 await refreshBadge($)
563 }
564 })
565}
566
567// ---------------------------------------------------------------- presses
568
569/** A press in the pane or the band: a failure is recorded, never thrown at the surface. */
570async function press($: EngineInterface, work: () => Promise<void>): Promise<void> {
571 try {
572 await work()
573 } catch {
574 await lose($, 'ui.press')
575 }
576}
577
578/** The pane's `p`: the same pause and resume as `/live pause` and `/live resume`, by what the button showed. */
579function togglePause($: EngineInterface, wasPaused: boolean): Promise<void> {
580 return press($, async () => (wasPaused ? resume($) : pause($)))
581}
582
583/** A criterion's Done or Undo. */
584function tick($: EngineInterface, index: number): Promise<void> {
585 return press($, async () => {
586 await update($, criteria, list => list.map((one, at) => (at === index ? { ...one, isTicked: !one.isTicked } : one)))
587 })
588}
589
590// ---------------------------------------------------------------- register
591
592/**
593 * What the person typed after Claude Code's own `/goal`: the goal and, after
594 * "done when", its criteria. Empty text is left alone (what the built-in does
595 * with it is its business, and witness has nothing to read).
596 */
597async function setGoal($: EngineInterface, text: string): Promise<void> {
598 const parsed = parseGoal(text)
599 const at = await $.clock.now()
600 await update($, criteria, () => parsed.criteria)
601 await update($, goalSetAt, () => at)
602 if (parsed.goal === '') {
603 // Only criteria: the goal keeps following the prompt.
604 const fallback = await read($, promptGoal)
605 await update($, goal, () => fallback)
606 await update($, isGoalSet, () => false)
607
608 return
609 }
610 await update($, goal, () => parsed.goal)
611 await update($, isGoalSet, () => true)
612}
613
614export const register: Register = on => {
615 on('session.start', async ($, e, next) => {
616 try {
617 await $.command.register({ name: 'live', description: 'witness: open the live view; "pause" stops watching, "resume" starts again' })
618 } catch {
619 await lose($, 'session.start')
620 }
621
622 return next(e)
623 })
624
625 on('prompt.submit', async ($, e, next) => {
626 const at = timeNow($)
627 queued($, 'prompt.submit', async () => {
628 if (await read($, isPaused)) {
629 return
630 }
631 const isSlash = e.text.trimStart().startsWith('/')
632 // A prompt delivered into a running turn, or a slash command, starts no work: the waiting stays open.
633 if (e.turnId === undefined && !isSlash) {
634 const submittedAt = await at
635 await closeWaiting($, submittedAt)
636 await update($, cursor, () => submittedAt)
637 }
638 const kind = (e.origin as { kind: string } | undefined)?.kind
639 if (!isSlash && (kind === undefined || PERSON_ORIGINS.has(kind))) {
640 const fromPrompt = goalFromPrompt(e.text)
641 await update($, promptGoal, () => fromPrompt)
642 if (!(await read($, isGoalSet))) {
643 await update($, goal, () => fromPrompt)
644 }
645 }
646 })
647
648 return next(e)
649 })
650
651 on('tool.call', async ($, e, next) => {
652 if (!(await isWatching($, 'tool.call'))) {
653 return next(e)
654 }
655 const call = e as unknown as ToolCall
656 inFlight += 1
657 const live = { isRunning: true }
658 // Queued, never awaited: the call goes on at once, and the queue still runs this before the call's own after.
659 const startedAt = timeNow($)
660 queued($, 'tool.call', async () => before($, call, await startedAt, live))
661 let ran: Awaited<ReturnType<typeof next>>
662 try {
663 ran = await next(e)
664 } catch (error) {
665 // The call itself threw: its time was spent, nothing else is recorded, and the error goes on as it was.
666 live.isRunning = false
667 try {
668 after($, call, { deny: 'the call threw' })
669 } catch {
670 await lose($, 'tool.call')
671 }
672 throw error
673 }
674 live.isRunning = false
675 try {
676 after($, call, ran as unknown as Ran)
677 } catch {
678 await lose($, 'tool.call')
679 }
680
681 return ran
682 })
683
684 on('turn.complete', async ($, e, next) => {
685 // A subagent's turn ending is not the person's move; its tool calls still count as Claude's work.
686 if (e.agentId !== undefined) {
687 return next(e)
688 }
689 if (inFlight === 0) {
690 endBeat()
691 }
692 const at = timeNow($)
693 queued($, 'turn.complete', async () => {
694 if (await read($, isPaused)) {
695 return
696 }
697 if (inFlight === 0) {
698 await update($, now, () => null)
699 }
700 if ((await read($, waitingSince)) !== null) {
701 return
702 }
703 // The turn's last stretch runs to its end, in the phase of the work before it; then the person's move.
704 const endedAt = await at
705 const from = await read($, cursor)
706 if (from !== null) {
707 await addStretch($, workPhase(await read($, segments)), from, endedAt)
708 }
709 await update($, cursor, () => endedAt)
710 await update($, waitingSince, () => endedAt)
711 })
712
713 return next(e)
714 })
715
716 on('command.run', { command: 'live' }, async ($, e) => {
717 try {
718 const verb = e.args.trim()
719 if (verb === 'pause') {
720 await pause($)
721
722 return { text: 'witness paused for this session.' }
723 }
724 if (verb === 'resume') {
725 resume($)
726
727 return { text: 'witness is watching again.' }
728 }
729 const placed = await showPane($, 'command.run')
730
731 return { text: placed ? 'witness opened.' : await summaryText($) }
732 } catch {
733 await lose($, 'command.run')
734
735 return { text: 'witness lost track at /live.' }
736 }
737 })
738
739 // `/goal` is Claude Code's own: witness reads what is typed after it and lets the built-in run, unchanged, exactly once.
740 on('command.run', { command: 'goal' }, async ($, e, next) => {
741 try {
742 const text = e.args.trim()
743 if (text !== '') {
744 await setGoal($, text)
745 queued($, '/goal', async () => refreshBadge($))
746 }
747 } catch {
748 await lose($, '/goal')
749 }
750
751 return next(e)
752 })
753
754 on('ui.close', { id: PANE }, async ($, e, next) => {
755 const closed = await next(e)
756 try {
757 // A hook beneath may have refused the close: the pane is still there.
758 if ((await panesSay($)) === true) {
759 return closed
760 }
761 // Closed (by the person, mostly): nothing shows the pulse, and the line above the prompt or the badge stands in for the pane again.
762 endBeat()
763 await update($, isPlaced, () => false)
764 queued($, 'ui.close', async () => refreshBadge($))
765 } catch {
766 await lose($, 'ui.close')
767 }
768
769 return closed
770 })
771
772 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
773 const { Box, Button, Text } = $.ui.resolve(e)
774 const nowAt = await $.clock.now()
775 const marked = await marksNow($)
776 const width = Math.max(10, e.props.bodyColumns - 2)
777 const cells = ribbonOf(await read($, segments), width)
778 const sums = await read($, totals)
779 const current = await read($, now)
780 const isBeat = await read($, beat)
781 const lost = await read($, lostAt)
782 const paused = await read($, isPaused)
783 const goalText = await read($, goal)
784
785 return (
786 <Box flexDirection="column" gap={1}>
787 {/* One line: cut with an ellipsis, and 3 columns short of the edge so it never reaches the close mark in the top-right corner. */}
788 <Box key="goal" marginRight={3}>
789 <Text wrap="truncate-end">
790 <Text color={STONE}>Goal </Text>
791 <Text bold>{goalText === '' ? 'waiting for a prompt' : goalText}</Text>
792 </Text>
793 </Box>
794 <Box flexDirection="column">
795 <Text>
796 {cells.length === 0 ? (
797 <Text color={STONE}>{'░'.repeat(width)}</Text>
798 ) : (
799 cells.map((cell, index) => (
800 <Text key={`cell-${index}`} color={PHASE_COLOR[cell.phase]} dimColor={cell.phase === 'waiting'}>
801 {'█'.repeat(cell.chars)}
802 </Text>
803 ))
804 )}
805 </Text>
806 <Box gap={2} flexWrap="wrap">
807 {PHASES.filter(phase => sums[phase] !== undefined).map(phase => (
808 <Text key={`total-${phase}`}>
809 <Text color={PHASE_COLOR[phase]} bold>
810 {phase}
811 </Text>
812 <Text color={STONE}> {durationText(sums[phase] ?? 0)}</Text>
813 </Text>
814 ))}
815 </Box>
816 </Box>
817 {current === null ? (
818 <Text color={STONE}>Now idle</Text>
819 ) : (
820 <Box gap={1}>
821 <Text color={isBeat ? CLAY : STONE}>●</Text>
822 <Text bold>Now</Text>
823 <Box flexShrink={1}>
824 <Text wrap="truncate-end">{current.label}</Text>
825 </Box>
826 </Box>
827 )}
828 {marked.criteria.length > 0 && (
829 <Box flexDirection="column">
830 {marked.criteria.map(({ criterion, mark }, index) => {
831 const look = criterionLook(mark, nowAt)
832
833 const hasTick = criterion.command === null && index < 9
834 const isBeside = look.note.length <= Math.floor(width * NOTE_SHARE)
835
836 return (
837 <Box key={`criterion-${index}`} flexDirection="column">
838 <Box gap={1}>
839 <Box width={2} flexShrink={0}>
840 <Text color={look.color} bold>
841 {look.glyph}
842 </Text>
843 </Box>
844 <Box flexGrow={1} flexShrink={1}>
845 <Text>{criterion.text}</Text>
846 </Box>
847 {hasTick ? (
848 <Button
849 key={`tick-${index + 1}`}
850 hotkey={String(index + 1)}
851 label={criterion.isTicked ? 'Undo' : 'Done'}
852 onPress={() => tick($, index)}
853 />
854 ) : (
855 isBeside && (
856 <Box key={`criterion-note-${index}`} flexShrink={0}>
857 <Text color={look.color ?? STONE}>{look.note}</Text>
858 </Box>
859 )
860 )}
861 </Box>
862 {!hasTick && !isBeside && (
863 <Box key={`criterion-note-below-${index}`} marginLeft={3}>
864 <Text color={look.color ?? STONE}>{look.note}</Text>
865 </Box>
866 )}
867 </Box>
868 )
869 })}
870 </Box>
871 )}
872 <Box flexDirection="column">
873 {marked.steps.length === 0 && <Text color={STONE}>No steps yet. Claude's task list shows up here.</Text>}
874 {marked.steps.map(({ step, mark }, index) => {
875 const look = stepLook(mark, nowAt)
876 const isActive = mark.kind === 'pending' && mark.isActive
877
878 // A note wider than NOTE_SHARE of the row (a flag's text) goes under the step: beside it, a box that
879 // never shrinks would squeeze the step's subject into a column two letters wide.
880 const isBeside = look.note.length <= Math.floor(width * NOTE_SHARE)
881
882 return (
883 <Box key={`step-${index}`} flexDirection="column">
884 <Box gap={1}>
885 <Box width={2} flexShrink={0}>
886 <Text color={look.color} bold>
887 {look.glyph}
888 </Text>
889 </Box>
890 <Box flexGrow={1} flexShrink={1}>
891 <Text bold={isActive} color={mark.kind === 'pending' && !isActive ? STONE : undefined}>
892 {step.subject}
893 </Text>
894 </Box>
895 {look.note !== '' && isBeside && (
896 <Box key={`note-${index}`} flexShrink={0}>
897 <Text color={look.color ?? STONE}>
898 {look.note}
899 </Text>
900 </Box>
901 )}
902 </Box>
903 {look.note !== '' && !isBeside && (
904 <Box key={`note-below-${index}`} marginLeft={3}>
905 <Text color={look.color ?? STONE}>
906 {look.note}
907 </Text>
908 </Box>
909 )}
910 </Box>
911 )
912 })}
913 </Box>
914 {!marked.hasProof && <Text color={STONE}>install emcc-core to see proof</Text>}
915 {lost !== null && <Text color={CLAY}>witness lost track at {lost}</Text>}
916 <Box gap={1}>
917 <Button key="pause" hotkey="p" label={paused ? 'Resume' : 'Pause'} onPress={() => togglePause($, paused)} />
918 </Box>
919 </Box>
920 )
921 })
922
923 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
924 if (e.props.hasSurvey || !(await read($, hasOpened)) || (await isPanePlaced($)) || (await emccDrawsBand($))) {
925 return next(e)
926 }
927 const { Box, Button, Text } = $.ui.resolve(e)
928 const counted = await provenCount($)
929
930 return (
931 <Box>
932 <Text color={CLAY} bold>
933 witness{' '}
934 </Text>
935 <Text color={STONE}>
936 {counted.proven}/{counted.total} proven{' '}
937 </Text>
938 <Button key="live" label="Live" onPress={() => press($, async () => void (await showPane($, 'ui.press')))} />
939 </Box>
940 )
941 })
942}
943hooks/goal.ts 49 lines1import type { WitnessCriterion } from '../types'
2
3const DONE_WHEN = /\b(?:done when|hecho cuando)\b/i
4const SENTENCE_END = /[.!?](?=\s|$)|\n/
5const LONGEST = 100
6
7export type ParsedGoal = { goal: string; criteria: WitnessCriterion[] }
8
9export function collapsed(command: string): string {
10 return command.trim().replace(/\s+/g, ' ')
11}
12
13export function sameCommand(a: string, b: string): boolean {
14 return collapsed(a) === collapsed(b)
15}
16
17/** The first sentence of a prompt, at most 100 characters (code points, so an emoji is never split). */
18export function goalFromPrompt(text: string): string {
19 const trimmed = text.trim()
20 const end = SENTENCE_END.exec(trimmed)
21 const first = end === null ? trimmed : trimmed.slice(0, end[0] === '\n' ? end.index : end.index + 1).trim()
22
23 const characters = Array.from(first)
24
25 return characters.length > LONGEST ? `${characters.slice(0, LONGEST - 1).join('')}…` : first
26}
27
28/** `/goal` text: the goal, then criteria after "done when", split by `;`. */
29export function parseGoal(text: string): ParsedGoal {
30 const trimmed = text.trim()
31 const match = DONE_WHEN.exec(trimmed)
32 if (match === null) {
33 return { goal: trimmed, criteria: [] }
34 }
35 const goal = trimmed.slice(0, match.index).replace(/[\s;,]+$/, '')
36 const criteria = trimmed
37 .slice(match.index + match[0].length)
38 .split(';')
39 .map(part => part.trim())
40 .filter(part => part !== '')
41 .map(part => {
42 const inTicks = /`([^`]+)`/.exec(part)?.[1]
43
44 return { text: part, command: inTicks === undefined ? null : collapsed(inTicks), isTicked: false }
45 })
46
47 return { goal, criteria }
48}
49hooks/marks.ts 93 lines1import type { WitnessCriterion, WitnessEdit, WitnessRun, WitnessStep } from '../types'
2import { sameCommand } from './goal'
3
4/** A check from emcc-core's evidence log (an `edit` entry is not one). */
5export type Check = { kind: string; at: number; command?: string; isOk?: boolean }
6
7/** A flag from emcc-core. */
8export type Flag = { source: string; rule: string; text: string; file?: string; at: number }
9
10export type Mark =
11 | { kind: 'proven'; by: string; at: number }
12 | { kind: 'unproven' }
13 | { kind: 'stale'; editsAfter: number }
14 | { kind: 'flagged'; text: string }
15 | { kind: 'pending'; isActive: boolean }
16 | { kind: 'unknown' }
17
18export type CriterionMark =
19 | { kind: 'proven'; at: number }
20 | { kind: 'failing'; at: number }
21 | { kind: 'stale'; editsAfter: number }
22 | { kind: 'flagged'; text: string }
23 | { kind: 'open' }
24 | { kind: 'ticked' }
25
26const PROVING = new Set(['test', 'build', 'typecheck'])
27
28/**
29 * A step's mark, computed from what happened, never stored. `checks` and
30 * `flags` are null when emcc-core could not be asked.
31 */
32export function markOf(
33 step: WitnessStep,
34 edits: readonly WitnessEdit[],
35 checks: readonly Check[] | null,
36 flags: readonly Flag[] | null,
37): Mark {
38 const begin = step.startedAt ?? step.createdAt
39 const end = step.completedAt ?? Number.POSITIVE_INFINITY
40 // A flag counts only for a step that went in progress, and only while it ran.
41 const started = step.startedAt
42 const flag = started === null ? undefined : (flags ?? []).filter(one => one.at >= started && one.at <= end).at(-1)
43 if (flag !== undefined) {
44 return { kind: 'flagged', text: flag.text }
45 }
46 if (step.status !== 'completed') {
47 return { kind: 'pending', isActive: step.status === 'in_progress' }
48 }
49 if (checks === null) {
50 return { kind: 'unknown' }
51 }
52 const proof = checks.filter(check => PROVING.has(check.kind) && check.isOk === true && check.at >= begin).at(-1)
53 if (proof === undefined) {
54 return { kind: 'unproven' }
55 }
56 // The step's own files, kept with it because the edit list is capped; with none, the listed edits that name it.
57 const files = new Set(step.files.length > 0 ? step.files : edits.filter(one => step.id !== null && one.stepId === step.id).map(one => one.file))
58 const editsAfter = edits.filter(one => one.at > proof.at && (files.size === 0 || files.has(one.file))).length
59
60 return editsAfter === 0 ? { kind: 'proven', by: proof.command ?? proof.kind, at: proof.at } : { kind: 'stale', editsAfter }
61}
62
63/**
64 * A `/goal` criterion's mark: its command's newest run, against every edit
65 * after it. A passing run with a flag at or before it was proven by a lowered
66 * bar, so it reads as flagged; `flags` is null when emcc-core could not be asked.
67 */
68export function criterionMarkOf(
69 criterion: WitnessCriterion,
70 runs: readonly WitnessRun[],
71 edits: readonly WitnessEdit[],
72 flags: readonly Flag[] | null,
73): CriterionMark {
74 if (criterion.command === null) {
75 return criterion.isTicked ? { kind: 'ticked' } : { kind: 'open' }
76 }
77 const command = criterion.command
78 const run = runs.filter(one => sameCommand(one.command, command)).at(-1)
79 if (run === undefined) {
80 return { kind: 'open' }
81 }
82 if (!run.isOk) {
83 return { kind: 'failing', at: run.at }
84 }
85 const flag = (flags ?? []).filter(one => one.at <= run.at).at(-1)
86 if (flag !== undefined) {
87 return { kind: 'flagged', text: flag.text }
88 }
89 const editsAfter = edits.filter(one => one.at > run.at).length
90
91 return editsAfter === 0 ? { kind: 'proven', at: run.at } : { kind: 'stale', editsAfter }
92}
93hooks/phases.ts 105 lines1import type { WitnessPhase, WitnessSegment } from '../types'
2
3export const KEEP_SEGMENTS = 200
4
5const EXPLORE_TOOLS = new Set(['Read', 'Grep', 'Glob', 'LS', 'NotebookRead', 'WebFetch', 'WebSearch'])
6const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
7const LEADING_CD = /^\s*(?:cd\s+(?:"[^"]*"|'[^']*'|\S+)\s*&&\s*)+/
8const READ_ONLY = /^\s*(?:rg|grep|ls|cat|head|tail|find|fd|tree|wc|git\s+(?:log|diff|show|status|blame))\b/
9
10/** What witness knows of a finished tool call. `isCheck` comes from emcc-core's `kindsOf`. */
11export type ToolFacts = { tool: string; command?: string; isCheck: boolean; isError: boolean }
12
13export type PhaseStep = { phase: WitnessPhase | null; isDebugging: boolean }
14
15/**
16 * The phase a finished tool call belongs to, and whether Claude is debugging
17 * after it: a failed check starts debugging, a passing one ends it. `null`
18 * counts toward the phase in progress.
19 */
20export function phaseOf(facts: ToolFacts, isDebugging: boolean): PhaseStep {
21 if (EDIT_TOOLS.has(facts.tool)) {
22 return { phase: isDebugging ? 'debug' : 'implement', isDebugging }
23 }
24 if (EXPLORE_TOOLS.has(facts.tool)) {
25 return { phase: 'explore', isDebugging }
26 }
27 if (facts.tool === 'Bash') {
28 if (facts.isCheck) {
29 return { phase: 'verify', isDebugging: facts.isError }
30 }
31 if (READ_ONLY.test((facts.command ?? '').replace(LEADING_CD, ''))) {
32 return { phase: 'explore', isDebugging }
33 }
34 }
35
36 return { phase: null, isDebugging }
37}
38
39/** The segments with this stretch added: the same phase extends the last one; past the cap the oldest two merge. */
40export function appended(segments: readonly WitnessSegment[], phase: WitnessPhase, start: number, end: number): WitnessSegment[] {
41 const last = segments.at(-1)
42 let next =
43 last !== undefined && last.phase === phase
44 ? [...segments.slice(0, -1), { ...last, end: Math.max(last.end, end) }]
45 : [...segments, { phase, start, end }]
46 while (next.length > KEEP_SEGMENTS) {
47 const [first, second, ...rest] = next
48 if (first === undefined || second === undefined) {
49 break
50 }
51 next = [{ phase: first.phase, start: first.start, end: second.end }, ...rest]
52 }
53
54 return next
55}
56
57export type Cell = { phase: WitnessPhase; chars: number }
58
59/** The ribbon `width` characters wide, each phase's share by time. */
60export function ribbonOf(segments: readonly WitnessSegment[], width: number): Cell[] {
61 const total = segments.reduce((sum, segment) => sum + (segment.end - segment.start), 0)
62 if (total <= 0 || width <= 0) {
63 return []
64 }
65 const cells: Cell[] = []
66 let elapsed = 0
67 let used = 0
68 for (const segment of segments) {
69 elapsed += segment.end - segment.start
70 const boundary = Math.round((elapsed / total) * width)
71 const chars = boundary - used
72 used = boundary
73 if (chars <= 0) {
74 continue
75 }
76 const last = cells.at(-1)
77 if (last !== undefined && last.phase === segment.phase) {
78 last.chars += chars
79 } else {
80 cells.push({ phase: segment.phase, chars })
81 }
82 }
83
84 return cells
85}
86
87/**
88 * The running total of time per phase, with `ms` more in `phase`. Kept apart
89 * from the segments, which merge their oldest ones past the cap and so cannot
90 * be added up for the whole session.
91 */
92export function addedTotal(
93 totals: Partial<Record<WitnessPhase, number>>,
94 phase: WitnessPhase,
95 ms: number,
96): Partial<Record<WitnessPhase, number>> {
97 return { ...totals, [phase]: (totals[phase] ?? 0) + ms }
98}
99
100export function durationText(ms: number): string {
101 const seconds = Math.floor(ms / 1000)
102
103 return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m`
104}
105types/index.d.ts 69 lines1/**
2 * witness's session state: what it saw Claude do, from which it draws the
3 * pane. Nothing here is persisted or sent anywhere.
4 */
5
6export type WitnessPhase = 'explore' | 'implement' | 'debug' | 'verify' | 'waiting'
7
8/** A stretch of the session spent in one phase, in session-clock ms. */
9export type WitnessSegment = { phase: WitnessPhase; start: number; end: number }
10
11export type WitnessStepStatus = 'pending' | 'in_progress' | 'completed'
12
13/** One of Claude's tasks. `id` is null when the engine's answer carried none. */
14export type WitnessStep = {
15 id: string | null
16 subject: string
17 status: WitnessStepStatus
18 createdAt: number
19 /** When it first went in progress: its proof counts from here. */
20 startedAt: number | null
21 /** When it last went in progress (a reopened step again): an edit goes to the step started last. */
22 lastStartedAt: number | null
23 completedAt: number | null
24 /** The files edited while it was in progress, each once: kept here because the edit list is capped. */
25 files: string[]
26}
27
28/** An edit that went through, and the step in progress when it was made. */
29export type WitnessEdit = { file: string; at: number; stepId: string | null }
30
31/** A finished shell command and whether it succeeded. */
32export type WitnessRun = { command: string; at: number; isOk: boolean }
33
34/** A `/goal` criterion: proven by `command` when it has one, ticked by hand when not. */
35export type WitnessCriterion = { text: string; command: string | null; isTicked: boolean }
36
37/** The action in progress. */
38export type WitnessNow = { label: string; since: number }
39
40declare module 'claude-code' {
41 interface PluginState {
42 witness: {
43 goal: string
44 promptGoal: string
45 isGoalSet: boolean
46 /** When text was last typed after Claude Code's `/goal` (null before): a criterion counts only the flags raised since. */
47 goalSetAt: number | null
48 criteria: WitnessCriterion[]
49 steps: WitnessStep[]
50 edits: WitnessEdit[]
51 runs: WitnessRun[]
52 segments: WitnessSegment[]
53 /** Time per phase over the whole session; the segments merge their oldest past a cap and cannot be added up. */
54 totals: Partial<Record<WitnessPhase, number>>
55 cursor: number | null
56 waitingSince: number | null
57 isDebugging: boolean
58 now: WitnessNow | null
59 beat: boolean
60 hasOpened: boolean
61 isPlaced: boolean
62 isPaused: boolean
63 lostAt: string | null
64 /** Written at the start of every queued step, so the step's reads are fresh; it means nothing else. */
65 queueTick: number
66 }
67 }
68}
69