SLOPSHOPPER

journey-review

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

panerowsguardcommandtoasttool
★ 1v0.1.3MITupdated 2026-10-24stabem/GraphHelm/plugins/journey-review
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · journey-review
│ ┃ Journeys ✕ › fix the failing auth test and add an audit log call │ ┃ flows failed (exit 0) │ ⏺ 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 │ │ › /journey │ ⎿ journey-review: Usage: /journey <flow-id> │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Journeys
flows failed (exit 0)
Pane · Lanes
[ Refresh ] Implement 0 Review 0 Blocked 0 Build queue 0 Si Lanes failed (exit 0). Is the Runtime up? [ ▾ Implement (0) ] none [ ▾ Review (0) ] none [ ▾ Blocked (0) ] none [ ▾ Build queue (0) ] none [ ▾ Silent (0) ] none [ ▾ Merge (0) ] none
README

GraphHelm

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. -->

The problem

Delegating a multi-step change to agents raises questions a chat transcript does not answer:

  • Which issues and PRs belong to my request, and what is still open or blocked?
  • Which agent is doing what, and which one is waiting on me?
  • Does the flow the user actually goes through still work, at this commit, and how do I know?
  • If it broke, at which step, and what changed compared to the last time it worked?

GraphHelm answers them from recorded events and journey runs, not from what an agent reports.

How a change flows

Using GraphHelm in your own project:

  1. Request. You ask for a change in your agent host (for example "let users reset their password"). The work is recorded as one request.
  2. Agents work. Agents open issues and pull requests for it. Each runs as a lane you can follow; a question an agent asks waits for your answer instead of being guessed.
  3. Journeys define the promise. The user-visible promise is written as a journey: .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.
  4. Journeys run. An approved journey is replayed at the committed head without a model call: in a real browser through the Playwright observer, or as cli/http acts for backends.
  5. You inspect the evidence. Each step shows its result, the checks it made, the captured frame (browser journeys) and how it compares with an earlier run.
  6. You review the result. For a PR, the review screen compares the journey's frames on 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: Graph, Lanes, Proof

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.

Journey-Proven Development (JPD)

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:

  • An observable goal: what the user should be able to do, in their terms.
  • Steps: screens connected by edges; each edge has 1 to 8 acts (click, type, navigate, an HTTP request, a CLI command).
  • Explicit checks: every screen lists what must be true there, such as a visible element with a role and name, an HTTP status, a JSON field, a CLI exit code or a line on stdout.
  • Evidence: per-step results, captured frames for browser journeys, and the run kept in history.

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:

ResultMeaning
passedThe step ran and every check held.
failed / driftAn act could not run (failed), or it ran and a check did not hold (drift).
not run / skippedThe step was not reached, so nothing was observed.
OBSERVER_MISSINGNothing 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.

Before/after and history

What Proof and the review screen compare, exactly:

  • Proof: the selected step's frame in the run on screen is compared with the newest earlier finished run that kept a frame for that same screen. Past runs are listed as history (pass or fail, and when they ran), but you cannot pick two arbitrary runs to compare.
  • PR review (Graph): Before is the journey's frame from the run on main; Now is the PR head's frame.
  • Modes: Frame (one image), Side by side, Overlay, and Changes (changed pixels in red with the share of the screen that changed). Changes refuses frames of different sizes instead of stretching them.

Limits to keep in mind:

  • A pixel difference is not automatically a regression, and an unchanged screen is not proof that behavior is right: the step's checks decide.
  • Mobile frames come from an emulated mobile viewport in the browser, not from a physical device.
  • Frames exist only for browser journeys; backend journeys show results and output, not pictures.
  • A capture becomes stale when files in its screen's scope changed after it was taken.

Quick start

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>

Optional: see the Runtime with a fixture run

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:

AttentionWhat it tells you
needs_youA named condition requires human attention
can_sleepNothing needs attention, and in-flight work has been checked
unknownThere is not enough information to judge in-flight silence
calmed_by_amendmentAn 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>

Architecture

  • Runtime (Rust, 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.
  • Studio (apps/studio): the local web view (Graph, Lanes, Proof). Talks only to the public Runtime API.
  • CLI and MCP (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.
  • JPD: the journey format, approval, replay, validation and sabotage described above.
  • Keel (KEEL.md): the method agents follow to build and review code (plan, execute, review, merge), with graphhelm keel check blocking the rules a machine can measure. Keel decides how code is written; journeys decide whether it works.

Status and limitations

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>

Contributing

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.

Source 2 files
hooks/register.tsx 670 lines
1import { 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
670
types/index.d.ts 56 lines
1export 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