SDLC control pane: start, watch, answer and stop pipelines (/sdlc-monitor)

Supports production, sales, billing and inventory management.
See AGENTS.md for the implementation plan, provisioning details and contribution guidelines.
API documentation is available via Swagger UI. The OpenAPI specification is generated from JSDoc comments in server/index.js.
A pre-commit hook is configured to automatically generate or update the tools/openapi-spec.json file whenever changes to server/index.js are committed. This ensures the specification is always up-to-date with the code.
To view the Swagger UI locally:
bash npm run generate-spec ` Alternatively, committing any change to server/index.js` will trigger the pre-commit hook.bash cd server npm start ` This usually runs on http://localhost:3000`.bash npm run start:swagger ` This server is dedicated to serving the Swagger UI and typically runs on http://localhost:3001`.http://localhost:3001/docs.The Plan stage of scripts/sdlc.sh and the /plan command use graft, a prebuilt code graph, to find the files a change touches. Each developer needs it installed and built once. If graft is missing, the Claude hooks do nothing silently and the Plan stage loses its code graph.
bash npm install -g @nanonets/graft graft --version # confirm it is on your PATH ` Update later with graft upgrade`.graft/ folder is git-ignored, so everyone builds their own: ``bash graft build graft check # exits non-zero if the graph is stale; rerun graft build to refresh ``.mcp.json registers the graft MCP server (graft mcp). On first use Claude Code asks you to approve project MCP servers; approve graft.graft ask "where is the sales invoice calculated" --source..claude/settings.json runs .claude/helpers/graft-hooks.cjs at session start, after edits and at stop. This injects a repo map into the session and keeps the graph in sync. The helper looks for graft in several places (a machine-specific path first, then local and global node_modules), so the path baked into it is harmless on other machines.graft_find_code, graft_find_all, graft_trace_calls, graft_file_api and graft_repo_map through the server in .mcp.json.Start a throwaway Postgres, then run the suite against it:
docker run -d --name prw-db -e POSTGRES_USER=app -e POSTGRES_PASSWORD=app -e POSTGRES_DB=app -p 5432:5432 postgres:16
DATABASE_URL=postgres://app:app@localhost:5432/app npm run test:integration
The suite creates and drops its own prw_test_* database, so it never touches the data in DATABASE_URL's database. Without DATABASE_URL it fails with DATABASE_URL is required for integration tests.
Integration tests (npm run test:integration) are a required part of the pipeline locally. scripts/sdlc.sh runs them through scripts/sdlc-integration.sh after the unit tests, at the Implement, Test repair and Review checks. The database is chosen in this order, first match wins:
DATABASE_URL set in your shell.DATABASE_URL in the repo-root .env (the file the server reads), if its host is this machine and it answers. This is the fast path: no container to start.postgres:16 container started with Docker and removed afterwards.SDLC_DB=docker skips 1 and 2. A non-local host in .env is ignored with a warning unless SDLC_ALLOW_REMOTE_DB=1. The suite creates and drops its own prw_test_* databases, so several runs can share one server (the database user needs CREATEDB). server/.env is not read by anything; use the repo-root .env.
CI skips them temporarily, with a visible warning. To enable them in CI, set the CI/CD variable SDLC_INTEGRATION_CI=run and provide DATABASE_URL (for example from a postgres:16 service). No code change is needed.
At the start of a run (FROM=spec) scripts/sdlc.sh records every file that is already modified, staged or untracked in Docs/backlog/<slug>/logs/baseline.json. The Ship stage (scripts/sdlc-ship.sh) then commits exactly the files the cycle created, changed or deleted since that snapshot, anywhere in the repo, in one feat(<slug>) commit, and ticks the item in Docs/backlog/index.md in a second commit. Anything else the developer had staged is left staged.
Skipped files are never committed; they are listed with the reason in Docs/backlog/<slug>/logs/ship-skipped.md and in the DONE message:
| Reason | Files |
|---|---|
pre-existing local changes | files that were already modified before the run and changed again |
sensitive file | .env, .env.* (not .env.example), *.pem, *.key, *.p12, id_rsa*, *.keystore |
local or agent configuration | .vscode/, .idea/, .claude/ |
generated | node_modules/, .DS_Store |
too large | files over 1 MiB |
Keys added to a git-ignored .env during the run are copied, with the placeholder value change-me (never the real value), into the sibling .env.example, which is committed. If nothing changed, Ship prints WARNING: nothing to commit and the run still succeeds. Resuming with any FROM other than spec reuses the existing baseline, or creates one with a warning (files changed before the resume then count as pre-existing).
scripts/sdlc-mod.sh runs the normal pipeline (scripts/sdlc.sh, unchanged) for one backlog item in its own git worktree, so several items can run at once. Two Claude Code plugins in .claude/plugins/ add the interface and the guard rails.
bash scripts/sdlc-mod.sh run <slug> # start or resume; worktree at ../<repo>-sdlc/<slug>, branch sdlc/<slug>
bash scripts/sdlc-mod.sh stop <slug> # interrupt the pipeline and everything it started
bash scripts/sdlc-mod.sh status # one line per run
bash scripts/sdlc-mod.sh watch <slug> # live view of one pipeline, read from its worktree (--once prints one frame; WATCH_INTERVAL seconds, default 3)
bash scripts/sdlc-mod.sh changes <slug> [--json] # files changed in the pipeline's worktree: "<changed> <uncommitted>"
bash scripts/sdlc-mod.sh discard <slug> [--yes] [--stop] # delete its worktree, branch and run record (without --yes: show what would go, exit 6)
bash scripts/sdlc-mod.sh restart <slug> [--yes] # stop it if running, discard it, start again from Spec
discard and restart throw away the item's worktree, its local branch sdlc/<slug> (including unpushed commits and uncommitted files) and its run record, but first save the branch tip in .git/sdlc-runs/<slug>.discarded; the command it prints, git branch sdlc/<slug> <sha>, brings the work back. discard refuses a running pipeline unless --stop is given, which stops it first (after --yes); restart stops it itself. restart refuses an item already merged into the base branch (exit 5).
At most 2 pipelines run at once (SDLC_MAX_PARALLEL, exit code 3 when full). Run records live in .git/sdlc-runs/. The wrapper links server/node_modules, graft and .env into each worktree.
Control pane (sdlc-monitor plugin). Start Claude with claude --plugin-dir .claude/plugins/sdlc-monitor, then press the SDLC button above the prompt or type /sdlc-monitor.
N files changed (M uncommitted), or no changes yet while running); the pipeline view shows the same in a Files line. Counts refresh with the pane, and every 10 s for finished pipelines.Docs/backlog/<slug>/manual-inputs.md under ## <stage> and the pipeline hands it to that stage's agent as binding instructions (it cannot override the rule that the tests are locked). Resume points: FROM=spec|plan|red-tests|implement|test-repair|review ./scripts/sdlc.sh <slug>; any other value exits 1 before anything is changed; plan and red-tests refuse once the red-tests commit exists (restart the run to redo them); review resumes from the latest locked tests commit. FROM=test-repair resumes at Test repair after a run stopped there for a non-verdict reason (the repair agent or auditor failed to run, or the claims failed the pre-check; the run prints the exact FROM=test-repair command when it can resume). It skips Implement and keeps test-issues.md, and first checks, reporting every failure together and calling no agent: the red-tests commit exists, the claim file has at least one valid claim, server/__tests__ is unchanged since the red-tests commit (otherwise the restore command git checkout <sha> -- server/__tests__ && git clean -fd server/__tests__ is printed, never run), the implementation changed outside server/__tests__/ and Docs/, the repair was not already accepted (then use FROM=review), and the claims pass the pre-check. FROM=implement deletes test-issues.md and prints a warning first. The same file can be edited by hand.Guard (sdlc-guard plugin). Loaded into every pipeline agent by the wrapper. It reads the current stage from status.json and refuses writes the stage does not allow: Spec and Plan write only that item's docs, Red tests write no source, Implement and Review never touch server/__tests__. It also refuses a doc written before the one it builds on.
Docs/backlog/index.md lists the work items. List them with a status filter:
npm run backlog
npm run backlog -- pending,in-progress
npm run backlog -- blocked --json
In Claude Code: /backlog-list blocked.
Markers in index.md: [ ] pending, [x] completed, [!] blocked, [~] parked (kept for later; scripts/sdlc.sh skips it, and a trailing (parked: reason) shows in the NOTE column). A [ ] item whose sdlc/<slug> branch exists (local or origin) is shown as in-progress. Statuses are pending, in-progress, blocked, parked, completed (aliases open and done, or all). The command only reads, it never changes files.
To stop the inline context injected by the hooks, remove the graft entries under hooks in .claude/settings.json on your machine (do not commit that change). The MCP server stays available through .mcp.json. To turn graft off completely, also remove graft from .mcp.json locally.
hooks/register.tsx 839 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentRow, PendingItem, Question, Run, RunState, Snapshot } from '../types'
5
6const PANE = 'sdlc'
7const EVERY_MS = 2000
8const PENDING_EVERY_MS = 10000
9const CAP = 2
10const WRAPPER = 'scripts/sdlc-mod.sh'
11// How often the file counts of a pipeline that is not running are recomputed.
12const CHANGES_EVERY_MS = 10000
13// The stage names status.json carries (scripts/sdlc.sh strips the numbering).
14const STAGES = ['Spec', 'Plan', 'Red tests', 'Implement', 'Test repair', 'Review', 'Commit']
15// How long the Start button stays a "Starting…" note while the wrapper creates the worktree.
16const LAUNCH_GRACE_MS = 20000
17// Agent stages that take a manual input: the FROM value that resumes at the stage, and the "## <key>" section of manual-inputs.md.
18const INPUT_STAGES = [
19 { key: 'spec', label: 'Spec' },
20 { key: 'plan', label: 'Plan' },
21 { key: 'red-tests', label: 'Red tests' },
22 { key: 'implement', label: 'Implement' },
23 { key: 'review', label: 'Review' },
24]
25const RESUME_AT_IMPLEMENT = ['Implement', 'Test repair', 'Review', 'Commit']
26
27const EMPTY: Snapshot = { now: 0, runs: [], pending: [], discarded: {} }
28const snapshot = atom({ plugin: 'sdlc-monitor', key: 'snapshot' } as const, EMPTY)
29const agents = atom({ plugin: 'sdlc-monitor', key: 'agents' } as const, {} as Record<string, AgentRow>)
30const view = atom({ plugin: 'sdlc-monitor', key: 'view' } as const, '')
31const selected = atom({ plugin: 'sdlc-monitor', key: 'selected' } as const, {} as Record<string, boolean>)
32const queue = atom({ plugin: 'sdlc-monitor', key: 'queue' } as const, [] as string[])
33const confirmStop = atom({ plugin: 'sdlc-monitor', key: 'confirmStop' } as const, '')
34const confirmDiscard = atom({ plugin: 'sdlc-monitor', key: 'confirmDiscard' } as const, '')
35const launching = atom({ plugin: 'sdlc-monitor', key: 'launching' } as const, {} as Record<string, number>)
36const draft = atom({ plugin: 'sdlc-monitor', key: 'draft' } as const, {} as Record<string, string>)
37const notice = atom({ plugin: 'sdlc-monitor', key: 'notice' } as const, '')
38
39const GLYPH: Record<RunState, string> = { starting: '◌', running: '●', paused: '⏸', done: '✔', failed: '✘', interrupted: '■', stopped: '✘' }
40const COLOR: Record<RunState, string> = { starting: 'cyan', running: 'cyan', paused: 'yellow', done: 'green', failed: 'red', interrupted: 'yellow', stopped: 'red' }
41const ORDER: Record<RunState, number> = { running: 0, paused: 1, starting: 2, failed: 3, stopped: 3, interrupted: 3, done: 4 }
42
43type StageState = 'done' | 'active' | 'failed' | 'paused' | 'pending'
44const STAGE_GLYPH: Record<StageState, string> = { done: '✔', active: '●', failed: '✘', paused: '⏸', pending: '○' }
45const STAGE_COLOR: Record<StageState, string> = { done: 'green', active: 'cyan', failed: 'red', paused: 'yellow', pending: 'gray' }
46
47const bar = (done: number, total: number, width: number) => {
48 const filled = total ? Math.round((width * done) / total) : 0
49
50 return '█'.repeat(filled) + '░'.repeat(width - filled)
51}
52
53const clock = (ms: number) => {
54 const s = Math.max(0, Math.floor(ms / 1000))
55
56 return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
57}
58
59const parse = (text: string | null): Record<string, unknown> | null => {
60 try {
61 return text ? JSON.parse(text) : null
62 } catch {
63 return null
64 }
65}
66
67const cut = (text: string, max: number) => (text.length > max ? `${text.slice(0, max - 1)}…` : text)
68
69// questions.md as the Spec agent writes it: "### Q<n>: <title>", a body, "**Suggested:** ...", "**Answer:**".
70function parseQuestions(text: string): Question[] {
71 const out: Question[] = []
72 let q: Question | null = null
73 let part: 'body' | 'suggested' | 'answer' = 'body'
74 for (const line of text.split('\n')) {
75 const head = line.match(/^### Q(\d+):\s*(.*)$/)
76 if (head) {
77 q = { n: Number(head[1]), title: head[2].trim(), body: '', suggested: '' }
78 out.push(q)
79 part = 'body'
80 } else if (q) {
81 if (/^\*\*Suggested:\*\*/.test(line)) {
82 part = 'suggested'
83 q.suggested = line.replace(/^\*\*Suggested:\*\*\s*/, '')
84 } else if (/^\*\*Answer:\*\*/.test(line)) {
85 part = 'answer'
86 } else if (part === 'body') {
87 q.body = `${q.body}${q.body ? ' ' : ''}${line.trim()}`.trim()
88 } else if (part === 'suggested' && line.trim()) {
89 q.suggested = `${q.suggested} ${line.trim()}`
90 }
91 }
92 }
93
94 return out
95}
96
97// Returns manual-inputs.md with the "## <key>" section set to value (an empty value removes it).
98function setManualInput(text: string, key: string, value: string): string {
99 const sections: Record<string, string> = {}
100 let cur = ''
101 for (const line of text.split('\n')) {
102 const head = line.match(/^## (\S+)\s*$/)
103 if (head) {
104 cur = head[1]
105 sections[cur] = ''
106 } else if (cur) sections[cur] += `${line}\n`
107 }
108 sections[key] = `${value.trim()}\n`
109
110 return INPUT_STAGES.filter(st => (sections[st.key] ?? '').trim())
111 .map(st => `## ${st.key}\n${sections[st.key].trim()}\n`)
112 .join('\n')
113}
114
115// Puts each answer on its "**Answer:**" line, in question order.
116function fillAnswers(text: string, answers: Record<number, string>): string {
117 let n = 0
118
119 return text
120 .split('\n')
121 .map(line => {
122 const head = line.match(/^### Q(\d+):/)
123 if (head) n = Number(head[1])
124
125 return /^\*\*Answer:\*\*/.test(line) && answers[n] ? `**Answer:** ${answers[n]}` : line
126 })
127 .join('\n')
128}
129
130function testCounts(log: string | null): Run['tests'] {
131 const m = (log ?? '').match(/^Tests:\s+(?:(\d+) failed, )?(?:(\d+) skipped, )?(?:(\d+) passed, )?(\d+) total/m)
132
133 return m ? { failed: Number(m[1] ?? 0), passed: Number(m[3] ?? 0), total: Number(m[4]) } : null
134}
135
136async function readText($: EngineInterface, path: string): Promise<string | null> {
137 try {
138 return (await $.fs.read(path)) as string
139 } catch {
140 return null
141 }
142}
143
144async function names($: EngineInterface, path: string): Promise<{ name: string; kind: string }[]> {
145 try {
146 return await $.fs.list(path)
147 } catch {
148 return []
149 }
150}
151
152async function isAlive($: EngineInterface, pid: number): Promise<boolean> {
153 if (!pid) return false
154 try {
155 return (await $.process.run(['kill', '-0', String(pid)])).exitCode === 0
156 } catch {
157 return false
158 }
159}
160
161let commonDir = ''
162let pendingAt = 0
163let pendingCache: PendingItem[] = []
164
165// Where sdlc-mod.sh keeps its run records: one folder shared by every worktree.
166async function registryDir($: EngineInterface): Promise<string> {
167 if (!commonDir) {
168 try {
169 commonDir = (await $.process.run(['git', 'rev-parse', '--git-common-dir'])).stdout.trim() || '.git'
170 } catch {
171 commonDir = '.git'
172 }
173 }
174
175 return `${commonDir}/sdlc-runs`
176}
177
178async function loadPending($: EngineInterface, now: number): Promise<PendingItem[]> {
179 if (now - pendingAt < PENDING_EVERY_MS && pendingAt) return pendingCache
180 try {
181 const r = await $.process.run(['node', 'scripts/backlog-list.cjs', 'pending', '--json'])
182 const rows = JSON.parse(r.stdout) as { slug: string; type: string | null; priority: string | null; title: string }[]
183 pendingCache = rows.map(x => ({ slug: x.slug, type: x.type ?? '-', priority: x.priority ?? '-', title: x.title }))
184 } catch {
185 pendingCache = []
186 }
187 pendingAt = now
188
189 return pendingCache
190}
191
192// Files a pipeline has changed, counted by the wrapper inside the pipeline's own worktree. A pipeline started by hand has
193// no record, so its changes cannot be told apart from the developer's own work: no counts. A running, paused or starting
194// pipeline is counted on every refresh; a finished one at most every CHANGES_EVERY_MS (the last result, null included, is reused).
195const LIVE: RunState[] = ['running', 'paused', 'starting']
196const changesCache = new Map<string, { at: number; value: Run['changes'] }>()
197
198async function loadChanges($: EngineInterface, slug: string, state: RunState, managed: boolean, now: number): Promise<Run['changes']> {
199 if (!managed) return null
200 const hit = changesCache.get(slug)
201 if (!LIVE.includes(state) && hit && now - hit.at < CHANGES_EVERY_MS) return hit.value
202 let value: Run['changes'] = null
203 try {
204 const r = await $.process.run(['bash', WRAPPER, 'changes', slug, '--json'])
205 if (r.exitCode === 0) {
206 const j = JSON.parse(r.stdout) as { changed?: unknown; uncommitted?: unknown }
207 if (typeof j.changed === 'number' && typeof j.uncommitted === 'number') value = { changed: j.changed, uncommitted: j.uncommitted }
208 }
209 } catch {
210 value = null
211 }
212 changesCache.set(slug, { at: now, value })
213
214 return value
215}
216
217// One pipeline: the wrapper's record, the pipeline's own status.json, and what it has written so far.
218async function loadRun($: EngineInterface, slug: string, worktree: string, reg: Record<string, unknown> | null, now: number): Promise<Run> {
219 const root = worktree || '.'
220 const docs = `${root}/Docs/backlog/${slug}`
221 const status = parse(await readText($, `${docs}/logs/status.json`))
222 const files = [
223 ...(await names($, docs)).filter(f => f.kind === 'file').map(f => f.name),
224 ...(await names($, `${docs}/logs`)).filter(f => /^(impl|spec)-\d+\.log$/.test(f.name)).map(f => f.name),
225 ]
226 const runOut = (await readText($, `${docs}/logs/run.out`)) ?? ''
227 const questionsText = await readText($, `${docs}/questions.md`)
228 const pid = Number(reg?.pid ?? status?.pid ?? 0)
229 const alive = await isAlive($, pid)
230
231 let state: RunState
232 if (reg?.interrupted === true) state = 'interrupted'
233 else if (status?.state === 'done') state = 'done'
234 else if (status?.state === 'paused') state = 'paused'
235 else if (status?.state === 'failed') state = 'failed'
236 else if (status?.state === 'running') state = alive ? 'running' : 'stopped'
237 else if (reg?.state === 'running' && alive) state = 'starting'
238 else if (reg?.state === 'exited' && reg.exit_code !== '0' && reg.exit_code !== '2') state = 'failed'
239 else state = alive ? 'starting' : 'stopped'
240
241 return {
242 slug,
243 worktree: root,
244 state,
245 stage: String(status?.stage ?? ''),
246 agent: String(status?.agent ?? ''),
247 attempt: String(status?.attempt ?? ''),
248 agentStarted: String(status?.agent_started ?? ''),
249 runStarted: String(status?.run_started ?? reg?.started ?? ''),
250 updated: String(status?.updated ?? ''),
251 pid,
252 files,
253 tests: testCounts(await readText($, `${docs}/logs/tests.log`)),
254 tail: runOut.split('\n').filter(l => l.trim()).slice(-8),
255 questions: questionsText ? parseQuestions(questionsText) : [],
256 managed: reg !== null,
257 message: String(reg?.message ?? ''),
258 changes: await loadChanges($, slug, state, reg !== null, now),
259 base: String(reg?.base ?? ''),
260 }
261}
262
263async function collect($: EngineInterface): Promise<Snapshot> {
264 const now = await $.clock.now()
265 const dir = await registryDir($)
266 const runs: Run[] = []
267 const seen = new Set<string>()
268
269 const discarded: Record<string, string> = {}
270 for (const f of await names($, dir)) {
271 if (f.kind === 'file' && f.name.endsWith('.discarded')) {
272 discarded[f.name.replace(/\.discarded$/, '')] = ((await readText($, `${dir}/${f.name}`)) ?? '').trim()
273 continue
274 }
275 if (f.kind !== 'file' || !f.name.endsWith('.json')) continue
276 const reg = parse(await readText($, `${dir}/${f.name}`))
277 if (!reg || typeof reg.slug !== 'string') continue
278 seen.add(reg.slug)
279 runs.push(await loadRun($, reg.slug, String(reg.worktree ?? ''), reg, now))
280 }
281 // A pipeline started by hand with scripts/sdlc.sh in this working tree has no record: find it by its status file.
282 for (const d of await names($, 'Docs/backlog')) {
283 if (d.kind === 'dir' && !seen.has(d.name) && (await readText($, `Docs/backlog/${d.name}/logs/status.json`))) {
284 runs.push(await loadRun($, d.name, '', null, now))
285 }
286 }
287 runs.sort((a, b) => ORDER[a.state] - ORDER[b.state] || b.updated.localeCompare(a.updated))
288
289 return { now, runs, pending: await loadPending($, now), discarded }
290}
291
292// Starts (or resumes) a pipeline in the background: it outlives this pane and this session.
293async function launch($: EngineInterface, slug: string, from: string) {
294 const env: Record<string, string> = { SDLC_MAX_PARALLEL: String(CAP) }
295 if (from) env.FROM = from
296 await $.process.run(['bash', '-c', `nohup bash ${WRAPPER} run "$0" </dev/null >/dev/null 2>&1 &`, slug], { env })
297}
298
299async function stopRun($: EngineInterface, slug: string) {
300 await $.process.run(['bash', WRAPPER, 'stop', slug])
301}
302
303// Stops the pipeline if it runs, then deletes its worktree, branch and run record (the branch tip is saved).
304async function discardRun($: EngineInterface, slug: string) {
305 await $.process.run(['bash', WRAPPER, 'discard', slug, '--yes', '--stop'])
306}
307
308async function startRun($: EngineInterface, slug: string) {
309 const now = await $.clock.now()
310 await update($, launching, l => ({ ...l, [slug]: now }))
311 await launch($, slug, '')
312}
313
314const resumeFrom = (run: Run) => (RESUME_AT_IMPLEMENT.includes(run.stage) ? 'implement' : '')
315
316async function refresh($: EngineInterface) {
317 const s = await collect($)
318 await update($, snapshot, () => s)
319 const pressed = await read($, launching)
320 const left = Object.fromEntries(Object.entries(pressed).filter(([k]) => !s.runs.some(r => r.slug === k)))
321 if (Object.keys(left).length !== Object.keys(pressed).length) await update($, launching, () => left)
322
323 return s
324}
325
326// Starts as many of the selected items as the cap allows; the rest wait in the queue.
327async function runSelected($: EngineInterface) {
328 const pick = await read($, selected)
329 const slugs = Object.keys(pick).filter(k => pick[k])
330 if (!slugs.length) return
331 const s = await read($, snapshot)
332 const running = s.runs.filter(r => r.state === 'running' || r.state === 'starting').length
333 const free = Math.max(0, CAP - running)
334 for (const slug of slugs.slice(0, free)) await launch($, slug, '')
335 await update($, queue, q => [...q, ...slugs.slice(free).filter(x => !q.includes(x))])
336 await update($, selected, () => ({}))
337 await update($, notice, () => `Started ${Math.min(free, slugs.length)}, queued ${Math.max(0, slugs.length - free)} (at most ${CAP} run at once).`)
338 await refresh($)
339}
340
341// Starts queued items whenever a slot is free.
342async function drainQueue($: EngineInterface, s: Snapshot) {
343 const waiting = await read($, queue)
344 if (!waiting.length) return
345 const running = s.runs.filter(r => r.state === 'running' || r.state === 'starting').length
346 if (running >= CAP) return
347 const next = waiting[0]
348 await update($, queue, q => q.filter(x => x !== next))
349 await launch($, next, '')
350}
351
352// Saves the developer's text for one stage; the pipeline hands it to that stage's agent.
353async function saveManualInput($: EngineInterface, run: Run, key: string, value: string) {
354 const path = `${run.worktree}/Docs/backlog/${run.slug}/manual-inputs.md`
355 await $.fs.write(path, setManualInput((await readText($, path)) ?? '', key, value))
356 await update($, notice, () => `Input saved for ${key} of ${run.slug}.`)
357}
358
359async function submitAnswers($: EngineInterface, run: Run) {
360 const drafts = await read($, draft)
361 const path = `${run.worktree}/Docs/backlog/${run.slug}/questions.md`
362 const text = await readText($, path)
363 if (!text) return
364 const answers: Record<number, string> = {}
365 for (const q of run.questions) answers[q.n] = drafts[`${run.slug}:${q.n}`] ?? ''
366 await $.fs.write(path, fillAnswers(text, answers))
367 await update($, draft, d => Object.fromEntries(Object.entries(d).filter(([k]) => !k.startsWith(`${run.slug}:`))))
368 await launch($, run.slug, '')
369 await update($, notice, () => `Answers saved, ${run.slug} resumed.`)
370 await refresh($)
371}
372
373function stageStates(run: Run): { name: string; state: StageState }[] {
374 const isGone = run.state === 'stopped'
375 const at = STAGES.indexOf(run.stage)
376 const wasRepaired = run.files.includes('test-repair-1.md')
377
378 return STAGES.map((name, i): { name: string; state: StageState } => {
379 if (run.state === 'done' || i < at) return { name, state: 'done' }
380 if (at < 0 || i > at) return { name, state: 'pending' }
381 if (run.state === 'paused') return { name, state: 'paused' }
382
383 return { name, state: run.state === 'failed' || run.state === 'interrupted' || isGone ? 'failed' : 'active' }
384 }).filter(st => st.name !== 'Test repair' || st.state === 'active' || st.state === 'failed' || wasRepaired)
385}
386
387const summary = (runs: Run[], queued: number) => {
388 const n = (state: RunState) => runs.filter(r => r.state === state).length
389 const parts = [`${n('running') + n('starting')} running`, `${n('paused')} paused`]
390 if (queued) parts.push(`${queued} queued`)
391
392 return parts.join(' · ')
393}
394
395const stateLine = (run: Run, now: number) => {
396 if (run.state === 'running') return `${run.stage || 'Starting'} · ${run.agent || '…'} · ${clock(now - Date.parse(run.agentStarted))}${run.attempt ? ` · attempt ${run.attempt}` : ''}`
397 if (run.state === 'paused') return `needs answers: ${run.questions.length} question${run.questions.length === 1 ? '' : 's'}`
398 if (run.state === 'done') return 'done'
399 if (run.state === 'interrupted') return `interrupted at ${run.stage || 'start'}`
400 if (run.state === 'stopped') return `stopped (process gone) at ${run.stage || 'start'}`
401 if (run.state === 'failed') return run.message ? `failed: ${run.message}` : `failed at ${run.stage || 'start'}`
402
403 return 'starting'
404}
405
406// The overview row: the state line, then the file counts when there is something to say.
407function oneLine(run: Run, now: number): string {
408 const line = stateLine(run, now)
409 const c = run.changes
410 if (!c) return line
411 if (c.changed === 0 && c.uncommitted === 0) return run.state === 'running' ? `${line} · no changes yet` : line
412
413 return `${line} · ${c.changed} file${c.changed === 1 ? '' : 's'} changed (${c.uncommitted} uncommitted)`
414}
415
416async function sweepAgents($: EngineInterface) {
417 let list: Awaited<ReturnType<EngineInterface['agent']['list']>> = []
418 try {
419 list = await $.agent.list()
420 } catch {
421 return
422 }
423 await update($, agents, rows => {
424 const next = { ...rows }
425 for (const a of list) {
426 const was = next[a.id]
427 next[a.id] = {
428 id: a.id,
429 label: a.name ?? (a.description || a.type),
430 status: String(a.status),
431 tools: was?.tools ?? 0,
432 lastTool: was?.lastTool ?? '',
433 tokens: was?.tokens ?? 0,
434 }
435 }
436
437 return next
438 })
439}
440
441let previous: Record<string, RunState> = {}
442let isFirst = true
443
444export const register: Register = on => {
445 on('session.start', async ($, e, next) => {
446 await $.command.register({ name: 'sdlc-monitor', description: 'Open the SDLC control pane: start, watch, answer and stop pipelines' })
447 $.clock.every(EVERY_MS, async () => {
448 const s = await refresh($)
449 await drainQueue($, s)
450 await sweepAgents($)
451 const now: Record<string, RunState> = {}
452 for (const r of s.runs) {
453 now[r.slug] = r.state
454 if (!isFirst && previous[r.slug] && previous[r.slug] !== r.state) {
455 if (r.state === 'paused') $.ui.toast(`${r.slug} is paused: ${r.questions.length} question(s) need answers`)
456 else if (r.state === 'failed' || r.state === 'stopped') $.ui.toast(`${r.slug} failed at ${r.stage || 'start'}`)
457 else if (r.state === 'done') $.ui.toast(`${r.slug} is done`)
458 }
459 }
460 previous = now
461 isFirst = false
462 const q = await read($, queue)
463 const active = s.runs.filter(r => r.state === 'running' || r.state === 'starting' || r.state === 'paused')
464 $.ui.status(active.length || q.length ? `sdlc ${summary(s.runs, q.length)}` : undefined)
465 })
466
467 return next(e)
468 })
469
470 on('command.run', { command: 'sdlc-monitor' }, async $ => {
471 await refresh($)
472 await sweepAgents($)
473 await $.ui.open({ id: PANE, title: 'SDLC control' })
474 const s = await read($, snapshot)
475
476 return { text: `SDLC control opened: ${summary(s.runs, 0)}, ${s.pending.length} pending.` }
477 })
478
479 // Per-agent tool counts, for the session's own subagents (a headless `claude -p` stage is a separate process).
480 on('tool.call', async ($, e, next) => {
481 if (e.agentId) {
482 const id = e.agentId
483 await update($, agents, rows => {
484 const was = rows[id] ?? { id, label: id.slice(0, 8), status: 'running', tools: 0, lastTool: '', tokens: 0 }
485
486 return { ...rows, [id]: { ...was, tools: was.tools + 1, lastTool: e.tool } }
487 })
488 }
489
490 return next(e)
491 })
492
493 on('turn.complete', async ($, e, next) => {
494 const u = e.usage
495
496 if (e.agentId && u) {
497 const id = e.agentId
498 await update($, agents, rows => {
499 const was = rows[id] ?? { id, label: id.slice(0, 8), status: 'running', tools: 0, lastTool: '', tokens: 0 }
500
501 return { ...rows, [id]: { ...was, tokens: was.tokens + u.input_tokens + u.output_tokens + u.cache_creation_input_tokens } }
502 })
503 }
504
505 return next(e)
506 })
507
508 // The always-visible entry point: one button above the prompt.
509 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
510 if (e.props.hasSurvey) return next(e)
511 const { Box, Button, Text } = $.ui.resolve(e)
512 const s = await read($, snapshot)
513 const q = await read($, queue)
514 const live = s.runs.filter(r => r.state === 'running' || r.state === 'starting' || r.state === 'paused')
515
516 return (
517 <Box gap={1}>
518 <Button key="sdlc-open" label="SDLC" variant="primary" onPress={() => $.ui.open({ id: PANE, title: 'SDLC control' })} />
519 <Text dimColor>{live.length || q.length ? summary(s.runs, q.length) : `${s.pending.length} pending`}</Text>
520 </Box>
521 )
522 })
523
524 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
525 const { Box, Button, Input, Text } = $.ui.resolve(e)
526 const s = await read($, snapshot)
527 const current = await read($, view)
528 const run = s.runs.find(r => r.slug === current)
529
530 if (!run && !current) {
531 const pick = await read($, selected)
532 const waiting = await read($, queue)
533 const message = await read($, notice)
534 const rows = Object.values(await read($, agents))
535 const chosen = Object.keys(pick).filter(k => pick[k]).length
536 const running = s.runs.filter(r => r.state === 'running' || r.state === 'starting').length
537
538 return (
539 <Box flexDirection="column" paddingX={1} gap={1}>
540 <Text bold>
541 SDLC control <Text dimColor> {summary(s.runs, waiting.length)} · up to {CAP} at once</Text>
542 </Text>
543 {message && <Text color="cyan">{message}</Text>}
544
545 <Box flexDirection="column">
546 <Text bold>Pending ({s.pending.length}): select one or more</Text>
547 {s.pending.length === 0 && <Text dimColor>Nothing pending in Docs/backlog/index.md.</Text>}
548 {s.pending.map(p => {
549 const isOn = pick[p.slug] === true
550 const isBusy = s.runs.some(r => r.slug === p.slug && (r.state === 'running' || r.state === 'starting' || r.state === 'paused')) || waiting.includes(p.slug)
551
552 return (
553 <Box key={`row-${p.slug}`} gap={1}>
554 <Button
555 key={`sel-${p.slug}`}
556 plain
557 label={`${isOn ? '[x]' : '[ ]'} ${p.slug}`}
558 onPress={() => update($, selected, m => ({ ...m, [p.slug]: !m[p.slug] }))}
559 />
560 <Text dimColor>
561 {p.type} {p.priority}
562 </Text>
563 <Text>{cut(p.title, 56)}</Text>
564 {isBusy && <Text color="yellow">already started</Text>}
565 </Box>
566 )
567 })}
568 <Box gap={1}>
569 <Button key="run-selected" label={`Run selected (${chosen})`} variant="primary" onPress={() => runSelected($)} />
570 <Button key="clear-selected" label="Clear" onPress={() => update($, selected, () => ({}))} />
571 <Text dimColor>{running >= CAP ? 'both slots busy: extra items will queue' : `${CAP - running} slot(s) free`}</Text>
572 </Box>
573 </Box>
574
575 <Box flexDirection="column">
576 <Text bold>Pipelines</Text>
577 {s.runs.length === 0 && <Text dimColor>None yet. Select items above and press Run selected.</Text>}
578 {s.runs.map(r => (
579 <Box key={`run-${r.slug}`} gap={1} flexWrap="wrap">
580 <Text color={COLOR[r.state]} bold={r.state === 'paused'}>
581 {GLYPH[r.state]} {r.slug}
582 </Text>
583 <Text dimColor>{oneLine(r, s.now)}</Text>
584 <Button key={`open-${r.slug}`} label={r.state === 'paused' ? 'Answer' : 'Open'} variant={r.state === 'paused' ? 'primary' : undefined} onPress={() => update($, view, () => r.slug)} />
585 </Box>
586 ))}
587 {waiting.map(slug => (
588 <Text key={`queued-${slug}`} dimColor>
589 ◌ {slug} queued
590 </Text>
591 ))}
592 </Box>
593
594 {rows.length > 0 && (
595 <Box flexDirection="column">
596 <Text bold>Subagents of this session</Text>
597 {rows.slice(0, 6).map(r => (
598 <Text key={r.id} dimColor>
599 {r.label} · {r.status} · {r.tools} tools{r.lastTool ? ` (${r.lastTool})` : ''} · {r.tokens.toLocaleString()} tok
600 </Text>
601 ))}
602 </Box>
603 )}
604 </Box>
605 )
606 }
607
608 // An item with no pipeline: never started, or just discarded.
609 if (!run) {
610 const item = s.pending.find(p => p.slug === current)
611 const saved = s.discarded[current] ?? ''
612 const pressedAt = (await read($, launching))[current]
613 const isStarting = pressedAt !== undefined && s.now - pressedAt < LAUNCH_GRACE_MS
614
615 return (
616 <Box flexDirection="column" paddingX={1} gap={1}>
617 <Box gap={1} flexWrap="wrap">
618 <Button key="back" label="← All pipelines" onPress={() => update($, view, () => '')} />
619 {isStarting ? (
620 <Text color="cyan">◌ Starting…</Text>
621 ) : (
622 <Button key="start" label="Start" variant="primary" onPress={() => startRun($, current)} />
623 )}
624 </Box>
625 <Text bold>
626 {current} <Text dimColor>not started</Text>
627 </Text>
628 {item && (
629 <Text dimColor>
630 {item.type} {item.priority} · {cut(item.title, 80)}
631 </Text>
632 )}
633 {saved && <Text dimColor>Discarded. To get the old branch back: {saved}</Text>}
634 </Box>
635 )
636 }
637
638 // One pipeline in detail.
639 const drafts = await read($, draft)
640 const stopping = (await read($, confirmStop)) === run.slug
641 const discarding = (await read($, confirmDiscard)) === run.slug
642 const stages = stageStates(run)
643 const done = stages.filter(x => x.state === 'done').length
644 const since = run.agentStarted ? s.now - Date.parse(run.agentStarted) : 0
645 const [tryNo, tryMax] = run.attempt.split('/').map(Number)
646 const impls = run.files.filter(f => /^impl-\d+\.log$/.test(f)).length
647 const answered = run.questions.filter(q => drafts[`${run.slug}:${q.n}`]).length
648 const canStop = run.managed && (run.state === 'running' || run.state === 'starting')
649 const canDiscard = run.managed
650 const canResume = run.managed && (run.state === 'failed' || run.state === 'interrupted' || run.state === 'stopped')
651
652 return (
653 <Box flexDirection="column" paddingX={1} gap={1}>
654 <Box gap={1} flexWrap="wrap">
655 <Button key="back" label="← All pipelines" onPress={() => update($, view, () => '')} />
656 {canStop && (
657 <Button
658 key="stop"
659 label={stopping ? 'Confirm stop' : 'Stop'}
660 variant={stopping ? 'primary' : undefined}
661 onPress={async () => {
662 if (stopping) {
663 await stopRun($, run.slug)
664 await update($, confirmStop, () => '')
665 await refresh($)
666 } else {
667 await update($, confirmStop, () => run.slug)
668 }
669 }}
670 />
671 )}
672 {stopping && <Button key="cancel-stop" label="Keep running" onPress={() => update($, confirmStop, () => '')} />}
673 {canResume && (
674 <Button
675 key="resume"
676 label={resumeFrom(run) ? 'Resume from Implement' : 'Resume'}
677 variant="primary"
678 onPress={async () => {
679 await launch($, run.slug, resumeFrom(run))
680 await refresh($)
681 }}
682 />
683 )}
684 {canDiscard && (
685 <Button
686 key="discard"
687 label={discarding ? 'Confirm discard' : 'Discard'}
688 variant={discarding ? 'primary' : undefined}
689 onPress={async () => {
690 if (discarding) {
691 await update($, confirmDiscard, () => '')
692 await update($, confirmStop, () => '')
693 await update($, draft, d => Object.fromEntries(Object.entries(d).filter(([k]) => !k.startsWith(`${run.slug}:`))))
694 await discardRun($, run.slug)
695 await update($, notice, () => `Discarded ${run.slug}.`)
696 await refresh($)
697 } else {
698 await update($, confirmDiscard, () => run.slug)
699 }
700 }}
701 />
702 )}
703 {discarding && <Button key="cancel-discard" label="Keep it" onPress={() => update($, confirmDiscard, () => '')} />}
704 </Box>
705
706 {discarding && (
707 <Text color="yellow">
708 This deletes the worktree and branch sdlc/{run.slug} (unpushed work is lost; the branch tip is saved so it can be recovered). A running pipeline is stopped first.
709 </Text>
710 )}
711 {!run.managed && (
712 <Text color="yellow">Started outside the wrapper, so Stop and Resume are not available here. Resume it in the main working tree with ./scripts/sdlc.sh {run.slug}.</Text>
713 )}
714 {run.message && <Text color="red">{run.message}</Text>}
715
716 <Box flexDirection="column">
717 <Text bold>
718 {run.slug} <Text color={COLOR[run.state]}>{run.state === 'stopped' ? 'stopped (process gone)' : run.state}</Text>
719 </Text>
720 <Text>
721 {bar(done, stages.length, 30)} {done}/{stages.length} stages
722 </Text>
723 </Box>
724
725 <Box flexWrap="wrap" gap={1}>
726 {stages.map(st => (
727 <Box key={`stage-${st.name}`} borderStyle="round" borderColor={STAGE_COLOR[st.state]} paddingX={1}>
728 <Text color={STAGE_COLOR[st.state]} bold={st.state === 'active'} dimColor={st.state === 'pending'}>
729 {STAGE_GLYPH[st.state]} {st.name}
730 </Text>
731 </Box>
732 ))}
733 </Box>
734
735 {run.state === 'running' && run.agent && (
736 <Box flexDirection="column">
737 <Text bold>Agent</Text>
738 <Text>
739 <Text color="cyan">{run.agent}</Text>
740 <Text dimColor> running {clock(since)}</Text>
741 </Text>
742 {tryMax > 0 && (
743 <Text>
744 attempt {bar(tryNo, tryMax, tryMax * 3)} {tryNo}/{tryMax}
745 </Text>
746 )}
747 </Box>
748 )}
749
750 {run.changes && <Text>{`Files ${run.changes.changed} changed since ${run.base || 'base'} · ${run.changes.uncommitted} uncommitted`}</Text>}
751
752 {run.tests && (
753 <Text>
754 Tests {bar(run.tests.passed, run.tests.total, 24)} {run.tests.passed}/{run.tests.total} passing
755 {run.tests.failed ? <Text color="red"> {run.tests.failed} failing</Text> : null}
756 </Text>
757 )}
758
759 <Text>
760 {['specs-1.md', 'plan-1.md', 'test-cases-1.md', 'review-1.md'].map(f => `${run.files.includes(f) ? '✔' : '○'} ${f.replace('-1.md', '')}`).join(' ')}
761 {impls ? ` ⟳ impl ×${impls}` : ''}
762 </Text>
763
764 {canResume && (
765 <Box flexDirection="column" gap={1}>
766 <Text bold>Manual input per stage (type, press Enter to save; saving an empty line clears it)</Text>
767 {INPUT_STAGES.map(st => (
768 <Box key={`input-${st.key}`} gap={1} flexWrap="wrap">
769 <Text>{st.label}</Text>
770 <Input key={`in-${st.key}`} placeholder={`instructions for the ${st.label} agent`} onSubmit={value => saveManualInput($, run, st.key, value)} />
771 <Button
772 key={`resume-at-${st.key}`}
773 label={`Resume at ${st.label}`}
774 onPress={async () => {
775 await launch($, run.slug, st.key === 'spec' ? '' : st.key)
776 await refresh($)
777 }}
778 />
779 </Box>
780 ))}
781 </Box>
782 )}
783
784 {run.state === 'paused' && run.questions.length > 0 && (
785 <Box flexDirection="column" gap={1}>
786 <Text bold color="yellow">
787 {answered}/{run.questions.length} questions answered
788 </Text>
789 {run.questions.map(q => {
790 const key = `${run.slug}:${q.n}`
791
792 return (
793 <Box key={`q-${q.n}`} flexDirection="column">
794 <Text bold>
795 Q{q.n}: {q.title}
796 </Text>
797 {q.body && <Text dimColor>{q.body}</Text>}
798 <Text>
799 Suggested: <Text color="green">{q.suggested}</Text>
800 </Text>
801 <Box gap={1} flexWrap="wrap">
802 <Button key={`use-${key}`} label="Use suggested" onPress={() => update($, draft, d => ({ ...d, [key]: 'accept' }))} />
803 <Input
804 key={`ans-${key}`}
805 placeholder="or type your own answer, then Enter"
806 onSubmit={value => update($, draft, d => ({ ...d, [key]: value.trim() }))}
807 />
808 </Box>
809 {drafts[key] && (
810 <Text color="cyan">
811 → {drafts[key] === 'accept' ? 'accept suggested' : drafts[key]}
812 </Text>
813 )}
814 </Box>
815 )
816 })}
817 {answered === run.questions.length ? (
818 <Button key="submit-answers" label="Submit answers and resume" variant="primary" onPress={() => submitAnswers($, run)} />
819 ) : (
820 <Text dimColor>Answer every question to resume.</Text>
821 )}
822 </Box>
823 )}
824 {run.state === 'paused' && run.questions.length === 0 && <Text color="yellow">Paused, but questions.md has no questions I can read: open Docs/backlog/{run.slug}/questions.md.</Text>}
825
826 <Box flexDirection="column">
827 <Text bold>Recent output</Text>
828 {run.tail.length === 0 && <Text dimColor>No output yet.</Text>}
829 {run.tail.map((line, i) => (
830 <Text key={`tail-${i}`} dimColor>
831 {cut(line, 100)}
832 </Text>
833 ))}
834 </Box>
835 </Box>
836 )
837 })
838}
839types/index.d.ts 65 lines1export type Question = { n: number; title: string; body: string; suggested: string }
2
3export type RunState = 'starting' | 'running' | 'paused' | 'done' | 'failed' | 'interrupted' | 'stopped'
4
5export type Run = {
6 slug: string
7 worktree: string
8 state: RunState
9 stage: string
10 agent: string
11 attempt: string
12 agentStarted: string
13 runStarted: string
14 updated: string
15 pid: number
16 files: string[]
17 tests: { passed: number; failed: number; total: number } | null
18 tail: string[]
19 questions: Question[]
20 // false for a pipeline started by hand with scripts/sdlc.sh: it has no wrapper record, so no Stop or Resume
21 managed: boolean
22 // why the wrapper refused to start it (already merged, cap reached, no brief)
23 message: string
24 // files changed in the pipeline's own worktree (sdlc-mod.sh changes); null when unavailable (unmanaged, discarded, call failed)
25 changes: { changed: number; uncommitted: number } | null
26 // the base branch from the run record, '' when it has none
27 base: string
28}
29
30export type PendingItem = { slug: string; type: string; priority: string; title: string }
31
32// discarded: slug -> the command that brings its discarded branch back (kept by sdlc-mod.sh discard)
33export type Snapshot = { now: number; runs: Run[]; pending: PendingItem[]; discarded: Record<string, string> }
34
35export type AgentRow = {
36 id: string
37 label: string
38 status: string
39 tools: number
40 lastTool: string
41 tokens: number
42}
43
44declare module 'claude-code' {
45 interface PluginState {
46 'sdlc-monitor': {
47 snapshot: Snapshot
48 agents: Record<string, AgentRow>
49 // which pipeline the pane shows: '' is the overview
50 view: string
51 selected: Record<string, boolean>
52 queue: string[]
53 // the slug whose Stop button is waiting for a second press
54 confirmStop: string
55 // the slug whose Discard and restart button is waiting for a second press
56 confirmDiscard: string
57 // slug -> when Start was pressed, so a second press does not start it twice
58 launching: Record<string, number>
59 // answers typed for a paused run, keyed `<slug>:<question number>`
60 draft: Record<string, string>
61 notice: string
62 }
63 }
64}
65