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