Hook-enforced approval gates for Claude Code: intent → spec → plan → build → verify → review, with a status bar and one-click approvals.

Claude Code writes code fast, and skips the parts of the job that keep a team safe: agreeing on what to build, writing it down, and waiting for a human to say yes. This plugin puts those steps back, and enforces them with hooks instead of asking nicely in a prompt.
Every change moves through Intent → Spec → Plan → Build → Verify → Review. Claude writes each artifact under docs/sdlc/<feature>/, and you approve it. Until the plan is approved, Claude cannot edit code. When it is done, the Definition of Done runs your verify commands, and an independent reviewer agent checks the change before the PR.
The workflow follows the AI-native SDLC playbook from Anthropic Academy. This plugin is an independent project, not made or endorsed by Anthropic.
sdlc approve intent|spec|plan, records an approval, with the artifact's sha256, your name and the time. Editing an approved artifact reopens its approval. Claude cannot approve for you.sdlc trivial.Stop hook runs your verify commands and sends Claude back on a failure.sdlc-reviewer agent reviews the diff against the spec, the plan and your REVIEW.md with fresh context.Approve spec. The prompt's typeahead completes sdlc ... commands.claude plugin marketplace add Sennjen/claude-sdlc
claude plugin install ai-sdlc@ai-sdlc
Then run /ai-sdlc:init in your project. It detects the verify commands, adds a short CLAUDE.md section and REVIEW.md, and writes sdlc.config.json, the switch that turns the plugin on for that repository. Without sdlc.config.json the plugin does nothing.
Requirements: Claude Code 2.1.293 or newer for the status bar (the gates work on older versions), python3 3.9 or newer, and git. The plugin is for Claude Code: it uses hooks, a bin/ command and a UI module, which claude.ai chat and Cowork don't load.
Everything runs on your machine. The plugin makes no network requests and sends no data anywhere.
python3 with the plugin's own scripts/sdlc.py from its hooks and its UI module; git to read the repository (git config user.name, diffs and hashes of the change); the verify commands you list in sdlc.config.json, through your shell, when a turn that changed code ends. When you click a file or PR link in the bar, it opens the file in the desktop app's Files pane, or runs open or xdg-open.sdlc.config.json, and the output of the tools Claude runs, to find the URL from gh pr create or glab mr create.approvals.json under docs/sdlc/, and its state in .sdlc/ (the active feature, per-session flags, the test lock, review verdicts, PR URLs and an audit.log). Approvals and the audit log record your git user name, or your OS user name when git has none, and the time.The full documentation, with every gate, command and limit, is in the repository README. Report problems in GitHub Issues.
MIT License. See LICENSE.
hooks/ui.tsx 989 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Approval, ExtraFiles, FeatureInfo, SdlcState, Stage, StageInfo } from '../types'
5
6// The gates stay in sdlc.py (command hooks). This module only shows their state and
7// sends user-only commands through the same UserPromptSubmit handler a typed
8// `sdlc approve ...` reaches, so approvals keep one code path and one audit trail.
9//
10// Two sites:
11// - the band above the prompt: feature, stage, status and the one action that fits now;
12// - `SDLC` in the prompt footer: unfolds the band into the details (every artifact with
13// its state and a link that opens it, the session switches, the same actions, `Init`
14// where the repository has no SDLC yet) and folds it back.
15// Actions that start a skill run its slash command at once, as if the person typed it.
16// Claude can only ask for a test unlock (the `request_test_unlock` tool); the person presses it.
17// The prompt's typeahead completes a typed `sdlc ...` command, and a terminal's native
18// notifications report what the person may be waiting on away from the screen.
19
20const REFRESH_MS = 10_000
21const STAGES: readonly Stage[] = ['intent', 'spec', 'plan']
22
23const stateAtom = atom({ plugin: 'ai-sdlc', key: 'state' } as const, null)
24const errorAtom = atom({ plugin: 'ai-sdlc', key: 'error' } as const, null)
25const busyAtom = atom({ plugin: 'ai-sdlc', key: 'busy' } as const, false)
26const filesAtom = atom({ plugin: 'ai-sdlc', key: 'files' } as const, {
27 progress: false,
28 approvals: false,
29 steps: { done: 0, total: 0 },
30})
31const detailsAtom = atom({ plugin: 'ai-sdlc', key: 'details' } as const, false)
32const hiddenAtom = atom({ plugin: 'ai-sdlc', key: 'hidden' } as const, false)
33const unlockAtom = atom({ plugin: 'ai-sdlc', key: 'unlockRequest' } as const, null)
34
35// The model's way to ask for the test lock to be lifted. It unlocks nothing: the band then
36// offers `Unlock tests`, and only the person's press (or typed command) unlocks. The verify
37// skill tells Claude to call it, so its schema sits in the tool list rather than behind
38// ToolSearch.
39const UNLOCK_TOOL = 'request_test_unlock'
40const UNLOCK_TOOL_SPEC = {
41 name: UNLOCK_TOOL,
42 isDeferred: false,
43 description:
44 'Ask the user to unlock the SDLC test lock. Call it when a locked test file has to change: ' +
45 'the test itself is wrong, or a review found cases that need new tests. It does not unlock ' +
46 'anything; the user presses "Unlock tests" in the SDLC bar or types `sdlc unlock tests`. ' +
47 'After calling it, stop and wait for the user.',
48 inputSchema: {
49 type: 'object',
50 properties: { reason: { type: 'string', description: 'Why the tests must change, in one sentence.' } },
51 required: ['reason'],
52 },
53}
54
55type On = Extract<SdlcState, { enabled: true }>
56type Steps = ExtraFiles['steps']
57type UnlockRequest = { reason: string } | null
58type Action = { key: string; label: string; run: () => unknown }
59
60const script = ($: EngineInterface) => `${$.plugin.root}/scripts/sdlc.py`
61const short = (sha: string | null | undefined) => (sha ? sha.slice(0, 12) : '-')
62const clock = (at: string | null | undefined) => (at ? at.replace('T', ' ').slice(0, 16) : '')
63const featureDir = (s: On) => (s.stages.intent?.path ?? '').replace(/\/intent\.md$/, '')
64const fileUrl = (root: string, rel: string) => `file://${encodeURI(`${root}/${rel}`)}`
65
66// ------------------------------------------------------------------- reading state
67
68let inflight: Promise<SdlcState | null> | null = null
69
70async function fetchState($: EngineInterface): Promise<SdlcState | null> {
71 const sid = await $.session.id()
72 const cwd = await $.session.cwd()
73 try {
74 const r = await $.process.run(['python3', script($), 'cli', 'state', '--session', sid], {
75 cwd,
76 env: { CLAUDE_PROJECT_DIR: cwd },
77 timeoutMs: 15_000,
78 })
79 if (r.exitCode !== 0) {
80 await update($, errorAtom, () => `sdlc.py state exited ${r.exitCode}: ${r.stderr.trim().slice(0, 200)}`)
81 return null
82 }
83 await update($, errorAtom, () => null)
84 return JSON.parse(r.stdout) as SdlcState
85 } catch (err) {
86 await update($, errorAtom, () => `cannot run sdlc.py: ${String(err).slice(0, 200)}`)
87 return null
88 }
89}
90
91const NO_STEPS = { done: 0, total: 0 }
92// plan.md lists its steps as `1. [ ] ...` under `## Work sequence` and is never edited
93// after approval; progress.md records a finished step as `- [x] Step 3: ...`.
94const PLAN_STEP = /^\s*(\d+)\.\s+\[[ xX]\]/gm
95const DONE_STEP = /^\s*-\s*\[[xX]\]\s*Step\s+(\d+)\b/gim
96
97/** Steps of the plan's work sequence, and how many of them progress.md marks done. */
98function countSteps(plan: string, progress: string): { done: number; total: number } {
99 const work = plan.split(/^##\s+/m).find(section => section.startsWith('Work sequence')) ?? ''
100 const numbers = [...work.matchAll(PLAN_STEP)].map(m => Number(m[1]))
101 const done = new Set([...progress.matchAll(DONE_STEP)].map(m => Number(m[1])).filter(n => numbers.includes(n)))
102 return { done: done.size, total: numbers.length }
103}
104
105async function statFiles($: EngineInterface, s: SdlcState): Promise<ExtraFiles> {
106 if (!s.enabled || !s.active) return { progress: false, approvals: false, steps: NO_STEPS }
107 const dir = `${s.root}/${featureDir(s)}`
108 const isFile = (name: string) =>
109 $.fs.stat(`${dir}/${name}`).then(
110 st => st.kind === 'file',
111 () => false,
112 )
113 const text = (name: string) => $.fs.read(`${dir}/${name}`).catch(() => '')
114 const steps = s.stage === 'build' ? countSteps(await text('plan.md'), await text('progress.md')) : NO_STEPS
115 return { progress: await isFile('progress.md'), approvals: await isFile('approvals.json'), steps }
116}
117
118/** A native notification through the person's own channel, for what they may be waiting on
119 * away from the screen. Only a terminal has a channel (the desktop answers `no-surface`),
120 * so it goes beside a toast, never in its place. */
121function notify($: EngineInterface, text: string) {
122 try {
123 void $.ui.notify(text, { title: 'SDLC' }).catch(() => undefined)
124 } catch {
125 // an engine before 2.1.295 has no $.ui.notify: the toast alone shows
126 }
127}
128
129function announce($: EngineInterface, prev: SdlcState | null, next: SdlcState) {
130 if (!prev || !prev.enabled || !next.enabled) return
131 if (prev.active === next.active) {
132 for (const stage of STAGES) {
133 if (prev.stages[stage]?.state === 'approved' && next.stages[stage]?.state === 'stale') {
134 $.ui.toast(`${stage}.md changed after approval: it needs a new approval`, { timeoutMs: 8000 })
135 }
136 }
137 }
138 if (prev.code_gate !== next.code_gate) {
139 $.ui.toast(next.code_gate === 'open' ? 'SDLC code gate is open' : 'SDLC code gate is closed')
140 }
141 const last = next.verify.last
142 if (last && last.at !== prev.verify.last?.at) {
143 $.ui.toast(last.ok ? 'SDLC verify passed' : 'SDLC verify failed: Definition of Done not met', {
144 timeoutMs: last.ok ? 4000 : 8000,
145 })
146 // A pass ends the turn, which the engine announces itself; a failure keeps Claude working.
147 if (!last.ok) notify($, 'Verify failed: the Definition of Done is not met')
148 }
149 if (prev.active !== next.active) return
150 const review = next.review
151 if (review && review.at !== prev.review?.at) {
152 $.ui.toast(review.verdict === 'ready' ? 'SDLC review passed: the PR is next'
153 : 'SDLC review: changes required', { timeoutMs: 8000 })
154 notify($, review.verdict === 'ready' ? 'Review passed: the PR is next' : 'Review: changes required')
155 }
156 if (next.pr && next.pr.url !== prev.pr?.url) {
157 $.ui.toast(`PR created: ${next.pr.url}`, { timeoutMs: 8000 })
158 notify($, `PR created: ${next.pr.url}`)
159 }
160}
161
162/** The draft the person was last told about, so each version of an artifact is announced
163 * once. A reload forgets it and announces the draft that waits once more. */
164let announcedDraft = ''
165
166/** At the end of Claude's turn: the stage's artifact, written or changed, waits for the
167 * person's approval. */
168function announceDraft($: EngineInterface, s: SdlcState | null) {
169 if (!s || !s.enabled || !s.active || !s.stage || s.stage === 'build') return
170 const info = s.stages[s.stage]
171 if (!info?.sha256 || (info.state !== 'draft' && info.state !== 'stale')) return
172 const key = `${s.active}/${s.stage}/${info.sha256}`
173 if (key === announcedDraft) return
174 announcedDraft = key
175 notify($, `${s.stage}.md of ${s.active} waits for your approval`)
176}
177
178async function refresh($: EngineInterface): Promise<SdlcState | null> {
179 if (inflight) return inflight
180 inflight = (async () => {
181 try {
182 const next = await fetchState($)
183 if (!next) return null
184 const files = await statFiles($, next)
185 const prev = await read($, stateAtom)
186 announce($, prev, next)
187 // A band hidden with its × comes back when there is something new to do.
188 const where = (s: SdlcState | null) =>
189 s && s.enabled ? `${s.active}/${s.stage}/${s.stage_state}/${reviewState(s)}/${!!s.pr}` : ''
190 if (prev && where(prev) !== where(next)) await update($, hiddenAtom, () => false)
191 // A request ends once the tests are unlocked, by the button or a typed command.
192 if (!next.enabled || !next.tests_locked) await update($, unlockAtom, () => null)
193 await update($, filesAtom, () => files)
194 await update($, stateAtom, () => next)
195 return next
196 } finally {
197 inflight = null
198 }
199 })()
200 return inflight
201}
202
203// ------------------------------------------------------------------- user commands
204
205/** Runs a user-only `sdlc ...` command through sdlc.py's UserPromptSubmit handler. */
206async function runUserCommand($: EngineInterface, line: string): Promise<string | null> {
207 const sid = await $.session.id()
208 const cwd = await $.session.cwd()
209 const payload = { session_id: sid, cwd, hook_event_name: 'UserPromptSubmit', prompt: line }
210 try {
211 const r = await $.process.run(['python3', script($), 'hook', 'prompt'], {
212 cwd,
213 stdin: JSON.stringify(payload),
214 timeoutMs: 20_000,
215 })
216 if (r.exitCode !== 0 || !r.stdout.trim()) return null
217 const out = JSON.parse(r.stdout) as { hookSpecificOutput?: { additionalContext?: string } }
218 return out.hookSpecificOutput?.additionalContext?.split('\n')[0] ?? null
219 } catch {
220 return null
221 }
222}
223
224/** Presses a user command, tells the model what happened, and refreshes the view. */
225async function press($: EngineInterface, line: string): Promise<string | null> {
226 if (await read($, busyAtom)) return null
227 await update($, busyAtom, () => true)
228 try {
229 const msg = await runUserCommand($, line)
230 if (!msg) {
231 $.ui.toast(`sdlc: \`${line}\` failed; type it in the chat instead`, { timeoutMs: 8000 })
232 return null
233 }
234 // The record is already written; if the note to the model is refused, the model still
235 // reads the new state in the next prompt's [SDLC] status line.
236 try {
237 await $.session.append({
238 message: {
239 type: 'user',
240 content: [{ type: 'text', text: `${msg} (The user pressed \`${line}\` in the SDLC UI.)` }],
241 },
242 })
243 } catch {
244 // nothing to undo
245 }
246 $.ui.toast(msg.replace(/^\[SDLC\]\s*/, '').split('. ')[0] ?? msg)
247 await refresh($)
248 return msg
249 } finally {
250 await update($, busyAtom, () => false)
251 }
252}
253
254/** Folds and unfolds the band's details (SDLC in the footer, the feature's name); a band
255 * hidden with its × comes back unfolded. */
256async function toggleDetails($: EngineInterface) {
257 void refresh($)
258 const wasHidden = await read($, hiddenAtom)
259 await update($, hiddenAtom, () => false)
260 await update($, detailsAtom, open => wasHidden || !open)
261}
262
263/** Puts a command in the prompt for the person to send; `hint` says what is left to type. */
264async function fillCommand($: EngineInterface, command: string, hint?: string) {
265 await update($, detailsAtom, () => false)
266 const filled = await $.prompt.fill({ text: `${command} ` })
267 $.ui.toast(filled.isFilled ? hint ?? `Press Enter to run ${command}` : `Type ${command} in the prompt`)
268}
269
270/** Runs a slash command as if the person typed it and pressed Enter (queued while a turn
271 * runs); where it cannot run, puts it in the prompt instead. */
272async function runCommand($: EngineInterface, command: string, args = '') {
273 await update($, detailsAtom, () => false)
274 const typed = args ? `${command} ${args}` : command
275 $.ui.toast(`Running ${typed}`)
276 void $.command.run({ command: command.replace(/^\//, ''), args }).catch(() => fillCommand($, typed))
277}
278
279/** Shows one of the desktop's own panes (Files, Diff); false where there is none. */
280async function showPane($: EngineInterface, args: { pane: 'file' | 'diff'; path?: string }): Promise<boolean> {
281 try {
282 if (!(await $.mcp.call('ccd_view', 'show_pane', args)).isError) return true
283 } catch {
284 // not the desktop, or the server is not reachable this way
285 }
286 try {
287 const shown = (await $.tool.call({ tool: 'mcp__ccd_view__show_pane', ...args } as never)) as {
288 deny?: string
289 isError?: boolean
290 }
291 if (!shown.deny && !shown.isError) return true
292 } catch {
293 // no such tool: not the desktop
294 }
295 return false
296}
297
298/** Opens the session's changes in the desktop's Diff pane. */
299async function showChanges($: EngineInterface) {
300 if (!(await showPane($, { pane: 'diff' }))) $.ui.toast('The Diff pane is in the desktop app; run `git diff` here')
301}
302
303/** Opens a file in the desktop's Files pane; where there is none, in the host's default app. */
304async function openFile($: EngineInterface, path: string) {
305 if (await showPane($, { pane: 'file', path })) return
306 await openUrl($, path)
307}
308
309/** Opens a path or URL in the host's default app. */
310async function openUrl($: EngineInterface, target: string) {
311 for (const argv of [['open', target], ['xdg-open', target]]) {
312 try {
313 if ((await $.process.run(argv, { timeoutMs: 10_000 })).exitCode === 0) return
314 } catch {
315 // try the next opener
316 }
317 }
318 $.ui.toast(`Cannot open ${target}`, { timeoutMs: 8000 })
319}
320
321/** Approves the artifact as it was on screen: if the file changed since, nothing is approved. */
322async function approve($: EngineInterface, shown: Approval) {
323 const fresh = await refresh($)
324 const now = fresh && fresh.enabled ? fresh.stages[shown.stage] : undefined
325 if (!now || now.sha256 !== shown.sha256) {
326 $.ui.toast(`${shown.path} changed since it was shown. Review it again.`, { timeoutMs: 8000 })
327 return
328 }
329 await press($, `sdlc approve ${shown.stage}`)
330 const after = await read($, stateAtom)
331 const recorded = after && after.enabled ? after.stages[shown.stage]?.approved_sha256 : null
332 if (recorded && recorded !== shown.sha256) {
333 $.ui.toast(
334 `${shown.path} changed during approval: recorded ${short(recorded)}, shown ${short(shown.sha256)}`,
335 { timeoutMs: 10_000 },
336 )
337 }
338}
339
340/** The review of the code as it is now: `ready`, `changes`, or `none` (never run, or the
341 * code changed since). */
342function reviewState(s: On): 'ready' | 'changes' | 'none' {
343 return s.review?.current ? s.review.verdict : 'none'
344}
345
346/** Whether every plan step is done; a plan without numbered steps counts as done when
347 * verify passes. */
348const isBuilt = (s: On, steps: Steps) => (steps.total === 0 ? !!s.verify.last?.ok : steps.done >= steps.total)
349
350/** The one action that moves the feature on from where it stands. */
351function nextAction($: EngineInterface, s: On, steps: Steps = NO_STEPS): Action | null {
352 if (!s.active) {
353 if (s.fasttrack) return { key: 'fasttrack-off', label: 'End fast-track', run: () => press($, 'sdlc trivial off') }
354 return { key: 'start', label: 'Start feature', run: () => runCommand($, '/ai-sdlc:intent') }
355 }
356 if (s.stage === 'build') {
357 const build = (label: string): Action => ({ key: 'build', label, run: () => runCommand($, '/ai-sdlc:build') })
358 if (!isBuilt(s, steps)) {
359 if (steps.total === 0) return build('Build')
360 return build(steps.done === 0 ? 'Start building' : 'Continue building')
361 }
362 // A finished build goes through review, then the PR; the feature closes once the PR exists.
363 if (s.pr) return { key: 'done', label: 'Close feature', run: () => press($, 'sdlc done') }
364 const review = reviewState(s)
365 if (review === 'ready') return { key: 'pr', label: 'Create PR', run: () => runCommand($, '/ai-sdlc:review', 'pr') }
366 return {
367 key: 'review',
368 label: review === 'changes' ? 'Review again' : 'Review',
369 run: () => runCommand($, '/ai-sdlc:review'),
370 }
371 }
372 const stage = s.stage
373 const info = stage ? s.stages[stage] : undefined
374 if (!stage || !info) return null
375 if (info.state === 'missing') {
376 // The plan skill runs outside plan mode too: plan mode replaces the session's permission
377 // mode (a bypassPermissions session then asks about every Bash read), and the code gate
378 // already keeps code untouched until `sdlc approve plan`.
379 return { key: 'write', label: `Write ${stage}`, run: () => runCommand($, `/ai-sdlc:${stage}`) }
380 }
381 if ((info.state === 'draft' || info.state === 'stale') && info.sha256) {
382 const sha256 = info.sha256
383 return {
384 key: 'approve',
385 label: `${info.state === 'stale' ? 'Re-approve' : 'Approve'} ${stage}`,
386 run: () => approve($, { stage, path: info.path, sha256 }),
387 }
388 }
389 return null
390}
391
392// ---------------------------------------------------------------------- typeahead
393
394/** The `sdlc ...` commands only the person may send, as sdlc.py reads them. */
395const VERBS: readonly Suggestion[] = [
396 { text: 'approve', description: 'approve intent, spec or plan' },
397 { text: 'skip', description: 'skip the spec: sdlc skip spec <reason>' },
398 { text: 'trivial', description: 'fast-track a small change; off ends it' },
399 { text: 'unlock', description: 'unlock the test files' },
400 { text: 'feature', description: 'make another feature active' },
401 { text: 'done', description: 'close the active feature' },
402 { text: 'status', description: 'show the SDLC status' },
403]
404
405type Suggestion = { text: string; description?: string }
406
407/** Rows for the word being typed in a `sdlc ...` line: `sdlc`, the verb, then its one
408 * argument, taken from the state the band shows. */
409function suggest(s: SdlcState | null, words: string[], token: string): Suggestion[] {
410 if (!s || !s.enabled) return []
411 const prefix = token.toLowerCase()
412 const starting = (rows: readonly Suggestion[]) => rows.filter(r => r.text.startsWith(prefix))
413 if (words.length === 0) {
414 return prefix.length >= 2 ? starting([{ text: 'sdlc', description: 'SDLC commands only you can send' }]) : []
415 }
416 if (words[0]?.toLowerCase() !== 'sdlc' || words.length > 2) return []
417 if (words.length === 1) return starting(VERBS)
418 switch (words[1]?.toLowerCase()) {
419 case 'approve':
420 return starting(STAGES.flatMap(stage => {
421 const info = s.stages[stage]
422 if (!info || info.state === 'missing' || info.state === 'skipped') return []
423 return [{ text: stage, description: stageValueText(info) }]
424 }))
425 case 'skip':
426 return starting([{ text: 'spec', description: 'then the reason' }])
427 case 'trivial':
428 return s.fasttrack ? starting([{ text: 'off', description: 'end fast-track' }]) : []
429 case 'unlock':
430 return starting([{ text: 'tests', description: s.tests_locked ? 'Locked' : 'not locked' }])
431 case 'feature':
432 return starting(otherFeatures(s).map(f => ({ text: f.slug, description: featureText(f) })))
433 default:
434 return []
435 }
436}
437
438export const register: Register = on => {
439 on('session.start', async ($, e, next) => {
440 const started = await next(e)
441 try {
442 await $.tool.register(UNLOCK_TOOL_SPEC)
443 } catch {
444 // an engine without plugin tools: the band simply never offers the request
445 }
446 await refresh($)
447 // The timer catches edits made outside the session (the person's editor). Where
448 // SDLC is off it idles; prompts and tool calls still notice a later /ai-sdlc:init.
449 $.clock.every(REFRESH_MS, () =>
450 void read($, stateAtom).then(s => (s && !s.enabled ? undefined : refresh($))),
451 )
452 return started
453 })
454
455 on('prompt.submit', async ($, e, next) => {
456 const result = await next(e)
457 void refresh($)
458 return result
459 })
460
461 on('tool.call', async ($, e, next) => {
462 if (e.tool === `mcp__${$.plugin.name}__${UNLOCK_TOOL}`) {
463 const s = await refresh($)
464 if (!s || !s.enabled || !s.tests_locked) return { result: 'The test files are not locked; nothing to ask.' }
465 const reason = String((e as { reason?: unknown }).reason ?? '').trim() || 'no reason given'
466 await update($, unlockAtom, () => ({ reason }))
467 await update($, hiddenAtom, () => false)
468 return {
469 result:
470 'The SDLC bar now asks the user to unlock the tests. Stop here and wait: tests stay ' +
471 'read-only until the [SDLC] status no longer says they are locked.',
472 }
473 }
474 const ran = await next(e)
475 if (['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash'].includes(e.tool)) void refresh($)
476 return ran
477 })
478
479 on('turn.complete', async ($, e, next) => {
480 const result = await next(e)
481 // Only the main loop's answer ends Claude's work; a subagent's turn ends inside it.
482 const isMain = !e.agentId && e.reason === 'answer'
483 void refresh($).then(s => (isMain ? announceDraft($, s) : undefined))
484 return result
485 })
486
487 // The prompt's typeahead: the `sdlc ...` commands and their arguments, from the state
488 // the band already holds, beneath the engine's own rows.
489 on('prompt.autocomplete', async ($, e, next) => {
490 const result = await next(e)
491 const words = e.text.slice(0, e.start).trim().split(/\s+/).filter(Boolean)
492 const mine = suggest(await read($, stateAtom), words, e.token)
493 return mine.length > 0 ? { suggestions: [...result.suggestions, ...mine] } : result
494 })
495
496 // The prompt footer: the session modes as the engine draws them, then `SDLC`,
497 // which unfolds the band above the prompt into the details and folds it back.
498 on('ui.render', { component: 'SessionMode' }, async ($, e) => {
499 const { Box, Text, Button } = $.ui.resolve(e)
500 const modes = e.props.modes
501 return (
502 <Box gap={1}>
503 {modes.length > 0 ? <Text dimColor>{modes.join(' & ')}</Text> : null}
504 <Button
505 key="sdlc"
506 label="SDLC"
507 plain
508 onPress={() => toggleDetails($)}
509 />
510 </Box>
511 )
512 })
513
514 // The band: one row (feature, stage, status, next action), or the details.
515 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
516 if (e.props.hasSurvey) return next(e)
517 const s = await read($, stateAtom)
518 const error = await read($, errorAtom)
519 const isOpen = await read($, detailsAtom)
520 if (!isOpen && (!s || !s.enabled || (await read($, hiddenAtom)))) return next(e)
521
522 const els = {
523 ...($.ui.resolve(e) as Elements),
524 surface: e.surface,
525 columns: e.props.bodyColumns,
526 isWorking: e.props.isWorking,
527 }
528 const busy = await read($, busyAtom)
529 const files = await read($, filesAtom)
530 const steps = files.steps
531 const unlock = await read($, unlockAtom)
532 if (!isOpen && s && s.enabled) {
533 return (
534 <els.Box gap={1}>
535 {Summary(els, $, s, steps)}
536 {ActionRow(els, $, s, busy, steps, unlock)}
537 <els.Button key="hide" label="×" role="dismiss" onPress={() => update($, hiddenAtom, () => true)} />
538 </els.Box>
539 )
540 }
541 return Details(els, $, s, error, busy, files, unlock)
542 })
543}
544
545// ---------------------------------------------------------------------- drawing
546
547// The element constructors come from `$.ui.resolve(e)`; each part takes them as its
548// first argument so one tree draws on every surface. The desktop gets vector bars and
549// rules, as its own popovers draw them; the terminal gets text.
550type Elements = { Box: any; Text: any; Button: any; Markdown: any; Svg?: any }
551type Els = Elements & { surface: string; columns: number; isWorking: boolean }
552
553/** Actions that record a decision about a file: held while Claude's turn runs, since the
554 * file may still be half written (a typed `sdlc approve` waits for the turn the same way). */
555const HELD_WHILE_WORKING = new Set(['approve', 'done'])
556const isHeld = (els: Els, key: string) => els.isWorking && HELD_WHILE_WORKING.has(key)
557
558/** A button that reads as disabled (the API has no `disabled`): dim, and a press only says
559 * why. Otherwise the button runs `run`, once at a time. */
560function ActionButton(els: Els, $: EngineInterface, a: { key: string; label: string; run: () => unknown },
561 hotkey: string, isPrimary: boolean, busy: boolean) {
562 const { Button } = els
563 if (isHeld(els, a.key)) {
564 return (
565 <Button key={a.key} label={a.label} dimColor
566 onPress={() => $.ui.toast('Available when Claude finishes the current turn')} />
567 )
568 }
569 return (
570 <Button
571 key={a.key}
572 label={busy && a.key === 'approve' ? 'Approving…' : a.label}
573 {...keys(els, hotkey, isPrimary)}
574 onPress={() => (busy ? undefined : a.run())}
575 />
576 )
577}
578
579/** A hotkey and the accent where they help: the terminal. The desktop draws its own quiet
580 * buttons, as it draws Create PR: no accent and no key badge. */
581const keys = (els: Els, hotkey: string, isPrimary = false) =>
582 els.surface === 'terminal'
583 ? { ...(hotkey ? { hotkey } : {}), variant: isPrimary ? ('primary' as const) : undefined }
584 : {}
585
586const STAGE_NAME: Record<Stage | 'build', string> = { intent: 'Intent', spec: 'Spec', plan: 'Plan', build: 'Build' }
587const STATE_WORD: Record<string, string> = {
588 approved: 'Approved',
589 skipped: 'Skipped',
590 draft: 'Draft',
591 stale: 'Changed',
592 missing: 'Not started',
593}
594// Status words take the person's theme colors (theme keys), so they read in a light, dark or
595// colorblind-friendly theme alike. The desktop's vector bars and rules draw in their own colors.
596const ERROR = 'error'
597const WARNING = 'warning'
598const ACCENT = '#5b8def'
599const TRACK = 'rgba(128,128,128,0.28)'
600
601/** Where the active feature stands, in words: "Spec · waiting for approval". */
602function whereText(s: On, steps: Steps = NO_STEPS): string {
603 if (!s.active) return s.fasttrack ? 'Fast-track on' : 'No active feature'
604 if (s.stage === 'build' && isBuilt(s, steps)) {
605 if (s.pr) return 'Build · PR created'
606 if (reviewState(s) === 'ready') return 'Build · review passed'
607 }
608 if (s.stage === 'build' && steps.total > 0) return `Build · ${steps.done}/${steps.total} steps`
609 if (s.stage === 'build') return `Build · ${s.verify.last?.ok ? 'verify passed' : 'in progress'}`
610 const doing = s.stage_state === 'missing' ? 'not started'
611 : s.stage_state === 'stale' ? 'changed after approval' : 'waiting for approval'
612 return `${STAGE_NAME[s.stage ?? 'intent']} · ${doing}`
613}
614
615/** The band's one row: feature, where it stands (the current artifact a link that opens
616 * it in the Files pane), a warning when one applies. */
617function Summary(els: Els, $: EngineInterface, s: On, steps: Steps) {
618 const { Box, Text } = els
619 // One or two words: a longer note pushes the band's button onto a second line, and the
620 // reason for an unlock request is already in the chat.
621 const warn = s.verify.last && !s.verify.last.ok ? 'verify failed'
622 : s.stage === 'build' && !s.pr && reviewState(s) === 'changes' ? 'changes required'
623 : s.tests_locked ? 'tests locked' : ''
624 const info = s.stage && s.stage !== 'build' ? s.stages[s.stage] : undefined
625 const isFile = !!info && (info.state === 'draft' || info.state === 'stale')
626 const where = whereText(s, steps)
627 return (
628 <Box gap={1} flexShrink={1} flexGrow={1}>
629 {s.active ? TitleButton(els, $, s.active) : null}
630 {isFile && info ? FileName(els, $, s.root, info.path, true) : null}
631 <Text dimColor wrap="truncate-end">
632 {isFile ? where.slice(where.indexOf('·')) : where}
633 </Text>
634 {warn ? (
635 <Text color={warn === 'tests locked' ? WARNING : ERROR} wrap="truncate-end">
636 {warn}
637 </Text>
638 ) : null}
639 </Box>
640 )
641}
642
643/** The one button for the next action; it runs at once. */
644function ActionRow(els: Els, $: EngineInterface, s: On, busy: boolean, steps: Steps, unlock: UnlockRequest) {
645 const { Box } = els
646 // Claude asked for the locked tests to be unlocked: that is what the build waits on now.
647 // Otherwise a lock is part of the plan (bug-fix flow) and the next build action leads.
648 const action = unlock && s.tests_locked
649 ? { key: 'unlock', label: 'Unlock tests', run: () => press($, 'sdlc unlock tests') }
650 : nextAction($, s, steps)
651 // With no feature there are two ways in: a feature, or a small change on fast-track.
652 const list = [action, !s.active && !s.fasttrack ? fastTrackAction($) : null].filter((a): a is Action => !!a)
653 if (list.length === 0) return null
654 return <Box gap={1}>{list.map((a, i) => ActionButton(els, $, a, i === 0 ? 'a' : 't', i === 0, busy))}</Box>
655}
656
657/** A small change without intent, spec and plan: the person types what it is. */
658function fastTrackAction($: EngineInterface): Action {
659 return {
660 key: 'fasttrack',
661 label: 'Fast-track',
662 run: () => fillCommand($, 'sdlc trivial', 'Type what the small change is, then press Enter'),
663 }
664}
665
666/** A section's title: bold, in the text colour, so it reads as the head of its table
667 * rather than as one more grey value. */
668function SectionTitle({ Text }: Els, title: string) {
669 return (
670 <Text key={`title-${title}`} bold>
671 {title}
672 </Text>
673 )
674}
675
676/** A thin rule across the body. */
677function Rule({ Svg, Text, surface, columns }: Els, key: string) {
678 if (surface === 'desktop' && Svg) {
679 return (
680 <Svg
681 key={key}
682 alt=""
683 source={`<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="1" viewBox="0 0 1000 1"><rect width="1000" height="1" fill="${TRACK}"/></svg>`}
684 />
685 )
686 }
687 return <Text dimColor>{'─'.repeat(Math.max(8, Math.min(columns, 80)))}</Text>
688}
689
690/** Four segments, intent to build: done in the accent, the current one faint, the rest track. */
691function StageBar({ Svg, Text, surface }: Els, s: On) {
692 const levels = [...STAGES, 'build' as const].map(stage => {
693 const isCurrent = s.stage === stage
694 if (stage === 'build') return isCurrent ? (s.verify.last?.ok ? 1 : 0.4) : 0
695 const state = s.stages[stage]?.state
696 return state === 'approved' || state === 'skipped' ? 1 : isCurrent ? 0.4 : 0
697 })
698 const alt = `Stages: ${[...STAGES, 'build'].map((st, i) => `${st} ${levels[i] === 1 ? 'done' : levels[i] ? 'current' : 'to do'}`).join(', ')}`
699 if (surface === 'desktop' && Svg) {
700 const W = 1000
701 const GAP = 10
702 const w = (W - GAP * 3) / 4
703 const rects = levels
704 .map((level, i) => {
705 const x = i * (w + GAP)
706 const fill = level ? ACCENT : TRACK
707 const opacity = level === 1 || !level ? 1 : level
708 return `<rect x="${x}" y="0" width="${w}" height="6" rx="3" fill="${fill}" fill-opacity="${opacity}"/>`
709 })
710 .join('')
711 return (
712 <Svg
713 key="stages"
714 alt={alt}
715 source={`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="6" viewBox="0 0 ${W} 6">${rects}</svg>`}
716 />
717 )
718 }
719 return (
720 <Text>
721 {[...STAGES, 'build' as const].map((stage, i) => (
722 <Text key={stage} dimColor={!levels[i]} bold={levels[i] === 0.4}>
723 {i ? ' › ' : ''}
724 {STAGE_NAME[stage]}
725 </Text>
726 ))}
727 </Text>
728 )
729}
730
731/** The title row: the title (it folds the details) at the left, the close mark at the right. */
732function Head(els: Els, $: EngineInterface, title: string, close: unknown) {
733 return (
734 <els.Box key="head" justifyContent="space-between" gap={2}>
735 {TitleButton(els, $, title)}
736 {close}
737 </els.Box>
738 )
739}
740
741/** The feature's name as a button that folds and unfolds the details, as SDLC in the footer
742 * does. `plain` draws it as text, with a plate only under the pointer. */
743function TitleButton({ Box, Button, surface }: Els, $: EngineInterface, title: string) {
744 const button = <Button key="feature" label={title} plain onPress={() => toggleDetails($)} />
745 // The desktop pads a button for its hover plate; pull it back so the name lines up with
746 // the text under it. The terminal draws a plain button with no padding.
747 return surface === 'desktop' ? <Box key="feature-box" marginLeft={TITLE_INSET}>{button}</Box> : button
748}
749
750/** The desktop's horizontal padding of a plain button, in cells (about 6 px). */
751const TITLE_INSET = -0.75
752
753/** One row of a section's table: what, and its state. */
754type Line = { key: string; label: unknown; labelText: string; value: unknown; valueText: string }
755
756/** A titled table. Its label and value columns are as wide as their longest entry, so the
757 * states line up; `minWidth` lets sections sit side by side and wrap when room runs out. */
758function Section(els: Els, key: string, title: string, lines: Line[]) {
759 const { Box, Text } = els
760 const labelWidth = Math.max(...lines.map(l => l.labelText.length)) + 3
761 const valueWidth = Math.max(...lines.map(l => l.valueText.length)) + 3
762 return (
763 <Box key={key} flexDirection="column" flexShrink={0} minWidth={labelWidth + valueWidth}>
764 {SectionTitle(els, title)}
765 {lines.map(l => (
766 <Box key={l.key}>
767 <Box width={labelWidth} flexShrink={0}>
768 {typeof l.label === 'string' ? <Text wrap="truncate-end">{l.label}</Text> : l.label}
769 </Box>
770 <Box width={valueWidth} flexShrink={0}>
771 {typeof l.value === 'string' ? <Text dimColor wrap="truncate-end">{l.value}</Text> : l.value}
772 </Box>
773 </Box>
774 ))}
775 </Box>
776 )
777}
778
779/** Features beside the active one that are not closed: the ones `sdlc feature` switches to. */
780function otherFeatures(s: On): FeatureInfo[] {
781 return s.features.filter(f => f.slug !== s.active && !f.done)
782}
783
784/** Where another feature stands, in words: "Spec · Draft". */
785function featureText(f: FeatureInfo): string {
786 return `${STAGE_NAME[f.stage]}${f.stage_state ? ` · ${STATE_WORD[f.stage_state] ?? f.stage_state}` : ''}`
787}
788
789/** The other features, one Button a row that makes its feature active. The name is the
790 * row's address and its stage a dim part, so a stage that moves keeps the address. The
791 * terminal draws each in brackets, so it reads as a control; the desktop as a list row. */
792function OtherFeatures(els: Els, $: EngineInterface, features: FeatureInfo[], busy: boolean) {
793 const { Box, Text, Button } = els
794 return (
795 <Box key="others" flexDirection="column" flexShrink={0}>
796 {SectionTitle(els, 'Other features')}
797 <Box flexDirection="column" alignItems="flex-start" rowGap={els.surface === 'desktop' ? 0.5 : 0}>
798 {features.map(f => (
799 <Button key={`activate-${f.slug}`} label={`Make ${f.slug} active`}
800 {...(els.surface === 'desktop' ? { plain: true } : {})}
801 onPress={() => (busy ? undefined : press($, `sdlc feature ${f.slug}`))}>
802 {f.slug} <Text dimColor>{featureText(f)}</Text>
803 </Button>
804 ))}
805 </Box>
806 </Box>
807 )
808}
809
810/** A file name: a link that opens it when it exists, quiet text when it does not. */
811function FileName({ Markdown, Text }: Els, $: EngineInterface, root: string, rel: string, exists: boolean) {
812 const name = rel.split('/').pop() ?? rel
813 if (!exists) return <Text dimColor>{name}</Text>
814 return (
815 <Markdown
816 key={`open-${name}`}
817 text={`[${name}](${fileUrl(root, rel)})`}
818 onLinkPress={() => void openFile($, `${root}/${rel}`)}
819 />
820 )
821}
822
823/** An artifact's state in one word; who approved or why it was skipped is in approvals.json. */
824function stageValueText(info: StageInfo | undefined): string {
825 const state = info?.state ?? 'missing'
826 return STATE_WORD[state] ?? state
827}
828
829/** The unfolded band, laid out as the desktop's own popovers are: Artifacts and Status
830 * side by side where the band is wide enough, one under the other where it is not. */
831function Details(
832 els: Els,
833 $: EngineInterface,
834 s: SdlcState | null,
835 error: string | null,
836 busy: boolean,
837 files: ExtraFiles,
838 unlock: UnlockRequest = null,
839) {
840 const { Box, Text, Button } = els
841 const close = (
842 <Button key="close" label="×" role="dismiss" onPress={() => update($, detailsAtom, () => false)} />
843 )
844 if (error) {
845 return (
846 <Box flexDirection="column" gap={1}>
847 {Head(els, $, 'SDLC', close)}
848 <Text color={ERROR}>{error}</Text>
849 <Box>
850 <Button key="retry" label="Retry" {...keys(els, 'r')} onPress={() => void refresh($)} />
851 </Box>
852 </Box>
853 )
854 }
855 if (!s) return <Text dimColor>Reading SDLC state…</Text>
856 if (!s.enabled) {
857 return (
858 <Box flexDirection="column" gap={1}>
859 {Head(els, $, 'SDLC', close)}
860 <Text dimColor>Not enabled in this repository. Init detects the verify commands and adds</Text>
861 <Text dimColor>sdlc.config.json, a CLAUDE.md section, REVIEW.md and docs/sdlc/.</Text>
862 <Box>
863 <Button key="init" label="Init SDLC" {...keys(els, 'i', true)} onPress={() => runCommand($, '/ai-sdlc:init')} />
864 </Box>
865 </Box>
866 )
867 }
868
869 const dir = featureDir(s)
870 const action = nextAction($, s, files.steps)
871
872 const artifacts: Line[] = STAGES.map(stage => {
873 const info = s.stages[stage]
874 const exists = !!info && info.state !== 'missing' && info.state !== 'skipped'
875 const valueText = stageValueText(info)
876 return {
877 key: stage,
878 label: FileName(els, $, s.root, info?.path ?? `${dir}/${stage}.md`, exists),
879 labelText: `${stage}.md`,
880 value: info?.state === 'stale' ? <Text color={WARNING}>{valueText}</Text> : valueText,
881 valueText,
882 }
883 })
884 if (files.progress) {
885 artifacts.push({ key: 'progress', label: FileName(els, $, s.root, `${dir}/progress.md`, true),
886 labelText: 'progress.md', value: 'Build log', valueText: 'Build log' })
887 }
888 if (files.approvals) {
889 artifacts.push({ key: 'approvals', label: FileName(els, $, s.root, `${dir}/approvals.json`, true),
890 labelText: 'approvals.json', value: 'Audit trail', valueText: 'Audit trail' })
891 }
892
893 const last = s.verify.last
894 const verify = s.verify.commands.length === 0 ? 'Not configured'
895 : last ? `${last.ok ? 'Passed' : 'Failed'} ${clock(last.at).slice(11)}` : 'Not run yet'
896 const gate = s.code_gate === 'open' ? 'Open' : 'Closed'
897 const progressText = files.steps.total > 0 && s.stage === 'build'
898 ? `${files.steps.done}/${files.steps.total} steps`
899 : null
900 const status: Line[] = [
901 ...(progressText
902 ? [{ key: 'building', label: 'Building', labelText: 'Building', value: progressText, valueText: progressText }]
903 : []),
904 { key: 'gate', label: 'Code gate', labelText: 'Code gate', value: gate, valueText: gate },
905 { key: 'verify', label: 'Verify', labelText: 'Verify',
906 value: last && !last.ok ? <Text color={ERROR}>{verify}</Text> : verify, valueText: verify },
907 ]
908 if (s.stage === 'build') {
909 const review = reviewState(s)
910 const reviewText = review === 'ready' ? 'Passed' : review === 'changes' ? 'Changes required'
911 : s.review ? 'Out of date' : 'Not run'
912 status.push({ key: 'review', label: 'Review', labelText: 'Review',
913 value: review === 'changes' ? <Text color={ERROR}>{reviewText}</Text> : reviewText, valueText: reviewText })
914 }
915 if (s.pr) {
916 const url = s.pr.url
917 const prText = `#${url.split('/').pop() ?? ''}`
918 status.push({ key: 'pr', label: 'PR', labelText: 'PR', valueText: prText,
919 value: <els.Markdown key="pr-link" text={`[${prText}](${url})`} onLinkPress={() => void openUrl($, url)} /> })
920 }
921 if (s.fasttrack) {
922 status.push({ key: 'fasttrack', label: 'Fast-track', labelText: 'Fast-track', value: 'On', valueText: 'On' })
923 }
924 if (s.tests_locked) {
925 // A short value keeps the three sections side by side; the warning color marks Claude's
926 // unlock request.
927 status.push({ key: 'tests', label: 'Tests', labelText: 'Tests',
928 value: unlock ? <Text color={WARNING}>Locked</Text> : 'Locked', valueText: 'Locked' })
929 }
930
931 // What the person can do now, one button per line.
932 const actions: { key: string; label: string; run: () => unknown; hotkey: string }[] = []
933 if (action) {
934 const label = busy && action.key === 'approve' ? 'Approving…' : action.label
935 actions.push({ key: action.key, label, run: action.run, hotkey: 'a' })
936 }
937 const verified = !!s.verify.last?.ok
938 const steps = files.steps
939 const building = s.active && s.stage === 'build'
940 // With numbered plan steps the build's progress decides; without, the verify result does.
941 const started = steps.total === 0 || steps.done > 0
942 const finished = isBuilt(s, steps)
943 if (building && started && !verified) {
944 actions.push({ key: 'verify', label: 'Run verify', hotkey: 'v', run: () => runCommand($, '/ai-sdlc:verify') })
945 }
946 if (building && started) {
947 actions.push({ key: 'changes', label: 'Show changes', hotkey: 'd', run: () => showChanges($) })
948 }
949 if (building && started && (steps.total > 0 || verified) && action?.key !== 'review') {
950 actions.push({ key: 'review', label: 'Review', hotkey: 'r', run: () => runCommand($, '/ai-sdlc:review') })
951 }
952 if (building && finished && action?.key !== 'done') {
953 actions.push({ key: 'done', label: 'Close feature', hotkey: 'c', run: () => press($, 'sdlc done') })
954 }
955 if (s.tests_locked) actions.push({ key: 'unlock', label: 'Unlock tests', run: () => press($, 'sdlc unlock tests'), hotkey: 'u' })
956 if (s.fasttrack && action?.key !== 'fasttrack-off') {
957 actions.push({ key: 'fasttrack-end', label: 'End fast-track', run: () => press($, 'sdlc trivial off'), hotkey: 'f' })
958 }
959 if (!s.active && !s.fasttrack) actions.push({ ...fastTrackAction($), hotkey: 't' })
960 const others = otherFeatures(s)
961
962 return (
963 <Box flexDirection="column" gap={1}>
964 <Box flexDirection="column">
965 {Head(els, $, s.active ?? 'SDLC', close)}
966 <Text dimColor>{whereText(s, files.steps)}</Text>
967 </Box>
968 {s.active ? StageBar(els, s) : null}
969 {Rule(els, 'rule')}
970 <Box flexWrap="wrap" columnGap={6} rowGap={1}>
971 {s.active ? Section(els, 'artifacts', 'Artifacts', artifacts) : null}
972 {Section(els, 'status', 'Status', status)}
973 {actions.length > 0 ? (
974 // A column of buttons. The desktop lays a row out a whole line tall, so the buttons
975 // stand half a row apart there; the terminal's bracketed buttons need no gap.
976 <Box key="actions" flexDirection="column" flexShrink={0}
977 minWidth={Math.max(...actions.map(a => a.label.length)) + 6}>
978 {SectionTitle(els, 'Actions')}
979 <Box flexDirection="column" alignItems="flex-start" rowGap={els.surface === 'desktop' ? 0.5 : 0}>
980 {actions.map((a, i) => ActionButton(els, $, a, a.hotkey, i === 0, busy))}
981 </Box>
982 </Box>
983 ) : null}
984 {others.length > 0 ? OtherFeatures(els, $, others, busy) : null}
985 </Box>
986 </Box>
987 )
988}
989types/index.d.ts 77 lines1export type Stage = 'intent' | 'spec' | 'plan'
2
3export type StageState = 'missing' | 'draft' | 'approved' | 'stale' | 'skipped'
4
5export type StageInfo = {
6 state: StageState
7 path: string
8 sha256: string | null
9 approved_sha256: string | null
10 by: string | null
11 at: string | null
12 reason: string | null
13}
14
15export type FeatureInfo = {
16 slug: string
17 stage: Stage | 'build'
18 stage_state: StageState | null
19 done: boolean
20}
21
22export type VerifyRun = { ok: boolean; at: string | null; report: string | null }
23
24/** The last sdlc-reviewer verdict for the active feature; `current` while the code it read
25 * is unchanged. */
26export type ReviewInfo = { verdict: 'ready' | 'changes'; at: string | null; current: boolean }
27
28/** The PR (or MR) the agent opened for the active feature. */
29export type PrInfo = { url: string; at: string | null }
30
31/** What `sdlc.py cli state --session <id>` prints. */
32export type SdlcState =
33 | { enabled: false }
34 | {
35 enabled: true
36 root: string
37 active: string | null
38 stage: Stage | 'build' | null
39 stage_state: StageState | null
40 stage_label: string | null
41 next_skill: string | null
42 code_gate: 'open' | 'closed'
43 artifact: string | null
44 stages: Partial<Record<Stage, StageInfo>>
45 features: FeatureInfo[]
46 tests_locked: boolean
47 fasttrack: { by: string; at: string; reason: string } | null
48 verify: { commands: string[]; last: VerifyRun | null }
49 review: ReviewInfo | null
50 pr: PrInfo | null
51 done?: boolean
52 }
53
54/** An artifact as it was on screen when the person pressed Approve. */
55export type Approval = { stage: Stage; path: string; sha256: string }
56
57/** Feature files beside the stage artifacts that `state` does not report, and the build's
58 * progress: plan steps done (marked in progress.md) of the plan's total. */
59export type ExtraFiles = { progress: boolean; approvals: boolean; steps: { done: number; total: number } }
60
61declare module 'claude-code' {
62 interface PluginState {
63 'ai-sdlc': {
64 state: SdlcState | null
65 error: string | null
66 busy: boolean
67 files: ExtraFiles
68 /** Whether `SDLC` in the footer unfolded the band into the details. */
69 details: boolean
70 /** Whether the person hid the band with its ×; a new stage or status shows it again. */
71 hidden: boolean
72 /** Claude asked (request_test_unlock) for the locked tests to be unlocked, and why. */
73 unlockRequest: { reason: string } | null
74 }
75 }
76}
77