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,375 @@
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 specValidator from "./sdd_spec_validator"
6
+ import regressionDetector from "./sdd_regression_detector"
7
+ import secretScanner from "./sdd_secret_scanner"
8
+ import requirementTracker from "./sdd_requirement_tracker"
9
+ import checkDependencyCooldown from "./check_dependency_cooldown"
10
+
11
+ const SUBAGENT_MAPPING: { [key: number]: string } = {
12
+ 0: "sdd-explorer",
13
+ 1: "sdd-planner",
14
+ 2: "sdd-builder",
15
+ 3: "sdd-tester",
16
+ 4: "sdd-deployer",
17
+ 5: "sdd-archiver"
18
+ }
19
+
20
+ const DEFAULT_LOCKFILE = {
21
+ change_name: "nuevo-cambio",
22
+ active_phase: 0,
23
+ active_subagent: "sdd-explorer",
24
+ status: "idle",
25
+ auto_pilot: false,
26
+ iteration: 0,
27
+ last_updated: "",
28
+ orchestrator_mode: "delegation_only",
29
+ direction: "forward" as "forward" | "backward" | "repeat",
30
+ last_successful_phase: 0,
31
+ retry_count: 0,
32
+ corrective_loop_active: false,
33
+ fresh_task: false,
34
+ checkpoints: []
35
+ }
36
+
37
+ export default tool({
38
+ description: "Tránsiciona de fase en el ciclo Spec-Driven Development (SDD), actualizando el archivo de bloqueo lockfile .openspec/sdd-lock.json de forma segura, e integra control de cambios en Git de forma automática.",
39
+ args: {
40
+ nextPhase: tool.schema.number().describe("El número de la siguiente fase del ciclo SDD (0-5)"),
41
+ status: tool.schema.string().describe("El nuevo estado del ciclo (ej: 'idle', 'in_progress', 'corrective_loop', 'restored')"),
42
+ reason: tool.schema.string().describe("La justificación o explicación resumida de los cambios logrados en esta fase"),
43
+ activeSubagent: tool.schema.string().optional().describe("El subagente activo opcional (ej: 'sdd-planner', 'sdd-builder')"),
44
+ iteration: tool.schema.number().optional().describe("El número de iteración correctiva opcional"),
45
+ changeName: tool.schema.string().optional().describe("El nombre del cambio de desarrollo activo opcional"),
46
+ complexity: tool.schema.enum(["low", "high"]).optional().default("high").describe("La complejidad del cambio de desarrollo activo (low o high)"),
47
+ direction: tool.schema.enum(["forward", "backward", "repeat"]).optional().default("forward").describe("Dirección de la transición: forward (normal), backward (retroceder a fase anterior), repeat (repetir fase actual)")
48
+ },
49
+ async execute(args, context) {
50
+ const projectRoot = context.worktree || context.directory;
51
+ let lockfilePath = path.join(projectRoot, ".openspec/sdd-lock.json");
52
+
53
+ // Validar si existe .openspec/sdd-lock.json o si es openspec/sdd-lock.json
54
+ if (!fs.existsSync(lockfilePath)) {
55
+ const altPath = path.join(projectRoot, "openspec/sdd-lock.json");
56
+ if (fs.existsSync(altPath)) {
57
+ lockfilePath = altPath;
58
+ } else {
59
+ // Si no existe ninguno, creamos el directorio y el archivo por defecto
60
+ const dirPath = path.join(projectRoot, ".openspec");
61
+ if (!fs.existsSync(dirPath)) {
62
+ fs.mkdirSync(dirPath, { recursive: true });
63
+ }
64
+ }
65
+ }
66
+
67
+ let lockfile: any = {
68
+ change_name: "nuevo-cambio",
69
+ active_phase: 0,
70
+ active_subagent: "sdd-explorer",
71
+ status: "idle",
72
+ auto_pilot: false,
73
+ iteration: 0,
74
+ last_updated: ""
75
+ };
76
+
77
+ if (fs.existsSync(lockfilePath)) {
78
+ try {
79
+ lockfile = JSON.parse(fs.readFileSync(lockfilePath, "utf-8"));
80
+ lockfile.orchestrator_mode = lockfile.orchestrator_mode || "delegation_only";
81
+ lockfile.direction = lockfile.direction || "forward";
82
+ lockfile.last_successful_phase = lockfile.last_successful_phase || 0;
83
+ lockfile.retry_count = lockfile.retry_count || 0;
84
+ lockfile.corrective_loop_active = lockfile.corrective_loop_active || false;
85
+ lockfile.fresh_task = lockfile.fresh_task || false;
86
+ lockfile.checkpoints = lockfile.checkpoints || [];
87
+ } catch (e) {
88
+ lockfile = { ...DEFAULT_LOCKFILE };
89
+ }
90
+ } else {
91
+ lockfile = { ...DEFAULT_LOCKFILE };
92
+ }
93
+
94
+ const direction = args.direction || lockfile.direction || "forward";
95
+
96
+ if (direction === "backward") {
97
+ const previousPhase = Math.max(0, (args.nextPhase || lockfile.active_phase) - 1);
98
+ lockfile.last_successful_phase = previousPhase;
99
+ lockfile.corrective_loop_active = true;
100
+ lockfile.fresh_task = true;
101
+ lockfile.retry_count = 0;
102
+ }
103
+
104
+ if (direction === "repeat") {
105
+ lockfile.retry_count = (lockfile.retry_count || 0) + 1;
106
+ lockfile.corrective_loop_active = true;
107
+ lockfile.fresh_task = true;
108
+ if (lockfile.retry_count > 3) {
109
+ return `[SDD Transition Blocked] Se excedió el límite de 3 reintentos para esta fase. Escalando a revisión humana.`;
110
+ }
111
+ }
112
+
113
+ if (direction === "forward") {
114
+ lockfile.fresh_task = false;
115
+ }
116
+
117
+ const activeChangeName = args.changeName || lockfile.change_name;
118
+
119
+ // ── SALVAGUARDAS AUTOMÁTICAS DE METODOLOGÍA SDD ──
120
+ // 1. Transición a Fase 2 (Construcción): Validar spec.md y parsear la checklist de tareas
121
+ if (args.nextPhase === 2 && args.status !== "corrective_loop" && direction === "forward") {
122
+ const specValidationResultObj: any = await specValidator.execute({ changeName: activeChangeName }, context);
123
+ const specValidationResultStr = typeof specValidationResultObj === "string" ? specValidationResultObj : (specValidationResultObj?.output || "");
124
+ try {
125
+ const result = JSON.parse(specValidationResultStr);
126
+ if (result.status === "FAILED") {
127
+ return `[SDD Transition Blocked] Transición rechazada por falla de calidad del Plano Técnico:\n\n${result.message}`;
128
+ }
129
+ } catch (e) {}
130
+
131
+ // Extracción de checklist de tareas desde spec.md para el monitor de estados
132
+ const specPath = path.join(projectRoot, ".openspec/changes", activeChangeName, "specs/spec.md");
133
+ if (fs.existsSync(specPath)) {
134
+ try {
135
+ const specContent = fs.readFileSync(specPath, "utf-8");
136
+ const qaSectionIndex = specContent.indexOf("## 5. Criterios de Aceptación");
137
+ if (qaSectionIndex !== -1) {
138
+ const qaContent = specContent.substring(qaSectionIndex);
139
+ const lines = qaContent.split("\n");
140
+ const parsedTasks: any[] = [];
141
+ let taskId = 1;
142
+ for (const line of lines) {
143
+ if (line.startsWith("##") && !line.includes("## 5.")) {
144
+ break;
145
+ }
146
+ const match = line.match(/^\s*-\s*\[\s*\]\s*(.+)$/i);
147
+ if (match) {
148
+ parsedTasks.push({
149
+ id: taskId++,
150
+ desc: match[1].trim(),
151
+ status: "pending"
152
+ });
153
+ }
154
+ }
155
+ if (parsedTasks.length > 0) {
156
+ lockfile.tasks = parsedTasks;
157
+ }
158
+ }
159
+ } catch (e) {}
160
+ }
161
+ }
162
+
163
+ // 2. Transición a Fase 5 (Cierre/Archiver): Validar regresiones de compilación, cobertura de requerimientos, cooldown y actualizar estado de tareas
164
+ if (args.nextPhase === 5 && args.status !== "corrective_loop") {
165
+ // Sincronizar checklist de tareas completadas desde el verification_report.md
166
+ if (lockfile.tasks) {
167
+ const reportPath = path.join(projectRoot, ".openspec/changes", activeChangeName, "validation_report.md");
168
+ if (fs.existsSync(reportPath)) {
169
+ try {
170
+ const reportContent = fs.readFileSync(reportPath, "utf-8");
171
+ const qaSectionIndex = reportContent.indexOf("## 3. Correspondencia de Criterios");
172
+ if (qaSectionIndex !== -1) {
173
+ const qaContent = reportContent.substring(qaSectionIndex);
174
+ const lines = qaContent.split("\n");
175
+ for (const line of lines) {
176
+ if (line.startsWith("##") && !line.includes("## 3.")) {
177
+ break;
178
+ }
179
+ const match = line.match(/^\s*-\s*\[(x|\s)\]\s*(.+)$/i);
180
+ if (match) {
181
+ const isCompleted = match[1].toLowerCase() === "x";
182
+ const descClean = match[2].replace(/\*/g, "").trim().toLowerCase();
183
+ for (const t of lockfile.tasks) {
184
+ const taskClean = t.desc.toLowerCase();
185
+ if (descClean.includes(taskClean) || taskClean.includes(descClean)) {
186
+ t.status = isCompleted ? "completed" : "pending";
187
+ }
188
+ }
189
+ }
190
+ }
191
+ }
192
+ } catch (e) {}
193
+ }
194
+ }
195
+
196
+ // A. Validar regresiones de compilación
197
+ const regressionResultObj: any = await regressionDetector.execute({ runCheck: true }, context);
198
+ const regressionResultStr = typeof regressionResultObj === "string" ? regressionResultObj : (regressionResultObj?.output || "");
199
+ try {
200
+ const result = JSON.parse(regressionResultStr);
201
+ if (result.status && result.status.startsWith("FAILED")) {
202
+ return `[SDD Transition Blocked] Transición rechazada por detección de errores o regresiones de compilación:\n\n${result.message}`;
203
+ }
204
+ } catch (e) {}
205
+
206
+ // B. Validar cobertura semántica de criterios de aceptación (Requerimientos)
207
+ const requirementResultObj: any = await requirementTracker.execute({ changeName: activeChangeName }, context);
208
+ const requirementResultStr = typeof requirementResultObj === "string" ? requirementResultObj : (requirementResultObj?.output || "");
209
+ try {
210
+ const result = JSON.parse(requirementResultStr);
211
+ if (result.status === "FAILED") {
212
+ return `[SDD Transition Blocked] Transición rechazada por falta de cobertura de pruebas para los criterios de aceptación:\n\n${result.message}`;
213
+ }
214
+ } catch (e) {}
215
+
216
+ // C. Validar Cooldown de dependencias agregadas en package.json en caliente
217
+ if (fs.existsSync(path.join(projectRoot, "package.json")) && fs.existsSync(path.join(projectRoot, ".git"))) {
218
+ try {
219
+ const diffOutput = execSync("git diff HEAD package.json", { cwd: projectRoot, encoding: "utf-8" });
220
+ const addedLines = diffOutput.split("\n").filter(l => l.startsWith("+") && !l.startsWith("+++"));
221
+ const depRegex = /"([^"]+)"\s*:\s*"([^"]+)"/;
222
+ for (const line of addedLines) {
223
+ const match = line.match(depRegex);
224
+ if (match) {
225
+ const pkg = match[1];
226
+ const version = match[2].replace(/[\^~>=]/g, ""); // Limpiar rangos
227
+ if (pkg === "zugzbot-sdd" || pkg.startsWith("@opencode-ai/")) continue;
228
+
229
+ const cooldownResultObj: any = await checkDependencyCooldown.execute({ package: pkg, version }, context);
230
+ const cooldownResultStr = typeof cooldownResultObj === "string" ? cooldownResultObj : (cooldownResultObj?.output || "");
231
+ try {
232
+ const cooldownResult = JSON.parse(cooldownResultStr);
233
+ if (cooldownResult.status === "BLOCKED") {
234
+ return `[SDD Transition Blocked] Transición rechazada por violación de la regla de Cooldown de Dependencias de Terceros:\n\n${cooldownResult.message}`;
235
+ }
236
+ } catch (e) {}
237
+ }
238
+ }
239
+ } catch (e) {}
240
+ }
241
+ }
242
+
243
+ // 3. Transición a Fase 0 / Cierre (Commit final): Escanear secretos
244
+ if (args.nextPhase === 0) {
245
+ const secretScanResultObj: any = await secretScanner.execute({ scanAll: false }, context);
246
+ const secretScanResultStr = typeof secretScanResultObj === "string" ? secretScanResultObj : (secretScanResultObj?.output || "");
247
+ try {
248
+ const result = JSON.parse(secretScanResultStr);
249
+ if (result.status === "FAILED") {
250
+ return `[SDD Transition Blocked] Transición y Git Commit cancelados por advertencia de seguridad (Se encontraron secretos expuestos):\n\n${result.message}`;
251
+ }
252
+ } catch (e) {}
253
+ }
254
+
255
+ // Actualizar campos
256
+ lockfile.direction = direction;
257
+ lockfile.active_phase = args.nextPhase;
258
+ lockfile.status = args.status;
259
+ lockfile.last_updated = new Date().toISOString().split('T')[0];
260
+
261
+ if (args.activeSubagent) {
262
+ lockfile.active_subagent = args.activeSubagent;
263
+ } else {
264
+ lockfile.active_subagent = SUBAGENT_MAPPING[args.nextPhase] || "sdd-planner";
265
+ }
266
+
267
+ if (args.iteration !== undefined) {
268
+ lockfile.iteration = args.iteration;
269
+ }
270
+
271
+ if (args.changeName) {
272
+ lockfile.change_name = args.changeName;
273
+ }
274
+
275
+ if (args.complexity !== undefined) {
276
+ lockfile.complexity = args.complexity;
277
+ }
278
+
279
+ // Escribir los cambios
280
+ fs.writeFileSync(lockfilePath, JSON.stringify(lockfile, null, 2), "utf-8");
281
+
282
+ // Escribir en el log o auditoría interna del cambio activo si existe el directorio
283
+ if (lockfile.change_name && lockfile.change_name !== "nuevo-cambio") {
284
+ const changeDir = path.join(projectRoot, ".openspec/changes", lockfile.change_name);
285
+ if (fs.existsSync(changeDir)) {
286
+ const historyPath = path.join(changeDir, "phase_history.jsonl");
287
+
288
+ // Obtener analíticas de tokens y costos de la sesión desde el servidor OpenCode
289
+ const port = process.env.OPENCODE_PORT || "4096";
290
+ let tokenStats = { cost: 0, input: 0, output: 0 };
291
+ const modelsUsed = new Set<string>();
292
+ try {
293
+ const url = `http://127.0.0.1:${port}/session/${context.sessionID}/message`;
294
+ const response = await fetch(url);
295
+ if (response.ok) {
296
+ const messages: any = await response.json();
297
+ const list = Array.isArray(messages) ? messages : (messages?.data || []);
298
+ list.forEach((msg: any) => {
299
+ const info = msg.info || msg;
300
+ if (info && info.role === "assistant") {
301
+ const cost = typeof info.cost === "number" && Number.isFinite(info.cost) ? info.cost : 0;
302
+ const input = info.tokens?.input ?? 0;
303
+ const output = info.tokens?.output ?? 0;
304
+ tokenStats.cost += cost;
305
+ tokenStats.input += input;
306
+ tokenStats.output += output;
307
+
308
+ // Extraer identificador de modelo de IA usado
309
+ const modelVal = info.modelID || (info.model && typeof info.model === "object" ? info.model.modelID : info.model);
310
+ if (modelVal) {
311
+ modelsUsed.add(String(modelVal));
312
+ }
313
+ }
314
+ });
315
+ }
316
+ } catch (e) {}
317
+
318
+ const logEntry = {
319
+ timestamp: new Date().toISOString(),
320
+ phase: args.nextPhase,
321
+ subagent: lockfile.active_subagent,
322
+ status: args.status,
323
+ reason: args.reason,
324
+ iteration: lockfile.iteration || 0,
325
+ analytics: {
326
+ session_id: context.sessionID,
327
+ models_used: Array.from(modelsUsed),
328
+ cumulative_cost_usd: tokenStats.cost,
329
+ cumulative_tokens_input: tokenStats.input,
330
+ cumulative_tokens_output: tokenStats.output
331
+ }
332
+ };
333
+ fs.appendFileSync(historyPath, JSON.stringify(logEntry) + "\n", "utf-8");
334
+ }
335
+ }
336
+
337
+ // INTEGRACIÓN AUTOMÁTICA CON GIT
338
+ let gitStatus = "";
339
+ if (fs.existsSync(path.join(projectRoot, ".git")) && lockfile.change_name && lockfile.change_name !== "nuevo-cambio") {
340
+ try {
341
+ const branchName = `sdd/change-${lockfile.change_name}`;
342
+
343
+ // 1. Chequear rama actual
344
+ const currentBranch = execSync("git rev-parse --abbrev-ref HEAD", { cwd: projectRoot, encoding: "utf-8" }).trim();
345
+
346
+ if (currentBranch !== branchName) {
347
+ // Chequear si la rama existe localmente
348
+ let branchExists = false;
349
+ try {
350
+ execSync(`git show-ref --verify --quiet refs/heads/${branchName}`, { cwd: projectRoot });
351
+ branchExists = true;
352
+ } catch (e) {}
353
+
354
+ if (branchExists) {
355
+ execSync(`git checkout ${branchName}`, { cwd: projectRoot, stdio: "ignore" });
356
+ } else {
357
+ execSync(`git checkout -b ${branchName}`, { cwd: projectRoot, stdio: "ignore" });
358
+ }
359
+ }
360
+
361
+ // 2. Hacer commit automático de los artefactos .openspec/
362
+ execSync("git add .openspec/", { cwd: projectRoot, stdio: "ignore" });
363
+
364
+ const commitMsg = `docs(sdd): transition to phase ${args.nextPhase} - ${args.reason.replace(/"/g, '\\"')}`;
365
+ execSync(`git commit -m "${commitMsg}"`, { cwd: projectRoot, stdio: "ignore" });
366
+
367
+ gitStatus = ` [Git: Rama '${branchName}' actualizada con commit semántico]`;
368
+ } catch (e: any) {
369
+ gitStatus = ` [Git Warning: No se pudo realizar commit automático: ${e.message || e}]`;
370
+ }
371
+ }
372
+
373
+ return `[SDD Tool] Fase transicionada con éxito a Fase ${args.nextPhase} (${lockfile.active_subagent}). Estado: ${args.status}. Motivo: ${args.reason}.${gitStatus}`;
374
+ }
375
+ })
@@ -0,0 +1,310 @@
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
+ if (cleaned.includes("\\")) {
9
+ try {
10
+ const bytes: number[] = []
11
+ let i = 0
12
+ while (i < cleaned.length) {
13
+ if (cleaned[i] === "\\" && i + 3 < cleaned.length && /^[0-7]{3}$/.test(cleaned.substring(i + 1, i + 4))) {
14
+ const octalVal = cleaned.substring(i + 1, i + 4)
15
+ bytes.push(parseInt(octalVal, 8))
16
+ i += 4
17
+ } else {
18
+ if (cleaned[i] === "\\" && i + 1 < cleaned.length) {
19
+ const next = cleaned[i + 1]
20
+ if (next === "n") { bytes.push(10); i += 2 }
21
+ else if (next === "t") { bytes.push(9); i += 2 }
22
+ else if (next === "\\") { bytes.push(92); i += 2 }
23
+ else if (next === "\"") { bytes.push(34); i += 2 }
24
+ else { bytes.push(cleaned.charCodeAt(i)); i++ }
25
+ } else {
26
+ const code = cleaned.charCodeAt(i)
27
+ if (code < 128) {
28
+ bytes.push(code)
29
+ } else {
30
+ const buf = Buffer.from(cleaned[i], "utf-8")
31
+ for (let b = 0; b < buf.length; b++) {
32
+ bytes.push(buf[b])
33
+ }
34
+ }
35
+ i++
36
+ }
37
+ }
38
+ }
39
+ return Buffer.from(bytes).toString("utf-8")
40
+ } catch (e) {
41
+ return cleaned.replace(/\\([0-7]{3})/g, (match, octal) => {
42
+ return String.fromCharCode(parseInt(octal, 8))
43
+ })
44
+ }
45
+ }
46
+ return cleaned
47
+ }
48
+
49
+ function sanitizeGitPath(line: string): string {
50
+ const content = line.substring(3).trim()
51
+ if (content.includes(" -> ")) {
52
+ const parts = content.split(" -> ")
53
+ return decodeGitPath(parts[1])
54
+ }
55
+ return decodeGitPath(content)
56
+ }
57
+
58
+ function checkHtmlTagBalance(filePath: string, content: string): string[] {
59
+ const ext = path.extname(filePath).toLowerCase();
60
+ if (![".html", ".tsx", ".jsx", ".ts", ".js"].includes(ext)) {
61
+ return [];
62
+ }
63
+
64
+ const issues: string[] = [];
65
+ const tagsToCheck = ["div", "span", "section", "p", "button", "main", "header", "footer", "a", "ul", "ol", "li"];
66
+
67
+ let cleaned = content;
68
+ if (ext === ".html") {
69
+ cleaned = content.replace(/<!--[\s\S]*?-->/g, "");
70
+ cleaned = cleaned.replace(/<script[\s\S]*?<\/script>/gi, "");
71
+ cleaned = cleaned.replace(/<style[\s\S]*?<\/style>/gi, "");
72
+ } else {
73
+ cleaned = content.replace(/\/\*[\s\S]*?\*\/|\/\/.*/g, ""); // JS/TS comments
74
+ }
75
+
76
+ for (const tag of tagsToCheck) {
77
+ const openRegex = new RegExp(`<${tag}\\b[^>]*[^/]>`, "gi");
78
+ const closeRegex = new RegExp(`</${tag}\\s*>`, "gi");
79
+
80
+ let openCount = 0;
81
+ let closeCount = 0;
82
+
83
+ let match;
84
+ while ((match = openRegex.exec(cleaned)) !== null) {
85
+ openCount++;
86
+ }
87
+ while ((match = closeRegex.exec(cleaned)) !== null) {
88
+ closeCount++;
89
+ }
90
+
91
+ if (openCount !== closeCount) {
92
+ issues.push(
93
+ `Desbalance en etiquetas '<${tag}>': Encontradas ${openCount} de apertura y ${closeCount} de cierre. Esto puede quebrar el DOM global de la aplicación.`
94
+ );
95
+ }
96
+ }
97
+
98
+ return issues;
99
+ }
100
+
101
+ export default tool({
102
+ description: "Audita la estética de la interfaz de usuario en busca de colores genéricos, fuentes predeterminadas del navegador y verifica el cumplimiento de las directrices visuales premium (sdd-ux-premium) de forma localizada.",
103
+ args: {
104
+ changeName: tool.schema.string().optional().describe("El nombre del cambio activo en kebab-case. Si no se provee, se detectará automáticamente."),
105
+ localPort: tool.schema.number().optional().describe("Puerto del servidor de desarrollo local (ej: 3000) para intentar capturar screenshot opcional.")
106
+ },
107
+ async execute(args, context) {
108
+ const projectRoot = context.worktree || context.directory;
109
+
110
+ // 1. Detectar el cambio activo
111
+ let changeName = args.changeName;
112
+ if (!changeName) {
113
+ const lockfilePath = path.join(projectRoot, ".openspec/sdd-lock.json");
114
+ const altLockPath = path.join(projectRoot, "openspec/sdd-lock.json");
115
+ let activeLockPath = fs.existsSync(lockfilePath) ? lockfilePath : (fs.existsSync(altLockPath) ? altLockPath : null);
116
+ if (activeLockPath) {
117
+ try {
118
+ const lockObj = JSON.parse(fs.readFileSync(activeLockPath, "utf-8"));
119
+ if (lockObj.change_name && lockObj.change_name !== "nuevo-cambio") {
120
+ changeName = lockObj.change_name;
121
+ }
122
+ } catch (e) {}
123
+ }
124
+ }
125
+
126
+ if (!changeName) {
127
+ changeName = "nuevo-cambio";
128
+ }
129
+
130
+ const reportDir = path.join(projectRoot, ".openspec/changes", changeName);
131
+ if (!fs.existsSync(reportDir)) {
132
+ fs.mkdirSync(reportDir, { recursive: true });
133
+ }
134
+
135
+ const reportPath = path.join(reportDir, "ui_report.md");
136
+
137
+ // 2. Recopilar archivos a auditar de forma localizada (Regla de Impacto Localizado)
138
+ const filesToAudit = new Set<string>();
139
+
140
+ // A. Buscar archivos modificados en Git que sean de UI
141
+ if (fs.existsSync(path.join(projectRoot, ".git"))) {
142
+ try {
143
+ const gitOutput = execSync("git status --porcelain", { cwd: projectRoot, encoding: "utf-8" });
144
+ gitOutput.split("\n").forEach(line => {
145
+ if (!line || line.length < 4) return;
146
+ const filePathRel = sanitizeGitPath(line);
147
+ const ext = path.extname(filePathRel).toLowerCase();
148
+ if ([".css", ".tsx", ".jsx", ".html"].includes(ext)) {
149
+ filesToAudit.add(filePathRel);
150
+ }
151
+ });
152
+ } catch (e) {}
153
+ }
154
+
155
+ // B. Buscar archivos listados en el spec.md activo
156
+ const specPath = path.join(projectRoot, ".openspec/changes", changeName, "specs/spec.md");
157
+ if (fs.existsSync(specPath)) {
158
+ try {
159
+ const specContent = fs.readFileSync(specPath, "utf-8");
160
+ const fileRegex = /[`']([^`']+\.(css|tsx|jsx|html))[`']/gi;
161
+ let match;
162
+ while ((match = fileRegex.exec(specContent)) !== null) {
163
+ const matchedFile = match[1].trim();
164
+ if (fs.existsSync(path.join(projectRoot, matchedFile))) {
165
+ filesToAudit.add(matchedFile);
166
+ }
167
+ }
168
+ } catch (e) {}
169
+ }
170
+
171
+ // 3. Escaneo de las clases y estilos UI
172
+ const nonPremiumColors = [
173
+ /\b(red|blue|green|yellow|black|white|purple|orange|pink|gray|grey)\b/i,
174
+ /#(ff0000|0000ff|00ff00|ffff00|000000|ffffff)\b/i
175
+ ];
176
+
177
+ const premiumFonts = ["Inter", "Outfit", "Roboto", "Sans-Serif", "system-ui", "sans-serif"];
178
+
179
+ interface FileFinding {
180
+ file: string;
181
+ colorIssues: string[];
182
+ fontIssues: string[];
183
+ hasTransitions: boolean;
184
+ structuralIssues: string[];
185
+ }
186
+
187
+ const findings: FileFinding[] = [];
188
+
189
+ filesToAudit.forEach(fileRel => {
190
+ const fullPath = path.join(projectRoot, fileRel);
191
+ if (!fs.existsSync(fullPath)) return;
192
+ try {
193
+ const content = fs.readFileSync(fullPath, "utf-8");
194
+ const colorIssues: string[] = [];
195
+ const fontIssues: string[] = [];
196
+ let hasTransitions = false;
197
+
198
+ // Analizar líneas
199
+ const lines = content.split("\n");
200
+ lines.forEach((line, index) => {
201
+ // Chequear colores
202
+ if (line.includes("color") || line.includes("bg-") || line.includes("background") || line.includes("border")) {
203
+ nonPremiumColors.forEach(reg => {
204
+ const match = line.match(reg);
205
+ if (match) {
206
+ colorIssues.push(`Línea ${index + 1}: ${line.trim()} (Detectado color genérico '${match[0]}')`);
207
+ }
208
+ });
209
+ }
210
+
211
+ // Chequear transiciones
212
+ if (line.includes("transition") || line.includes("cubic-bezier") || line.includes("animate-")) {
213
+ hasTransitions = true;
214
+ }
215
+ });
216
+
217
+ // Chequear fuentes
218
+ if (content.includes("font-family")) {
219
+ const hasPremium = premiumFonts.some(f => content.includes(f));
220
+ if (!hasPremium) {
221
+ fontIssues.push("Define 'font-family' pero no incluye fuentes premium recomendadas (Inter, Outfit, etc.).");
222
+ }
223
+ }
224
+
225
+ // Chequear balance estructural HTML/DOM
226
+ const structuralIssues = checkHtmlTagBalance(fileRel, content);
227
+
228
+ if (colorIssues.length > 0 || fontIssues.length > 0 || !hasTransitions || structuralIssues.length > 0) {
229
+ findings.push({
230
+ file: fileRel,
231
+ colorIssues: colorIssues.slice(0, 5), // Limitar a 5 por archivo
232
+ fontIssues,
233
+ hasTransitions,
234
+ structuralIssues
235
+ });
236
+ }
237
+ } catch (e) {}
238
+ });
239
+
240
+ // 4. Intento de Screenshot Headless (Opcional si provee puerto)
241
+ let screenshotStatus = "No ejecutado (Puerto no provisto o dev server inactivo)";
242
+ let screenshotPathRel = "";
243
+ if (args.localPort) {
244
+ try {
245
+ const screenshotFile = path.join(reportDir, "screenshot_ui.png");
246
+ execSync(`npx -y playwright-cli screenshot http://localhost:${args.localPort} ${screenshotFile} --timeout 5000`, { stdio: "ignore" });
247
+ if (fs.existsSync(screenshotFile)) {
248
+ screenshotStatus = `Captura realizada con éxito en puerto ${args.localPort}!`;
249
+ screenshotPathRel = `./screenshot_ui.png`;
250
+ }
251
+ } catch (e: any) {
252
+ screenshotStatus = `Intento fallido de captura: ${e.message || e}`;
253
+ }
254
+ }
255
+
256
+ // 5. Escribir reporte markdown premium
257
+ let totalIssues = 0;
258
+ findings.forEach(f => totalIssues += f.colorIssues.length + f.fontIssues.length + f.structuralIssues.length + (f.hasTransitions ? 0 : 1));
259
+
260
+ const markdown = `# 🎨 Reporte de Auditoría Estética y Estructural UI/UX: ${changeName}
261
+
262
+ Este reporte ha sido autogenerado por la herramienta premium **sdd_ui_auditor** para auditar el cumplimiento estricto de las directrices de percepción visual **sdd-ux-premium** y balance estructural de marcado de manera focalizada.
263
+
264
+ > [!NOTE]
265
+ > **Resumen del Diagnóstico Estético y Estructural (Impacto Localizado):**
266
+ > - **Total de Errores/Advertencias:** ${totalIssues}
267
+ > - **Archivos de UI Auditados con Observaciones:** ${findings.length}
268
+ > - **Captura de Pantalla Visual:** ${screenshotStatus}
269
+
270
+ ${screenshotPathRel ? `### 📸 Captura de Pantalla Realizada\n![UI Realtime Live](${screenshotPathRel})\n` : ""}
271
+
272
+ ## 📊 Detalle de Archivos Escaneados
273
+
274
+ ${findings.length === 0 ? "### ¡Felicidades! 🎉 No se encontraron problemas estéticos ni de marcado en los archivos modificados. Tu UI sigue las directrices premium al 100%." : findings.map(f => `
275
+ ### 📁 Archivo: \`${f.file}\`
276
+ - **Micro-animaciones / Transiciones:** ${f.hasTransitions ? "🟢 Detectadas" : "🟡 **FALTAN TRANSICIONES SUAVES** (Agrega cubic-bezier o transition)"}
277
+ ${f.structuralIssues.length > 0 ? `- **Errores de Marcado Estructural (DOM):**\n${f.structuralIssues.map(s => ` - 🔴 ${s}`).join("\n")}` : "🟢 Balance de etiquetas HTML correcto"}
278
+ ${f.colorIssues.length > 0 ? `- **Colores Genéricos Detectados:**\n${f.colorIssues.map(c => ` - ${c}`).join("\n")}` : "🟢 Colores correctos"}
279
+ ${f.fontIssues.length > 0 ? `- **Estilo Tipográfico:**\n${f.fontIssues.map(fi => ` - ${fi}`).join("\n")}` : "🟢 Tipografía correcta o heredada correctamente"}
280
+ `).join("\n---\n")}
281
+
282
+ ---
283
+
284
+ ## 💡 Recomendaciones para Diseño Premium
285
+
286
+ 1. **Reemplazo de Colores Planos:**
287
+ - En lugar de usar colores puros o genéricos (como rojo, azul o gris plano), define una paleta HSL adaptada.
288
+ - *Ejemplo:* Usa HSL con tonos pastel sofisticados o grises de base carbón (\`hsl(220, 15%, 16%)\`).
289
+
290
+ 2. **Tipografía moderna en CSS:**
291
+ - Asegura la importación de una tipografía de alta fidelidad:
292
+ \`\`\`css
293
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600&display=swap');
294
+ body {
295
+ font-family: 'Inter', sans-serif;
296
+ }
297
+ \`\`\`
298
+
299
+ 3. **Curvas de Transición Suaves:**
300
+ - En efectos hover o interactivos, evita transiciones secas de 0.1s. Utiliza:
301
+ \`\`\`css
302
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
303
+ \`\`\`
304
+ `;
305
+
306
+ fs.writeFileSync(reportPath, markdown, "utf-8");
307
+
308
+ return `[SDD UI Auditor] Reporte de estética UI generado con éxito en ${path.relative(projectRoot, reportPath)}. Total advertencias: ${totalIssues}.`;
309
+ }
310
+ })