SLOPSHOPPER

sdlc-monitor

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

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sdlc-monitor
│ ┃ SDLC control ✕ › fix the failing auth test and add an audit log call │ ┃ SDLC control 0 running · 0 paused · up to │ ┃ 2 at once ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Pending (0): select one or more ⏺ Bash(bun test) │ ┃ Nothing pending in Docs/backlog/index.md. ⎿ 3 pass, 1 fail │ ┃ [ Run selected (0) ] [ Clear ] 2 slot(s) fr │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Pipelines │ ┃ None yet. Select items above and press Run ✻ Worked for 42s · done 4:20 PM │ ┃ selected. │ › /sdlc-monitor │ ⎿ sdlc-monitor: SDLC control opened: 0 running · 0 paused, 0 pendi │ │ [ SDLC ] 0 pending ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
[ SDLC ] 0 pending
Pane · SDLC control
SDLC control 0 running · 0 paused · up to 2 at once Pending (0): select one or more Nothing pending in Docs/backlog/index.md. [ Run selected (0) ] [ Clear ] 2 slot(s) free Pipelines None yet. Select items above and press Run selected.
README

production-retail-and-wholesale

Supports production, sales, billing and inventory management.

See AGENTS.md for the implementation plan, provisioning details and contribution guidelines.

API Documentation & Swagger UI

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:

  1. Ensure the specification is generated: If you haven't made a commit yet or want to manually regenerate: ``bash npm run generate-spec ` Alternatively, committing any change to server/index.js` will trigger the pre-commit hook.
  1. Start the main API server: This server runs the actual API endpoints. ``bash cd server npm start ` This usually runs on http://localhost:3000`.
  1. Start the Swagger UI server: In a separate terminal, run: ``bash npm run start:swagger ` This server is dedicated to serving the Swagger UI and typically runs on http://localhost:3001`.
  1. Access the UI: Open your browser and navigate to http://localhost:3001/docs.

Graft setup (required for the SDLC workflow)

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.

  1. Install (needs Node.js and npm): ``bash npm install -g @nanonets/graft graft --version # confirm it is on your PATH ` Update later with graft upgrade`.
  2. Build the graph from the repo root (free, no API key). The 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 ``
  3. Start Claude Code in the repo. .mcp.json registers the graft MCP server (graft mcp). On first use Claude Code asks you to approve project MCP servers; approve graft.
  4. Try it: graft ask "where is the sales invoice calculated" --source.

Two ways Claude uses graft

  • Hooks (default). .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.
  • MCP tools. Claude can call graft_find_code, graft_find_all, graft_trace_calls, graft_file_api and graft_repo_map through the server in .mcp.json.

Running integration tests

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.

SDLC pipeline: 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:

  1. DATABASE_URL set in your shell.
  2. 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.
  3. A throwaway 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.

SDLC pipeline: what the Ship stage commits

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:

ReasonFiles
pre-existing local changesfiles 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/
generatednode_modules/, .DS_Store
too largefiles 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).

Interactive SDLC: control pane and parallel pipelines

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.

  • Overview: the pending backlog items with checkboxes, a Run selected button (extra items wait in a queue until a slot frees), and one row per pipeline with its state: running, paused, done, failed, interrupted. Each row ends with how many files the pipeline has changed (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.
  • Open a pipeline: stage chips, progress bar, the current agent with elapsed time and attempt, test progress, artifacts and recent output.
  • Paused for answers: a toast appears, the row shows Answer, and the pipeline view lists each Spec question with its suggested answer. Press Use suggested or type your own, then Submit answers and resume.
  • Interrupt: Stop (press twice to confirm). A failed or interrupted pipeline offers Resume (from Implement if it got past Red tests).
  • Manual input per stage: a failed, interrupted or stopped pipeline shows one text box per agent stage (Spec, Plan, Red tests, Implement, Review) and a Resume at <stage> button. Press Enter to save the text; it goes into 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.
  • Discard and start over: Discard (press twice to confirm) stops the pipeline if it is running and deletes its worktree and branch. The pane stays on the item and shows Start (and the command that brings the old branch back). Start runs it again from Spec, and the running pipeline shows Stop and Discard again. Discard is offered for any pipeline the wrapper started.

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.

Backlog

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.

Source 2 files
hooks/register.tsx 839 lines
1import { 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}
839
types/index.d.ts 65 lines
1export 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