SLOPSHOPPER

sdd-pipeline

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

newpanebandguardcommandtoast
★ 1v5.3.0MITupdated 2026-10-07noelserdna/sdd-pipeline
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sdd-pipeline
│ ┃ SDD · agentes ✕ › fix the failing auth test and add an audit log call │ ┃ Ahora │ ┃ Sin proyecto SDD en este directorio (/sdd ⏺ Read(src/auth.ts) │ ┃ <ruta> para seguir otro). ⎿ Read 6 lines │ ┃ Conversación principal: $ cat .env (hace 0s) ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Agentes ⏺ Bash(bun test) │ ┃ Ningún agente lanzado en esta sesión. ⎿ 3 pass, 1 fail │ ┃ │ ┃ [ Actualizar ] [ Limpiar terminados ] ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /sdd │ ⎿ sdd-pipeline: Este directorio no tiene un proyecto SDD; el panel │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · SDD · agentes
Ahora Sin proyecto SDD en este directorio (/sdd <ruta> para seguir otro). Conversación principal: $ cat .env (hace 0s) Agentes Ningún agente lanzado en esta sesión. [ Actualizar ] [ Limpiar terminados ]
README

sdd-pipeline

Leer en español

ci license

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.

  • 21 skills — the 7-stage pipeline, per-requirement acceptance, lateral skills, brownfield adoption, utilities, the interactive orchestrator and the multi-session lead
  • One CLI, 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 git
  • 5 hooks (7 event registrations) — pipeline status at session start, upstream immutability guard, a guard against fabricated consent and accidental self-approval, traceability context and pipeline-state updates
  • MCP server — 6 tools, 7 resources and 2 prompts; coverage reports the acceptance verdict of each requirement
  • Multi-session implementation — role-scoped sessions (SDD_ROLE), parallel streams in git worktrees, lead handoffs

Install

/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.

Quick start

/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".

The pipeline

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.

Skills

Pipeline (7)

#SkillInputOutput
1sdd-requirements-engineerthe customerrequirements/ (needs, requirements, approval tag)
2sdd-specifications-engineerrequirements/spec/
3sdd-spec-auditorspec/audits/, corrected spec/
4sdd-test-plannerspec/, ux/test/
5sdd-plan-architectspec/, design/, ux/, audits/plan/ (vertical FASEs)
6sdd-task-generatorplan/task/
7sdd-task-implementertask/, spec/, plan/code, tests, commits

Lateral (4)

SkillPurposeOutput
sdd-security-auditorOWASP ASVS v4 / CWE security posture audit of the specsaudits/SECURITY-AUDIT-BASELINE.md
sdd-req-changeADD / MODIFY / DEPRECATE requirements with pipeline cascade (ISO 14764), on a change/ branchupdated requirements/, spec/, changes/
sdd-tech-designerArchitecture and stack decisions across 12 dimensions (ATAM-lite)design/
sdd-ux-designerDesign system, wireframes, accessibility, interaction modelux/

Brownfield (3)

SkillPurpose
sdd-reverse-engineerCode → SDD artifacts (requirements, specs, retroactive FASEs by functional area, tasks, findings)
sdd-reconcileDetect and resolve spec ↔ code drift
sdd-importJira, OpenAPI, Markdown, Notion, CSV, Excel → SDD format

Utilities (7)

SkillPurpose
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-acceptanceVerdict 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-setupInitialise a project: state file, git hook and vendored validator, .gitignore policy, stack kits, multi-session roles; cleans up 4.x status lines
sdd-pipeline-statusStage report, staleness, acceptance summary, next action; --diagnose classifies an existing project (8 adoption scenarios) and lists the skills to run
sdd-gap-detectorMissing 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-summarySummarise the session and update project memory
sdd-orchestratorRuns the whole pipeline interactively from the main conversation, asking for the gate decisions (requirements approval, FASE acceptance)
sdd-leadMulti-session lead: dispatches stages to role sessions after each human gate, receives handoffs, answers station questions

Git and acceptance

Git history is the evidence that a task was done and a requirement delivered (docs/git.md, references/git-conventions.md):

  • Commits carry Task, Refs and Change trailers written with git commit --trailer; the commit-msg hook and CI run the same validator (sdd verify).
  • Work starts on a branch (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".
  • With 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.

Optional: Jev bulk judgments

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.

Hooks

Declared in hooks/hooks.json and run from the plugin directory — nothing is copied into your project.

HookEventWhat it does
sdd-session-start.shSessionStartInjects pipeline status (N/7 done, stale stages, next step, session role and live peers) and the last acceptance summary
sdd-upstream-guard.shPreToolUse Edit/WriteDenies 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.shPreToolUse BashDenies 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.jsPreToolUse Read/Edit/WriteAdds traceability context for the file being touched
sdd-pipeline-state-updater.shPreToolUse Skill, UserPromptExpansion, PostToolUse WriteMarks 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.

MCP server

server/dist/server.js is a single bundled file (no node_modules needed) registered as sdd:

ToolPurpose
sdd_querySearch artifacts by text, id, type or domain
sdd_impactBlast radius by depth (WILL_BREAK / LIKELY_AFFECTED / MAY_NEED_REVIEW)
sdd_context360° view of one artifact; for a requirement, its acceptance verdict with per-criterion evidence
sdd_coverageVerdict per requirement from .sdd/acceptance.json; without it, link gaps by domain or layer
sdd_traceFull chain traversal with break detection
sdd_gapsFindings 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.

Multi-session implementation

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.
  • Streams are the exception in a vertical plan: only when a FASE splits into disjoint write-sets. 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).
  • Handoffs: when a station finishes a stage it sends 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.
  • Everything degrades to single-session behaviour when SDD_ROLE is not set.

See docs/multisesion.md for the full protocol and docs/multisesion/ for the design review behind it.

Repository layout

.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

Development

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.

Documentation

History

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.

License

MIT — Andres Leon

Source 2 files
hooks/live/register.tsx 356 lines
1// 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}
356
types/sdd-live.d.ts 50 lines
1export 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