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
|
@@ -0,0 +1,152 @@
|
|
|
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
|
+
// Helper to get PID file path
|
|
15
|
+
const getPidFilePath = (root: string) => {
|
|
16
|
+
return path.resolve(root, ".openspec/dev_server.pid")
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Tool: sdd_free_port
|
|
20
|
+
export const free_port = tool({
|
|
21
|
+
description: "Busca y termina de manera forzada cualquier proceso que esté escuchando en un puerto específico",
|
|
22
|
+
args: {
|
|
23
|
+
port: tool.schema.number().describe("El puerto a liberar (ej. 3000 o 8000)")
|
|
24
|
+
},
|
|
25
|
+
async execute(args, context) {
|
|
26
|
+
const port = args.port
|
|
27
|
+
try {
|
|
28
|
+
const pid = execSync(`lsof -t -i :${port}`).toString().trim()
|
|
29
|
+
if (pid) {
|
|
30
|
+
execSync(`kill -9 ${pid.split('\n').join(' ')}`)
|
|
31
|
+
return JSON.stringify({
|
|
32
|
+
status: "SUCCESS",
|
|
33
|
+
message: `Puerto ${port} liberado. Procesos terminados: ${pid.split('\n').join(', ')}`
|
|
34
|
+
}, null, 2)
|
|
35
|
+
}
|
|
36
|
+
return JSON.stringify({
|
|
37
|
+
status: "SUCCESS",
|
|
38
|
+
message: `Puerto ${port} ya estaba libre (no se encontraron procesos)`
|
|
39
|
+
}, null, 2)
|
|
40
|
+
} catch (e) {
|
|
41
|
+
return JSON.stringify({
|
|
42
|
+
status: "SUCCESS",
|
|
43
|
+
message: `Puerto ${port} está libre`
|
|
44
|
+
}, null, 2)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
// Tool: sdd_start_server
|
|
50
|
+
export const start_server = tool({
|
|
51
|
+
description: "Inicia un servidor de desarrollo o producción en segundo plano y registra su PID para limpieza posterior",
|
|
52
|
+
args: {
|
|
53
|
+
command: tool.schema.string().describe("Comando para iniciar el servidor (ej. 'yarn dev' o 'npm run dev')"),
|
|
54
|
+
port: tool.schema.number().optional().default(3000).describe("Puerto esperado del servidor (default: 3000)"),
|
|
55
|
+
cwd: tool.schema.string().optional().describe("Directorio de trabajo para ejecutar el comando")
|
|
56
|
+
},
|
|
57
|
+
async execute(args, context) {
|
|
58
|
+
const root = getRoot(context)
|
|
59
|
+
const targetCwd = args.cwd ? path.resolve(root, args.cwd) : root
|
|
60
|
+
const pidFile = getPidFilePath(root)
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const existingPid = execSync(`lsof -t -i :${args.port}`).toString().trim()
|
|
64
|
+
if (existingPid) {
|
|
65
|
+
execSync(`kill -9 ${existingPid.split('\n').join(' ')}`)
|
|
66
|
+
}
|
|
67
|
+
} catch (e) {
|
|
68
|
+
// ignore
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const parts = args.command.split(" ")
|
|
72
|
+
const cmd = parts[0]
|
|
73
|
+
const cmdArgs = parts.slice(1)
|
|
74
|
+
|
|
75
|
+
const child = spawn(cmd, cmdArgs, {
|
|
76
|
+
cwd: targetCwd,
|
|
77
|
+
detached: true,
|
|
78
|
+
stdio: "ignore",
|
|
79
|
+
env: {
|
|
80
|
+
...process.env,
|
|
81
|
+
PORT: String(args.port)
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
child.unref()
|
|
86
|
+
|
|
87
|
+
const pid = child.pid
|
|
88
|
+
if (pid) {
|
|
89
|
+
const dir = path.dirname(pidFile)
|
|
90
|
+
if (!fs.existsSync(dir)) {
|
|
91
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
92
|
+
}
|
|
93
|
+
fs.writeFileSync(pidFile, String(pid), "utf8")
|
|
94
|
+
|
|
95
|
+
await new Promise(resolve => setTimeout(resolve, 3000))
|
|
96
|
+
|
|
97
|
+
return JSON.stringify({
|
|
98
|
+
status: "SUCCESS",
|
|
99
|
+
message: `Servidor iniciado con PID ${pid} ejecutando '${args.command}' en puerto ${args.port}`,
|
|
100
|
+
pid
|
|
101
|
+
}, null, 2)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return JSON.stringify({
|
|
105
|
+
status: "ERROR",
|
|
106
|
+
message: `No se pudo obtener el PID del proceso para el comando '${args.command}'`
|
|
107
|
+
}, null, 2)
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
// Tool: sdd_stop_server
|
|
112
|
+
export const stop_server = tool({
|
|
113
|
+
description: "Detiene el servidor en segundo plano usando el PID registrado",
|
|
114
|
+
args: {},
|
|
115
|
+
async execute(args, context) {
|
|
116
|
+
const root = getRoot(context)
|
|
117
|
+
const pidFile = getPidFilePath(root)
|
|
118
|
+
|
|
119
|
+
if (fs.existsSync(pidFile)) {
|
|
120
|
+
try {
|
|
121
|
+
const pidStr = fs.readFileSync(pidFile, "utf8").trim()
|
|
122
|
+
const pid = parseInt(pidStr, 10)
|
|
123
|
+
if (!isNaN(pid)) {
|
|
124
|
+
try {
|
|
125
|
+
process.kill(-pid, "SIGKILL")
|
|
126
|
+
} catch (err) {
|
|
127
|
+
try {
|
|
128
|
+
process.kill(pid, "SIGKILL")
|
|
129
|
+
} catch (err2) {
|
|
130
|
+
// ignore
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
fs.unlinkSync(pidFile)
|
|
134
|
+
return JSON.stringify({
|
|
135
|
+
status: "SUCCESS",
|
|
136
|
+
message: `Servidor con PID ${pid} terminado exitosamente`
|
|
137
|
+
}, null, 2)
|
|
138
|
+
}
|
|
139
|
+
} catch (e) {
|
|
140
|
+
return JSON.stringify({
|
|
141
|
+
status: "ERROR",
|
|
142
|
+
message: `Error al intentar detener el servidor: ${(e as Error).message}`
|
|
143
|
+
}, null, 2)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return JSON.stringify({
|
|
148
|
+
status: "SUCCESS",
|
|
149
|
+
message: "No hay ningún servidor registrado activo"
|
|
150
|
+
}, null, 2)
|
|
151
|
+
}
|
|
152
|
+
})
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin"
|
|
2
|
+
import fs from "fs"
|
|
3
|
+
import path from "path"
|
|
4
|
+
import { execSync } from "child_process"
|
|
5
|
+
|
|
6
|
+
// 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
|
+
// Helper to parse semantic errors from compiler and linter outputs (reducing raw trace log bloat for the LLM)
|
|
15
|
+
const parseSemanticErrors = (rawOutput: string, type: "eslint" | "tsc"): any[] => {
|
|
16
|
+
const parsed: any[] = []
|
|
17
|
+
if (type === "eslint") {
|
|
18
|
+
const lines = rawOutput.split("\n")
|
|
19
|
+
for (const line of lines) {
|
|
20
|
+
const match = line.match(/^([^:]+):line\s+(\d+),\s+col\s+(\d+),\s+(.+)$/i) || line.match(/^([^\s]+):(\d+):(\d+):\s+(.+)$/)
|
|
21
|
+
if (match) {
|
|
22
|
+
parsed.push({
|
|
23
|
+
file: match[1].trim(),
|
|
24
|
+
line: parseInt(match[2], 10),
|
|
25
|
+
column: parseInt(match[3], 10),
|
|
26
|
+
error: match[4].trim()
|
|
27
|
+
})
|
|
28
|
+
} else if (line.includes("error") && line.trim().length > 0) {
|
|
29
|
+
parsed.push({ raw: line.trim() })
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
} else if (type === "tsc") {
|
|
33
|
+
const lines = rawOutput.split("\n")
|
|
34
|
+
for (const line of lines) {
|
|
35
|
+
const match = line.match(/^([^(]+)\((\d+),(\d+)\):\s+(error\s+TS\d+:\s+.+)$/)
|
|
36
|
+
if (match) {
|
|
37
|
+
parsed.push({
|
|
38
|
+
file: match[1].trim(),
|
|
39
|
+
line: parseInt(match[2], 10),
|
|
40
|
+
column: parseInt(match[3], 10),
|
|
41
|
+
error: match[4].trim()
|
|
42
|
+
})
|
|
43
|
+
} else if (line.includes("error TS") && line.trim().length > 0) {
|
|
44
|
+
parsed.push({ raw: line.trim() })
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return parsed.length > 0 ? parsed : [{ raw: rawOutput.slice(0, 1500) }]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Tool: sdd_quick_lint
|
|
52
|
+
export const quick_lint = tool({
|
|
53
|
+
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.",
|
|
54
|
+
args: {},
|
|
55
|
+
async execute(args, context) {
|
|
56
|
+
const root = getRoot(context)
|
|
57
|
+
|
|
58
|
+
const pkgPath = path.resolve(root, "package.json")
|
|
59
|
+
if (!fs.existsSync(pkgPath)) {
|
|
60
|
+
return JSON.stringify({
|
|
61
|
+
status: "ERROR",
|
|
62
|
+
message: "No se encontró package.json. ¿Estás en un proyecto Next.js?",
|
|
63
|
+
}, null, 2)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let eslintFiles = "src/"
|
|
67
|
+
try {
|
|
68
|
+
const stateFile = path.resolve(root, ".openspec/sdd_state.json")
|
|
69
|
+
if (fs.existsSync(stateFile)) {
|
|
70
|
+
const state = JSON.parse(fs.readFileSync(stateFile, "utf8"))
|
|
71
|
+
if (state.activeContract) {
|
|
72
|
+
const contractPath = path.resolve(root, state.activeContract)
|
|
73
|
+
if (fs.existsSync(contractPath)) {
|
|
74
|
+
const contract = JSON.parse(fs.readFileSync(contractPath, "utf8"))
|
|
75
|
+
if (contract && Array.isArray(contract.files_affected) && contract.files_affected.length > 0) {
|
|
76
|
+
const existingFiles = contract.files_affected.filter((f: string) => fs.existsSync(path.resolve(root, f)))
|
|
77
|
+
if (existingFiles.length > 0) {
|
|
78
|
+
eslintFiles = existingFiles.join(" ")
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
} catch (e) {
|
|
85
|
+
// ignore
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const out = execSync(`npx eslint ${eslintFiles} --quiet --max-warnings 0 2>&1 || true`, {
|
|
90
|
+
cwd: root,
|
|
91
|
+
encoding: "utf8",
|
|
92
|
+
timeout: 120_000,
|
|
93
|
+
})
|
|
94
|
+
const hasErrors = out.toLowerCase().includes("error") || out.trim().length > 0
|
|
95
|
+
return JSON.stringify({
|
|
96
|
+
status: hasErrors ? "FAIL" : "SUCCESS",
|
|
97
|
+
message: hasErrors ? `Lint encontró errores en: ${eslintFiles}. Corrígelos antes de transicionar a F3.` : "Lint limpio.",
|
|
98
|
+
output: out.slice(0, 2000),
|
|
99
|
+
}, null, 2)
|
|
100
|
+
} catch (e: any) {
|
|
101
|
+
return JSON.stringify({
|
|
102
|
+
status: "FAIL",
|
|
103
|
+
message: `Lint falló: ${e.message?.slice(0, 500) || "unknown error"}`,
|
|
104
|
+
}, null, 2)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
// Tool: sdd_shift_left_verify
|
|
110
|
+
export const shift_left_verify = tool({
|
|
111
|
+
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.",
|
|
112
|
+
args: {},
|
|
113
|
+
async execute(args, context) {
|
|
114
|
+
const root = getRoot(context)
|
|
115
|
+
const result: { tsc: { status: string, errors?: any[] }, eslint: { status: string, errors?: any[] } } = {
|
|
116
|
+
tsc: { status: "SUCCESS" },
|
|
117
|
+
eslint: { status: "SUCCESS" }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 1. Run tsc --noEmit
|
|
121
|
+
try {
|
|
122
|
+
execSync("npx tsc --noEmit", { cwd: root, stdio: "pipe", timeout: 60000 })
|
|
123
|
+
} catch (e: any) {
|
|
124
|
+
const rawOutput = e.stdout?.toString() || e.stderr?.toString() || ""
|
|
125
|
+
const errors = parseSemanticErrors(rawOutput, "tsc")
|
|
126
|
+
result.tsc = {
|
|
127
|
+
status: "FAIL",
|
|
128
|
+
errors
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 2. Run eslint targeting modified files dynamically
|
|
133
|
+
let eslintFiles = "src/"
|
|
134
|
+
try {
|
|
135
|
+
const stateFile = path.resolve(root, ".openspec/sdd_state.json")
|
|
136
|
+
if (fs.existsSync(stateFile)) {
|
|
137
|
+
const state = JSON.parse(fs.readFileSync(stateFile, "utf8"))
|
|
138
|
+
if (state.activeContract) {
|
|
139
|
+
const contractPath = path.resolve(root, state.activeContract)
|
|
140
|
+
if (fs.existsSync(contractPath)) {
|
|
141
|
+
const contract = JSON.parse(fs.readFileSync(contractPath, "utf8"))
|
|
142
|
+
if (contract && Array.isArray(contract.files_affected) && contract.files_affected.length > 0) {
|
|
143
|
+
const existingFiles = contract.files_affected.filter((f: string) => fs.existsSync(path.resolve(root, f)))
|
|
144
|
+
if (existingFiles.length > 0) {
|
|
145
|
+
eslintFiles = existingFiles.join(" ")
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
} catch (e) {
|
|
152
|
+
// ignore
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
const out = execSync(`npx eslint ${eslintFiles} --quiet 2>&1 || true`, {
|
|
157
|
+
cwd: root,
|
|
158
|
+
encoding: "utf8",
|
|
159
|
+
timeout: 60000,
|
|
160
|
+
})
|
|
161
|
+
const isCircularError = out.toLowerCase().includes("converting circular structure")
|
|
162
|
+
if ((out.toLowerCase().includes("error") || out.trim().length > 0) && !isCircularError) {
|
|
163
|
+
const errors = parseSemanticErrors(out, "eslint")
|
|
164
|
+
result.eslint = {
|
|
165
|
+
status: "FAIL",
|
|
166
|
+
errors
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
} catch (e: any) {
|
|
170
|
+
result.eslint = {
|
|
171
|
+
status: "FAIL",
|
|
172
|
+
errors: [{ raw: e.message || "Eslint execution error" }]
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const overallSuccess = result.tsc.status === "SUCCESS" && result.eslint.status === "SUCCESS"
|
|
177
|
+
|
|
178
|
+
return JSON.stringify({
|
|
179
|
+
status: overallSuccess ? "SUCCESS" : "FAIL",
|
|
180
|
+
message: overallSuccess ? "Shift-Left Verification exitosa: Cero errores de compilación y cero errores de lint." : "Validación fallida: Se encontraron errores estáticos.",
|
|
181
|
+
verification: result
|
|
182
|
+
}, null, 2)
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
// Tool: sdd_generate_tests
|
|
187
|
+
export const generate_tests = tool({
|
|
188
|
+
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.",
|
|
189
|
+
args: {},
|
|
190
|
+
async execute(args, context) {
|
|
191
|
+
const root = getRoot(context)
|
|
192
|
+
const stateFile = path.resolve(root, ".openspec/sdd_state.json")
|
|
193
|
+
if (!fs.existsSync(stateFile)) {
|
|
194
|
+
return JSON.stringify({ success: false, error: "sdd_state.json no existe. Inicia una sesión SDD primero." }, null, 2)
|
|
195
|
+
}
|
|
196
|
+
const state = JSON.parse(fs.readFileSync(stateFile, "utf8"))
|
|
197
|
+
const activeContract = state.activeContract
|
|
198
|
+
if (!activeContract) {
|
|
199
|
+
return JSON.stringify({ success: false, error: "El estado activo no tiene un activeContract. El orquestador debe fijar la sesión." }, null, 2)
|
|
200
|
+
}
|
|
201
|
+
const contractPath = path.resolve(root, activeContract)
|
|
202
|
+
if (!fs.existsSync(contractPath)) {
|
|
203
|
+
return JSON.stringify({ success: false, error: `El archivo del contrato '${contractPath}' no existe.` }, null, 2)
|
|
204
|
+
}
|
|
205
|
+
const contract = JSON.parse(fs.readFileSync(contractPath, "utf8"))
|
|
206
|
+
const test_scenarios = contract.test_scenarios || []
|
|
207
|
+
if (test_scenarios.length === 0) {
|
|
208
|
+
return JSON.stringify({ success: true, message: "No se encontraron test_scenarios en el contrato. No hay plantillas que generar." }, null, 2)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const grouped_tests: Record<string, any[]> = {}
|
|
212
|
+
for (const ts of test_scenarios) {
|
|
213
|
+
const feature = ts.feature_ref || "general"
|
|
214
|
+
if (!grouped_tests[feature]) grouped_tests[feature] = []
|
|
215
|
+
grouped_tests[feature].push(ts)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const created: string[] = []
|
|
219
|
+
const skipped: string[] = []
|
|
220
|
+
|
|
221
|
+
for (const [feature, scenarios] of Object.entries(grouped_tests)) {
|
|
222
|
+
const clean_feature = feature.replace(/([A-Z])/g, "-$1").toLowerCase().replace(/^-/, "")
|
|
223
|
+
const hasReact = scenarios.some(s => s.type === "unit" || s.type === "visual")
|
|
224
|
+
const ext = hasReact ? "tsx" : "ts"
|
|
225
|
+
const test_dir = path.resolve(root, "tests", "unit")
|
|
226
|
+
if (!fs.existsSync(test_dir)) {
|
|
227
|
+
fs.mkdirSync(test_dir, { recursive: true })
|
|
228
|
+
}
|
|
229
|
+
const test_file = path.join(test_dir, `${clean_feature}.test.${ext}`)
|
|
230
|
+
|
|
231
|
+
if (fs.existsSync(test_file)) {
|
|
232
|
+
skipped.push(`tests/unit/${clean_feature}.test.${ext}`)
|
|
233
|
+
continue
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const lines: string[] = []
|
|
237
|
+
lines.push('import { describe, it, expect } from "vitest";')
|
|
238
|
+
if (hasReact) {
|
|
239
|
+
lines.push('import { render, screen } from "@testing-library/react";')
|
|
240
|
+
lines.push('import userEvent from "@testing-library/user-event";')
|
|
241
|
+
lines.push(`// import ${feature} from "@/components/blocks/${clean_feature}";`)
|
|
242
|
+
}
|
|
243
|
+
lines.push("")
|
|
244
|
+
lines.push(`describe("${feature} Tests (Contract Scenarios)", () => {`)
|
|
245
|
+
|
|
246
|
+
for (const s of scenarios) {
|
|
247
|
+
const tid = s.id || "TS-XX"
|
|
248
|
+
const name = s.name || "Test case"
|
|
249
|
+
lines.push(` // ${tid}: ${name}`)
|
|
250
|
+
lines.push(` // Given: ${s.given || ""}`)
|
|
251
|
+
lines.push(` // When: ${s.when || ""}`)
|
|
252
|
+
lines.push(` // Then: ${s.then || ""}`)
|
|
253
|
+
lines.push(` it("${tid}: ${name}", async () => {`)
|
|
254
|
+
lines.push(' // TODO: Implement actual contract assertions')
|
|
255
|
+
lines.push(' expect(true).toBe(true);')
|
|
256
|
+
lines.push(' });')
|
|
257
|
+
lines.push("")
|
|
258
|
+
}
|
|
259
|
+
lines.push("});")
|
|
260
|
+
|
|
261
|
+
fs.writeFileSync(test_file, lines.join("\n").trim() + "\n", "utf8")
|
|
262
|
+
created.push(`tests/unit/${clean_feature}.test.${ext}`)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return JSON.stringify({
|
|
266
|
+
status: "SUCCESS",
|
|
267
|
+
message: "Generación de plantillas de prueba completada.",
|
|
268
|
+
created,
|
|
269
|
+
skipped
|
|
270
|
+
}, null, 2)
|
|
271
|
+
}
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
// Tool: sdd_save_playwright_artifacts
|
|
275
|
+
export const save_playwright_artifacts = tool({
|
|
276
|
+
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.",
|
|
277
|
+
args: {
|
|
278
|
+
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."),
|
|
279
|
+
move: tool.schema.boolean().default(false).describe("Si true, mueve los archivos en lugar de copiarlos.")
|
|
280
|
+
},
|
|
281
|
+
async execute(args, context) {
|
|
282
|
+
const root = getRoot(context)
|
|
283
|
+
const stateFile = path.resolve(root, ".openspec/sdd_state.json")
|
|
284
|
+
if (!fs.existsSync(stateFile)) {
|
|
285
|
+
return JSON.stringify({ success: false, error: "sdd_state.json no existe. Inicia una sesión SDD primero." }, null, 2)
|
|
286
|
+
}
|
|
287
|
+
const state = JSON.parse(fs.readFileSync(stateFile, "utf8"))
|
|
288
|
+
const activeContract = state.activeContract
|
|
289
|
+
if (!activeContract) {
|
|
290
|
+
return JSON.stringify({ success: false, error: "El estado activo no tiene un activeContract. El orquestador debe fijar la sesión." }, null, 2)
|
|
291
|
+
}
|
|
292
|
+
const contractPath = path.resolve(root, activeContract)
|
|
293
|
+
if (!fs.existsSync(contractPath)) {
|
|
294
|
+
return JSON.stringify({ success: false, error: `El archivo del contrato '${contractPath}' no existe.` }, null, 2)
|
|
295
|
+
}
|
|
296
|
+
const activeDir = path.dirname(contractPath)
|
|
297
|
+
const sourceBase = path.resolve(root, ".openspec/.playwright")
|
|
298
|
+
if (!fs.existsSync(sourceBase)) {
|
|
299
|
+
return JSON.stringify({ success: true, message: "No existen carpetas de Playwright para archivar en .openspec/.playwright." }, null, 2)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
let callId = args.callId
|
|
303
|
+
if (!callId) {
|
|
304
|
+
const files = fs.readdirSync(sourceBase).filter(f => fs.statSync(path.join(sourceBase, f)).isDirectory())
|
|
305
|
+
if (files.length === 0) {
|
|
306
|
+
return JSON.stringify({ success: true, message: "No hay carpetas de artefactos de Playwright disponibles en .openspec/.playwright" }, null, 2)
|
|
307
|
+
}
|
|
308
|
+
// sort by mtime descending to get the newest
|
|
309
|
+
files.sort((a, b) => {
|
|
310
|
+
return fs.statSync(path.join(sourceBase, b)).mtimeMs - fs.statSync(path.join(sourceBase, a)).mtimeMs
|
|
311
|
+
})
|
|
312
|
+
callId = files[0]
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const sourceDir = path.join(sourceBase, callId)
|
|
316
|
+
if (!fs.existsSync(sourceDir)) {
|
|
317
|
+
return JSON.stringify({ success: false, error: `La ruta de origen '${sourceDir}' no existe.` }, null, 2)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const destDir = path.join(activeDir, "playwright", callId)
|
|
321
|
+
if (!fs.existsSync(destDir)) {
|
|
322
|
+
fs.mkdirSync(destDir, { recursive: true })
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const copyRecursiveSync = (src: string, dest: string, moveMode: boolean) => {
|
|
326
|
+
const exists = fs.existsSync(src)
|
|
327
|
+
const stats = exists ? fs.statSync(src) : null
|
|
328
|
+
const isDirectory = stats ? stats.isDirectory() : false
|
|
329
|
+
if (isDirectory) {
|
|
330
|
+
if (!fs.existsSync(dest)) {
|
|
331
|
+
fs.mkdirSync(dest, { recursive: true })
|
|
332
|
+
}
|
|
333
|
+
fs.readdirSync(src).forEach((childItemName) => {
|
|
334
|
+
copyRecursiveSync(path.join(src, childItemName), path.join(dest, childItemName), moveMode)
|
|
335
|
+
})
|
|
336
|
+
if (moveMode) {
|
|
337
|
+
fs.rmdirSync(src)
|
|
338
|
+
}
|
|
339
|
+
} else {
|
|
340
|
+
fs.copyFileSync(src, dest)
|
|
341
|
+
if (moveMode) {
|
|
342
|
+
fs.unlinkSync(src)
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
try {
|
|
348
|
+
copyRecursiveSync(sourceDir, destDir, args.move)
|
|
349
|
+
return JSON.stringify({
|
|
350
|
+
status: "SUCCESS",
|
|
351
|
+
message: `Artefactos archivados con éxito en la sesión activa.`,
|
|
352
|
+
callId,
|
|
353
|
+
destination: destDir
|
|
354
|
+
}, null, 2)
|
|
355
|
+
} catch (e: any) {
|
|
356
|
+
return JSON.stringify({
|
|
357
|
+
status: "ERROR",
|
|
358
|
+
error: e.message
|
|
359
|
+
}, null, 2)
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
})
|
package/opencode.json
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://opencode.ai/config.json",
|
|
3
|
-
"theme": "tokyonight",
|
|
4
3
|
"permission": {
|
|
5
4
|
"*": "allow",
|
|
6
5
|
"skill": {
|
|
@@ -92,112 +91,5 @@
|
|
|
92
91
|
"enabled": true,
|
|
93
92
|
"purpose": "Permite buscar, filtrar y obtener ejemplos de uso JSX de más de 1,500 iconos de Lucide React de forma exacta mediante herramientas del MCP."
|
|
94
93
|
}
|
|
95
|
-
},
|
|
96
|
-
"agent": {
|
|
97
|
-
"sdd-orchestrator": {
|
|
98
|
-
"description": "Coordinador principal del flujo SDD y del stack cerrado",
|
|
99
|
-
"mode": "primary",
|
|
100
|
-
"model": "deepseek/deepseek-v4-flash",
|
|
101
|
-
"prompt": "{file:./.opencode/rules/sdd-global.md}\n\n{file:./.opencode/agents/sdd-orchestrator.md}",
|
|
102
|
-
"permission": {
|
|
103
|
-
"*": "allow",
|
|
104
|
-
"write": {
|
|
105
|
-
".openspec/*": "allow",
|
|
106
|
-
".openspec/**": "allow",
|
|
107
|
-
".opencode/*": "allow",
|
|
108
|
-
".opencode/**": "allow",
|
|
109
|
-
"*": "deny"
|
|
110
|
-
},
|
|
111
|
-
"edit": "deny",
|
|
112
|
-
"bash": "deny",
|
|
113
|
-
"task": {
|
|
114
|
-
"*": "deny",
|
|
115
|
-
"sdd-*": "allow"
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
},
|
|
119
|
-
"sdd-spec-writer": {
|
|
120
|
-
"description": "Redacta y valida contratos OpenAPI e interfaces en specs/",
|
|
121
|
-
"mode": "subagent",
|
|
122
|
-
"hidden": true,
|
|
123
|
-
"model": "deepseek/deepseek-v4-flash",
|
|
124
|
-
"prompt": "{file:./.opencode/rules/sdd-global.md}\n\n{file:./.opencode/agents/sdd-spec-writer.md}",
|
|
125
|
-
"permission": {
|
|
126
|
-
"*": "allow",
|
|
127
|
-
"bash": "deny"
|
|
128
|
-
}
|
|
129
|
-
},
|
|
130
|
-
"sdd-coder": {
|
|
131
|
-
"description": "Implementa código ajustándose estrictamente a los contratos en specs/",
|
|
132
|
-
"mode": "subagent",
|
|
133
|
-
"hidden": true,
|
|
134
|
-
"steps": 10,
|
|
135
|
-
"model": "deepseek/deepseek-v4-flash",
|
|
136
|
-
"temperature": 0.35,
|
|
137
|
-
"frequency_penalty": 0.5,
|
|
138
|
-
"presence_penalty": 0.2,
|
|
139
|
-
"prompt": "{file:./.opencode/rules/sdd-global.md}\n\n{file:./.opencode/agents/sdd-coder.md}",
|
|
140
|
-
"permission": {
|
|
141
|
-
"*": "allow",
|
|
142
|
-
"bash": {
|
|
143
|
-
"*": "ask",
|
|
144
|
-
"npm *": "allow",
|
|
145
|
-
"pnpm *": "allow",
|
|
146
|
-
"yarn *": "allow",
|
|
147
|
-
"vitest *": "allow",
|
|
148
|
-
"npx eslint *": "allow",
|
|
149
|
-
"eslint *": "allow",
|
|
150
|
-
"pytest *": "allow",
|
|
151
|
-
"python3 *": "allow",
|
|
152
|
-
"python *": "allow",
|
|
153
|
-
"uv *": "allow",
|
|
154
|
-
"pip *": "allow",
|
|
155
|
-
"npx *": "allow",
|
|
156
|
-
"npx shadcn*": "allow",
|
|
157
|
-
"cat *": "allow"
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
},
|
|
161
|
-
"sdd-tester": {
|
|
162
|
-
"description": "Diseña y corre pruebas unitarias, integración y visuales (Playwright)",
|
|
163
|
-
"mode": "subagent",
|
|
164
|
-
"hidden": true,
|
|
165
|
-
"steps": 15,
|
|
166
|
-
"model": "deepseek/deepseek-v4-flash",
|
|
167
|
-
"temperature": 0.35,
|
|
168
|
-
"frequency_penalty": 0.5,
|
|
169
|
-
"presence_penalty": 0.2,
|
|
170
|
-
"prompt": "{file:./.opencode/rules/sdd-global.md}\n\n{file:./.opencode/agents/sdd-tester.md}",
|
|
171
|
-
"permission": {
|
|
172
|
-
"*": "allow",
|
|
173
|
-
"bash": {
|
|
174
|
-
"*": "ask",
|
|
175
|
-
"npm *": "allow",
|
|
176
|
-
"pnpm *": "allow",
|
|
177
|
-
"yarn *": "allow",
|
|
178
|
-
"vitest *": "allow",
|
|
179
|
-
"npx eslint *": "allow",
|
|
180
|
-
"eslint *": "allow",
|
|
181
|
-
"pytest *": "allow",
|
|
182
|
-
"python3 *": "allow",
|
|
183
|
-
"python *": "allow",
|
|
184
|
-
"uv *": "allow",
|
|
185
|
-
"pip *": "allow",
|
|
186
|
-
"npx *": "allow",
|
|
187
|
-
"npx shadcn*": "allow",
|
|
188
|
-
"cat *": "allow"
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
},
|
|
192
|
-
"sdd-deployer": {
|
|
193
|
-
"description": "Limpia Docker y realiza el despliegue local del sistema",
|
|
194
|
-
"mode": "subagent",
|
|
195
|
-
"hidden": true,
|
|
196
|
-
"model": "deepseek/deepseek-v4-flash",
|
|
197
|
-
"prompt": "{file:./.opencode/agents/sdd-deployer.md}",
|
|
198
|
-
"permission": {
|
|
199
|
-
"*": "allow"
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
94
|
}
|
|
203
95
|
}
|
package/package.json
CHANGED
package/tui.json
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://opencode.ai/tui.json",
|
|
3
|
+
"theme": "tokyonight",
|
|
3
4
|
"plugin": [
|
|
4
5
|
"./.opencode/plugins/plugin_tui.tsx"
|
|
5
|
-
]
|
|
6
|
+
],
|
|
7
|
+
"keybinds": {
|
|
8
|
+
"leader": "ctrl+x",
|
|
9
|
+
"sidebar_toggle": "leader,b",
|
|
10
|
+
"theme_list": "leader,t",
|
|
11
|
+
"session_list": "leader,l",
|
|
12
|
+
"editor_open": "leader,e"
|
|
13
|
+
}
|
|
6
14
|
}
|