zugzbot-sdd 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/AGENTS.md +212 -0
  2. package/README.md +112 -0
  3. package/ZUGZ.md +91 -0
  4. package/agents/aux-handyman.md +36 -0
  5. package/agents/aux-oracle.md +39 -0
  6. package/agents/sdd-archiver.md +33 -0
  7. package/agents/sdd-builder.md +29 -0
  8. package/agents/sdd-deployer.md +43 -0
  9. package/agents/sdd-explorer.md +49 -0
  10. package/agents/sdd-planner.md +59 -0
  11. package/agents/sdd-tester.md +51 -0
  12. package/agents/zugzbot.md +84 -0
  13. package/bin/zugzbot.js +249 -0
  14. package/bun.lock +259 -0
  15. package/commands/sdd-archiver.md +11 -0
  16. package/commands/sdd-builder.md +11 -0
  17. package/commands/sdd-deployer.md +12 -0
  18. package/commands/sdd-explorer.md +11 -0
  19. package/commands/sdd-planner.md +11 -0
  20. package/commands/sdd-tester.md +12 -0
  21. package/commands/sdd.md +11 -0
  22. package/eslint.config.js +51 -0
  23. package/opencode.json +121 -0
  24. package/package.json +46 -0
  25. package/plugin.json +10 -0
  26. package/plugins/plugin_sdd_core.ts +54 -0
  27. package/plugins/plugin_tui.tsx +318 -0
  28. package/sdd +1228 -0
  29. package/skills/sdd-dependency-cooldown/SKILL.md +40 -0
  30. package/skills/sdd-tree-generator/SKILL.md +40 -0
  31. package/skills-lock.json +35 -0
  32. package/tests/static/dom_structure.test.js +57 -0
  33. package/tests/static/tag_balance.test.js +74 -0
  34. package/tests/unit/harness_structure.test.js +65 -0
  35. package/tools/brain-utils.ts +122 -0
  36. package/tools/check_dependency_cooldown.ts +134 -0
  37. package/tools/index.ts +14 -0
  38. package/tools/sdd_archive_and_commit.ts +207 -0
  39. package/tools/sdd_bdd_tester.ts +163 -0
  40. package/tools/sdd_brain_sync.ts +160 -0
  41. package/tools/sdd_checkpoint.ts +142 -0
  42. package/tools/sdd_compact_context.ts +122 -0
  43. package/tools/sdd_generate_tree.ts +64 -0
  44. package/tools/sdd_install_autoskills.ts +100 -0
  45. package/tools/sdd_regression_detector.ts +241 -0
  46. package/tools/sdd_requirement_tracker.ts +236 -0
  47. package/tools/sdd_secret_scanner.ts +205 -0
  48. package/tools/sdd_spec_validator.ts +139 -0
  49. package/tools/sdd_transition.ts +375 -0
  50. package/tools/sdd_ui_auditor.ts +310 -0
  51. package/tsconfig.json +28 -0
  52. package/zugz-models.json +23 -0
