Context-driven development for Claude Code: setup, spec, plan, implement, review, programmes, and decision tracks.

Context-driven development for Claude Code: setup, spec, plan, implement, review, archive, handoff, and revert.
Measure twice, code once.
| Command | Description |
|---|---|
/conductor:conductor-setup | Project bootstrap; on an existing project, upgrades it to current conventions |
/conductor:conductor-new-track | Brainstorm, spec, plan (single track or programme mode) |
/conductor:conductor-implement | Execute plan todos (depends_on, eligible picker, cleanup + continue options) |
/conductor:conductor-status | Progress, eligible / blocked tracks, deferred checks, unmerged branches, out-of-date files |
/conductor:conductor-archive | Move finished tracks' spec and plan to conductor/archive/, keeping a ledger line |
/conductor:conductor-handoff | Save session state into the plan or backlog and print a short prompt for the next session |
/conductor:conductor-revert | Git-aware revert (the only skill Claude will not start by itself) |
/conductor:conductor-review | Review against guidelines, plan, spec |
/conductor:conductor-programme-review | Review multi-track programme |
/conductor:conductor-validate-review | Validate review findings against repo |
/conductor:conductor-prototype | Decision-track spike on spike/<slug> branch |
Conductor runs a track as a small execution graph, not a linear chain:
| Concern | Mechanism | Where |
|---|---|---|
| Standing answers | Working Agreements in the project's workflow.md: Conductor files local or committed, who commits, branch, autonomy, hand checks, verifier | Working Agreements Protocol |
| Plumbing without the model | scripts/conductor_state.py: reads (tracks, plan, backlog, verify-paths, doctor) and state writes (set-todo, track-status, archive) | Deterministic Plumbing Protocol |
| Real dependencies only | todo blocked_by + files; implement runs any ready todo | Plan Authoring Guide |
| Parallel work | disjoint-file todos and parallel-ready tracks fan out to subagents, user-confirmed | Parallel Dispatch Protocol |
| Verification on the edge | fresh read-only verifier per phase (the whole track on the last one), per todo for risky changes, and on every plan draft | Independent Verification Protocol |
| Tests that can fail | Google Testing on the Toilet rules, contract probes, no tautological tests | templates/test-quality.md |
| Local failures | retry / skip / repair / isolate / escalate / stop table | Failure Policy |
| Bounded loops | attempts per todo, review_rounds per plan, hard caps | Convergence Budgets |
| Cost | scripts → cheap model → strong model by task | Model Routing |
All protocols live in templates/conductor-protocol.md.
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/conductor_state.py" tracks
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/conductor_state.py" plan conductor/plans/<file>.plan.md
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/conductor_state.py" verify-paths <plan-or-review.md> --create-ok
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/conductor_state.py" backlog
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/conductor_state.py" set-todo <plan> <todo_id> completed --sha <sha>
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/conductor_state.py" track-status <track_id> in_progress
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/conductor_state.py" archive <track_id>
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/conductor_state.py" doctor [--fix] [--stamp]
On Claude Code builds with function hooks, the plugin also loads hooks/register.tsx, a mod that keeps the active track on screen. Everything it shows comes from conductor_state.py; it never parses plans itself, and it shows nothing in a repo without conductor/context/tracks.md.
| Piece | What it does |
|---|---|
| Status line | <track> 7/12 while the band is hidden, or the next eligible track when none is in progress |
| Band above the prompt | Track, progress bar, blocked and deferred counts, the next todo; Board (b) and Hide buttons |
/conductor-board | Pane with next todo, parallel batch, waiting and blocked todos, deferred checks, every other track in progress (progress, next todo, its own Implement), other tracks, and buttons that fill in /conductor:conductor-implement, review, archive or status |
| Toasts | Phase done, a todo newly blocked, review hitting its 2-round limit, track complete |
| Upgrade nudge | At session start, a toast when doctor finds drift worth /conductor:conductor-setup |
| Status guard | Denies Edit/Write/sed -i/redirects that change a todo's status in conductor/plans/ or rewrite tracks.md, pointing at set-todo. New pending todos and other plan fields pass. It matches spellings, so it is a guardrail, not a boundary; it stays off when python3 cannot run the script |
It refreshes after each Edit, Write or Bash call and at the end of every turn, re-running the script only when tracks.md or a plan in progress changed. With several tracks in progress, the status line and band follow the one whose plan changed last. Develop it with claude plugin validate plugins/conductor and claude plugin test plugins/conductor.
Run /conductor:conductor-setup again. On a project that is already set up it runs doctor, applies the mechanical repairs (plans stranded by old archives, duplicated backlog items, contradictory .gitignore advice), adds Working Agreements, refreshes stale workflow sections while keeping project-specific lines, and stamps the version in conductor/context/index.md. /conductor:conductor-status says when a project is out of date.
Two claude plugin eval suites, kept separate so each runs, costs, and reports on its own:
| Suite | Dir | Covers |
|---|---|---|
| status | evals/ (default) | /conductor:conductor-status outcomes: counts, eligible / blocked / parallel-ready, legacy format, not-set-up, negative |
| graph | evals-graph/ | implement picks the first ready todo (blocked_by), offers parallel dispatch, reports typo'd blockers, escalates at attempts: 3; validate-review flags wrong paths; implement not-set-up |
claude plugin eval ./plugins/conductor --allow-tools Write
claude plugin eval ./plugins/conductor --eval-dir evals-graph --allow-tools Write Edit
Both suites grade outcomes (last message and files), not tool trajectories: a slash-expanded skill never shows up as a Skill tool call. No case grants Bash, so conductor_state.py is not exercised by the evals; the skills' documented manual fallbacks are. results/ dirs are gitignored.
The script has its own unit tests, which run each subcommand against a throwaway conductor/ tree:
python3 -m unittest discover plugins/conductor/scripts
From conductor/reviews/*.md → validate → split tracks → synthesis → implement in order (continue via explicit cleanup choices when unblocked).
Reference: docs/examples/remediation-programme-example.md
Deliverable is an OKF decision concept at .adr/decisions/<slug>.md, in the local decision bundle at the repo root. .adr/ carries a .gitignore with *, so decisions, spike evidence and the fallback glossary are never committed unless you ask. Project docs stay in repo knowledge bundles (knowledge/, <pkg>/knowledge/).
Workflow: /engineering:grilling → /engineering:research → /conductor:conductor-prototype → /engineering:grill-with-docs
See OKF v0.1.
**/knowledge/index.md bundlesknowledge/ or domain <pkg>/knowledge/ beside codetemplates/knowledge/bundle-placement-guide.mdconductor/context/, conductor/specs/, conductor/plans/, conductor/reviews/, conductor/archive/. Commit them, or keep conductor/ gitignored; setup records the choice in Working Agreements and every command follows it.knowledge/ or <pkg>/knowledge/ in the repositoryConductor from gemini-cli-extensions/conductor. OKF from Google Cloud OKF spec. Engineering skills from mattpocock/skills (MIT).
Output style: Base rules are defined in templates/conductor-protocol.md and templates/output-style.md.
hooks/register.tsx 331 lines1// Conductor HUD: a status line, a band above the prompt, a /conductor-board
2// pane, milestone toasts, an upgrade nudge, and a guard that keeps todo
3// status changes on conductor_state.py. All data comes from that script, so
4// the HUD never parses plans itself.
5
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, Register } from 'claude-code'
8
9import { changesStatus, denyReason, isPlanPath, rewritesConductorFile } from './guard'
10import { hasBand, inFlightPlans, milestones, pickTrack, statusText, toSnapshot } from './snapshot'
11
12type Json = Record<string, any>
13
14const PANE = 'conductor-board'
15const snapshot = atom({ plugin: 'conductor', key: 'snapshot' } as const, null)
16const isBandHidden = atom({ plugin: 'conductor', key: 'isBandHidden' } as const, false)
17
18const scriptPath = ($: EngineInterface) => `${$.plugin.root}/scripts/conductor_state.py`
19
20// Module state resets on reload; session.start then fires again and refills it.
21let lastKey = ''
22// Plans of the tracks in progress at the last run; their mtimes are in the key.
23let watchedPlans: string[] = []
24// The guard only steers to set-todo when python3 can actually run it.
25let isScriptUsable = false
26
27async function runScript($: EngineInterface, args: string[]): Promise<Json | null> {
28 try {
29 const { stdout } = await $.process.run(['python3', scriptPath($), ...args], {
30 cwd: await $.session.root(),
31 timeoutMs: 8000,
32 })
33 isScriptUsable = true
34 return JSON.parse(stdout) as Json
35 } catch {
36 return null
37 }
38}
39
40// The status line only while the band is not showing the same track.
41async function syncStatus($: EngineInterface): Promise<void> {
42 const s = await read($, snapshot)
43 const isBandShown = hasBand(s) && !(await read($, isBandHidden))
44 $.ui.status(isBandShown ? undefined : statusText(s))
45}
46
47async function mtime($: EngineInterface, path: string): Promise<number> {
48 try {
49 return (await $.fs.stat(path)).mtimeMs
50 } catch {
51 return 0
52 }
53}
54
55// Re-runs the script only when tracks.md or a plan in progress changed, so
56// calling this after every edit costs a few stats.
57async function refresh($: EngineInterface, force = false): Promise<void> {
58 const root = await $.session.root()
59 const tracksFile = `${root}/conductor/context/tracks.md`
60 const prev = await read($, snapshot)
61 if (!(await $.fs.exists(tracksFile))) {
62 if (prev !== null) await update($, snapshot, () => null)
63 $.ui.status(undefined)
64 return
65 }
66 const planMtimes = async (plans: string[]) =>
67 Object.fromEntries(await Promise.all(plans.map(async p => [p, await mtime($, `${root}/${p}`)] as const)))
68 const watched = await planMtimes(watchedPlans)
69 const key = [await mtime($, tracksFile), ...watchedPlans.map(p => watched[p])].join(':')
70 if (!force && key === lastKey) return
71 lastKey = key
72
73 const tracks = await runScript($, ['tracks'])
74 if (!tracks || tracks.error) return
75 // tracks.md may have started or finished a track since the key was taken.
76 const plans = inFlightPlans(tracks)
77 const mtimes = plans.join() === watchedPlans.join() ? watched : await planMtimes(plans)
78 watchedPlans = plans
79 const track = pickTrack(tracks, mtimes)
80 const plan = track?.plan ? await runScript($, ['plan', track.plan]) : null
81 // The board lists every track in flight, so read the others' plans too.
82 const others = ((tracks.tracks ?? []) as Json[]).filter(
83 t => t.plan && t.track_id !== track?.track_id && (tracks.in_progress ?? []).includes(t.track_id),
84 )
85 const otherPlans = Object.fromEntries(
86 await Promise.all(others.map(async t => [t.track_id, await runScript($, ['plan', t.plan])] as const)),
87 )
88 const next = toSnapshot(tracks, plan, mtimes, otherPlans)
89 await update($, snapshot, () => next)
90 await syncStatus($)
91 for (const line of milestones(prev, next)) $.ui.toast(line, { timeoutMs: 6000 })
92}
93
94async function nudgeUpgrade($: EngineInterface): Promise<void> {
95 const doctor = await runScript($, ['doctor'])
96 // A version stamp alone is no drift: every plugin release would nag otherwise.
97 const issues: Json[] = (doctor?.issues ?? []).filter(
98 (i: Json) => i.id !== 'not_set_up' && i.fix !== 'stamp',
99 )
100 if (issues.length === 0) return
101 const from = doctor?.project_version ?? 'unstamped'
102 $.ui.toast(
103 `Conductor files are behind (${from} → ${doctor?.plugin_version}, ${issues.length} ` +
104 `issue${issues.length === 1 ? '' : 's'}). Run /conductor:conductor-setup to upgrade.`,
105 { timeoutMs: 10000 },
106 )
107}
108
109// Shared by /conductor-board and the band's Board button. The button calls this
110// directly: a plugin's own $.command.run skips its own command.run hook, so the
111// command would come back unanswered.
112async function openBoard($: EngineInterface): Promise<string> {
113 await update($, isBandHidden, () => false)
114 await refresh($, true)
115 const opened = await $.ui.open({ id: PANE, title: 'Conductor' })
116 return opened.isPlaced
117 ? 'Conductor board opened.'
118 : 'Conductor board is waiting for a wider terminal.'
119}
120
121export const register: Register = on => {
122 on('session.start', async ($, e, next) => {
123 const started = await next(e)
124 await $.command.register({
125 name: 'conductor-board',
126 description: 'Show the Conductor track board in a pane',
127 })
128 await refresh($, true)
129 if (await read($, snapshot)) await nudgeUpgrade($)
130 return started
131 })
132
133 on('turn.complete', async ($, e, next) => {
134 const done = await next(e)
135 await refresh($)
136 return done
137 })
138
139 // Guard: todo status changes go through set-todo.
140 on('tool.call', { tool: 'Edit' }, ($, e, next) =>
141 isScriptUsable && isPlanPath(e.file_path) && changesStatus(e.old_string, e.new_string)
142 ? { deny: denyReason(scriptPath($)) }
143 : next(e),
144 )
145
146 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
147 if (isScriptUsable && isPlanPath(e.file_path) && (await $.fs.exists(e.file_path))) {
148 const before = await $.fs.read(e.file_path)
149 if (changesStatus(before, e.content)) return { deny: denyReason(scriptPath($)) }
150 }
151 return next(e)
152 })
153
154 on('tool.call', { tool: 'Bash' }, ($, e, next) =>
155 isScriptUsable && rewritesConductorFile(e.command)
156 ? { deny: denyReason(scriptPath($)) }
157 : next(e),
158 )
159
160 // Keep the HUD live mid-turn: these are the tools that change conductor/.
161 on('tool.call', async ($, e, next) => {
162 const ran = await next(e)
163 if (e.tool === 'Edit' || e.tool === 'Write' || e.tool === 'Bash') await refresh($)
164 return ran
165 })
166
167 // Always answers: a hook that throws is skipped, and the engine then says no
168 // hook answered the command, hiding the real failure.
169 on('command.run', { command: 'conductor-board' }, async $ => {
170 try {
171 return { text: await openBoard($) }
172 } catch (err) {
173 return { text: `Conductor board could not open: ${err instanceof Error ? err.message : err}` }
174 }
175 })
176
177 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
178 const s = await read($, snapshot)
179 if (e.props.hasSurvey || !hasBand(s) || !s?.plan || (await read($, isBandHidden))) {
180 return next(e)
181 }
182 // Another plugin's band beneath stays, under this one.
183 const beneath = await next(e)
184 const { Box, Button, Text } = $.ui.resolve(e)
185 const p = s.plan
186 const filled = p.total ? Math.round((p.done / p.total) * 10) : 0
187 const flags = [
188 p.blocked.length ? `${p.blocked.length} blocked` : '',
189 p.deferred.length ? `${p.deferred.length} deferred` : '',
190 s.inFlight.length > 1 ? `+${s.inFlight.length - 1} in flight` : '',
191 ].filter(Boolean)
192 const nextLine = p.next
193 ? `next: ${p.next.content}`
194 : (p.blockedReason ?? 'all todos done; run review')
195
196 // No width on the outer box: the engine refuses its own drawing (beneath)
197 // under a Box with a width, and would drop the whole band.
198 return (
199 <Box flexDirection="column">
200 <Box flexDirection="column" width={e.props.bodyColumns}>
201 <Box flexDirection="row" gap={1}>
202 <Text bold color="cyan">◆ {s.trackId}</Text>
203 <Text>
204 {'█'.repeat(filled)}
205 {'░'.repeat(10 - filled)} {p.done}/{p.total}
206 </Text>
207 {flags.length > 0 && <Text color="yellow">{flags.join(' · ')}</Text>}
208 <Box flexGrow={1} />
209 <Button key="board" label="Board" hotkey="b" onPress={() => openBoard($)} />
210 <Button key="hide" label="Hide" onPress={async () => {
211 await update($, isBandHidden, () => true)
212 await syncStatus($)
213 }} />
214 </Box>
215 <Text dimColor wrap="truncate-end">{nextLine}</Text>
216 </Box>
217 {beneath}
218 </Box>
219 )
220 })
221
222 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
223 const { Box, Button, Text } = $.ui.resolve(e)
224 const s = await read($, snapshot)
225 if (!s) {
226 return <Text dimColor>No conductor/ here. Run /conductor:conductor-setup to start.</Text>
227 }
228 const p = s.plan
229 const room = Math.max(3, Math.floor(((e.viewport?.rows ?? 30) - 16) / 3))
230 const header = (title: string) => <Text bold>{title}</Text>
231 const row = (text: string, dim = false) => (
232 <Text dimColor={dim} wrap="truncate-end">
233 {' '}
234 {text}
235 </Text>
236 )
237 const fill = (cmd: string) => () => $.prompt.fill({ text: cmd })
238
239 return (
240 <Box flexDirection="column" gap={1} width={e.props.bodyColumns}>
241 <Box flexDirection="column">
242 <Text bold color="cyan">
243 {s.trackId ?? 'No track'} {s.isInProgress ? '(in progress)' : s.trackId ? '(next up)' : ''}
244 </Text>
245 {s.description && <Text wrap="wrap">{s.description}</Text>}
246 {p && (
247 <Text>
248 {p.done}/{p.total} todos · phases {p.phases.join(', ') || 'none'}
249 {p.reviewRounds ? ` · review round ${p.reviewRounds}/2` : ''}
250 </Text>
251 )}
252 </Box>
253
254 {p && (
255 <Box flexDirection="column">
256 {header('Next')}
257 {row(p.next ? `${p.next.id}: ${p.next.content}` : (p.blockedReason ?? 'nothing ready'))}
258 {p.parallelBatch.length > 0 && row(`parallel batch: ${p.parallelBatch.join(', ')}`, true)}
259 </Box>
260 )}
261
262 {p && p.waiting.length > 0 && (
263 <Box flexDirection="column">
264 {header(`Waiting (${p.waiting.length})`)}
265 {p.waiting.slice(0, room).map(t => row(`${t.id} ← ${t.blockedBy.join(', ')}`, true))}
266 </Box>
267 )}
268
269 {p && (p.blocked.length > 0 || p.deferred.length > 0) && (
270 <Box flexDirection="column">
271 {header('Needs a person')}
272 {p.blocked.slice(0, room).map(t => row(`blocked ${t.id}${t.on ? `: ${t.on}` : ''}`))}
273 {p.deferred.slice(0, room).map(t => row(`deferred check ${t.id}${t.phase ? ` (phase ${t.phase})` : ''}`))}
274 </Box>
275 )}
276
277 {s.others.length > 0 && (
278 <Box flexDirection="column">
279 {header(`Also in progress (${s.others.length})`)}
280 {s.others.slice(0, room).map(t => (
281 <Box key={t.trackId} flexDirection="column">
282 <Box flexDirection="row" gap={1}>
283 <Text color="cyan" wrap="truncate-end">
284 {' '}
285 {t.trackId} {t.total ? `${t.done}/${t.total}` : ''}
286 </Text>
287 <Box flexGrow={1} />
288 <Button
289 key={`implement-${t.trackId}`}
290 label="Implement"
291 onPress={fill(`/conductor:conductor-implement ${t.trackId}`)}
292 />
293 </Box>
294 {row(t.next ? ` next: ${t.next.content}` : ' nothing ready', true)}
295 </Box>
296 ))}
297 </Box>
298 )}
299
300 <Box flexDirection="column">
301 {header('Tracks')}
302 {row(`eligible: ${s.eligible.join(', ') || 'none'}`)}
303 {s.blockedTracks.slice(0, room).map(t => row(`blocked ${t.id} ← ${t.missing.join(', ')}`, true))}
304 {s.archivable.length > 0 && row(`archivable: ${s.archivable.join(', ')}`, true)}
305 </Box>
306
307 <Box flexDirection="row" gap={1} flexWrap="wrap">
308 {s.trackId && (
309 <Button
310 key="implement"
311 label="Implement"
312 hotkey="i"
313 variant="primary"
314 onPress={fill(`/conductor:conductor-implement ${s.trackId}`)}
315 />
316 )}
317 {s.isInProgress && p && !p.next && (
318 <Button key="review" label="Review" hotkey="v" onPress={fill(`/conductor:conductor-review ${s.trackId}`)} />
319 )}
320 {s.archivable.length > 0 && (
321 <Button key="archive" label="Archive" hotkey="a" onPress={fill('/conductor:conductor-archive')} />
322 )}
323 <Button key="status" label="Status" hotkey="s" onPress={fill('/conductor:conductor-status')} />
324 <Button key="refresh" label="Refresh" hotkey="r" onPress={() => refresh($, true)} />
325 <Button key="close" label="Close" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
326 </Box>
327 </Box>
328 )
329 })
330}
331hooks/guard.ts 54 lines1// Keeps todo status changes on conductor_state.py set-todo, as
2// templates/conductor-protocol.md asks. Pure, so tests can call it directly.
3//
4// Gotcha: this matches spellings, so it is a guardrail against slips, not a
5// security boundary. Plan edits that leave statuses alone (review_rounds, new
6// pending todos, body text) always pass, because skills make them by hand.
7
8const PLAN_PATH = /(^|\/)conductor\/plans\/[^/]+\.plan\.md$/
9const STATUS_LINE = /^\s*status:\s*['"]?([A-Za-z_]+)['"]?\s*$/gm
10
11export const isPlanPath = (path: string): boolean => PLAN_PATH.test(path)
12
13function statusCounts(text: string): Map<string, number> {
14 const counts = new Map<string, number>()
15 for (const m of text.matchAll(STATUS_LINE)) {
16 const s = m[1] ?? ''
17 counts.set(s, (counts.get(s) ?? 0) + 1)
18 }
19 return counts
20}
21
22// True when `after` moved a todo's status, as opposed to adding new pending
23// todos or touching other fields.
24export function changesStatus(before: string, after: string): boolean {
25 const a = statusCounts(before)
26 const b = statusCounts(after)
27 for (const s of new Set([...a.keys(), ...b.keys()])) {
28 const delta = (b.get(s) ?? 0) - (a.get(s) ?? 0)
29 if (delta === 0) continue
30 if (s === 'pending' && delta > 0) continue
31 return true
32 }
33 return false
34}
35
36const IN_PLACE = /\b(sed|perl)\b[^|;&]*\s-[a-zA-Z]*i/
37const REDIRECT = /(>|\btee\b)\s*['"]?\S*conductor\/(plans\/\S+\.plan\.md|context\/tracks\.md)/
38const TARGET = /conductor\/(plans\/\S+\.plan\.md|context\/tracks\.md)/
39
40// True when a shell command rewrites a plan or the registry in place.
41export function rewritesConductorFile(command: string): boolean {
42 if (command.includes('conductor_state.py')) return false
43 return (IN_PLACE.test(command) && TARGET.test(command)) || REDIRECT.test(command)
44}
45
46export function denyReason(script: string): string {
47 return (
48 'conductor: change todo status with ' +
49 `\`python3 "${script}" set-todo <plan> <todo_id> <status>\` ` +
50 '(and track status with `track-status`), not by hand. ' +
51 'Hand edits have mangled plans before; see the Deterministic Plumbing Protocol.'
52 )
53}
54hooks/snapshot.ts 149 lines1// Pure mapping from conductor_state.py's JSON to the HUD's snapshot, and the
2// milestones worth a toast between two snapshots. No `$` here, so tests can
3// feed it recorded script output.
4
5import type { ConductorPlan, ConductorSnapshot, ConductorTodo } from '../types'
6
7type Json = Record<string, any>
8
9const str = (value: unknown): string | null =>
10 value === null || value === undefined ? null : String(value)
11
12const todo = (raw: Json): ConductorTodo => ({
13 id: String(raw.id),
14 content: String(raw.content ?? ''),
15 phase: str(raw.phase),
16})
17
18// The track the HUD follows: of the tracks in progress, the one whose plan
19// changed last (each todo update rewrites it), so starting a second track moves
20// the HUD to it; else the recommended one. Ties keep tracks.md order.
21export function pickTrack(tracks: Json, planMtimes: Record<string, number> = {}): Json | null {
22 const all: Json[] = tracks.tracks ?? []
23 const byId = (id: unknown) => all.find(t => t.track_id === id) ?? null
24 const inFlight = ((tracks.in_progress ?? []) as unknown[]).map(byId).filter((t): t is Json => t !== null)
25 let picked: Json | null = null
26 for (const t of inFlight) {
27 if (!picked || (planMtimes[t.plan] ?? 0) > (planMtimes[picked.plan] ?? 0)) picked = t
28 }
29 return picked ?? byId(tracks.recommended)
30}
31
32// Plans of the tracks in progress: the files whose changes can move the HUD.
33export function inFlightPlans(tracks: Json): string[] {
34 const ids = new Set(tracks.in_progress ?? [])
35 return ((tracks.tracks ?? []) as Json[])
36 .filter(t => ids.has(t.track_id) && t.plan)
37 .map(t => String(t.plan))
38}
39
40export function toPlan(raw: Json): ConductorPlan {
41 const ready: Json[] = raw.ready ?? []
42 const waiting: Json[] = raw.waiting ?? []
43 const counts: Json = raw.counts ?? {}
44 const open = [...ready, ...waiting, ...(raw.blocked ?? [])]
45 .map(t => str(t.phase))
46 .filter((p): p is string => p !== null)
47 return {
48 path: String(raw.plan),
49 name: str(raw.name),
50 done: (counts.completed ?? 0) + (counts.deferred ?? 0),
51 total: raw.total ?? 0,
52 next: raw.next ? todo(raw.next) : null,
53 ready: ready.map(todo),
54 waiting: waiting.map(t => ({ ...todo(t), blockedBy: (t.blocked_by ?? []).map(String) })),
55 blocked: (raw.blocked ?? []).map((b: Json) => ({ id: String(b.id), on: str(b.on) })),
56 deferred: (raw.deferred ?? []).map((d: Json) => ({ id: String(d.id), phase: str(d.phase) })),
57 parallelBatch: (raw.parallel_batch ?? []).map(String),
58 reviewRounds: Number(raw.review_rounds ?? 0),
59 phases: (raw.phases ?? []).map(String),
60 openPhases: [...new Set(open)],
61 blockedReason: str(raw.blocked_reason),
62 }
63}
64
65// otherPlans: the script's plan JSON for the other tracks in progress, by track id.
66export function toSnapshot(
67 tracks: Json,
68 plan: Json | null,
69 planMtimes: Record<string, number> = {},
70 otherPlans: Record<string, Json> = {},
71): ConductorSnapshot {
72 const track = pickTrack(tracks, planMtimes)
73 const inFlight: string[] = (tracks.in_progress ?? []).map(String)
74 const others = inFlight
75 .filter(id => id !== track?.track_id)
76 .map(id => {
77 const t = ((tracks.tracks ?? []) as Json[]).find(x => x.track_id === id)
78 const raw = otherPlans[id]
79 const p = raw && !raw.error ? toPlan(raw) : null
80 return {
81 trackId: id,
82 description: str(t?.description),
83 done: p?.done ?? 0,
84 total: p?.total ?? 0,
85 next: p?.next ?? null,
86 }
87 })
88 return {
89 trackId: track?.track_id ?? null,
90 inFlight,
91 others,
92 description: track?.description ?? null,
93 isInProgress: track?.status === 'in_progress',
94 plan: plan && !plan.error ? toPlan(plan) : null,
95 recommended: tracks.recommended ?? null,
96 eligible: (tracks.eligible ?? []).map((t: Json) => String(t.track_id)),
97 blockedTracks: (tracks.blocked ?? []).map((t: Json) => ({
98 id: String(t.track_id),
99 missing: (t.missing ?? []).map(String),
100 })),
101 archivable: (tracks.archivable ?? []).map(String),
102 allComplete: Boolean(tracks.all_complete),
103 }
104}
105
106// What changed between two refreshes that the person would otherwise only
107// find in scrollback.
108export function milestones(prev: ConductorSnapshot | null, next: ConductorSnapshot): string[] {
109 if (!prev) return []
110 const out: string[] = []
111
112 if (prev.isInProgress && prev.trackId && next.archivable.includes(prev.trackId)) {
113 out.push(`Conductor: track ${prev.trackId} complete`)
114 }
115
116 const a = prev.plan
117 const b = next.plan
118 if (!a || !b || a.path !== b.path) return out
119
120 for (const phase of a.openPhases) {
121 if (!b.openPhases.includes(phase) && b.phases.includes(phase)) {
122 out.push(`Conductor: phase ${phase} done`)
123 }
124 }
125 const wasBlocked = new Set(a.blocked.map(t => t.id))
126 for (const t of b.blocked) {
127 if (!wasBlocked.has(t.id)) out.push(`Conductor: ${t.id} blocked${t.on ? ` on ${t.on}` : ''}`)
128 }
129 if (a.reviewRounds < 2 && b.reviewRounds >= 2) {
130 out.push('Conductor: review hit its 2-round limit; the next round escalates')
131 }
132 return out
133}
134
135// The engine already prefixes a plugin's status line with its name.
136export function statusText(s: ConductorSnapshot | null): string | undefined {
137 if (!s?.trackId) return undefined
138 const more = s.inFlight.length > 1 ? ` (+${s.inFlight.length - 1} in flight)` : ''
139 if (s.isInProgress && s.plan) return `${s.trackId} ${s.plan.done}/${s.plan.total}${more}`
140 if (s.isInProgress) return `${s.trackId}${more}`
141 return `next track ${s.trackId}`
142}
143
144// The band shows the in-progress track's plan; the status line stands in for it
145// otherwise, so the two never repeat each other.
146export function hasBand(s: ConductorSnapshot | null): boolean {
147 return Boolean(s?.isInProgress && s.plan)
148}
149types/index.d.ts 63 lines1// State the Conductor HUD keeps for a session: what conductor_state.py last
2// reported, reduced to what the status line, band and board draw.
3
4export type ConductorTodo = {
5 id: string
6 content: string
7 phase: string | null
8}
9
10export type ConductorWaitingTodo = ConductorTodo & { blockedBy: string[] }
11
12export type ConductorPlan = {
13 path: string
14 name: string | null
15 done: number
16 total: number
17 next: ConductorTodo | null
18 ready: ConductorTodo[]
19 waiting: ConductorWaitingTodo[]
20 blocked: { id: string; on: string | null }[]
21 deferred: { id: string; phase: string | null }[]
22 parallelBatch: string[]
23 reviewRounds: number
24 phases: string[]
25 // Phases that still hold a todo that is not completed or deferred.
26 openPhases: string[]
27 blockedReason: string | null
28}
29
30// A track in progress other than the followed one, as the board lists it.
31export type ConductorTrackSummary = {
32 trackId: string
33 description: string | null
34 done: number
35 total: number
36 next: ConductorTodo | null
37}
38
39export type ConductorSnapshot = {
40 trackId: string | null
41 // Every track in progress, in tracks.md order; trackId is the one followed.
42 inFlight: string[]
43 // The other tracks in flight, in tracks.md order.
44 others: ConductorTrackSummary[]
45 description: string | null
46 isInProgress: boolean
47 plan: ConductorPlan | null
48 recommended: string | null
49 eligible: string[]
50 blockedTracks: { id: string; missing: string[] }[]
51 archivable: string[]
52 allComplete: boolean
53}
54
55declare module 'claude-code' {
56 interface PluginState {
57 conductor: {
58 snapshot: ConductorSnapshot | null
59 isBandHidden: boolean
60 }
61 }
62}
63