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.
- package/.opencode/agents/sdd-coder.md +37 -5
- package/.opencode/agents/sdd-deployer.md +4 -1
- package/.opencode/agents/sdd-orchestrator.md +33 -12
- package/.opencode/agents/sdd-reviewer.md +53 -0
- package/.opencode/agents/sdd-spec-writer.md +14 -2
- package/.opencode/agents/sdd-tester.md +27 -4
- package/.opencode/commands/fast.md +19 -0
- package/.opencode/commands/reset.md +1 -1
- package/.opencode/commands/review.md +18 -0
- package/.opencode/contract-schema.json +8 -3
- package/.opencode/plugins/sdd-bridge.ts +96 -1
- package/.opencode/tools/brain.ts +26 -15
- package/.opencode/tools/fast-track-init.js +36 -0
- package/.opencode/tools/sdd_bootstrap.ts +625 -0
- package/.opencode/tools/sdd_core.ts +673 -0
- package/.opencode/tools/sdd_design.ts +303 -0
- package/.opencode/tools/sdd_docker.ts +254 -0
- package/.opencode/tools/sdd_network.ts +152 -0
- package/.opencode/tools/sdd_testing.ts +362 -0
- package/opencode.json +0 -108
- package/package.json +1 -1
- package/tui.json +9 -1
- package/.opencode/tools/sdd.ts +0 -2072
package/.opencode/tools/sdd.ts
DELETED
|
@@ -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
|
-
|