zugzbot 1.5.0 → 1.7.0

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.
@@ -68,12 +68,7 @@ Eres el Validador de Contratos (sdd-tester) del flujo SDD. Tu trabajo es ejecuta
68
68
  - **Auto-generación de Plantillas (OBLIGATORIO)**: Al comenzar, ejecuta obligatoriamente `sdd_generate_tests` para autogenerar las plantillas a partir de los `test_scenarios` del contrato. Las plantillas ya incluyen imports reales, mocks estándar (Proxy lucide-react, mock reactivo next-themes), y assertions significativas basadas en el `then` de cada escenario. NO las reescribas desde cero.
69
69
  - **NO explorar el código**: Tu brief ya contiene los archivos de producción a testear y los patrones de mock pre-armados. NO hagas `read` masivos de componentes, NO hagas `glob` ciegos. Solo lee el archivo de test específico que estés arreglando.
70
70
  - **Preparación de Puerto**: Llama proactivamente a `sdd_free_port` para liberar el puerto de pruebas.
71
- - **Ejecución Incremental/Dirigida**: Ejecuta la suite de pruebas de forma focalizada apuntando únicamente a los archivos de test específicos relacionados con la funcionalidad modificada (ej. `npx vitest run src/__tests__/<test_name>.test.tsx` o `pytest tests/unit/<test_name>.py` en lugar de correr toda la base de pruebas), acelerando exponencialmente los tiempos de espera.
72
- - **Timeout duro OBLIGATORIO**: SIEMPRE ejecuta vitest con `--testTimeout=10000 --bail=1` para fallar rápido en el primer error. NUNCA ejecutes `vitest` sin estos flags. Esto previene cuelgues infinitos por imports circulares o chains de Providers que happy-dom no puede resolver.
73
- ```bash
74
- npx vitest run --testTimeout=10000 --bail=1 --reporter=verbose src/__tests__/<file>.test.tsx
75
- ```
76
- Si el comando excede 60 segundos, ABORTA inmediatamente y reporta el cuelgue. NO esperes más de 1 minuto bajo ninguna circunstancia.
71
+ - **Ejecución Incremental/Dirigida (Uso Mandatorio de Tool de Tests)**: Para ejecutar la suite de pruebas de forma focalizada ahorrando una enorme cantidad de tokens de logs de consola, **DEBES** utilizar la herramienta `sdd_run_tests({ file: "src/__tests__/<test_name>.test.tsx" })` (o la ruta correspondiente en pytest). Solo utiliza comandos `bash` directos (como `npx vitest run --testTimeout=10000 --bail=1 --reporter=verbose ...`) si la herramienta `sdd_run_tests` reporta un fallo de entorno o si necesitas depurar localmente. Si un comando de tests excede 60 segundos, ABORTA de inmediato y reporta el cuelgue.
77
72
  - **Si el test runner se cuelga**: Antes de reintentar, ejecuta `npx tsc --noEmit` y `npm run build` para validar la compilación. Si ambos pasan, el problema es del test runner mismo (happy-dom + componente complejo). Reporta al orquestador como "rollback evitable" — el código de producción es válido.
78
73
  - **Linter Focalizado**: Ejecuta `npx eslint` apuntando específicamente a los archivos de producción modificados listados en `files_affected` del brief y a tus archivos de test (ej. `npx eslint src/components/blocks/MyBlock.tsx src/__tests__/MyBlock.test.tsx`), optimizando el análisis estático.
79
74
  - **Resolución Proactiva de Fallas**: Si los tests fallan debido a problemas menores de mocking, importaciones incorrectas, llaves duplicadas en React o configuraciones de test, **tienes autorización total para editarlos y repararlos tú mismo** dentro de `tests/` o `src/__tests__/`. Solo si el error es un fallo lógico en el código de producción de la aplicación (el cual tienes prohibido modificar), debes reportar un rollback a `F2_IMPLEMENTATION`.
