SLOPSHOPPER

sdlc

AI-native SDLC (Anthropic playbook): intent -> spec -> plan -> build -> verify -> review -> maintain, with hook-enforced human approval gates.

newbandspinnerguardtoastprompt
v0.4.4MITupdated 2026-10-06Sennjen/claude-sdlc/plugins/sdlc
A shopper browsing a rack in a slop shop
README

claude-sdlc

A Claude Code plugin that puts the agent on the rails of Anthropic's AI-native SDLC playbook:

Intent → Spec → Plan → Build → Verify → Review → Maintain, with human approval gates that are enforced by hooks, not just requested in a prompt.

Layers

LayerWhereRole
Hooksplugins/sdlc/hooks/hooks.json, scripts/sdlc.pyDeterministic gates. The agent cannot skip them
Skillsplugins/sdlc/skills/*Per-stage playbooks and templates, loaded on demand
Agentplugins/sdlc/agents/sdlc-reviewer.mdIndependent read-only reviewer for stage 5
UIplugins/sdlc/hooks/ui.tsxA band above the prompt and SDLC in the footer: state, next action, approvals by button. Shows state; enforces nothing
CLAUDE.md sectionadded by /sdlc:initShort always-on rules for the repository
sdlc.config.jsonrepo rootOpt-in switch plus verify commands. Without it the plugin does nothing

Gates

GateHookRule
Stage statusUserPromptSubmitEvery prompt gets a one-line status for the active feature and the next step
ApprovalsUserPromptSubmitOnly a real user message sdlc approve <stage> records an approval (sha256 of the artifact, author, time) in docs/sdlc/<feature>/approvals.json
Stage orderPreToolUse Edit/Writespec.md needs an approved intent. plan.md needs an approved (or skipped) spec
Code gatePreToolUse Edit/Write/BashCode changes need an approved intent, spec and plan whose hashes still match. Editing an approved artifact reopens its approval
Self-approvalPreToolUseThe agent cannot write approvals.json or .sdlc/, call the hook script directly, or send user commands (sdlc approve ...) through a nested claude session. Reading them (cat, grep, jq, git log) is allowed
Test lockPreToolUseAfter sdlc lock-tests (bug-fix flow) test files are read-only until the user unlocks them
Protected filesPreToolUseFiles that steer the agent need user confirmation (ask): CLAUDE.md and CLAUDE.local.md in any directory, .claude/ settings, hooks, agents, skills, commands, rules and output styles, .mcp.json, REVIEW.md, sdlc.config.json
Deploy gatePreToolUse Bashgh pr merge, publish and deploy commands need user confirmation (ask). So does a git push to a protected branch (protected_branches: main, master, release/*), with force, delete, tags or mirror, or one the hook cannot read. A push that only updates other branches passes: the PR and branch protection guard the rest
Plan driftPreToolUse Edit/WriteIn build, an edit to a code or test file that plan.md does not list under Files affected gets a note, once per file and session: add the file to the plan and re-approve, or undo the edit. It does not block
Definition of DoneStopIf code changed in the session, the verify commands run; a failure sends the agent back (max 2 retries per state)
Plan modePostToolUse ExitPlanModeA plan accepted in plan mode is not the SDLC approval: the agent is told to write it to plan.md and ask for sdlc approve plan
Review and PRSubagentStop, PostToolUseRecords the sdlc-reviewer verdict with the code it read, and the URL from gh pr create, glab mr create or a forge MCP tool, for the SDLC bar. Gates nothing

User commands (type them as a chat message)

sdlc approve intent|spec|plan   approve the current artifact
sdlc skip spec <reason>         small change: go from intent straight to plan
sdlc trivial [reason]           fast-track for this session: no new behaviour (typo, copy, config value)
sdlc trivial off                end fast-track
sdlc unlock tests               allow test edits again
sdlc feature <slug>             switch the active feature
sdlc done                       close the active feature once its PR exists
sdlc status                     show the status (no model turn)

SDLC bar

In an interactive session the plugin shows a band above the prompt, for example feat-a spec.md · waiting for approval [Approve spec] ×. It holds the active feature, its stage, a warning (verify failed, changes required, tests locked) and the one action that moves it on:

WhenAction
No active featureStart feature (runs /sdlc:intent) and Fast-track (sdlc trivial)
The stage's artifact is missingWrite intent, Write spec, Write plan (runs the stage skill)
The artifact is a draft or changedApprove <stage> / Re-approve <stage>, one click
BuildStart building / Continue building (runs /sdlc:build), with n/N steps
Every plan step is done, no current reviewReview (runs /sdlc:review); Review again after CHANGES REQUIRED
The review of the current code passedCreate PR (runs /sdlc:review pr)
The PR existsClose feature (sdlc done)
Claude asked to unlock the testsUnlock tests (sdlc unlock tests)

In the desktop app a new session starts Claude Code with its first message, so the bar appears after that message. Any user command works as the first message: sdlc status shows the status without a model turn, and sdlc approve spec approves as the button does.

The build counts the numbered steps under ## Work sequence in plan.md and the - [x] Step N: ... lines in progress.md. A plan without numbered steps counts as done when verify passes. A review counts while the code it read is unchanged: the SubagentStop hook keeps the sdlc-reviewer verdict line (READY FOR HUMAN REVIEW or CHANGES REQUIRED) with a hash of the code that differs from the default branch, so a commit on the feature branch keeps it and any code edit makes it stale. The PR URL comes from the output of gh pr create, glab mr create or a forge MCP tool that creates a pull or merge request. Both live in .sdlc/features/<feature>.json. × hides the band until the stage, the review or the PR changes.

SDLC in the footer, or the feature's name in the band, unfolds the details:

  • Artifacts: intent.md, spec.md, plan.md, progress.md and approvals.json with their state. A name opens the file in the desktop's Files pane (elsewhere, in the default app).
  • Status: build steps, the code gate, the last verify result, the review, a link to the PR, fast-track and the test lock.
  • Actions: the next action, Run verify, Show changes (the Diff pane), Review, Close feature, Unlock tests, End fast-track, and Make active for other features.
  • Init SDLC (runs /sdlc:init) in a repository without sdlc.config.json.

A user-only button (Approve, Unlock tests, Close feature, End fast-track, Make active) sends the same sdlc ... line through the same UserPromptSubmit handler a typed command reaches, so approvals.json and audit.log look the same. The agent cannot press a button. Approve and Close feature wait while Claude's turn runs, because the file may still be half written. If the artifact changed after it was shown, nothing is approved. A skill button runs its slash command at once, as if the person typed it. Toasts report an approval that went stale, the code gate opening or closing, verify passing or failing, the review verdict and the new PR.

Plan mode

The playbook drafts the plan in Claude Code plan mode, where Claude reads the code but cannot change it. The bar does not switch to plan mode: Write plan runs /sdlc:plan in the current mode, and Claude writes plan.md directly. The code gate already keeps the code untouched until you approve the plan. Plan mode replaces the session's permission mode while it lasts. In a bypassPermissions session, every Bash command of Claude and its subagents then asks for permission.

To draft in plan mode anyway, press Shift+Tab before you ask for the plan. Claude interviews you and presents the plan in the template's sections. Accepting it in the plan-mode dialog only ends plan mode: Claude then writes it to plan.md. In both modes the code gate opens when you approve that file (Approve plan or sdlc approve plan).

The tests stay locked until the person unlocks them. Claude can only ask: the plugin gives it a request_test_unlock tool (mcp__sdlc__request_test_unlock), which turns the band's action into Unlock tests. The tool itself unlocks nothing.

The UI is a Claude Mod: a function-hooks module (hooks/ui.tsx). Mods are on by default in Claude Code 2.1.287 or newer in the terminal, and 2.1.286 or newer in the Code tab of the Desktop app. Tested with 2.1.291. Older versions skip the module and log that it did not load; the gates keep working because they are command hooks. In claude -p the module runs but draws nothing, so there is no band.

The agent has a safe CLI on its PATH: sdlc status | state [--session ID] | features | new <slug> [type] | lock-tests. state prints the status as JSON for UIs.

Install

Requirements: Claude Code 2.1.287+ (for the UI; the gates work on older versions), python3 (3.9+) and git.

In an interactive claude terminal:

/plugin marketplace add Sennjen/claude-sdlc
/plugin install sdlc@ai-sdlc

or from a shell:

claude plugin marketplace add Sennjen/claude-sdlc
claude plugin install sdlc@ai-sdlc

Then, inside a project, run /sdlc:init. It detects the verify commands, adds the CLAUDE.md section and REVIEW.md, and writes sdlc.config.json last. Commit the result.

To try the plugin without installing it, clone the repository and run claude --plugin-dir ./claude-sdlc/plugins/sdlc.

Enforce it across an organization

An engineer can disable a plugin they installed themselves. To make the gates non-negotiable, an administrator delivers the plugin through managed settings (MDM or the Claude admin console). Managed settings take precedence over every other scope, and users cannot edit them:

{
  "extraKnownMarketplaces": {
    "ai-sdlc": {
      "source": { "source": "git", "url": "https://gitlab.example.com/tools/claude-sdlc.git" },
      "autoUpdate": true
    }
  },
  "enabledPlugins": { "sdlc@ai-sdlc": true },
  "strictKnownMarketplaces": [
    { "source": "git", "url": "https://gitlab.example.com/tools/claude-sdlc.git" }
  ],
  "allowManagedHooksOnly": true,
  "permissions": { "disableBypassPermissionsMode": "disable" }
}
  • extraKnownMarketplaces registers this repository as the ai-sdlc marketplace on every machine. Use a github source ({ "source": "github", "repo": "org/claude-sdlc" }) for GitHub.
  • enabledPlugins force-enables sdlc. Disabling it at user or project scope does not stop it from loading.
  • strictKnownMarketplaces allows plugins only from listed sources. Add every other approved marketplace here, or its plugins stop installing.
  • allowManagedHooksOnly blocks user, project and local hooks. Hooks of force-enabled plugins still run, so the SDLC gates stay on and no other hook can weaken them.
  • disableBypassPermissionsMode keeps the ask decisions (protected files, deploy gate) in front of a person.

Two limits remain. The plugin stays inert in a repository without sdlc.config.json, so the repository has to commit that file, and a person can still delete it (the hook only asks). And the hooks guard the agent, not the people: anything that reaches the branch another way is caught only by branch protection and human review.

Artifacts

docs/sdlc/<feature>/
  intent.md       stage 1: problem, outcome, affected users/systems, constraints, open questions
  spec.md         stage 2: requirements, acceptance criteria, design, flagged concerns
  plan.md         stage 3: files affected, work sequence, risks, validation
  progress.md     build log (keeps plan.md, and so its approval, unchanged)
  approvals.json  who approved what and when (commit it: it is the audit trail)
.sdlc/            local session state and audit.log (gitignored)

Limitations

  • Shell-write detection (>, tee, sed -i, cp, git apply...) and the nested-session check (claude -p "sdlc approve plan") are best effort. A determined agent could still write files or send user commands through a script. The hooks keep a cooperative agent honest; they are not a sandbox. For hard guarantees, use managed settings and sandboxing.
  • In-place edits are judged by the files they edit: sed/gsed with -i or --in-place[=SUFFIX] (GNU long options may be cut to a unique prefix), BSD sed with -I, and perl/ruby with -i. A BSD suffix of its own (sed -i '' ..., sed -i .bak ...) is neither script nor file, unless the GNU reading would edit it: the hook checks that on disk only for one simple command and a word with nothing to expand, and otherwise counts the word as a file. Words after the first operand are files (perl, ruby, BSD sed). One with no file left counts as an unknown target. Not detected: the sed script commands w, W and e, gawk -i inplace, and a program behind a wrapper (env, xargs, find -exec) or a subshell opened with a lone ( .
  • A shell variable in a write target (S=/tmp/out; cp a $S/) is expanded only from a literal value the same command assigns once, at top level, before the target, with no compound command, subshell or variable-setting builtin (read, printf -v, cd...) before it. Any other variable is read as written, so cp a $X/app.js counts as a write to $X/app.js in the repository. Targets are normalized first: /tmp/../repo/src/app.js is not a tmp path.
  • The deploy gate looks at what a Bash call runs. A gated word inside a file name (cat upload-assets.ts), a heredoc body or quoted text with spaces (git commit -m "...") counts only when it is executed: the program itself, a script given to bash/python3/tsx, text handed to a shell (bash -c, ssh host '...', | sh) or $(...). Code run by other interpreters (python3 -c, node -e) and package scripts is not inspected.
  • A Bash call that mentions approvals.json, .sdlc/ or sdlc.py passes only if every program in it is a known reader (cat, ls, grep, jq, sed without -i/w/e, find without -exec/-delete, git status/log/show/diff/add/commit...) and it redirects only to /dev/* or tmp. Anything else is denied, even when it only reads (python3 -c, find -exec, sed -n '/Next/p').
  • If pre-edit or pre-bash crashes, the gate fails closed: the call is denied, the person sees why, and audit.log records hook-error. "on_hook_error": "allow" in sdlc.config.json lets calls through while the bug is fixed. The other hooks are skipped with a note. A hook that times out, or a missing python3, still lets the call through.
  • The push gate reads git push strictly: a refspec it cannot resolve (a variable, cd before it, -C, -c, a pattern, an abbreviated option) asks, as does any push it finds inside quotes, a heredoc or $(...). It cannot see what a script or alias pushes.
  • The plan-drift note reads paths, directories and globs from the first column of the Files affected table (or a list in that section). A plan that names files another way gets no note.
  • The Stop gate runs the verify commands itself, so keep them fast.
  • On Claude Code 2.1.291 a hook's ask holds in bypassPermissions mode too: claude -p --dangerously-skip-permissions denies the gated call, so an interactive session should show the prompt. Older versions may not; set "gated_command_decision": "deny" if deploys must be done by hand.

Tests

cd plugins/sdlc && python3 -m unittest discover -s tests -v

The UI module has its own tests and type check (Claude Code 2.1.284+, TypeScript 5.4+):

claude plugin validate plugins/sdlc && claude plugin test plugins/sdlc
cd plugins/sdlc && npx -p typescript tsc -p .

The eval suite in plugins/sdlc/evals/ checks the agent's behaviour, not the hooks: a feature request starts with intent.md, an approved intent gets a spec that flags conflicting constraints, a skipped spec gets a plan before code, a bug fix locks the tests before the fix, work outside the approved plan stays out of the change, a typo asks for sdlc trivial, a question starts nothing, the review runs the sdlc-reviewer subagent, and a push to main waits for the user. Each case runs three times with the plugin and three times without it, so Δ shows what the plugin adds. It makes real model calls (roughly 54 agent runs), so run it from a terminal where claude is logged in:

cd plugins/sdlc && claude plugin eval . --scaffold --allow-tools Bash Write Edit
cd plugins/sdlc && claude plugin eval . --case trivial-change-asks-for-fast-track --runs 1 --ablation none --scaffold --allow-tools Bash Write Edit

The second command runs one case once, for iterating on a skill. --scaffold runs each case's fixture.sh, which builds a small repository with the SDLC enabled.

tsc reads the engine's declarations from .claude-plugin/types/ (gitignored). Claude Code writes them there when it loads the plugin from this folder (claude --plugin-dir plugins/sdlc).

License

MIT

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