@@ -0,0 +1,207 @@
1
+ import { tool } from "@opencode-ai/plugin"
2
+ import fs from "fs"
3
+ import path from "path"
4
+ import { execSync } from "child_process"
5
+ import sddInstallAutoskills from "./sdd_install_autoskills"
6
+ import { BrainEntry, parseEntries, today, nextId, buildFullBrain, buildIndex, buildEntryBlock, readBrainFile } from "./brain-utils.js"
7
+
8
+ function bumpVersion(version: string, type: "major" | "minor" | "patch"): string {
9
+ const parts = version.split(".").map(x => parseInt(x, 10))
10
+ if (parts.length !== 3 || parts.some(isNaN)) return version
11
+ if (type === "major") {
12
+ parts[0]++
13
+ parts[1] = 0
14
+ parts[2] = 0
15
+ } else if (type === "minor") {
16
+ parts[1]++
17
+ parts[2] = 0
18
+ } else if (type === "patch") {
19
+ parts[2]++
20
+ }
21
+ return parts.join(".")
22
+ }
23
+
24
+ function moveRecursive(src: string, dest: string) {
25
+ const stats = fs.statSync(src)
26
+ if (stats.isDirectory()) {
27
+ fs.mkdirSync(dest, { recursive: true })
28
+ fs.readdirSync(src).forEach(child => {
29
+ moveRecursive(path.join(src, child), path.join(dest, child))
30
+ })
31
+ fs.rmdirSync(src)
32
+ } else {
33
+ fs.renameSync(src, dest)
34
+ }
35
+ }
36
+
37
+ export default tool({
38
+ description: "Cierra el ciclo SDD de forma atómica: realiza el bump SemVer, inyecta lecciones en el cerebro, documenta en CHANGELOG, archiva el directorio de cambios y realiza el commit de cierre de Git.",
39
+ args: {
40
+ changeName: tool.schema.string().describe("Nombre del cambio activo (carpeta dentro de .openspec/changes/)"),
41
+ commitMessage: tool.schema.string().describe("Mensaje de commit detallado y semántico (Conventional Commit)"),
42
+ bumpType: tool.schema.enum(["patch", "minor", "major", "none"]).describe("Tipo de incremento de versión semántica"),
43
+ category: tool.schema.string().optional().describe("Categoría del aprendizaje para el cerebro"),
44
+ tag: tool.schema.string().optional().describe("Tag corto del aprendizaje para el cerebro"),
45
+ problem: tool.schema.string().optional().describe("Problema resuelto (máx 120 caracteres)"),
46
+ solution: tool.schema.string().optional().describe("Solución aplicada (máx 300 caracteres)")
47
+ },
48
+ async execute(args, context) {
49
+ const projectRoot = context.worktree || context.directory
50
+ const changeDir = path.join(projectRoot, ".openspec/changes", args.changeName)
51
+
52
+ if (!fs.existsSync(changeDir)) {
53
+ return `[SDD Archive Error] No se encontró la carpeta del cambio activo en: ${changeDir}`
54
+ }
55
+
56
+ const report: string[] = ["━━━ sdd_archive_and_commit ━━━"]
57
+
58
+ // 1. SemVer Bump en package.json
59
+ let versionStr = "1.0.0"
60
+ const pkgPath = path.join(projectRoot, "package.json")
61
+ if (fs.existsSync(pkgPath)) {
62
+ try {
63
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"))
64
+ const oldVer = pkg.version || "1.0.0"
65
+ if (args.bumpType && args.bumpType !== "none") {
66
+ versionStr = bumpVersion(oldVer, args.bumpType)
67
+ pkg.version = versionStr
68
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf-8")
69
+ report.push(`✓ Versión incrementada en package.json: ${oldVer} → ${versionStr}`)
70
+ } else {
71
+ versionStr = oldVer
72
+ report.push(`- Sin cambios en package.json (versión: ${versionStr})`)
73
+ }
74
+ } catch (e: any) {
75
+ report.push(`⚠️ Error leyendo/escribiendo package.json: ${e.message}`)
76
+ }
77
+ }
78
+
79
+ // 2. Escribir entrada en CHANGELOG.md
80
+ const changelogPath = path.join(projectRoot, ".openspec/CHANGELOG.md")
81
+ const dateStr = today()
82
+ const changelogEntry = `### [${versionStr}] - ${dateStr}\n- ${args.commitMessage.split("\n")[0]}\n`
83
+ if (fs.existsSync(changelogPath)) {
84
+ try {
85
+ let content = fs.readFileSync(changelogPath, "utf-8")
86
+ const idx = content.indexOf("## ")
87
+ if (idx !== -1) {
88
+ content = content.slice(0, idx) + `## Historial de Versiones\n\n${changelogEntry}\n` + content.slice(idx + "## Historial de Versiones\n".length)
89
+ } else {
90
+ content = content + `\n${changelogEntry}`
91
+ }
92
+ fs.writeFileSync(changelogPath, content, "utf-8")
93
+ report.push(`✓ Registrada versión en .openspec/CHANGELOG.md`)
94
+ } catch (e: any) {
95
+ report.push(`⚠️ Error escribiendo CHANGELOG.md: ${e.message}`)
96
+ }
97
+ } else {
98
+ try {
99
+ const content = `# 📝 CHANGELOG\n\n## Historial de Versiones\n\n${changelogEntry}`
100
+ fs.writeFileSync(changelogPath, content, "utf-8")
101
+ report.push(`✓ Inicializado e indexado .openspec/CHANGELOG.md`)
102
+ } catch (e: any) {
103
+ report.push(`⚠️ Error inicializando CHANGELOG.md: ${e.message}`)
104
+ }
105
+ }
106
+
107
+ // 3. Sincronizar lección técnica con brain.md si se proveen datos
108
+ if (args.category && args.tag && args.problem && args.solution) {
109
+ const brainPath = path.join(projectRoot, ".openspec/brain.md")
110
+ try {
111
+ let entries: BrainEntry[] = []
112
+ if (fs.existsSync(brainPath)) {
113
+ const brainData = readBrainFile(brainPath)
114
+ entries = brainData.entries
115
+ }
116
+
117
+ const newEntry: BrainEntry = {
118
+ id: nextId(entries),
119
+ category: args.category,
120
+ tag: args.tag,
121
+ problem: args.problem,
122
+ solution: args.solution,
123
+ date: dateStr
124
+ }
125
+ entries.push(newEntry)
126
+ fs.writeFileSync(brainPath, buildFullBrain(entries), "utf-8")
127
+ report.push(`✓ Lección ${newEntry.id} inyectada con éxito en .openspec/brain.md`)
128
+ } catch (e: any) {
129
+ report.push(`⚠️ Error sincronizando cerebro: ${e.message}`)
130
+ }
131
+ }
132
+
133
+ // 3.5. Sincronizar habilidades de IA (Autoskills) de forma automática
134
+ try {
135
+ report.push("▶ Buscando y sincronizando habilidades de IA nuevas en base a tus cambios...")
136
+ const skillsOutputObj: any = await sddInstallAutoskills.execute({ dryRun: false }, context)
137
+ const skillsOutputStr = typeof skillsOutputObj === "string" ? skillsOutputObj : (skillsOutputObj?.output || "")
138
+ const shortSkillsOutput = skillsOutputStr
139
+ .split("\n")
140
+ .filter((l: string) => l.trim() && !l.startsWith("▶") && !l.startsWith("━━━"))
141
+ .map((l: string) => ` ${l}`)
142
+ .join("\n")
143
+ report.push(`✓ Sincronización de Habilidades Finalizada:\n${shortSkillsOutput || " No se encontraron nuevas habilidades que instalar."}`)
144
+ } catch (e: any) {
145
+ report.push(`⚠️ Sincronización automática de habilidades fallida o no disponible: ${e.message || e}`)
146
+ }
147
+
148
+ // 4. Escribir commit_message.txt en location TEMPORAL antes de archivar
149
+ const tempCommitMsgPath = path.join(projectRoot, ".openspec", `commit_msg_${args.changeName}.txt`)
150
+ try {
151
+ fs.writeFileSync(tempCommitMsgPath, args.commitMessage + "\n", "utf-8")
152
+ report.push(`✓ Archivo commit_message.txt generado en location temporal`)
153
+ } catch (e: any) {
154
+ report.push(`⚠️ Error escribiendo commit_message.txt: ${e.message}`)
155
+ }
156
+
157
+ // 5. Resetear el lockfile a idle (ANTES del commit para incluirlo en el cierre)
158
+ const lockfilePath = path.join(projectRoot, ".openspec/sdd-lock.json")
159
+ if (fs.existsSync(lockfilePath)) {
160
+ try {
161
+ const lockfile = JSON.parse(fs.readFileSync(lockfilePath, "utf-8"))
162
+ lockfile.active_phase = 0
163
+ lockfile.active_subagent = "sdd-planner"
164
+ lockfile.status = "idle"
165
+ lockfile.last_updated = dateStr
166
+ fs.writeFileSync(lockfilePath, JSON.stringify(lockfile, null, 2), "utf-8")
167
+ report.push(`✓ Lockfile .openspec/sdd-lock.json restablecido a 'idle'`)
168
+ } catch (e: any) {
169
+ report.push(`⚠️ No se pudo restablecer el lockfile: ${e.message}`)
170
+ }
171
+ }
172
+
173
+ // 6. Confirmación Git Atómica (usa temp commit msg, antes de archivar)
174
+ if (fs.existsSync(path.join(projectRoot, ".git"))) {
175
+ try {
176
+ execSync("git add .", { cwd: projectRoot, stdio: "ignore" })
177
+ execSync(`git commit -F "${tempCommitMsgPath}"`, { cwd: projectRoot, stdio: "ignore" })
178
+ report.push(`✓ Commit de Git ejecutado usando el mensaje semántico`)
179
+ } catch (e: any) {
180
+ report.push(`⚠️ Git Commit falló o no había cambios pendientes de código: ${e.message}`)
181
+ }
182
+ }
183
+
184
+ // 8. Archivar la carpeta físicamente (DESPUÉS del commit)
185
+ const archiveDir = path.join(projectRoot, ".openspec/changes/archive", `${dateStr}-${args.changeName}`)
186
+ try {
187
+ if (fs.existsSync(archiveDir)) {
188
+ fs.rmSync(archiveDir, { recursive: true, force: true })
189
+ }
190
+ fs.mkdirSync(path.dirname(archiveDir), { recursive: true })
191
+ moveRecursive(changeDir, archiveDir)
192
+ report.push(`✓ Carpeta archivada en: .openspec/changes/archive/${dateStr}-${args.changeName}/`)
193
+ } catch (e: any) {
194
+ return `[SDD Archive Error] Error crítico archivando carpetas: ${e.message}`
195
+ }
196
+
197
+ // 9. Limpiar archivo temporal de commit message
198
+ try {
199
+ if (fs.existsSync(tempCommitMsgPath)) {
200
+ fs.unlinkSync(tempCommitMsgPath)
201
+ }
202
+ } catch (e: any) {}
203
+
204
+ report.push("━━━ finalizado con éxito absoluto ━━━")
205
+ return report.join("\n")
206
+ }
207
+ })
@@ -0,0 +1,163 @@
1
+ import { tool } from "@opencode-ai/plugin"
2
+ import fs from "fs"
3
+ import path from "path"
4
+ import { execSync } from "child_process"
5
+
6
+ export default tool({
7
+ description: "Traductor de Living Specs BDD: Parsea escenarios 'Given-When-Then' en spec.md y genera automáticamente esqueletos de pruebas reales en la suite del proyecto destino.",
8
+ args: {
9
+ changeName: tool.schema.string().optional().describe("Nombre del cambio en kebab-case. Por defecto se autodetectará del sdd-lock."),
10
+ runTests: tool.schema.boolean().optional().describe("Si es true, ejecuta la suite de pruebas local tras la generación.")
11
+ },
12
+ async execute(args, context) {
13
+ const projectRoot = context.worktree || context.directory;
14
+
15
+ // 1. Detectar cambio activo
16
+ let changeName = args.changeName;
17
+ if (!changeName) {
18
+ const lockfilePath = path.join(projectRoot, ".openspec/sdd-lock.json");
19
+ const altLockPath = path.join(projectRoot, "openspec/sdd-lock.json");
20
+ let activeLockPath = fs.existsSync(lockfilePath) ? lockfilePath : (fs.existsSync(altLockPath) ? altLockPath : null);
21
+ if (activeLockPath) {
22
+ try {
23
+ const lockObj = JSON.parse(fs.readFileSync(activeLockPath, "utf-8"));
24
+ if (lockObj.change_name && lockObj.change_name !== "nuevo-cambio") {
25
+ changeName = lockObj.change_name;
26
+ }
27
+ } catch (e) {}
28
+ }
29
+ }
30
+
31
+ if (!changeName || changeName === "nuevo-cambio") {
32
+ return `[BDD Tester Blocked] Error: No hay un cambio de desarrollo activo especificado o registrado en el sdd-lock.`;
33
+ }
34
+
35
+ // 2. Localizar spec.md
36
+ const specPath = path.join(projectRoot, ".openspec/changes", changeName, "specs/spec.md");
37
+ if (!fs.existsSync(specPath)) {
38
+ return `[BDD Tester Blocked] Error: No se pudo localizar el archivo de especificación en '${path.relative(projectRoot, specPath)}'.`;
39
+ }
40
+
41
+ // 3. Parsear escenarios Given-When-Then
42
+ const specContent = fs.readFileSync(specPath, "utf-8");
43
+ const scenarios: Array<{ title: string; steps: string[] }> = [];
44
+
45
+ let currentScenarioTitle = "";
46
+ let currentSteps: string[] = [];
47
+
48
+ const lines = specContent.split("\n");
49
+ lines.forEach(line => {
50
+ const trimmed = line.trim();
51
+ if (trimmed.startsWith("Scenario:") || trimmed.startsWith("Escenario:")) {
52
+ if (currentScenarioTitle) {
53
+ scenarios.push({ title: currentScenarioTitle, steps: currentSteps });
54
+ }
55
+ currentScenarioTitle = trimmed.substring(trimmed.indexOf(":") + 1).trim();
56
+ currentSteps = [];
57
+ } else if (currentScenarioTitle && (trimmed.startsWith("Given") || trimmed.startsWith("When") || trimmed.startsWith("Then") || trimmed.startsWith("And") || trimmed.startsWith("Dado") || trimmed.startsWith("Cuando") || trimmed.startsWith("Entonces") || trimmed.startsWith("Y "))) {
58
+ currentSteps.push(trimmed);
59
+ }
60
+ });
61
+
62
+ if (currentScenarioTitle) {
63
+ scenarios.push({ title: currentScenarioTitle, steps: currentSteps });
64
+ }
65
+
66
+ if (scenarios.length === 0) {
67
+ return `[BDD Tester Complete] Escaneo terminado: No se encontraron bloques estructurados 'Scenario:' o 'Given-When-Then' en spec.md.`;
68
+ }
69
+
70
+ // 4. Identificar Entorno y Autogenerar Pruebas
71
+ let testLanguage = "js"; // Default
72
+ let testFilePath = "";
73
+ let generatedCode = "";
74
+
75
+ if (fs.existsSync(path.join(projectRoot, "package.json"))) {
76
+ // Node/JS/TS Stack
77
+ const tsconfig = fs.existsSync(path.join(projectRoot, "tsconfig.json"));
78
+ testLanguage = tsconfig ? "ts" : "js";
79
+
80
+ const testsDir = path.join(projectRoot, "tests");
81
+ if (!fs.existsSync(testsDir)) {
82
+ fs.mkdirSync(testsDir, { recursive: true });
83
+ }
84
+
85
+ testFilePath = path.join(testsDir, `sdd_${changeName.replace(/-/g, "_")}.test.${testLanguage}`);
86
+
87
+ // Detectar framework de pruebas en package.json de forma inteligente
88
+ let testFrameworkImport = "";
89
+ try {
90
+ const pkgContent = fs.readFileSync(path.join(projectRoot, "package.json"), "utf-8");
91
+ if (pkgContent.includes('"vitest"')) {
92
+ testFrameworkImport = "import { describe, it } from 'vitest';\n";
93
+ }
94
+ } catch (e) {}
95
+
96
+ generatedCode = `// ==============================================================================
97
+ // BDD LIVING SPECIFICATION AUTO-GENERATED TEST SUITE
98
+ // Generated for change: ${changeName}
99
+ // ==============================================================================
100
+ ${testFrameworkImport}
101
+ describe('SDD Living Spec: ${changeName}', () => {
102
+ ${scenarios.map(s => ` it('Escenario: ${s.title.replace(/'/g, "\\'")}', () => {
103
+ // BDD Scenario Specification Steps:
104
+ ${s.steps.map(step => ` // - ${step}`).join("\n")}
105
+
106
+ // TODO: Implementar validación funcional de código real aquí
107
+ });`).join("\n\n")}
108
+ });
109
+ `;
110
+ } else if (fs.existsSync(path.join(projectRoot, "requirements.txt")) || fs.existsSync(path.join(projectRoot, "pyproject.toml"))) {
111
+ // Python Stack
112
+ testLanguage = "py";
113
+ const testsDir = path.join(projectRoot, "tests");
114
+ if (!fs.existsSync(testsDir)) {
115
+ fs.mkdirSync(testsDir, { recursive: true });
116
+ }
117
+ testFilePath = path.join(testsDir, `test_sdd_${changeName.replace(/-/g, "_")}.py`);
118
+
119
+ generatedCode = `"""
120
+ BDD LIVING SPECIFICATION AUTO-GENERATED TEST SUITE
121
+ Generated for change: ${changeName}
122
+ """
123
+ import unittest
124
+
125
+ class TestSdd${changeName.replace(/-/g, "").toUpperCase()}(unittest.TestCase):
126
+ ${scenarios.map((s, idx) => ` def test_scenario_${idx + 1}(self):
127
+ """Escenario: ${s.title}"""
128
+ # BDD Steps:
129
+ ${s.steps.map(step => ` # - ${step}`).join("\n")}
130
+
131
+ # TODO: Implementar validación funcional de código real aquí
132
+ pass`).join("\n\n")}
133
+
134
+ if __name__ == '__main__':
135
+ unittest.main()
136
+ `;
137
+ } else {
138
+ return `[BDD Tester Blocked] Error: No se pudo determinar el stack del proyecto (falta package.json o requirements.txt).`;
139
+ }
140
+
141
+ fs.writeFileSync(testFilePath, generatedCode, "utf-8");
142
+
143
+ let testExecutionOutput = "No ejecutada (runTests = false)";
144
+ if (args.runTests) {
145
+ try {
146
+ if (testLanguage === "ts" || testLanguage === "js") {
147
+ const pkgContent = fs.readFileSync(path.join(projectRoot, "package.json"), "utf-8");
148
+ if (pkgContent.includes('"vitest"')) {
149
+ testExecutionOutput = execSync(`npx vitest run ${testFilePath}`, { encoding: "utf-8" });
150
+ } else {
151
+ testExecutionOutput = execSync(`npx jest ${testFilePath}`, { encoding: "utf-8" });
152
+ }
153
+ } else if (testLanguage === "py") {
154
+ testExecutionOutput = execSync(`python3 -m unittest ${testFilePath}`, { encoding: "utf-8" });
155
+ }
156
+ } catch (e: any) {
157
+ testExecutionOutput = `Fallo en ejecución de pruebas: ${e.stdout || e.message || e}`;
158
+ }
159
+ }
160
+
161
+ return `[BDD Tester Complete] Esqueleto de pruebas BDD autogenerado en: ${path.relative(projectRoot, testFilePath)}. Escenarios detectados: ${scenarios.length}.\n\nSalida de Pruebas:\n${testExecutionOutput}`;
162
+ }
163
+ })
@@ -0,0 +1,160 @@
1
+ import { tool } from "@opencode-ai/plugin"
2
+ import fs from "fs"
3
+ import path from "path"
4
+ import { BrainEntry, parseEntries, buildFullBrain, readBrainFile, writeBrainFile, nextId, today } from "./brain-utils.js"
5
+
6
+ const BRAIN_FILE = ".openspec/brain.md"
7
+ const BRAIN_SUBDIR = ".openspec/brain"
8
+ const MAX_ENTRIES = 40
9
+
10
+ export default tool({
11
+ description: `Gestiona el cerebro del proyecto (brain.md) con índice indexado, IDs auto-secuenciales y sharding automático.
12
+ Cualquier agente (incluyendo al Orquestador Zugzbot y todos los subagentes) puede activar esta herramienta en cualquier fase para guardar aprendizajes técnicos valiosos.
13
+
14
+ Acciones:
15
+ - "init": Crea el archivo brain.md si no existe.
16
+ - "add": Agrega una nueva lección técnica con formato estandarizado.
17
+ - "list": Lista todas las entradas del cerebro.
18
+ - "remove": Elimina una entrada por su ID (ej: L001).
19
+ - "rebuild-index": Reconstruye el índice desde las entradas existentes.
20
+ - "shard": Fragmenta en archivos por dominio (.openspec/brain/{category}.md).
21
+
22
+ USO OBLIGATORIO: Se prohibe la edición directa de brain.md con write/edit. Usar siempre esta herramienta para interactuar con el cerebro.`,
23
+ args: {
24
+ action: tool.schema.enum(["init", "add", "list", "remove", "rebuild-index", "shard"])
25
+ .describe("Acción a ejecutar"),
26
+ category: tool.schema.string().optional()
27
+ .describe("Categoría/dominio: frontend, backend, devops, architecture, css, tooling, testing"),
28
+ tag: tool.schema.string().optional()
29
+ .describe("Tag corto sin espacios (ej: tailwind-grid-fix, prisma-timezone-workaround)"),
30
+ problem: tool.schema.string().optional()
31
+ .describe("Descripción concisa del problema o bug (máx 120 caracteres)"),
32
+ solution: tool.schema.string().optional()
33
+ .describe("Descripción concisa de la solución (máx 300 caracteres)"),
34
+ entryId: tool.schema.string().optional()
35
+ .describe("ID de entrada a eliminar (ej: L001)")
36
+ },
37
+ async execute(args, context) {
38
+ const projectRoot = context.worktree || context.directory
39
+ const brainPath = path.join(projectRoot, BRAIN_FILE)
40
+
41
+ switch (args.action) {
42
+ case "init": {
43
+ if (fs.existsSync(brainPath)) {
44
+ const { entries } = readBrainFile(brainPath)
45
+ return `[Brain Sync] ✅ brain.md ya existe (${entries.length} entradas).`
46
+ }
47
+ writeBrainFile(brainPath, [])
48
+ return `[Brain Sync] ✅ brain.md creado en .openspec/brain.md`
49
+ }
50
+
51
+ case "add": {
52
+ if (!args.category || !args.tag || !args.problem || !args.solution) {
53
+ return `[Brain Sync] ❌ Error: add requiere category, tag, problem, solution`
54
+ }
55
+ if (args.problem.length > 120) {
56
+ return `[Brain Sync] ❌ Error: problem excede 120 caracteres (tiene ${args.problem.length})`
57
+ }
58
+ if (args.solution.length > 300) {
59
+ return `[Brain Sync] ❌ Error: solution excede 300 caracteres (tiene ${args.solution.length})`
60
+ }
61
+
62
+ const { entries } = readBrainFile(brainPath)
63
+
64
+ const dup = entries.find(e => e.tag === args.tag && e.problem === args.problem)
65
+ if (dup) {
66
+ return `[Brain Sync] ⚠️ Entrada duplicada: ${dup.id}:${dup.tag}. Se omite inserción.`
67
+ }
68
+
69
+ const newEntry: BrainEntry = {
70
+ id: nextId(entries),
71
+ category: args.category,
72
+ tag: args.tag,
73
+ problem: args.problem,
74
+ solution: args.solution,
75
+ date: today(),
76
+ }
77
+ entries.push(newEntry)
78
+ writeBrainFile(brainPath, entries)
79
+
80
+ if (entries.length > MAX_ENTRIES) {
81
+ return `[Brain Sync] ✅ ${newEntry.id} añadida. ⚠️ Cerebro tiene ${entries.length} entradas. Ejecuta 'shard' para fragmentar.`
82
+ }
83
+
84
+ return `[Brain Sync] ✅ ${newEntry.id} añadida [${args.category}/${args.tag}]. Total: ${entries.length} entradas.`
85
+ }
86
+
87
+ case "list": {
88
+ const { entries } = readBrainFile(brainPath)
89
+ if (entries.length === 0) {
90
+ return `[Brain Sync] 📭 No hay entradas en el cerebro.`
91
+ }
92
+ return `[Brain Sync] 📋 ${entries.length} entradas:\n` +
93
+ entries.map(e =>
94
+ ` ${e.id} | ${(e.category || "-").padEnd(10)} | ${e.tag.padEnd(22)} | ${e.problem.slice(0, 50)}`
95
+ ).join("\n")
96
+ }
97
+
98
+ case "remove": {
99
+ if (!args.entryId) {
100
+ return `[Brain Sync] ❌ Error: remove requiere entryId`
101
+ }
102
+ const { entries } = readBrainFile(brainPath)
103
+ const idx = entries.findIndex(e => e.id === args.entryId)
104
+ if (idx === -1) {
105
+ return `[Brain Sync] ❌ No se encontró entrada ${args.entryId}`
106
+ }
107
+ const removed = entries.splice(idx, 1)[0]
108
+ writeBrainFile(brainPath, entries)
109
+ return `[Brain Sync] ✅ ${removed.id}:${removed.tag} eliminada. Quedan ${entries.length}.`
110
+ }
111
+
112
+ case "rebuild-index": {
113
+ const { entries } = readBrainFile(brainPath)
114
+ writeBrainFile(brainPath, entries)
115
+ return `[Brain Sync] ✅ Índice reconstruido. ${entries.length} entradas indexadas.`
116
+ }
117
+
118
+ case "shard": {
119
+ const { entries } = readBrainFile(brainPath)
120
+ if (entries.length === 0) {
121
+ return `[Brain Sync] ⚠️ No hay entradas para fragmentar.`
122
+ }
123
+
124
+ const byCat = new Map<string, BrainEntry[]>()
125
+ for (const e of entries) {
126
+ const cat = e.category || "general"
127
+ if (!byCat.has(cat)) byCat.set(cat, [])
128
+ byCat.get(cat)!.push(e)
129
+ }
130
+
131
+ const brainSubdir = path.join(projectRoot, BRAIN_SUBDIR)
132
+ fs.mkdirSync(brainSubdir, { recursive: true })
133
+ for (const [cat, catEntries] of byCat) {
134
+ writeBrainFile(path.join(brainSubdir, `${cat}.md`), catEntries)
135
+ }
136
+
137
+ const masterLines = [
138
+ "# 🧠 Cerebro del Proyecto (Fragmentado)",
139
+ "",
140
+ "> Fragmentado por dominio para optimizar tokens de contexto.",
141
+ "",
142
+ "## Índice de Dominios",
143
+ "",
144
+ "| Dominio | Entradas | Archivo |",
145
+ "| :--- | :--- | :--- |",
146
+ ]
147
+ for (const [cat, catEntries] of byCat) {
148
+ masterLines.push(`| ${cat} | ${catEntries.length} | \`brain/${cat}.md\` |`)
149
+ }
150
+ masterLines.push("", "---", "", "Usa `sdd_brain_sync add` con `category` para dirigir al shard correcto.")
151
+ fs.writeFileSync(brainPath, masterLines.join("\n"), "utf-8")
152
+
153
+ return `[Brain Sync] ✅ Fragmentado en ${byCat.size} shards (${entries.length} entradas).`
154
+ }
155
+
156
+ default:
157
+ return `[Brain Sync] ❌ Acción '${args.action}' no válida.`
158
+ }
159
+ }
160
+ })
@@ -0,0 +1,142 @@
1
+ import { tool } from "@opencode-ai/plugin"
2
+ import fs from "fs"
3
+ import path from "path"
4
+
5
+ interface Checkpoint {
6
+ id: number
7
+ timestamp: string
8
+ phase: number
9
+ active_subagent: string
10
+ change_name: string
11
+ status: string
12
+ iteration: number
13
+ lock_snapshot: any
14
+ }
15
+
16
+ export default tool({
17
+ description: "Guarda checkpoint del estado actual del ciclo SDD antes de transiciones, o restaura desde un checkpoint anterior. Permite repetir o retroceder fases sin perder contexto.",
18
+ args: {
19
+ action: tool.schema.enum(["save", "restore", "list", "clear"]).describe("Accion a realizar: save (guardar checkpoint), restore (restaurar desde checkpoint), list (ver checkpoints guardados), clear (limpiar checkpoints antiguos)"),
20
+ phase: tool.schema.number().optional().describe("Fase desde la cual guardar checkpoint o a la cual restaurar"),
21
+ checkpointId: tool.schema.number().optional().describe("ID especifico del checkpoint a restaurar (para action=restore)")
22
+ },
23
+ async execute(args, context) {
24
+ const projectRoot = context.worktree || context.directory
25
+ const lockfilePath = path.join(projectRoot, ".openspec/sdd-lock.json")
26
+
27
+ if (!fs.existsSync(lockfilePath)) {
28
+ return "[SDD Checkpoint] ERROR: No existe sdd-lock.json. No se puede crear checkpoint."
29
+ }
30
+
31
+ let lockfile: any = {}
32
+ try {
33
+ lockfile = JSON.parse(fs.readFileSync(lockfilePath, "utf-8"))
34
+ } catch (e) {
35
+ return "[SDD Checkpoint] ERROR: No se pudo leer el lockfile."
36
+ }
37
+
38
+ const checkpointsDir = path.join(projectRoot, ".openspec/checkpoints")
39
+ if (!fs.existsSync(checkpointsDir)) {
40
+ fs.mkdirSync(checkpointsDir, { recursive: true })
41
+ }
42
+
43
+ if (args.action === "save") {
44
+ const checkpoint: Checkpoint = {
45
+ id: Date.now(),
46
+ timestamp: new Date().toISOString(),
47
+ phase: lockfile.active_phase || 0,
48
+ active_subagent: lockfile.active_subagent || "sdd-planner",
49
+ change_name: lockfile.change_name || "nuevo-cambio",
50
+ status: lockfile.status || "idle",
51
+ iteration: lockfile.iteration || 0,
52
+ lock_snapshot: { ...lockfile }
53
+ }
54
+
55
+ const historyPath = path.join(checkpointsDir, "checkpoint_history.jsonl")
56
+ fs.appendFileSync(historyPath, JSON.stringify(checkpoint) + "\n", "utf-8")
57
+
58
+ const lockWithCheckpoint = { ...lockfile, last_checkpoint_id: checkpoint.id }
59
+ fs.writeFileSync(lockfilePath, JSON.stringify(lockWithCheckpoint, null, 2), "utf-8")
60
+
61
+ return `[SDD Checkpoint] Guardado checkpoint #${checkpoint.id} para fase ${checkpoint.phase} (${checkpoint.active_subagent}). Timestamp: ${checkpoint.timestamp}`
62
+ }
63
+
64
+ if (args.action === "list") {
65
+ const historyPath = path.join(checkpointsDir, "checkpoint_history.jsonl")
66
+ if (!fs.existsSync(historyPath)) {
67
+ return "[SDD Checkpoint] No hay checkpoints guardados."
68
+ }
69
+
70
+ const lines = fs.readFileSync(historyPath, "utf-8").split("\n").filter(l => l.trim())
71
+ const checkpoints: Checkpoint[] = lines.map(line => {
72
+ try { return JSON.parse(line) } catch { return null }
73
+ }).filter(Boolean)
74
+
75
+ if (checkpoints.length === 0) {
76
+ return "[SDD Checkpoint] No hay checkpoints guardados."
77
+ }
78
+
79
+ const list = checkpoints.slice(-10).reverse().map((cp: Checkpoint) =>
80
+ ` [${cp.id}] Fase ${cp.phase} (${cp.active_subagent}) - ${cp.change_name} @ ${cp.timestamp}`
81
+ ).join("\n")
82
+
83
+ return `[SDD Checkpoint] Ultimos ${checkpoints.length} checkpoints:\n${list}`
84
+ }
85
+
86
+ if (args.action === "restore") {
87
+ const historyPath = path.join(checkpointsDir, "checkpoint_history.jsonl")
88
+ if (!fs.existsSync(historyPath)) {
89
+ return "[SDD Checkpoint] ERROR: No hay checkpoints para restaurar."
90
+ }
91
+
92
+ const lines = fs.readFileSync(historyPath, "utf-8").split("\n").filter(l => l.trim())
93
+ const checkpoints: Checkpoint[] = lines.map(line => {
94
+ try { return JSON.parse(line) } catch { return null }
95
+ }).filter(Boolean)
96
+
97
+ let targetCheckpoint: Checkpoint | null = null
98
+
99
+ if (args.checkpointId !== undefined) {
100
+ targetCheckpoint = checkpoints.find(cp => cp.id === args.checkpointId) || null
101
+ } else if (args.phase !== undefined) {
102
+ const candidates = checkpoints.filter(cp => cp.phase === args.phase)
103
+ targetCheckpoint = candidates[candidates.length - 1] || null
104
+ } else {
105
+ targetCheckpoint = checkpoints[checkpoints.length - 1] || null
106
+ }
107
+
108
+ if (!targetCheckpoint) {
109
+ return `[SDD Checkpoint] ERROR: No se encontro checkpoint para los parametros dados.`
110
+ }
111
+
112
+ const restoredLock = {
113
+ ...targetCheckpoint.lock_snapshot,
114
+ active_phase: args.phase ?? targetCheckpoint.phase,
115
+ status: "restored_from_checkpoint",
116
+ last_restored_from: targetCheckpoint.id
117
+ }
118
+
119
+ fs.writeFileSync(lockfilePath, JSON.stringify(restoredLock, null, 2), "utf-8")
120
+
121
+ return `[SDD Checkpoint] Restaurado checkpoint #${targetCheckpoint.id} (Fase ${targetCheckpoint.phase}). Estado del lockfile restaurado.`
122
+ }
123
+
124
+ if (args.action === "clear") {
125
+ const historyPath = path.join(checkpointsDir, "checkpoint_history.jsonl")
126
+ if (fs.existsSync(historyPath)) {
127
+ const lines = fs.readFileSync(historyPath, "utf-8").split("\n").filter(l => l.trim())
128
+ const checkpoints: Checkpoint[] = lines.map(line => {
129
+ try { return JSON.parse(line) } catch { return null }
130
+ }).filter(Boolean)
131
+
132
+ const lastPhase = checkpoints.length > 0 ? checkpoints[checkpoints.length - 1].phase : 0
133
+ const recentCheckpoints = checkpoints.filter(cp => cp.phase >= lastPhase - 1)
134
+
135
+ fs.writeFileSync(historyPath, recentCheckpoints.map(cp => JSON.stringify(cp)).join("\n") + "\n", "utf-8")
136
+ }
137
+ return "[SDD Checkpoint] Checkpoints antiguos limpiados. Se conservaron los 2 mas recientes por fase."
138
+ }
139
+
140
+ return "[SDD Checkpoint] Accion no reconocida. Use: save, restore, list, o clear."
141
+ }
142
+ })