Review a GraphHelm journey run inline in the Claude Code chat and approve or reject it.

Orchestrate coding agents around the user journeys a change affects, and see the evidence that those journeys still work.
GraphHelm is for developers who hand software work to AI coding agents (Claude Code, Codex) and need to know whether the result actually works for a user. It links three things that usually live in different places: the request you made, the issues and pull requests agents produce for it, and the journey runs that show the affected flows working, step by step, with captured screens and history.
A merge, a green build or an agent saying "done" is not treated as proof. A request reads as proven only when its journeys passed their last run.
Quick start · Install in your project · Studio · Journeys guide · Documentation
Experimental · runs on your machine · MIT licensed. There is no stable release yet; expect a developer-oriented setup and changing interfaces. See status and limitations.
<!-- No checked-in screenshot of the current Proof view exists yet. Add one under docs/assets/ from a real run before linking it here; do not use design mock-ups as evidence. -->
Delegating a multi-step change to agents raises questions a chat transcript does not answer:
GraphHelm answers them from recorded events and journey runs, not from what an agent reports.
Using GraphHelm in your own project:
.graphhelm/journeys/<id>.journey.yaml, a small graph of screens (observable states) and edges (the acts between them), with explicit checks on each screen. A journey is a draft until it passes a preview and you, the owner, approve it.cli/http acts for backends.main with the PR head's. The request moves to Proven only when its journeys passed.Contributing to GraphHelm itself follows the same idea with this repository's own rules: issue first, a Keel card sized to the change, one independent review, merge by the reviewer. See Contributing.
The Studio is a local web page connected to your project's Runtime. It uses only the public Runtime API, so everything it does can also be done from the CLI or MCP. Details: apps/studio/README.md.
Graph: what was asked, and where it stands. Each request you made, with the issues opened for it, their pull requests, and the result, as a graph or as Kanban columns. This is where you see dependencies and what is left. "Merged" is not "done": a card reads Proven only when its journeys passed their last replay. A card in review opens a large review screen with the journey's frames on main (Before) and on the PR head (Now).
Lanes: who is working, and who needs you. One card per agent with its request, progress, last action and idle time. Questions agents ask appear here with your reply. You can Wake, Reassign or Stop an agent, and a Needs action list collects what is blocked on you. The run's capacity is shown too.
Proof: does it work, and what changed. The project's journeys, grouped by feature. Run one and watch the browser being prepared and its log; then read the step cards: each step's status, its checks and its captured frame. A failed step names its cause. Compare a frame with an earlier run (see below), and Approve or Reject a draft journey as the owner.
JPD is the rule behind Proof: a promise is done only when an observer has watched it hold. Full guide: docs/jpd/README.md; design: JOURNEY_PROVEN_DEVELOPMENT.md.
A journey has:
Journeys can be browser flows (accessible roles and names, run by the Playwright observer) or backend flows (http and cli acts). One flow is either browser or backend, not both.
Results are kept distinct:
| Result | Meaning |
|---|---|
| passed | The step ran and every check held. |
| failed / drift | An act could not run (failed), or it ran and a check did not hold (drift). |
| not run / skipped | The step was not reached, so nothing was observed. |
OBSERVER_MISSING | Nothing installed can observe the promise (for example no browser observer). This is never counted as a pass. |
A screenshot on its own proves only that a screen was seen at a revision, not that the promise holds; the checks are what decide a step. A later green run does not erase an earlier failure. To show a journey really observes the code, graphhelm keel test sabotage applies a breaking patch and expects the journey to come back caught.
What Proof and the review screen compare, exactly:
main; Now is the PR head's frame.Limits to keep in mind:
stale when files in its screen's scope changed after it was taken.Prerequisites: Git, Rust 1.97.1 (pinned) and Node 22+ for the Studio. See prerequisites.
1. Install in your project. Follow INSTALL.md top to bottom (or hand it to your agent). It builds the CLI (cargo +1.97.1 install --locked --path apps/cli), runs graphhelm init, starts the Runtime, installs the plugin and registers MCP, with a verify step for each. Installing the plugin alone is not enough.
2. Install the browser observer (for browser journeys):
graphhelm setup --project . --home <home> --install-observer playwright
3. Open the Studio from the project directory:
graphhelm studio start # starts the Runtime and the Studio for this project
graphhelm studio open # opens it in the browser, already logged in
4. Write and run your first journey. Write .graphhelm/journeys/<id>.journey.yaml (the format and a full example are in docs/jpd/README.md; a runnable backend example is examples/jpd-quickstart), then:
graphhelm journey compile <id> --fmt --include-draft --project . # check and format
graphhelm journey preview <id> --project . # play the draft in the background (never proof)
graphhelm journey preview <id> --project . --read # read the result; it must pass
graphhelm journey approve <id> --token-file .graphhelm/events.token --project . # owner approves
graphhelm keel test validation --repo . # runs every journey path at committed HEAD
Approval is refused with flow.preview_not_passed until a preview of that exact flow, at its scope commit, has passed. A draft's cli acts wait at confirm_needed until the owner confirms them (see docs/jpd/README.md). In the Studio, Proof runs the preview and shows Approve once it passed. Step-by-step with real output: docs/guides/journeys.md.
<a id="try-it-locally"></a> <a id="try-a-local-run"></a>
This demo deliberately fails one step so you can see how GraphHelm asks for intervention. It uses fixture outcomes: no model call, no repository edit, no deploy.
git clone https://github.com/stabem/GraphHelm.git && cd GraphHelm
DEMO_DIR="$(mktemp -d)"
printf '%s\n' '{"nodeOutcomes":{"implementation":"failure"}}' > "$DEMO_DIR/fixtures.json"
cargo run --locked -p graphhelm-cli -- execution start \
--file examples/graphs/manual-override-deploy.yaml \
--events "$DEMO_DIR/events" --fixtures "$DEMO_DIR/fixtures.json" \
--mode supervised --execution demo --pretty
cargo run --locked -p graphhelm-cli -- execution status --events "$DEMO_DIR/events" --execution demo
Look for data.attention: "needs_you" and a blocked_node reason naming implementation. The attention values:
| Attention | What it tells you |
|---|---|
needs_you | A named condition requires human attention |
can_sleep | Nothing needs attention, and in-flight work has been checked |
unknown | There is not enough information to judge in-flight silence |
calmed_by_amendment | An explicit silence-budget amendment accounts for the quiet |
A successful execution status can exit 0 while reporting needs_you, so automation should read the JSON field. More in the offline quickstart.
<a id="what-works-today"></a> <a id="repository-map"></a>
core/, adapters/): the source of truth. An append-only event store, typed execution graphs, deterministic policy and validation, and the journey runner. It binds to loopback and protects its API with a bearer token.apps/studio): the local web view (Graph, Lanes, Proof). Talks only to the public Runtime API.apps/cli): graphhelm for people and scripts (JSON envelope for machines, a readable summary in a terminal); the MCP server gives agents the same operations. Plugins for Claude Code and Codex bring them into agent sessions.graphhelm keel check blocking the rules a machine can measure. Keel decides how code is written; journeys decide whether it works.Available: CLI, Runtime, MCP server, Claude Code and Codex plugins, the Studio (Graph, Lanes, Proof), browser journeys with the Playwright observer, backend (http/cli) journeys, owner approval, validation and sabotage tests, Keel checks. A v0.1.1 CLI preview has Windows x86-64 and Ubuntu x86-64 binaries; otherwise build from source. graphhelm update reinstalls from origin/main.
Experimental: journey explore, drift and heal (journeys guide §10); the optional agent-driven e2e observer (needs a model key); scoped agent review of draft journeys (an agent can record a blind review, but the journey stays a draft; approval is owner-only).
Planned, not shipped: a visual graph editor, embedded AI chat, the full Context Compiler and Dreams Engine. The roadmap lists targets, not shipped features.
Trust notes: runs on your machine, but connected model and tool providers may receive project data, so review routes before use. The local event store is not encrypted at rest. There is no automatic paid-provider fallback. There is no hosted CI; validation runs locally.
<a id="contributing-and-trust"></a> <a id="development-method"></a>
Try the quick start and report where it breaks: version, OS, host, command and observed result, with secrets removed.
Changes to GraphHelm follow docs/process/DELIVERY.md: an issue first, branch issue-<N>-<desc>, a Keel card sized to the change, the tests the change reaches listed in the PR, one independent review, and merge by the approving reviewer. Repository rules for agents are in AGENTS.md.
Contribution guide · Issues · Support · Code of conduct
Report vulnerabilities privately through SECURITY.md. GraphHelm is MIT licensed; bundled Studio fonts keep their OFL notices.
hooks/register.tsx 670 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { FlowRow, JourneyView, LaneRow, LanesView, StepView } from '../types'
5
6// Nothing is hard-coded to a machine: the project is the session's cwd, the binary is found on PATH,
7// and the thumbnail helper sits beside this file in the plugin. `ready` fills these before first use.
8let EXE = 'graphhelm'
9let PROJECT = ''
10let TOKEN = ''
11let THUMB = ''
12const ready = async ($: any) => {
13 if (PROJECT && THUMB) return
14 const slash = (p: string) => p.split('\\').join('/')
15 PROJECT = slash(String(await $.session.cwd()))
16 TOKEN = `${PROJECT}/.graphhelm/events.token`
17 THUMB = `${slash(String($.plugin.root))}/hooks/thumb.py`
18}
19const SVG_MAX = 120000
20const PREFIX = 'journey-'
21const EXP = { w: 1100, h: 688 }
22const LIST = 'journeys'
23const TOOL = 'mcp__journey-review__show_journey'
24
25const views = atom({ plugin: 'journey-review', key: 'views' } as const, {} as Record<string, JourneyView>)
26const flowsAtom = atom({ plugin: 'journey-review', key: 'flows' } as const, [] as FlowRow[])
27const flowsErr = atom({ plugin: 'journey-review', key: 'flowsError' } as const, null as string | null)
28const selectedAtom = atom({ plugin: 'journey-review', key: 'selected' } as const, {} as Record<string, string>)
29
30// The token is passed as a file path only; its contents are never read here.
31const approveArgv = (id: string): string[] => [
32 EXE, 'journey', 'approve', id, '--project', PROJECT, '--token-file', TOKEN, '--json',
33]
34const approveText = (id: string): string =>
35 `graphhelm journey approve ${id} --project ${PROJECT} --token-file ${TOKEN}`
36
37const COLOR: Record<string, string> = { pass: 'green', drift: 'yellow', fail: 'red', failed: 'red' }
38const HEX: Record<string, string> = { pass: '#2ea043', drift: '#d29922', fail: '#f85149', failed: '#f85149' }
39const colorOf = (r: string) => COLOR[r] ?? 'gray'
40const hexOf = (r: string) => HEX[r] ?? '#8b949e'
41const esc = (t: string) => t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
42
43const REASONS: Record<string, string> = {
44 page_error: 'the page showed an error',
45 expect_missing: 'an expected element did not appear',
46 act_failed: 'an action could not be done',
47 not_reached: 'the run never got to this step',
48 timeout: 'the step took too long',
49 internal: 'the preview tool hit an internal error',
50}
51const reasonText = (code: string) => REASONS[code] ?? code
52
53const listOf = (yaml: string, key: string): string[] => {
54 const m = new RegExp('^' + key + ':\\s*\\[(.*)\\]', 'm').exec(yaml)
55 return m ? (m[1] ?? '').split(',').map(x => x.trim()).filter(Boolean) : []
56}
57
58const parse = (out: string): any => {
59 try { return JSON.parse(out) } catch { return null }
60}
61
62// Main path order: first edge's source screen, then each edge's target.
63const order = (flow: any): any[] => {
64 const screens: any[] = flow?.screens ?? []
65 const byId = new Map(screens.map(s => [s.id, s]))
66 const edges: any[] = flow?.edges ?? []
67 const main: string[] = flow?.paths?.main ?? edges.map(e => e.id)
68 const out: any[] = []
69 const add = (id: string) => {
70 const s = byId.get(id)
71 if (s && !out.includes(s)) out.push(s)
72 }
73 const first = edges.find(e => e.id === main[0])
74 if (first) add(first.from)
75 for (const eid of main) {
76 const e = edges.find(x => x.id === eid)
77 if (e) add(e.to)
78 }
79 for (const s of screens) add(s.id)
80 return out
81}
82
83const buildView = (id: string, flow: any, kept: any): Partial<JourneyView> => {
84 const steps: StepView[] = order(flow).map(s => {
85 const run = kept?.screens?.[s.id] ?? {}
86 const checks =
87 Array.isArray(run.checks) && run.checks.length
88 ? run.checks.map((c: any) => ({ role: String(c.role), name: String(c.name), seen: c.seen === true }))
89 : (s.expect ?? []).map((c: any) => ({ role: String(c.role), name: String(c.name), seen: null }))
90 const edge = (flow?.edges ?? []).find((x: any) => x.to === s.id)
91 const action = edge ? (edge.acts ?? []).map((a: any) => a.name).filter(Boolean).join(', ') : undefined
92 return {
93 id: s.id,
94 title: s.title ?? s.id,
95 result: run.result ?? 'not run',
96 reason: run.reason,
97 frame: run.frame === true ? `${PROJECT}/.graphhelm/journey-previews/${id}/frames/${s.id}.png` : undefined,
98 action: action || undefined,
99 checks,
100 }
101 })
102 const last = order(flow).slice(-1)[0]
103 const proves = (last?.expect ?? []).map((c: any) => `${c.role} ${c.name}`).join(', ')
104 return {
105 title: flow?.title ?? id,
106 purpose: flow?.purpose,
107 proves: proves ? `"${last?.title ?? 'the last step'}" shows ${proves}` : undefined,
108 steps,
109 overall: kept?.result ?? kept?.state,
110 commit: kept?.commit,
111 ranAt: kept?.ranAt,
112 }
113}
114
115const runPreview = async ($: any, id: string, force: boolean) => {
116 await ready($)
117 const argv = [EXE, 'journey', 'preview', id, '--project', PROJECT, ...(force ? ['--force'] : ['--read']), '--json']
118 const p = await $.process.run(argv, { timeoutMs: 120000 })
119 return { exitCode: p.exitCode as number, kept: parse(p.stdout)?.data }
120}
121
122const load = async ($: any, id: string, force: boolean) => {
123 await ready($)
124 const set = (patch: Partial<JourneyView>) =>
125 update($, views, (all: Record<string, JourneyView>) => ({
126 ...all,
127 [id]: { id, title: id, loading: false, steps: [], rejecting: false, note: '', ...all[id], ...patch },
128 }))
129 await set({ loading: true, error: undefined })
130 const f = await $.process.run([EXE, 'journey', 'flows', '--project', PROJECT, '--json'], { timeoutMs: 60000 })
131 const flow = (parse(f.stdout)?.data?.flows as any[] | undefined)?.find(x => x.id === id)
132 if (!flow) return set({ loading: false, error: `Flow "${id}" not found (flows exit ${f.exitCode}).` })
133
134 let { exitCode, kept } = await runPreview($, id, force)
135 // A run is in the background: poll until it ends (at most about three minutes).
136 for (let n = 0; kept?.state === 'running' && n < 90; n++) {
137 await $.clock.sleep(2000)
138 ;({ kept } = await runPreview($, id, false))
139 }
140 if (!kept) return set({ loading: false, error: `Preview gave no JSON (exit ${exitCode}).` })
141
142 // Nothing kept for the current commit: take the newest kept preview of any commit.
143 let older: string | undefined
144 let empty = false
145 if (kept.state === 'none') {
146 let last: any = null
147 try {
148 last = parse(await $.fs.read(`${PROJECT}/.graphhelm/journey-previews/${id}/state.json`))
149 } catch { /* nothing kept at all */ }
150 if (last && last.screens && Object.keys(last.screens).length && last.state !== 'running') {
151 kept = last
152 older = String(last.commit ?? '').slice(0, 8) || undefined
153 } else {
154 empty = true
155 }
156 }
157 // A fresh run resets the selection to the first step that is not pass.
158 await update($, selectedAtom, (all: Record<string, string>) => {
159 const { [id]: _drop, ...rest } = all
160 return rest
161 })
162 // The flow file adds who it is for and what it risks (the flows JSON does not carry them).
163 let actors: string[] = []
164 let risks: string[] = []
165 try {
166 const yaml = String(await $.fs.read(`${PROJECT}/.graphhelm/journeys/${id}.journey.yaml`))
167 actors = listOf(yaml, 'actors')
168 risks = listOf(yaml, 'risks')
169 } catch { /* no flow file: the block shows what the flows JSON has */ }
170 return set({ ...buildView(id, flow, kept), actors, risks, loading: false, older, empty })
171}
172
173// Thumbnails are made when a card is drawn, only for what it shows, and cached
174// by frame path + mtime + size (module memory: a reload starts it empty).
175const cache = new Map<string, { w: number; h: number; b64: string } | null>()
176const thumb = async ($: any, path: string, w: number, h: number, q = 70, limit = 90000) => {
177 await ready($)
178 let mtime = 0
179 try { mtime = Number((await $.fs.stat(path))?.mtimeMs ?? 0) } catch { /* no stat: key by path */ }
180 const key = `${path}|${mtime}|${w}x${h}|${q}`
181 if (cache.has(key)) return cache.get(key) ?? null
182 const t = await $.process.run(['python', THUMB, path, String(w), String(h), String(q), String(limit)], { timeoutMs: 30000 })
183 const [dims, b64] = String(t.stdout).trim().split(/\r?\n/)
184 const [tw, th] = String(dims).split(' ').map(Number)
185 const out = t.exitCode === 0 && b64 && tw && th ? { w: tw, h: th, b64 } : null
186 cache.set(key, out)
187 return out
188}
189
190const relTime = (iso?: string): string => {
191 const t = iso ? Date.parse(iso) : NaN
192 if (!Number.isFinite(t)) return 'time unknown'
193 const s = Math.max(0, Math.round((Date.now() - t) / 1000))
194 if (s < 90) return 'just now'
195 if (s < 5400) return `${Math.round(s / 60)} min ago`
196 if (s < 129600) return `${Math.round(s / 3600)} h ago`
197 return `${Math.round(s / 86400)} d ago`
198}
199
200const placeholderSvg = (note: string) => {
201 const { w, h } = EXP
202 return (
203 `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">` +
204 `<rect width="${w}" height="${h}" rx="10" fill="#161b22"/>` +
205 `<text x="${w / 2}" y="${h / 2}" text-anchor="middle" font-size="22" fill="#8b949e" font-family="sans-serif">${esc(note)}</text></svg>`
206 )
207}
208
209// The journey card: header, one large frame of the selected step with Prev/Next/Open original,
210// caption, checks, one button per step, and a footer with the buttons.
211const draw = async ($: any, e: any, v: JourneyView, id: string) => {
212 const { Box, Text, Button, Image, Input, Svg } = $.ui.resolve(e) as any
213 const isTerminal = e.surface === 'terminal'
214 const patch = (p: Partial<JourneyView>) =>
215 update($, views, (all: Record<string, JourneyView>) => ({ ...all, [id]: { ...all[id], ...p } }))
216
217 const approve = async (press: any) => {
218 await ready($)
219 const r = await $.process.run(approveArgv(id), { timeoutMs: 120000 })
220 if (r.exitCode === 0 && parse(r.stdout)?.ok !== false) {
221 $.ui.toast(`Approved ${id}.`)
222 await load($, id, false)
223 } else {
224 await $.ui.copy({ text: approveText(id), surface: press?.surface })
225 $.ui.toast(`Approve failed (exit ${r.exitCode}); command copied. Run it in a terminal.`)
226 }
227 }
228 const reject = async (press: any, note: string) => {
229 await $.ui.copy({ text: `Journey ${id} rejected: ${note}`, surface: press?.surface })
230 $.ui.toast('GraphHelm has no reject command; your note is copied. Paste it in the PR or issue.')
231 await patch({ rejecting: false, note: '' })
232 }
233
234 const steps = v.steps
235 const total = steps.length
236 const passed = steps.filter(s => s.result === 'pass').length
237 const anyFail = steps.some(s => s.result === 'fail' || s.result === 'failed')
238 const tally = total === 0 ? 'gray' : anyFail ? 'red' : passed === total ? 'green' : 'yellow'
239
240 // Selection: the atom, else the first step that is not pass, else step 1.
241 const chosen = (await read($, selectedAtom))[id]
242 const firstOpen = steps.findIndex(s => s.result !== 'pass')
243 const selIndex = Math.max(0, steps.findIndex(s => s.id === chosen) >= 0
244 ? steps.findIndex(s => s.id === chosen)
245 : firstOpen >= 0 ? firstOpen : 0)
246 const sel = steps[selIndex]
247
248 // Desktop picture, made lazily for the selected step only: one large JPEG (cached by path + mtime).
249 let frameSvg: string | null = null
250 if (!isTerminal && sel) {
251 const ran = sel.result !== 'not run'
252 if (sel.frame) {
253 const huge = await thumb($, sel.frame, EXP.w, EXP.h, 80, 125000)
254 if (huge) {
255 const src = `<svg xmlns="http://www.w3.org/2000/svg" width="${huge.w}" height="${huge.h}" viewBox="0 0 ${huge.w} ${huge.h}"><image href="data:image/jpeg;base64,${huge.b64}" width="${huge.w}" height="${huge.h}"/></svg>`
256 if (src.length <= 131072) frameSvg = src
257 }
258 } else {
259 frameSvg = placeholderSvg(ran ? 'No screenshot for this step.' : 'No screenshot. The run stopped before this step.')
260 }
261 }
262 const go = (to: number) => async () => {
263 const t = steps[to]
264 if (t) await update($, selectedAtom, (all: Record<string, string>) => ({ ...all, [id]: t.id }))
265 }
266 const original = async () => {
267 if (!sel?.frame) return
268 // explorer.exe wants backslashes; the process runs hidden.
269 await $.process.run(['explorer.exe', sel.frame.split('/').join(String.fromCharCode(92))], { timeoutMs: 15000 })
270 }
271 const pick = (stepId: string) => () => update($, selectedAtom, (all: Record<string, string>) => ({ ...all, [id]: stepId }))
272 const short = (t: string) => (t.length > 18 ? `${t.slice(0, 17)}…` : t)
273 const mark = (r: string) => (r === 'pass' ? '✓' : r === 'not run' ? '·' : '✗')
274
275 const checks: any[] = []
276 if (sel) {
277 if (sel.result === 'not run') {
278 checks.push(<Text key="none" dimColor>Checks did not run</Text>)
279 } else {
280 sel.checks.forEach((c, i) =>
281 checks.push(
282 <Text key={`c${i}`} color={c.seen === true ? 'green' : c.seen === false ? 'red' : undefined}>
283 {c.seen === true ? '✓' : c.seen === false ? '✗' : '·'} {c.role} {c.name}
284 </Text>,
285 ))
286 if (sel.reason && sel.result !== 'pass') {
287 checks.push(
288 <Box key="why" flexDirection="row" gap={1}>
289 <Text color="red">✗ {reasonText(sel.reason)}</Text>
290 <Text dimColor>({sel.reason})</Text>
291 </Box>,
292 )
293 }
294 }
295 }
296
297 return (
298 <Box flexDirection="column" borderStyle="round" paddingX={2} paddingY={1} gap={1}>
299 <Box flexDirection="row" justifyContent="space-between" alignItems="flex-start" gap={2}>
300 <Box flexDirection="column" flexShrink={1}>
301 <Text bold>{v.title}</Text>
302 <Text dimColor>
303 {id} on {v.commit ? v.commit.slice(0, 8) : 'no commit'}, {relTime(v.ranAt)}{v.older ? ' (older)' : ''}
304 </Text>
305 </Box>
306 <Text bold color={tally}>{passed} of {total} passed</Text>
307 </Box>
308 <Box flexDirection="column">
309 <Box flexDirection="row" flexWrap="wrap" gap={1}>
310 <Text dimColor>Journey</Text>
311 <Text>{id.split('-').join(' ')}</Text>
312 </Box>
313 {v.purpose && (
314 <Box flexDirection="row" flexWrap="wrap" gap={1}>
315 <Text dimColor>Why</Text>
316 <Text>{v.purpose}</Text>
317 </Box>
318 )}
319 {v.actors && v.actors.length > 0 && (
320 <Box flexDirection="row" flexWrap="wrap" gap={1}>
321 <Text dimColor>For whom</Text>
322 <Text>the {v.actors.join(', ')}</Text>
323 </Box>
324 )}
325 {v.proves && (
326 <Box flexDirection="row" flexWrap="wrap" gap={1}>
327 <Text dimColor>Proves when</Text>
328 <Text>{v.proves}</Text>
329 </Box>
330 )}
331 <Box flexDirection="row" flexWrap="wrap" gap={1}>
332 <Text dimColor>Steps</Text>
333 <Text>{total} {total === 1 ? 'step' : 'steps'}, checked by a headless browser run along the main path{v.risks && v.risks.length ? `; risks: ${v.risks.join(', ')}` : ''}</Text>
334 </Box>
335 </Box>
336 {v.loading && <Text dimColor>Running…</Text>}
337 {v.error && <Text color="red">{v.error}</Text>}
338 {v.empty && !v.loading && <Text dimColor>No run yet — press Refresh</Text>}
339 {!v.empty && sel && (
340 <Box flexDirection="column" gap={1}>
341 <Box flexDirection="row" flexWrap="wrap" gap={1} alignItems="center">
342 <Text bold>{selIndex + 1} / {total} {sel.title}</Text>
343 <Button key="prev" onPress={go(selIndex - 1)}>Prev</Button>
344 <Button key="next" onPress={go(selIndex + 1)}>Next</Button>
345 <Button key="original" onPress={original}>Open original</Button>
346 </Box>
347 {isTerminal ? (
348 sel.frame ? (
349 <Image key={`img-${sel.id}`} source={{ file: sel.frame, format: 'png' }} columns={100} rows={28} alt={sel.title} />
350 ) : (
351 <Text dimColor>No screenshot. The run stopped before this step.</Text>
352 )
353 ) : frameSvg ? (
354 <Svg source={frameSvg} alt={sel.title} />
355 ) : (
356 <Text dimColor>frame too big to draw inline: {sel.frame}</Text>
357 )}
358 <Box flexDirection="row" flexWrap="wrap" gap={1}>
359 <Text bold>{selIndex + 1}. {sel.title}</Text>
360 {sel.action && <Text dimColor>after you {sel.action}</Text>}
361 </Box>
362 <Box flexDirection="row" flexWrap="wrap" gap={2}>{checks}</Box>
363 <Box flexDirection="row" flexWrap="wrap" gap={1}>
364 {steps.map((s, i) => (
365 <Button key={`pick-${s.id}`} variant={i === selIndex ? 'primary' : undefined} onPress={pick(s.id)}>
366 {i + 1} {short(s.title).padEnd(18, ' ')} <Text color={colorOf(s.result)}>{mark(s.result)}</Text>
367 </Button>
368 ))}
369 </Box>
370 </Box>
371 )}
372 {v.rejecting && (
373 <Input
374 key="note"
375 label="Note: "
376 placeholder="why reject?"
377 value={v.note}
378 onInput={(t: string) => { void patch({ note: t }) }}
379 onSubmit={(t: string, p: any) => reject(p, t)}
380 />
381 )}
382 {!isTerminal && (
383 <Svg source={'<svg xmlns="http://www.w3.org/2000/svg" width="520" height="1"><rect width="520" height="1" fill="#8b949e" fill-opacity="0.35"/></svg>'} alt="" width={520} height={1} />
384 )}
385 <Box flexDirection="row" justifyContent="space-between" alignItems="center" gap={2}>
386 <Button key="refresh" onPress={() => load($, id, true)}>Run again</Button>
387 <Box flexDirection="row" gap={1}>
388 <Button key="reject" onPress={() => patch({ rejecting: true })}>Reject</Button>
389 <Button key="approve" variant="primary" onPress={approve}>Approve</Button>
390 </Box>
391 </Box>
392 </Box>
393 )
394}
395
396const viewOf = async ($: any, id: string) => (await read($, views))[id] as JourneyView | undefined
397
398const openFlow = async ($: any, id: string) => {
399 await $.ui.open({ id: PREFIX + id, title: `Journey ${id}`, focus: true })
400 await load($, id, false)
401}
402
403const PY = 'python'
404const LANES_PANE = 'lanes'
405const LANES_TOOL = 'mcp__journey-review__show_lanes'
406const SILENT_MS = 30 * 60 * 1000
407const EVERY_MS = 30000
408
409const COLUMNS = [
410 { id: 'implement', label: 'Implement' },
411 { id: 'review', label: 'Review' },
412 { id: 'blocked', label: 'Blocked' },
413 { id: 'build', label: 'Build queue' },
414 { id: 'silent', label: 'Silent' },
415 { id: 'merge', label: 'Merge' },
416] as const
417
418const lanesAtom = atom({ plugin: 'journey-review', key: 'lanes' } as const, {
419 rows: [], loading: false, collapsed: {},
420} as LanesView)
421
422const ms = (iso?: string | null): number => {
423 const t = iso ? Date.parse(iso) : NaN
424 return Number.isFinite(t) ? t : NaN
425}
426
427const span = (from: number, to: number): string => {
428 if (!Number.isFinite(from) || !Number.isFinite(to)) return '?'
429 const m = Math.max(0, Math.round((to - from) / 60000))
430 if (m < 1) return '<1m'
431 if (m < 90) return `${m}m`
432 if (m < 2880) return `${Math.round(m / 60)}h`
433 return `${Math.round(m / 1440)}d`
434}
435
436// Same rules as the Studio board: a lane that holds or waits for a build slot is in Build queue;
437// a stalled task or a silent lane is Silent; a fix/blocked task is Blocked; then the step.
438const toRows = (data: any): LaneRow[] => {
439 const now = ms(data?.now) || Date.now()
440 const slotLanes = new Set<string>()
441 for (const s of data?.slots ?? []) {
442 if (s?.holder?.lane) slotLanes.add(String(s.holder.lane))
443 for (const w of s?.waiting ?? []) if (w?.lane) slotLanes.add(String(w.lane))
444 }
445 const stalls = new Set<string>(data?.stalls ?? [])
446 const silent = new Set<string>(data?.silent ?? [])
447 const rows: LaneRow[] = []
448 for (const t of data?.tasks ?? []) {
449 if (t.releasedBy || t.step === 'merged') continue
450 const lane = String(t.lane ?? '')
451 const last = Math.max(ms(t.clock?.lastAt) || 0, ms(t.lastHeartbeatAt) || 0, ms(t.claimedAt) || 0) || NaN
452 const since = ms(t.clock?.since) || ms(t.claimedAt)
453 const idle = Number.isFinite(last) ? now - last : NaN
454 let column: LaneRow['column']
455 if (slotLanes.has(lane)) column = 'build'
456 else if (stalls.has(t.key) || silent.has(lane)) column = 'silent'
457 else if (t.step === 'fix' || t.step === 'critic' || t.blockedBy) column = 'blocked'
458 else if (t.step === 'review') column = 'review'
459 else if (t.step === 'merge') column = 'merge'
460 else column = 'implement'
461 rows.push({
462 key: String(t.key), issue: Number(t.issue), title: String(t.title ?? t.prTitle ?? ''), step: String(t.step ?? ''),
463 lane, pr: t.pr ?? null, inStep: span(since, now), idle: span(last, now),
464 isStale: !Number.isFinite(idle) || idle > SILENT_MS, idleMs: Number.isFinite(idle) ? idle : -1, column,
465 })
466 }
467 return rows.sort((a, b) => b.idleMs - a.idleMs)
468}
469
470// The CLI has no command that lists the open board, so a small helper reads the token file and
471// asks the Runtime. The project gives the token path; the helper finds the Runtime URL and the
472// execution in the project's `.graphhelm/team.json`. The token is read inside that child process
473// and never printed or stored here.
474const fetchBoard = async ($: any): Promise<{ rows?: LaneRow[]; error?: string }> => {
475 await ready($)
476 const helper = THUMB.replace(/thumb\.py$/, 'lanes.py')
477 const r = await $.process.run([PY, '-I', helper, PROJECT, TOKEN], { timeoutMs: 60000 })
478 let data: any = null
479 try { data = JSON.parse(String(r.stdout).trim().split(/\r?\n/).pop() ?? '') } catch { /* below */ }
480 if (r.exitCode !== 0 || !data) return { error: `Lanes failed (exit ${r.exitCode}). Is the Runtime up?` }
481 return { rows: toRows(data) }
482}
483
484const refresh = async ($: any) => {
485 await update($, lanesAtom, (v: LanesView) => ({ ...v, loading: true }))
486 const out = await fetchBoard($)
487 await update($, lanesAtom, (v: LanesView) => ({
488 ...v, loading: false, error: out.error, rows: out.rows ?? v.rows,
489 }))
490}
491
492let timer: { cancel: () => void } | null = null
493
494const summary = (rows: LaneRow[]): string => {
495 const counts = COLUMNS.map(c => `${c.label} ${rows.filter(r => r.column === c.id).length}`).join(', ')
496 const red = rows.filter(r => r.isStale).length
497 return `${rows.length} open tasks. ${counts}. No activity over 30 min: ${red}.`
498}
499
500async function openLanes($: any) {
501 const opened = await $.ui.open({ id: LANES_PANE, title: 'Lanes', focus: true })
502 await refresh($)
503 if (!timer) timer = $.clock.every(EVERY_MS, () => refresh($))
504 return opened
505}
506
507
508const lanesStart = async ($: any) => {
509 await $.command.register({ name: 'lanes', description: 'Open the GraphHelm Lanes board in a side panel' })
510 await $.tool.register({
511 name: 'show_lanes',
512 description: 'Open the GraphHelm Lanes board (open tasks by column) in a side panel for the owner.',
513 inputSchema: { type: 'object', properties: {} },
514 isDeferred: false,
515 })
516}
517
518
519
520export const register: Register = on => {
521 on('command.run', { command: 'lanes' }, async $ => {
522 const opened = await openLanes($)
523 return { text: opened?.isPlaced === false ? `Lanes pane not placed: ${(opened as any).reason}` : 'Lanes panel opened.' }
524 })
525
526 on('tool.call', { tool: LANES_TOOL }, async $ => {
527 await openLanes($)
528 const v = await read($, lanesAtom)
529 return { result: v.error ? `Lanes panel opened, but: ${v.error}` : `Lanes panel opened. ${summary(v.rows)}` }
530 })
531
532 on('ui.render', { component: 'Pane', requestId: LANES_PANE }, async ($, e) => {
533 const { Box, Text, Button } = $.ui.resolve(e) as any
534 const v = await read($, lanesAtom)
535 const toggle = (id: string) => () =>
536 update($, lanesAtom, (s: LanesView) => ({ ...s, collapsed: { ...s.collapsed, [id]: !s.collapsed[id] } }))
537 return (
538 <Box flexDirection="column" paddingX={1} gap={1}>
539 <Box flexDirection="row" flexWrap="wrap" gap={1} alignItems="center">
540 <Button key="refresh" onPress={() => refresh($)}>Refresh</Button>
541 {COLUMNS.map(c => (
542 <Text key={`n-${c.id}`} bold>{`${c.label} ${v.rows.filter(r => r.column === c.id).length}`}</Text>
543 ))}
544 </Box>
545 {v.loading && <Text dimColor>Loading…</Text>}
546 {v.error && <Text color="red">{v.error}</Text>}
547 {COLUMNS.map(c => {
548 const list = v.rows.filter(r => r.column === c.id)
549 const shut = v.collapsed[c.id] === true
550 return (
551 <Box key={`g-${c.id}`} flexDirection="column">
552 <Button key={`toggle-${c.id}`} onPress={toggle(c.id)}>{`${shut ? '▸' : '▾'} ${c.label} (${list.length})`}</Button>
553 {!shut && list.length === 0 && <Text dimColor> none</Text>}
554 {!shut && list.map(r => (
555 <Box key={`t-${r.key}`} flexDirection="column" marginLeft={2}>
556 <Text color={r.isStale ? 'red' : undefined} wrap="truncate-end">{`#${r.issue} ${r.title}`}</Text>
557 <Text dimColor={!r.isStale} color={r.isStale ? 'red' : undefined}>
558 {`${r.step} · ${r.lane} · ${r.pr ? `PR ${r.pr}` : 'no PR'} · in step ${r.inStep} · idle ${r.idle}`}
559 </Text>
560 </Box>
561 ))}
562 </Box>
563 )
564 })}
565 </Box>
566 )
567 })
568
569 on('session.start', async ($, e, next) => {
570 await $.command.register({ name: 'journey', description: 'Show a GraphHelm journey inline: /journey <flow-id>' })
571 await $.command.register({ name: 'journeys', description: 'List GraphHelm journey flows' })
572 await lanesStart($)
573 await $.tool.register({
574 name: 'show_journey',
575 description: 'Show a GraphHelm journey run (frames, checks, Approve/Reject/Run again) inline in the chat for the owner.',
576 inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'flow id, e.g. studio-connect' } }, required: ['id'] },
577 isDeferred: false,
578 })
579 return next(e)
580 })
581
582 // /journey <id>: load, then CommandOutput draws the card inline.
583 on('command.run', { command: 'journey' }, async ($, e) => {
584 const id = String((e as any).args ?? '').trim().split(/\s+/)[0]
585 if (!id) return { text: 'Usage: /journey <flow-id>' }
586 await load($, id, false)
587 return { text: `Journey ${id}` }
588 })
589
590 on('ui.render', { component: 'CommandOutput', props: { command: 'journey' } }, async ($, e, next) => {
591 const id = String((e as any).props?.args ?? '').trim().split(/\s+/)[0]
592 const v = id ? await viewOf($, id) : undefined
593 if (!v) return next(e)
594 return draw($, e, v, id)
595 })
596
597 // show_journey(id): the model calls it; its arguments sit flat on the event.
598 // The result must be a string; the id rides in its first words for ToolResult.
599 on('tool.call', { tool: TOOL }, async ($, e) => {
600 const id = String((e as any).id ?? (e as any).input?.id ?? '').trim()
601 if (!id) return { deny: 'show_journey needs an id.' }
602 await load($, id, false)
603 const v = await viewOf($, id)
604 const steps = (v?.steps ?? []).map(s => `${s.title}: ${s.result}`).join('; ')
605 return { result: `Journey ${id} shown to the owner. Result: ${v?.overall ?? 'unknown'}. ${steps}` }
606 })
607
608 on('ui.render', { component: 'ToolResult', props: { tool: TOOL } }, async ($, e, next) => {
609 const out = (e as any).props?.output
610 const raw = typeof out === 'string' ? out : Array.isArray(out) ? out.map((c: any) => c?.text ?? '').join(' ') : ''
611 const id = /^Journey (\S+) shown/.exec(raw)?.[1] ?? ''
612 const v = id ? await viewOf($, id) : undefined
613 if (!v) return next(e)
614 return draw($, e, v, id)
615 })
616
617 // A run of calls folds into one ToolGroup line and the group draws no ToolResult:
618 // when our tool is in a group, keep the engine's line and draw the cards under it.
619 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
620 const mine = ((e as any).props?.calls ?? []).filter((c: any) => c?.tool === TOOL && !c.isRunning && !c.isErrored)
621 if (!mine.length) return next(e)
622 const { Box } = $.ui.resolve(e) as any
623 const cards: any[] = []
624 for (const c of mine) {
625 const id = String(c.input?.id ?? '').trim()
626 const v = id ? await viewOf($, id) : undefined
627 if (v) cards.push(await draw($, e, v, id))
628 }
629 if (!cards.length) return next(e)
630 const line = await next(e)
631 return <Box flexDirection="column" gap={1}>{line}{cards}</Box>
632 })
633
634 on('command.run', { command: 'journeys' }, async $ => {
635 await $.ui.open({ id: LIST, title: 'Journeys' })
636 await ready($)
637 const r = await $.process.run([EXE, 'journey', 'flows', '--project', PROJECT, '--json'], { timeoutMs: 60000 })
638 const flows = parse(r.stdout)?.data?.flows as any[] | undefined
639 await update($, flowsAtom, () =>
640 (flows ?? []).map(f => ({ id: f.id, title: f.title ?? f.id, status: f.status ?? '' })))
641 await update($, flowsErr, () => (flows ? null : `flows failed (exit ${r.exitCode})`))
642 return { text: 'Journeys pane opened.' }
643 })
644
645 // Pane: kept because it is cheap, it draws the same card. /journeys buttons open it.
646 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
647 const rid = (e as any).requestId as string
648 if (rid !== LIST && !rid?.startsWith(PREFIX)) return next(e)
649 const { Box, Text, Button } = $.ui.resolve(e) as any
650 if (rid === LIST) {
651 const flows = await read($, flowsAtom)
652 const err = await read($, flowsErr)
653 return (
654 <Box flexDirection="column">
655 {err && <Text color="red">{err}</Text>}
656 {flows.map(f => (
657 <Button key={`open-${f.id}`} onPress={() => openFlow($, f.id)}>
658 {f.id} - {f.title} ({f.status})
659 </Button>
660 ))}
661 </Box>
662 )
663 }
664 const id = rid.slice(PREFIX.length)
665 const v = await viewOf($, id)
666 if (!v) return <Text dimColor>Loading…</Text>
667 return draw($, e, v, id)
668 })
669}
670types/index.d.ts 56 lines1export type Check = { role: string; name: string; seen: boolean | null }
2export type StepView = {
3 id: string
4 title: string
5 result: string
6 reason?: string
7 frame?: string
8 action?: string
9 checks: Check[]
10}
11export type JourneyView = {
12 id: string
13 title: string
14 loading: boolean
15 error?: string
16 overall?: string
17 commit?: string
18 ranAt?: string
19 steps: StepView[]
20 rejecting: boolean
21 note: string
22 older?: string
23 purpose?: string
24 proves?: string
25 actors?: string[]
26 risks?: string[]
27 empty?: boolean
28}
29export type LaneRow = {
30 key: string
31 issue: number
32 title: string
33 step: string
34 lane: string
35 pr: number | null
36 inStep: string
37 idle: string
38 isStale: boolean
39 idleMs: number
40 column: 'implement' | 'review' | 'blocked' | 'build' | 'silent' | 'merge'
41}
42export type LanesView = { rows: LaneRow[]; loading: boolean; error?: string; collapsed: Record<string, boolean> }
43export type FlowRow = { id: string; title: string; status: string }
44
45declare module 'claude-code' {
46 interface PluginState {
47 'journey-review': {
48 views: Record<string, JourneyView>
49 flows: FlowRow[]
50 flowsError: string | null
51 selected: Record<string, string>
52 lanes: LanesView
53 }
54 }
55}
56