@@ -0,0 +1,93 @@
1
+ ---
2
+ description: Muestra el desglose de costos, tokens y archivos cambiados de la sesion actual y los ultimos comandos
3
+ ---
4
+
5
+ # /cost — Desglose de consumo y productividad
6
+
7
+ Has sido invocado con el comando `/cost`. Muestra al usuario de forma directa y visual:
8
+
9
+ ## 1. Sesion actual (live)
10
+
11
+ ```json
12
+ !`node -e "
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const metricsPath = path.resolve('.openspec/.sdd_session_metrics.json');
16
+ const statePath = path.resolve('.openspec/sdd_state.json');
17
+ if (!fs.existsSync(metricsPath)) { console.log(JSON.stringify({status: 'NO_SESSION', message: 'No hay sesion activa o no se ha registrado consumo.'})); process.exit(0); }
18
+ const m = JSON.parse(fs.readFileSync(metricsPath, 'utf8'));
19
+ const s = fs.existsSync(statePath) ? JSON.parse(fs.readFileSync(statePath, 'utf8')) : {};
20
+ const startedAt = m.startedAt || '';
21
+ const lastUpdated = m.lastUpdatedAt || '';
22
+ const durationMs = startedAt && lastUpdated ? Math.max(0, Date.parse(lastUpdated) - Date.parse(startedAt)) : 0;
23
+ const fmtDur = (ms) => { const s = Math.floor(ms/1000); const m = Math.floor(s/60); return m + 'm ' + (s%60) + 's'; };
24
+ const fmtCost = (n) => n < 0.001 ? '$' + n.toFixed(5) : n < 1 ? '$' + n.toFixed(4) : '$' + n.toFixed(3);
25
+ const fmtInt = (n) => Math.round(n).toLocaleString('en-US');
26
+ const out = {
27
+ status: 'ACTIVE',
28
+ contract: m.contractName || 'fast-track',
29
+ sessionId: m.sessionId || 'n/a',
30
+ phase: s.phase || 'unknown',
31
+ startedAt,
32
+ lastUpdated,
33
+ duration: fmtDur(durationMs),
34
+ totals: {
35
+ cost: fmtCost(m.totals.cost || 0),
36
+ tokensIn: fmtInt(m.totals.tokensIn || 0),
37
+ tokensOut: fmtInt(m.totals.tokensOutput || 0),
38
+ messages: fmtInt(m.totals.messages || 0)
39
+ },
40
+ byAgent: Object.entries(m.byAgent || {}).sort((a,b) => (b[1].cost||0)-(a[1].cost||0)).map(([k,v]) => ({agent: k, cost: fmtCost(v.cost||0), tokensIn: fmtInt(v.tokensIn||0), tokensOut: fmtInt(v.tokensOut||0), messages: fmtInt(v.messages||0)}))
41
+ };
42
+ console.log(JSON.stringify(out, null, 2));
43
+ "`
44
+ ```
45
+
46
+ ## 2. Ultimos 10 changelogs (historial de comandos)
47
+
48
+ ```json
49
+ !`node -e "
50
+ const fs = require('fs');
51
+ const path = require('path');
52
+ const idxPath = path.resolve('.openspec/changelog/INDEX.md');
53
+ if (!fs.existsSync(idxPath)) { console.log(JSON.stringify({status: 'NO_CHANGELOGS', message: 'Aun no hay changelogs registrados. Corre un /fast o cierra un ciclo SDD.'})); process.exit(0); }
54
+ const content = fs.readFileSync(idxPath, 'utf8');
55
+ const lines = content.split('\n').filter(l => l.trim().startsWith('|') && !l.startsWith('|---') && !l.startsWith('| Timestamp'));
56
+ const entries = lines.slice(0, 10).map(l => {
57
+ const cells = l.split('|').map(c => c.trim()).filter(Boolean);
58
+ return { timestamp: cells[0], command: cells[1], cost: cells[2], files: cells[3], diff: cells[4] };
59
+ });
60
+ console.log(JSON.stringify({status: 'OK', count: entries.length, entries}, null, 2));
61
+ "`
62
+ ```
63
+
64
+ ## 3. Historial agregado (todas las sesiones cerradas)
65
+
66
+ ```json
67
+ !`node -e "
68
+ const fs = require('fs');
69
+ const path = require('path');
70
+ const logPath = path.resolve('.openspec/archive/_sessions.jsonl');
71
+ if (!fs.existsSync(logPath)) { console.log(JSON.stringify({status: 'NO_HISTORY', message: 'Aun no hay sesiones cerradas en el archivo de historial.'})); process.exit(0); }
72
+ const lines = fs.readFileSync(logPath, 'utf8').split('\n').filter(l => l.trim());
73
+ const fmtCost = (n) => n < 0.001 ? '$' + n.toFixed(5) : n < 1 ? '$' + n.toFixed(4) : '$' + n.toFixed(3);
74
+ const fmtInt = (n) => Math.round(n).toLocaleString('en-US');
75
+ const sessions = lines.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
76
+ const totals = sessions.reduce((acc, s) => ({
77
+ cost: acc.cost + (s.cost || 0),
78
+ tokensIn: acc.tokensIn + (s.tokensIn || 0),
79
+ tokensOutput: acc.tokensOutput + (s.tokensOutput || 0),
80
+ messages: acc.messages + (s.messages || 0)
81
+ }), {cost:0, tokensIn:0, tokensOutput:0, messages:0});
82
+ const top5 = sessions.sort((a,b) => (b.cost||0) - (a.cost||0)).slice(0,5).map(s => ({contract: s.contractName, cost: fmtCost(s.cost||0), messages: fmtInt(s.messages||0), startedAt: s.startedAt}));
83
+ console.log(JSON.stringify({status: 'OK', sessionCount: sessions.length, totals: { cost: fmtCost(totals.cost), tokensIn: fmtInt(totals.tokensIn), tokensOutput: fmtInt(totals.tokensOutput), messages: fmtInt(totals.messages) }, top5}, null, 2));
84
+ "`
85
+ ```
86
+
87
+ ## Instrucciones de presentacion
88
+
89
+ 1. **Sesion actual**: muestra el JSON en una tabla markdown clara. Incluye duracion, costo, tokens y mensajes.
90
+ 2. **Ultimos changelogs**: si hay, muestra una tabla con timestamp, comando, costo, archivos y +/- . Si no hay, indica que aun no hay historial.
91
+ 3. **Historial agregado**: muestra el top 5 de specs mas caros y los totales historicos.
92
+ 4. **Veredicto**: cierra con una linea resumida como "Llevas $X.XX gastados en Y sesiones. El comando mas caro fue `<nombre>` ($X.XX).".
93
+ 5. **Tip pro**: si el usuario lo invoca seguido, sugiere correr `/reset` para limpiar el contexto y empezar fresco sin perder el changelog (que ya esta persistido en `.openspec/changelog/`).
@@ -1,6 +1,7 @@
1
1
  import { type Plugin } from "@opencode-ai/plugin"
2
2
  import fs from "fs"
3
3
  import path from "path"
4
+ import { write_changelog } from "../tools/sdd_changelog.ts"
4
5
 
5
6
  type AgentMetrics = {
6
7
  cost: number
@@ -373,6 +374,48 @@ export const SddBridgePlugin: Plugin = async ({ project, client, $, directory, w
373
374
  if (newState.phase === "F0_DETECT") {
374
375
  try {
375
376
  const openspecDir = path.resolve(projectRoot, ".openspec")
377
+
378
+ // 0. Changelog capture (BEFORE GC clears metrics) - records the cycle
379
+ try {
380
+ const lastMetrics = readMetrics()
381
+ if (lastMetrics && (lastMetrics.totals?.cost > 0 || lastMetrics.totals?.messages > 0)) {
382
+ const derivedCommand = lastMetrics.contractName || "sdd-cycle"
383
+ await write_changelog
384
+ .execute(
385
+ {
386
+ command: derivedCommand,
387
+ prompt:
388
+ "(prompt no capturado automaticamente por el bridge - edita el changelog para anadirlo si lo necesitas)",
389
+ },
390
+ {
391
+ sessionID: "",
392
+ messageID: "",
393
+ agent: "sdd-bridge",
394
+ directory: projectRoot,
395
+ worktree: projectRoot,
396
+ abort: new AbortController().signal,
397
+ metadata: () => {},
398
+ ask: async () => {},
399
+ } as any
400
+ )
401
+ .catch(() => {})
402
+
403
+ // Notify via toast
404
+ if (client?.tui?.showToast) {
405
+ await client.tui
406
+ .showToast({
407
+ body: {
408
+ message: `📝 Changelog capturado en .openspec/changelog/ (costo: $${(lastMetrics.totals?.cost || 0).toFixed(4)})`,
409
+ variant: "info",
410
+ },
411
+ })
412
+ .catch(() => {})
413
+ }
414
+ }
415
+ } catch (e) {
416
+ // best-effort: never let changelog failure block GC
417
+ }
418
+
376
419
  const cleanScreenshots = (dir: string) => {
377
420
  if (fs.existsSync(dir)) {
378
421
  const files = fs.readdirSync(dir)
@@ -393,6 +436,12 @@ export const SddBridgePlugin: Plugin = async ({ project, client, $, directory, w
393
436
  try { fs.rmSync(playwrightTmpDir, { recursive: true, force: true }) } catch (e) {}
394
437
  }
395
438
 
439
+ // 2.5 Clear ZCS raw_context cache
440
+ const rawContextDir = path.resolve(openspecDir, ".raw_context")
441
+ if (fs.existsSync(rawContextDir)) {
442
+ try { fs.rmSync(rawContextDir, { recursive: true, force: true }) } catch (e) {}
443
+ }
444
+
396
445
  // 3. Clear transient metrics file
397
446
  const metricsPath = path.resolve(openspecDir, ".sdd_session_metrics.json")
398
447
  if (fs.existsSync(metricsPath)) {
@@ -96,3 +96,9 @@ Para evitar que los subagentes se queden sin pasos (step/token exhaustion) o acu
96
96
  - **Compilación Atómica:** El Coder **debe** implementar y compilar exitosamente cada archivo de forma individual antes de escribir el siguiente. Está estrictamente prohibido escribir todos los componentes de golpe sin ejecutar validaciones intermedias de TypeScript (`tsc --noEmit`).
97
97
  - **Verificación Temprana de Dependencias:** El Coder **debe** comprobar en el primer paso de F2 que dependencias de pruebas críticas como `@testing-library/dom` estén correctamente instaladas en proyectos React 19 para evitar fallos intermitentes de tipado en Vitest.
98
98
  - **PROHIBICIÓN ESTRICTA DE `todowrite` en Coder/Tester/Deployer/Spec-Writer:** Estas herramientas **no deben invocar** `todowrite` en ningún momento de su sesión. El seguimiento de fases está centralizado en el Orquestador. Inyectar TODOs en subagentes satura su contexto y agota pasos de LLM innecesariamente. El Orquestador solo debe usar `todowrite` **2 veces máximo por sesión**: una al inicio (lista de fases) y otra al final (marcar todas completadas).
99
+
100
+ ## 10. Compresión y Recuperación de Contexto (ZCS)
101
+ Para maximizar la eficiencia en el uso de la ventana de contexto y ahorrar tokens de API, el arnés implementa Zugzbot Context Shaper (ZCS) con un esquema de recuperación reversible (CCR):
102
+ - **Placeholders de Compresión:** Si detectas bloques de texto en el brief o en los logs de error con la estructura `[CONTEXTO_COMPRIMIDO: hash (Tipo: Categoria, Líneas: N)]`, significa que el contenido original ha sido archivado localmente para no saturar tu contexto.
103
+ - **Recuperación bajo demanda (MANDATORIO):** Si necesitas ver el contenido completo y exacto sin comprimir (por ejemplo, para depurar un stack trace de error largo o leer patrones de mock enteros), debes invocar obligatoriamente la herramienta `sdd_retrieve_raw({ hash: "hash_identificador" })`. No asumas ni inventes código o logs que estén comprimidos; recupéralos de forma dirigida.
104
+
@@ -0,0 +1,348 @@
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
+ // ============================================================================
7
+ // SDD Changelog Tool
8
+ // ============================================================================
9
+ //
10
+ // Escribe un changelog markdown estructurado por cada comando significativo
11
+ // (/fast, /loop, sprint de F2, fin de contrato). Cada entrada incluye:
12
+ // - Prompt literal del usuario
13
+ // - Diff stats (archivos tocados, lineas +/-)
14
+ // - Costo y duracion de la sesion actual
15
+ // - Output resumido (provisto por el agente o el bridge)
16
+ // - Timestamp ISO + comando que lo origino
17
+ //
18
+ // Adicionalmente mantiene un INDEX.md con una linea por entrada para poder
19
+ // escanear toda la historia de un vistazo (alimenta el comando /cost).
20
+ // ============================================================================
21
+
22
+ const getRoot = (context: any): string => {
23
+ if (context?.directory && context.directory !== "/") return context.directory
24
+ if (context?.worktree && context.worktree !== "/") return context.worktree
25
+ return process.cwd()
26
+ }
27
+
28
+ const getMetricsPath = (root: string) => path.resolve(root, ".openspec/.sdd_session_metrics.json")
29
+ const getStatePath = (root: string) => path.resolve(root, ".openspec/sdd_state.json")
30
+ const getChangelogDir = (root: string) => path.resolve(root, ".openspec/changelog")
31
+ const getIndexPath = (root: string) => path.resolve(root, ".openspec/changelog/INDEX.md")
32
+
33
+ const safeExec = (cmd: string, cwd: string): string | null => {
34
+ try {
35
+ return execSync(cmd, { cwd, stdio: "pipe", encoding: "utf8" }).toString()
36
+ } catch {
37
+ return null
38
+ }
39
+ }
40
+
41
+ const formatCost = (cost: number): string => {
42
+ if (typeof cost !== "number" || !Number.isFinite(cost)) return "$0.00"
43
+ if (cost < 0.001) return `$${cost.toFixed(5)}`
44
+ if (cost < 1) return `$${cost.toFixed(4)}`
45
+ return `$${cost.toFixed(3)}`
46
+ }
47
+
48
+ const formatDuration = (ms: number): string => {
49
+ if (!ms || ms < 0) return "0s"
50
+ const totalSeconds = Math.floor(ms / 1000)
51
+ const minutes = Math.floor(totalSeconds / 60)
52
+ const seconds = totalSeconds % 60
53
+ if (minutes === 0) return `${seconds}s`
54
+ if (seconds === 0) return `${minutes}m`
55
+ return `${minutes}m ${seconds}s`
56
+ }
57
+
58
+ const formatInt = (n: number): string => {
59
+ if (typeof n !== "number" || !Number.isFinite(n)) return "0"
60
+ return Math.round(n).toLocaleString("en-US")
61
+ }
62
+
63
+ const slugify = (s: string): string => {
64
+ return s
65
+ .toLowerCase()
66
+ .replace(/[^a-z0-9]+/g, "-")
67
+ .replace(/^-+|-+$/g, "")
68
+ .slice(0, 60)
69
+ || "unnamed"
70
+ }
71
+
72
+ const readMetrics = (root: string): any => {
73
+ const p = getMetricsPath(root)
74
+ if (!fs.existsSync(p)) return null
75
+ try {
76
+ return JSON.parse(fs.readFileSync(p, "utf8"))
77
+ } catch {
78
+ return null
79
+ }
80
+ }
81
+
82
+ const readState = (root: string): any => {
83
+ const p = getStatePath(root)
84
+ if (!fs.existsSync(p)) return null
85
+ try {
86
+ return JSON.parse(fs.readFileSync(p, "utf8"))
87
+ } catch {
88
+ return null
89
+ }
90
+ }
91
+
92
+ const captureDiffStats = (root: string): any => {
93
+ // Inline minimal version (no recursion into sdd_diff_capture to avoid tool-of-tool deps).
94
+ const numstatOut = safeExec("git diff --numstat HEAD", root) || ""
95
+ const nameStatusOut = safeExec("git diff --name-status HEAD", root) || ""
96
+ const untrackedOut = safeExec("git ls-files --others --exclude-standard", root) || ""
97
+
98
+ const statusMap = new Map<string, string>()
99
+ for (const line of nameStatusOut.split("\n")) {
100
+ const t = line.trim()
101
+ if (!t) continue
102
+ const parts = t.split("\t")
103
+ if (parts.length < 2) continue
104
+ statusMap.set(parts[parts.length - 1], parts[0].charAt(0))
105
+ }
106
+
107
+ const files: Array<{ path: string; status: string; added: number; removed: number; binary: boolean }> = []
108
+ let totalAdded = 0
109
+ let totalRemoved = 0
110
+
111
+ for (const line of numstatOut.split("\n")) {
112
+ const t = line.trim()
113
+ if (!t) continue
114
+ const parts = t.split("\t")
115
+ if (parts.length < 3) continue
116
+ const isBinary = parts[0] === "-" && parts[1] === "-"
117
+ const added = isBinary ? 0 : parseInt(parts[0], 10) || 0
118
+ const removed = isBinary ? 0 : parseInt(parts[1], 10) || 0
119
+ const filePath = parts.slice(2).join("\t")
120
+ files.push({ path: filePath, status: statusMap.get(filePath) || "M", added, removed, binary: isBinary })
121
+ totalAdded += added
122
+ totalRemoved += removed
123
+ }
124
+
125
+ for (const line of untrackedOut.split("\n")) {
126
+ const p = line.trim()
127
+ if (!p) continue
128
+ let added = 0
129
+ try {
130
+ const content = fs.readFileSync(`${root}/${p}`, "utf8")
131
+ added = content.split("\n").length
132
+ } catch {
133
+ added = 0
134
+ }
135
+ files.push({ path: p, status: "?", added, removed: 0, binary: false })
136
+ totalAdded += added
137
+ }
138
+
139
+ return { files, totalAdded, totalRemoved, fileCount: files.length }
140
+ }
141
+
142
+ const buildMarkdown = (params: {
143
+ command: string
144
+ prompt: string
145
+ outputSummary?: string
146
+ metrics: any
147
+ state: any
148
+ diff: any
149
+ timestamp: string
150
+ closedAt?: string
151
+ }): string => {
152
+ const { command, prompt, outputSummary, metrics, state, diff, timestamp, closedAt } = params
153
+
154
+ const durationMs = metrics?.startedAt && closedAt
155
+ ? Math.max(0, Date.parse(closedAt) - Date.parse(metrics.startedAt))
156
+ : 0
157
+
158
+ const totals = metrics?.totals || { cost: 0, tokensIn: 0, tokensOutput: 0, messages: 0 }
159
+ const phase = state?.phase || "F0_DETECT"
160
+ const contract = state?.activeContract || metrics?.contractName || "fast-track"
161
+
162
+ let md = `# ${command} · ${timestamp}\n\n`
163
+
164
+ md += `## Contexto\n`
165
+ md += `- **Comando**: \`${command}\`\n`
166
+ md += `- **Contrato**: \`${contract}\`\n`
167
+ md += `- **Fase final**: \`${phase}\`\n`
168
+ md += `- **Sesion**: \`${metrics?.sessionId || "n/a"}\`\n`
169
+ md += `- **Duracion**: ${formatDuration(durationMs)}\n\n`
170
+
171
+ md += `## Prompt\n`
172
+ md += `> ${prompt.replace(/\n/g, "\n> ")}\n\n`
173
+
174
+ md += `## Costo y tokens\n`
175
+ md += `| Metrica | Valor |\n|---|---|\n`
176
+ md += `| Costo total | ${formatCost(totals.cost)} |\n`
177
+ md += `| Tokens entrada | ${formatInt(totals.tokensIn)} |\n`
178
+ md += `| Tokens salida | ${formatInt(totals.tokensOutput)} |\n`
179
+ md += `| Mensajes | ${formatInt(totals.messages)} |\n\n`
180
+
181
+ if (metrics?.byAgent && Object.keys(metrics.byAgent).length > 0) {
182
+ md += `### Desglose por agente\n`
183
+ md += `| Agente | Costo | Tokens in | Tokens out | Mensajes |\n|---|---|---|---|---|\n`
184
+ const sorted = Object.entries(metrics.byAgent).sort(
185
+ (a: any, b: any) => (b[1].cost || 0) - (a[1].cost || 0)
186
+ )
187
+ for (const [agent, m] of sorted as any) {
188
+ md += `| \`${agent}\` | ${formatCost(m.cost)} | ${formatInt(m.tokensIn)} | ${formatInt(m.tokensOutput)} | ${formatInt(m.messages)} |\n`
189
+ }
190
+ md += `\n`
191
+ }
192
+
193
+ md += `## Diff capturado\n`
194
+ if (!diff || diff.fileCount === 0) {
195
+ md += `_No se detectaron cambios en el working tree._\n\n`
196
+ } else {
197
+ md += `- **Archivos tocados**: ${diff.fileCount}\n`
198
+ md += `- **Lineas agregadas**: +${formatInt(diff.totalAdded)}\n`
199
+ md += `- **Lineas removidas**: -${formatInt(diff.totalRemoved)}\n\n`
200
+ md += `| Archivo | Status | +/- |\n|---|---|---|\n`
201
+ for (const f of diff.files.slice(0, 50)) {
202
+ const diffStr = f.binary ? "(binario)" : `+${f.added} / -${f.removed}`
203
+ const statusBadge = {
204
+ A: "añadido",
205
+ M: "modificado",
206
+ D: "eliminado",
207
+ R: "renombrado",
208
+ C: "copiado",
209
+ "?": "untracked",
210
+ }[f.status as string] || f.status
211
+ md += `| \`${f.path}\` | ${statusBadge} | ${diffStr} |\n`
212
+ }
213
+ if (diff.files.length > 50) {
214
+ md += `| _... y ${diff.files.length - 50} archivos mas_ | | |\n`
215
+ }
216
+ md += `\n`
217
+ }
218
+
219
+ if (outputSummary && outputSummary.trim().length > 0) {
220
+ md += `## Output del agente\n`
221
+ md += `${outputSummary.trim()}\n\n`
222
+ }
223
+
224
+ md += `---\n`
225
+ md += `_Changelog autogenerado por sdd_changelog el ${timestamp}._\n`
226
+
227
+ return md
228
+ }
229
+
230
+ const appendToIndex = (indexPath: string, entry: { file: string; command: string; timestamp: string; cost: number; files: number; linesAdded: number; linesRemoved: number }) => {
231
+ let content = "# SDD Changelog Index\n\n"
232
+ content += "Lista cronologica (mas reciente primero) de todos los comandos que dejaron rastro.\n\n"
233
+ content += "| Timestamp | Comando | Costo | Archivos | +/- |\n|---|---|---|---|---|\n"
234
+
235
+ const newLine = `| ${entry.timestamp} | [${entry.command}](./${path.basename(entry.file)}) | ${formatCost(entry.cost)} | ${entry.files} | +${formatInt(entry.linesAdded)} / -${formatInt(entry.linesRemoved)} |`
236
+
237
+ let existingLines: string[] = []
238
+ if (fs.existsSync(indexPath)) {
239
+ const current = fs.readFileSync(indexPath, "utf8")
240
+ const lines = current.split("\n")
241
+ // Strip header (keep header + table header)
242
+ const headerEnd = lines.findIndex((l) => l.startsWith("|---"))
243
+ if (headerEnd > 0 && lines[headerEnd + 1]) {
244
+ existingLines = lines.slice(headerEnd + 1).filter((l) => l.trim().startsWith("|"))
245
+ }
246
+ }
247
+
248
+ const updated = [newLine, ...existingLines].slice(0, 200) // cap at 200 entries
249
+ fs.writeFileSync(indexPath, content + updated.join("\n") + "\n", "utf8")
250
+ }
251
+
252
+ export const write_changelog = tool({
253
+ description:
254
+ "Escribe un changelog markdown estructurado en .openspec/changelog/<command>-<timestamp>.md " +
255
+ "con prompt, diff stats, costo y duracion de la sesion actual. Tambien actualiza " +
256
+ ".openspec/changelog/INDEX.md con una linea resumen. Usado por el hook del sdd-bridge " +
257
+ "en transiciones de fase y por comandos /fast, /loop al finalizar.",
258
+ args: {
259
+ command: tool.schema
260
+ .string()
261
+ .describe("Nombre del comando que origina el changelog (ej: 'fast', 'loop', 'sprint-1', 'fast-track')."),
262
+ prompt: tool.schema
263
+ .string()
264
+ .describe("Prompt literal que el usuario escribio para este comando."),
265
+ outputSummary: tool.schema
266
+ .string()
267
+ .optional()
268
+ .describe("Resumen corto (3-5 lineas) del output del subagente. Opcional pero recomendado."),
269
+ closedAt: tool.schema
270
+ .string()
271
+ .optional()
272
+ .describe("ISO timestamp de cierre del comando. Default: ahora."),
273
+ },
274
+ async execute(args, context) {
275
+ const root = getRoot(context)
276
+ const command = slugify(args.command || "unnamed")
277
+ const timestamp = new Date().toISOString().replace(/\.\d{3}Z$/, "Z")
278
+ const closedAt = args.closedAt || timestamp
279
+
280
+ const metrics = readMetrics(root)
281
+ const state = readState(root)
282
+ const diff = captureDiffStats(root)
283
+
284
+ const filename = `${command}-${timestamp.replace(/[:.]/g, "-")}.md`
285
+ const changelogDir = getChangelogDir(root)
286
+ if (!fs.existsSync(changelogDir)) fs.mkdirSync(changelogDir, { recursive: true })
287
+
288
+ const filepath = path.join(changelogDir, filename)
289
+ const markdown = buildMarkdown({
290
+ command: args.command,
291
+ prompt: args.prompt || "(sin prompt registrado)",
292
+ outputSummary: args.outputSummary,
293
+ metrics,
294
+ state,
295
+ diff,
296
+ timestamp,
297
+ closedAt,
298
+ })
299
+
300
+ try {
301
+ fs.writeFileSync(filepath, markdown, "utf8")
302
+ } catch (e: any) {
303
+ return JSON.stringify(
304
+ {
305
+ status: "ERROR",
306
+ message: `No se pudo escribir el changelog: ${e?.message || "unknown"}`,
307
+ path: filepath,
308
+ },
309
+ null,
310
+ 2
311
+ )
312
+ }
313
+
314
+ try {
315
+ appendToIndex(getIndexPath(root), {
316
+ file: filename,
317
+ command: args.command,
318
+ timestamp,
319
+ cost: metrics?.totals?.cost || 0,
320
+ files: diff.fileCount,
321
+ linesAdded: diff.totalAdded,
322
+ linesRemoved: diff.totalRemoved,
323
+ })
324
+ } catch {
325
+ // index failure is non-blocking
326
+ }
327
+
328
+ return JSON.stringify(
329
+ {
330
+ status: "SUCCESS",
331
+ path: filepath,
332
+ filename,
333
+ command: args.command,
334
+ timestamp,
335
+ summary: {
336
+ cost: metrics?.totals?.cost || 0,
337
+ filesChanged: diff.fileCount,
338
+ linesAdded: diff.totalAdded,
339
+ linesRemoved: diff.totalRemoved,
340
+ messages: metrics?.totals?.messages || 0,
341
+ },
342
+ relativePath: `.openspec/changelog/${filename}`,
343
+ },
344
+ null,
345
+ 2
346
+ )
347
+ }
348
+ })
@@ -0,0 +1,242 @@
1
+ import { tool } from "@opencode-ai/plugin"
2
+ import fs from "fs"
3
+ import path from "path"
4
+ import crypto from "crypto"
5
+
6
+ // Helper to safely resolve root directory
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
+ * Comprime código fuente eliminando comentarios, docstrings y líneas vacías.
16
+ */
17
+ export function compressCode(code: string, commentStyle: "slash" | "hash" = "slash"): string {
18
+ if (!code) return ""
19
+
20
+ let inString: string | null = null
21
+ let inComment: "single" | "multi" | null = null
22
+ let result = ""
23
+
24
+ for (let i = 0; i < code.length; i++) {
25
+ const char = code[i]
26
+ const nextChar = code[i + 1] || ""
27
+
28
+ if (inComment === "multi") {
29
+ if (char === "*" && nextChar === "/") {
30
+ inComment = null
31
+ i++ // Skip '/'
32
+ }
33
+ continue
34
+ }
35
+
36
+ if (inComment === "single") {
37
+ if (char === "\n" || char === "\r") {
38
+ inComment = null
39
+ result += char
40
+ }
41
+ continue
42
+ }
43
+
44
+ if (inString) {
45
+ if (char === inString && code[i - 1] !== "\\") {
46
+ inString = null
47
+ }
48
+ result += char
49
+ continue
50
+ }
51
+
52
+ // Check for comments based on style
53
+ if (commentStyle === "slash") {
54
+ if (char === "/" && nextChar === "*") {
55
+ inComment = "multi"
56
+ i++
57
+ continue
58
+ }
59
+ if (char === "/" && nextChar === "/") {
60
+ inComment = "single"
61
+ i++
62
+ continue
63
+ }
64
+ } else if (commentStyle === "hash") {
65
+ if (char === "#") {
66
+ inComment = "single"
67
+ continue
68
+ }
69
+ }
70
+
71
+ // Check for string start
72
+ if (char === "'" || char === '"' || char === "`") {
73
+ inString = char
74
+ }
75
+
76
+ result += char
77
+ }
78
+
79
+ // Clean up empty lines
80
+ return result
81
+ .split(/\r?\n/)
82
+ .filter(line => line.trim().length > 0)
83
+ .join("\n")
84
+ }
85
+
86
+ /**
87
+ * Comprime cadenas JSON grandes recortando arrays a un tamaño máximo.
88
+ */
89
+ export function compressJson(jsonStr: string, maxItems = 3): string {
90
+ if (!jsonStr) return ""
91
+ try {
92
+ const obj = JSON.parse(jsonStr)
93
+
94
+ const prune = (o: any): any => {
95
+ if (Array.isArray(o)) {
96
+ if (o.length > maxItems) {
97
+ const sliced = o.slice(0, maxItems).map(prune)
98
+ sliced.push(`... [ZCS: ${o.length - maxItems} elementos omitidos]`)
99
+ return sliced
100
+ }
101
+ return o.map(prune)
102
+ } else if (o !== null && typeof o === "object") {
103
+ const res: any = {}
104
+ for (const k of Object.keys(o)) {
105
+ res[k] = prune(o[k])
106
+ }
107
+ return res
108
+ }
109
+ return o
110
+ }
111
+
112
+ return JSON.stringify(prune(obj), null, 2)
113
+ } catch (e) {
114
+ // Si no es JSON válido, devolver el texto original
115
+ return jsonStr
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Comprime logs de consola de tests unitarios (Vitest/pytest),
121
+ * preservando los fallos, errores y resúmenes de suites.
122
+ */
123
+ export function compressTestLogs(logs: string): string {
124
+ if (!logs) return ""
125
+ const lines = logs.split(/\r?\n/)
126
+ const result: string[] = []
127
+
128
+ let recordingError = false
129
+ let errorLinesCount = 0
130
+
131
+ for (const line of lines) {
132
+ const upper = line.toUpperCase()
133
+
134
+ // Detectar inicio de fallos o errores
135
+ const isErrorIndicator =
136
+ line.includes("FAIL") ||
137
+ line.includes("Error:") ||
138
+ line.includes("AssertionError") ||
139
+ line.includes("❌") ||
140
+ upper.includes("EXCEPTION") ||
141
+ upper.includes("FAILED")
142
+
143
+ if (isErrorIndicator) {
144
+ recordingError = true
145
+ errorLinesCount = 0
146
+ result.push(line)
147
+ continue
148
+ }
149
+
150
+ if (recordingError) {
151
+ // Guardar las primeras 8 líneas del error/stack trace
152
+ if (errorLinesCount < 8) {
153
+ result.push(line)
154
+ errorLinesCount++
155
+ } else {
156
+ // Truncar stack traces largos del framework (node_modules/vitest, etc.)
157
+ if (line.trim() === "" || line.includes("PASS") || line.includes("Test Suites:")) {
158
+ recordingError = false
159
+ result.push(" [... ZCS: stack trace del framework omitido para ahorrar tokens ...]")
160
+ result.push(line)
161
+ }
162
+ }
163
+ } else {
164
+ // Siempre conservar resúmenes e información de suites exitosas
165
+ const isSummaryIndicator =
166
+ line.includes("PASS") ||
167
+ line.includes("Summary") ||
168
+ line.includes("Test Suites:") ||
169
+ line.includes("Tests:") ||
170
+ line.includes("Snapshots:") ||
171
+ line.includes("Time:") ||
172
+ line.includes("Ran all test suites")
173
+
174
+ if (isSummaryIndicator || line.trim().startsWith("✓")) {
175
+ result.push(line)
176
+ }
177
+ }
178
+ }
179
+
180
+ // Si no se capturaron líneas o todo pasó muy rápido, retornar resumido
181
+ if (result.length === 0) {
182
+ return logs.length > 500 ? `${logs.slice(0, 500)}\n... [ZCS: truncado por longitud]` : logs
183
+ }
184
+
185
+ return result.join("\n")
186
+ }
187
+
188
+ /**
189
+ * Registra el contenido crudo en disco (CCR) y devuelve un placeholder comprimido.
190
+ */
191
+ export function ccrCompress(content: string, category: string, context: any): string {
192
+ if (!content) return ""
193
+
194
+ const root = getRoot(context)
195
+ const rawContextDir = path.resolve(root, ".openspec/.raw_context")
196
+
197
+ if (!fs.existsSync(rawContextDir)) {
198
+ fs.mkdirSync(rawContextDir, { recursive: true })
199
+ }
200
+
201
+ // Calcular hash del contenido para evitar duplicados
202
+ const hash = crypto.createHash("sha256").update(content).digest("hex").slice(0, 12)
203
+ const destFile = path.join(rawContextDir, `${hash}.txt`)
204
+
205
+ // Escribir solo si no existe ya
206
+ if (!fs.existsSync(destFile)) {
207
+ fs.writeFileSync(destFile, content, "utf8")
208
+ }
209
+
210
+ const lineCount = content.split(/\r?\n/).length
211
+ return `[CONTEXTO_COMPRIMIDO: ${hash} (Tipo: ${category}, Líneas: ${lineCount}). Si necesitas ver el contenido completo sin compresión para editar o depurar, ejecuta la herramienta sdd_retrieve_raw({ hash: "${hash}" })]`
212
+ }
213
+
214
+ // Tool: sdd_retrieve_raw
215
+ export const retrieve_raw = tool({
216
+ description: "Recupera un bloque de contexto original sin comprimir (logs, código, JSON) a partir de su hash identificador.",
217
+ args: {
218
+ hash: tool.schema.string().describe("El hash de 12 caracteres del contexto que deseas recuperar (ej. 'a1b2c3d4e5f6').")
219
+ },
220
+ async execute(args, context) {
221
+ try {
222
+ const root = getRoot(context)
223
+ const hash = args.hash.trim()
224
+ const rawPath = path.resolve(root, `.openspec/.raw_context/${hash}.txt`)
225
+
226
+ if (!fs.existsSync(rawPath)) {
227
+ return JSON.stringify({
228
+ success: false,
229
+ message: `No se encontró el contexto original para el hash '${hash}'. El caché podría haber sido purgado.`
230
+ }, null, 2)
231
+ }
232
+
233
+ const content = fs.readFileSync(rawPath, "utf8")
234
+ return content
235
+ } catch (e: any) {
236
+ return JSON.stringify({
237
+ success: false,
238
+ message: `Error al recuperar contexto crudo: ${e.message || e}`
239
+ }, null, 2)
240
+ }
241
+ }
242
+ })
@@ -2,6 +2,7 @@ import { tool } from "@opencode-ai/plugin"
2
2
  import fs from "fs"
3
3
  import path from "path"
4
4
  import { execSync } from "child_process"
5
+ import { ccrCompress } from "./sdd_compress.ts"
5
6
 
6
7
  // Helper to safely resolve root directory (avoiding OpenCode bug where worktree is '/' in non-git repos)
7
8
  const getRoot = (context: any) => {
@@ -362,6 +363,15 @@ export const set_phase = tool({
362
363
  currentState.loopTargetIterations = 1
363
364
  currentState.loopCurrentIteration = 1
364
365
  }
366
+ // Clean up raw_context files (ZCS)
367
+ try {
368
+ const rawContextDir = path.resolve(root, ".openspec/.raw_context")
369
+ if (fs.existsSync(rawContextDir)) {
370
+ fs.rmSync(rawContextDir, { recursive: true, force: true })
371
+ }
372
+ } catch (e) {
373
+ // ignore
374
+ }
365
375
  // Clean up running servers
366
376
  const pidFile = getPidFilePath(root)
367
377
  if (fs.existsSync(pidFile)) {
@@ -624,16 +634,17 @@ export const save_active_brief = tool({
624
634
  const errors = last("errors", 5)
625
635
  const design = last("design", 3)
626
636
 
627
- if (learnings || errors || design) {
637
+ let brainMemoryStr = ""
638
+ if (design) brainMemoryStr += `\n### Design (aplicar en estilo):\n${design}\n`
639
+ if (learnings) brainMemoryStr += `\n### Learnings (patrones validados):\n${learnings}\n`
640
+ if (errors) brainMemoryStr += `\n### Errors / Regresiones (evitar repetir):\n${errors}\n`
641
+
642
+ if (brainMemoryStr) {
628
643
  enrichedBrief += `\n\n## [AUTO-INJECTED] Brain Memory (lecciones de sesiones previas)\n`
629
- if (design) {
630
- enrichedBrief += `\n### Design (aplicar en estilo):\n${design}\n`
631
- }
632
- if (learnings) {
633
- enrichedBrief += `\n### Learnings (patrones validados):\n${learnings}\n`
634
- }
635
- if (errors) {
636
- enrichedBrief += `\n### Errors / Regresiones (evitar repetir):\n${errors}\n`
644
+ if (brainMemoryStr.length > 1500) {
645
+ enrichedBrief += ccrCompress(brainMemoryStr, "Brain Memory", context) + "\n"
646
+ } else {
647
+ enrichedBrief += brainMemoryStr
637
648
  }
638
649
  }
639
650
  }
@@ -645,31 +656,35 @@ export const save_active_brief = tool({
645
656
  try {
646
657
  const isNext = contract?.stack?.core?.some((c: string) => /next\.?js/i.test(c))
647
658
  if (isNext) {
659
+ let mockPatterns = `// Mock dinámico de lucide-react (cualquier icono)\n`
660
+ mockPatterns += `vi.mock("lucide-react", () => new Proxy({}, {\n`
661
+ mockPatterns += ` get: (_t, prop) => {\n`
662
+ mockPatterns += ` const Icon = (props: any) => null;\n`
663
+ mockPatterns += ` Icon.displayName = String(prop);\n`
664
+ mockPatterns += ` return Icon;\n`
665
+ mockPatterns += ` },\n`
666
+ mockPatterns += `}));\n\n`
667
+ mockPatterns += `// Mock reactivo de next-themes para ThemeToggle\n`
668
+ mockPatterns += `let theme = "light";\n`
669
+ mockPatterns += `vi.mock("next-themes", () => ({\n`
670
+ mockPatterns += ` useTheme: () => ({\n`
671
+ mockPatterns += ` get theme() { return theme; },\n`
672
+ mockPatterns += ` setTheme: (t: string) => { theme = t; },\n`
673
+ mockPatterns += ` get resolvedTheme() { return theme; },\n`
674
+ mockPatterns += ` }),\n`
675
+ mockPatterns += `}));\n\n`
676
+ mockPatterns += `// Mock de next/navigation\n`
677
+ mockPatterns += `vi.mock("next/navigation", () => ({\n`
678
+ mockPatterns += ` useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }),\n`
679
+ mockPatterns += ` usePathname: () => "/",\n`
680
+ mockPatterns += `}));\n`
681
+
648
682
  enrichedBrief += `\n\n## [AUTO-INJECTED] Mock Patterns (Vitest + Testing Library)\n`
649
- enrichedBrief += `\`\`\`typescript\n`
650
- enrichedBrief += `// Mock dinámico de lucide-react (cualquier icono)\n`
651
- enrichedBrief += `vi.mock("lucide-react", () => new Proxy({}, {\n`
652
- enrichedBrief += ` get: (_t, prop) => {\n`
653
- enrichedBrief += ` const Icon = (props: any) => null;\n`
654
- enrichedBrief += ` Icon.displayName = String(prop);\n`
655
- enrichedBrief += ` return Icon;\n`
656
- enrichedBrief += ` },\n`
657
- enrichedBrief += `}));\n\n`
658
- enrichedBrief += `// Mock reactivo de next-themes para ThemeToggle\n`
659
- enrichedBrief += `let theme = "light";\n`
660
- enrichedBrief += `vi.mock("next-themes", () => ({\n`
661
- enrichedBrief += ` useTheme: () => ({\n`
662
- enrichedBrief += ` get theme() { return theme; },\n`
663
- enrichedBrief += ` setTheme: (t: string) => { theme = t; },\n`
664
- enrichedBrief += ` get resolvedTheme() { return theme; },\n`
665
- enrichedBrief += ` }),\n`
666
- enrichedBrief += `}));\n\n`
667
- enrichedBrief += `// Mock de next/navigation\n`
668
- enrichedBrief += `vi.mock("next/navigation", () => ({\n`
669
- enrichedBrief += ` useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }),\n`
670
- enrichedBrief += ` usePathname: () => "/",\n`
671
- enrichedBrief += `}));\n`
672
- enrichedBrief += `\`\`\`\n`
683
+ if (mockPatterns.length > 1000) {
684
+ enrichedBrief += ccrCompress(mockPatterns, "Mock Patterns", context) + "\n"
685
+ } else {
686
+ enrichedBrief += `\`\`\`typescript\n${mockPatterns}\`\`\`\n`
687
+ }
673
688
  }
674
689
  } catch (e) {
675
690
  // ignore
@@ -0,0 +1,200 @@
1
+ import { tool } from "@opencode-ai/plugin"
2
+ import { execSync } from "child_process"
3
+
4
+ // ============================================================================
5
+ // SDD Diff Capture Tool
6
+ // ============================================================================
7
+ //
8
+ // Captura estadisticas de `git diff` (numstat + name-status) para alimentar el
9
+ // changelog granular de cada comando /fast, /loop, o sprint de F2. Se mantiene
10
+ // como tool independiente (no embebida en sdd-bridge.ts) para que sea
11
+ // reutilizable desde agentes, desde el hook del bridge y desde /cost.
12
+ //
13
+ // Comportamiento:
14
+ // - Si no hay repo git, devuelve { available: false } sin crashear.
15
+ // - Si hay cambios sin commitear, los captura contra HEAD (default).
16
+ // - Si se pasa `since`, compara ese ref contra HEAD.
17
+ // - Maneja archivos binarios y renames de forma best-effort.
18
+ // ============================================================================
19
+
20
+ const getRoot = (context: any): string => {
21
+ if (context?.directory && context.directory !== "/") return context.directory
22
+ if (context?.worktree && context.worktree !== "/") return context.worktree
23
+ return process.cwd()
24
+ }
25
+
26
+ const safeExec = (cmd: string, cwd: string): string | null => {
27
+ try {
28
+ return execSync(cmd, { cwd, stdio: "pipe", encoding: "utf8" }).toString()
29
+ } catch {
30
+ return null
31
+ }
32
+ }
33
+
34
+ const isGitRepo = (cwd: string): boolean => {
35
+ const out = safeExec("git rev-parse --is-inside-work-tree", cwd)
36
+ return out?.trim() === "true"
37
+ }
38
+
39
+ export const capture_diff = tool({
40
+ description:
41
+ "Captura estadisticas de git diff (archivos tocados, lineas agregadas/removidas, " +
42
+ "renames). Usado por /fast, /loop y el hook del sdd-bridge para alimentar el " +
43
+ "changelog granular. Si no hay repo git, devuelve { available: false } sin error.",
44
+ args: {
45
+ since: tool.schema
46
+ .string()
47
+ .optional()
48
+ .describe("Ref base para comparar (ej: 'HEAD~1', commit hash, branch). Default: HEAD (cambios sin commitear)."),
49
+ until: tool.schema
50
+ .string()
51
+ .optional()
52
+ .describe("Ref final para comparar. Default: working tree (incluye uncommitted)."),
53
+ pathspec: tool.schema
54
+ .string()
55
+ .optional()
56
+ .describe("Filtro opcional tipo git pathspec (ej: 'src/' para limitar a src/)."),
57
+ },
58
+ async execute(args, context) {
59
+ const root = getRoot(context)
60
+
61
+ if (!isGitRepo(root)) {
62
+ return JSON.stringify(
63
+ {
64
+ status: "NO_GIT",
65
+ available: false,
66
+ message: "No se detecto un repositorio git en la raiz del proyecto.",
67
+ files: [],
68
+ totalAdded: 0,
69
+ totalRemoved: 0,
70
+ },
71
+ null,
72
+ 2
73
+ )
74
+ }
75
+
76
+ const since = args.since?.trim() || "HEAD"
77
+ const until = args.until?.trim() || ""
78
+ const pathspec = args.pathspec?.trim() || ""
79
+
80
+ // Build range. Format: <since>..<until> (if until) or <since> (diff vs working tree)
81
+ const range = until ? `${since}..${until}` : since
82
+ const pathArg = pathspec ? `-- '${pathspec.replace(/'/g, "'\\''")}'` : ""
83
+
84
+ // 1. numstat: lines added/removed per file (binary shows as -\t-)
85
+ const numstatCmd = `git diff --numstat ${range}${pathArg}`
86
+ const numstatOut = safeExec(numstatCmd, root) || ""
87
+
88
+ // 2. name-status: A=added, M=modified, D=deleted, R=renamed, C=copied
89
+ const nameStatusCmd = `git diff --name-status ${range}${pathArg}`
90
+ const nameStatusOut = safeExec(nameStatusCmd, root) || ""
91
+
92
+ const files: Array<{
93
+ path: string
94
+ status: string
95
+ added: number
96
+ removed: number
97
+ binary: boolean
98
+ }> = []
99
+
100
+ let totalAdded = 0
101
+ let totalRemoved = 0
102
+ let binaryCount = 0
103
+
104
+ // Parse name-status first (gives status including R/C codes)
105
+ const statusMap = new Map<string, string>()
106
+ for (const line of nameStatusOut.split("\n")) {
107
+ const trimmed = line.trim()
108
+ if (!trimmed) continue
109
+ const parts = trimmed.split("\t")
110
+ if (parts.length < 2) continue
111
+ const status = parts[0]
112
+ const path = parts[parts.length - 1] // for renames, last col is the new path
113
+ statusMap.set(path, status.charAt(0)) // R100 -> R, C075 -> C
114
+ }
115
+
116
+ // Parse numstat
117
+ for (const line of numstatOut.split("\n")) {
118
+ const trimmed = line.trim()
119
+ if (!trimmed) continue
120
+ const parts = trimmed.split("\t")
121
+ if (parts.length < 3) continue
122
+ const addedStr = parts[0]
123
+ const removedStr = parts[1]
124
+ const path = parts.slice(2).join("\t") // paths with spaces may have been joined
125
+
126
+ const isBinary = addedStr === "-" && removedStr === "-"
127
+ const added = isBinary ? 0 : parseInt(addedStr, 10) || 0
128
+ const removed = isBinary ? 0 : parseInt(removedStr, 10) || 0
129
+
130
+ const status = statusMap.get(path) || (isBinary ? "M" : "M")
131
+
132
+ files.push({
133
+ path,
134
+ status,
135
+ added,
136
+ removed,
137
+ binary: isBinary,
138
+ })
139
+
140
+ totalAdded += added
141
+ totalRemoved += removed
142
+ if (isBinary) binaryCount++
143
+ }
144
+
145
+ // 3. Detect untracked files (only when diffing vs HEAD with no until)
146
+ let untrackedCount = 0
147
+ if (!args.until) {
148
+ const untrackedOut = safeExec("git ls-files --others --exclude-standard", root) || ""
149
+ for (const line of untrackedOut.split("\n")) {
150
+ const p = line.trim()
151
+ if (!p) continue
152
+ if (pathspec && !p.startsWith(pathspec.replace(/\/$/, ""))) continue
153
+ // Count lines for untracked files (best-effort)
154
+ let added = 0
155
+ try {
156
+ const absPath = `${root}/${p}`
157
+ const content = require("fs").readFileSync(absPath, "utf8")
158
+ added = content.split("\n").length
159
+ } catch {
160
+ added = 0
161
+ }
162
+ files.push({
163
+ path: p,
164
+ status: "?",
165
+ added,
166
+ removed: 0,
167
+ binary: false,
168
+ })
169
+ totalAdded += added
170
+ untrackedCount++
171
+ }
172
+ }
173
+
174
+ // 4. Staged changes if comparing against HEAD (working tree may have staged)
175
+ let stagedOnly = false
176
+ if (!args.until) {
177
+ const stagedOut = safeExec("git diff --cached --numstat", root) || ""
178
+ if (stagedOut.trim()) stagedOnly = true
179
+ }
180
+
181
+ return JSON.stringify(
182
+ {
183
+ status: "SUCCESS",
184
+ available: true,
185
+ range: { since, until: until || "working-tree" },
186
+ pathspec: pathspec || null,
187
+ files,
188
+ totalAdded,
189
+ totalRemoved,
190
+ fileCount: files.length,
191
+ binaryCount,
192
+ untrackedCount,
193
+ stagedOnly,
194
+ capturedAt: new Date().toISOString(),
195
+ },
196
+ null,
197
+ 2
198
+ )
199
+ }
200
+ })
@@ -2,6 +2,7 @@ import { tool } from "@opencode-ai/plugin"
2
2
  import fs from "fs"
3
3
  import path from "path"
4
4
  import { execSync } from "child_process"
5
+ import { compressTestLogs, ccrCompress } from "./sdd_compress.ts"
5
6
 
6
7
  // Helper to safely resolve root directory (avoiding OpenCode bug where worktree is '/' in non-git repos)
7
8
  const getRoot = (context: any) => {
@@ -669,3 +670,62 @@ export const save_playwright_artifacts = tool({
669
670
  }
670
671
  }
671
672
  })
673
+
674
+ // Tool: sdd_run_tests
675
+ export const run_tests = tool({
676
+ description: "Ejecuta las pruebas unitarias y de integración del proyecto (Vitest o pytest), comprimiendo la salida para ahorrar tokens.",
677
+ args: {
678
+ file: tool.schema.string().optional().describe("La ruta específica del archivo de test a ejecutar (relativa al root). Si no se pasa, corre todos los tests."),
679
+ timeout: tool.schema.number().optional().default(10000).describe("Timeout en milisegundos para la ejecución de cada test (solo aplicable a Vitest). Default: 10000.")
680
+ },
681
+ async execute(args, context) {
682
+ const root = getRoot(context)
683
+ const targetDir = getTargetDir(root)
684
+ const targetPath = targetDir === "." ? root : path.resolve(root, targetDir)
685
+
686
+ // Detectar si es un proyecto Python
687
+ const isPython = fs.existsSync(path.resolve(targetPath, "requirements.txt")) || fs.existsSync(path.resolve(targetPath, "pyproject.toml"))
688
+
689
+ // Obtener ruta del archivo relativa al directorio de destino si aplica
690
+ let testPathArg = ""
691
+ if (args.file) {
692
+ const fullPath = path.resolve(root, args.file)
693
+ testPathArg = path.relative(targetPath, fullPath)
694
+ }
695
+
696
+ let cmd = ""
697
+ if (isPython) {
698
+ cmd = `pytest ${testPathArg || "tests/"}`
699
+ } else {
700
+ const testTimeout = args.timeout ?? 10000
701
+ cmd = `npx vitest run --testTimeout=${testTimeout} --bail=1 --reporter=verbose ${testPathArg}`
702
+ }
703
+
704
+ try {
705
+ const out = execSync(cmd, {
706
+ cwd: targetPath,
707
+ encoding: "utf8",
708
+ timeout: 120_000, // timeout global del proceso de 2 minutos
709
+ stdio: "pipe"
710
+ })
711
+
712
+ const compressed = compressTestLogs(out)
713
+
714
+ return JSON.stringify({
715
+ status: "SUCCESS",
716
+ message: "Las pruebas se ejecutaron y pasaron exitosamente.",
717
+ output: compressed.length > 1500 ? ccrCompress(out, "Test Success Output", context) : compressed
718
+ }, null, 2)
719
+ } catch (e: any) {
720
+ const rawOutput = e.stdout?.toString() || e.stderr?.toString() || e.message || ""
721
+ const compressed = compressTestLogs(rawOutput)
722
+
723
+ return JSON.stringify({
724
+ status: "FAIL",
725
+ message: "La ejecución de pruebas falló. Corrige los errores mostrados abajo.",
726
+ output: compressed.length > 1500 ? ccrCompress(rawOutput, "Test Failure Output", context) : compressed
727
+ }, null, 2)
728
+ }
729
+ }
730
+ })
731
+
package/README.md CHANGED
@@ -18,7 +18,7 @@ npx zugzbot@latest
18
18
 
19
19
  ¡Eso es todo! El instalador automatizado copiará de forma no destructiva y fusionará los siguientes recursos en tu proyecto:
20
20
 
21
- * 📁 **`.opencode/`** — El núcleo del sistema: agentes, comandos, skills y herramientas personalizadas (incluyendo el catálogo offline de **Oh My Design** con 246 marcas).
21
+ * 📁 **`.opencode/`** — El núcleo del sistema: agentes, comandos, skills y herramientas personalizadas (incluyendo el catálogo unificado de **Shadcn UI** con 4 registries: `shadcn`, `basecn`, `reactbits` y `blocks-so`).
22
22
  * ⚙️ **`opencode.json`** — La configuración maestra de seguridad, variables de entorno, plugins y servidores MCP de tu bot.
23
23
  * 🖥️ **`tui.json`** — Configuración personalizada para la interfaz interactiva de terminal (TUI).
24
24
 
@@ -167,6 +167,30 @@ Para publicar una nueva versión de Zugzbot en el registro público de NPM:
167
167
 
168
168
  ---
169
169
 
170
+ ## 📝 Tracking Granular de Cambios y Costos (v1.6.0+)
171
+
172
+ Desde la versión **1.6.0**, cada vez que el arnés cierra un ciclo (transición a `F0_DETECT`), el plugin `sdd-bridge` captura automáticamente un **changelog markdown estructurado** en `.openspec/changelog/<command>-<timestamp>.md` con:
173
+
174
+ - Prompt literal del usuario
175
+ - Costo exacto, tokens entrada/salida y duración
176
+ - Breakdown por agente (`plan`, `build`, `sdd-coder`, etc.)
177
+ - Diff completo (archivos tocados, líneas +/-) capturado vía `git diff`
178
+
179
+ El comando **`/cost`** (incluido desde 1.6.0) muestra en cualquier momento:
180
+ - Sesión activa con totales en vivo
181
+ - Últimos 10 changelogs del proyecto
182
+ - Top 5 specs más caros desde el historial agregado
183
+
184
+ **Tres piezas clave del sistema:**
185
+
186
+ | Archivo | Rol |
187
+ |---|---|
188
+ | `.opencode/tools/sdd_diff_capture.ts` | Tool MCP que ejecuta `git diff --numstat` + `--name-status` + `ls-files --others` |
189
+ | `.opencode/tools/sdd_changelog.ts` | Tool MCP que escribe `.openspec/changelog/<command>-<ts>.md` + actualiza `INDEX.md` |
190
+ | `.opencode/commands/cost.md` | Comando `/cost` con 3 secciones (sesión, changelogs, histórico) |
191
+
192
+ ---
193
+
170
194
  ## 📄 Licencia
171
195
 
172
196
  Este proyecto se distribuye bajo la licencia **MIT**. Siéntete libre de clonarlo, modificarlo y adaptarlo para construir tus propios equipos de agentes automatizados.
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "zugzbot",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
4
+
4
5
  "description": "Fácil instalador del arnés SDD de Zugzbot para proyectos OpenCode",
5
6
  "type": "module",
6
7
  "bin": {