zugzbot 1.0.18 → 1.0.20

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.
@@ -1,2072 +0,0 @@
1
- import { tool } from "@opencode-ai/plugin"
2
- import fs from "fs"
3
- import path from "path"
4
- import { execSync, spawn } from "child_process"
5
-
6
- // Helper to safely resolve root directory (avoiding OpenCode bug where worktree is '/' in non-git repos)
7
- const getRoot = (context: any) => {
8
- if (context?.directory && context.directory !== "/") return context.directory;
9
- if (context?.worktree && context.worktree !== "/") return context.worktree;
10
- if (context?.cwd && context.cwd !== "/") return context.cwd;
11
- return process.cwd();
12
- };
13
-
14
-
15
- // Helper to parse semantic errors from compiler and linter outputs (reducing raw trace log bloat for the LLM)
16
- const parseSemanticErrors = (rawOutput: string, type: "eslint" | "tsc"): any[] => {
17
- const parsed: any[] = []
18
- if (type === "eslint") {
19
- const lines = rawOutput.split("\n")
20
- for (const line of lines) {
21
- const match = line.match(/^([^:]+):line\s+(\d+),\s+col\s+(\d+),\s+(.+)$/i) || line.match(/^([^\s]+):(\d+):(\d+):\s+(.+)$/)
22
- if (match) {
23
- parsed.push({
24
- file: match[1].trim(),
25
- line: parseInt(match[2], 10),
26
- column: parseInt(match[3], 10),
27
- error: match[4].trim()
28
- })
29
- } else if (line.includes("error") && line.trim().length > 0) {
30
- parsed.push({ raw: line.trim() })
31
- }
32
- }
33
- } else if (type === "tsc") {
34
- const lines = rawOutput.split("\n")
35
- for (const line of lines) {
36
- const match = line.match(/^([^(]+)\((\d+),(\d+)\):\s+(error\s+TS\d+:\s+.+)$/)
37
- if (match) {
38
- parsed.push({
39
- file: match[1].trim(),
40
- line: parseInt(match[2], 10),
41
- column: parseInt(match[3], 10),
42
- error: match[4].trim()
43
- })
44
- } else if (line.includes("error TS") && line.trim().length > 0) {
45
- parsed.push({ raw: line.trim() })
46
- }
47
- }
48
- }
49
- return parsed.length > 0 ? parsed : [{ raw: rawOutput.slice(0, 1500) }]
50
- }
51
-
52
- // Helper to resolve state path
53
- const getStateFilePath = (context: any) => {
54
- const root = getRoot(context)
55
- return path.resolve(root, ".openspec/sdd_state.json")
56
- }
57
-
58
- // Helper to resolve metrics path
59
- const getMetricsFilePath = (root: string) => {
60
- return path.resolve(root, ".openspec/.sdd_session_metrics.json")
61
- }
62
-
63
- // Helper to read state
64
- const readState = (filePath: string) => {
65
- const defaultState = {
66
- phase: "F0_DETECT",
67
- activeContract: "",
68
- stack: {
69
- core: [],
70
- databases: []
71
- },
72
- updatedAt: new Date().toISOString()
73
- }
74
- try {
75
- if (fs.existsSync(filePath)) {
76
- return JSON.parse(fs.readFileSync(filePath, "utf8"))
77
- }
78
- } catch (e) {
79
- // ignore parsing errors
80
- }
81
- return defaultState
82
- }
83
-
84
- // Helper to write state
85
- const writeState = (filePath: string, state: any) => {
86
- const dir = path.dirname(filePath)
87
- if (!fs.existsSync(dir)) {
88
- fs.mkdirSync(dir, { recursive: true })
89
- }
90
- state.updatedAt = new Date().toISOString()
91
- fs.writeFileSync(filePath, JSON.stringify(state, null, 2), "utf8")
92
- }
93
-
94
- // Helper to read session metrics
95
- const readMetrics = (metricsPath: string): any => {
96
- try {
97
- if (fs.existsSync(metricsPath)) {
98
- return JSON.parse(fs.readFileSync(metricsPath, "utf8"))
99
- }
100
- } catch (e) {
101
- // ignore
102
- }
103
- return null
104
- }
105
-
106
- // Format duration in human-readable minutes
107
- const formatDuration = (ms: number): string => {
108
- if (!ms || ms < 0) return "0 min"
109
- const totalSeconds = Math.floor(ms / 1000)
110
- const minutes = Math.floor(totalSeconds / 60)
111
- const seconds = totalSeconds % 60
112
- if (minutes === 0) return `${seconds} s`
113
- if (seconds === 0) return `${minutes} min`
114
- return `${minutes} min ${seconds} s`
115
- }
116
-
117
- // Format cost as USD
118
- const formatCost = (cost: number): string => {
119
- if (typeof cost !== "number" || !Number.isFinite(cost)) return "$0.00"
120
- if (cost < 0.001) return `$${cost.toFixed(5)}`
121
- if (cost < 1) return `$${cost.toFixed(4)}`
122
- return `$${cost.toFixed(3)}`
123
- }
124
-
125
- // Format integer with thousands separator
126
- const formatInt = (n: number): string => {
127
- if (typeof n !== "number" || !Number.isFinite(n)) return "0"
128
- return Math.round(n).toLocaleString("en-US")
129
- }
130
-
131
- // Build the session summary object from raw metrics + state
132
- const buildSummary = (metrics: any, contractName: string, closedAt: string) => {
133
- const startedAt = metrics?.startedAt || closedAt
134
- const startedMs = Date.parse(startedAt) || Date.parse(closedAt)
135
- const closedMs = Date.parse(closedAt)
136
- const durationMs = Math.max(0, closedMs - startedMs)
137
- const durationMinutes = +(durationMs / 60000).toFixed(2)
138
-
139
- const totals = metrics?.totals || { cost: 0, tokensIn: 0, tokensOutput: 0, messages: 0 }
140
- const byAgentRaw = metrics?.byAgent || {}
141
- const byAgent = Object.entries(byAgentRaw)
142
- .map(([agent, m]: [string, any]) => ({
143
- agent,
144
- cost: +(m.cost || 0).toFixed(6),
145
- tokensIn: m.tokensIn || 0,
146
- tokensOutput: m.tokensOutput || 0,
147
- messages: m.messages || 0,
148
- }))
149
- .sort((a, b) => b.cost - a.cost)
150
-
151
- return {
152
- contractName,
153
- sessionId: metrics?.sessionId || "",
154
- startedAt,
155
- closedAt,
156
- durationMs,
157
- durationMinutes,
158
- totals: {
159
- cost: +totals.cost.toFixed(6),
160
- tokensIn: totals.tokensIn || 0,
161
- tokensOutput: totals.tokensOutput || 0,
162
- messages: totals.messages || 0,
163
- },
164
- byAgent,
165
- }
166
- }
167
-
168
- // Render the human-readable markdown
169
- const renderSummaryMd = (summary: any): string => {
170
- const lines: string[] = []
171
- lines.push(`# Resumen de Sesión SDD`)
172
- lines.push(``)
173
- lines.push(`* **Contrato**: \`${summary.contractName}\``)
174
- lines.push(`* **Cerrado**: ${summary.closedAt}`)
175
- lines.push(`* **Duración**: ${formatDuration(summary.durationMs)} (${summary.durationMinutes} min)`)
176
- lines.push(``)
177
- lines.push(`## Totales`)
178
- lines.push(``)
179
- lines.push(`| Métrica | Valor |`)
180
- lines.push(`|---|---|`)
181
- lines.push(`| Costo | ${formatCost(summary.totals.cost)} |`)
182
- lines.push(`| Tokens entrada | ${formatInt(summary.totals.tokensIn)} |`)
183
- lines.push(`| Tokens salida | ${formatInt(summary.totals.tokensOutput)} |`)
184
- lines.push(`| Mensajes asistente | ${formatInt(summary.totals.messages)} |`)
185
- lines.push(``)
186
-
187
- if (summary.byAgent.length > 0) {
188
- lines.push(`## Por agente`)
189
- lines.push(``)
190
- lines.push(`| Agente | Costo | Tokens in | Tokens out | Mensajes |`)
191
- lines.push(`|---|---|---|---|---|`)
192
- for (const a of summary.byAgent) {
193
- lines.push(`| \`${a.agent}\` | ${formatCost(a.cost)} | ${formatInt(a.tokensIn)} | ${formatInt(a.tokensOutput)} | ${a.messages} |`)
194
- }
195
- lines.push(``)
196
- }
197
-
198
- lines.push(`## Conclusión`)
199
- lines.push(``)
200
- lines.push(`> **${formatDuration(summary.durationMs)}** · **${formatCost(summary.totals.cost)}** · **${formatInt(summary.totals.tokensIn + summary.totals.tokensOutput)} tokens** totales · **${summary.byAgent.length} agentes**.`)
201
- lines.push(``)
202
- return lines.join("\n")
203
- }
204
-
205
- // Append (or upsert) a single line in the global _sessions.jsonl
206
- const upsertSessionsLog = (archiveDir: string, summary: any) => {
207
- const logPath = path.join(archiveDir, "_sessions.jsonl")
208
- const line = JSON.stringify({
209
- contractName: summary.contractName,
210
- sessionId: summary.sessionId,
211
- startedAt: summary.startedAt,
212
- closedAt: summary.closedAt,
213
- durationMinutes: summary.durationMinutes,
214
- cost: summary.totals.cost,
215
- tokensIn: summary.totals.tokensIn,
216
- tokensOutput: summary.totals.tokensOutput,
217
- messages: summary.totals.messages,
218
- })
219
-
220
- let existing: string[] = []
221
- if (fs.existsSync(logPath)) {
222
- try {
223
- existing = fs.readFileSync(logPath, "utf8").split("\n").filter((l) => l.trim().length > 0)
224
- } catch (e) {
225
- existing = []
226
- }
227
- }
228
-
229
- // Deduplicate by contractName (replace previous entry if any)
230
- const filtered = existing.filter((l) => {
231
- try {
232
- const obj = JSON.parse(l)
233
- return obj?.contractName !== summary.contractName
234
- } catch {
235
- return true
236
- }
237
- })
238
- filtered.push(line)
239
-
240
- fs.writeFileSync(logPath, filtered.join("\n") + "\n", "utf8")
241
- }
242
-
243
- // Clear the metrics file once consumed
244
- const clearMetrics = (metricsPath: string) => {
245
- try {
246
- if (fs.existsSync(metricsPath)) {
247
- fs.unlinkSync(metricsPath)
248
- }
249
- } catch (e) {
250
- // best-effort
251
- }
252
- }
253
-
254
- // Write the session summary files into the archived contract folder
255
- const writeSessionSummary = (archiveFolder: string, root: string) => {
256
- try {
257
- if (!fs.existsSync(archiveFolder)) return null
258
- const metricsPath = getMetricsFilePath(root)
259
- const metrics = readMetrics(metricsPath)
260
- const folderName = path.basename(archiveFolder)
261
- const closedAt = new Date().toISOString()
262
- const summary = buildSummary(metrics, folderName, closedAt)
263
-
264
- const jsonPath = path.join(archiveFolder, "session_summary.json")
265
- const mdPath = path.join(archiveFolder, "session_summary.md")
266
- fs.writeFileSync(jsonPath, JSON.stringify(summary, null, 2), "utf8")
267
- fs.writeFileSync(mdPath, renderSummaryMd(summary), "utf8")
268
-
269
- const archiveDir = path.dirname(archiveFolder)
270
- upsertSessionsLog(archiveDir, summary)
271
-
272
- // Clean up the transient metrics file once consumed
273
- clearMetrics(metricsPath)
274
-
275
- return summary
276
- } catch (e) {
277
- return null
278
- }
279
- }
280
-
281
- // Tool: sdd_set_phase (file sdd.ts, export set_phase -> sdd_set_phase)
282
- export const set_phase = tool({
283
- 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.",
284
- args: {
285
- phase: tool.schema.enum(["F0_DETECT", "F1_CONTRACT", "F2_IMPLEMENTATION", "F3_VERIFICATION", "F4_DEPLOYMENT"]).describe("La fase a establecer"),
286
- activeContract: tool.schema.string().optional().describe("La ruta o nombre del archivo de contrato JSON activo (.openspec/specs/XXXX_TIMESTAMP_NAME/contract.json)"),
287
- coreStack: tool.schema.array(tool.schema.string()).optional().describe("Tecnologías base del stack detectado"),
288
- databases: tool.schema.array(tool.schema.string()).optional().describe("Bases de datos añadidas al stack"),
289
- 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."),
290
- skip_lint_gate: tool.schema.boolean().default(false).describe("(Solo F3_VERIFICATION) Si true, no ejecuta el auto-lint gate."),
291
- loopMode: tool.schema.boolean().optional().describe("Establece si el modo piloto automático (/loop) está activado para tomar decisiones autónomas de forma acumulativa."),
292
- loopTargetIterations: tool.schema.number().optional().describe("Número total de iteraciones autónomas deseadas en el ciclo de mejora continua."),
293
- loopCurrentIteration: tool.schema.number().optional().describe("Número de la iteración autónoma actual (empieza en 1)."),
294
- },
295
- async execute(args, context) {
296
- const root = getRoot(context)
297
- const filePath = getStateFilePath(context)
298
- const currentState = readState(filePath)
299
-
300
- // Auto-create spec folder atomically when entering F1_CONTRACT with spec_name
301
- let autoCreatedContract: string | null = null
302
- if (args.phase === "F1_CONTRACT" && args.spec_name) {
303
- const specsDir = path.resolve(root, ".openspec/specs")
304
- if (!fs.existsSync(specsDir)) {
305
- fs.mkdirSync(specsDir, { recursive: true })
306
- }
307
- const now = new Date()
308
- const yyyy = now.getFullYear()
309
- const mm = String(now.getMonth() + 1).padStart(2, "0")
310
- const dd = String(now.getDate()).padStart(2, "0")
311
- const hh = String(now.getHours()).padStart(2, "0")
312
- const mi = String(now.getMinutes()).padStart(2, "0")
313
- const ss = String(now.getSeconds()).padStart(2, "0")
314
- const folderName = `${yyyy}-${mm}-${dd}__${hh}-${mi}-${ss}_${args.spec_name}`
315
- const targetFolder = path.join(specsDir, folderName)
316
- fs.mkdirSync(targetFolder, { recursive: true })
317
- autoCreatedContract = path.relative(root, path.join(targetFolder, "contract.json"))
318
- }
319
-
320
- // Auto-archive the active spec folder when resetting to F0_DETECT
321
- if (args.phase === "F0_DETECT" && currentState.activeContract) {
322
- const contractPath = path.resolve(root, currentState.activeContract)
323
- if (fs.existsSync(contractPath)) {
324
- const specFolder = path.dirname(contractPath)
325
- const archiveDir = path.resolve(root, ".openspec/archive")
326
- if (!fs.existsSync(archiveDir)) {
327
- fs.mkdirSync(archiveDir, { recursive: true })
328
- }
329
- const folderName = path.basename(specFolder)
330
- const targetArchiveFolder = path.join(archiveDir, folderName)
331
-
332
- try {
333
- fs.renameSync(specFolder, targetArchiveFolder)
334
- } catch (e) {
335
- // ignore or log
336
- }
337
-
338
- // Write session summary into the just-archived folder
339
- writeSessionSummary(targetArchiveFolder, root)
340
- }
341
- }
342
-
343
- if (args.phase === "F4_DEPLOYMENT") {
344
- // Wait up to 60s for Docker daemon to be ready (macOS open -a Docker fallback)
345
- const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
346
- for (let i = 0; i < 12; i++) {
347
- try {
348
- execSync("docker info", { stdio: "ignore", timeout: 3000 })
349
- break // ready!
350
- } catch (e) {
351
- if (i === 0) {
352
- try { execSync("open -a Docker", { stdio: "ignore" }) } catch (err) {}
353
- }
354
- await sleep(5000)
355
- }
356
- }
357
- }
358
-
359
- // Auto-lint gate on transition to F3_VERIFICATION
360
- let lintWarning: string | null = null
361
- if (args.phase === "F3_VERIFICATION" && !args.skip_lint_gate) {
362
- try {
363
- const out = execSync("npx eslint src/ --quiet 2>&1 || true", {
364
- cwd: root,
365
- encoding: "utf8",
366
- timeout: 120_000,
367
- })
368
- const isCircularError = out.toLowerCase().includes("converting circular structure") || out.toLowerCase().includes("circular structure")
369
- if (out.toLowerCase().includes("error") || (out.trim().length > 0 && !isCircularError)) {
370
- lintWarning = `Lint encontró posibles errores. Considere abortar la transición a F3 y volver a F2:\n${out.slice(0, 1000)}`
371
- } else if (isCircularError) {
372
- // It's a known false positive with ESLint 9.x flatCompat legacy configs.
373
- // Note: with our updated flat config without FlatCompat, we shouldn't even see this,
374
- // but we keep this safeguard just in case of any user/legacy configs.
375
- console.log("[set_phase] Ignorado falso positivo circular de ESLint.")
376
- }
377
- } catch (e) {
378
- // best-effort, no abortamos
379
- }
380
- }
381
-
382
- currentState.phase = args.phase
383
- if (autoCreatedContract) {
384
- currentState.activeContract = autoCreatedContract
385
- } else if (args.activeContract !== undefined) {
386
- currentState.activeContract = args.activeContract
387
- }
388
- if (args.coreStack !== undefined) currentState.stack.core = args.coreStack
389
- if (args.databases !== undefined) currentState.stack.databases = args.databases
390
- if (args.loopMode !== undefined) currentState.loopMode = args.loopMode
391
- if (args.loopTargetIterations !== undefined) currentState.loopTargetIterations = args.loopTargetIterations
392
- if (args.loopCurrentIteration !== undefined) currentState.loopCurrentIteration = args.loopCurrentIteration
393
-
394
- // If resetting to F0_DETECT, ensure everything is 100% clean
395
- if (args.phase === "F0_DETECT") {
396
- if (args.loopMode === undefined && args.loopCurrentIteration === undefined) {
397
- currentState.loopMode = false
398
- currentState.loopTargetIterations = 1
399
- currentState.loopCurrentIteration = 1
400
- }
401
- // Clean up running servers
402
- const pidFile = getPidFilePath(root)
403
- if (fs.existsSync(pidFile)) {
404
- try {
405
- const pid = parseInt(fs.readFileSync(pidFile, "utf8").trim(), 10)
406
- if (!isNaN(pid)) {
407
- try { process.kill(-pid, "SIGKILL") } catch (err) {
408
- try { process.kill(pid, "SIGKILL") } catch (err2) {}
409
- }
410
- }
411
- fs.unlinkSync(pidFile)
412
- } catch (e) {}
413
- }
414
-
415
- currentState.activeContract = ""
416
- currentState.stack.core = []
417
- currentState.stack.databases = []
418
-
419
- // Clean up empty directories in .openspec/specs/
420
- try {
421
- const specsDir = path.resolve(root, ".openspec/specs")
422
- if (fs.existsSync(specsDir)) {
423
- const files = fs.readdirSync(specsDir)
424
- for (const f of files) {
425
- const fullPath = path.join(specsDir, f)
426
- if (fs.statSync(fullPath).isDirectory()) {
427
- const subfiles = fs.readdirSync(fullPath)
428
- if (subfiles.length === 0) {
429
- fs.rmdirSync(fullPath)
430
- }
431
- }
432
- }
433
- }
434
- } catch (e) {
435
- // ignore
436
- }
437
-
438
- // Clean up transient Playwright folders
439
- try {
440
- const playwrightTmpDir = path.resolve(root, ".openspec/.playwright")
441
- if (fs.existsSync(playwrightTmpDir)) {
442
- fs.rmSync(playwrightTmpDir, { recursive: true, force: true })
443
- }
444
- } catch (e) {
445
- // ignore
446
- }
447
- }
448
-
449
- writeState(filePath, currentState)
450
-
451
- const response: any = {
452
- status: lintWarning ? "WARNING" : "SUCCESS",
453
- message: `Fase transicionada exitosamente a ${currentState.phase}`,
454
- state: currentState,
455
- }
456
- if (autoCreatedContract) {
457
- response.activeContract = autoCreatedContract
458
- response.message = `Fase F1_CONTRACT activada con spec folder creado atómicamente: ${autoCreatedContract}`
459
- }
460
- if (lintWarning) {
461
- response.lintWarning = lintWarning
462
- }
463
- return JSON.stringify(response, null, 2)
464
- }
465
- })
466
-
467
- // Tool: sdd_get_state (file sdd.ts, export get_state -> sdd_get_state)
468
- export const get_state = tool({
469
- description: "Obtiene el estado de desarrollo actual (fase activa, contrato seleccionado, stack validado)",
470
- args: {},
471
- async execute(args, context) {
472
- const filePath = getStateFilePath(context)
473
- const currentState = readState(filePath)
474
- return JSON.stringify(currentState, null, 2)
475
- }
476
- })
477
-
478
- // Tool: sdd_get_initial_session_data
479
- export const get_initial_session_data = tool({
480
- 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.",
481
- args: {},
482
- async execute(args, context) {
483
- const root = getRoot(context)
484
- const statePath = getStateFilePath(context)
485
- const currentState = readState(statePath)
486
-
487
- // Read brain memory
488
- let brainMemory: any = { found: false }
489
- const brainFilePath = path.resolve(root, ".openspec/brain.md")
490
- if (fs.existsSync(brainFilePath)) {
491
- try {
492
- const content = fs.readFileSync(brainFilePath, "utf8")
493
- brainMemory = {
494
- found: true,
495
- content: content.slice(0, 5000)
496
- }
497
- } catch (e) {}
498
- }
499
-
500
- // Get design recommendations
501
- const recommendations = RECOMMENDED_BRANDS
502
-
503
- return JSON.stringify({
504
- status: "SUCCESS",
505
- state: currentState,
506
- brainMemory,
507
- designRecommendations: recommendations,
508
- note: "Inicialización completada. No es necesario que llames a sdd_get_state, brain_read_memory o sdd_list_design_recommendations por separado."
509
- }, null, 2)
510
- }
511
- })
512
-
513
- // Tool: sdd_save_active_brief
514
- export const save_active_brief = tool({
515
- 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.",
516
- args: {
517
- brief: tool.schema.string().describe("Contenido en formato Markdown con el resumen del spec activo, componentes, diseño y dependencias.")
518
- },
519
- async execute(args, context) {
520
- const root = getRoot(context)
521
- const openspecDir = path.resolve(root, ".openspec")
522
- if (!fs.existsSync(openspecDir)) {
523
- fs.mkdirSync(openspecDir, { recursive: true })
524
- }
525
- const briefPath = path.resolve(openspecDir, "active-brief.md")
526
- fs.writeFileSync(briefPath, args.brief, "utf8")
527
- return JSON.stringify({
528
- status: "SUCCESS",
529
- message: `Brief de contexto de sistema guardado exitosamente en .openspec/active-brief.md`,
530
- filePath: briefPath
531
- }, null, 2)
532
- }
533
- })
534
-
535
- // Tool: sdd_create_spec_folder (file sdd.ts, export create_spec_folder -> sdd_create_spec_folder)
536
- export const create_spec_folder = tool({
537
- description: "Crea una nueva carpeta ordenada para la especificación del cambio (formato: yyyy-mm-dd__hh-mm-ss_nombre)",
538
- args: {
539
- name: tool.schema.string().describe("Nombre del cambio en minúsculas y separado por guiones (ej. sumar-endpoint)")
540
- },
541
- async execute(args, context) {
542
- const root = getRoot(context)
543
- const specsDir = path.resolve(root, ".openspec/specs")
544
-
545
- if (!fs.existsSync(specsDir)) {
546
- fs.mkdirSync(specsDir, { recursive: true })
547
- }
548
-
549
- const now = new Date()
550
- const year = now.getFullYear()
551
- const month = String(now.getMonth() + 1).padStart(2, "0")
552
- const day = String(now.getDate()).padStart(2, "0")
553
- const hours = String(now.getHours()).padStart(2, "0")
554
- const minutes = String(now.getMinutes()).padStart(2, "0")
555
- const seconds = String(now.getSeconds()).padStart(2, "0")
556
- const formattedDate = `${year}-${month}-${day}__${hours}-${minutes}-${seconds}`
557
- const folderName = `${formattedDate}_${args.name}`
558
- const targetFolder = path.join(specsDir, folderName)
559
-
560
- fs.mkdirSync(targetFolder, { recursive: true })
561
-
562
- return JSON.stringify({
563
- status: "SUCCESS",
564
- folderName,
565
- folderPath: path.relative(root, targetFolder)
566
- }, null, 2)
567
- }
568
- })
569
-
570
- // Helper to get PID file path
571
- const getPidFilePath = (root: string) => {
572
- return path.resolve(root, ".openspec/dev_server.pid")
573
- }
574
-
575
- // Tool: sdd_free_port
576
- export const free_port = tool({
577
- description: "Busca y termina de manera forzada cualquier proceso que esté escuchando en un puerto específico",
578
- args: {
579
- port: tool.schema.number().describe("El puerto a liberar (ej. 3000 o 8000)")
580
- },
581
- async execute(args, context) {
582
- const port = args.port
583
- try {
584
- const pid = execSync(`lsof -t -i :${port}`).toString().trim()
585
- if (pid) {
586
- execSync(`kill -9 ${pid.split('\n').join(' ')}`)
587
- return JSON.stringify({
588
- status: "SUCCESS",
589
- message: `Puerto ${port} liberado. Procesos terminados: ${pid.split('\n').join(', ')}`
590
- }, null, 2)
591
- }
592
- return JSON.stringify({
593
- status: "SUCCESS",
594
- message: `Puerto ${port} ya estaba libre (no se encontraron procesos)`
595
- }, null, 2)
596
- } catch (e) {
597
- return JSON.stringify({
598
- status: "SUCCESS",
599
- message: `Puerto ${port} está libre`
600
- }, null, 2)
601
- }
602
- }
603
- })
604
-
605
- // Tool: sdd_clean_docker_environment
606
- export const clean_docker_environment = tool({
607
- description: "Limpia de forma agresiva y segura el entorno de Docker: remueve contenedores detenidos, imágenes huérfanas/dangling creadas por compilaciones fallidas, y redes no utilizadas. Se debe ejecutar antes de desplegar un nuevo contenedor para liberar recursos y evitar colisiones.",
608
- args: {},
609
- async execute(args, context) {
610
- const results: Record<string, string> = {}
611
- try {
612
- results.containers = execSync("docker container prune -f", { encoding: "utf8" }).toString().trim()
613
- } catch (e: any) {
614
- results.containers = `Error: ${e.message}`
615
- }
616
-
617
- try {
618
- results.images = execSync("docker image prune -f", { encoding: "utf8" }).toString().trim()
619
- } catch (e: any) {
620
- results.images = `Error: ${e.message}`
621
- }
622
-
623
- try {
624
- results.networks = execSync("docker network prune -f", { encoding: "utf8" }).toString().trim()
625
- } catch (e: any) {
626
- results.networks = `Error: ${e.message}`
627
- }
628
-
629
- return JSON.stringify({
630
- status: "SUCCESS",
631
- message: "Entorno de Docker limpiado de forma segura y exitosa.",
632
- details: results
633
- }, null, 2)
634
- }
635
- })
636
-
637
- // Tool: sdd_start_server
638
- export const start_server = tool({
639
- description: "Inicia un servidor de desarrollo o producción en segundo plano y registra su PID para limpieza posterior",
640
- args: {
641
- command: tool.schema.string().describe("Comando para iniciar el servidor (ej. 'yarn dev' o 'npm run dev')"),
642
- port: tool.schema.number().optional().default(3000).describe("Puerto esperado del servidor (default: 3000)"),
643
- cwd: tool.schema.string().optional().describe("Directorio de trabajo para ejecutar el comando")
644
- },
645
- async execute(args, context) {
646
- const root = getRoot(context)
647
- const targetCwd = args.cwd ? path.resolve(root, args.cwd) : root
648
- const pidFile = getPidFilePath(root)
649
-
650
- try {
651
- const existingPid = execSync(`lsof -t -i :${args.port}`).toString().trim()
652
- if (existingPid) {
653
- execSync(`kill -9 ${existingPid.split('\n').join(' ')}`)
654
- }
655
- } catch (e) {
656
- // ignore
657
- }
658
-
659
- const parts = args.command.split(" ")
660
- const cmd = parts[0]
661
- const cmdArgs = parts.slice(1)
662
-
663
- const child = spawn(cmd, cmdArgs, {
664
- cwd: targetCwd,
665
- detached: true,
666
- stdio: "ignore",
667
- env: {
668
- ...process.env,
669
- PORT: String(args.port)
670
- }
671
- })
672
-
673
- child.unref()
674
-
675
- const pid = child.pid
676
- if (pid) {
677
- const dir = path.dirname(pidFile)
678
- if (!fs.existsSync(dir)) {
679
- fs.mkdirSync(dir, { recursive: true })
680
- }
681
- fs.writeFileSync(pidFile, String(pid), "utf8")
682
-
683
- await new Promise(resolve => setTimeout(resolve, 3000))
684
-
685
- return JSON.stringify({
686
- status: "SUCCESS",
687
- message: `Servidor iniciado con PID ${pid} ejecutando '${args.command}' en puerto ${args.port}`,
688
- pid
689
- }, null, 2)
690
- }
691
-
692
- return JSON.stringify({
693
- status: "ERROR",
694
- message: `No se pudo obtener el PID del proceso para el comando '${args.command}'`
695
- }, null, 2)
696
- }
697
- })
698
-
699
- // Tool: sdd_stop_server
700
- export const stop_server = tool({
701
- description: "Detiene el servidor en segundo plano usando el PID registrado",
702
- args: {},
703
- async execute(args, context) {
704
- const root = getRoot(context)
705
- const pidFile = getPidFilePath(root)
706
-
707
- if (fs.existsSync(pidFile)) {
708
- try {
709
- const pidStr = fs.readFileSync(pidFile, "utf8").trim()
710
- const pid = parseInt(pidStr, 10)
711
- if (!isNaN(pid)) {
712
- try {
713
- process.kill(-pid, "SIGKILL")
714
- } catch (err) {
715
- try {
716
- process.kill(pid, "SIGKILL")
717
- } catch (err2) {
718
- // ignore
719
- }
720
- }
721
- fs.unlinkSync(pidFile)
722
- return JSON.stringify({
723
- status: "SUCCESS",
724
- message: `Servidor con PID ${pid} terminado exitosamente`
725
- }, null, 2)
726
- }
727
- } catch (e) {
728
- return JSON.stringify({
729
- status: "ERROR",
730
- message: `Error al intentar detener el servidor: ${(e as Error).message}`
731
- }, null, 2)
732
- }
733
- }
734
-
735
- return JSON.stringify({
736
- status: "SUCCESS",
737
- message: "No hay ningún servidor registrado activo"
738
- }, null, 2)
739
- }
740
- })
741
-
742
- // Tool: sdd_select_design
743
- export const select_design = tool({
744
- description: "Copia fielmente el archivo DESIGN.md y sus ejemplos y recursos interactivos asociados desde el catálogo original de oh-my-design a la carpeta .openspec/ del proyecto.",
745
- args: {
746
- brandId: tool.schema.string().describe("ID exacto del diseño en oh-my-design (ej: 'linear.app', 'vercel', 'stripe')")
747
- },
748
- async execute(args, context) {
749
- const root = getRoot(context)
750
- const brandId = args.brandId.trim()
751
- const sourceDir = path.resolve(root, ".opencode/oh-my-design/design-md", brandId)
752
- const targetDir = path.resolve(root, ".openspec")
753
-
754
- if (!fs.existsSync(sourceDir)) {
755
- // Try resolving with substring match if not found exactly
756
- const catalogDir = path.resolve(root, ".opencode/oh-my-design/design-md")
757
- if (fs.existsSync(catalogDir)) {
758
- const brands = fs.readdirSync(catalogDir)
759
- const match = brands.find(b => b.toLowerCase() === brandId.toLowerCase() || b.toLowerCase().includes(brandId.toLowerCase()))
760
- if (match && match !== brandId) {
761
- // Resolved to a different brandId; re-run with the exact match.
762
- // We delegate to a helper to avoid `this.execute` type issues.
763
- return selectDesignHelper(match, context)
764
- }
765
- }
766
- return JSON.stringify({
767
- status: "ERROR",
768
- message: `No se encontró el directorio de diseño para la marca "${brandId}" en ${sourceDir}`
769
- }, null, 2)
770
- }
771
-
772
- return selectDesignHelper(brandId, context)
773
- }
774
- })
775
-
776
- // Helper extracted to allow substring-match recursion without `this.execute` typing issues.
777
- async function selectDesignHelper(brandId: string, context: any) {
778
- const root = getRoot(context)
779
- const sourceDir = path.resolve(root, ".opencode/oh-my-design/design-md", brandId)
780
- const targetDir = path.resolve(root, ".openspec")
781
-
782
- if (!fs.existsSync(sourceDir)) {
783
- return JSON.stringify({
784
- status: "ERROR",
785
- message: `No se encontró el directorio de diseño para la marca "${brandId}" en ${sourceDir}`
786
- }, null, 2)
787
- }
788
-
789
- // Single canonical path: ONLY design-assets/<brandId>/ (no root DESIGN.md)
790
- // Reason: extract-design-pattern.py and the rest of the toolchain already
791
- // look up `preview.html` / `preview-dark.html` from this subfolder. The
792
- // duplicate .openspec/DESIGN.md caused three-way drift between catalog
793
- // and project copies. The skill sdd-quickstart points to design-assets/.
794
-
795
- // 1. Copy accompanying files (HTML previews, README, DESIGN.md, etc.) to a brand subfolder in .openspec
796
- const targetBrandDir = path.join(targetDir, "design-assets", brandId)
797
- if (!fs.existsSync(targetBrandDir)) {
798
- fs.mkdirSync(targetBrandDir, { recursive: true })
799
- }
800
-
801
- const copiedFiles: string[] = []
802
- const sourceDesign = path.join(sourceDir, "DESIGN.md")
803
- if (!fs.existsSync(sourceDesign)) {
804
- return JSON.stringify({
805
- status: "ERROR",
806
- message: `No se encontró el archivo DESIGN.md en la ruta origen: ${sourceDesign}`
807
- }, null, 2)
808
- }
809
-
810
- const files = fs.readdirSync(sourceDir)
811
- for (const file of files) {
812
- if (file.startsWith(".")) continue
813
- const srcFile = path.join(sourceDir, file)
814
- const dstFile = path.join(targetBrandDir, file)
815
- if (fs.statSync(srcFile).isFile()) {
816
- fs.copyFileSync(srcFile, dstFile)
817
- copiedFiles.push(file)
818
- }
819
- }
820
-
821
- // 2. Overwrite .openspec/DESIGN.md so that opencode.json static references always resolve and agents get the active design guidelines
822
- const legacyDesign = path.join(targetDir, "DESIGN.md")
823
- try {
824
- fs.copyFileSync(sourceDesign, legacyDesign)
825
- } catch (e) {
826
- /* ignore */
827
- }
828
-
829
- return JSON.stringify({
830
- status: "SUCCESS",
831
- message: `Diseño "${brandId}" copiado a la ruta canónica .openspec/design-assets/${brandId}/ y actualizado en .openspec/DESIGN.md`,
832
- copiedFiles,
833
- designAssetsDir: path.relative(root, targetBrandDir),
834
- canonicalDesignPath: path.relative(root, path.join(targetBrandDir, "DESIGN.md")),
835
- }, null, 2)
836
- }
837
-
838
- // =====================================================================
839
- // NEW TOOLS (zugzbot-v2 optimization plan)
840
- // =====================================================================
841
-
842
- // Curated subset of brands with HTML+CSS interactive previews.
843
- // These are the brands the orchestrator should recommend first because
844
- // they have usable assets in `.opencode/oh-my-design/design-md/<brandId>/`.
845
- const RECOMMENDED_BRANDS: Record<string, { id: string; name: string; category: string; vibe: string }[]> = {
846
- saas: [
847
- { id: "supabase", name: "Supabase", category: "saas", vibe: "Open-source Firebase alternative, dark-mode-first, dense data UI" },
848
- { id: "linear.app", name: "Linear", category: "saas", vibe: "Issue tracking premium, ultra-fast keyboard-first, monochrome" },
849
- { id: "vercel", name: "Vercel", category: "saas", vibe: "Vercel/Next.js native, mono+geist font, gradient accents" },
850
- { id: "raycast", name: "Raycast", category: "saas", vibe: "Productivity launcher, sharp corners, command-bar UX" },
851
- { id: "posthog", name: "PostHog", category: "saas", vibe: "Product analytics, orange accent, fun playful tone" },
852
- ],
853
- fintech: [
854
- { id: "stripe", name: "Stripe", category: "fintech", vibe: "Premium payments, gradient brand, dense docs" },
855
- { id: "revolut", name: "Revolut", category: "fintech", vibe: "Neobank, dark+violet, large numbers" },
856
- { id: "wise", name: "Wise", category: "fintech", vibe: "Cross-border money, bright green, friendly tone" },
857
- { id: "toss", name: "Toss", category: "fintech", vibe: "Korean super-app, blue primary, imperative microcopy" },
858
- ],
859
- ecommerce: [
860
- { id: "airbnb", name: "Airbnb", category: "ecommerce", vibe: "Travel marketplace, coral accent, photographic" },
861
- { id: "apple", name: "Apple", category: "ecommerce", vibe: "Hardware store, ultra-minimal, SF Pro typography" },
862
- { id: "nike", name: "Nike", category: "ecommerce", vibe: "Athletic brand, black+volt, UPPERCASE bold, flat" },
863
- { id: "shopify", name: "Shopify", category: "ecommerce", vibe: "E-commerce platform, green primary, merchant-focused" },
864
- ],
865
- consumer: [
866
- { id: "spotify", name: "Spotify", category: "consumer", vibe: "Music streaming, green+dark, immersive cards" },
867
- { id: "figma", name: "Figma", category: "consumer", vibe: "Design tool, multi-color, playful professional" },
868
- { id: "notion", name: "Notion", category: "consumer", vibe: "Productivity, minimal, document-first" },
869
- ],
870
- }
871
-
872
- // Tool: sdd_list_design_recommendations
873
- // Returns a curated short-list of design brands grouped by category.
874
- // Saves the orchestrator from 3-4 separate `oh-my-design_list_references` calls.
875
- export const list_design_recommendations = tool({
876
- description: "Devuelve una lista curada y corta de marcas de diseño recomendadas (con assets HTML+CSS interactivos), filtrada por categoría de uso. Reemplaza 3-4 llamadas a oh-my-design_list_references en F0.",
877
- args: {
878
- use_case: tool.schema.enum(["saas", "fintech", "ecommerce", "consumer", "all"]).default("all").describe("Categoría del proyecto para filtrar marcas relevantes"),
879
- max_per_category: tool.schema.number().default(3).describe("Máximo de marcas a devolver por categoría"),
880
- },
881
- async execute(args, context) {
882
- const useCase = args.use_case
883
- const maxPer = Math.max(1, Math.min(args.max_per_category || 3, 6))
884
-
885
- if (useCase === "all") {
886
- const result: Record<string, any[]> = {}
887
- for (const cat of Object.keys(RECOMMENDED_BRANDS)) {
888
- result[cat] = RECOMMENDED_BRANDS[cat].slice(0, maxPer)
889
- }
890
- return JSON.stringify({
891
- status: "SUCCESS",
892
- message: `Recomendaciones de diseño curadas (top ${maxPer} por categoría)`,
893
- recommendations: result,
894
- note: "Estas marcas tienen preview.html/preview-dark.html en .opencode/oh-my-design/design-md/. Para marcas adicionales usa oh-my-design_list_references.",
895
- }, null, 2)
896
- }
897
-
898
- const list = RECOMMENDED_BRANDS[useCase] || []
899
- return JSON.stringify({
900
- status: "SUCCESS",
901
- message: `Recomendaciones de diseño para use_case="${useCase}"`,
902
- recommendations: { [useCase]: list.slice(0, maxPer) },
903
- note: "Estas marcas tienen preview.html/preview-dark.html en .opencode/oh-my-design/design-md/.",
904
- }, null, 2)
905
- }
906
- })
907
-
908
- // Tool: sdd_apply_brand_tokens
909
- // Injects brand-specific CSS custom properties into the project's globals.css
910
- // without removing the shadcn theme variables. Prevents the recurring
911
- // "Cannot apply unknown utility class `border-border`" build error.
912
- export const apply_brand_tokens = tool({
913
- description: "Inyecta tokens de diseño de marca (colores, tipografía, radius) en src/app/globals.css preservando las variables shadcn (--color-border, --color-background, etc.). Usar en F2 cuando se necesite aplicar el tema de marca.",
914
- args: {
915
- tokens: tool.schema.string().describe("JSON stringificado con {colors: {...}, typography: {...}, radius: {...}} extraído de contract.json design.tokens"),
916
- },
917
- async execute(args, context) {
918
- const root = getRoot(context)
919
- const globalsPath = path.resolve(root, "src/app/globals.css")
920
-
921
- if (!fs.existsSync(globalsPath)) {
922
- return JSON.stringify({
923
- status: "ERROR",
924
- message: `No se encontró src/app/globals.css. Ejecuta primero el bootstrap de nextjs-shadcn.`,
925
- }, null, 2)
926
- }
927
-
928
- let brandTokens: any
929
- try {
930
- brandTokens = JSON.parse(args.tokens)
931
- } catch (e) {
932
- return JSON.stringify({
933
- status: "ERROR",
934
- message: `tokens no es JSON válido: ${(e as Error).message}`,
935
- }, null, 2)
936
- }
937
-
938
- const colors = brandTokens.colors || {}
939
- const typography = brandTokens.typography?.family || {}
940
- const radius = brandTokens.radius || {}
941
-
942
- // Build the brand CSS variables block. Preserves shadcn vars.
943
- const brandVarLines: string[] = []
944
- for (const [k, v] of Object.entries(colors)) {
945
- const safeName = String(k).replace(/[^a-zA-Z0-9-]/g, "-").toLowerCase()
946
- brandVarLines.push(` --color-brand-${safeName}: ${v};`)
947
- }
948
- if (typography.sans) {
949
- brandVarLines.push(` --font-sans: ${typography.sans};`)
950
- brandVarLines.push(` --font-heading: ${typography.sans};`)
951
- }
952
- for (const [k, v] of Object.entries(radius)) {
953
- brandVarLines.push(` --radius-${k}: ${v}px;`)
954
- }
955
-
956
- const brandBlock = `/* Brand tokens (injected by sdd_apply_brand_tokens) */
957
- @theme inline {
958
- ${brandVarLines.join("\n")}
959
- }
960
- `
961
-
962
- let css = fs.readFileSync(globalsPath, "utf8")
963
-
964
- // Remove any previous brand block to keep this idempotent.
965
- css = css.replace(/\/\* Brand tokens[\s\S]*?\}\s*\n/g, "")
966
-
967
- // Append at the end. Existing shadcn @theme inline block remains untouched.
968
- css = css.trimEnd() + "\n\n" + brandBlock
969
-
970
- fs.writeFileSync(globalsPath, css, "utf8")
971
-
972
- return JSON.stringify({
973
- status: "SUCCESS",
974
- message: `Inyectados ${brandVarLines.length} tokens de marca en src/app/globals.css (preservando variables shadcn)`,
975
- globalsPath: path.relative(root, globalsPath),
976
- brandVariables: brandVarLines.length,
977
- }, null, 2)
978
- }
979
- })
980
-
981
- // Tool: sdd_generate_dockerfile
982
- // Generates a production-ready multi-stage Dockerfile + .dockerignore +
983
- // docker-compose.yml from the project stack in ~1 call. Replaces ~5 read
984
- // + 3 write operations the deployer does manually.
985
- export const generate_dockerfile = tool({
986
- description: "Genera Dockerfile multi-stage, .dockerignore y docker-compose.yml optimizados a partir del stack del proyecto (nextjs|fastapi). Detecta package manager desde package.json.",
987
- args: {
988
- stack: tool.schema.enum(["nextjs", "fastapi"]).describe("Stack del proyecto"),
989
- port: tool.schema.number().default(3000).describe("Puerto de la aplicación"),
990
- },
991
- async execute(args, context) {
992
- const root = getRoot(context)
993
-
994
- if (args.stack === "nextjs") {
995
- // Detect package manager
996
- let pm = "npm"
997
- let installCmd = "npm ci --frozen-lockfile"
998
- let buildCmd = "npm run build"
999
- if (fs.existsSync(path.resolve(root, "pnpm-lock.yaml"))) {
1000
- pm = "pnpm"
1001
- installCmd = "pnpm install --frozen-lockfile"
1002
- buildCmd = "pnpm build"
1003
- } else if (fs.existsSync(path.resolve(root, "yarn.lock"))) {
1004
- pm = "yarn"
1005
- installCmd = "yarn install --frozen-lockfile"
1006
- buildCmd = "yarn build"
1007
- }
1008
-
1009
- // Pin to node 20-alpine (most common, Next 16 compatible)
1010
- const nodeImage = "node:20-alpine"
1011
-
1012
- const dockerfile = `# Stage 1: Dependencias
1013
- FROM ${nodeImage} AS deps
1014
- WORKDIR /app
1015
- COPY package.json ${pm === "npm" ? "package-lock.json* " : pm === "pnpm" ? "pnpm-lock.yaml* " : "yarn.lock* "}./
1016
- RUN ${installCmd}
1017
-
1018
- # Stage 2: Constructor
1019
- FROM ${nodeImage} AS builder
1020
- WORKDIR /app
1021
- RUN corepack enable || true
1022
- COPY --from=deps /app/node_modules ./node_modules
1023
- COPY . .
1024
- RUN ${buildCmd}
1025
-
1026
- # Stage 3: Ejecutor
1027
- FROM ${nodeImage} AS runner
1028
- WORKDIR /app
1029
- ENV NODE_ENV=production
1030
- ENV NEXT_TELEMETRY_DISABLED=1
1031
-
1032
- RUN addgroup --system --gid 1001 nodejs \\
1033
- && adduser --system --uid 1001 nextjs \\
1034
- && mkdir -p public
1035
-
1036
- COPY --from=builder /app/public ./public
1037
- COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
1038
- COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
1039
-
1040
- USER nextjs
1041
- EXPOSE ${args.port}
1042
- ENV PORT=${args.port}
1043
- ENV HOSTNAME="0.0.0.0"
1044
-
1045
- HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
1046
- CMD node -e "require('http').get('http://localhost:${args.port}', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) }).on('error', () => process.exit(1))"
1047
-
1048
- CMD ["node", "server.js"]
1049
- `
1050
-
1051
- const dockerignore = `node_modules
1052
- .next
1053
- .git
1054
- .opencode
1055
- .openspec
1056
- .env*
1057
- *.md
1058
- vitest.config.ts
1059
- *.test.ts
1060
- *.spec.ts
1061
- coverage
1062
- playwright-report
1063
- test-results
1064
- `
1065
-
1066
- const compose = `services:
1067
- web:
1068
- build:
1069
- context: .
1070
- dockerfile: Dockerfile
1071
- ports:
1072
- - "${args.port}:${args.port}"
1073
- environment:
1074
- - NODE_ENV=production
1075
- - NEXT_TELEMETRY_DISABLED=1
1076
- restart: unless-stopped
1077
- healthcheck:
1078
- test: ["CMD", "node", "-e", "require('http').get('http://localhost:${args.port}', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) }).on('error', () => process.exit(1))"]
1079
- interval: 30s
1080
- timeout: 10s
1081
- retries: 3
1082
- start_period: 30s
1083
- `
1084
-
1085
- const filesWritten: string[] = []
1086
- for (const [name, content] of [
1087
- ["Dockerfile", dockerfile],
1088
- [".dockerignore", dockerignore],
1089
- ["docker-compose.yml", compose],
1090
- ] as const) {
1091
- const fullPath = path.resolve(root, name)
1092
- fs.writeFileSync(fullPath, content, "utf8")
1093
- filesWritten.push(path.relative(root, fullPath))
1094
- }
1095
-
1096
- return JSON.stringify({
1097
- status: "SUCCESS",
1098
- message: `Docker artifacts generados (${pm}, ${nodeImage}, puerto ${args.port})`,
1099
- filesWritten,
1100
- packageManager: pm,
1101
- nodeImage,
1102
- }, null, 2)
1103
- }
1104
-
1105
- // fastapi path
1106
- const dockerfilePy = `FROM python:3.11-slim
1107
- WORKDIR /app
1108
- RUN apt-get update && apt-get install -y --no-install-recommends \\
1109
- build-essential \\
1110
- && rm -rf /var/lib/apt/lists/*
1111
- COPY requirements.txt .
1112
- RUN pip install --no-cache-dir -r requirements.txt
1113
- COPY src/ ./src/
1114
- EXPOSE ${args.port}
1115
- CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "${args.port}"]
1116
- `
1117
- const dockerignorePy = `__pycache__/
1118
- *.pyc
1119
- *.pyo
1120
- *.pyd
1121
- .Python
1122
- env/
1123
- venv/
1124
- .venv/
1125
- .git
1126
- .opencode
1127
- .openspec
1128
- .pytest_cache/
1129
- tests/
1130
- `
1131
- const composePy = `services:
1132
- api:
1133
- build:
1134
- context: .
1135
- dockerfile: Dockerfile
1136
- ports:
1137
- - "${args.port}:${args.port}"
1138
- environment:
1139
- - ENV=production
1140
- restart: unless-stopped
1141
- `
1142
- const filesWritten: string[] = []
1143
- for (const [name, content] of [
1144
- ["Dockerfile", dockerfilePy],
1145
- [".dockerignore", dockerignorePy],
1146
- ["docker-compose.yml", composePy],
1147
- ] as const) {
1148
- const fullPath = path.resolve(root, name)
1149
- fs.writeFileSync(fullPath, content, "utf8")
1150
- filesWritten.push(path.relative(root, fullPath))
1151
- }
1152
-
1153
- return JSON.stringify({
1154
- status: "SUCCESS",
1155
- message: `Docker artifacts generados para FastAPI (puerto ${args.port})`,
1156
- filesWritten,
1157
- }, null, 2)
1158
- }
1159
- })
1160
-
1161
- // Tool: sdd_quick_lint
1162
- // Runs the project linter against src/ only (avoiding the harness dir).
1163
- // Used by sdd_set_phase as a gate before transitioning to F3_VERIFICATION.
1164
- export const quick_lint = tool({
1165
- description: "Ejecuta el linter del proyecto (eslint) restringido a src/. Devuelve exit code y warnings. Usado como gate automático antes de transicionar a F3.",
1166
- args: {},
1167
- async execute(args, context) {
1168
- const root = getRoot(context)
1169
-
1170
- // Detect package manager scripts
1171
- const pkgPath = path.resolve(root, "package.json")
1172
- if (!fs.existsSync(pkgPath)) {
1173
- return JSON.stringify({
1174
- status: "ERROR",
1175
- message: "No se encontró package.json. ¿Estás en un proyecto Next.js?",
1176
- }, null, 2)
1177
- }
1178
-
1179
- try {
1180
- const out = execSync("npx eslint src/ --quiet --max-warnings 0 2>&1 || true", {
1181
- cwd: root,
1182
- encoding: "utf8",
1183
- timeout: 120_000,
1184
- })
1185
- const hasErrors = out.toLowerCase().includes("error") || out.trim().length > 0
1186
- return JSON.stringify({
1187
- status: hasErrors ? "FAIL" : "SUCCESS",
1188
- message: hasErrors ? "Lint encontró errores. Corrígelos antes de transicionar a F3." : "Lint limpio.",
1189
- output: out.slice(0, 2000),
1190
- }, null, 2)
1191
- } catch (e: any) {
1192
- return JSON.stringify({
1193
- status: "FAIL",
1194
- message: `Lint falló: ${e.message?.slice(0, 500) || "unknown error"}`,
1195
- }, null, 2)
1196
- }
1197
- }
1198
- })
1199
-
1200
- // Tool: sdd_shift_left_verify
1201
- // Performs high-speed semantic shift-left validations (tsc & eslint combined) with stack trace error parsing.
1202
- export const shift_left_verify = tool({
1203
- description: "Ejecuta validaciones estáticas Shift-Left completas en el proyecto: ejecuta el compilador de TypeScript (tsc) y el linter (eslint) de manera combinada. Limpia y parsea semánticamente los stack traces y logs de error crudos, devolviendo un JSON limpio y estructurado de errores que el LLM puede digerir y solucionar de inmediato sin perder atención.",
1204
- args: {},
1205
- async execute(args, context) {
1206
- const root = getRoot(context)
1207
- const result: { tsc: { status: string, errors?: any[] }, eslint: { status: string, errors?: any[] } } = {
1208
- tsc: { status: "SUCCESS" },
1209
- eslint: { status: "SUCCESS" }
1210
- }
1211
-
1212
- // 1. Run tsc --noEmit
1213
- try {
1214
- execSync("npx tsc --noEmit", { cwd: root, stdio: "pipe", timeout: 60000 })
1215
- } catch (e: any) {
1216
- const rawOutput = e.stdout?.toString() || e.stderr?.toString() || ""
1217
- const errors = parseSemanticErrors(rawOutput, "tsc")
1218
- result.tsc = {
1219
- status: "FAIL",
1220
- errors
1221
- }
1222
- }
1223
-
1224
- // 2. Run eslint src/
1225
- try {
1226
- const out = execSync("npx eslint src/ --quiet 2>&1 || true", {
1227
- cwd: root,
1228
- encoding: "utf8",
1229
- timeout: 60000,
1230
- })
1231
- const isCircularError = out.toLowerCase().includes("converting circular structure")
1232
- if ((out.toLowerCase().includes("error") || out.trim().length > 0) && !isCircularError) {
1233
- const errors = parseSemanticErrors(out, "eslint")
1234
- result.eslint = {
1235
- status: "FAIL",
1236
- errors
1237
- }
1238
- }
1239
- } catch (e: any) {
1240
- result.eslint = {
1241
- status: "FAIL",
1242
- errors: [{ raw: e.message || "Eslint execution error" }]
1243
- }
1244
- }
1245
-
1246
- const overallSuccess = result.tsc.status === "SUCCESS" && result.eslint.status === "SUCCESS"
1247
-
1248
- return JSON.stringify({
1249
- status: overallSuccess ? "SUCCESS" : "FAIL",
1250
- message: overallSuccess ? "Shift-Left Verification exitosa: Cero errores de compilación y cero errores de lint." : "Validación fallida: Se encontraron errores estáticos.",
1251
- verification: result
1252
- }, null, 2)
1253
- }
1254
- })
1255
-
1256
- // =====================================================================
1257
- // BOOTSTRAP TOOLS (zugzbot-v2 wave 1 optimization)
1258
- // =====================================================================
1259
-
1260
- // Helper: read bootstrap status JSON
1261
- const BOOTSTRAP_STATUS_PATH = ".openspec/.sdd_bootstrap.json"
1262
- const TEMPLATE_VERSION = "1.0.0"
1263
- const MIN_NODE_VERSION = "v20.9.0" // Next.js 16 requirement
1264
- const MIN_PYTHON_VERSION = "3.11" // FastAPI modern syntax (X | Y unions, etc.)
1265
-
1266
- function readBootstrapStatus(root: string): any | null {
1267
- const statusPath = path.resolve(root, BOOTSTRAP_STATUS_PATH)
1268
- try {
1269
- if (fs.existsSync(statusPath)) {
1270
- return JSON.parse(fs.readFileSync(statusPath, "utf8"))
1271
- }
1272
- } catch (e) { /* ignore */ }
1273
- return null
1274
- }
1275
-
1276
- function writeBootstrapStatus(root: string, status: any): void {
1277
- const statusPath = path.resolve(root, BOOTSTRAP_STATUS_PATH)
1278
- const dir = path.dirname(statusPath)
1279
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
1280
- fs.writeFileSync(statusPath, JSON.stringify(status, null, 2), "utf8")
1281
- }
1282
-
1283
- function detectPackageManager(root: string): "pnpm" | "yarn" | "npm" {
1284
- if (fs.existsSync(path.resolve(root, "pnpm-lock.yaml"))) return "pnpm"
1285
- if (fs.existsSync(path.resolve(root, "yarn.lock"))) return "yarn"
1286
- return "npm"
1287
- }
1288
-
1289
- function getNodeVersion(): string {
1290
- try {
1291
- return execSync("node --version", { encoding: "utf8" }).trim()
1292
- } catch {
1293
- return "unknown"
1294
- }
1295
- }
1296
-
1297
- function semverMajor(version: string): number {
1298
- const m = version.replace(/^v/, "").match(/^(\d+)/)
1299
- return m ? parseInt(m[1], 10) : 0
1300
- }
1301
-
1302
- function getPythonVersion(): string {
1303
- // Try python3 first (more common on macOS/Linux), then python
1304
- for (const cmd of ["python3 --version", "python --version"]) {
1305
- try {
1306
- const out = execSync(cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim()
1307
- // "Python 3.11.5" → "3.11.5"
1308
- const m = out.match(/Python\s+(\d+\.\d+(?:\.\d+)?)/i)
1309
- if (m) return m[1]
1310
- } catch {
1311
- // try next
1312
- }
1313
- }
1314
- return "unknown"
1315
- }
1316
-
1317
- function checkPythonVersion(): { ok: boolean; version: string; message: string } {
1318
- const version = getPythonVersion()
1319
- if (version === "unknown") {
1320
- return {
1321
- ok: false,
1322
- version,
1323
- message: `No se detectó Python en PATH. Instala Python >= ${MIN_PYTHON_VERSION} antes de continuar.`,
1324
- }
1325
- }
1326
- const parts = version.split(".")
1327
- const major = parseInt(parts[0], 10)
1328
- const minor = parseInt(parts[1] || "0", 10)
1329
- const minParts = MIN_PYTHON_VERSION.split(".").map((n) => parseInt(n, 10))
1330
- const ok = major > minParts[0] || (major === minParts[0] && minor >= minParts[1])
1331
- if (!ok) {
1332
- return {
1333
- ok: false,
1334
- version,
1335
- message: `Python ${version} detectado. FastAPI moderno requiere >= ${MIN_PYTHON_VERSION}. Actualiza Python antes de continuar.`,
1336
- }
1337
- }
1338
- return { ok: true, version, message: "OK" }
1339
- }
1340
-
1341
- function detectPythonPackageManager(root: string): "uv" | "pip" {
1342
- // uv is preferred (much faster, has lockfile support)
1343
- try {
1344
- execSync("uv --version", { stdio: "ignore" })
1345
- return "uv"
1346
- } catch {
1347
- return "pip"
1348
- }
1349
- }
1350
-
1351
- // Tool: sdd_bootstrap_status
1352
- // Read-only: reports current bootstrap state.
1353
- export const bootstrap_status = tool({
1354
- description: "Reporta el estado de bootstrap del proyecto (qué plantilla se usó, cuándo, qué componentes shadcn están instalados, versión de Node y package manager). Si el proyecto no está bootstrapped, retorna NOT_BOOTSTRAPPED. Útil para que el coder verifique antes de empezar a codear.",
1355
- args: {},
1356
- async execute(args, context) {
1357
- const root = getRoot(context)
1358
- const status = readBootstrapStatus(root)
1359
-
1360
- if (!status) {
1361
- return JSON.stringify({
1362
- status: "NOT_BOOTSTRAPPED",
1363
- message: "El proyecto no ha sido bootstrapped. Llama sdd_bootstrap_nextjs_shadcn o sdd_bootstrap_fastapi primero.",
1364
- projectRoot: root,
1365
- hasNodeModules: fs.existsSync(path.resolve(root, "node_modules")),
1366
- hasPyproject: fs.existsSync(path.resolve(root, "pyproject.toml")),
1367
- }, null, 2)
1368
- }
1369
-
1370
- // Check if lockfile is newer than last bootstrap (suggest re-bootstrap)
1371
- const pm = status.packageManager || "npm"
1372
- const lockfile = pm === "pnpm" ? "pnpm-lock.yaml" : pm === "yarn" ? "yarn.lock" : "package-lock.json"
1373
- const lockfilePath = path.resolve(root, lockfile)
1374
- let needsRebootstrap = false
1375
- if (fs.existsSync(lockfilePath)) {
1376
- const lockMtime = fs.statSync(lockfilePath).mtimeMs
1377
- const lastBootstrap = new Date(status.lastBootstrappedAt).getTime()
1378
- if (lockMtime > lastBootstrap) needsRebootstrap = true
1379
- }
1380
-
1381
- return JSON.stringify({
1382
- status: "BOOTSTRAPPED",
1383
- message: needsRebootstrap
1384
- ? "Bootstrap previo detectado pero el lockfile es más nuevo. Considera re-bootstrappear con force=true."
1385
- : "Bootstrap vigente.",
1386
- projectRoot: root,
1387
- currentNodeVersion: getNodeVersion(),
1388
- recommendedMinNode: MIN_NODE_VERSION,
1389
- ...status,
1390
- needsRebootstrap,
1391
- }, null, 2)
1392
- }
1393
- })
1394
-
1395
- // Tool: sdd_bootstrap_nextjs_shadcn
1396
- // Idempotent: copies the .opencode/templates/nextjs-shadcn/ template to the
1397
- // project root (skipping files that already exist unless force=true), optionally
1398
- // installs dependencies and shadcn components, and writes a status file.
1399
- export const bootstrap_nextjs_shadcn = tool({
1400
- description: "Inicializa un proyecto Next.js 16 + Shadcn UI + Tailwind v4 + Vitest a partir de la plantilla canónica en .opencode/templates/nextjs-shadcn/. Es IDEMPOTENTE: si el proyecto ya está inicializado y no se pasa force=true, no toca nada. Copia archivo-por-archivo (sin pisar los existentes), mergea package.json, y opcionalmente instala dependencias y shadcn components.",
1401
- args: {
1402
- components: tool.schema.array(tool.schema.string()).default([]).describe("Lista de shadcn components a instalar (ej: ['button','input','table']). Vacío = no instalar shadcn components."),
1403
- install: tool.schema.boolean().default(true).describe("Si true, ejecuta npm/pnpm/yarn install después de copiar. Si false, solo copia archivos."),
1404
- force: tool.schema.boolean().default(false).describe("Si true, sobrescribe archivos existentes. Si false, los salta (mergea package.json)."),
1405
- },
1406
- async execute(args, context) {
1407
- const root = getRoot(context)
1408
- const start = Date.now()
1409
- const templateDir = path.resolve(root, ".opencode/templates/nextjs-shadcn")
1410
-
1411
- // Check 1: template exists
1412
- if (!fs.existsSync(templateDir)) {
1413
- return JSON.stringify({
1414
- status: "ERROR",
1415
- message: `No se encontró la plantilla en ${templateDir}. Verifica que .opencode/ esté intacto.`,
1416
- }, null, 2)
1417
- }
1418
-
1419
- // Check 2: Node version
1420
- const nodeVersion = getNodeVersion()
1421
- if (nodeVersion === "unknown" || semverMajor(nodeVersion) < 20) {
1422
- return JSON.stringify({
1423
- status: "ERROR",
1424
- message: `Node ${nodeVersion} detectado. Next.js 16 requiere >= ${MIN_NODE_VERSION}. Instala/actualiza Node antes de continuar.`,
1425
- nodeVersion,
1426
- recommendedMinNode: MIN_NODE_VERSION,
1427
- }, null, 2)
1428
- }
1429
-
1430
- // Idempotency check
1431
- const isInitialized =
1432
- fs.existsSync(path.resolve(root, "src/app/page.tsx")) &&
1433
- fs.existsSync(path.resolve(root, "package.json")) &&
1434
- fs.existsSync(path.resolve(root, "src/lib/utils.ts"))
1435
-
1436
- if (isInitialized && !args.force) {
1437
- return JSON.stringify({
1438
- status: "SUCCESS",
1439
- initialized: false,
1440
- message: "Proyecto ya inicializado. Pasa force=true para re-bootstrappear (CUIDADO: sobrescribirá archivos existentes).",
1441
- nextSteps: "Llama sdd_bootstrap_status para ver el estado actual.",
1442
- }, null, 2)
1443
- }
1444
-
1445
- // Copy files (file-by-file, additive)
1446
- const filesCopied: string[] = []
1447
- const filesSkipped: string[] = []
1448
- const copyFileSafe = (rel: string) => {
1449
- const src = path.join(templateDir, rel)
1450
- const dst = path.resolve(root, rel)
1451
- if (!fs.existsSync(src)) return // template file missing
1452
- const dstExists = fs.existsSync(dst)
1453
- if (dstExists && !args.force) {
1454
- filesSkipped.push(rel)
1455
- return
1456
- }
1457
- fs.mkdirSync(path.dirname(dst), { recursive: true })
1458
- fs.copyFileSync(src, dst)
1459
- filesCopied.push(rel)
1460
- }
1461
-
1462
- // Special handling for package.json: merge dependencies
1463
- const userPkgPath = path.resolve(root, "package.json")
1464
- let mergedPkg: any = null
1465
- if (fs.existsSync(userPkgPath) && !args.force) {
1466
- try {
1467
- const userPkg = JSON.parse(fs.readFileSync(userPkgPath, "utf8"))
1468
- const tmplPkg = JSON.parse(fs.readFileSync(path.join(templateDir, "package.json"), "utf8"))
1469
- // Deep-merge dependencies (user wins)
1470
- for (const key of ["dependencies", "devDependencies", "peerDependencies"]) {
1471
- if (tmplPkg[key]) {
1472
- userPkg[key] = { ...tmplPkg[key], ...(userPkg[key] || {}) }
1473
- }
1474
- }
1475
- // Preserve user scripts
1476
- userPkg.scripts = { ...(tmplPkg.scripts || {}), ...(userPkg.scripts || {}) }
1477
- // Preserve user name/version/type
1478
- mergedPkg = userPkg
1479
- fs.writeFileSync(userPkgPath, JSON.stringify(userPkg, null, 2), "utf8")
1480
- filesCopied.push("package.json (merged)")
1481
- } catch (e) {
1482
- // If merge fails, fall back to overwrite
1483
- copyFileSafe("package.json")
1484
- }
1485
- } else {
1486
- copyFileSafe("package.json")
1487
- }
1488
-
1489
- // Special handling for next.config.ts: ensure output: "standalone"
1490
- const userNextConfig = path.resolve(root, "next.config.ts")
1491
- if (fs.existsSync(userNextConfig) && !args.force) {
1492
- try {
1493
- const content = fs.readFileSync(userNextConfig, "utf8")
1494
- if (!content.includes("output: \"standalone\"") && !content.includes("output: 'standalone'")) {
1495
- const updated = content.replace(
1496
- /(const nextConfig[^{]*\{)/,
1497
- `$1\n output: "standalone",`
1498
- )
1499
- fs.writeFileSync(userNextConfig, updated, "utf8")
1500
- filesCopied.push("next.config.ts (added standalone)")
1501
- } else {
1502
- filesSkipped.push("next.config.ts")
1503
- }
1504
- } catch (e) {
1505
- copyFileSafe("next.config.ts")
1506
- }
1507
- } else {
1508
- copyFileSafe("next.config.ts")
1509
- }
1510
-
1511
- // Standard copy for all other template files
1512
- const standardFiles = [
1513
- "tsconfig.json",
1514
- "eslint.config.mjs",
1515
- "vitest.config.ts",
1516
- "postcss.config.mjs",
1517
- "components.json",
1518
- "src/app/page.tsx",
1519
- "src/app/layout.tsx",
1520
- "src/app/globals.css",
1521
- "src/lib/utils.ts",
1522
- "src/components/theme-provider.tsx",
1523
- "src/test/setup.ts",
1524
- "public/.gitkeep",
1525
- ]
1526
- for (const f of standardFiles) copyFileSafe(f)
1527
-
1528
- // Detect package manager
1529
- const pm = detectPackageManager(root)
1530
-
1531
- // Install dependencies (if requested and not already installed)
1532
- let installDuration = 0
1533
- let installSkipped = false
1534
- let installError: string | null = null
1535
- const hasNodeModules = fs.existsSync(path.resolve(root, "node_modules"))
1536
- if (args.install && (args.force || !hasNodeModules)) {
1537
- const installStart = Date.now()
1538
- const installCmd = pm === "pnpm"
1539
- ? "pnpm install --prefer-offline --frozen-lockfile"
1540
- : pm === "yarn"
1541
- ? "yarn install --frozen-lockfile"
1542
- : "npm install --prefer-offline"
1543
- try {
1544
- execSync(installCmd, {
1545
- cwd: root,
1546
- stdio: "ignore",
1547
- timeout: 300_000,
1548
- })
1549
- installDuration = Date.now() - installStart
1550
- } catch (e: any) {
1551
- installError = e.message?.slice(0, 500) || "unknown error"
1552
- }
1553
- } else if (hasNodeModules && !args.force) {
1554
- installSkipped = true
1555
- }
1556
-
1557
- // Install shadcn components (if requested)
1558
- const componentsInstalled: string[] = []
1559
- let componentsError: string | null = null
1560
- if (args.components && args.components.length > 0) {
1561
- try {
1562
- const cmd = `npx shadcn@latest add ${args.components.join(" ")} --yes --overwrite`
1563
- execSync(cmd, {
1564
- cwd: root,
1565
- stdio: "ignore",
1566
- timeout: 180_000,
1567
- })
1568
- componentsInstalled.push(...args.components)
1569
- } catch (e: any) {
1570
- componentsError = e.message?.slice(0, 500) || "unknown error"
1571
- }
1572
- }
1573
-
1574
- // Write status file
1575
- const bootstrapRecord = {
1576
- template: "nextjs-shadcn",
1577
- version: TEMPLATE_VERSION,
1578
- lastBootstrappedAt: new Date().toISOString(),
1579
- packageManager: pm,
1580
- nodeVersion,
1581
- filesCopied,
1582
- filesSkipped,
1583
- componentsInstalled,
1584
- installDuration,
1585
- totalDuration: Date.now() - start,
1586
- }
1587
- writeBootstrapStatus(root, bootstrapRecord)
1588
-
1589
- // Read final package.json for output (per user request 6)
1590
- let finalPackageJson: any = null
1591
- try {
1592
- finalPackageJson = JSON.parse(fs.readFileSync(userPkgPath, "utf8"))
1593
- } catch (e) { /* ignore */ }
1594
-
1595
- return JSON.stringify({
1596
- status: installError || componentsError ? "WARNING" : "SUCCESS",
1597
- initialized: true,
1598
- message: installError
1599
- ? `Bootstrap completo con warnings: install falló. ${installError}`
1600
- : componentsError
1601
- ? `Bootstrap completo con warnings: shadcn add falló. ${componentsError}`
1602
- : `Bootstrap completo en ${Date.now() - start}ms.`,
1603
- packageManager: pm,
1604
- nodeVersion,
1605
- filesCopied,
1606
- filesSkipped,
1607
- installSkipped,
1608
- installDuration,
1609
- componentsInstalled,
1610
- finalPackageJson,
1611
- nextSteps: "Run sdd_start_server({ command: '<pm> dev', port: 3000 }) para arrancar el dev server.",
1612
- _bootstrapRecord: bootstrapRecord,
1613
- }, null, 2)
1614
- }
1615
- })
1616
-
1617
- // Tool: sdd_bootstrap_fastapi
1618
- // Idempotent: copies the .opencode/templates/fastapi-sdd/ template to the
1619
- // project root (skipping files that already exist unless force=true), optionally
1620
- // installs Python dependencies (with uv preferred, pip fallback), installs
1621
- // additional extras, and writes a status file.
1622
- export const bootstrap_fastapi = tool({
1623
- description: "Inicializa un proyecto FastAPI + Pydantic + Uvicorn + Pytest + Ruff a partir de la plantilla canónica en .opencode/templates/fastapi-sdd/. Es IDEMPOTENTE: si el proyecto ya está inicializado y no se pasa force=true, no toca nada. Copia archivo-por-archivo (sin pisar los existentes), opcionalmente instala dependencias con uv (fallback pip).",
1624
- args: {
1625
- extras: tool.schema.array(tool.schema.string()).default([]).describe("Lista de paquetes Python adicionales a instalar (ej: ['sqlalchemy','pydantic-settings','pytest-asyncio']). Vacío = no instalar extras extra."),
1626
- install: tool.schema.boolean().default(true).describe("Si true, ejecuta uv sync (o pip install -e '.[dev]') después de copiar. Si false, solo copia archivos."),
1627
- force: tool.schema.boolean().default(false).describe("Si true, sobrescribe archivos existentes. Si false, los salta."),
1628
- },
1629
- async execute(args, context) {
1630
- const root = getRoot(context)
1631
- const start = Date.now()
1632
- const templateDir = path.resolve(root, ".opencode/templates/fastapi-sdd")
1633
-
1634
- // Check 1: template exists
1635
- if (!fs.existsSync(templateDir)) {
1636
- return JSON.stringify({
1637
- status: "ERROR",
1638
- message: `No se encontró la plantilla en ${templateDir}. Verifica que .opencode/ esté intacto.`,
1639
- }, null, 2)
1640
- }
1641
-
1642
- // Check 2: Python version
1643
- const pythonCheck = checkPythonVersion()
1644
- if (!pythonCheck.ok) {
1645
- return JSON.stringify({
1646
- status: "ERROR",
1647
- message: pythonCheck.message,
1648
- pythonVersion: pythonCheck.version,
1649
- recommendedMinPython: MIN_PYTHON_VERSION,
1650
- }, null, 2)
1651
- }
1652
-
1653
- // Idempotency check
1654
- const isInitialized =
1655
- fs.existsSync(path.resolve(root, "src/app/main.py")) &&
1656
- fs.existsSync(path.resolve(root, "pyproject.toml"))
1657
-
1658
- if (isInitialized && !args.force) {
1659
- return JSON.stringify({
1660
- status: "SUCCESS",
1661
- initialized: false,
1662
- message: "Proyecto Python ya inicializado. Pasa force=true para re-bootstrappear (CUIDADO: sobrescribirá archivos existentes).",
1663
- nextSteps: "Llama sdd_bootstrap_status para ver el estado actual.",
1664
- }, null, 2)
1665
- }
1666
-
1667
- // Copy files (file-by-file, additive)
1668
- const filesCopied: string[] = []
1669
- const filesSkipped: string[] = []
1670
- const copyFileSafe = (rel: string) => {
1671
- const src = path.join(templateDir, rel)
1672
- const dst = path.resolve(root, rel)
1673
- if (!fs.existsSync(src)) return
1674
- const dstExists = fs.existsSync(dst)
1675
- if (dstExists && !args.force) {
1676
- filesSkipped.push(rel)
1677
- return
1678
- }
1679
- fs.mkdirSync(path.dirname(dst), { recursive: true })
1680
- fs.copyFileSync(src, dst)
1681
- filesCopied.push(rel)
1682
- }
1683
-
1684
- // Special handling for pyproject.toml: merge dependencies
1685
- const userPyprojectPath = path.resolve(root, "pyproject.toml")
1686
- let mergedPyproject: string | null = null
1687
- if (fs.existsSync(userPyprojectPath) && !args.force) {
1688
- try {
1689
- // Use a simple TOML-aware merge via regex on the [project] dependencies array
1690
- // (We keep this minimal — the toolchain merges via hatchling if both have
1691
- // their own dependencies. For a robust merge, callers should pass force=true.)
1692
- const userToml = fs.readFileSync(userPyprojectPath, "utf8")
1693
- const tmplToml = fs.readFileSync(path.join(templateDir, "pyproject.toml"), "utf8")
1694
- // If user has [project] section, leave it; if missing, append template's full file
1695
- if (userToml.includes("[project]") || userToml.includes("[tool.")) {
1696
- // Keep user file as-is, but report that merge was skipped
1697
- filesSkipped.push("pyproject.toml (user-defined, not auto-merged)")
1698
- } else {
1699
- fs.writeFileSync(userPyprojectPath, tmplToml, "utf8")
1700
- mergedPyproject = tmplToml
1701
- filesCopied.push("pyproject.toml (template only)")
1702
- }
1703
- } catch (e) {
1704
- copyFileSafe("pyproject.toml")
1705
- }
1706
- } else {
1707
- copyFileSafe("pyproject.toml")
1708
- }
1709
-
1710
- // Standard copy for all other template files
1711
- const standardFiles = [
1712
- "ruff.toml",
1713
- ".python-version",
1714
- "Dockerfile",
1715
- ".dockerignore",
1716
- "docker-compose.yml",
1717
- "README.md",
1718
- "src/app/__init__.py",
1719
- "src/app/main.py",
1720
- "src/app/core/__init__.py",
1721
- "src/app/core/config.py",
1722
- "src/app/routers/__init__.py",
1723
- "src/app/schemas/__init__.py",
1724
- "src/tests/__init__.py",
1725
- "src/tests/conftest.py",
1726
- "src/tests/test_main.py",
1727
- "tests/__init__.py",
1728
- ]
1729
- for (const f of standardFiles) copyFileSafe(f)
1730
-
1731
- // Detect package manager (uv preferred, pip fallback)
1732
- const pm = detectPythonPackageManager(root)
1733
-
1734
- // Install dependencies (if requested)
1735
- let installDuration = 0
1736
- let installSkipped = false
1737
- let installError: string | null = null
1738
- const hasVenv = fs.existsSync(path.resolve(root, ".venv"))
1739
- const hasInstalled = fs.existsSync(path.resolve(root, "uv.lock")) || hasVenv
1740
- if (args.install && (args.force || !hasInstalled)) {
1741
- const installStart = Date.now()
1742
- const installCmd = pm === "uv"
1743
- ? "uv sync"
1744
- : "pip install -e '.[dev]'"
1745
- try {
1746
- execSync(installCmd, {
1747
- cwd: root,
1748
- stdio: "ignore",
1749
- timeout: 300_000,
1750
- })
1751
- installDuration = Date.now() - installStart
1752
- } catch (e: any) {
1753
- installError = e.message?.slice(0, 500) || "unknown error"
1754
- }
1755
- } else if (hasInstalled && !args.force) {
1756
- installSkipped = true
1757
- }
1758
-
1759
- // Install extras (if requested)
1760
- const extrasInstalled: string[] = []
1761
- let extrasError: string | null = null
1762
- if (args.extras && args.extras.length > 0) {
1763
- const extrasCmd = pm === "uv"
1764
- ? `uv add ${args.extras.join(" ")}`
1765
- : `pip install ${args.extras.join(" ")}`
1766
- try {
1767
- execSync(extrasCmd, {
1768
- cwd: root,
1769
- stdio: "ignore",
1770
- timeout: 180_000,
1771
- })
1772
- extrasInstalled.push(...args.extras)
1773
- } catch (e: any) {
1774
- extrasError = e.message?.slice(0, 500) || "unknown error"
1775
- }
1776
- }
1777
-
1778
- // Write status file
1779
- const bootstrapRecord = {
1780
- template: "fastapi-sdd",
1781
- version: TEMPLATE_VERSION,
1782
- lastBootstrappedAt: new Date().toISOString(),
1783
- packageManager: pm,
1784
- pythonVersion: pythonCheck.version,
1785
- filesCopied,
1786
- filesSkipped,
1787
- extrasInstalled,
1788
- installDuration,
1789
- totalDuration: Date.now() - start,
1790
- }
1791
- writeBootstrapStatus(root, bootstrapRecord)
1792
-
1793
- // Read final pyproject.toml for output (per user request 6)
1794
- let finalPyprojectToml: string | null = null
1795
- try {
1796
- finalPyprojectToml = fs.readFileSync(userPyprojectPath, "utf8")
1797
- } catch (e) { /* ignore */ }
1798
-
1799
- return JSON.stringify({
1800
- status: installError || extrasError ? "WARNING" : "SUCCESS",
1801
- initialized: true,
1802
- message: installError
1803
- ? `Bootstrap completo con warnings: install falló. ${installError}`
1804
- : extrasError
1805
- ? `Bootstrap completo con warnings: extras add falló. ${extrasError}`
1806
- : `Bootstrap completo en ${Date.now() - start}ms.`,
1807
- packageManager: pm,
1808
- pythonVersion: pythonCheck.version,
1809
- filesCopied,
1810
- filesSkipped,
1811
- installSkipped,
1812
- installDuration,
1813
- extrasInstalled,
1814
- finalPyprojectToml,
1815
- nextSteps: "Run `uv run uvicorn app.main:app --reload --port 8000` (o `uvicorn ...` con venv activo) para arrancar el dev server.",
1816
- _bootstrapRecord: bootstrapRecord,
1817
- }, null, 2)
1818
- }
1819
- })
1820
-
1821
- export const validate_lucide_icons_batch = tool({
1822
- description: "Valida un lote de nombres de iconos de Lucide React de forma rápida e idempotente. Si node_modules/lucide-react existe, verifica contra los exports reales; de lo contrario, valida contra un listado estático de los iconos más comunes.",
1823
- args: {
1824
- icons: tool.schema.array(tool.schema.string()).describe("Lista de nombres de iconos a validar (ej: ['Sun', 'Moon', 'Plus'])"),
1825
- },
1826
- async execute(args, context) {
1827
- const root = getRoot(context)
1828
- const results: Record<string, { valid: boolean; source: string }> = {}
1829
-
1830
- // Lista de iconos comunes como fallback
1831
- const commonIcons = new Set([
1832
- "Sun", "Moon", "Plus", "Trash2", "History", "Clock", "Calculator", "User", "Settings",
1833
- "Home", "ChevronLeft", "ChevronRight", "ChevronUp", "ChevronDown", "Search", "X", "Check",
1834
- "Edit", "Menu", "LogOut", "LogIn", "Lock", "Mail", "Phone", "MapPin", "Calendar", "Upload",
1835
- "Download", "ExternalLink", "Eye", "EyeOff", "AlertCircle", "CheckCircle", "Info", "HelpCircle",
1836
- "Bell", "Share2", "Copy", "File", "Folder", "Image", "Video", "Music", "Play", "Pause",
1837
- "Server", "Database", "Terminal", "Code", "Grid", "List", "Filter", "Heart", "Star",
1838
- "ArrowRight", "ArrowLeft", "ArrowUp", "ArrowDown", "RefreshCw", "Send", "Activity", "Briefcase",
1839
- "Camera", "Cloud", "Compass", "Cpu", "CreditCard", "DollarSign", "DownloadCloud", "Edit2", "Edit3",
1840
- "FileText", "Gift", "Globe", "Hash", "Key", "Laptop", "Map", "MessageCircle", "MessageSquare",
1841
- "Mic", "Monitor", "Package", "Paperclip", "Percent", "Power", "Printer", "Radio", "RotateCcw",
1842
- "Save", "Scissors", "Shield", "ShoppingBag", "ShoppingCart", "Slider", "Sliders", "Smartphone",
1843
- "Speaker", "Tablet", "Tag", "Target", "ThumbsUp", "ThumbsDown", "ToggleLeft", "ToggleRight",
1844
- "Trash", "TrendingUp", "TrendingDown", "Truck", "Tv", "Type", "Umbrella", "Unlock", "UploadCloud",
1845
- "Users", "Volume2", "VolumeX", "Wifi", "Wind", "Zap"
1846
- ])
1847
-
1848
- let hasNodeModules = false
1849
- let exportsList: Set<string> | null = null
1850
-
1851
- // Intentar buscar lucide-react en node_modules
1852
- const lucidePath = path.resolve(root, "node_modules/lucide-react")
1853
- if (fs.existsSync(lucidePath)) {
1854
- try {
1855
- // Ejecutar un script de node rápido para obtener los exports de lucide-react
1856
- const nodeScript = `
1857
- const lucide = require('lucide-react');
1858
- console.log(JSON.stringify(Object.keys(lucide)));
1859
- `
1860
- const output = execSync(`node -e "${nodeScript.replace(/"/g, '\\"')}"`, { cwd: root, stdio: "pipe" }).toString()
1861
- const keys = JSON.parse(output)
1862
- exportsList = new Set(keys)
1863
- hasNodeModules = true
1864
- } catch (e) {
1865
- // Fallback
1866
- }
1867
- }
1868
-
1869
- for (const icon of args.icons) {
1870
- if (hasNodeModules && exportsList) {
1871
- results[icon] = {
1872
- valid: exportsList.has(icon),
1873
- source: "node_modules/lucide-react"
1874
- }
1875
- } else {
1876
- // Validar contra lista estática o formato PascalCase general como fallback optimista
1877
- const isPascalCase = /^[A-Z][a-zA-Z0-9]*$/.test(icon)
1878
- const inCommon = commonIcons.has(icon)
1879
- results[icon] = {
1880
- valid: inCommon || isPascalCase,
1881
- source: inCommon ? "static_common_list" : "pascal_case_fallback"
1882
- }
1883
- }
1884
- }
1885
-
1886
- return JSON.stringify({
1887
- status: "SUCCESS",
1888
- validatedAt: new Date().toISOString(),
1889
- results
1890
- }, null, 2)
1891
- }
1892
- })
1893
-
1894
- export const generate_tests = tool({
1895
- description: "Autogenera plantillas de pruebas unitarias/integración en tests/unit/ a partir de los escenarios de prueba descritos en el contrato activo de sdd_state.json. No pisa archivos de pruebas existentes.",
1896
- args: {},
1897
- async execute(args, context) {
1898
- const root = getRoot(context)
1899
- const stateFile = path.resolve(root, ".openspec/sdd_state.json")
1900
- if (!fs.existsSync(stateFile)) {
1901
- return JSON.stringify({ success: false, error: "sdd_state.json no existe. Inicia una sesión SDD primero." }, null, 2)
1902
- }
1903
- const state = JSON.parse(fs.readFileSync(stateFile, "utf8"))
1904
- const activeContract = state.activeContract
1905
- if (!activeContract) {
1906
- return JSON.stringify({ success: false, error: "El estado activo no tiene un activeContract. El orquestador debe fijar la sesión." }, null, 2)
1907
- }
1908
- const contractPath = path.resolve(root, activeContract)
1909
- if (!fs.existsSync(contractPath)) {
1910
- return JSON.stringify({ success: false, error: `El archivo del contrato '${contractPath}' no existe.` }, null, 2)
1911
- }
1912
- const contract = JSON.parse(fs.readFileSync(contractPath, "utf8"))
1913
- const test_scenarios = contract.test_scenarios || []
1914
- if (test_scenarios.length === 0) {
1915
- return JSON.stringify({ success: true, message: "No se encontraron test_scenarios en el contrato. No hay plantillas que generar." }, null, 2)
1916
- }
1917
-
1918
- const grouped_tests: Record<string, any[]> = {}
1919
- for (const ts of test_scenarios) {
1920
- const feature = ts.feature_ref || "general"
1921
- if (!grouped_tests[feature]) grouped_tests[feature] = []
1922
- grouped_tests[feature].push(ts)
1923
- }
1924
-
1925
- const created: string[] = []
1926
- const skipped: string[] = []
1927
-
1928
- for (const [feature, scenarios] of Object.entries(grouped_tests)) {
1929
- const clean_feature = feature.replace(/([A-Z])/g, "-$1").toLowerCase().replace(/^-/, "")
1930
- const hasReact = scenarios.some(s => s.type === "unit" || s.type === "visual")
1931
- const ext = hasReact ? "tsx" : "ts"
1932
- const test_dir = path.resolve(root, "tests", "unit")
1933
- if (!fs.existsSync(test_dir)) {
1934
- fs.mkdirSync(test_dir, { recursive: true })
1935
- }
1936
- const test_file = path.join(test_dir, `${clean_feature}.test.${ext}`)
1937
-
1938
- if (fs.existsSync(test_file)) {
1939
- skipped.push(`tests/unit/${clean_feature}.test.${ext}`)
1940
- continue
1941
- }
1942
-
1943
- const lines: string[] = []
1944
- lines.push('import { describe, it, expect } from "vitest";')
1945
- if (hasReact) {
1946
- lines.push('import { render, screen } from "@testing-library/react";')
1947
- lines.push('import userEvent from "@testing-library/user-event";')
1948
- lines.push(`// import ${feature} from "@/components/blocks/${clean_feature}";`)
1949
- }
1950
- lines.push("")
1951
- lines.push(`describe("${feature} Tests (Contract Scenarios)", () => {`)
1952
-
1953
- for (const s of scenarios) {
1954
- const tid = s.id || "TS-XX"
1955
- const name = s.name || "Test case"
1956
- lines.push(` // ${tid}: ${name}`)
1957
- lines.push(` // Given: ${s.given || ""}`)
1958
- lines.push(` // When: ${s.when || ""}`)
1959
- lines.push(` // Then: ${s.then || ""}`)
1960
- lines.push(` it("${tid}: ${name}", async () => {`)
1961
- lines.push(' // TODO: Implement actual contract assertions')
1962
- lines.push(' expect(true).toBe(true);')
1963
- lines.push(' });')
1964
- lines.push("")
1965
- }
1966
- lines.push("});")
1967
-
1968
- fs.writeFileSync(test_file, lines.join("\n").trim() + "\n", "utf8")
1969
- created.push(`tests/unit/${clean_feature}.test.${ext}`)
1970
- }
1971
-
1972
- return JSON.stringify({
1973
- status: "SUCCESS",
1974
- message: "Generación de plantillas de prueba completada.",
1975
- created,
1976
- skipped
1977
- }, null, 2)
1978
- }
1979
- })
1980
-
1981
- export const save_playwright_artifacts = tool({
1982
- description: "Promueve y archiva los artefactos de captura visual/videos (.openspec/.playwright/) generados por Playwright a la carpeta del spec/contrato activo de forma ordenada, evitando ensuciar la raíz.",
1983
- args: {
1984
- callId: tool.schema.string().optional().describe("ID de llamada de Playwright MCP específica. Si no se pasa, toma la última ejecución de forma automática."),
1985
- move: tool.schema.boolean().default(false).describe("Si true, mueve los archivos en lugar de copiarlos.")
1986
- },
1987
- async execute(args, context) {
1988
- const root = getRoot(context)
1989
- const stateFile = path.resolve(root, ".openspec/sdd_state.json")
1990
- if (!fs.existsSync(stateFile)) {
1991
- return JSON.stringify({ success: false, error: "sdd_state.json no existe. Inicia una sesión SDD primero." }, null, 2)
1992
- }
1993
- const state = JSON.parse(fs.readFileSync(stateFile, "utf8"))
1994
- const activeContract = state.activeContract
1995
- if (!activeContract) {
1996
- return JSON.stringify({ success: false, error: "El estado activo no tiene un activeContract. El orquestador debe fijar la sesión." }, null, 2)
1997
- }
1998
- const contractPath = path.resolve(root, activeContract)
1999
- if (!fs.existsSync(contractPath)) {
2000
- return JSON.stringify({ success: false, error: `El archivo del contrato '${contractPath}' no existe.` }, null, 2)
2001
- }
2002
- const activeDir = path.dirname(contractPath)
2003
- const sourceBase = path.resolve(root, ".openspec/.playwright")
2004
- if (!fs.existsSync(sourceBase)) {
2005
- return JSON.stringify({ success: true, message: "No existen carpetas de Playwright para archivar en .openspec/.playwright." }, null, 2)
2006
- }
2007
-
2008
- let callId = args.callId
2009
- if (!callId) {
2010
- const files = fs.readdirSync(sourceBase).filter(f => fs.statSync(path.join(sourceBase, f)).isDirectory())
2011
- if (files.length === 0) {
2012
- return JSON.stringify({ success: true, message: "No hay carpetas de artefactos de Playwright disponibles en .openspec/.playwright" }, null, 2)
2013
- }
2014
- // sort by mtime descending to get the newest
2015
- files.sort((a, b) => {
2016
- return fs.statSync(path.join(sourceBase, b)).mtimeMs - fs.statSync(path.join(sourceBase, a)).mtimeMs
2017
- })
2018
- callId = files[0]
2019
- }
2020
-
2021
- const sourceDir = path.join(sourceBase, callId)
2022
- if (!fs.existsSync(sourceDir)) {
2023
- return JSON.stringify({ success: false, error: `La ruta de origen '${sourceDir}' no existe.` }, null, 2)
2024
- }
2025
-
2026
- const destDir = path.join(activeDir, "playwright", callId)
2027
- if (!fs.existsSync(destDir)) {
2028
- fs.mkdirSync(destDir, { recursive: true })
2029
- }
2030
-
2031
- const copyRecursiveSync = (src: string, dest: string, moveMode: boolean) => {
2032
- const exists = fs.existsSync(src)
2033
- const stats = exists && fs.statSync(src)
2034
- const isDirectory = exists && stats.isDirectory()
2035
- if (isDirectory) {
2036
- if (!fs.existsSync(dest)) {
2037
- fs.mkdirSync(dest, { recursive: true })
2038
- }
2039
- fs.readdirSync(src).forEach((childItemName) => {
2040
- copyRecursiveSync(path.join(src, childItemName), path.join(dest, childItemName), moveMode)
2041
- })
2042
- if (moveMode) {
2043
- fs.rmdirSync(src)
2044
- }
2045
- } else {
2046
- fs.copyFileSync(src, dest)
2047
- if (moveMode) {
2048
- fs.unlinkSync(src)
2049
- }
2050
- }
2051
- }
2052
-
2053
- try {
2054
- copyRecursiveSync(sourceDir, destDir, args.move)
2055
- return JSON.stringify({
2056
- status: "SUCCESS",
2057
- message: `Artefactos archivados con éxito en la sesión activa.`,
2058
- callId,
2059
- destination: destDir
2060
- }, null, 2)
2061
- } catch (e: any) {
2062
- return JSON.stringify({
2063
- status: "ERROR",
2064
- error: e.message
2065
- }, null, 2)
2066
- }
2067
- }
2068
- })
2069
-
2070
-
2071
-
2072
-