While /thegraph runs: one line above the prompt with its steps in order, each marked, and the signals that fired, and a button that opens a pane with a gantt…

Personal collection of Claude Code and Codex skills.
Each top-level folder containing a SKILL.md is one skill. This repo is the single source of truth; skills become active by linking them into each agent's skill directory, where Claude Code and Codex discover them.
| Skill | What it does |
|---|---|
thegraph-codex | Codex-native, explicitly invoked version of thegraph's end-to-end discipline: establish the task from repository guidance and primary sources, make routine implementation decisions autonomously, ask only for consequential product or scope decisions, prove behavioural tests can fail, then run verification proportionate to the changed surface. It does not require Claude Code platform skills or create external tracker records without an explicit request. |
to-html | Convert a markdown file — or content already in the conversation — into a single self-contained, styled HTML file: inline CSS, sticky table of contents, callouts, premium layout blocks. No build step. |
brief | Explain the work in flight to the person who has to decide about it — state, the next action, what it changes and what it leaves alone, and what they must decide (including "nothing", which is information). Publishes a one-screen Artifact with its own briefing layout; falls back to chat when there is no structure worth laying out. Status claims are queried with gh/git rather than copied out of the conversation, and measured facts are marked apart from inferred ones. Vocabulary is governed by a structural check — every noun must point at something the user can see or do — not by a banned-word list, because the words that lose someone next are never the ones already on the list. A view, never a source: nothing may exist only in a briefing. Invoked only (/brief, /brief <issue>, /brief session) — judging whether someone understands you is not yours to do. |
gate | The Acceptance Gate — review an AFK agent's finished work on an issue, render a verdict (Pass / Rework / Respec / Escalate) with the matching next action, and run it on the tracker once you approve. Delegates the diff review to the code-review skill. |
to-deck | Retired — no longer installed; moved to retired/, see ADR-0059 and ADR-0027. Turned a study PDF into a reusable JSON question bank through an interactive Korean Q&A session: keeps the workbook's own questions in order (origin: workbook) and adds generated extras (origin: extra) — a mix of explanatory and single-term recall questions — each a plain {question, answer} pair. Offers a one-at-a-time session or batch extraction; no question-type labels — a downstream tool classifies them. |
grill-code | Retired — no longer installed; moved to retired/, see ADR-0058 and ADR-0027. Grilled code along one chosen mode — security, common-component extraction, refactoring, performance, or learning — scanning the scope relentlessly. Defect modes score each finding on severity + effort and rank them by priority (P0–P3); the non-defect learning mode explains AI-written code as Lessons ranked by Value. Report only; standalone (no dependency on code-review/audit). Can save the report as HTML via to-html. |
img-to-pdf | Combine a folder of images (or an explicit file list) into one PDF, one image per page, in natural-sort order (2.jpg before 10.jpg). Default: a searchable PDF — the page image plus an invisible, selectable/searchable text layer via macOS Vision OCR (Korean + English). --image-only instead embeds JPEG/PNG losslessly with no text layer (HEIC/HEIF/WEBP pre-converted via sips; img2pdf auto-installed into a cached venv). OCR mode is macOS-only; the Swift helper compiles once and caches. |
theflow | Retired — no longer installed; moved to retired/, see ADR-0027. The seven-step working discipline for a substantive change to a boundary-defined codebase, succeeded by thegraph which carries every rule it held (ADR-0019). Kept readable because the docs/agents/theflow.md bindings it read still exist in the repos it onboarded — nothing reads them now, grill-the-graph's path for compiling one having gone with ADR-0067. |
grill-the-flow | Retired — no longer installed; moved to retired/, see ADR-0027. The setup half of theflow — it interrogated a maintainer to author the repo's docs/agents/theflow.md bindings. Succeeded by grill-the-graph, which asks a much smaller question and writes a much smaller file. Nothing reads the bindings docs it wrote, that path having gone with ADR-0067. |
read-it | Read the situation before touching anything, and come back with a route. The first act of a change and the only one whose mistakes nothing downstream catches — a wrong proof method fails at the proof and a wrong placement fails at the diff, while a wrong reading of the issue passes everything, a run that solves the wrong problem well having still solved something well. Four reads, three of them conditional on something you check rather than weigh: the build (short, and answering little on purpose); the cluster, where one tracker query finds a parent (spine — the anchor's root is a hypothesis to test); the territory map where the repo keeps one; and always the real sources plus the hidden state (firsthand, one reflex rather than two — the source list widens what you read, it never decides whether you read, and with no outside source named you still enumerate the hidden state of the code you are touching). Doing one of the four and doing all four are both ordinary; what is not is skipping one and being unable to say why. Then restates the issue in its own words and stops to ask — the only guaranteed stop, since every later one is conditional and a run where nothing goes wrong first reaches a person with the work already done and nowhere to have redirected it. Returns a route label, never a skill name, so what runs next stays the caller's to decide. Everything it finds is a candidate; nothing reaches a tracker unasked. |
make-it | Write the change. Calls tdd for the tests and redden after every one of them — a green from a test nobody watched fail is not evidence, which is the one thing tdd does not do. Keeps the narrow exception where compilation itself enforces completeness and a round-trip test replaces strict RED, and the rhythm a whole-suite-at-the-end habit loses: typecheck and the nearby tests often, everything once at the end. Prose changes walk it too, with the test section conditional. Carries the rule that a comment says what the code is, and follows the repo's CLAUDE.md for where the rest goes rather than holding its own copy — comments are written on every edit, and most edits never pass through make-it. Its substance is the four signals that only appear while your hands are in the code, since you cannot schedule the moment you will feel one: "this is a bit odd, I'll just fix it up here" — the tell that the problem is one step further in, tempting because it works and costs nothing visible, until the day somebody fixes the real thing and every patch like it becomes silently wrong; a new file with no obvious home; writing against an outside reference; and a call that is the maintainer's. What you notice and do not fix is carried, never filed. |
check-it | Measure a finished change before anyone calls it done — the hand that shaped a thing is the worst judge of whether it is finished. Eight checks, ordered by cost rather than ceremony, each conditional: the gates bare (bare), whether new or moved files landed where the layout rule said, proof against something real rather than a fake, the diff read for shape (assay), for cost (silt) and for exposure (security-review) at the same time, the whole corpus read for what is missing (lens), and the surfaces that describe the behaviour (sweep). A prose change runs the last two and nothing else. One question runs through all of it — a red, a defect, a defensive line: is it mine? Answered by re-running against the tree without this change and comparing the failure, never the exit code, since a baseline red for its own reason makes every red compare 1 to 1 and inverts the attribution silently. And one shape is hunted assuming the writer left no help at all, because whoever did not notice could not have written it down: for each defensive line, what would have to be true for this to be unnecessary? — if the answer names something you did not change, that is where it belongs. Fixes nothing; measures, attributes, hands on. |
decant | Sort source comments paragraph by paragraph under one policy: a comment says what the code is, and the why, the trap, the measured value and the history move to the repo's map. Six bins; the unit is the paragraph; the drop question is asked last, because a wrong move costs a lookup and a wrong drop costs a measurement. Checks every DESIGN against the code and then its note, and reports what the map is missing (notes owed) and where note and comment give different reasons (two grounds). Runs on a whole repository, territory by territory in an order derived by counting, when a maintainer asks — never per change, because a check after the fact is undone by the next edit made outside it; the write-time rule lives in the repo's CLAUDE.md. Reports and stops — unless the maintainer hands it the writes, and then it moves sentences rather than rewriting them, accounts for every one, and re-points whatever quoted them. The measurements behind it are ADR-0076. In a repository you contribute to, upstream's guidance and closest merged PR replace that policy, and only the comments the branch adds or changes are read (ADR-0079). |
winnow | Cut an always-loaded agent document — CLAUDE.md, AGENTS.md, a skill body — down to what every run needs. Five bins per paragraph: rule stays, detail moves behind a pointer that names its trigger, number stays only where a decision turns on it, roster becomes the command that derives it, history leaves for version control. History is asked last, and an incident story has its rule extracted before the story goes. Shows the table — every history part quoted, not counted — and stops for the maintainer; on approval writes, then proves every rule still has a home by reading the original from version control, and repoints references to moved sections. The case it was cut from is ADR-0077. |
assay | Test what a change is made of, along two axes kept apart because a change can pass one and fail the other: does the code hold up, and does it do what was asked. The first carries this repo's own documented standards — which always outrank it — plus Fowler's twelve code smells as labelled heuristics, never hard violations, covering cohesion (Divergent Change), coupling (Feature Envy, Shotgun Surgery, Message Chains) and duplication (Duplicated Code, Data Clumps, Primitive Obsession); and it opens the module the change landed in, since "edited for several unrelated reasons" is a claim about a module and a diff shows one slice of it. The second asks what the issue wanted and got, what it wanted and did not, and what arrived that nobody asked for. Findings are graded violation / judgement / already-decided — the third is why the records get read, since a finding that contradicts a recorded decision re-opens a call somebody made. Never merged, never ranked across axes, one closing line per axis. |
silt | Read a change for what will make it slow, and refuse to call anything a defect without a number. Silt is what a river drops: nothing dramatic, nothing wrong on any given day, and one season later the channel is shallow — which is the shape of the performance defects that actually cost, since the ones that announce themselves get found. Carries seven shapes with, for each, the cheapest way to find out whether it is real here: registered and never released, a container that only grows, I/O inside a loop, a large structure copied by value, recomputed every time, blocking the thread that must not block, a lookup with nothing to make it fast. A suspicion arrives with the way to settle it or it does not go out at all — that bar is what stops this becoming a list of things that make somebody uneasy. And a number with nothing beside it is not a number: measure the same thing on the tree without the change. Proposes no optimisation, does not widen, and does not accept "it's fine, it's small" — if it is small the measurement is cheap. |
ask-it | Put everything that has been carried in front of a person, once, and file only what they keep — the one door, since nothing may evaporate into a pull-request body or a code comment, where it is buried on merge. envelope owns the collecting and the seven columns; what this adds is that the items arrive from different places with dispositions that share no vocabulary — something to file, something to decide, something belonging to another repository, something a release will oblige — and interleaved, the reader classifies before answering, which is the sorting that presenting one at a time was bought to remove. byartifact searches before anything is opened, and only for items actually being filed. A follow-up is parented through the tracker's own relation in the same act as filing it, and whether it also joins the cluster is a separate judgement: provenance and root are different claims, and the error is not symmetric — missing a sibling costs a reconstruction, counting an adjacent one corrupts the roster that somebody later copies almost verbatim. A release's obligations are the consumer tests that actually broke, named; a purely additive release obliges nothing, and that is an answer worth writing. |
thegraph | Take one change from an issue to done, in a codebase whose identity is a boundary — a core that stays correct by not absorbing the concerns of the things that consume it. Four skills in order — read-it → make-it → check-it → ask-it — with one guaranteed stop before any of them run: the reading of the issue, confirmed by the person who holds it, since a change where no signal fires otherwise reaches a person for the first time at the very end, with the work done and nowhere to have redirected it. Two routes after that, and the one that gets missed is expensive: an issue asking for a choice gets the adversarial read over the options before any proposal, or the enumeration lands after approval and its costs get demoted into follow-ups. Everything else is a signal you cannot schedule — the urge to patch around something one step further in, a test just written, an outside reference about to be relied on, a call that is the maintainer's — acted on where it arrives rather than noted for later. What holds throughout: nothing reaches a tracker unasked; what can be counted is counted; a derivation falls to a better derivation and a judgement falls only to the person who made it, so a record that does not say which it holds reads as the first. Human-invoked (/thegraph). |
grill-the-graph | The setup half of thegraph. Writes one short prose file, docs/agents/thegraph.md, holding what a person already knows and no file in the repo answers — which is very nearly just the real sources the project is built against, each marked how it works or where files go, raw or summarized, an example to follow or a spec that binds. A line earns its place only by clearing three conditions together: no file here answers it, a person already knows it, and something reads it. The second is the one that used to be missing — "only a person could decide it" and "a person already knows it" are different sets, and everything this skill once asked for (sacred paths, the tie-breaker, the seams, a proof method per layer) cleared the first and failed the second, so the answers came back drafted by the builder and ratified by someone who could not have produced them. Generates no scripts and no agents, carries no build stamp, and copies nothing the repo already states; the build grows as runs ask for what it lacks, and is never rebuilt. An older generated build — scripts/thegraph/, .claude/agents/thegraph-* — is handed to salvage rather than read or updated. A project built against nothing outside itself yet answers none yet, and the file records that the question was put rather than inventing a row. Where the repo has no territory map, it makes the map's folder with a one-line hub instead of building one — the rule needs a destination on disk, and an empty map is a young repo's honest state; notes arrive as edits need them or all at once through grill-map. Then it writes the comment rule into the repo's CLAUDE.md: a comment says what the code is, with the rest sent to the map, the decision records or the commit message, naming only destinations that exist on disk. That is the one place the rule is written, because comments are written on every edit and a pass that checks them afterwards is undone by the next one. In a repository you contribute to rather than own — named by the argument (/grill-the-graph local, or own for your own), and asked first when there is none, with viewerPermission on the receiving repository as the recommendation — it commits nothing: the same file and a rule deferring to upstream's own guidance go in untracked, excluded files, no map is made, and a hook blocks Claude attribution on the way out (ADR-0079). In your own repository attribution stays unless you turn it off when asked, and off installs the same check, locally. |
salvage | Clear an older grill-the-graph build out of a repo without losing the answers a person gave it. The whole job is telling two things apart: what somebody sat down and answered, which has to survive, and what was copied out of the repository, which is why that build went. Finds what is ours by the build stamp every generated file carries and by the build document's own manifest, and reads both — a stamped file the manifest does not name is the case that matters, the manifest being a copy like any other. Four rings: ours (build doc, generated agents and scripts, the ignore line, and the run-state cache version control never shows you); somebody else's file pointing at ours, where only the line comes out — a repo left with a hook invoking a deleted script cannot commit, so clearing the first ring and reporting the second is a break with a note attached; produced by our rules but not written by us, shown and asked, and any generated file a later commit touched, because that made it theirs; and outside, untouched — decision records describe the world when they were written, broken links included. Lifts the reference list into the short file the current skill defines, drops the rest with a reason each, and re-reads the tree afterwards, since a cleanup reporting success without looking again has checked nothing. |
tickets | Retired — no longer installed; moved to retired/, see ADR-0069 and ADR-0027. Put back what slicing dropped — the third producer in the thegraph family. A general slicer keeps a ticket thin because it cannot know who will execute it; here the executor was known, so the calls a maintainer had already made travelled with the ticket instead of staying behind in the spec. Ran right after the tickets were cut, in the same unbroken window as the grilling: transcribed the settled calls into each ticket, enrolled the parent as the relation the tracker actually renders (which is still why spine's roster is a query and not a body parse), and marked each entry human / derived / unknown. It read thegraph's "What the issue must supply" at runtime rather than hardcoding it, and that is the one thing it demonstrably bought: the schema went from five slots to six and the producer needed zero edits. Retired on the maintainer's call, with the falsification ADR-0045 had written in advance already fired — on the only run anyone measured, every contract entry was marked derived, which licenses nothing. The schema it filled is live; thegraph reads a contract wherever one is written, and a ticket that answers none of it was always the ordinary case. |
spine | Read the cluster before the ticket, extracted from thegraph's node of the same name (ADR-0042). An issue is written at filing time and read at work time; in between, the context that made it obvious decays, so a fix reasoned from the ticket alone re-decides what a sibling already settled — which is exactly how closing one issue produces the next one, while looking like diligence. Read the anchor and its siblings for three things: the suspected shared root, what each established including what it rejected, and what is explicitly still open. The anchor's root is a hypothesis to test, and falsifying it is the more valuable of the two outcomes. Briefs from live state, anchored on the human's last touch, in outcome voice. Fuses catchup. |
firsthand | Read the real thing before you guess, and never promote ignorance into fact — thegraph's reference and enumerate nodes, which turned out to be one reflex split across two slots (ADR-0042). Fetch raw source and grep the actual lines: never a summarizing fetch, because summary drops method bodies and a handler that is there reads as absent. A feature being new never excuses skipping its mechanism layer, which the reference almost always has. Pin a runtime value with a throwaway probe and record the number — reading code is not observing what it does. Then the exit guard: unconfirmed is a gap, not an absence. Re-confirming costs one fetch; guessing wrong costs days. Clear a worry and you record the condition it holds under. Fuses factchk. |
| sweep | Every surface that describes the behaviour drifts the moment the behaviour moves, and nothing compiles the drift away (ADR-0042). Doc-comments ship verbatim and are the last thing describing a fixed bug as a contract; a published changelog entry is never rewritten, only superseded; a decision record whose premise the change falsified is amended in that same change, because
hooks/register.tsx 488 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import {
5 focusOf,
6 isDone,
7 isSheetPath,
8 labelOf,
9 minutes,
10 onAnswer,
11 onDismiss,
12 onEdit,
13 onSkill,
14 onTurnEnd,
15 parseSheet,
16 STEP_COLOR,
17 gantt,
18 stepAt,
19 trail,
20 turnSummary,
21 addLog,
22 sheetLog,
23 testCounts,
24 testOf,
25 baseName,
26 sheetLine,
27 sheetPathFor,
28 routeName,
29 startRun,
30 stepLabel,
31 stepsOf,
32} from './run'
33
34const run = atom({ plugin: 'thegraph-panel', key: 'run' } as const, null)
35const isPaneOpen = atom({ plugin: 'thegraph-panel', key: 'isPaneOpen' } as const, false)
36const now = atom({ plugin: 'thegraph-panel', key: 'now' } as const, 0)
37const turnLines = atom({ plugin: 'thegraph-panel', key: 'turnLines' } as const, {})
38
39const PANE = 'thegraph'
40/** Blank cells at the line's left and right, as usage-band keeps, so both share their edges. */
41const LEFT = 2
42const RIGHT = 1
43/** A running step's minutes redraw this often while nothing else happens. */
44const TICK_MS = 30_000
45
46/** Cells for a step's name: before its gantt bar, and in the log's step column. */
47const STEP_CELLS = 10
48
49/** Turn lines kept, the oldest dropped first. */
50const TURN_LINES_MAX = 200
51
52// When the last main-thread turn ended: a turn's summary counts what the log gained since.
53let lastTurnEnd = 0
54
55// What followed /thegraph on the prompt, waiting for the skill to expand. Lost on a reload: the run starts unlabelled.
56let pendingLabel: string | null = null
57
58// The session's folder, whose name heads each sheet's file name; and the OS temp folder, asked once.
59let cwd: string | null = null
60let tempDir: string | null | undefined
61// The sheet's modification time when it was last read, so an unchanged sheet is not read again.
62let sheetSeen = -1
63
64// No shell runs the argv: cmd answers on Windows, sh elsewhere.
65const TEMP_ASKS: readonly (readonly string[])[] = [
66 ['cmd', '/c', 'echo %TEMP%'],
67 ['sh', '-c', 'printf %s "${TMPDIR:-/tmp}"'],
68]
69
70const askTempDir = async ($: EngineInterface): Promise<string | null> => {
71 if (tempDir !== undefined) return tempDir
72 tempDir = null
73 for (const argv of TEMP_ASKS) {
74 try {
75 const r = await $.process.run(argv, { timeoutMs: 5_000 })
76 const dir = r.stdout.trim()
77 if (r.exitCode === 0 && dir !== '' && !dir.includes('%')) {
78 tempDir = dir
79 break
80 }
81 } catch {
82 // This one cannot start here; ask the next.
83 }
84 }
85 return tempDir
86}
87
88// Reads the sheet again when its file has changed since the last read.
89const checkSheet = async ($: EngineInterface) => {
90 const path = (await read($, run))?.sheetPath
91 if (path == null) return
92 let mtime: number
93 try {
94 mtime = (await $.fs.stat(path)).mtimeMs
95 } catch {
96 return
97 }
98 if (mtime === sheetSeen) return
99 sheetSeen = mtime
100 await readSheet($, path)
101}
102
103const tick = async ($: EngineInterface) => {
104 const r = await read($, run)
105 if (r !== null && r.doneAt === null) {
106 const t = await $.clock.now()
107 await update($, now, () => t)
108 // A write the tool hooks did not see (another process, a missed call) still shows within a tick.
109 await checkSheet($)
110 }
111}
112
113// This plugin's own ui.close hook does not run under its own call, so the flag is cleared here.
114const closePane = async ($: EngineInterface) => {
115 if (await read($, isPaneOpen)) {
116 await $.ui.close({ id: PANE })
117 await update($, isPaneOpen, () => false)
118 }
119}
120
121// Closes the pane whatever this plugin remembers: after /clear its state is gone and the pane is not.
122const shutPane = async ($: EngineInterface) => {
123 try {
124 await $.ui.close({ id: PANE })
125 } catch {
126 // Not open; nothing to close.
127 }
128 await update($, isPaneOpen, () => false)
129}
130
131// A pane open with no run behind it, as /clear or /resume leaves one, is closed.
132const shutOrphanPane = async ($: EngineInterface) => {
133 if ((await read($, run)) !== null) return
134 let isOpen = false
135 try {
136 isOpen = (await $.ui.panes()).some(p => p.id === PANE)
137 } catch {
138 return
139 }
140 if (isOpen) await shutPane($)
141}
142
143const togglePane = async ($: EngineInterface) => {
144 if (await read($, isPaneOpen)) {
145 await closePane($)
146 return
147 }
148 const opened = await $.ui.open({ id: PANE, title: 'thegraph', closeOnEscape: true })
149 await update($, isPaneOpen, () => true)
150 try {
151 await $.ui.scroll({ in: PANE, to: 'end' })
152 } catch {
153 // Nothing drawn yet to scroll; the pane opens at its top.
154 }
155 if (!opened.isPlaced) $.ui.toast(`thegraph: the pane waits (${opened.reason})`)
156}
157
158// A field of a tool's result, which reaches this module untyped.
159const field = (value: unknown, key: string): unknown =>
160 value !== null && typeof value === 'object' && key in value ? (value as Record<string, unknown>)[key] : undefined
161const resultOf = (result: object): unknown => ('result' in result ? result.result : undefined)
162
163// Reads the run sheet as the model left it; one that cannot be read leaves what was there.
164const readSheet = async ($: EngineInterface, path: string) => {
165 let text: string
166 try {
167 text = await $.fs.read(path)
168 } catch {
169 return
170 }
171 const sheet = parseSheet(path, text)
172 const t = await $.clock.now()
173 await update($, run, r => (r === null ? r : sheetLog(r.sheet, sheet, t).reduce(addLog, { ...r, sheet })))
174}
175
176// The person gave the run up: the line goes, and the pane with it.
177const dismiss = async ($: EngineInterface) => {
178 await closePane($)
179 await update($, run, () => null)
180}
181
182export const register: Register = on => {
183 on('session.start', async ($, e, next) => {
184 cwd = e.cwd
185 const result = await next(e)
186 $.clock.every(TICK_MS, () => void tick($))
187 return result
188 })
189
190 on('prompt.submit', async ($, e, next) => {
191 // The person's own words, typed here or through Remote Control; not a notification or another plugin's.
192 if (e.origin.kind === 'composer' || e.origin.kind === 'bridge') {
193 const label = labelOf(e.text)
194 if (label !== null) {
195 pendingLabel = label
196 } else {
197 await shutOrphanPane($)
198 const t = await $.clock.now()
199 const before = await read($, run)
200 // A slash command (/reload-plugins, /context) answers nothing, though it does move on from a finished run.
201 const isCommand = e.text.trimStart().startsWith('/')
202 if (before !== null && (!isCommand || before.doneAt !== null)) {
203 const after = onAnswer(before, t)
204 await update($, run, () => after)
205 if (after === null) await closePane($)
206 }
207 }
208 }
209 return next(e)
210 }).catch(($, e, next) => next(e))
211
212 on('skill.prompt', async ($, e, next) => {
213 const t = await $.clock.now()
214 if (e.skill === 'thegraph') {
215 // Name this run's sheet now and tell the run, so the one file is known to be ours.
216 const temp = await askTempDir($)
217 const hex = Math.floor(Math.random() * 0x100000000).toString(16).padStart(8, '0')
218 const sheetPath = temp === null ? null : sheetPathFor(temp, baseName(cwd ?? '') || 'repo', t, hex)
219 await update($, run, () => ({ ...startRun(pendingLabel, t), sheetPath }))
220 pendingLabel = null
221 sheetSeen = -1
222 await update($, now, () => t)
223 const result = await next(e)
224 return sheetPath === null ? result : { ...result, text: result.text + sheetLine(sheetPath) }
225 }
226 if ((await read($, run)) !== null) {
227 await update($, run, r => (r === null ? r : onSkill(r, e.skill, t)))
228 }
229 await update($, now, () => t)
230 return next(e)
231 })
232
233 on('turn.complete', async ($, e, next) => {
234 const r = await read($, run)
235 if (e.agentId === undefined && !e.isAborted && r !== null) {
236 const t = await $.clock.now()
237 const since = Math.max(lastTurnEnd, r.startedAt)
238 const focus = focusOf(r)
239 const step = focus?.key ?? r.log.findLast(l => l.kind === 'step')?.text ?? null
240 const line = turnSummary(r.log, since, step)
241 const key = String(e.durationMs)
242 await update($, turnLines, lines => Object.fromEntries([...Object.entries(lines), [key, line]].slice(-TURN_LINES_MAX)))
243 lastTurnEnd = t
244 await update($, run, r2 => (r2 === null ? r2 : onTurnEnd(r2, t)))
245 await update($, now, () => t)
246 }
247 return next(e)
248 })
249
250 // While the run works, the spinner says where it is: `… · thegraph make-it 3/5 · ✎5`.
251 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
252 const r = await read($, run)
253 const focus = r === null ? null : focusOf(r)
254 if (r === null || focus === null) return next(e)
255 const total = stepsOf(r).length
256 const edits = r.log.filter(l => l.kind === 'edit' && l.at >= Math.max(lastTurnEnd, r.startedAt)).length
257 const tail = `… · thegraph ${stepLabel(focus.key, focus.state)} ${focus.index + 1}/${total}${edits > 0 ? ` · ✎${edits}` : ''}`
258 return next({ ...e, props: { ...e.props, suffix: tail } })
259 })
260
261 // A turn's closing line keeps what that turn did, beside the engine's own words.
262 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
263 const line = (await read($, turnLines))[String(e.props.durationMs)]
264 if (line === undefined) return next(e)
265 const { Box, Text } = $.ui.resolve(e)
266 const theirs = await next(e)
267 return (
268 <Box flexDirection="row">
269 {theirs}
270 <Text dimColor={false}>
271 {line.map(seg => (seg.color === null ? <Text dimColor>{seg.text}</Text> : <Text color={seg.color}>{seg.text}</Text>))}
272 </Text>
273 </Box>
274 )
275 })
276
277 // A question put to the person inside a turn: the confirm stop is often asked this way,
278 // and its reply is the answer no prompt would bring.
279 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
280 if (e.agentId !== undefined || (await read($, run)) === null) return next(e)
281 const asked = await $.clock.now()
282 const questions = e.questions.map(q => q.question)
283 await update($, run, r =>
284 r === null ? r : questions.reduce((acc, q) => addLog(acc, { at: asked, kind: 'ask', text: q, detail: null }), onTurnEnd(r, asked)),
285 )
286 const result = await next(e)
287 const t = await $.clock.now()
288 const answers = (field(resultOf(result), 'answers') ?? {}) as Record<string, string>
289 // Esc puts the box away with nothing answered, however the engine words it (a deny, an error,
290 // no answers): no answer at all, and the run is not taken as answered.
291 const isDismissed = !questions.some(q => answers[q] !== undefined)
292 await update($, run, r => {
293 if (r === null) return r
294 // Each question's line, newest first, gets its answer; one left unanswered says so.
295 const log = [...r.log]
296 for (const q of questions) {
297 const i = log.findLastIndex(l => l.kind === 'ask' && l.text === q && l.detail === null)
298 const entry = log[i]
299 if (entry) log[i] = { ...entry, detail: isDismissed ? 'dismissed' : (answers[q] ?? 'no answer') }
300 }
301 return isDismissed ? onDismiss({ ...r, log }) : onAnswer({ ...r, log }, t)
302 })
303 await update($, now, () => t)
304 return result
305 }).catch(($, e, next) => next(e))
306
307 // A write to the run sheet is the run's own account: read it back once written.
308 // Any other file edit right after the confirm is make-it begun without its skill.
309 on('tool.call', { tool: ['Edit', 'Write', 'NotebookEdit'] }, async ($, e, next) => {
310 if (e.agentId !== undefined || (await read($, run)) === null) return next(e)
311 const path = 'file_path' in e ? e.file_path : undefined
312 if (path !== undefined && isSheetPath(path)) {
313 // The run named its own sheet (no path was handed to it, or it kept an older one): follow it.
314 await update($, run, r => (r === null || r.sheetPath === path ? r : { ...r, sheetPath: path }))
315 sheetSeen = -1
316 return next(e)
317 }
318 const t = await $.clock.now()
319 await update($, run, r => (r === null ? r : onEdit(r, t)))
320 const result = await next(e)
321 if (path !== undefined && !('deny' in result && result.deny !== undefined)) {
322 const isNew = field(resultOf(result), 'type') === 'create'
323 const name = path.split(/[\\/]/).filter(Boolean).at(-1) ?? path
324 await update($, run, r => (r === null ? r : addLog(r, { at: t, kind: 'edit', text: name, detail: null, isOk: isNew })))
325 }
326 return result
327 }).catch(($, e, next) => next(e))
328
329 // A shell command that runs tests: its result goes in the log, with the counts it printed.
330 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
331 const runner = testOf(e.command)
332 if (e.agentId !== undefined || runner === null || (await read($, run)) === null) return next(e)
333 const result = await next(e)
334 const t = await $.clock.now()
335 const done = resultOf(result)
336 const out = `${String(field(done, 'stdout') ?? '')}\n${String(field(done, 'stderr') ?? '')}`
337 const isOk = done !== undefined && !('isError' in result && result.isError === true) && field(done, 'interrupted') !== true
338 await update($, run, r => (r === null ? r : addLog(r, { at: t, kind: 'test', text: runner, detail: testCounts(out), isOk })))
339 return result
340 }).catch(($, e, next) => next(e))
341
342 // Whatever tool wrote it (Bash, Write, a script), the sheet changes only under a tool call:
343 // look at its modification time once each main-thread call has run.
344 on('tool.call', async ($, e, next) => {
345 const result = await next(e)
346 if (e.agentId === undefined) await checkSheet($)
347 return result
348 }).catch(($, e, next) => next(e))
349
350 // The person's close (its mark, ctrl+x x, Esc) as much as the button's.
351 on('ui.close', async ($, e, next) => {
352 if (e.id === PANE) await update($, isPaneOpen, () => false)
353 return next(e)
354 }).catch(($, e, next) => next(e))
355
356 // One line over whatever the other bands draw: the steps in order, each marked, the time in
357 // the one at hand, a failed last test and the signals; and at the right edge, lined up with
358 // the band's beneath, the pane's button and ×.
359 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
360 const below = await next(e)
361 const r = await read($, run)
362 if (r === null || e.props.hasSurvey) return below
363
364 const isOpen = await read($, isPaneOpen)
365 const { Box, Button, Text } = $.ui.resolve(e)
366 const t = Math.max(await read($, now), r.startedAt)
367 const segs = trail(r, t)
368 const line = (
369 <Box flexDirection="row" justifyContent="space-between" paddingLeft={LEFT} paddingRight={RIGHT}>
370 <Text wrap="truncate-end">
371 <Text bold>thegraph</Text>
372 {r.label !== null && <Text dimColor>{` ${r.label}`}</Text>}
373 <Text>{' '}</Text>
374 {segs.map(g => <Text color={g.color ?? undefined} bold={g.bold} dimColor={g.dim}>{g.text}</Text>)}
375 </Text>
376 <Box flexDirection="row" flexShrink={0}>
377 <Text>{' '}</Text>
378 <Button key="pane" variant="primary" hotkey="g" onPress={() => togglePane($)}>
379 {isOpen ? '▾ close pane' : '▸ pane'}
380 </Button>
381 <Text> </Text>
382 <Button key="dismiss" role="dismiss" hotkey="x" dimColor onPress={() => dismiss($)}>
383 ×
384 </Button>
385 {/* The main screen reports no clicks: name the keys that press them there (ctrl+x tab focuses the band). */}
386 {e.viewport?.isFullscreen === false && <Text dimColor> ^x⇥ g·x</Text>}
387 </Box>
388 </Box>
389 )
390 return (
391 <Box flexDirection="column">
392 {line}
393 {below}
394 </Box>
395 )
396 })
397
398 // The pane: a header, a gantt of the steps, then the log as a table, oldest first, opened at its end.
399 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
400 const { Box, Button, Text } = $.ui.resolve(e)
401 const r = await read($, run)
402 // The pane's own way out: the band's button goes with the run, and /clear takes the run.
403 const close = (
404 <Box flexDirection="row" flexShrink={0}>
405 <Text> </Text>
406 <Button key="close" role="dismiss" hotkey="q" dimColor onPress={() => shutPane($)}>
407 close
408 </Button>
409 {e.viewport?.isFullscreen === false && <Text dimColor> ^x x</Text>}
410 </Box>
411 )
412 if (r === null) {
413 return (
414 <Box flexDirection="row" justifyContent="space-between">
415 <Text dimColor>thegraph is not running.</Text>
416 {close}
417 </Box>
418 )
419 }
420 const t = Math.max(await read($, now), r.startedAt)
421 const route = r.sheet?.route ?? (r.route === null ? null : routeName(r.route))
422 // The gantt: a row per step, the run's time across, filled wherever that step ran.
423 const end = Math.max(t, r.doneAt ?? 0)
424 const bars = gantt(r, end, Math.max(8, Math.min(48, e.props.bodyColumns - STEP_CELLS - 5))).map(g => (
425 <Text wrap="truncate-end">
426 <Text bold={g.state === 'run' || g.state === 'wait'} dimColor={g.state === 'todo'}>{g.key.padEnd(STEP_CELLS)}</Text>
427 {g.cells.map(c => (c ? <Text color={STEP_COLOR[g.key] ?? 'white'}>█</Text> : <Text dimColor>·</Text>))}
428 <Text dimColor>{` ${g.ms === null ? '–' : minutes(g.ms)}`.padStart(5)}</Text>
429 </Text>
430 ))
431 const lines = r.log.map((l, i) => {
432 const at = `+${String(Math.floor((l.at - r.startedAt) / 60_000)).padStart(2)}m `
433 const step = stepAt(r, l.at)
434 const prev = r.log[i - 1]
435 const isFirst = prev === undefined || stepAt(r, prev.at) !== step
436 return (
437 <Text wrap="wrap">
438 <Text dimColor>{at}</Text>
439 {isFirst && step !== null
440 ? <Text color={STEP_COLOR[step.split(' ')[0] ?? ''] ?? undefined}>{step.padEnd(STEP_CELLS + 1)}</Text>
441 : <Text dimColor>{(step === null ? '' : '│').padEnd(STEP_CELLS + 1)}</Text>}
442 {l.kind === 'step' && <Text bold>{`▸ ${l.text}`}</Text>}
443 {l.kind === 'ask' && (
444 <Text>
445 <Text color="warning">◆ </Text>
446 <Text>{l.text}</Text>
447 {l.detail === null ? <Text color="warning"> …waiting</Text> : <Text dimColor> → </Text>}
448 {l.detail !== null && <Text bold>{l.detail}</Text>}
449 </Text>
450 )}
451 {l.kind === 'edit' && <Text color="blue">{`${l.isOk ? '+' : '✎'} ${l.text}`}</Text>}
452 {l.kind === 'test' && (
453 <Text>
454 <Text color={l.isOk ? 'success' : 'error'}>{l.isOk ? '✓ ' : '✗ '}</Text>
455 <Text>{l.text}</Text>
456 {l.detail !== null && <Text dimColor>{` ${l.detail}`}</Text>}
457 </Text>
458 )}
459 {l.kind === 'signal' && <Text color="magenta">{`⚑ ${l.text}`}</Text>}
460 {l.kind === 'note' && <Text dimColor>{`→ ${l.text}`}</Text>}
461 {l.kind === 'carry' && <Text color="cyan">{`↗ ${l.text}`}</Text>}
462 {l.kind === 'end' && <Text bold color="success">✓ done</Text>}
463 </Text>
464 )
465 })
466 return (
467 <Box flexDirection="column">
468 <Box flexDirection="row" justifyContent="space-between">
469 <Text wrap="truncate-end">
470 <Text bold>thegraph</Text>
471 {r.label !== null && <Text dimColor>{` ${r.label}`}</Text>}
472 {route !== null && <Text color="cyan">{` · ${route}`}</Text>}
473 <Text dimColor>{` · ${minutes((r.doneAt ?? t) - r.startedAt)}`}</Text>
474 {isDone(r) && <Text color="success"> · done</Text>}
475 </Text>
476 {close}
477 </Box>
478 {r.sheet?.issue != null && <Text dimColor wrap="wrap">{r.sheet.issue}</Text>}
479 <Text dimColor>{'─'.repeat(Math.max(8, Math.min(40, e.props.bodyColumns)))}</Text>
480 {bars}
481 <Text> </Text>
482 <Text dimColor>{`time ${'step'.padEnd(STEP_CELLS + 1)}what`}</Text>
483 {lines.length === 0 ? <Text dimColor>Nothing logged yet.</Text> : lines}
484 </Box>
485 )
486 })
487}
488hooks/run.ts 341 lines1import type { LogEntry, Route, Run, Seg, Sheet, SheetStep, StepRec } from '../types'
2
3/** The stops where a person answers: after read-it, and after lens. */
4export const CONFIRM = 'confirm'
5export const DECIDE = 'decide'
6
7/** Skills that are steps of the order; anything else thegraph calls is ignored. */
8const STEP_SKILLS = ['read-it', 'lens', 'make-it', 'check-it', 'ask-it']
9
10/** Skills that interrupt the order whenever their moment comes. */
11export const SIGNAL_SKILLS = ['redden', 'firsthand', 'boundary']
12
13/** The steps a route walks; before the route is known, the build one. */
14export const plan = (route: Route | null): string[] =>
15 route === 'decision' ? ['read-it', CONFIRM, 'lens', DECIDE] : ['read-it', CONFIRM, 'make-it', 'check-it', 'ask-it']
16
17/** How the pane names a route: read-it says prose or code only in its text, which no event carries. */
18export const routeName = (route: Route): string => (route === 'decision' ? 'open decision' : 'prose/code')
19
20/** `/thegraph #42 band redraw` → `#42 band redraw`; null for a prompt that does not start one. */
21export const labelOf = (text: string): string | null => {
22 const m = /^\/thegraph(?![\w-])\s*(.*)$/s.exec(text.trim())
23 if (!m) return null
24 const rest = (m[1] ?? '').split('\n')[0]?.trim() ?? ''
25 return rest === '' ? '' : [...rest].slice(0, 40).join('')
26}
27
28export const startRun = (label: string | null, now: number): Run => ({
29 label: label === '' ? null : label,
30 startedAt: now,
31 route: null,
32 steps: [],
33 signals: [],
34 isWaiting: false,
35 isDismissed: false,
36 doneAt: null,
37 sheetPath: null,
38 sheet: null,
39 log: [],
40})
41
42/** The log keeps this many lines, the oldest dropped first. */
43const LOG_MAX = 300
44
45/** Adds a line to the log; a step already the last one begun is not logged again. */
46export const addLog = (run: Run, entry: LogEntry): Run => {
47 if (entry.kind === 'step' && run.log.findLast(l => l.kind === 'step')?.text === entry.text) return run
48 return { ...run, log: [...run.log, entry].slice(-LOG_MAX) }
49}
50
51const logged = (run: Run, at: number, kind: LogEntry['kind'], text: string): Run => addLog(run, { at, kind, text, detail: null })
52
53const openStep = (run: Run): StepRec | undefined => run.steps.find(s => s.endedAt === null)
54const closeAll = (steps: StepRec[], now: number) => steps.map(s => (s.endedAt === null ? { ...s, endedAt: now } : s))
55const begin = (run: Run, key: string, now: number): StepRec[] => [...closeAll(run.steps, now), { key, startedAt: now, endedAt: null }]
56
57/** Right after the confirm stop, nothing begun since: where thegraph picks its route. */
58const isAtRoute = (run: Run) => openStep(run) === undefined && run.steps.at(-1)?.key === CONFIRM
59
60/** A skill was expanded while the run is open. */
61export const onSkill = (run: Run, skill: string, now: number): Run => {
62 if (run.doneAt !== null) return run
63 if (SIGNAL_SKILLS.includes(skill)) return logged({ ...run, signals: [...run.signals, { skill, at: now }] }, now, 'signal', skill)
64 if (!STEP_SKILLS.includes(skill)) return run
65 if (skill === 'lens') {
66 // check-it runs lens on every change: only a lens at the route is the open-decision step.
67 if (run.route === 'decision' || (run.route === null && isAtRoute(run))) {
68 return logged({ ...run, route: 'decision', isWaiting: false, steps: begin(run, skill, now) }, now, 'step', skill)
69 }
70 return run
71 }
72 const route: Route | null = skill === 'read-it' ? run.route : (run.route ?? 'build')
73 return logged({ ...run, route, isWaiting: false, steps: begin(run, skill, now) }, now, 'step', skill)
74}
75
76/**
77 * The move passed to the person: a main-thread turn ended, or a question
78 * (AskUserQuestion) went up within one. Whatever runs now waits on them.
79 */
80export const onTurnEnd = (run: Run, now: number): Run => {
81 if (run.doneAt !== null) return run
82 const open = openStep(run)
83 if (open?.key === 'read-it') return logged({ ...run, isWaiting: true, steps: begin(run, CONFIRM, now) }, now, 'step', CONFIRM)
84 if (open?.key === 'lens') return logged({ ...run, isWaiting: true, steps: begin(run, DECIDE, now) }, now, 'step', DECIDE)
85 // Anything else, a trivial end after the confirm included, is the person's to say: the run waits, and × ends it.
86 return { ...run, isWaiting: true }
87}
88
89/** The person answered: a prompt of their own, or a question's reply. Null once a finished run has been moved on from. */
90export const onAnswer = (run: Run, now: number): Run | null => {
91 if (run.doneAt !== null) return null
92 // After a question put away, the person's words take the step up again: no stop closes, nothing ends.
93 if (run.isDismissed) return { ...run, isWaiting: false, isDismissed: false }
94 if (!run.isWaiting) return run
95 const open = openStep(run)
96 if (open?.key === CONFIRM) return { ...run, isWaiting: false, steps: closeAll(run.steps, now) }
97 if (open?.key === DECIDE || open?.key === 'ask-it') {
98 return logged({ ...run, isWaiting: false, steps: closeAll(run.steps, now), doneAt: now }, now, 'end', 'done')
99 }
100 return { ...run, isWaiting: false }
101}
102
103/** A question box put away with nothing answered: the run still waits on the person, and is not answered. */
104export const onDismiss = (run: Run): Run => (run.doneAt !== null ? run : { ...run, isWaiting: true, isDismissed: true })
105
106/**
107 * A main-thread file edit. Right after the confirm, with nothing begun, it is
108 * make-it's work started without its skill being expanded: an inference, so it
109 * only ever opens make-it there, and never on the decision route.
110 */
111export const onEdit = (run: Run, now: number): Run =>
112 run.doneAt === null && run.route !== 'decision' && isAtRoute(run) ? onSkill(run, 'make-it', now) : run
113
114export type StepState = 'done' | 'run' | 'wait' | 'todo'
115
116/**
117 * Where one planned step stands: its latest record decides, unless a step before
118 * it in the plan began again since (check-it sending work back to make-it), which
119 * leaves it to do once more.
120 */
121export const stateOf = (run: Run, key: string): StepState => {
122 const rec = run.steps.findLast(s => s.key === key)
123 if (!rec) return 'todo'
124 const keys = plan(run.route)
125 const earlier = keys.slice(0, keys.indexOf(key))
126 if (run.steps.some(s => earlier.includes(s.key) && s.startedAt > rec.startedAt)) return 'todo'
127 if (rec.endedAt !== null) return 'done'
128 return run.isWaiting || key === CONFIRM || key === DECIDE ? 'wait' : 'run'
129}
130
131// --- the run sheet ---------------------------------------------------------------
132
133/** A run sheet's file name: `<repo>-<yyyymmdd>-<hhmm>-<8 hex>.md` in a `thegraph` folder. */
134const SHEET_PATH = /[\\/]thegraph[\\/][^\\/]+-\d{8}-\d{4}-[0-9a-f]{8}\.md$/i
135export const isSheetPath = (path: string): boolean => SHEET_PATH.test(path)
136
137/**
138 * The path a run's sheet gets: `<temp>/thegraph/<repo>-<yyyymmdd>-<hhmm>-<hex>.md`, in the
139 * temp folder's own separator, the time as the engine's clock reads it locally.
140 */
141export const sheetPathFor = (tempDir: string, repo: string, now: number, hex: string): string => {
142 const sep = tempDir.includes('\\') ? '\\' : '/'
143 const d = new Date(now)
144 const two = (n: number) => String(n).padStart(2, '0')
145 const stamp = `${d.getFullYear()}${two(d.getMonth() + 1)}${two(d.getDate())}-${two(d.getHours())}${two(d.getMinutes())}`
146 const name = repo.replace(/[^\w.-]+/g, '-') || 'repo'
147 return `${tempDir.replace(/[\\/]+$/, '')}${sep}thegraph${sep}${name}-${stamp}-${hex}.md`
148}
149
150/** The line added to thegraph's text so the run writes where this plugin watches. */
151export const sheetLine = (path: string): string => `\n\nThis run's run sheet: ${path}\n`
152
153/** The last part of a path, either separator. */
154export const baseName = (path: string): string => path.split(/[\\/]/).filter(Boolean).at(-1) ?? ''
155
156/** The sheet's keys for the two stops, drawn in the line's own words. */
157const SHEET_KEY: Record<string, string> = { confirm: CONFIRM, decide: DECIDE }
158const SHEET_MARK: Record<string, SheetStep['mark']> = { ' ': 'todo', '~': 'doing', x: 'done', X: 'done' }
159
160/** Reads a run sheet's text; lines it does not know are skipped. */
161export const parseSheet = (path: string, text: string): Sheet => {
162 const sheet: Sheet = { path, issue: null, route: null, steps: [], carried: [] }
163 let isCarried = false
164 for (const line of text.split(/\r?\n/)) {
165 const field = /^(issue|route):\s*(.*\S)\s*$/.exec(line)
166 const step = /^- \[([ ~xX])\]\s+(\S+)/.exec(line)
167 const note = /^\s+→\s*(.*\S)\s*$/.exec(line)
168 if (/^##\s/.test(line)) isCarried = /^##\s+Carried\b/i.test(line)
169 else if (isCarried && /^- /.test(line)) sheet.carried.push(line.slice(2).trim())
170 else if (field?.[1] === 'issue') sheet.issue = field[2] ?? null
171 // A template placeholder (`<read-it's label ...>`) is no route yet.
172 else if (field?.[1] === 'route') sheet.route = (field[2] ?? '').startsWith('<') ? null : (field[2] ?? null)
173 else if (step) sheet.steps.push({ key: SHEET_KEY[step[2] ?? ''] ?? step[2] ?? '', mark: SHEET_MARK[step[1] ?? ' '] ?? 'todo', note: null })
174 else if (note) {
175 const last = sheet.steps[sheet.steps.length - 1]
176 if (last) last.note = note[1] ?? null
177 }
178 }
179 return sheet
180}
181
182/** The lines a newer reading of the sheet adds: steps begun, notes written, items carried, the end. */
183export const sheetLog = (before: Sheet | null, after: Sheet, at: number): LogEntry[] => {
184 const out: LogEntry[] = []
185 const was = new Map((before?.steps ?? []).map(s => [s.key, s]))
186 for (const step of after.steps) {
187 const old = was.get(step.key)
188 if (step.mark === 'doing' && old?.mark !== 'doing') out.push({ at, kind: 'step', text: step.key, detail: null })
189 if (step.note !== null && step.note !== old?.note) out.push({ at, kind: 'note', text: step.note, detail: null })
190 }
191 const carried = new Set(before?.carried ?? [])
192 for (const item of after.carried) if (!carried.has(item)) out.push({ at, kind: 'carry', text: item, detail: null })
193 const isEnd = (sheet: Sheet | null) => sheet !== null && sheet.steps.length > 0 && sheet.steps.every(s => s.mark === 'done')
194 if (isEnd(after) && !isEnd(before)) out.push({ at, kind: 'end', text: 'done', detail: null })
195 return out
196}
197
198/** The test runner a shell command calls, by name; null for any other command. */
199const TEST_RUNNER = /\b(vitest|jest|pytest|cargo test|go test|bun test|deno test|(?:npm|pnpm|yarn)(?: run)? test|claude plugin test|check-mods|flutter test|dotnet test|gradle test|mvn test)\b/
200export const testOf = (command: string): string | null => TEST_RUNNER.exec(command)?.[1] ?? null
201
202/** `48 pass · 2 fail`, from what a runner printed; null when it printed neither count. */
203export const testCounts = (output: string): string | null => {
204 const pass = /(\d+)\s+pass(?:ed|ing)?\b/i.exec(output)?.[1]
205 const fail = /(\d+)\s+fail(?:ed|ing|ures?)?\b/i.exec(output)?.[1]
206 const parts = [pass === undefined ? null : `${pass} pass`, fail === undefined || fail === '0' ? null : `${fail} fail`].filter(p => p !== null)
207 return parts.length === 0 ? null : parts.join(' · ')
208}
209
210// --- the trail and the gantt -----------------------------------------------------------
211
212/** Each step's colour: its bar on the gantt, its name in a turn's closing line. */
213export const STEP_COLOR: Record<string, string> = {
214 'read-it': 'cyan', [CONFIRM]: 'warning', 'make-it': 'claude', 'check-it': 'success', 'ask-it': 'magenta', lens: 'blue', [DECIDE]: 'warning',
215}
216
217const TRAIL_MARK: Record<StepState, string> = { done: ' ✓', run: ' ◐', wait: ' ◆', todo: '' }
218const TRAIL_COLOR: Record<StepState, string | null> = { done: 'success', run: 'claude', wait: 'warning', todo: null }
219
220/**
221 * The band's line after the run's name: every step in order with its mark, `×2` on one
222 * begun twice, the time in the step at hand (or `done` and the whole run's), then the last
223 * test when it failed and the count of signals.
224 */
225export const trail = (run: Run, now: number): Seg[] => {
226 const segs: Seg[] = []
227 stepsOf(run).forEach(({ key, state }, i) => {
228 if (i > 0) segs.push({ text: ' › ', color: null, dim: true })
229 const n = run.steps.filter(s => s.key === key).length
230 segs.push({ text: `${key}${n > 1 ? `×${n}` : ''}${TRAIL_MARK[state]}`, color: TRAIL_COLOR[state], bold: state === 'run' || state === 'wait', dim: state === 'todo' })
231 })
232 const focus = focusOf(run)
233 if (focus === null) {
234 segs.push({ text: ' done', color: 'success', bold: true }, { text: ` · ${minutes((run.doneAt ?? now) - run.startedAt)}`, color: null, dim: true })
235 } else if (focus.state !== 'todo') {
236 const ms = stepMs(run, focus.key, now)
237 if (ms !== null) segs.push({ text: ` · ${minutes(ms)}`, color: null, dim: true })
238 }
239 const tail: Seg[] = []
240 const test = run.log.findLast(l => l.kind === 'test')
241 if (test?.isOk === false) tail.push({ text: `✗${test.detail === null ? '' : ` ${test.detail}`}`, color: 'error' })
242 if (run.signals.length > 0) tail.push({ text: `⚑${run.signals.length}`, color: 'magenta' })
243 tail.forEach((t, i) => segs.push({ ...t, text: `${i === 0 ? ' ' : ' '}${t.text}` }))
244 return segs
245}
246
247/**
248 * The gantt: a row per step in the plan, `width` cells across the run from its start to
249 * `now`, a cell filled wherever that step ran; a step begun twice fills two stretches.
250 * `ms` is its time over every run, null for one not begun.
251 */
252export const gantt = (run: Run, now: number, width: number): { key: string; state: StepState; cells: boolean[]; ms: number | null }[] => {
253 const end = Math.max(now, run.startedAt + 1)
254 const per = (end - run.startedAt) / width
255 return stepsOf(run).map(({ key, state }) => {
256 const recs = run.steps.filter(s => s.key === key)
257 const cells = Array.from({ length: width }, (_, i) => {
258 const from = run.startedAt + i * per
259 return recs.some(s => s.startedAt < from + per && (s.endedAt ?? end) > from)
260 })
261 return { key, state, cells, ms: stepMs(run, key, end) }
262 })
263}
264
265/** The step a moment of the run fell in, `#2` on its second time round; null before any. */
266export const stepAt = (run: Run, at: number): string | null => {
267 const i = run.steps.findLastIndex(s => s.startedAt <= at)
268 const rec = run.steps[i]
269 if (rec === undefined) return null
270 const round = run.steps.slice(0, i + 1).filter(s => s.key === rec.key).length
271 return round > 1 ? `${rec.key} #${round}` : rec.key
272}
273
274// --- the conversation's lines ------------------------------------------------------------
275
276/** What a stretch of the log did, as the turn's closing line carries it: the step, then counts. */
277export const turnSummary = (log: readonly LogEntry[], since: number, step: string | null): Seg[] => {
278 const part = log.filter(l => l.at >= since)
279 const edits = part.filter(l => l.kind === 'edit').length
280 const test = part.findLast(l => l.kind === 'test')
281 const asks = part.filter(l => l.kind === 'ask').length
282 const signals = part.filter(l => l.kind === 'signal').length
283 const segs: Seg[] = [{ text: ' · thegraph ', color: null }]
284 if (step !== null) segs.push({ text: step, color: STEP_COLOR[step] ?? null })
285 if (edits > 0) segs.push({ text: ` ✎${edits}`, color: 'blue' })
286 if (test) segs.push({ text: ` ${test.isOk ? '✓' : '✗'}${test.detail === null ? '' : ` ${test.detail}`}`, color: test.isOk ? 'success' : 'error' })
287 if (asks > 0) segs.push({ text: ` ◆${asks}`, color: 'warning' })
288 if (signals > 0) segs.push({ text: ` ⚑${signals}`, color: 'magenta' })
289 return segs
290}
291
292/** Every step's place: from the run sheet once there is one, else from the events. */
293export const stepsOf = (run: Run): { key: string; state: StepState }[] => {
294 const sheet = run.sheet
295 if (sheet === null || sheet.steps.length === 0) return plan(run.route).map(key => ({ key, state: stateOf(run, key) }))
296 const steps = sheet.steps.map(s => ({ key: s.key, state: (s.mark === 'done' ? 'done' : s.mark === 'doing' ? 'run' : 'todo') as StepState }))
297 // The sheet cannot say that the person holds the move; the events can.
298 if (run.isWaiting) {
299 const at = steps.findIndex(s => s.state === 'run')
300 const stop = steps[at >= 0 ? at : steps.findIndex(s => s.state === 'todo')]
301 if (stop) stop.state = 'wait'
302 }
303 return steps
304}
305
306/** Whether the run is over: its events said so, or every step on its sheet is checked. */
307export const isDone = (run: Run): boolean =>
308 run.doneAt !== null || (run.sheet !== null && run.sheet.steps.length > 0 && run.sheet.steps.every(s => s.mark === 'done'))
309
310/**
311 * The step the line names: the one running or waiting, else the next one to do;
312 * null once the run is done. `index` is its place in the plan, from 0.
313 */
314export const focusOf = (run: Run): { key: string; index: number; state: StepState } | null => {
315 if (isDone(run)) return null
316 const steps = stepsOf(run)
317 const at = steps.findIndex(s => s.state === 'run' || s.state === 'wait')
318 const index = at >= 0 ? at : steps.findIndex(s => s.state === 'todo')
319 const step = steps[index]
320 return step === undefined ? null : { key: step.key, index, state: step.state }
321}
322
323/** A step's name as drawn: a step that waits on the person says so. */
324export const stepLabel = (key: string, state: StepState): string => (state === 'wait' ? `${key} · waiting` : key)
325
326/** Milliseconds a step took, or has taken so far, over every time it ran. */
327export const stepMs = (run: Run, key: string, now: number): number | null => {
328 const recs = run.steps.filter(s => s.key === key)
329 if (recs.length === 0) return null
330 return recs.reduce((ms, s) => ms + ((s.endedAt ?? now) - s.startedAt), 0)
331}
332
333export const minutes = (ms: number): string => (ms < 60_000 ? '<1m' : `${Math.round(ms / 60_000)}m`)
334
335/** `redden×2 firsthand`, in the order each first fired. */
336export const signalSummary = (run: Run): string => {
337 const counts = new Map<string, number>()
338 for (const s of run.signals) counts.set(s.skill, (counts.get(s.skill) ?? 0) + 1)
339 return [...counts].map(([k, n]) => (n > 1 ? `${k}×${n}` : k)).join(' ')
340}
341types/index.d.ts 85 lines1/**
2 * Which way the run went after the reading was confirmed: `build` (prose or code,
3 * make-it onwards) or `decision` (lens over the options). A trivial run ends at the
4 * confirm stop with no step after it, which no event tells apart from a pause.
5 */
6export type Route = 'build' | 'decision'
7
8/** One step as it happened: a skill thegraph called, or a stop where a person answers. */
9export type StepRec = { key: string; startedAt: number; endedAt: number | null }
10
11/** A skill that interrupts the order (redden, firsthand, boundary), and when. */
12export type SignalRec = { skill: string; at: number }
13
14/** One step line of a run sheet: `- [~] make-it`, and the note under it for the next step. */
15export type SheetStep = { key: string; mark: 'todo' | 'doing' | 'done'; note: string | null }
16
17/** The run sheet thegraph keeps in the OS temp folder (ADR-0080), as last written. */
18export type Sheet = {
19 path: string
20 /** The issue as read-it resolved it, and its title. */
21 issue: string | null
22 /** read-it's route label, verbatim: trivial, open decision, prose, code. */
23 route: string | null
24 steps: SheetStep[]
25 carried: string[]
26}
27
28/**
29 * One line of the run's log, in the order it happened: a step begun, a question and
30 * its answer, a file written, a test run, a signal, a note to the next step, an item
31 * carried, the end.
32 */
33export type LogEntry = {
34 at: number
35 kind: 'step' | 'ask' | 'edit' | 'test' | 'signal' | 'note' | 'carry' | 'end'
36 text: string
37 /** The answer to a question, a test's counts; null while a question waits. */
38 detail: string | null
39 /** A test that passed, a file the write created. */
40 isOk?: boolean
41}
42
43/** A piece of a line drawn in one colour (a theme key or a colour name, or none for the default), bold or dim. */
44export type Seg = { text: string; color: string | null; bold?: boolean; dim?: boolean }
45
46/** One /thegraph run, as its skill calls and the turns between them showed it. */
47export type Run = {
48 /** What followed `/thegraph` on the prompt (an issue number), or null. */
49 label: string | null
50 startedAt: number
51 route: Route | null
52 steps: StepRec[]
53 signals: SignalRec[]
54 /** The turn ended while the run was open: the next move is the person's. */
55 isWaiting: boolean
56 /**
57 * A question box was put away (Esc) with nothing answered: what the person types
58 * next takes the run up again rather than answering, and so never ends it.
59 */
60 isDismissed: boolean
61 doneAt: number | null
62 /** Where this run's sheet lives: the path this plugin named when thegraph expanded, or one the run wrote itself. */
63 sheetPath: string | null
64 /** The run's own account, once it has written a run sheet; it outranks what the events suggest. */
65 sheet: Sheet | null
66 /** What happened, oldest first; the pane draws it. */
67 log: LogEntry[]
68}
69
70declare module 'claude-code' {
71 interface PluginState {
72 'thegraph-panel': {
73 run: Run | null
74 isPaneOpen: boolean
75 /** The clock as last ticked, so a running step's minutes redraw while idle. */
76 now: number
77 /**
78 * What each turn of the run did, keyed by the turn's duration in milliseconds:
79 * the closing line carries no other mark of which turn it closes.
80 */
81 turnLines: Record<string, Seg[]>
82 }
83 }
84}
85