zugzbot 1.0.18 → 1.0.19

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.
@@ -0,0 +1,673 @@
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 { RECOMMENDED_BRANDS } from "./sdd_design"
6
+
7
+ // Helper to safely resolve root directory (avoiding OpenCode bug where worktree is '/' in non-git repos)
8
+ const getRoot = (context: any) => {
9
+ if (context?.directory && context.directory !== "/") return context.directory;
10
+ if (context?.worktree && context.worktree !== "/") return context.worktree;
11
+ if (context?.cwd && context.cwd !== "/") return context.cwd;
12
+ return process.cwd();
13
+ };
14
+
15
+ // Helper to resolve state path
16
+ const getStateFilePath = (context: any) => {
17
+ const root = getRoot(context)
18
+ return path.resolve(root, ".openspec/sdd_state.json")
19
+ }
20
+
21
+ // Helper to resolve metrics path
22
+ const getMetricsFilePath = (root: string) => {
23
+ return path.resolve(root, ".openspec/.sdd_session_metrics.json")
24
+ }
25
+
26
+ // Helper to read state
27
+ const readState = (filePath: string) => {
28
+ const defaultState = {
29
+ phase: "F0_DETECT",
30
+ activeContract: "",
31
+ stack: {
32
+ core: [],
33
+ databases: []
34
+ },
35
+ updatedAt: new Date().toISOString()
36
+ }
37
+ try {
38
+ if (fs.existsSync(filePath)) {
39
+ return JSON.parse(fs.readFileSync(filePath, "utf8"))
40
+ }
41
+ } catch (e) {
42
+ // ignore parsing errors
43
+ }
44
+ return defaultState
45
+ }
46
+
47
+ // Helper to write state
48
+ const writeState = (filePath: string, state: any) => {
49
+ const dir = path.dirname(filePath)
50
+ if (!fs.existsSync(dir)) {
51
+ fs.mkdirSync(dir, { recursive: true })
52
+ }
53
+ state.updatedAt = new Date().toISOString()
54
+ fs.writeFileSync(filePath, JSON.stringify(state, null, 2), "utf8")
55
+ }
56
+
57
+ // Helper to read session metrics
58
+ const readMetrics = (metricsPath: string): any => {
59
+ try {
60
+ if (fs.existsSync(metricsPath)) {
61
+ return JSON.parse(fs.readFileSync(metricsPath, "utf8"))
62
+ }
63
+ } catch (e) {
64
+ // ignore
65
+ }
66
+ return null
67
+ }
68
+
69
+ // Format duration in human-readable minutes
70
+ const formatDuration = (ms: number): string => {
71
+ if (!ms || ms < 0) return "0 min"
72
+ const totalSeconds = Math.floor(ms / 1000)
73
+ const minutes = Math.floor(totalSeconds / 60)
74
+ const seconds = totalSeconds % 60
75
+ if (minutes === 0) return `${seconds} s`
76
+ if (seconds === 0) return `${minutes} min`
77
+ return `${minutes} min ${seconds} s`
78
+ }
79
+
80
+ // Format cost as USD
81
+ const formatCost = (cost: number): string => {
82
+ if (typeof cost !== "number" || !Number.isFinite(cost)) return "$0.00"
83
+ if (cost < 0.001) return `$${cost.toFixed(5)}`
84
+ if (cost < 1) return `$${cost.toFixed(4)}`
85
+ return `$${cost.toFixed(3)}`
86
+ }
87
+
88
+ // Format integer with thousands separator
89
+ const formatInt = (n: number): string => {
90
+ if (typeof n !== "number" || !Number.isFinite(n)) return "0"
91
+ return Math.round(n).toLocaleString("en-US")
92
+ }
93
+
94
+ // Build the session summary object from raw metrics + state
95
+ const buildSummary = (metrics: any, contractName: string, closedAt: string) => {
96
+ const startedAt = metrics?.startedAt || closedAt
97
+ const startedMs = Date.parse(startedAt) || Date.parse(closedAt)
98
+ const closedMs = Date.parse(closedAt)
99
+ const durationMs = Math.max(0, closedMs - startedMs)
100
+ const durationMinutes = +(durationMs / 60000).toFixed(2)
101
+
102
+ const totals = metrics?.totals || { cost: 0, tokensIn: 0, tokensOutput: 0, messages: 0 }
103
+ const byAgentRaw = metrics?.byAgent || {}
104
+ const byAgent = Object.entries(byAgentRaw)
105
+ .map(([agent, m]: [string, any]) => ({
106
+ agent,
107
+ cost: +(m.cost || 0).toFixed(6),
108
+ tokensIn: m.tokensIn || 0,
109
+ tokensOutput: m.tokensOutput || 0,
110
+ messages: m.messages || 0,
111
+ }))
112
+ .sort((a, b) => b.cost - a.cost)
113
+
114
+ return {
115
+ contractName,
116
+ sessionId: metrics?.sessionId || "",
117
+ startedAt,
118
+ closedAt,
119
+ durationMs,
120
+ durationMinutes,
121
+ totals: {
122
+ cost: +totals.cost.toFixed(6),
123
+ tokensIn: totals.tokensIn || 0,
124
+ tokensOutput: totals.tokensOutput || 0,
125
+ messages: totals.messages || 0,
126
+ },
127
+ byAgent,
128
+ }
129
+ }
130
+
131
+ // Render the human-readable markdown
132
+ const renderSummaryMd = (summary: any): string => {
133
+ const lines: string[] = []
134
+ lines.push(`# Resumen de Sesión SDD`)
135
+ lines.push(``)
136
+ lines.push(`* **Contrato**: \`${summary.contractName}\``)
137
+ lines.push(`* **Cerrado**: ${summary.closedAt}`)
138
+ lines.push(`* **Duración**: ${formatDuration(summary.durationMs)} (${summary.durationMinutes} min)`)
139
+ lines.push(``)
140
+ lines.push(`## Totales`)
141
+ lines.push(``)
142
+ lines.push(`| Métrica | Valor |`)
143
+ lines.push(`|---|---|`)
144
+ lines.push(`| Costo | ${formatCost(summary.totals.cost)} |`)
145
+ lines.push(`| Tokens entrada | ${formatInt(summary.totals.tokensIn)} |`)
146
+ lines.push(`| Tokens salida | ${formatInt(summary.totals.tokensOutput)} |`)
147
+ lines.push(`| Mensajes asistente | ${formatInt(summary.totals.messages)} |`)
148
+ lines.push(``)
149
+
150
+ if (summary.byAgent.length > 0) {
151
+ lines.push(`## Por agente`)
152
+ lines.push(``)
153
+ lines.push(`| Agente | Costo | Tokens in | Tokens out | Mensajes |`)
154
+ lines.push(`|---|---|---|---|---|`)
155
+ for (const a of summary.byAgent) {
156
+ lines.push(`| \`${a.agent}\` | ${formatCost(a.cost)} | ${formatInt(a.tokensIn)} | ${formatInt(a.tokensOutput)} | ${a.messages} |`)
157
+ }
158
+ lines.push(``)
159
+ }
160
+
161
+ lines.push(`## Conclusión`)
162
+ lines.push(``)
163
+ lines.push(`> **${formatDuration(summary.durationMs)}** · **${formatCost(summary.totals.cost)}** · **${formatInt(summary.totals.tokensIn + summary.totals.tokensOutput)} tokens** totales · **${summary.byAgent.length} agentes**.`)
164
+ lines.push(``)
165
+ return lines.join("\n")
166
+ }
167
+
168
+ // Append (or upsert) a single line in the global _sessions.jsonl
169
+ const upsertSessionsLog = (archiveDir: string, summary: any) => {
170
+ const logPath = path.join(archiveDir, "_sessions.jsonl")
171
+ const line = JSON.stringify({
172
+ contractName: summary.contractName,
173
+ sessionId: summary.sessionId,
174
+ startedAt: summary.startedAt,
175
+ closedAt: summary.closedAt,
176
+ durationMinutes: summary.durationMinutes,
177
+ cost: summary.totals.cost,
178
+ tokensIn: summary.totals.tokensIn,
179
+ tokensOutput: summary.totals.tokensOutput,
180
+ messages: summary.totals.messages,
181
+ })
182
+
183
+ let existing: string[] = []
184
+ if (fs.existsSync(logPath)) {
185
+ try {
186
+ existing = fs.readFileSync(logPath, "utf8").split("\n").filter((l) => l.trim().length > 0)
187
+ } catch (e) {
188
+ existing = []
189
+ }
190
+ }
191
+
192
+ // Deduplicate by contractName (replace previous entry if any)
193
+ const filtered = existing.filter((l) => {
194
+ try {
195
+ const obj = JSON.parse(l)
196
+ return obj?.contractName !== summary.contractName
197
+ } catch {
198
+ return true
199
+ }
200
+ })
201
+ filtered.push(line)
202
+
203
+ fs.writeFileSync(logPath, filtered.join("\n") + "\n", "utf8")
204
+ }
205
+
206
+ // Clear the metrics file once consumed
207
+ const clearMetrics = (metricsPath: string) => {
208
+ try {
209
+ if (fs.existsSync(metricsPath)) {
210
+ fs.unlinkSync(metricsPath)
211
+ }
212
+ } catch (e) {
213
+ // best-effort
214
+ }
215
+ }
216
+
217
+ // Write the session summary files into the archived contract folder
218
+ const writeSessionSummary = (archiveFolder: string, root: string) => {
219
+ try {
220
+ if (!fs.existsSync(archiveFolder)) return null
221
+ const metricsPath = getMetricsFilePath(root)
222
+ const metrics = readMetrics(metricsPath)
223
+ const folderName = path.basename(archiveFolder)
224
+ const closedAt = new Date().toISOString()
225
+ const summary = buildSummary(metrics, folderName, closedAt)
226
+
227
+ const jsonPath = path.join(archiveFolder, "session_summary.json")
228
+ const mdPath = path.join(archiveFolder, "session_summary.md")
229
+ fs.writeFileSync(jsonPath, JSON.stringify(summary, null, 2), "utf8")
230
+ fs.writeFileSync(mdPath, renderSummaryMd(summary), "utf8")
231
+
232
+ const archiveDir = path.dirname(archiveFolder)
233
+ upsertSessionsLog(archiveDir, summary)
234
+
235
+ // Clean up the transient metrics file once consumed
236
+ clearMetrics(metricsPath)
237
+
238
+ return summary
239
+ } catch (e) {
240
+ return null
241
+ }
242
+ }
243
+
244
+ // Helper to get PID file path
245
+ const getPidFilePath = (root: string) => {
246
+ return path.resolve(root, ".openspec/dev_server.pid")
247
+ }
248
+
249
+ // Tool: sdd_set_phase (file sdd_core.ts, export set_phase -> sdd_set_phase)
250
+ export const set_phase = tool({
251
+ description: "Establece la fase activa del ciclo de desarrollo SDD (F0_DETECT, F1_CONTRACT, F2_IMPLEMENTATION, F3_VERIFICATION, F4_DEPLOYMENT). Si phase=F1_CONTRACT y se pasa spec_name, crea la carpeta del spec atómicamente y devuelve la ruta absoluta. Si phase=F3_VERIFICATION, ejecuta auto-lint (no bloqueante, solo informativo) y aborta si hay errores de lint.",
252
+ args: {
253
+ phase: tool.schema.enum(["F0_DETECT", "F1_CONTRACT", "F2_IMPLEMENTATION", "F3_VERIFICATION", "F4_DEPLOYMENT"]).describe("La fase a establecer"),
254
+ activeContract: tool.schema.string().optional().describe("La ruta o nombre del archivo de contrato JSON activo (.openspec/specs/XXXX_TIMESTAMP_NAME/contract.json)"),
255
+ coreStack: tool.schema.array(tool.schema.string()).optional().describe("Tecnologías base del stack detectado"),
256
+ databases: tool.schema.array(tool.schema.string()).optional().describe("Bases de datos añadidas al stack"),
257
+ spec_name: tool.schema.string().optional().describe("(Solo F1_CONTRACT) Nombre del spec en minúsculas y guiones. Si se pasa junto con phase=F1_CONTRACT, crea la carpeta del spec atómicamente y devuelve la ruta completa."),
258
+ skip_lint_gate: tool.schema.boolean().default(false).describe("(Solo F3_VERIFICATION) Si true, no ejecuta el auto-lint gate."),
259
+ loopMode: tool.schema.boolean().optional().describe("Establece si el modo piloto automático (/loop) está activado para tomar decisiones autónomas de forma acumulativa."),
260
+ loopTargetIterations: tool.schema.number().optional().describe("Número total de iteraciones autónomas deseadas en el ciclo de mejora continua."),
261
+ loopCurrentIteration: tool.schema.number().optional().describe("Número de la iteración autónoma actual (empieza en 1)."),
262
+ },
263
+ async execute(args, context) {
264
+ const root = getRoot(context)
265
+ const filePath = getStateFilePath(context)
266
+ const currentState = readState(filePath)
267
+
268
+ // Auto-create spec folder atomically when entering F1_CONTRACT with spec_name
269
+ let autoCreatedContract: string | null = null
270
+ if (args.phase === "F1_CONTRACT" && args.spec_name) {
271
+ const specsDir = path.resolve(root, ".openspec/specs")
272
+ if (!fs.existsSync(specsDir)) {
273
+ fs.mkdirSync(specsDir, { recursive: true })
274
+ }
275
+ const now = new Date()
276
+ const yyyy = now.getFullYear()
277
+ const mm = String(now.getMonth() + 1).padStart(2, "0")
278
+ const dd = String(now.getDate()).padStart(2, "0")
279
+ const hh = String(now.getHours()).padStart(2, "0")
280
+ const mi = String(now.getMinutes()).padStart(2, "0")
281
+ const ss = String(now.getSeconds()).padStart(2, "0")
282
+ const folderName = `${yyyy}-${mm}-${dd}__${hh}-${mi}-${ss}_${args.spec_name}`
283
+ const targetFolder = path.join(specsDir, folderName)
284
+ fs.mkdirSync(targetFolder, { recursive: true })
285
+ autoCreatedContract = path.relative(root, path.join(targetFolder, "contract.json"))
286
+ }
287
+
288
+ // Auto-archive the active spec folder when resetting to F0_DETECT
289
+ if (args.phase === "F0_DETECT" && currentState.activeContract) {
290
+ const contractPath = path.resolve(root, currentState.activeContract)
291
+ if (fs.existsSync(contractPath)) {
292
+ const specFolder = path.dirname(contractPath)
293
+ const archiveDir = path.resolve(root, ".openspec/archive")
294
+ if (!fs.existsSync(archiveDir)) {
295
+ fs.mkdirSync(archiveDir, { recursive: true })
296
+ }
297
+ const folderName = path.basename(specFolder)
298
+ const targetArchiveFolder = path.join(archiveDir, folderName)
299
+
300
+ try {
301
+ fs.renameSync(specFolder, targetArchiveFolder)
302
+ } catch (e) {
303
+ // ignore or log
304
+ }
305
+
306
+ // Write session summary into the just-archived folder
307
+ writeSessionSummary(targetArchiveFolder, root)
308
+ }
309
+ }
310
+
311
+ if (args.phase === "F4_DEPLOYMENT") {
312
+ // Wait up to 60s for Docker daemon to be ready (macOS open -a Docker fallback)
313
+ const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
314
+ for (let i = 0; i < 12; i++) {
315
+ try {
316
+ execSync("docker info", { stdio: "ignore", timeout: 3000 })
317
+ break // ready!
318
+ } catch (e) {
319
+ if (i === 0) {
320
+ try { execSync("open -a Docker", { stdio: "ignore" }) } catch (err) {}
321
+ }
322
+ await sleep(5000)
323
+ }
324
+ }
325
+ }
326
+
327
+ // Auto-lint gate on transition to F3_VERIFICATION
328
+ let lintWarning: string | null = null
329
+ if (args.phase === "F3_VERIFICATION" && !args.skip_lint_gate) {
330
+ try {
331
+ const out = execSync("npx eslint src/ --quiet 2>&1 || true", {
332
+ cwd: root,
333
+ encoding: "utf8",
334
+ timeout: 120_000,
335
+ })
336
+ const isCircularError = out.toLowerCase().includes("converting circular structure") || out.toLowerCase().includes("circular structure")
337
+ if (out.toLowerCase().includes("error") || (out.trim().length > 0 && !isCircularError)) {
338
+ lintWarning = `Lint encontró posibles errores. Considere abortar la transición a F3 y volver a F2:\n${out.slice(0, 1000)}`
339
+ } else if (isCircularError) {
340
+ console.log("[set_phase] Ignorado falso positivo circular de ESLint.")
341
+ }
342
+ } catch (e) {
343
+ // best-effort, no abortamos
344
+ }
345
+ }
346
+
347
+ currentState.phase = args.phase
348
+ if (autoCreatedContract) {
349
+ currentState.activeContract = autoCreatedContract
350
+ } else if (args.activeContract !== undefined) {
351
+ currentState.activeContract = args.activeContract
352
+ }
353
+ if (args.coreStack !== undefined) currentState.stack.core = args.coreStack
354
+ if (args.databases !== undefined) currentState.stack.databases = args.databases
355
+ if (args.loopMode !== undefined) currentState.loopMode = args.loopMode
356
+ if (args.loopTargetIterations !== undefined) currentState.loopTargetIterations = args.loopTargetIterations
357
+ if (args.loopCurrentIteration !== undefined) currentState.loopCurrentIteration = args.loopCurrentIteration
358
+
359
+ // If resetting to F0_DETECT, ensure everything is 100% clean
360
+ if (args.phase === "F0_DETECT") {
361
+ if (args.loopMode === undefined && args.loopCurrentIteration === undefined) {
362
+ currentState.loopMode = false
363
+ currentState.loopTargetIterations = 1
364
+ currentState.loopCurrentIteration = 1
365
+ }
366
+ // Clean up running servers
367
+ const pidFile = getPidFilePath(root)
368
+ if (fs.existsSync(pidFile)) {
369
+ try {
370
+ const pid = parseInt(fs.readFileSync(pidFile, "utf8").trim(), 10)
371
+ if (!isNaN(pid)) {
372
+ try { process.kill(-pid, "SIGKILL") } catch (err) {
373
+ try { process.kill(pid, "SIGKILL") } catch (err2) {}
374
+ }
375
+ }
376
+ fs.unlinkSync(pidFile)
377
+ } catch (e) {}
378
+ }
379
+
380
+ currentState.activeContract = ""
381
+ currentState.stack.core = []
382
+ currentState.stack.databases = []
383
+
384
+ // Clean up empty directories in .openspec/specs/
385
+ try {
386
+ const specsDir = path.resolve(root, ".openspec/specs")
387
+ if (fs.existsSync(specsDir)) {
388
+ const files = fs.readdirSync(specsDir)
389
+ for (const f of files) {
390
+ const fullPath = path.join(specsDir, f)
391
+ if (fs.statSync(fullPath).isDirectory()) {
392
+ const subfiles = fs.readdirSync(fullPath)
393
+ if (subfiles.length === 0) {
394
+ fs.rmdirSync(fullPath)
395
+ }
396
+ }
397
+ }
398
+ }
399
+ } catch (e) {
400
+ // ignore
401
+ }
402
+
403
+ // Clean up transient Playwright folders
404
+ try {
405
+ const playwrightTmpDir = path.resolve(root, ".openspec/.playwright")
406
+ if (fs.existsSync(playwrightTmpDir)) {
407
+ fs.rmSync(playwrightTmpDir, { recursive: true, force: true })
408
+ }
409
+ } catch (e) {
410
+ // ignore
411
+ }
412
+ }
413
+
414
+ writeState(filePath, currentState)
415
+
416
+ const response: any = {
417
+ status: lintWarning ? "WARNING" : "SUCCESS",
418
+ message: `Fase transicionada exitosamente a ${currentState.phase}`,
419
+ state: currentState,
420
+ }
421
+ if (autoCreatedContract) {
422
+ response.activeContract = autoCreatedContract
423
+ response.message = `Fase F1_CONTRACT activada con spec folder creado atómicamente: ${autoCreatedContract}`
424
+ }
425
+ if (lintWarning) {
426
+ response.lintWarning = lintWarning
427
+ }
428
+ return JSON.stringify(response, null, 2)
429
+ }
430
+ })
431
+
432
+ // Tool: sdd_get_state (file sdd_core.ts, export get_state -> sdd_get_state)
433
+ export const get_state = tool({
434
+ description: "Obtiene el estado de desarrollo actual (fase activa, contrato seleccionado, stack validado)",
435
+ args: {},
436
+ async execute(args, context) {
437
+ const filePath = getStateFilePath(context)
438
+ const currentState = readState(filePath)
439
+ return JSON.stringify(currentState, null, 2)
440
+ }
441
+ })
442
+
443
+ // Tool: sdd_get_initial_session_data
444
+ export const get_initial_session_data = tool({
445
+ description: "Obtiene atómicamente todos los datos de inicio de sesión: el estado actual del arnés, las memorias históricas clave del Brain ('learnings', 'design', 'routing'), y la lista curada de recomendaciones de diseño de Oh My Design. Reemplaza las llamadas secuenciales a sdd_get_state, brain_read_memory y sdd_list_design_recommendations.",
446
+ args: {},
447
+ async execute(args, context) {
448
+ const root = getRoot(context)
449
+ const statePath = getStateFilePath(context)
450
+ const currentState = readState(statePath)
451
+
452
+ // Read brain memory
453
+ let brainMemory: any = { found: false }
454
+ const brainFilePath = path.resolve(root, ".openspec/brain.md")
455
+ if (fs.existsSync(brainFilePath)) {
456
+ try {
457
+ const content = fs.readFileSync(brainFilePath, "utf8") || ""
458
+ const lines = content.split(/\r?\n/)
459
+ const sections: Record<string, string[]> = {}
460
+ let currentHeader = "general"
461
+ for (const line of lines) {
462
+ if (line.startsWith("# ")) {
463
+ currentHeader = line.substring(2).trim().toLowerCase()
464
+ sections[currentHeader] = []
465
+ } else if (line.trim().length > 0) {
466
+ if (!sections[currentHeader]) sections[currentHeader] = []
467
+ sections[currentHeader].push(line)
468
+ }
469
+ }
470
+
471
+ let prunedContent = ""
472
+ for (const [header, bodyLines] of Object.entries(sections)) {
473
+ const limit = 10
474
+ const finalLines = bodyLines.slice(-limit)
475
+ const formattedHeader = header.charAt(0).toUpperCase() + header.slice(1)
476
+ prunedContent += `# ${formattedHeader}\n` + finalLines.join("\n") + "\n\n"
477
+ }
478
+
479
+ brainMemory = {
480
+ found: true,
481
+ content: prunedContent.trim()
482
+ }
483
+ } catch (e) {}
484
+ }
485
+
486
+ // Get design recommendations
487
+ const recommendations = RECOMMENDED_BRANDS
488
+
489
+ return JSON.stringify({
490
+ status: "SUCCESS",
491
+ state: currentState,
492
+ brainMemory,
493
+ designRecommendations: recommendations,
494
+ note: "Inicialización completada. No es necesario que llames a sdd_get_state, brain_read_memory o sdd_list_design_recommendations por separado."
495
+ }, null, 2)
496
+ }
497
+ })
498
+
499
+ // Tool: sdd_save_active_brief
500
+ export const save_active_brief = tool({
501
+ description: "Guarda un resumen breve y estructurado del spec o contrato activo en .openspec/active-brief.md para que sirva de anclaje de contexto de sistema (System State Anchoring) para el subagente sdd-coder o sdd-tester.",
502
+ args: {
503
+ brief: tool.schema.string().describe("Contenido en formato Markdown con el resumen del spec activo, componentes, diseño y dependencias.")
504
+ },
505
+ async execute(args, context) {
506
+ const root = getRoot(context)
507
+ const openspecDir = path.resolve(root, ".openspec")
508
+ if (!fs.existsSync(openspecDir)) {
509
+ fs.mkdirSync(openspecDir, { recursive: true })
510
+ }
511
+
512
+ // Attempt to read the active contract path from sdd_state.json and extract files_affected
513
+ let filesAffected: string[] = []
514
+ try {
515
+ const statePath = path.resolve(openspecDir, "sdd_state.json")
516
+ if (fs.existsSync(statePath)) {
517
+ const state = JSON.parse(fs.readFileSync(statePath, "utf8"))
518
+ if (state.activeContract) {
519
+ const contractPath = path.resolve(root, state.activeContract)
520
+ if (fs.existsSync(contractPath)) {
521
+ const contract = JSON.parse(fs.readFileSync(contractPath, "utf8"))
522
+ if (contract && Array.isArray(contract.files_affected)) {
523
+ filesAffected = contract.files_affected
524
+ }
525
+ }
526
+ }
527
+ }
528
+ } catch (e) {
529
+ // ignore
530
+ }
531
+
532
+ let enrichedBrief = args.brief
533
+
534
+ // Append structured section split if filesAffected is populated
535
+ if (filesAffected.length > 0) {
536
+ enrichedBrief += `\n\n## [CRITICAL] POLÍTICA DE ZERO-SEARCH Y PRUEBAS INCREMENTALES\n`
537
+ enrichedBrief += `La lista de archivos exactos creados o modificados en este contrato es:\n`
538
+ enrichedBrief += filesAffected.map(f => `- \`${f}\``).join("\n") + "\n\n"
539
+
540
+ enrichedBrief += `### CODER_CONTEXT (F2)\n`
541
+ enrichedBrief += `- Debes editar, crear y leer EXCLUSIVAMENTE estos archivos de producción: ${filesAffected.map(f => `\`${f}\``).join(", ")}.\n`
542
+ enrichedBrief += `- PROHIBIDO utilizar glob o grep de manera exploratoria ciega.\n\n`
543
+
544
+ enrichedBrief += `### TESTER_CONTEXT (F3)\n`
545
+ enrichedBrief += `- Debes correr pruebas enfocándote SOLO en tests que verifiquen estos archivos.\n`
546
+ enrichedBrief += `- Ejecuta el linter de forma dirigida: \`npx eslint ${filesAffected.join(" ")}\`.\n`
547
+ }
548
+
549
+ const briefPath = path.resolve(openspecDir, "active-brief.md")
550
+ fs.writeFileSync(briefPath, enrichedBrief, "utf8")
551
+ return JSON.stringify({
552
+ status: "SUCCESS",
553
+ message: `Brief de contexto de sistema guardado exitosamente en .openspec/active-brief.md`,
554
+ filePath: briefPath
555
+ }, null, 2)
556
+ }
557
+ })
558
+
559
+ // Tool: sdd_create_spec_folder (file sdd_core.ts, export create_spec_folder -> sdd_create_spec_folder)
560
+ export const create_spec_folder = tool({
561
+ description: "Crea una nueva carpeta ordenada para la especificación del cambio (formato: yyyy-mm-dd__hh-mm-ss_nombre)",
562
+ args: {
563
+ name: tool.schema.string().describe("Nombre del cambio en minúsculas y separado por guiones (ej. sumar-endpoint)")
564
+ },
565
+ async execute(args, context) {
566
+ const root = getRoot(context)
567
+ const specsDir = path.resolve(root, ".openspec/specs")
568
+
569
+ if (!fs.existsSync(specsDir)) {
570
+ fs.mkdirSync(specsDir, { recursive: true })
571
+ }
572
+
573
+ const now = new Date()
574
+ const year = now.getFullYear()
575
+ const month = String(now.getMonth() + 1).padStart(2, "0")
576
+ const day = String(now.getDate()).padStart(2, "0")
577
+ const hours = String(now.getHours()).padStart(2, "0")
578
+ const minutes = String(now.getMinutes()).padStart(2, "0")
579
+ const seconds = String(now.getSeconds()).padStart(2, "0")
580
+ const formattedDate = `${year}-${month}-${day}__${hours}-${minutes}-${seconds}`
581
+ const folderName = `${formattedDate}_${args.name}`
582
+ const targetFolder = path.join(specsDir, folderName)
583
+
584
+ fs.mkdirSync(targetFolder, { recursive: true })
585
+
586
+ return JSON.stringify({
587
+ status: "SUCCESS",
588
+ folderName,
589
+ folderPath: path.relative(root, targetFolder)
590
+ }, null, 2)
591
+ }
592
+ })
593
+
594
+ // Tool: sdd_validate_contract
595
+ export const validate_contract = tool({
596
+ description: "Valida de forma estricta el archivo contract.json contra el contract-schema.json oficial para detectar errores de tipos, alucinaciones o campos faltantes antes de avanzar.",
597
+ args: {
598
+ contractPath: tool.schema.string().describe("Ruta relativa al contract.json (ej. '.openspec/specs/XXXX/contract.json')")
599
+ },
600
+ async execute(args, context) {
601
+ const root = getRoot(context)
602
+ const targetPath = path.resolve(root, args.contractPath)
603
+ if (!fs.existsSync(targetPath)) {
604
+ return JSON.stringify({ success: false, error: `El archivo del contrato '${args.contractPath}' no existe.` }, null, 2)
605
+ }
606
+
607
+ try {
608
+ const contract = JSON.parse(fs.readFileSync(targetPath, "utf8"))
609
+ const schemaPath = path.resolve(root, ".opencode/contract-schema.json")
610
+ if (!fs.existsSync(schemaPath)) {
611
+ return JSON.stringify({ success: false, error: "No se encontró el archivo contract-schema.json en .opencode/" }, null, 2)
612
+ }
613
+
614
+ const errors: string[] = []
615
+
616
+ const validateField = (name: string, val: any, expectedType: string, required = false) => {
617
+ if (val === undefined || val === null) {
618
+ if (required) errors.push(`Campo requerido faltante: '${name}'`);
619
+ return;
620
+ }
621
+ if (expectedType === "array") {
622
+ if (!Array.isArray(val)) errors.push(`Campo '${name}' debe ser de tipo array.`);
623
+ } else if (expectedType === "object") {
624
+ if (typeof val !== "object" || Array.isArray(val)) errors.push(`Campo '${name}' debe ser de tipo object.`);
625
+ } else {
626
+ if (typeof val !== expectedType) errors.push(`Campo '${name}' debe ser de tipo ${expectedType}.`);
627
+ }
628
+ }
629
+
630
+ // Check required fields
631
+ validateField("contractName", contract.contractName, "string", true)
632
+ validateField("description", contract.description, "string", true)
633
+ validateField("category", contract.category, "string", true)
634
+ validateField("stack", contract.stack, "object", true)
635
+ validateField("test_scenarios", contract.test_scenarios, "array", true)
636
+ validateField("files_affected", contract.files_affected, "array", true)
637
+
638
+ if (contract.stack) {
639
+ validateField("stack.core", contract.stack.core, "array", true)
640
+ validateField("stack.databases", contract.stack.databases, "array", false)
641
+ }
642
+
643
+ if (contract.test_scenarios) {
644
+ contract.test_scenarios.forEach((ts: any, i: number) => {
645
+ validateField(`test_scenarios[${i}].id`, ts.id, "string", true)
646
+ validateField(`test_scenarios[${i}].name`, ts.name, "string", true)
647
+ validateField(`test_scenarios[${i}].type`, ts.type, "string", true)
648
+ validateField(`test_scenarios[` + i + `].feature_ref`, ts.feature_ref, "string", true)
649
+ validateField(`test_scenarios[` + i + `].then`, ts.then, "string", true)
650
+ })
651
+ }
652
+
653
+ if (errors.length > 0) {
654
+ return JSON.stringify({
655
+ status: "FAIL",
656
+ message: "El contrato tiene errores de validación contra el esquema.",
657
+ errors
658
+ }, null, 2)
659
+ }
660
+
661
+ return JSON.stringify({
662
+ status: "SUCCESS",
663
+ message: "¡El contrato contract.json es 100% válido y cumple estrictamente con el esquema!"
664
+ }, null, 2)
665
+
666
+ } catch (e: any) {
667
+ return JSON.stringify({
668
+ status: "FAIL",
669
+ message: `Error al parsear o validar contract.json: ${e.message}`
670
+ }, null, 2)
671
+ }
672
+ }
673
+ })