Help with a pull request: /pr-assistant:pr map draws it as one page, /pr-assistant:pr walk draws that map and goes through it with you beside a panel of the…

<img src="docs/brand/logo.svg" alt="LubbDubb" width="120">
A self-hosted, always-running orchestration harness for one software engineer's work — a cockpit that watches your inputs (issues, PRs, CI, review comments), decides what to do on a heartbeat, and dispatches Claude Code agents to do it, escalating to you only what genuinely needs judgment.
The name is the heartbeat: the server's core is a periodic pulse that drives everything.
Where to read what.
docs/mission.mdis why it exists and what it changes about the job. This file is the overview: what the harness does, how work flows through it, and the configuration that matters on day one.docs/workflow.mdis the workflow in full, including where a different one slots in.docs/spec/is the specification of how every part of the application behaves today, written as fact — every config key, every dispatch rule, the agent runtimes, the API, the cockpit. If you want the detail behind anything below, it is in there.docs/feature-timeline.mdis how it got this way.
One repeating cycle, driven by a heartbeat (heartbeatIntervalMs, default 30s while the fleet is busy; idleHeartbeatIntervalMs, 5 minutes, while it is not) and also triggerable on demand:
snapshot the world → diff against the last snapshot → reconcile plans
→ decide (dispatcher) → execute (executor) → audit
Every step is recorded. The dispatcher's rationale, every action it emitted, and the outcome of each action are persisted — so an idle cycle is as explainable as a busy one. Agents run in pooled git worktrees (code work) or scratch dirs (desk work), and report back over a typed tool channel.
Grouped by where in the loop it sits. Each line links to the spec that owns it.
| Feature | What it is |
|---|---|
| Opt-in watching | One ${labelPrefix}-watch tag decides what is acted on, on issues and pull requests alike. Nothing outside it is touched. → [06][s06] |
| Two providers | GitHub and Azure DevOps behind per-capability seams, plus a fake provider the whole suite and the demo run on. → [15][s15] |
| Tracker states | Where the provider has workflow states, pickup is gated on them and the harness moves the item to in-progress and in-review. → [06][s06] |
| Priority and order | Priority labels weight pickup; the ranked plan ships as Up next with a cut-line at current headroom, re-orderable by you. → [05][s05] |
| Tickets board | Every open and closed item, as a table or a column-per-state board you drag cards across — the drop's cost said before it lands. → [17][s17] |
| Attachments | Images attached to a brief follow the issue to whichever agent works it, stored outside every worktree. → [12][s12] |
| Feature | What it is |
|---|---|
| A rule pipeline | Two dozen named rules walked in a declared order, each proposing work from the world. The order is data, not numbers on a comment. → [05][s05] |
| A bounded vocabulary | The dispatcher can only ever ask for one of eleven validated actions; anything malformed is rejected and audited, never executed. → [05][s05] |
| Per-check CI policy | What to do about which check went red — fix it, fix it with guidance, hold it because it is not ours, or escalate once. → [02][s02] |
| The decision log | Executed, deferred, rejected and skipped alike, each with its reason and the rule that produced it, expandable to why that rule exists. → [18][s18] |
| Cooldowns and caps | Per-origin attempt caps and cooldowns, so a dispatch that keeps failing escalates instead of looping. → [05][s05] |
| Feature | What it is |
|---|---|
| Agents in worktrees | A bounded pool of checkouts leased to branches and switched rather than recreated, so a branch that comes back starts warm. → [09][s09] |
| A typed tool channel | An MCP server agents call back on — read the world, raise what they learned, open a pull request, report a check. → [11][s11] |
| A permission backstop | An agent hitting a command outside the allow-list asks you rather than hanging. → [11][s11] |
| Models per rule | Named profiles — a model and the depth it runs at — assigned per dispatch rule, so a conflict fix and a plan are not priced alike. → [02][s02] |
| Jobs and schedules | An ad-hoc prompt queued from the cockpit, or queued for you on a cron expression. Both wait for a slot like everything else. → [13][s13] |
| Crash recovery | Agents orphaned by a restart are parked, and the pulse is held, until you restore, requeue or remove each one. → [10][s10] |
| Live transcripts | Click an agent and read what it is doing, type into it, and see what it produced mid-run. → [10][s10], [12][s12] |
| Feature | What it is |
|---|---|
| Health predicates | Failing CI, behind or conflicting with its base, unhandled review threads, ready to merge — one agent per branch, top concern first. → [07][s07] |
| The fleet review | Off by default: the harness reads a pull request of its own before a person is asked, with a triage that picks how thoroughly. → [07][s07] |
| Answering a review | Every unhandled thread goes to one agent, replied to through the harness — signed, recorded, and resolved on the provider's own threads. → [07][s07] |
| Stacks | A part is based on the part it depends on; a red base is attributed to the PR that owns it, and merging is bottom-up. → [07][s07] |
| Waiting on you | A pull request somebody assigned you, who asked, and how long it has been sitting there. → [17][s17] |
| Feature | What it is |
|---|---|
| Goal appraisal | Is there a goal here to work from? A refusal says what is missing, on the ticket, and lifts when the goal text changes. → [08][s08] |
| Planning | One pull request, or a dependency-chained decomposition into parts each with its own branch and scope — or "this is already done". → [08][s08] |
| Plan approval | Always. A plan carries risks, scope-outs and how anyone will know it worked, and can be discussed with a conversational planner. → [08][s08] |
| Assessment | Asked of what was delivered, not of the agent's confidence. Its no arm replans, adds a part, or escalates. → [08][s08] |
| Validation | Checks that can only be answered by running the delivered thing, written for a person — or handed to your own Claude Code. → [20][s20] |
| Retrospective | One write-up per delivered goal, from the shared scratchpad and the harness's own record of what it cost. → [13][s13] |
| Close-out | The harness never closes a ticket. It files a standing obligation with your name on it, which settles once the tracker stops listing it open. → [13][s13] |
| Feature | What it is |
|---|---|
| Environments | Off by default. The commit each PR landed as, and whether each environment has it yet — asked with your own command, three-valued. → [24][s24] |
| Arrivals | Arriving somewhere can be what opens what a delivered goal owes you, and what puts a line on the ticket. Both opt-in, per environment. → [24][s24] |
| Post-deploy watch | A goal declares what a running system must show; an arrival opens a window, and your telemetry is asked on a schedule. No model in it. → [29][s29] |
| Obstacles | What is in the fleet's way, keyed rather than matched on prose: two independent voices, an owner, and an exit that is never a person. → [27][s27] |
| The cross-fleet pool | The distance above fleet: one namespace per fleet in a shared repository, with a corroboration model and a digest. → [28][s28] |
| Feature | What it is |
|---|---|
| Needs you | One rail: escalations, plan approvals, outbound proposals, permission requests, config health, and the obligations a delivery leaves. → [17][s17] |
| Insights | What runs cost, what they yielded and what came out — plus a live burn watch that surfaces a run several times its bucket's median. → [18][s18] |
| The runway | Whether there is work left for the fleet, measured in fleet time — and whether the reason there is not is you. → [25][s25] |
| Config health | One row per setting that can stop the fleet silently, each ending in a check against the real world rather than advice. → [26][s26] |
| The error log | The one path every caught failure funnels through: persisted, mirrored to stderr, streamed to the cockpit. → [18][s18] |
| Self-update | The harness watches its own build, drains, and hands off to a supervisor that replaces it. → [21][s21] |
| Local runs | The machine's one dev environment, brought up and taken down from the cockpit, with its output in the pane the fleet already has. → [23][s23] |
| Pets | A vivarium at the foot of the rail. Your actions drop eggs; the eggs hatch. It gates nothing. → [22][s22] |
Two entry points, one path. A prompt states a goal and a ticket is found or created for it; a ticket states its own. Everything downstream keys on the ticket, so work started from a prompt is as recoverable, reviewable and reportable as work started from the tracker.
flowchart TD
P([Start with a prompt]) --> G[Goal is stated]
T([Start with a ticket]) --> TK
G -- find or create --> TK[Ticket]
TK --> V{Enough information<br/>to proceed?}
V -- no --> AL[Say what is missing,<br/>on the ticket]
AL --> UW([Stop working it])
UW -. the goal text changes .-> TK
V -- yes --> PL[Plan the work]
PL --> AP{Plan accepted?}
AP -- no, revise --> PL
AP -- yes --> WK[/Do the work/]
WK --> QG{Quality gates<br/>review, CI, a person}
QG -- not satisfied --> WK
QG -- satisfied --> M[Merge]
M --> GC{Goal achieved?}
GC -- no --> PL
GC -- yes --> DV[Deliver: validate, write up,<br/>hand back what is yours]
DV --> AR{Configured<br/>environments?}
AR -- yes --> EN[Watch it arrive,<br/>then watch it behave]
AR -- no --> D
EN --> D([Done])
| Step | What happens |
|---|---|
| Intake | Every open issue/PR is fetched and shown. What is acted on is decided by the watch tag, plus tracker workflow states where the provider has them. |
| Enough information? | One agent reads the ticket against the repository and says whether there is a goal here to work from. Only an explicit unclear holds anything. |
| Plan the work | A planning agent reads the repo and returns either one PR will do, a decomposition into dependency-chained parts, or this goal is already met. |
| Plan accepted? | Every plan verdict appears in Needs you. Accepting releases the parts to be scheduled; rejecting falls the issue back to the single-PR path, and it can be held. |
| Do the work | An agent per part (or one for the whole issue), each in its own worktree. Code is the most common arm, not the only one — a part may finish with a report. |
| Quality gates | The fleet's own review of the diff (opt-in), then tests, static analysis, pipeline health and human review. Each failing check is classified per check. |
| Merge | A green, approved, mergeable, comment-clear PR is merged — bottom-up for a stack, and never while it is based on another in-flight branch. |
| Goal achieved? | Asked of what was actually delivered, not of the agent's confidence. A no returns to planning, because what is missing may be a different decomposition. |
| Deliver | A validation sheet of checks only a person or a running system can answer; a retrospective written from the record; the tracker state moved and a status comment. |
| Close out | Nothing closes the ticket. A standing obligation with your name on it is filed, and settles itself once the tracker stops listing the item open. |
| Arrival | Where environments are configured: the commit each PR landed as, whether each environment has it yet, and — where declared — whether it is behaving now it is there. |
Each is a decision something has to make, not a step that always passes.
no arm proposes a replan, a follow-up part, or escalates — depending on what the assessment says fell short.The dispatcher ranks every candidate, then applies the concurrency cut. Roughly, highest first:
PR work runs before new issue pickup, so a PR in trouble is always worked ahead of starting new tickets. The full ordered plan ships to the cockpit as Up next, with a cut-line at the current headroom; you can re-order it, and the override persists.
The harness owns the loop; you own the verdicts. You are asked when — and only when — a decision is genuinely yours:
sendPrRepliesWithoutApproval, on by default, which sends a drafted review reply straight to the thread. A rejection stands until the world gives a reason to ask again — a push, a CI result, an approval, a comment — and the reason you typed is handed to the next agent that works that item.Two things are deliberately fixed: every act reaching the outside world is authorized, and an agent declares that it finished — silence never reads as success.
Node >=22.12 and git. A fresh clone installs with npm ci — node-pty is a native build, so it is not instant (on npm 12+ it builds because package.json lists it in allowScripts).
npm ci # native dep: node-pty
cp lubbdubb.config.example.json lubbdubb.config.json # your local config (gitignored)
npm start # builds the cockpit, serves on 127.0.0.1:4300
Every key in the config is optional and the harness boots with no file at all — but the defaults select the real Claude Code runtime, while the shipped example selects the mock one (agentMode: "raw" against the built-in fake providers). So copy it for a first run and the whole loop turns with no model or provider credentials. From then on the cockpit's Config page edits that same file directly, key by key, leaving its comments and ordering alone — hand-editing and the form are two ways at one file.
npm start builds the cockpit bundle and then runs the server. Two variants matter: npm run start:server skips the build and serves whatever web/dist already holds, and npm run serve runs the server under the supervisor that can replace it — which is what self-update needs, and the way to run it under systemd, NSSM or a long-lived terminal. → docs/spec/21
Boot prints what it decided, and the link to open:
[lubbdubb] cockpit listening on 127.0.0.1:4300
[lubbdubb] open the cockpit: http://127.0.0.1:4300/#t=<token>
[lubbdubb] token minted at .lubbdubb/cockpit-token (0600) — reused on the next start
[lubbdubb] heartbeat=300000ms cap=3
[lubbdubb] agent tools: on
Open it once per browser and the cockpit remembers the token. The harness binds loopback only and every route needs that token, because the cockpit can queue a job — and a job spawns a real agent with write access to your repo. The token file is gitignored, along with the rest of .lubbdubb/ (the SQLite database, worktrees, desk scratch dirs and attachments all live under it).
The harness ships a Claude Code plugin for your own Claude Code: the /lubbdubb:… skills every Open in Claude Code link in the cockpit calls (/lubbdubb:check 284:C, /lubbdubb:plan 284, /lubbdubb:ask 284, …), the desktop tool channel those skills talk to, and a notice board: a status line above your prompt and a panel beside the transcript with what the harness is waiting on you for, feature progress, pull request states, the fleet and what is up next. Boot writes it to ~/.lubbdubb/plugin:
[lubbdubb] Claude Code plugin 1.0.0-… written to ~/.lubbdubb/plugin — install or update it from the cockpit's MCP tab
Until it is installed the cockpit shows a banner under the top bar; Install it opens the MCP tab, whose button runs claude plugin marketplace add and claude plugin install for you at user scope, and removes the hand-registered lubbdubb MCP server and the old /lubbdubb skill if you had them. Restart open Claude Code sessions to pick it up. Skip it and nothing breaks: every check simply falls to the fleet. → docs/spec/11
Then: use Inject event to simulate the world moving (a CI failure, a review comment) and watch the harness react; click an agent to see its live transcript and type into it; answer items in Needs you; use New job to launch an ad-hoc prompt, or New schedule to have one queued on a cron expression (0 9 * * 1-5 — weekdays at nine, read in the harness's own timezone). The Decision log shows what was decided each cycle and which rule produced it; Activity shows how the world itself changed.
Needs you also carries the configuration checks — one row per setting that can stop the fleet silently, each ending in a check against the real world rather than a sentence of advice. On a fresh install that rail is the shortest route from the mock loop to a working deployment. → docs/spec/26
Every key is optional, and every key, its default and its precedence is in docs/spec/02-configuration.md. These are the ones that decide whether a deployment works at all:
| Key | Default | Why it matters |
|---|---|---|
repoRoot | the directory you launch in | The git repository worktrees are cut from. Left alone, the harness works on its own checkout. |
| integrations | all fake | Which provider serves each capability — sourceControl, issues, pool.
hooks/register.tsx 452 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderInput } from 'claude-code'
3
4import type { EdgeState, MapNode, MapStatus, Note, StopKind } from '../types'
5import {
6 clip,
7 finish,
8 goto,
9 MAX_HUNK,
10 MAX_STOPS,
11 note,
12 NOTE_KINDS,
13 plain,
14 readDiff,
15 readMap,
16 slice,
17 text,
18 start,
19 STOP_KINDS,
20 where,
21 type Step,
22} from './walk'
23
24const walk = atom({ plugin: 'pr-assistant', key: 'walk' } as const, null)
25const isPanelShut = atom({ plugin: 'pr-assistant', key: 'isPanelShut' } as const, false)
26
27const PANE = 'pr-walk'
28const TITLE = 'PR walkthrough'
29
30
31const NOTE: Record<Note['kind'], { label: string; color: string }> = {
32 likely: { label: 'likely', color: 'red' },
33 check: { label: 'check me', color: 'yellow' },
34}
35
36type Mark = { mark: string; color?: string }
37
38const CHANGE = {
39 changed: { mark: '~', color: 'yellow' },
40 new: { mark: '+', color: 'green' },
41 removed: { mark: '-', color: 'red' },
42} satisfies Record<string, Mark>
43
44const KIND: Record<StopKind, Mark> = { ...CHANGE, unchanged: { mark: '·' } }
45
46const NODE: Record<MapStatus, Mark> = {
47 ...CHANGE,
48 path: { mark: '·', color: 'blue' },
49 outside: { mark: '○' },
50 test: { mark: 't', color: 'magenta' },
51 doc: { mark: 'd' },
52}
53
54const ARROW: Record<EdgeState, Mark> = {
55 normal: { mark: '→' },
56 changed: { mark: '→', color: 'yellow' },
57 blocked: { mark: '✕', color: 'red' },
58 ghost: { mark: '⇢' },
59 absent: { mark: ' ' },
60}
61
62const TOOLS = [
63 {
64 name: 'walk_start',
65 description:
66 'Open the PR walkthrough panel on a pull request and lay out its stops, in reading order. Replaces any walkthrough already on the panel. Call once, after you have read the PR and split it into stops.',
67 inputSchema: {
68 type: 'object',
69 properties: {
70 pr: {
71 type: 'object',
72 properties: { number: { type: 'integer' }, title: { type: 'string' }, url: { type: 'string' } },
73 required: ['number', 'title'],
74 },
75 summary: { type: 'string', description: 'One or two plain sentences: what the PR does.' },
76 map: {
77 type: 'string',
78 description:
79 "Path to the PR map's JSON (the file the map job built from). With it, each walk_goto draws that stop's part of the map in the chat.",
80 },
81 diff: {
82 type: 'string',
83 description:
84 "Path to the PR's whole unified diff, saved to a file (`gh pr diff <n> > file`). With it, each stop names its `hunks` and walk_goto needs only the stop number.",
85 },
86 stops: {
87 type: 'array',
88 minItems: 1,
89 maxItems: MAX_STOPS,
90 items: {
91 type: 'object',
92 properties: {
93 title: { type: 'string', description: 'A few words, what happens at this stop.' },
94 kind: { type: 'string', enum: STOP_KINDS },
95 files: { type: 'array', items: { type: 'string' } },
96 steps: {
97 type: 'array',
98 items: { type: 'integer', minimum: 1 },
99 description: 'The map step numbers (1-based) this stop covers. Needs `map`.',
100 },
101 hunks: {
102 type: 'array',
103 items: { type: 'string' },
104 description:
105 'The hunks that matter at this stop, as `path:line` (a head-version line inside the hunk) or `path` for all of a file. Needs `diff`.',
106 },
107 },
108 required: ['title', 'kind'],
109 },
110 },
111 },
112 required: ['pr', 'stops'],
113 },
114 },
115 {
116 name: 'walk_goto',
117 description:
118 "Move the panel to a stop (1-based) as you present it. The stop's hunks, named at walk_start, are drawn on the panel and in the chat under this call. Call each time you present a stop, including when going back.",
119 inputSchema: {
120 type: 'object',
121 properties: {
122 stop: { type: 'integer', minimum: 1 },
123 diff: {
124 type: 'string',
125 description: `Only when walk_start had no \`diff\`: unified-diff hunks (each starting with an @@ header) for this stop, at most ${MAX_HUNK} characters. Replaces the stop's own hunks.`,
126 },
127 path: { type: 'string', description: 'The file the hunk is from, for highlighting.' },
128 },
129 required: ['stop'],
130 },
131 },
132 {
133 name: 'walk_note',
134 description:
135 'Add a note to the panel (a possible issue: kind "likely" for one you believe is real, "check" for one you could not confirm), or clear / reopen one by id once the question is settled.',
136 inputSchema: {
137 type: 'object',
138 properties: {
139 action: { type: 'string', enum: ['add', 'clear', 'reopen'], default: 'add' },
140 id: { type: 'integer', description: 'The note to clear or reopen.' },
141 kind: { type: 'string', enum: NOTE_KINDS },
142 text: { type: 'string', description: 'One or two plain sentences.' },
143 file: { type: 'string' },
144 line: { type: 'integer', description: 'A line in the PR head version of the file.' },
145 },
146 },
147 },
148 {
149 name: 'walk_end',
150 description: 'Mark the walkthrough done once you have given the wrap-up. The open notes stay on the panel.',
151 inputSchema: { type: 'object', properties: {} },
152 },
153] as const
154
155const failed = ($: EngineInterface) => (err: unknown) =>
156 $.ui.toast(`PR walkthrough: ${err instanceof Error ? err.message : String(err)}`)
157const say = ($: EngineInterface, text: string) => () =>
158 void $.prompt.submit({ text, asUser: true }).catch(failed($))
159const draft = ($: EngineInterface, text: string) => () => void $.prompt.fill({ text }).catch(failed($))
160
161async function apply($: EngineInterface, step: Step, isStart = false): Promise<{ result: string } | { deny: string }> {
162 if ('refusal' in step) return { deny: step.refusal }
163 await update($, walk, () => step.walk)
164 if (isStart) await reopen($).catch(() => undefined)
165 else await keepOpen($).catch(() => undefined)
166 return { result: step.reply }
167}
168
169async function reopen($: EngineInterface) {
170 await update($, isPanelShut, () => false)
171 await $.ui.open({ id: PANE, title: TITLE })
172}
173
174async function keepOpen($: EngineInterface) {
175 const [isShut, panes] = await Promise.all([read($, isPanelShut), $.ui.panes()])
176 if (isShut || panes.some(p => p.id === PANE)) return
177 await $.ui.open({ id: PANE, title: TITLE })
178}
179
180export const register: Register = on => {
181 on('session.start', async ($, e, next) => {
182 await Promise.all([
183 ...TOOLS.map(t => $.tool.register({ ...t, inputSchema: t.inputSchema as Record<string, unknown> })),
184 $.command.register({ name: 'walk-panel', description: 'Open the PR walkthrough panel' }),
185 ])
186 return next(e)
187 })
188
189 on('command.run', { command: 'walk-panel' }, async $ => {
190 await reopen($)
191 return { text: 'PR walkthrough panel opened.' }
192 })
193
194 on('ui.close', async ($, e, next) => {
195 if (e.id === PANE && e.origin.kind === 'person') await update($, isPanelShut, () => true)
196 return next(e)
197 })
198
199 on('tool.call', { tool: 'mcp__pr-assistant__walk_start' }, async ($, e) => {
200 const args = e as Record<string, unknown>
201 const path = text(args.map)
202 const diffPath = text(args.diff)
203 let diff = null
204 if (diffPath !== null) {
205 const loaded = await $.fs
206 .read(diffPath)
207 .then(readDiff, (err: unknown) => (err instanceof Error ? err.message : String(err)))
208 if (typeof loaded === 'string' || loaded.length === 0)
209 return {
210 deny: `Could not use the diff at ${diffPath}: ${typeof loaded === 'string' ? loaded : 'it holds no hunks.'} Leave \`diff\` out to send each hunk with walk_goto.`,
211 }
212 diff = loaded
213 }
214 let map = null
215 if (path !== null) {
216 const loaded = await $.fs
217 .read(path)
218 .then(t => readMap(JSON.parse(t)))
219 .catch((err: unknown) => (err instanceof Error ? err.message : String(err)))
220 if (typeof loaded === 'string')
221 return { deny: `Could not use the map at ${path}: ${loaded} Leave \`map\` out to walk without it.` }
222 map = loaded
223 }
224 return apply($, start(args, map, diff), true)
225 })
226
227 on('tool.call', { tool: 'mcp__pr-assistant__walk_goto' }, async ($, e) =>
228 apply($, goto(await read($, walk), e as Record<string, unknown>, e.tool_use_id)),
229 )
230
231 on('tool.call', { tool: 'mcp__pr-assistant__walk_note' }, async ($, e) =>
232 apply($, note(await read($, walk), e as Record<string, unknown>)),
233 )
234
235 on('tool.call', { tool: 'mcp__pr-assistant__walk_end' }, async $ => apply($, finish(await read($, walk))))
236
237 on('ui.render', { component: 'ToolResult', props: { tool: 'mcp__pr-assistant__walk_goto' } }, async ($, e, next) => {
238 const now = await read($, walk)
239 if (e.props.isErrored || now === null) return next(e)
240 const view = now.views[e.props.tool_use_id]
241 const stop = view === undefined ? undefined : now.stops[view.stop]
242 if (view === undefined || stop === undefined) return next(e)
243 const at = view.stop
244 const part = now.map === null ? null : slice(now.map, stop.steps)
245 if (part === null && view.hunks.length === 0) return next(e)
246 const { Box, Text, Code } = $.ui.resolve(e)
247 const width = Math.max(24, (e.viewport?.columns ?? 80) - 8)
248
249 const card = (n: MapNode) => (
250 <Box key={`node-${n.id}`} flexDirection="column" paddingLeft={2}>
251 <Box gap={1}>
252 <Text color={NODE[n.status].color} dimColor={NODE[n.status].color === undefined}>
253 {NODE[n.status].mark}
254 </Text>
255 <Text bold>{plain(n.title)}</Text>
256 {n.file !== null && <Text dimColor>{clip(n.file.split('/').pop() ?? n.file, 40)}</Text>}
257 </Box>
258 {n.note !== null && <Text dimColor>{` ${plain(n.note)}`}</Text>}
259 {n.before !== null && <Text color="red">{` before: ${plain(n.before)}`}</Text>}
260 {n.after !== null && <Text color="green">{` after: ${plain(n.after)}`}</Text>}
261 </Box>
262 )
263
264 const shown = (part?.edges ?? []).filter(x => x.before !== x.after || x.label !== null)
265
266 const link = (mode: 'before' | 'after', state: EdgeState) => (
267 <Text key={mode} color={ARROW[state].color} dimColor={ARROW[state].color === undefined}>
268 {state === 'absent' ? `${mode}: none` : `${mode}: ${ARROW[state].mark}`}
269 </Text>
270 )
271
272 return (
273 <Box flexDirection="column" borderStyle="round" paddingX={1}>
274 <Text dimColor>{clip(`${part === null ? 'Stop' : 'Map · stop'} ${at + 1}: ${stop.title}`, width)}</Text>
275 {(part?.columns ?? []).map((c, i) => (
276 <Box key={`col-${i}`} flexDirection="column">
277 {i > 0 && <Text dimColor> ↓</Text>}
278 <Text dimColor>{c.label.toUpperCase()}</Text>
279 {c.nodes.map(card)}
280 </Box>
281 ))}
282 {shown.length > 0 && (
283 <Box flexDirection="column" marginTop={1}>
284 {shown.map((x, i) => (
285 <Box key={`edge-${i}`} gap={1}>
286 <Text>{clip(`${plain(x.fromTitle)} → ${plain(x.toTitle)}`, Math.max(12, width - 28))}</Text>
287 {x.label !== null && <Text dimColor>{x.label}</Text>}
288 {x.before !== x.after && [link('before', x.before), link('after', x.after)]}
289 </Box>
290 ))}
291 </Box>
292 )}
293 {(part?.steps ?? []).map(s => (
294 <Box key={`step-${s.n}`} flexDirection="column" marginTop={1}>
295 <Text bold>{`${s.n}. ${plain(s.title)}`}</Text>
296 {s.text !== null && <Text dimColor>{plain(s.text)}</Text>}
297 {s.before !== null && <Text color="red">{`Before: ${plain(s.before)}`}</Text>}
298 {s.after !== null && <Text color="green">{`After: ${plain(s.after)}`}</Text>}
299 </Box>
300 ))}
301 {view.hunks.map((x, i) => (
302 <Box key={`hunk-${i}`} flexDirection="column" marginTop={1}>
303 {x.path !== null && <Text dimColor>{clip(x.path, width)}</Text>}
304 <Code source={x.source} format="diff" {...(x.path !== null ? { path: x.path } : {})} />
305 </Box>
306 ))}
307 </Box>
308 )
309 })
310
311 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
312 try {
313 return await drawPane($, e)
314 } catch (err) {
315 const { Text } = $.ui.resolve(e)
316 return <Text dimColor>{`The panel could not draw: ${err instanceof Error ? err.message : String(err)}`}</Text>
317 }
318 })
319}
320
321async function drawPane($: EngineInterface, e: RenderInput<'Pane'>) {
322 const { Box, Text, Button, Link, Code } = $.ui.resolve(e)
323 const now = await read($, walk)
324 const width = Math.max(24, e.props.bodyColumns)
325
326 if (now === null) {
327 return (
328 <Box flexDirection="column">
329 <Text dimColor>No walkthrough yet.</Text>
330 <Text dimColor>Start one with /pr-assistant:pr walk <PR number>.</Text>
331 </Box>
332 )
333 }
334
335 const heading = clip(`#${now.pr.number} ${now.pr.title}`, width)
336 const open = now.notes.filter(n => !n.isCleared)
337 const stop = now.current === null ? null : now.stops[now.current]
338 const at = now.current ?? -1
339
340 const header = (label: string, count?: string) => (
341 <Box gap={1}>
342 <Text bold>{label}</Text>
343 {count !== undefined && <Text dimColor>{count}</Text>}
344 </Box>
345 )
346
347 const noteRow = (n: Note) => {
348 const place = where(n)
349 return (
350 <Box key={`note-${n.id}`} flexDirection="column">
351 <Box gap={1}>
352 <Text dimColor>{`${n.id}.`}</Text>
353 {n.isCleared ? (
354 <Text dimColor>cleared</Text>
355 ) : (
356 <Text bold color={NOTE[n.kind].color}>
357 {NOTE[n.kind].label}
358 </Text>
359 )}
360 {place !== null && <Text dimColor>{clip(place, Math.max(8, width - 16))}</Text>}
361 </Box>
362 <Text dimColor={n.isCleared} strikethrough={n.isCleared}>
363 {n.text}
364 </Text>
365 {!n.isCleared && (
366 <Button
367 key={`note-${n.id}-ask`}
368 plain
369 dimColor
370 label="Ask about this"
371 onPress={draft($, `About note ${n.id}: `)}
372 />
373 )}
374 </Box>
375 )
376 }
377
378 return (
379 <Box flexDirection="column" gap={1}>
380 <Box flexDirection="column">
381 {now.pr.url !== null ? (
382 <Text bold>
383 <Link href={now.pr.url} label={heading} />
384 </Text>
385 ) : (
386 <Text bold>{heading}</Text>
387 )}
388 {now.summary !== null && <Text dimColor>{now.summary}</Text>}
389 </Box>
390
391 <Box flexDirection="column">
392 {header('Stops', now.isDone ? 'done' : now.current === null ? `${now.stops.length}` : `${at + 1} of ${now.stops.length}`)}
393 {now.stops.map((s, i) => {
394 const isHere = i === at
395 const seen = now.seen.includes(i)
396 return (
397 <Box key={`stop-${i + 1}`} gap={1}>
398 <Text color={isHere ? 'cyan' : undefined} dimColor={!isHere && !seen}>
399 {isHere ? '▶' : seen ? '✓' : ' '}
400 </Text>
401 <Text color={KIND[s.kind].color} dimColor={KIND[s.kind].color === undefined}>
402 {KIND[s.kind].mark}
403 </Text>
404 <Button
405 key={`stop-${i + 1}-go`}
406 plain
407 label={clip(`${i + 1}. ${s.title}`, Math.max(8, width - 6))}
408 onPress={say($, `Go to stop ${i + 1}.`)}
409 />
410 </Box>
411 )
412 })}
413 </Box>
414
415 {stop !== undefined && stop !== null && (
416 <Box flexDirection="column">
417 {header(`Stop ${at + 1}`, stop.kind)}
418 {stop.files.map((f, i) => (
419 <Text key={`file-${i}`} dimColor>
420 {clip(f, width)}
421 </Text>
422 ))}
423 {now.hunks.map((x, i) => (
424 <Code key={`hunk-${i}`} source={x.source} format="diff" {...(x.path !== null ? { path: x.path } : {})} />
425 ))}
426 </Box>
427 )}
428
429 {!now.isDone && (
430 <Box gap={2}>
431 <Button key="back" label="Back" onPress={say($, 'Back.')} />
432 <Button key="next" variant="primary" label="Next" onPress={say($, 'Next.')} />
433 <Button key="done" label="Wrap up" onPress={say($, 'Done, wrap up.')} />
434 </Box>
435 )}
436
437 <Box flexDirection="column" gap={1}>
438 {header('Notes', `${open.length} open`)}
439 {now.notes.length === 0 && <Text dimColor>Nothing flagged yet.</Text>}
440 {now.notes.map(noteRow)}
441 {open.length > 0 && (
442 <Button
443 key="review"
444 label="Post open notes as a review"
445 onPress={draft($, `Post the open notes as a pending review on #${now.pr.number}.`)}
446 />
447 )}
448 </Box>
449 </Box>
450 )
451}
452hooks/walk.ts 236 lines1import type { EdgeState, Hunk, MapNode, MapStatus, MapStep, Note, NoteKind, PrMap, Slice, Stop, StopKind, Walk } from '../types'
2
3export const STOP_KINDS: StopKind[] = ['changed', 'new', 'removed', 'unchanged']
4export const NOTE_KINDS: NoteKind[] = ['likely', 'check']
5export const MAX_STOPS = 12
6export const MAX_HUNK = 10_000
7
8const MAP_STATUSES: MapStatus[] = ['changed', 'new', 'removed', 'path', 'outside', 'test', 'doc']
9const EDGE_STATES: EdgeState[] = ['normal', 'changed', 'blocked', 'ghost', 'absent']
10
11export type Step = { walk: Walk; reply: string } | { refusal: string }
12
13type Args = Record<string, unknown>
14
15export const text = (v: unknown): string | null => (typeof v === 'string' && v.trim() !== '' ? v.trim() : null)
16const whole = (v: unknown): number | null => (typeof v === 'number' && Number.isInteger(v) ? v : null)
17const strings = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [])
18const oneOf = <T,>(list: readonly T[], v: unknown): T | null => ((list as unknown[]).includes(v) ? (v as T) : null)
19
20type DiffHunk = { path: string; old: [number, number]; new: [number, number]; source: string }
21
22export function readDiff(raw: string): DiffHunk[] {
23 const out: DiffHunk[] = []
24 let from: string | null = null
25 let to: string | null = null
26 let at: DiffHunk | null = null
27 const side = (l: string) => {
28 const p = l.slice(4).replace(/\t.*$/, '').trim()
29 return p === '/dev/null' ? null : p.replace(/^[ab]\//, '')
30 }
31 for (const line of raw.split('\n')) {
32 if (line.startsWith('diff --git ')) {
33 at = null
34 from = to = null
35 } else if (at === null && line.startsWith('--- ')) from = side(line)
36 else if (at === null && line.startsWith('+++ ')) to = side(line)
37 else if (line.startsWith('@@')) {
38 const m = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line)
39 const path = to ?? from
40 at = null
41 if (m === null || path === null) continue
42 const span = (a?: string, b?: string): [number, number] => [Number(a), b === undefined ? 1 : Number(b)]
43 at = { path, old: span(m[1], m[2]), new: span(m[3], m[4]), source: `${line}\n` }
44 out.push(at)
45 } else if (at !== null && /^[ +\-\\]/.test(line)) at.source += `${line}\n`
46 else at = null
47 }
48 return out
49}
50
51const covers = ([start, length]: [number, number], line: number) => line >= start && line < start + Math.max(1, length)
52
53function cut(diff: DiffHunk[], refs: string[]): Hunk[] | string {
54 const chosen = new Set<DiffHunk>()
55 for (const ref of refs) {
56 const m = /^(.*?)(?::(\d+))?$/.exec(ref.trim())!
57 const line = m[2] === undefined ? null : Number(m[2])
58 const inFile = diff.filter(h => h.path === m[1] || h.path.endsWith(`/${m[1]}`))
59 const onNew = line === null ? inFile : inFile.filter(h => covers(h.new, line))
60 const found = onNew.length > 0 || line === null ? onNew : inFile.filter(h => covers(h.old, line))
61 if (found.length === 0) return `\`${ref}\` is in no hunk of the diff`
62 for (const h of found) chosen.add(h)
63 }
64 const hunks: Hunk[] = []
65 for (const h of diff.filter(d => chosen.has(d))) {
66 const last = hunks[hunks.length - 1]
67 if (last !== undefined && last.path === h.path) last.source += h.source
68 else hunks.push({ source: h.source, path: h.path })
69 }
70 const big = hunks.find(h => h.source.length > MAX_HUNK)
71 if (big !== undefined)
72 return `its hunks in ${big.path} come to ${big.source.length} characters; the panel shows at most ${MAX_HUNK}. Name single lines instead`
73 return hunks
74}
75
76export function start(args: Args, map: PrMap | null = null, diff: DiffHunk[] | null = null): Step {
77 const pr = (args.pr ?? {}) as Args
78 const number = whole(pr.number)
79 const title = text(pr.title)
80 if (number === null || title === null) return { refusal: '`pr` needs a whole `number` and a `title`.' }
81 const raw = Array.isArray(args.stops) ? (args.stops as Args[]) : []
82 if (raw.length < 1 || raw.length > MAX_STOPS) return { refusal: `Give 1 to ${MAX_STOPS} stops.` }
83 const stops: Stop[] = []
84 for (const [i, s] of raw.entries()) {
85 const title = text(s.title)
86 const kind = oneOf(STOP_KINDS, s.kind)
87 if (title === null || kind === null)
88 return { refusal: `Stop ${i + 1} needs a \`title\` and a \`kind\` (${STOP_KINDS.join(', ')}).` }
89 const files = strings(s.files)
90 const steps = Array.isArray(s.steps) ? s.steps.map(whole) : []
91 if (steps.length > 0 && map === null) return { refusal: `Stop ${i + 1} names map \`steps\` but no \`map\` was given.` }
92 if (map !== null && steps.some(n => n === null || n < 1 || n > map.steps.length))
93 return { refusal: `Stop ${i + 1}: \`steps\` are map step numbers from 1 to ${map.steps.length}.` }
94 const refs = strings(s.hunks)
95 if (refs.length > 0 && diff === null) return { refusal: `Stop ${i + 1} names \`hunks\` but no \`diff\` was given.` }
96 const hunks = diff === null ? [] : cut(diff, refs)
97 if (typeof hunks === 'string') return { refusal: `Stop ${i + 1}: ${hunks}.` }
98 stops.push({ title, kind, files, steps: steps as number[], hunks })
99 }
100 const url = text(pr.url)
101 return {
102 walk: {
103 pr: { number, title, url: url !== null && /^https?:\/\//.test(url) ? url : null },
104 summary: text(args.summary),
105 stops,
106 current: null,
107 seen: [],
108 hunks: [],
109 notes: [],
110 isDone: false,
111 map,
112 views: {},
113 },
114 reply: `Panel open on #${number} with ${stops.length} stops${map === null ? '' : ', the map loaded'}. Call walk_goto as you reach each one.`,
115 }
116}
117
118export function goto(walk: Walk | null, args: Args, call?: string): Step {
119 if (walk === null) return { refusal: 'No walkthrough is running. Call walk_start first.' }
120 const stop = whole(args.stop)
121 if (stop === null || stop < 1 || stop > walk.stops.length)
122 return { refusal: `\`stop\` is a number from 1 to ${walk.stops.length}.` }
123 const source = typeof args.diff === 'string' && args.diff.trim() !== '' ? args.diff : null
124 if (source !== null && source.length > MAX_HUNK)
125 return { refusal: `The diff is ${source.length} characters; the panel shows at most ${MAX_HUNK}. Send the one hunk that matters.` }
126 const index = stop - 1
127 const hunks: Hunk[] = source === null ? walk.stops[index]!.hunks : [{ source, path: text(args.path) }]
128 return {
129 walk: {
130 ...walk,
131 current: index,
132 seen: walk.seen.includes(index) ? walk.seen : [...walk.seen, index],
133 hunks,
134 isDone: false,
135 views: call === undefined ? walk.views : { ...walk.views, [call]: { stop: index, hunks } },
136 },
137 reply: `Panel on stop ${stop} of ${walk.stops.length}.`,
138 }
139}
140
141export function note(walk: Walk | null, args: Args): Step {
142 if (walk === null) return { refusal: 'No walkthrough is running. Call walk_start first.' }
143 const action = args.action ?? 'add'
144 if (action === 'add') {
145 const kind = oneOf(NOTE_KINDS, args.kind)
146 const body = text(args.text)
147 if (kind === null || body === null)
148 return { refusal: `A note needs a \`kind\` (${NOTE_KINDS.join(', ')}) and a \`text\`.` }
149 const id = walk.notes.reduce((max, n) => Math.max(max, n.id), 0) + 1
150 const line = whole(args.line)
151 const added: Note = {
152 id,
153 kind,
154 text: body,
155 file: text(args.file),
156 line: line !== null && line > 0 ? line : null,
157 stop: walk.current === null ? null : walk.current + 1,
158 isCleared: false,
159 }
160 return { walk: { ...walk, notes: [...walk.notes, added] }, reply: `Note ${id} added.` }
161 }
162 if (action !== 'clear' && action !== 'reopen') return { refusal: '`action` is add, clear or reopen.' }
163 const id = whole(args.id)
164 if (id === null || !walk.notes.some(n => n.id === id)) return { refusal: `There is no note ${String(args.id)}.` }
165 const isCleared = action === 'clear'
166 return {
167 walk: { ...walk, notes: walk.notes.map(n => (n.id === id ? { ...n, isCleared } : n)) },
168 reply: `Note ${id} ${isCleared ? 'cleared' : 'reopened'}.`,
169 }
170}
171
172export function finish(walk: Walk | null): Step {
173 if (walk === null) return { refusal: 'No walkthrough is running.' }
174 const open = walk.notes.filter(n => !n.isCleared).length
175 return {
176 walk: { ...walk, current: null, hunks: [], isDone: true },
177 reply: `Walkthrough marked done; ${open} open note${open === 1 ? '' : 's'} left on the panel.`,
178 }
179}
180
181export const where = (n: Note): string | null =>
182 n.file === null ? null : n.line === null ? n.file : `${n.file}:${n.line}`
183
184export const clip = (s: string, room: number): string => (s.length <= room ? s : `${s.slice(0, Math.max(1, room - 1))}…`)
185
186export const plain = (s: string): string => s.replace(/\*\*([^*]+)\*\*/g, '$1').replace(/`([^`]+)`/g, '$1')
187
188export function readMap(raw: unknown): PrMap | string {
189 const d = (raw ?? {}) as Args
190 const list = (v: unknown): Args[] => (Array.isArray(v) ? (v as Args[]) : [])
191 const columns = list(d.columns).map(c => ({ id: text(c.id) ?? '', label: text(c.label) ?? '' }))
192 const nodes: MapNode[] = list(d.nodes).map(n => ({
193 id: text(n.id) ?? '',
194 title: text(n.title) ?? '',
195 file: text(n.file),
196 column: text(n.column) ?? '',
197 status: oneOf(MAP_STATUSES, n.status) ?? 'path',
198 note: text(n.note),
199 before: text(n.before),
200 after: text(n.after),
201 }))
202 const edges = list(d.edges).map(e => ({
203 from: text(e.from) ?? '',
204 to: text(e.to) ?? '',
205 label: text(e.label),
206 before: oneOf(EDGE_STATES, e.before) ?? 'normal',
207 after: oneOf(EDGE_STATES, e.after) ?? (e.changed === true ? 'changed' : 'normal'),
208 }))
209 const steps: MapStep[] = list(d.steps).map(s => ({
210 title: text(s.title) ?? '',
211 nodes: strings(s.nodes),
212 text: text(s.text),
213 before: text(s.before),
214 after: text(s.after),
215 }))
216 if (columns.length === 0 || nodes.length === 0 || steps.length === 0)
217 return 'it has no columns, nodes or steps. Pass the JSON file the map job built.'
218 return { columns, nodes, edges, steps }
219}
220
221export function slice(map: PrMap, steps: number[]): Slice | null {
222 const chosen = steps.flatMap(n => (map.steps[n - 1] === undefined ? [] : [{ ...map.steps[n - 1]!, n }]))
223 if (chosen.length === 0) return null
224 const byId = new Map(map.nodes.map(n => [n.id, n]))
225 const ids = new Set(chosen.flatMap(s => s.nodes).filter(id => byId.has(id)))
226 return {
227 columns: map.columns
228 .map(c => ({ label: c.label, nodes: map.nodes.filter(n => n.column === c.id && ids.has(n.id)) }))
229 .filter(c => c.nodes.length > 0),
230 edges: map.edges
231 .filter(e => ids.has(e.from) && ids.has(e.to))
232 .map(e => ({ ...e, fromTitle: byId.get(e.from)!.title, toTitle: byId.get(e.to)!.title })),
233 steps: chosen,
234 }
235}
236types/index.d.ts 77 lines1export type StopKind = 'changed' | 'new' | 'removed' | 'unchanged'
2
3export type Stop = { title: string; kind: StopKind; files: string[]; steps: number[]; hunks: Hunk[] }
4
5export type MapStatus = 'changed' | 'new' | 'removed' | 'path' | 'outside' | 'test' | 'doc'
6
7export type EdgeState = 'normal' | 'changed' | 'blocked' | 'ghost' | 'absent'
8
9export type MapNode = {
10 id: string
11 title: string
12 file: string | null
13 column: string
14 status: MapStatus
15 note: string | null
16 before: string | null
17 after: string | null
18}
19
20export type MapEdge = { from: string; to: string; label: string | null; before: EdgeState; after: EdgeState }
21
22export type MapStep = {
23 title: string
24 nodes: string[]
25 text: string | null
26 before: string | null
27 after: string | null
28}
29
30export type PrMap = {
31 columns: { id: string; label: string }[]
32 nodes: MapNode[]
33 edges: MapEdge[]
34 steps: MapStep[]
35}
36
37export type Slice = {
38 columns: { label: string; nodes: MapNode[] }[]
39 edges: (MapEdge & { fromTitle: string; toTitle: string })[]
40 steps: (MapStep & { n: number })[]
41}
42
43export type NoteKind = 'likely' | 'check'
44
45export type Note = {
46 id: number
47 kind: NoteKind
48 text: string
49 file: string | null
50 line: number | null
51 stop: number | null
52 isCleared: boolean
53}
54
55export type Hunk = { source: string; path: string | null }
56
57export type View = { stop: number; hunks: Hunk[] }
58
59export type Walk = {
60 pr: { number: number; title: string; url: string | null }
61 summary: string | null
62 stops: Stop[]
63 current: number | null
64 seen: number[]
65 hunks: Hunk[]
66 notes: Note[]
67 isDone: boolean
68 map: PrMap | null
69 views: Record<string, View>
70}
71
72declare module 'claude-code' {
73 interface PluginState {
74 'pr-assistant': { walk: Walk | null; isPanelShut: boolean }
75 }
76}
77