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,236 @@
1
+ import { tool } from "@opencode-ai/plugin"
2
+ import fs from "fs"
3
+ import path from "path"
4
+
5
+ export default tool({
6
+ description: "Rastreador Semántico de Requerimientos: Compara los criterios de aceptación en spec.md con las aserciones y títulos de prueba en tests/ para asegurar una cobertura del 100%.",
7
+ args: {
8
+ changeName: tool.schema.string().optional().describe("Nombre del cambio en kebab-case. Por defecto se autodetectará del sdd-lock.")
9
+ },
10
+ async execute(args, context) {
11
+ const projectRoot = context.worktree || context.directory;
12
+ let changeName = args.changeName;
13
+ let isManualQa = false;
14
+
15
+ // 1. Detectar cambio activo y verificar si hay QA manual configurado en el lockfile
16
+ const lockfilePath = path.join(projectRoot, ".openspec/sdd-lock.json");
17
+ const altLockPath = path.join(projectRoot, "openspec/sdd-lock.json");
18
+ const activeLockPath = fs.existsSync(lockfilePath) ? lockfilePath : (fs.existsSync(altLockPath) ? altLockPath : null);
19
+ if (activeLockPath) {
20
+ try {
21
+ const lockObj = JSON.parse(fs.readFileSync(activeLockPath, "utf-8"));
22
+ if (!changeName && lockObj.change_name && lockObj.change_name !== "nuevo-cambio") {
23
+ changeName = lockObj.change_name;
24
+ }
25
+ if (lockObj.qa_manual === true || lockObj.manual_qa === true) {
26
+ isManualQa = true;
27
+ }
28
+ } catch (e) {}
29
+ }
30
+
31
+ // Verificar si hay QA manual configurado en opencode.json
32
+ const opencodeJsonPath = path.join(projectRoot, "opencode.json");
33
+ if (fs.existsSync(opencodeJsonPath)) {
34
+ try {
35
+ const config = JSON.parse(fs.readFileSync(opencodeJsonPath, "utf-8"));
36
+ if (config.sdd?.qa_manual === true || config.sdd?.manual_qa === true) {
37
+ isManualQa = true;
38
+ }
39
+ } catch (e) {}
40
+ }
41
+
42
+ if (!changeName || changeName === "nuevo-cambio") {
43
+ return JSON.stringify({
44
+ status: "FAILED",
45
+ reason: "No se pudo resolver el nombre del cambio activo."
46
+ }, null, 2);
47
+ }
48
+
49
+ // 2. Leer spec.md
50
+ const specPath = path.join(projectRoot, ".openspec/changes", changeName, "specs/spec.md");
51
+ if (!fs.existsSync(specPath)) {
52
+ return JSON.stringify({
53
+ status: "FAILED",
54
+ reason: `No se encuentra spec.md en ${specPath}`
55
+ }, null, 2);
56
+ }
57
+
58
+ const specContent = fs.readFileSync(specPath, "utf-8");
59
+
60
+ // Autodetección de modo QA Manual explícito en spec.md
61
+ if (
62
+ specContent.includes("[QA Manual]") ||
63
+ specContent.includes("QA Mode: Manual") ||
64
+ specContent.includes("QA: Manual") ||
65
+ specContent.toLowerCase().includes("qa_manual: true")
66
+ ) {
67
+ isManualQa = true;
68
+ }
69
+
70
+ // 3. Extraer criterios de aceptación
71
+ const criteria: string[] = [];
72
+ let inCriteriaSection = false;
73
+
74
+ specContent.split("\n").forEach(line => {
75
+ const trimmed = line.trim();
76
+ if (trimmed.startsWith("## 5. Criterios")) {
77
+ inCriteriaSection = true;
78
+ return;
79
+ }
80
+ if (inCriteriaSection && trimmed.startsWith("##")) {
81
+ inCriteriaSection = false;
82
+ }
83
+
84
+ if (inCriteriaSection && (trimmed.startsWith("- [ ]") || trimmed.startsWith("- [x]") || trimmed.startsWith("- [X]"))) {
85
+ // Extraer texto del criterio sin el prefijo del checkbox
86
+ const text = trimmed.substring(5).trim();
87
+ if (text) criteria.push(text);
88
+ }
89
+ });
90
+
91
+ if (criteria.length === 0) {
92
+ return JSON.stringify({
93
+ status: "WARNING",
94
+ criteriaCount: 0,
95
+ message: "⚠️ RASTREADOR SEMÁNTICO: No se encontraron criterios de aceptación con casillas '- [ ]' en la sección '## 5. Criterios de Aceptación' de tu spec.md."
96
+ }, null, 2);
97
+ }
98
+
99
+ if (isManualQa) {
100
+ return JSON.stringify({
101
+ status: "APPROVED",
102
+ criteriaCount: criteria.length,
103
+ message: `✅ VALIDACIÓN MANUAL (QA MANUAL) ACTIVA: Conforme al reglamento de AGENTS.md y la configuración del proyecto, se aprueba la transición sin requerir cobertura de pruebas automatizadas. Todos los ${criteria.length} criterios de aceptación deben validarse empíricamente en el entorno en caliente.`
104
+ }, null, 2);
105
+ }
106
+
107
+ // 4. Ubicar y leer archivos de prueba de forma recursiva y distribuida
108
+ const testFiles: string[] = [];
109
+ const excludeDirs = [
110
+ "node_modules", ".git", ".openspec", ".opencode", "dist",
111
+ "build", ".next", "coverage"
112
+ ];
113
+
114
+ function findTestFilesRecursive(dir: string) {
115
+ if (!fs.existsSync(dir)) return;
116
+ let entries: string[] = [];
117
+ try {
118
+ entries = fs.readdirSync(dir);
119
+ } catch (e) {
120
+ return;
121
+ }
122
+
123
+ entries.forEach(entry => {
124
+ const fullPath = path.join(dir, entry);
125
+ let stat;
126
+ try {
127
+ stat = fs.statSync(fullPath);
128
+ } catch (e) {
129
+ return;
130
+ }
131
+
132
+ if (stat.isDirectory()) {
133
+ if (!excludeDirs.includes(entry)) {
134
+ findTestFilesRecursive(fullPath);
135
+ }
136
+ } else {
137
+ const lowerFile = entry.toLowerCase();
138
+ const ext = path.extname(entry).toLowerCase();
139
+ const isTestFile =
140
+ lowerFile.includes(".test.") ||
141
+ lowerFile.includes(".spec.") ||
142
+ lowerFile.startsWith("test_") ||
143
+ lowerFile.endsWith("_test.go") ||
144
+ (dir.split(path.sep).some(p => p === "tests" || p === "test") && [".ts", ".tsx", ".js", ".jsx", ".py", ".go", ".rs"].includes(ext));
145
+
146
+ if (isTestFile) {
147
+ testFiles.push(fullPath);
148
+ }
149
+ }
150
+ });
151
+ }
152
+
153
+ findTestFilesRecursive(projectRoot);
154
+
155
+ if (testFiles.length === 0) {
156
+ return JSON.stringify({
157
+ status: "APPROVED",
158
+ criteriaCount: criteria.length,
159
+ message: `⚠️ ADVERTENCIA SEMÁNTICA (FALLBACK MANUAL): No se detectaron archivos de prueba distribuidos en el repositorio. Consecuentemente, se asume un flujo de VALIDACIÓN MANUAL (QA Manual) de forma automática para evitar bloquear el ciclo SDD. Por favor, asegúrate de verificar los ${criteria.length} criterios de aceptación manualmente en el entorno en caliente.`
160
+ }, null, 2);
161
+ }
162
+
163
+ // 5. Comparar semánticamente los criterios con los tests
164
+ interface CriterionAudit {
165
+ criterio: string;
166
+ covered: boolean;
167
+ matchedInFile?: string;
168
+ matchingSnippet?: string;
169
+ }
170
+
171
+ const auditResults: CriterionAudit[] = [];
172
+
173
+ criteria.forEach(crit => {
174
+ // Extraer palabras clave de más de 3 letras ignorando conectores
175
+ const keywords = crit
176
+ .toLowerCase()
177
+ .replace(/[^a-záéíóúñü0-9\s]/g, "")
178
+ .split(/\s+/)
179
+ .filter(word => word.length > 3 && !["debe", "para", "como", "esta", "este", "consecuente"].includes(word));
180
+
181
+ let matched = false;
182
+ let matchedFile = "";
183
+ let matchedSnippet = "";
184
+
185
+ for (const testFile of testFiles) {
186
+ try {
187
+ const testContent = fs.readFileSync(testFile, "utf-8");
188
+ const testLines = testContent.split("\n");
189
+
190
+ // Búsqueda aproximada: comprobar si el test contiene palabras clave del criterio
191
+ for (let i = 0; i < testLines.length; i++) {
192
+ const line = testLines[i].toLowerCase();
193
+ // Si coincide con más del 60% de las palabras clave de un criterio en la misma línea
194
+ const matchCount = keywords.filter(keyword => line.includes(keyword)).length;
195
+ const threshold = Math.max(1, Math.floor(keywords.length * 0.5));
196
+
197
+ if (matchCount >= threshold && threshold > 0) {
198
+ matched = true;
199
+ matchedFile = path.basename(testFile);
200
+ matchedSnippet = `Línea ${i + 1}: ${testLines[i].trim()}`;
201
+ break;
202
+ }
203
+ }
204
+
205
+ if (matched) break;
206
+ } catch (e) {}
207
+ }
208
+
209
+ auditResults.push({
210
+ criterio: crit,
211
+ covered: matched,
212
+ matchedInFile: matchedFile || undefined,
213
+ matchingSnippet: matchedSnippet || undefined
214
+ });
215
+ });
216
+
217
+ const uncoveredCount = auditResults.filter(r => !r.covered).length;
218
+
219
+ if (uncoveredCount > 0) {
220
+ return JSON.stringify({
221
+ status: "FAILED",
222
+ criteriaCount: criteria.length,
223
+ uncoveredCount,
224
+ results: auditResults,
225
+ message: `❌ AUDITORÍA SEMÁNTICA FALLIDA: Se detectaron ${uncoveredCount} criterios de aceptación sin cobertura de pruebas en la suite de pruebas detectada:\n\n${auditResults.map(r => r.covered ? ` - [✓] "${r.criterio}" (Cubierto por ${r.matchedInFile})` : ` - [ ] "${r.criterio}" (⚠️ ¡SIN PRUEBA DE COBERTURA EN LA SUITE!)`).join("\n")}\n\nPor favor, pide a @sdd-builder que añada casos de prueba para cubrir estos criterios de QA.`
226
+ }, null, 2);
227
+ }
228
+
229
+ return JSON.stringify({
230
+ status: "APPROVED",
231
+ criteriaCount: criteria.length,
232
+ results: auditResults,
233
+ message: `✅ AUDITORÍA SEMÁNTICA EXITOSA: Todos los ${criteria.length} criterios de aceptación definidos en spec.md tienen cobertura de aserción correspondiente en la suite de pruebas.\n\n${auditResults.map(r => ` - [✓] "${r.criterio}" (Validado en ${r.matchedInFile})`).join("\n")}`
234
+ }, null, 2);
235
+ }
236
+ })
@@ -0,0 +1,205 @@
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
+ function decodeGitPath(gitPath: string): string {
7
+ let cleaned = gitPath.replace(/^"|"$/g, "");
8
+
9
+ if (cleaned.includes("\\")) {
10
+ try {
11
+ const bytes: number[] = []
12
+ let i = 0
13
+ while (i < cleaned.length) {
14
+ if (cleaned[i] === "\\" && i + 3 < cleaned.length && /^[0-7]{3}$/.test(cleaned.substring(i + 1, i + 4))) {
15
+ const octalVal = cleaned.substring(i + 1, i + 4)
16
+ bytes.push(parseInt(octalVal, 8))
17
+ i += 4
18
+ } else {
19
+ if (cleaned[i] === "\\" && i + 1 < cleaned.length) {
20
+ const next = cleaned[i + 1]
21
+ if (next === "n") { bytes.push(10); i += 2 }
22
+ else if (next === "t") { bytes.push(9); i += 2 }
23
+ else if (next === "\\") { bytes.push(92); i += 2 }
24
+ else if (next === "\"") { bytes.push(34); i += 2 }
25
+ else { bytes.push(cleaned.charCodeAt(i)); i++ }
26
+ } else {
27
+ const code = cleaned.charCodeAt(i)
28
+ if (code < 128) {
29
+ bytes.push(code)
30
+ } else {
31
+ const buf = Buffer.from(cleaned[i], "utf-8")
32
+ for (let b = 0; b < buf.length; b++) {
33
+ bytes.push(buf[b])
34
+ }
35
+ }
36
+ i++
37
+ }
38
+ }
39
+ }
40
+ return Buffer.from(bytes).toString("utf-8")
41
+ } catch (e) {
42
+ return cleaned.replace(/\\([0-7]{3})/g, (match, octal) => {
43
+ return String.fromCharCode(parseInt(octal, 8))
44
+ })
45
+ }
46
+ }
47
+ return cleaned
48
+ }
49
+
50
+ function sanitizeGitPath(line: string): string {
51
+ const content = line.substring(3).trim()
52
+ if (content.includes(" -> ")) {
53
+ const parts = content.split(" -> ")
54
+ return decodeGitPath(parts[1])
55
+ }
56
+ return decodeGitPath(content)
57
+ }
58
+
59
+ export default tool({
60
+ description: "Escanea archivos modificados o listados en Git en busca de posibles fugas de secretos (tokens, llaves privadas, contraseñas en caliente) antes del commit de cierre de la Fase 3.",
61
+ args: {
62
+ scanAll: tool.schema.boolean().optional().default(false).describe("Si es true, escanea todos los archivos de código del proyecto; por defecto solo escanea archivos modificados en Git.")
63
+ },
64
+ async execute(args, context) {
65
+ const projectRoot = context.worktree || context.directory;
66
+ const findings: Array<{ file: string; line: number; type: string; snippet: string }> = [];
67
+
68
+ // 1. Obtener la lista de archivos a escanear
69
+ let filesToScan: string[] = [];
70
+
71
+ if (args.scanAll) {
72
+ // Escaneo recursivo simple excluyendo carpetas prohibidas
73
+ function scanDirRecursive(dir: string) {
74
+ if (!fs.existsSync(dir)) return;
75
+ const list = fs.readdirSync(dir);
76
+ list.forEach(file => {
77
+ const fullPath = path.join(dir, file);
78
+ let stat;
79
+ try {
80
+ stat = fs.statSync(fullPath);
81
+ } catch (e) {
82
+ return;
83
+ }
84
+ if (stat.isDirectory()) {
85
+ if (!["node_modules", ".git", ".openspec", ".opencode", "dist", "build", ".next", "coverage"].includes(file)) {
86
+ scanDirRecursive(fullPath);
87
+ }
88
+ } else {
89
+ const ext = path.extname(file).toLowerCase();
90
+ if ([".ts", ".tsx", ".js", ".jsx", ".py", ".go", ".rs", ".json", ".env"].includes(ext)) {
91
+ filesToScan.push(fullPath);
92
+ }
93
+ }
94
+ });
95
+ }
96
+ scanDirRecursive(projectRoot);
97
+ } else {
98
+ // Buscar solo archivos modificados o sin trackear usando Git de forma robusta
99
+ if (fs.existsSync(path.join(projectRoot, ".git"))) {
100
+ try {
101
+ const gitOutput = execSync("git status --porcelain", { cwd: projectRoot, encoding: "utf-8" });
102
+ gitOutput.split("\n").forEach(line => {
103
+ if (!line || line.length < 4) return;
104
+ const filePathRel = sanitizeGitPath(line);
105
+ const fullPath = path.join(projectRoot, filePathRel);
106
+ if (fs.existsSync(fullPath)) {
107
+ let stat;
108
+ try { stat = fs.statSync(fullPath) } catch (e) { return }
109
+ if (stat.isFile()) {
110
+ const ext = path.extname(fullPath).toLowerCase();
111
+ if ([".ts", ".tsx", ".js", ".jsx", ".py", ".go", ".rs", ".json", ".env"].includes(ext)) {
112
+ filesToScan.push(fullPath);
113
+ }
114
+ }
115
+ }
116
+ });
117
+ } catch (e) {
118
+ return JSON.stringify({
119
+ status: "WARNING",
120
+ reason: "Git no disponible o error al consultar git status. Usa scanAll: true."
121
+ }, null, 2);
122
+ }
123
+ } else {
124
+ return JSON.stringify({
125
+ status: "WARNING",
126
+ reason: "No se detectó un repositorio de Git en la raíz del proyecto. Usa scanAll: true para escanear todo el proyecto."
127
+ }, null, 2);
128
+ }
129
+ }
130
+
131
+ if (filesToScan.length === 0) {
132
+ return JSON.stringify({
133
+ status: "APPROVED",
134
+ scannedCount: 0,
135
+ message: "✅ ESCANEO DE SEGURIDAD: No se encontraron archivos modificados para escanear."
136
+ }, null, 2);
137
+ }
138
+
139
+ // 2. Patrones de secretos de alta confianza
140
+ const secretRegexes = [
141
+ { type: "AWS Access Key ID", regex: /AKIA[0-9A-Z]{16}/ },
142
+ { type: "AWS Secret Access Key", regex: /[^A-Za-z0-9/+=][A-Za-z0-9/+=]{40}[^A-Za-z0-9/+=]/ },
143
+ { type: "Private Key", regex: /-----BEGIN (RSA|EC|PGP|OPENSSH)? PRIVATE KEY-----/i },
144
+ { type: "Slack Token", regex: /xox[bapr]-[0-9]{12}-[a-zA-Z0-9]{24}/ },
145
+ { type: "Stripe API Key", regex: /sk_live_[0-9a-zA-Z]{24}/ },
146
+ { type: "OpenAI API Key", regex: /sk-[a-zA-Z0-9]{48}/ },
147
+ { type: "Google API Key", regex: /AIzaSy[A-Za-z0-9_-]{35}/ },
148
+ { type: "Github Personal Access Token", regex: /ghp_[a-zA-Z0-9]{36}/ },
149
+ { type: "Generic High-Entropy Credential Assignment", regex: /(api_key|secret_key|client_secret|password|access_token|db_pass|auth_token)\s*[:=]\s*["'][a-zA-Z0-9_./-]{16,}["']/i }
150
+ ];
151
+
152
+ const ignoreFilter = (line: string): boolean => {
153
+ if (/process\.env/i.test(line)) return true;
154
+ if (/\{env:/i.test(line)) return true;
155
+ if (/\$env:/i.test(line)) return true;
156
+ if (/"Authorization"\s*:\s*["']Bearer /i.test(line) && line.includes("env")) return true;
157
+ return false;
158
+ };
159
+
160
+ // 3. Escaneo de archivos
161
+ filesToScan.forEach(file => {
162
+ try {
163
+ const content = fs.readFileSync(file, "utf-8");
164
+ const lines = content.split("\n");
165
+ lines.forEach((line, index) => {
166
+ if (ignoreFilter(line)) return;
167
+
168
+ secretRegexes.forEach(pattern => {
169
+ const match = line.match(pattern.regex);
170
+ if (match) {
171
+ const relPath = path.relative(projectRoot, file);
172
+ const matchedStr = match[0];
173
+ const redacted = matchedStr.length > 8
174
+ ? matchedStr.substring(0, 4) + "..." + matchedStr.substring(matchedStr.length - 4)
175
+ : "****";
176
+
177
+ findings.push({
178
+ file: relPath,
179
+ line: index + 1,
180
+ type: pattern.type,
181
+ snippet: line.trim().replace(matchedStr, redacted)
182
+ });
183
+ }
184
+ });
185
+ });
186
+ } catch (e) {}
187
+ });
188
+
189
+ if (findings.length > 0) {
190
+ return JSON.stringify({
191
+ status: "FAILED",
192
+ scannedCount: filesToScan.length,
193
+ findingsCount: findings.length,
194
+ findings,
195
+ message: `❌ ESCANEO DE SEGURIDAD FALLIDO: Se detectaron ${findings.length} posibles fugas de secretos en archivos modificados:\n\n${findings.map(f => ` - 📁 [${f.file} : Línea ${f.line}] - Tipo: ${f.type}\n Línea: "${f.snippet}"`).join("\n\n")}\n\nPor favor, remueve estos secretos en caliente antes de archivar y realizar commits.`
196
+ }, null, 2);
197
+ }
198
+
199
+ return JSON.stringify({
200
+ status: "APPROVED",
201
+ scannedCount: filesToScan.length,
202
+ message: `✅ ESCANEO DE SEGURIDAD EXITOSO: Se escanearon ${filesToScan.length} archivos y no se detectaron credenciales ni secretos en caliente. ¡Estabilidad y cumplimiento impecables!`
203
+ }, null, 2);
204
+ }
205
+ })
@@ -0,0 +1,139 @@
1
+ import { tool } from "@opencode-ai/plugin"
2
+ import fs from "fs"
3
+ import path from "path"
4
+
5
+ export default tool({
6
+ description: "Audita y valida el plano técnico (spec.md) del cambio de desarrollo activo para asegurar que cumpla con los estándares de calidad y secciones mandatorias de la metodología SDD.",
7
+ args: {
8
+ changeName: tool.schema.string().optional().describe("Nombre del cambio en kebab-case. Por defecto se autodetectará del sdd-lock.")
9
+ },
10
+ async execute(args, context) {
11
+ const projectRoot = context.worktree || context.directory;
12
+ let changeName = args.changeName;
13
+ let complexity = "high";
14
+
15
+ // 1. Detectar cambio activo y complejidad del sdd-lock si no se provee
16
+ const lockfilePath = path.join(projectRoot, ".openspec/sdd-lock.json");
17
+ const altLockPath = path.join(projectRoot, "openspec/sdd-lock.json");
18
+ const activeLockPath = fs.existsSync(lockfilePath) ? lockfilePath : (fs.existsSync(altLockPath) ? altLockPath : null);
19
+ if (activeLockPath) {
20
+ try {
21
+ const lockObj = JSON.parse(fs.readFileSync(activeLockPath, "utf-8"));
22
+ if (!changeName && lockObj.change_name && lockObj.change_name !== "nuevo-cambio") {
23
+ changeName = lockObj.change_name;
24
+ }
25
+ if (lockObj.complexity) {
26
+ complexity = lockObj.complexity;
27
+ }
28
+ } catch (e) {}
29
+ }
30
+
31
+ if (!changeName || changeName === "nuevo-cambio") {
32
+ return JSON.stringify({
33
+ status: "FAILED",
34
+ reason: "No se pudo autodetectar un cambio de desarrollo activo en .openspec/sdd-lock.json. Asegúrate de iniciar la Fase 1 primero."
35
+ }, null, 2);
36
+ }
37
+
38
+ const specPath = path.join(projectRoot, ".openspec/changes", changeName, "specs/spec.md");
39
+ if (!fs.existsSync(specPath)) {
40
+ return JSON.stringify({
41
+ status: "FAILED",
42
+ reason: `No se encuentra el archivo de especificación specs/spec.md para el cambio '${changeName}'. Ruta esperada: ${path.relative(projectRoot, specPath)}`
43
+ }, null, 2);
44
+ }
45
+
46
+ const specContent = fs.readFileSync(specPath, "utf-8");
47
+
48
+ // Secciones requeridas y sus expresiones regulares de validación según complejidad
49
+ const requiredSections = [
50
+ { name: "Plano Técnico / Título", regex: /^# Plano Técnico de Especificación/m },
51
+ { name: "1. Diagnóstico y Archivos Afectados", regex: /^## 1\. Diagnóstico/m },
52
+ { name: "3. Propuesta de Solución y Arquitectura", regex: /^## 3\. Propuesta/m },
53
+ { name: "5. Criterios de Aceptación y Calidad", regex: /^## 5\. Criterios/m }
54
+ ];
55
+
56
+ if (complexity !== "low") {
57
+ requiredSections.push(
58
+ { name: "2. Consenso de Encuesta con el Usuario", regex: /^## 2\. Consenso/m },
59
+ { name: "4. Especificaciones BDD (Comportamiento)", regex: /^## 4\. Especificaciones BDD|Feature:/m }
60
+ );
61
+ }
62
+
63
+ const missingSections: string[] = [];
64
+ requiredSections.forEach(section => {
65
+ if (!section.regex.test(specContent)) {
66
+ missingSections.push(section.name);
67
+ }
68
+ });
69
+
70
+ if (missingSections.length > 0) {
71
+ return JSON.stringify({
72
+ status: "FAILED",
73
+ changeName,
74
+ complexity,
75
+ reason: "El archivo spec.md no cumple con el formato mandatorio.",
76
+ missingSections,
77
+ message: `❌ VALIDACIÓN FALLIDA [Complejidad: ${complexity}]: Faltan las siguientes secciones requeridas en el plano técnico:\n${missingSections.map(s => ` - ${s}`).join("\n")}\n\nPor favor, pide a @sdd-planner que complete todas las secciones requeridas.`
78
+ }, null, 2);
79
+ }
80
+
81
+ // Comprobar si hay placeholders obvios sin completar según complejidad
82
+ const placeholders = [
83
+ /\[nombre-cambio\]/i,
84
+ /\[Un solo párrafo conciso con el enfoque técnico\]/i
85
+ ];
86
+
87
+ if (complexity !== "low") {
88
+ placeholders.push(
89
+ /\[Resumen de la duda y decisión adoptada\]/i,
90
+ /\[Caso de prueba principal o flujo clave\]/i,
91
+ /\[Contexto inicial del sistema\]/i,
92
+ /\[Acción que realiza el usuario o sistema\]/i,
93
+ /\[Resultado final esperado\]/i
94
+ );
95
+ }
96
+
97
+ const detectedPlaceholders: string[] = [];
98
+ placeholders.forEach(regex => {
99
+ const match = specContent.match(regex);
100
+ if (match) {
101
+ detectedPlaceholders.push(match[0]);
102
+ }
103
+ });
104
+
105
+ if (detectedPlaceholders.length > 0) {
106
+ return JSON.stringify({
107
+ status: "FAILED",
108
+ changeName,
109
+ complexity,
110
+ reason: "Se detectaron placeholders sin completar en el plano técnico.",
111
+ detectedPlaceholders,
112
+ message: `❌ VALIDACIÓN FALLIDA [Complejidad: ${complexity}]: El spec.md contiene marcadores de posición (placeholders) que no han sido completados:\n${detectedPlaceholders.map(p => ` - ${p}`).join("\n")}\n\nPor favor, reemplaza estos valores con las especificaciones reales antes de avanzar.`
113
+ }, null, 2);
114
+ }
115
+
116
+ // Validar Given-When-Then solo si la complejidad es alta
117
+ if (complexity !== "low") {
118
+ const hasScenario = /Scenario:|Escenario:/i.test(specContent);
119
+ const hasGWT = /(Given|Dado)[\s\S]+(When|Cuando)[\s\S]+(Then|Entonces)/i.test(specContent);
120
+
121
+ if (!hasScenario || !hasGWT) {
122
+ return JSON.stringify({
123
+ status: "FAILED",
124
+ changeName,
125
+ complexity,
126
+ reason: "No se encontraron escenarios Given-When-Then estructurados.",
127
+ message: `❌ VALIDACIÓN FALLIDA [Complejidad: ${complexity}]: Se requiere al menos un escenario estructurado 'Scenario:' con pasos Given-When-Then (Dado-Cuando-Entonces) para habilitar las pruebas automáticas en la Fase 2.`
128
+ }, null, 2);
129
+ }
130
+ }
131
+
132
+ return JSON.stringify({
133
+ status: "APPROVED",
134
+ changeName,
135
+ complexity,
136
+ message: `✅ VALIDACIÓN EXITOSA [Complejidad: ${complexity}]: El plano técnico 'specs/spec.md' para '${changeName}' cumple al 100% con los contratos de calidad de la metodología SDD. Está listo para ser implementado en la Fase 2.`
137
+ }, null, 2);
138
+ }
139
+ })