zugzbot 1.5.0 → 1.6.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.
|
@@ -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)
|
|
@@ -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,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
|
+
})
|
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
|
|
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.
|