Specification-Driven Development pipeline (SWEBOK v4): 21 skills, 5 hooks (7 event registrations) and an MCP traceability server. Greenfield, brownfield and…

Specification-Driven Development pipeline for Claude Code, based on SWEBOK v4 — packaged as a single installable plugin.
From requirements to production code: a structured, auditable, traceable pipeline that turns natural-language requirements into implemented software, with hooks that guard the process and an MCP server that answers questions about the traceability graph.
scripts/sdd.mjs — task lint, git traceability (sdd trace), commit verification, work branches, need coverage, plan lint, the acceptance ledger (sdd accept) and the release gate (sdd gate); Node ≥ 18, no dependencies, runs in CI with only Node and gitSDD_ROLE), parallel streams in git worktrees, lead handoffs/plugin marketplace add noelserdna/sdd-pipeline
/plugin install sdd-pipeline@noelserdna
Use --scope project (or claude plugin install sdd-pipeline@noelserdna --scope project) to share the plugin with a team through .claude/settings.json.
Requirements: Claude Code ≥ 2.1.224 · Node.js ≥ 18 · git ≥ 2.32 · bash · jq (recommended; hooks fall back to node) · python3 (optional: traceability graph for the MCP server). macOS and Linux; Windows through WSL.
On first use Claude Code asks you to approve the sdd MCP server. After a plugin update run /reload-plugins or start a new session.
Migrating from sdd@noelserdna-claude-plugin-sdd, sdd-pipeline@sdd-pipeline-local or hooks copied into .claude/hooks/? See docs/migracion.md.
/sdd-setup # pipeline-state.json, git commit-msg hook + vendored validator, .gitignore policy
/sdd-setup --stack=rails --app-dir=web # optional stack kit: SDD Stack Profile, conventions, path rules (docs/stacks.md)
/sdd-requirements-engineer # customer needs (verbatim) → requirements with examples and a verification method
/sdd-specifications-engineer # spec/ (domain, use cases, workflows, contracts, ADRs, BDD scenarios AC-NNN-NN)
/sdd-spec-auditor # audits/AUDIT-BASELINE.md — gate PASS / CONDITIONAL / BLOCKED
/sdd-test-planner # test/
/sdd-plan-architect # plan/ — walking skeleton, then one demoable increment per FASE
/sdd-task-generator # task/ (atomic tasks, dependency graph, streams)
/sdd-task-implementer --fase 0 # work branch, test-first, one commit per task with Task/Refs trailers, FASE demo
/sdd-acceptance --fase 0 # verdict per requirement with its evidence; --sign-off records the customer's acceptance
/sdd-pipeline-status # where am I, what is stale, what is next
Or let the sdd-orchestrator skill drive the whole pipeline interactively: "run the SDD pipeline for this project".
sdd-requirements-engineer → requirements/CUSTOMER-NEEDS.md, REQUIREMENTS.md (tag requirements-v{N} on approval)
sdd-specifications-engineer → spec/ (domain, use-cases, workflows, contracts, nfr, adr, tests)
sdd-spec-auditor → audits/AUDIT-BASELINE.md + corrected spec/
↳ lateral (optional): sdd-security-auditor, sdd-tech-designer, sdd-ux-designer
sdd-test-planner → test/TEST-PLAN.md, TEST-MATRIX-*.md, E2E-SCENARIOS.md
sdd-plan-architect → plan/ (ARCHITECTURE.md, PLAN.md with Plan-Style: vertical, fases/)
sdd-task-generator → task/TASK-FASE-*.md, TASK-ORDER.md (TASK-INDEX.md optional)
sdd-task-implementer → code and tests (Stack Profile paths), git commits, FASE demo
sdd-acceptance → acceptance/ACCEPTANCE-REPORT.md, decisions.jsonl, tag fase-{N}-accepted
Every artifact traces end to end: N → REQ → UC → WF → API → BDD/AC → INV → ADR → TASK → COMMIT → CODE → TEST → verdict.
FASEs are vertical. FASE-0 is a walking skeleton — the smallest write → observe → persist path of the central use case (in the todo example: todo add, todo list and the JSON file) plus only the infrastructure that path needs. Each later FASE is one user journey with a ## Demo of at most 10 steps. At the end of a FASE the customer watches the demo and accepts it, accepts it with observations, or rejects it with feedback that is routed as a defect, a change request or a question.
The route adapts to the project. Right after the requirements are approved, sdd route proposes which optional stages this project needs, from counted facts and seven narrow judgments about the needs (external customer, sensitive data, several roles, integrations, UI flows, long life, complex state). A small CLI skips formal specs, the spec audit and the test plan, and is planned straight from the requirements' acceptance criteria. A person confirms the route in one question, skipped stages are recorded with their reason, and sdd-req-change re-evaluates the route after every change, raising the rigor when needed, never lowering it — see docs/ruta.md.
State lives in pipeline-state.json (one file, the single source of truth); changes flow forward through sdd-req-change, which marks downstream stages stale and reopens the acceptance of a modified requirement.
| # | Skill | Input | Output |
|---|---|---|---|
| 1 | sdd-requirements-engineer | the customer | requirements/ (needs, requirements, approval tag) |
| 2 | sdd-specifications-engineer | requirements/ | spec/ |
| 3 | sdd-spec-auditor | spec/ | audits/, corrected spec/ |
| 4 | sdd-test-planner | spec/, ux/ | test/ |
| 5 | sdd-plan-architect | spec/, design/, ux/, audits/ | plan/ (vertical FASEs) |
| 6 | sdd-task-generator | plan/ | task/ |
| 7 | sdd-task-implementer | task/, spec/, plan/ | code, tests, commits |
| Skill | Purpose | Output |
|---|---|---|
sdd-security-auditor | OWASP ASVS v4 / CWE security posture audit of the specs | audits/SECURITY-AUDIT-BASELINE.md |
sdd-req-change | ADD / MODIFY / DEPRECATE requirements with pipeline cascade (ISO 14764), on a change/ branch | updated requirements/, spec/, changes/ |
sdd-tech-designer | Architecture and stack decisions across 12 dimensions (ATAM-lite) | design/ |
sdd-ux-designer | Design system, wireframes, accessibility, interaction model | ux/ |
| Skill | Purpose |
|---|---|
sdd-reverse-engineer | Code → SDD artifacts (requirements, specs, retroactive FASEs by functional area, tasks, findings) |
sdd-reconcile | Detect and resolve spec ↔ code drift |
sdd-import | Jira, OpenAPI, Markdown, Notion, CSV, Excel → SDD format |
| Skill | Purpose |
|---|---|
sdd-live (mod) | A live view inside Claude Code: the SDD stage, the skill in course and what each subagent is doing, above the prompt and in a /sdd pane — see docs/vista-en-vivo.md |
sdd-acceptance | Verdict per requirement (VERIFIED / FAILING / MISSING / WAIVED) with its evidence, ID-chain integrity, a goal loop until every Must is met, the customer's sign-off and updates of the project's status page — see docs/aceptacion.md |
sdd-setup | Initialise a project: state file, git hook and vendored validator, .gitignore policy, stack kits, multi-session roles; cleans up 4.x status lines |
sdd-pipeline-status | Stage report, staleness, acceptance summary, next action; --diagnose classifies an existing project (8 adoption scenarios) and lists the skills to run |
sdd-gap-detector | Missing endpoints, orphan code, schema mismatches — with a human review document; --semantic checks whether the code implements each requirement (Jev judge when enabled, LLM otherwise) |
sdd-session-summary | Summarise the session and update project memory |
sdd-orchestrator | Runs the whole pipeline interactively from the main conversation, asking for the gate decisions (requirements approval, FASE acceptance) |
sdd-lead | Multi-session lead: dispatches stages to role sessions after each human gate, receives handoffs, answers station questions |
Git history is the evidence that a task was done and a requirement delivered (docs/git.md, references/git-conventions.md):
Task, Refs and Change trailers written with git commit --trailer; the commit-msg hook and CI run the same validator (sdd verify).sdd branch start fase 2 lifecycle); merges are merge commits, never squash, so the per-task trailers survive.sdd trace req REQ-F-004, sdd trace why src/tasks.ts:42 and sdd trace delivered REQ-F-004 answer "which commits", "why is this line here" and "which tags ship it".tracker: github|gitlab in the Stack Profile, sdd issue open|update|close keeps one issue per FASE and per change (the FASE issue closes at acceptance) and sdd pr-body builds the PR description; /sdd-setup --tracker adds CI templates that run sdd verify --range, sdd lint and sdd gate --mode warn. Every push, issue, PR or merge asks first.Acceptance answers "is each requirement satisfied, and with what evidence?" (docs/aceptacion.md):
node scripts/sdd.mjs accept --report acceptance/ACCEPTANCE-REPORT.md # ledger from JUnit + decisions.jsonl
node scripts/sdd.mjs gate --mode enforce # 0 goal met · 1 not met · 2 stale evidence · 3 met with waived Musts
Tests are named with their scenario id (AC-001-03) so results bind to acceptance criteria; demo, measurement and inspection evidence is recorded by a named person. Human records and acceptance tags ask for confirmation first (tool guard) and cannot be hand-edited (upstream guard) — this prevents accidental self-approval, it is not a guarantee.
With TYPESAFE_API_KEY set, scripts/sdd-jev.mjs lets skills screen many small items in one pass with TypeSafe's Jev (calibrated yes/no, choice and score answers in ~100 ms): requirement quality and customer-need coverage in sdd-requirements-engineer, detection-pattern triage in sdd-spec-auditor, requirement coverage in sdd-gap-detector --semantic, feedback routing at the FASE gate, and advisory test-adequacy and demo-evidence checks in sdd-acceptance. The LLM reads only what Jev flags or is unsure about, and Jev never decides a verdict, waiver or sign-off. Without the key (or with SDD_JEV=off) every skill works as before. Spec and code text is sent to TypeSafe, so enable it only where that is allowed — see docs/jev.md.
The maintainer agents that audit this repository (sdd-pipeline-auditor, sdd-cross-auditor) live in .claude/agents/ and are not shipped with the plugin.
Declared in hooks/hooks.json and run from the plugin directory — nothing is copied into your project.
| Hook | Event | What it does |
|---|---|---|
sdd-session-start.sh | SessionStart | Injects pipeline status (N/7 done, stale stages, next step, session role and live peers) and the last acceptance summary |
sdd-upstream-guard.sh | PreToolUse Edit/Write | Denies writes to upstream artifacts while a downstream stage runs (constitution art. 4); enforces role ownership; denies hand edits of acceptance/decisions.jsonl and the acceptance report |
sdd-tool-guard.sh | PreToolUse Bash | Denies commands that assign human-consent variables for AI actions (e.g. PRISMA_USER_CONSENT_FOR_DANGEROUS_AI_ACTION); asks before sdd accept record and fase-N-accepted / requirements-vN tags |
sdd-augment-hook.js | PreToolUse Read/Edit/Write | Adds traceability context for the file being touched |
sdd-pipeline-state-updater.sh | PreToolUse Skill, UserPromptExpansion, PostToolUse Write | Marks a stage running when its skill starts or a file under its directory is written (locked, worktree-aware) |
sdd-setup additionally installs a git commit-msg hook that runs sdd verify from the validator vendored into .claude/sdd/ (commit it; CI uses the same copy): feat, test and refactor need a Task trailer, fix and perf a Task or Change (bypass: [skip-sdd] or SDD_SKIP_VERIFY=1). Opt-in quality gates (Stop, TaskCompleted) live in templates/settings-optional-quality-gates.json.
server/dist/server.js is a single bundled file (no node_modules needed) registered as sdd:
| Tool | Purpose |
|---|---|
sdd_query | Search artifacts by text, id, type or domain |
sdd_impact | Blast radius by depth (WILL_BREAK / LIKELY_AFFECTED / MAY_NEED_REVIEW) |
sdd_context | 360° view of one artifact; for a requirement, its acceptance verdict with per-criterion evidence |
sdd_coverage | Verdict per requirement from .sdd/acceptance.json; without it, link gaps by domain or layer |
sdd_trace | Full chain traversal with break detection |
sdd_gaps | Findings from sdd-gap-detector |
Plus sdd://pipeline/*, sdd://graph/*, sdd://coverage/gaps, sdd://artifacts/{type}[/{id}] resources and the analyze_impact / generate_status_report prompts. The graph (dashboard/traceability-graph.json) is built by python3 scripts/sdd-graph.py; the server looks for it from the working directory upwards and degrades gracefully when there is none.
Long-lived, named Claude Code sessions can own different parts of the pipeline and message each other (Claude Code ≥ 2.1.224):
/sdd-setup --multisession # writes .claude/sdd-sessions.json (roles → session name, colour, owned paths, stages)
.claude/sdd/sdd-up.sh sdd-lead # launches a tmux session `claude -n <project>-lead` with SDD_ROLE=sdd-lead
.claude/sdd/sdd-up.sh impl-f1a # a worktree + session for FASE 1 / Stream A
SDD_ROLE identifies the session; the session-start hook shows the role and its live peers, and the upstream guard denies writes outside the role's owned paths.sdd-task-implementer --stream=A works in its own worktree and --integrate --fase N merges the streams back in the main checkout (git merge --no-ff, verification, fase-N-verified tag).stage=<x> status=done gate=<…> to the lead session (never "run X"; the human still takes every gate decision in sdd-lead). Questions that would block a station are written to .sdd/questions-<role>.md and answered from the lead.SDD_ROLE is not set.See docs/multisesion.md for the full protocol and docs/multisesion/ for the design review behind it.
.claude-plugin/ plugin.json, marketplace.json hooks/ hooks.json + scripts (+ lib/sdd-common.sh)
skills/ 21 skills scripts/ sdd.mjs CLI (+ lib/), sdd-state.sh, sdd-jev.mjs, sdd-graph.py, validators
.claude/agents/ maintainer auditors (not shipped) server/ MCP server (src/, dist/server.js, tests)
references/ constitution, git conventions, templates/ pipeline-state, gitignore, sessions, quality gates, stack kits
handoff protocol, async questions
examples/todo-app toy project for E2E tests tests/ hooks, setup, tasks, graph, jev, bench, git, plan, acceptance, tracker, e2e docs/ guides, git, acceptance, design
node scripts/validate-plugin.mjs # manifests, skills, hooks, mcp, stack kits, commit examples
bash tests/hooks/run.sh # hook behaviour (roles, worktrees, locking, guards)
bash tests/tasks/run.sh # task-line grammar (V-19) and trailer-based task status
bash tests/git/run.sh # sdd trace / verify / branch against temporary repos
bash tests/plan/run.sh # sdd lint --plan on the vertical and Streams fixtures
bash tests/acceptance/run.sh # sdd accept / gate / loop: verdicts, freshness, waivers, JUnit reader
bash tests/graph/run.sh # sdd-graph.py (commit parity with sdd trace, Stack Profile scans) and test-result parsers
bash tests/jev/run.sh # sdd-jev.mjs against a local API mock (no key, no network)
bash tests/e2e/run-all.sh # B1 static validation + B2 real install in an isolated CLAUDE_CONFIG_DIR
cd server && npm ci && npm run check && npm run build && npm test
node scripts/sdd.mjs --help # the CLI (scripts/sdd-task-lint.mjs is an alias)
claude --plugin-dir . -p "/sdd-pipeline-status" # try the plugin without installing it
scripts/release.sh 5.0.0 # bump plugin.json/marketplace/server, CHANGELOG, tag sdd-pipeline--v5.0.0
CI runs lint (shellcheck), validation, the script test suites (tests/{hooks,setup,bench,tasks,graph,jev,git,acceptance,plan}) and the server build/test matrix (ubuntu + macos, node 18/22), and checks that server/dist/server.js is reproducible.
sdd tracescripts/sdd-profile.sh)This repository unifies sdd-skills (upstream), claude-plugin-sdd (the previous distributable plugin) and a reduced internal fork. Both public repositories are archived at v3.1.0; see docs/legacy/INVENTARIO.md for where every piece came from.
MIT — Andres Leon
hooks/live/register.tsx 356 lines1// sdd-live — what is happening in an SDD project, live, inside Claude Code.
2//
3// - Above the prompt: the SDD phase, the delivery in course, requirements proven, the gate; then what runs now (the
4// SDD skill, how many agents work and on what).
5// - /sdd opens a pane: Now (skill and stage), Agents (each subagent: what it was asked, its type, how long, its last
6// action and its recent ones, how it ended), Project (requirements with their warnings in plain words), Journal,
7// and the customer page's link.
8// - Toasts when an agent finishes and when the phase, the proven count or the gate changes.
9//
10// Agents are followed from the engine's own events: `agent.spawn` (what it was asked, its id), `tool.call` carrying
11// that id (what it does), `turn.complete` carrying it (how it ended), and `$.agent.list()` for the status of the
12// ones still alive. The project comes from `sdd status build --no-out` (read-only), refreshed when the session
13// starts, after each main turn and after an SDD skill or a commit.
14import { atom, read, update } from 'claude-code'
15import type { Register } from 'claude-code'
16
17import type { Agent, Now, Snapshot } from '../../types/sdd-live'
18
19const PANE = 'sdd-live'
20const snap = atom({ plugin: 'sdd-pipeline', key: 'snap' } as const, null)
21const error = atom({ plugin: 'sdd-pipeline', key: 'error' } as const, null)
22const target = atom({ plugin: 'sdd-pipeline', key: 'target' } as const, null)
23const isHidden = atom({ plugin: 'sdd-pipeline', key: 'isHidden' } as const, false)
24const agents = atom({ plugin: 'sdd-pipeline', key: 'agents' } as const, [])
25const now = atom({ plugin: 'sdd-pipeline', key: 'now' } as const, { skill: null, skillAt: null, last: null, lastAt: null })
26
27const PHASE: Record<string, string> = {
28 understand: 'Entender', agree: 'Acordar', design: 'Diseñar', plan: 'Planificar',
29 build: 'Construir', verify: 'Comprobar', deliver: 'Entregar', done: 'Entregado',
30}
31const STATUS: Record<string, string> = {
32 pending: 'Pendiente', building: 'En construcción', shown: 'Demostrado', failing: 'No cumple',
33 deferred: 'Aplazado', deprecated: 'Retirado',
34}
35const WARN: Record<string, string> = {
36 unshown: 'falta captura', weakened: 'prueba sin el texto exacto', challenge: 'revisor encontró un problema',
37 stale: 'evidencia antigua', failing: 'prueba falla',
38}
39const GATE: Record<number, string> = { 0: 'gate ok', 1: 'gate: no cumplido', 2: 'gate: evidencia antigua', 3: 'gate ok con aplazados', 4: 'gate: hallazgo abierto' }
40const COLOR: Record<string, string> = { shown: 'success', building: 'suggestion', failing: 'error', deferred: 'remember', pending: 'subtle', deprecated: 'subtle' }
41/** The SDD skills by the stage a person recognises. */
42const SKILL: Record<string, string> = {
43 'sdd-requirements-engineer': 'Requisitos', 'sdd-specifications-engineer': 'Especificaciones', 'sdd-spec-auditor': 'Auditoría de specs',
44 'sdd-test-planner': 'Plan de pruebas', 'sdd-plan-architect': 'Plan de entregas', 'sdd-task-generator': 'Tareas',
45 'sdd-task-implementer': 'Construcción', 'sdd-acceptance': 'Aceptación', 'sdd-gap-detector': 'Huecos spec/código',
46 'sdd-req-change': 'Cambio de requisitos', 'sdd-tech-designer': 'Diseño técnico', 'sdd-ux-designer': 'Diseño UX',
47 'sdd-security-auditor': 'Auditoría de seguridad', 'sdd-orchestrator': 'Orquestador', 'sdd-lead': 'Lead multisesión',
48 'sdd-setup': 'Puesta en marcha', 'sdd-pipeline-status': 'Estado del pipeline', 'sdd-reverse-engineer': 'Ingeniería inversa',
49 'sdd-reconcile': 'Reconciliar specs', 'sdd-import': 'Importar', 'sdd-session-summary': 'Resumen de sesión',
50}
51const LIVE = new Set(['pending', 'running', 'waiting'])
52const AGENT_ICON: Record<string, string> = { running: '▶', pending: '…', waiting: '⏸', idle: '·', completed: '✓', failed: '✗', killed: '■' }
53const AGENT_COLOR: Record<string, string> = { running: 'claude', pending: 'subtle', waiting: 'warning', idle: 'subtle', completed: 'success', failed: 'error', killed: 'subtle' }
54
55/** The skill's short name: `sdd-pipeline:sdd-acceptance` → `sdd-acceptance`. */
56function skillName(skill: string): string {
57 return String(skill).split(':').pop() || String(skill)
58}
59function base(p: unknown): string {
60 return String(p ?? '').split('/').filter(Boolean).pop() || String(p ?? '')
61}
62function clip(s: unknown, n: number): string {
63 const t = String(s ?? '').replace(/\s+/g, ' ').trim()
64 return t.length > n ? `${t.slice(0, n - 1)}…` : t
65}
66/** One line for what a tool call does, in the words a person reads in a log. */
67function describe(e: any): string {
68 switch (e.tool) {
69 case 'Bash': return `$ ${clip(e.command, 70)}`
70 case 'Read': return `lee ${base(e.file_path)}`
71 case 'Edit': case 'MultiEdit': return `edita ${base(e.file_path)}`
72 case 'Write': return `escribe ${base(e.file_path)}`
73 case 'Grep': return `busca «${clip(e.pattern, 40)}»`
74 case 'Glob': return `lista ${clip(e.pattern, 40)}`
75 case 'Skill': return `skill ${skillName(e.skill)}`
76 case 'Agent': case 'Task': return `lanza agente «${clip(e.description, 40)}»`
77 case 'WebFetch': return `consulta ${clip(e.url, 50)}`
78 case 'WebSearch': return `busca en la web «${clip(e.query, 40)}»`
79 default: return String(e.tool)
80 }
81}
82function ago(ms: number): string {
83 const s = Math.max(0, Math.round(ms / 1000))
84 if (s < 60) return `${s}s`
85 const m = Math.floor(s / 60)
86 return m < 60 ? `${m}m${String(s % 60).padStart(2, '0')}s` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
87}
88
89/** The sdd.mjs this view runs: the plugin's own when shipped inside sdd-pipeline, else $SDD_PLUGIN_ROOT's, else the
90 * newest installed sdd-pipeline in the plugin cache (a release ranks above its own pre-release). */
91async function cli($: any): Promise<string | null> {
92 const own = `${$.plugin.root}/scripts/sdd.mjs`
93 if (await $.fs.exists(own)) return own
94 const env = await $.env.get('SDD_PLUGIN_ROOT')
95 if (env && (await $.fs.exists(`${env}/scripts/sdd.mjs`))) return `${env}/scripts/sdd.mjs`
96 const home = await $.env.get('HOME')
97 const cache = `${home}/.claude/plugins/cache/noelserdna/sdd-pipeline`
98 if (!(await $.fs.exists(cache))) return null
99 const versions = (await $.fs.list(cache)).filter((x: any) => x.kind === 'dir' && /^\d+\.\d+\.\d+/.test(x.name)).map((x: any) => x.name)
100 const key = (v: string) => { const [core = '', pre] = v.split('-', 2); return [...core.split('.').map(n => Number(n) || 0), pre ? 0 : 1] }
101 versions.sort((a: string, b: string) => { const x = key(a), y = key(b); for (let i = 0; i < 4; i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (y[i] ?? 0) - (x[i] ?? 0); return 0 })
102 return versions.length ? `${cache}/${versions[0]}/scripts/sdd.mjs` : null
103}
104
105/** Rebuilds the project snapshot (read-only); a folder with no SDD project leaves it null. */
106async function refresh($: any, quiet = true) {
107 const root = (await read($, target)) || (await $.session.cwd())
108 const isSdd = (await $.fs.exists(`${root}/pipeline-state.json`)) || (await $.fs.exists(`${root}/requirements/REQUIREMENTS.md`))
109 if (!isSdd) { await update($, snap, () => null); await update($, error, () => null); await status($); return }
110 const sdd = await cli($)
111 if (!sdd) { await update($, error, () => 'no encuentro el plugin sdd-pipeline'); return }
112 const run = await $.process.run(['node', sdd, 'status', 'build', '--no-out'], { cwd: root, timeoutMs: 60000 })
113 if (run.exitCode !== 0) { await update($, error, () => (run.stderr || run.stdout).split('\n')[0].slice(0, 200)); return }
114 let d: any
115 try { d = JSON.parse(run.stdout) } catch { await update($, error, () => 'salida de sdd status build ilegible'); return }
116 const active = (d.requirements || []).filter((r: any) => r.status !== 'deprecated')
117 const s: Snapshot = {
118 root,
119 name: d.project?.name || base(root),
120 phase: d.where?.phase || 'understand',
121 now: d.where?.now || '',
122 next: d.where?.next ?? null,
123 ask: (d.where?.needFromYou || []).map((a: any) => a.text),
124 fase: d.where?.fase ? `entrega ${d.where.fase.n}/${d.where.fase.of}` : null,
125 shown: active.filter((r: any) => r.status === 'shown').length,
126 total: active.length,
127 gate: d.technical?.gate?.code ?? null,
128 reqs: active.map((r: any) => ({ id: r.id, title: r.title, plain: r.plain, status: r.status, fase: r.fase,
129 warnings: [...new Set<string>((r.warnings || []).map((w: any) => w.code))] })),
130 journal: (d.journal || []).slice(-8).reverse().map((j: any) => ({ at: j.at, kind: j.kind, text: j.text })),
131 url: d.page?.url ?? null,
132 at: await $.clock.now(),
133 }
134 const before = await read($, snap)
135 await update($, snap, () => s)
136 await update($, error, () => null)
137 await status($)
138 if (!quiet && before && before.root === s.root) {
139 if (before.phase !== s.phase) $.ui.toast(`SDD: ahora ${PHASE[s.phase] || s.phase} — ${s.now}`)
140 else if (before.shown !== s.shown) $.ui.toast(`SDD: ${s.shown} de ${s.total} requisitos demostrados`)
141 else if (before.gate !== s.gate && s.gate !== null) $.ui.toast(`SDD: ${GATE[s.gate] || 'gate ' + s.gate}`)
142 }
143}
144
145/** The status line: the phase and the agents at work. */
146async function status($: any) {
147 const s = await read($, snap)
148 const live = (await read($, agents)).filter(a => LIVE.has(a.status)).length
149 const parts = [s ? `SDD ${PHASE[s.phase] || s.phase} · ${s.shown}/${s.total}` : null, live ? `${live} agente${live === 1 ? '' : 's'}` : null].filter(Boolean)
150 $.ui.status(parts.length ? parts.join(' · ') : undefined)
151}
152
153/** Records one agent's change; the list keeps the newest 40. */
154async function touch($: any, id: string, change: (a: Agent) => Agent, create?: () => Agent) {
155 await update($, agents, list => {
156 const i = list.findIndex(a => a.id === id)
157 if (i === -1) return create ? [create(), ...list].slice(0, 40) : list
158 const next = [...list]
159 next[i] = change(next[i] as Agent)
160 return next
161 })
162}
163
164/** Reconciles the statuses with the engine's own list (an agent stopped, killed or left waiting). */
165async function poll($: any) {
166 const mine = await read($, agents)
167 if (!mine.some(a => LIVE.has(a.status))) return
168 let rows: any[] = []
169 try { rows = await $.agent.list() } catch { return }
170 const at = await $.clock.now()
171 for (const r of rows) {
172 const known = mine.find(a => a.id === r.id)
173 if (known && known.status !== r.status) {
174 await touch($, r.id, a => ({ ...a, status: r.status, endedAt: LIVE.has(r.status) ? null : (a.endedAt ?? at) }))
175 }
176 }
177 await status($)
178}
179
180export const register: Register = on => {
181 on('session.start', async ($, e, next) => {
182 await $.command.register({ name: 'sdd', description: 'Panel en vivo del proyecto SDD: etapa, agentes en marcha, requisitos y diario', argumentHint: '[ruta del proyecto | refresh | off | clear]' })
183 const started = await next(e)
184 void refresh($).catch(() => {})
185 $.clock.every(5000, () => { void poll($).catch(() => {}) })
186 return started
187 })
188
189 on('agent.spawn', async ($, e, next) => {
190 const r: any = await next(e)
191 if (r && r.agentId) {
192 const at = await $.clock.now()
193 const ev: any = e
194 await touch($, r.agentId, a => a, () => ({
195 id: r.agentId, description: clip(ev.description || ev.subagentType, 80), type: String(ev.subagentType || 'agent'),
196 workflow: ev.workflow?.runId ?? null, parent: ev.parentAgentId ?? null, status: 'running',
197 startedAt: at, endedAt: null, last: null, lastAt: null, actions: 0, recent: [],
198 }))
199 await status($)
200 }
201 return r
202 }).catch(($, e, next) => next(e)) // an observer: whatever fails here, the agent starts
203
204 on('tool.call', async ($, e, next) => {
205 const ev: any = e
206 const line = describe(ev)
207 const at = await $.clock.now()
208 if (ev.agentId) {
209 await touch($, ev.agentId, a => ({ ...a, status: 'running', last: line, lastAt: at, actions: a.actions + 1, recent: [line, ...a.recent].slice(0, 6) }))
210 } else {
211 await update($, now, n => ({ ...n, last: line, lastAt: at }))
212 if (ev.tool === 'Skill' && /(^|:)sdd-/.test(String(ev.skill))) await update($, now, n => ({ ...n, skill: skillName(ev.skill), skillAt: at }))
213 }
214 const ran = await next(e)
215 // a commit or an SDD skill changes the project: refresh it once the call is done
216 if (!ev.agentId && ((ev.tool === 'Bash' && /\bgit (commit|merge|tag)\b|\bsdd(\.mjs)? (accept|journal|status|route)\b/.test(String(ev.command))) || ev.tool === 'Skill')) {
217 void refresh($, false).catch(() => {})
218 }
219 return ran
220 }).catch(($, e, next) => next(e)) // an observer: whatever fails here, the call runs
221
222 on('skill.prompt', async ($, e, next) => {
223 const name = skillName((e as any).skill)
224 if (name.startsWith('sdd-')) { const at = await $.clock.now(); await update($, now, n => ({ ...n, skill: name, skillAt: at })) }
225 return next(e)
226 })
227
228 on('turn.complete', async ($, e, next) => {
229 const done: any = await next(e)
230 const ev: any = e
231 if (ev.agentId) {
232 const at = await $.clock.now()
233 const list = await read($, agents)
234 const a = list.find(x => x.id === ev.agentId)
235 if (a && LIVE.has(a.status)) {
236 const ended = ev.isAborted ? 'killed' : 'completed'
237 await touch($, ev.agentId, x => ({ ...x, status: ended, endedAt: at }))
238 $.ui.toast(`${ended === 'completed' ? '✓' : '■'} Agente «${clip(a.description, 50)}» ${ended === 'completed' ? 'terminó' : 'se detuvo'} en ${ago(at - a.startedAt)}`)
239 await status($)
240 }
241 } else {
242 void refresh($, false).catch(() => {})
243 }
244 return done
245 })
246
247 on('command.run', { command: 'sdd' }, async ($, e) => {
248 const arg = ((e as any).args || '').trim()
249 if (arg === 'off') { await update($, target, () => null); await refresh($); return { text: 'sdd-live: vuelve a seguir el directorio de la sesión.' } }
250 if (arg === 'clear') { await update($, agents, list => list.filter(a => LIVE.has(a.status))); await status($); return { text: 'sdd-live: agentes terminados borrados de la lista.' } }
251 if (arg && arg !== 'refresh') {
252 if (!(await $.fs.exists(arg))) return { text: `sdd-live: no existe ${arg}` }
253 await update($, target, () => arg)
254 }
255 await update($, isHidden, () => false)
256 await refresh($)
257 const s = await read($, snap)
258 await $.ui.open({ id: PANE, title: s ? `SDD · ${s.name}` : 'SDD · agentes' })
259 const live = (await read($, agents)).filter(a => LIVE.has(a.status)).length
260 return { text: s ? `Panel SDD de ${s.name} abierto (${live} agente${live === 1 ? '' : 's'} en marcha).` : ((await read($, error)) || `Este directorio no tiene un proyecto SDD; el panel muestra solo los agentes (${live} en marcha).`) }
261 })
262
263 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
264 const s = await read($, snap)
265 const list = await read($, agents)
266 const live = list.filter(a => LIVE.has(a.status))
267 const n: Now = await read($, now)
268 if ((!s && live.length === 0) || e.props.hasSurvey || (await read($, isHidden))) return next(e)
269 const { Box, Text, Button } = $.ui.resolve(e)
270 const t = await $.clock.now()
271 const gate = s && s.gate !== null ? GATE[s.gate] : null
272 const first = live[0]
273 return (
274 <Box flexDirection="column">
275 <Box>
276 <Text bold>SDD </Text>
277 {s && <Text color="claude">{PHASE[s.phase] || s.phase}</Text>}
278 {s && s.fase && <Text dimColor> · {s.fase}</Text>}
279 {s && <Text dimColor> · {s.shown}/{s.total} demostrados</Text>}
280 {gate && <Text color={s!.gate === 0 || s!.gate === 3 ? 'success' : 'warning'}> · {gate}</Text>}
281 {n.skill && <Text dimColor> · {SKILL[n.skill] || n.skill}</Text>}
282 {live.length > 0 && <Text color="claude"> · {live.length} agente{live.length === 1 ? '' : 's'}</Text>}
283 <Text> </Text>
284 <Button key="hide" label="Ocultar" plain onPress={() => update($, isHidden, () => true)} />
285 </Box>
286 {first && (
287 <Text dimColor wrap="truncate-end">
288 ▶ {first.description}{first.last ? ` — ${first.last}` : ''} ({ago(t - first.startedAt)}){live.length > 1 ? ` · y ${live.length - 1} más (/sdd)` : ''}
289 </Text>
290 )}
291 {!first && s && s.ask.length > 0 && <Text color="warning" wrap="truncate-end">Necesitamos del cliente: {s.ask[0]}</Text>}
292 {!first && s && s.ask.length === 0 && s.now && <Text dimColor wrap="truncate-end">{s.now}</Text>}
293 </Box>
294 )
295 })
296
297 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
298 const { Box, Text, Button } = $.ui.resolve(e)
299 const s = await read($, snap)
300 const err = await read($, error)
301 const list = await read($, agents)
302 const n: Now = await read($, now)
303 const t = await $.clock.now()
304 const live = list.filter(a => LIVE.has(a.status))
305 const ended = list.filter(a => !LIVE.has(a.status)).slice(0, 6)
306 const rows = Math.max(12, e.viewport?.rows ?? 30)
307 const reqRoom = Math.max(3, rows - 18 - live.length * 3 - ended.length)
308 const reqs = s ? [...s.reqs].sort((a, b) => (b.warnings.length - a.warnings.length) || a.id.localeCompare(b.id)).slice(0, reqRoom) : []
309 return (
310 <Box flexDirection="column">
311 <Text bold>Ahora</Text>
312 {s ? <Text wrap="wrap">{PHASE[s.phase] || s.phase}{s.fase ? ` · ${s.fase}` : ''} · {s.shown}/{s.total} demostrados — {s.now}</Text>
313 : <Text dimColor wrap="wrap">{err || 'Sin proyecto SDD en este directorio (/sdd <ruta> para seguir otro).'}</Text>}
314 {n.skill && <Text wrap="truncate-end">Skill: <Text color="claude">{SKILL[n.skill] || n.skill}</Text><Text dimColor> ({n.skill}, desde hace {ago(t - (n.skillAt ?? t))})</Text></Text>}
315 {n.last && <Text dimColor wrap="truncate-end">Conversación principal: {n.last} (hace {ago(t - (n.lastAt ?? t))})</Text>}
316 {s && s.ask.map(a => <Text color="warning" wrap="wrap">Del cliente: {a}</Text>)}
317 <Text> </Text>
318 <Text bold>Agentes {live.length ? `(${live.length} en marcha)` : ''}</Text>
319 {list.length === 0 && <Text dimColor>Ningún agente lanzado en esta sesión.</Text>}
320 {live.map(a => (
321 <Box flexDirection="column">
322 <Text wrap="truncate-end"><Text color={AGENT_COLOR[a.status] || 'text'}>{AGENT_ICON[a.status] || '?'} </Text><Text bold>{a.description}</Text><Text dimColor> · {a.type}{a.workflow ? ' · workflow' : ''} · {ago(t - a.startedAt)} · {a.actions} acciones</Text></Text>
323 <Text dimColor wrap="truncate-end"> {a.last ? `ahora: ${a.last} (hace ${ago(t - (a.lastAt ?? t))})` : 'arrancando…'}</Text>
324 {a.recent.length > 1 && <Text dimColor wrap="truncate-end"> antes: {a.recent.slice(1, 4).join(' · ')}</Text>}
325 </Box>
326 ))}
327 {ended.map(a => (
328 <Text dimColor wrap="truncate-end"><Text color={AGENT_COLOR[a.status] || 'subtle'}>{AGENT_ICON[a.status] || '·'} </Text>{a.description} · {a.type} · {ago((a.endedAt ?? t) - a.startedAt)} · {a.actions} acciones</Text>
329 ))}
330 {s && <Text> </Text>}
331 {s && <Text bold>Requisitos</Text>}
332 {reqs.map(r => (
333 <Box flexDirection="column">
334 <Text wrap="truncate-end">
335 <Text color={COLOR[r.status] || 'text'}>{STATUS[r.status] || r.status}</Text>
336 <Text dimColor> {r.id} </Text>
337 {r.plain || r.title}
338 </Text>
339 {r.warnings.length > 0 && <Text color="warning" wrap="truncate-end"> ⚠ {r.warnings.map(w => WARN[w] || w).join(' · ')}</Text>}
340 </Box>
341 ))}
342 {s && s.journal.length > 0 && <Text> </Text>}
343 {s && s.journal.length > 0 && <Text bold>Diario</Text>}
344 {s && s.journal.slice(0, 4).map(j => <Text wrap="truncate-end"><Text dimColor>{j.at.slice(0, 10)} </Text>{j.text}</Text>)}
345 <Text> </Text>
346 {s && (s.url ? <Text dimColor wrap="truncate-end">Página del cliente: {s.url}</Text> : <Text dimColor>Sin página del cliente publicada todavía.</Text>)}
347 <Box>
348 <Button key="refresh" label="Actualizar" onPress={() => refresh($, false)} />
349 <Text> </Text>
350 <Button key="clear" label="Limpiar terminados" onPress={() => update($, agents, l => l.filter(a => LIVE.has(a.status)))} />
351 </Box>
352 </Box>
353 )
354 })
355}
356types/sdd-live.d.ts 50 lines1export type Req = { id: string; title: string; plain: string | null; status: string; warnings: string[]; fase: number | null }
2export type Entry = { at: string; kind: string; text: string }
3/** The SDD project as `sdd status build --no-out` describes it (contract sdd-status-v1, cut to what the views draw). */
4export type Snapshot = {
5 root: string
6 name: string
7 phase: string
8 now: string
9 next: string | null
10 ask: string[]
11 fase: string | null
12 shown: number
13 total: number
14 gate: number | null
15 reqs: Req[]
16 journal: Entry[]
17 url: string | null
18 at: number
19}
20/** One subagent of this session: what it was asked, what it is doing now and how it ended. */
21export type Agent = {
22 id: string
23 description: string
24 type: string
25 workflow: string | null
26 parent: string | null
27 status: string
28 startedAt: number
29 endedAt: number | null
30 last: string | null
31 lastAt: number | null
32 actions: number
33 recent: string[]
34}
35/** What is running in the main conversation: the SDD skill in course and the main loop's last action. */
36export type Now = { skill: string | null; skillAt: number | null; last: string | null; lastAt: number | null }
37
38declare module 'claude-code' {
39 interface PluginState {
40 'sdd-pipeline': {
41 snap: Snapshot | null
42 error: string | null
43 target: string | null
44 isHidden: boolean
45 agents: Agent[]
46 now: Now
47 }
48 }
49}
50