SLOPSHOPPER

AI-native SDLC

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

newbandspinnerguardtoastprompt
★ 2v0.7.1MITupdated 2026-10-15Sennjen/claude-sdlc/plugins/ai-sdlc
A shopper browsing a rack in a slop shop
README

AI-native SDLC for Claude Code

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.

What you get

  • Approval gates. Only a message you type, 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.
  • A code gate. Code edits wait for an approved intent, spec and plan whose hashes still match. Small changes can skip the ceremony with sdlc trivial.
  • A Definition of Done. When code changed, the Stop hook runs your verify commands and sends Claude back on a failure.
  • Locked tests for bug fixes. Tests written to reproduce a bug stay read-only until you unlock them, so the fix cannot bend the tests.
  • Deploy and push gates. Merges, publishes, deploys and pushes to protected branches ask you first.
  • An independent reviewer. The sdlc-reviewer agent reviews the diff against the spec, the plan and your REVIEW.md with fresh context.
  • A status bar. A band above the prompt shows the feature, its stage and the one action that moves it on, such as Approve spec. The prompt's typeahead completes sdlc ... commands.

Get started

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.

What the plugin runs, reads and stores

Everything runs on your machine. The plugin makes no network requests and sends no data anywhere.

  • Runs: 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.
  • Reads: the repository, sdlc.config.json, and the output of the tools Claude runs, to find the URL from gh pr create or glab mr create.
  • Decides: its hooks allow, deny or ask about Claude's tool calls (the gates above). It never changes your Claude Code settings or permission mode.
  • Stores, inside your repository: the artifacts and 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.

More

The full documentation, with every gate, command and limit, is in the repository README. Report problems in GitHub Issues.

MIT License. See LICENSE.

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