SLOPSHOPPER

thegraph-panel

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…

newpanebandspinnerguardtoast
v0.1.0no licenseupdated 2026-10-08kihyun1998/skills/mods/thegraph-panel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · thegraph-panel
│ ┃ thegraph ✕ › fix the failing auth test and add an audit log call │ ┃ thegraph is not running. [ close ] │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · thegraph
thegraph is not running. [ close ]
README

kihyun-skills

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.

Skills

SkillWhat it does
thegraph-codexCodex-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-htmlConvert 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.
briefExplain 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.
gateThe 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-deckRetired — 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-codeRetired — 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-pdfCombine 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.
theflowRetired — 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-flowRetired — 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-itRead 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-itWrite 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-itMeasure 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.
decantSort 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).
winnowCut 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.
assayTest 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.
siltRead 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-itPut 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.
thegraphTake 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-graphThe 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.
salvageClear 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.
ticketsRetired — 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.
spineRead 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.
firsthandRead 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

Source 3 files
hooks/register.tsx 488 lines
1import { 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}
488
hooks/run.ts 341 lines
1import 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}
341
types/index.d.ts 85 lines
1/**
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