SDD en Claude Code: franja con el ciclo en curso, /sdd con el detalle y el SPEC GATE aplicado a las ediciones de código.

Real output of @e-burgos/sdd-harness, so you can read what the CLI generates before running it on your own machine.
Every directory here was produced by the CLI and committed untouched. Nothing is hand-written, nothing is trimmed for the demo: what you browse is what you get.
Generated from the version in
VERSION, always the latest published on npm.
| Directory | Mode | Command | What it shows |
|---|---|---|---|
flexi-market/ | Nx monorepo | harness init | Nx 23 + pnpm workspace with two apps — portal (React 19 + Vite) and orders-api (Spring Boot 3 hexagonal, Maven) — plus a shared-types lib, a Postgres service, and the full SDD system |
pulse-api/ | Standalone | harness init --standalone | One Fastify API with its code at the repo root, no Nx, same SDD system |
legacy-shop/ | SDD harness | harness configure sdd | A project that already existed and adopted SDD without changing a line of its own code |
All three carry the complete portable kit under sdd/: the 7 cycle agents, 18 skills, the gates as slash commands under prompts/, strict JSON Schemas, the dependency-free docs viewer, and the Hermes layer (sdd/memory/, sdd/pricing.json).
legacy-shop is the interesting oneThe other two are generated from nothing. This one is installed on top of an existing project, and the proof is in the repo: seeds/legacy-shop/ is the input. Diff it against the result and you see exactly what the command did — and did not do:
src/ is byte-for-byte identical. The CLI never touches your code.AGENTS.md was absorbed into legacy-shop/sdd/dual-harness/AGENTS.md, not overwritten, before the root file became a symlink. Its rules are still there.package.json kept start, test, lint and version 2.7.3; the sdd:* and setup:agents scripts were merged in alongside them.sdd/documentation/ — the methodology as the user receives it: INSTALL, HOW-TO and the full reference, in Spanish and English (sdd/README.md is the bilingual index).sdd/global.json — the single source of the project name and its registered subprojects.AGENTS.md → sdd/dual-harness/AGENTS.md — the dual harness. It is a symlink, and so are CLAUDE.md, .claude/commands/ and everything under .github/skills/: one set of instructions that Claude Code and GitHub Copilot both read.sdd/schemas/ — additionalProperties: false everywhere. sdd/scripts/validate-sdd.mjs is what enforces them, and it runs as part of harness init.npx @e-burgos/sdd-harness init
The interactive wizard asks for the name, mode, apps, libs and services. These examples take the non-interactive path instead — the same one meant for agents and CI:
npx @e-burgos/sdd-harness init --config configs/nx.json
npx @e-burgos/sdd-harness configure sdd --name legacy-shop --description "..."
The inputs used here are the config files under configs/ and the seed project under seeds/ — nothing else.
A stale example of a spec-driven kit is worse than no example: someone copies gates and schemas that no longer exist. So nobody updates these by hand.
.github/workflows/regenerate.yml checks the npm latest tag every 6 hours, and when it moves ahead of VERSION it runs scripts/generate.mjs: generate from the published package, prune build output, commit. That also makes it a smoke test — if a release cannot generate a workspace, this repo goes red.
Send pull requests to e-burgos/sdd-harness, not here. Anything committed to the example directories is overwritten by the next release. The configs and seeds, on the other hand, are inputs — those you can edit.
hooks/.src/register.tsx 139 lines1// Claude Code mod of the SDD kit. Off unless sdd/tools.json → claude_mod.enabled is true
2// (`pnpm sdd:mod -- --enable`); the switch is re-read on every refresh, no reload needed.
3// The rules stay in sdd/dual-harness/rules/sdd-gates.md: this only shows and applies them.
4// Sources live in dot-directories on purpose: TypeScript's `**/*` globs skip them, so the host
5// project's tsc / next build never tries to compile a module that imports 'claude-code'.
6import { atom, read, update } from 'claude-code';
7import type { EngineInterface, Register } from 'claude-code';
8
9import type { Snapshot } from '../../.claude-plugin/contract';
10import { bandLine, judgeEdit, loadSnapshot, progress } from './sdd';
11
12const snapshot = atom({ plugin: 'sdd-mod', key: 'snapshot' } as const, null);
13const PANE = 'sdd-status';
14const OFF_HINT = 'El mod SDD está apagado. Prendelo con: pnpm sdd:mod -- --enable';
15
16async function refresh($: EngineInterface): Promise<Snapshot> {
17 const root = await $.session.root();
18 const next = await loadSnapshot(
19 {
20 read: (path) => $.fs.read(path),
21 list: async (path) => (await $.fs.list(path)).map(({ name, kind }) => ({ name, kind })),
22 },
23 root,
24 );
25 await update($, snapshot, () => next);
26 return next;
27}
28
29// A gate that cannot read sdd/ lets the edit through: sdd:validate and CI stay the safety net.
30async function gate($: EngineInterface, filePath: string) {
31 try {
32 const current = await refresh($);
33 return judgeEdit(current, await $.session.root(), filePath);
34 } catch {
35 return { kind: 'allow' } as const;
36 }
37}
38
39export const register: Register = (on) => {
40 on('session.start', async ($, e, next) => {
41 await $.command.register({
42 name: 'sdd',
43 description: 'SDD: ciclos en curso, tareas, fixes abiertos y modo del gate',
44 });
45 await refresh($).catch(() => undefined);
46 return next(e);
47 });
48
49 on('turn.complete', async ($, e, next) => {
50 await refresh($).catch(() => undefined);
51 return next(e);
52 });
53
54 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
55 const verdict = await gate($, e.file_path);
56 if (verdict.kind === 'deny') return { deny: verdict.reason };
57 if (verdict.kind === 'warn') $.ui.toast(verdict.reason);
58 return next(e);
59 });
60
61 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
62 const verdict = await gate($, e.file_path);
63 if (verdict.kind === 'deny') return { deny: verdict.reason };
64 if (verdict.kind === 'warn') $.ui.toast(verdict.reason);
65 return next(e);
66 });
67
68 on('tool.call', { tool: 'NotebookEdit' }, async ($, e, next) => {
69 const verdict = await gate($, e.notebook_path);
70 if (verdict.kind === 'deny') return { deny: verdict.reason };
71 if (verdict.kind === 'warn') $.ui.toast(verdict.reason);
72 return next(e);
73 });
74
75 on('command.run', { command: 'sdd' }, async ($) => {
76 const current = await refresh($);
77 if (!current.settings.enabled) return { text: OFF_HINT };
78 await $.ui.open({ id: PANE, title: 'SDD' });
79 return { text: bandLine(current) ?? 'SDD' };
80 });
81
82 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
83 const current = await read($, snapshot);
84 const line = current ? bandLine(current) : null;
85 if (e.props.hasSurvey || line === null || current === null) return next(e);
86 const { Text } = $.ui.resolve(e);
87 const idle = current.cycles.length === 0 && current.fixes.length === 0;
88 const color = current.error ? 'warning' : idle ? 'suggestion' : 'success';
89 return (
90 <Text color={color} wrap="truncate-end">
91 {line}
92 </Text>
93 );
94 });
95
96 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
97 const { Box, Text } = $.ui.resolve(e);
98 const current = await read($, snapshot);
99 if (current === null || !current.settings.enabled) {
100 return <Text dimColor>{OFF_HINT}</Text>;
101 }
102 const gateText =
103 current.settings.gate === 'block'
104 ? 'Gate: bloquea ediciones de código sin ciclo ni fix abierto'
105 : 'Gate: solo avisa (claude_mod.gate = "warn")';
106 return (
107 <Box flexDirection="column">
108 <Text dimColor>{gateText}</Text>
109 {current.error && <Text color="warning">No pude leer sdd/: {current.error}</Text>}
110 {current.cycles.length === 0 && <Text dimColor>Sin ciclos in-progress.</Text>}
111 {current.cycles.map((cycle) => (
112 <Box flexDirection="column" marginTop={1}>
113 <Text bold>
114 {cycle.spec} · {cycle.cycle} · {cycle.flow} · {progress(cycle)}
115 </Text>
116 {cycle.tasks.map((task) => (
117 <Text dimColor={task.status === 'done' || task.status === 'skipped'}>
118 {task.status === 'done' ? '✓' : task.status === 'in-progress' ? '▸' : '·'}{' '}
119 {task.id} {task.title}
120 </Text>
121 ))}
122 </Box>
123 ))}
124 {current.fixes.length > 0 && (
125 <Box flexDirection="column" marginTop={1}>
126 <Text bold>Fixes abiertos</Text>
127 {current.fixes.map((fix) => (
128 <Text>
129 {fix.id} · {fix.status} · {fix.title}
130 </Text>
131 ))}
132 </Box>
133 )}
134 <Text dimColor>pnpm sdd:gate {'<spec>'} [cycle-XX] · pnpm sdd:validate</Text>
135 </Box>
136 );
137 });
138};
139hooks/.src/sdd.ts 163 lines1// Pure SDD logic of the mod: reads sdd/ through a small reader and judges edits. No import
2// from 'claude-code' at run time, so the CLI's own vitest suite exercises it as is.
3import type {
4 ActiveCycle,
5 CycleTask,
6 ModSettings,
7 OpenFix,
8 Snapshot,
9} from '../../.claude-plugin/contract';
10
11export type Reader = {
12 read: (path: string) => Promise<string>;
13 list: (path: string) => Promise<{ name: string; kind: string }[]>;
14};
15
16export type Verdict =
17 | { kind: 'allow' }
18 | { kind: 'warn'; reason: string }
19 | { kind: 'deny'; reason: string };
20
21// Kit and harness surfaces are not "code": SDD records, agent config and root markdown.
22const EXEMPT_DIRS = new Set([
23 'sdd',
24 '.claude',
25 '.github',
26 '.gemini',
27 '.agents',
28 '.agent',
29 '.vscode',
30 '.idea',
31]);
32
33const OPEN_FIX = new Set(['pending', 'in-progress']);
34
35export function readSettings(tools: unknown): ModSettings {
36 const mod = (tools as { claude_mod?: { enabled?: unknown; gate?: unknown } })
37 ?.claude_mod;
38 return {
39 enabled: mod?.enabled === true,
40 gate: mod?.gate === 'warn' ? 'warn' : 'block',
41 };
42}
43
44async function readJson(reader: Reader, path: string): Promise<any> {
45 return JSON.parse(await reader.read(path));
46}
47
48async function activeCycles(reader: Reader, root: string): Promise<ActiveCycle[]> {
49 const index = await readJson(reader, `${root}/sdd/specs/index.json`);
50 const out: ActiveCycle[] = [];
51 for (const spec of index.specs ?? []) {
52 if (spec.status === 'completed' || spec.status === 'cancelled') continue;
53 const dir = `${root}/${spec.folder}/cycles`;
54 const entries = await reader.list(dir).catch(() => []);
55 for (const entry of entries) {
56 if (!/^cycle-\d{2}$/.test(entry.name)) continue;
57 const cycle = await readJson(reader, `${dir}/${entry.name}/cycle.json`).catch(
58 () => null,
59 );
60 if (cycle?.status !== 'in-progress') continue;
61 const tasks = await readJson(reader, `${dir}/${entry.name}/tasks.json`).catch(
62 () => null,
63 );
64 out.push({
65 spec: spec.id,
66 title: spec.title ?? spec.id,
67 cycle: entry.name,
68 flow: cycle.flow ?? tasks?.flow ?? 'full',
69 tasks: (tasks?.tasks ?? []).map(
70 (t: CycleTask): CycleTask => ({ id: t.id, title: t.title, status: t.status }),
71 ),
72 });
73 }
74 }
75 return out.sort((a, b) => `${a.spec}/${a.cycle}`.localeCompare(`${b.spec}/${b.cycle}`));
76}
77
78async function openFixes(reader: Reader, root: string): Promise<OpenFix[]> {
79 const fixes = await readJson(reader, `${root}/sdd/fixes.json`).catch(() => null);
80 return (fixes?.fixes ?? [])
81 .filter((f: OpenFix) => OPEN_FIX.has(f.status))
82 .map((f: OpenFix): OpenFix => ({ id: f.id, title: f.title, status: f.status }));
83}
84
85export async function loadSnapshot(reader: Reader, root: string): Promise<Snapshot> {
86 const tools = await readJson(reader, `${root}/sdd/tools.json`).catch(() => null);
87 const settings = readSettings(tools);
88 if (!settings.enabled) return { settings, cycles: [], fixes: [] };
89 try {
90 const [cycles, fixes] = await Promise.all([
91 activeCycles(reader, root),
92 openFixes(reader, root),
93 ]);
94 return { settings, cycles, fixes };
95 } catch (error) {
96 const message = error instanceof Error ? error.message : String(error);
97 return { settings, cycles: [], fixes: [], error: message.slice(0, 120) };
98 }
99}
100
101/** Path relative to the repo root with `/` separators, or null when it lands outside it. */
102export function relativeToRoot(root: string, filePath: string): string | null {
103 const norm = (p: string) => p.replace(/\\/g, '/');
104 const base = norm(root).replace(/\/+$/, '');
105 const raw = norm(filePath);
106 const isAbsolute = raw.startsWith('/') || /^[A-Za-z]:\//.test(raw);
107 const parts: string[] = [];
108 for (const part of (isAbsolute ? raw : `${base}/${raw}`).split('/')) {
109 if (part === '' || part === '.') continue;
110 if (part === '..') parts.pop();
111 else parts.push(part);
112 }
113 const full = (raw.startsWith('/') || base.startsWith('/') ? '/' : '') + parts.join('/');
114 if (!full.startsWith(`${base}/`)) return null;
115 return full.slice(base.length + 1);
116}
117
118export function isCodePath(rel: string): boolean {
119 const [first, ...rest] = rel.split('/');
120 if (rest.length === 0) return !/\.md$/i.test(first ?? '');
121 return !EXEMPT_DIRS.has(first ?? '');
122}
123
124export function judgeEdit(snapshot: Snapshot, root: string, filePath: string): Verdict {
125 if (!snapshot.settings.enabled || snapshot.error) return { kind: 'allow' };
126 const rel = relativeToRoot(root, filePath);
127 if (rel === null || !isCodePath(rel)) return { kind: 'allow' };
128 if (snapshot.cycles.length > 0 || snapshot.fixes.length > 0) return { kind: 'allow' };
129 const reason =
130 `SPEC GATE: no hay ningún ciclo in-progress ni un fix abierto, así que todavía no se ` +
131 `escribe código (${rel}). Abrí un ciclo (pnpm sdd:gate <spec> → sdd-orchestrator) o ` +
132 `registrá el cambio con [FIX]/[BUGFIX]/[HOTFIX]. Reglas: sdd/dual-harness/rules/sdd-gates.md.`;
133 return snapshot.settings.gate === 'block'
134 ? { kind: 'deny', reason }
135 : { kind: 'warn', reason };
136}
137
138export function progress(cycle: ActiveCycle): string {
139 const counted = cycle.tasks.filter((t) => t.status !== 'skipped');
140 const done = counted.filter((t) => t.status === 'done').length;
141 return `${done}/${counted.length}`;
142}
143
144/** One line for the band above the prompt; null when there is nothing to show. */
145export function bandLine(snapshot: Snapshot): string | null {
146 if (!snapshot.settings.enabled) return null;
147 if (snapshot.error) return `SDD ▸ no pude leer sdd/ (${snapshot.error}) · gate en pausa`;
148 const parts: string[] = [];
149 const [first, ...others] = snapshot.cycles;
150 if (first) {
151 parts.push(
152 `${first.spec} · ${first.cycle} · ${first.flow} · ${progress(first)} tareas`,
153 );
154 if (others.length > 0) parts.push(`+${others.length} ciclo(s)`);
155 }
156 if (snapshot.fixes.length > 0) parts.push(`${snapshot.fixes.length} fix abierto(s)`);
157 if (parts.length === 0) {
158 const effect = snapshot.settings.gate === 'block' ? 'bloqueado' : 'con aviso';
159 return `SDD ▸ sin ciclo ni fix en curso · escribir código: ${effect} · /sdd`;
160 }
161 return `SDD ▸ ${parts.join(' · ')} · /sdd`;
162}
163.claude-plugin/contract/index.d.ts 30 lines1export type GateMode = 'block' | 'warn';
2
3export type ModSettings = { enabled: boolean; gate: GateMode };
4
5export type CycleTask = { id: string; title: string; status: string };
6
7export type ActiveCycle = {
8 spec: string;
9 title: string;
10 cycle: string;
11 flow: string;
12 tasks: CycleTask[];
13};
14
15export type OpenFix = { id: string; title: string; status: string };
16
17export type Snapshot = {
18 settings: ModSettings;
19 cycles: ActiveCycle[];
20 fixes: OpenFix[];
21 /** Set when sdd/ could not be read: the gate stays open and the band says why. */
22 error?: string;
23};
24
25declare module 'claude-code' {
26 interface PluginState {
27 'sdd-mod': { snapshot: Snapshot | null };
28 }
29}
30