zugzbot-sdd 1.5.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.
Files changed (52) hide show
  1. package/AGENTS.md +212 -0
  2. package/README.md +112 -0
  3. package/ZUGZ.md +91 -0
  4. package/agents/aux-handyman.md +36 -0
  5. package/agents/aux-oracle.md +39 -0
  6. package/agents/sdd-archiver.md +33 -0
  7. package/agents/sdd-builder.md +29 -0
  8. package/agents/sdd-deployer.md +43 -0
  9. package/agents/sdd-explorer.md +49 -0
  10. package/agents/sdd-planner.md +59 -0
  11. package/agents/sdd-tester.md +51 -0
  12. package/agents/zugzbot.md +84 -0
  13. package/bin/zugzbot.js +249 -0
  14. package/bun.lock +259 -0
  15. package/commands/sdd-archiver.md +11 -0
  16. package/commands/sdd-builder.md +11 -0
  17. package/commands/sdd-deployer.md +12 -0
  18. package/commands/sdd-explorer.md +11 -0
  19. package/commands/sdd-planner.md +11 -0
  20. package/commands/sdd-tester.md +12 -0
  21. package/commands/sdd.md +11 -0
  22. package/eslint.config.js +51 -0
  23. package/opencode.json +121 -0
  24. package/package.json +46 -0
  25. package/plugin.json +10 -0
  26. package/plugins/plugin_sdd_core.ts +54 -0
  27. package/plugins/plugin_tui.tsx +318 -0
  28. package/sdd +1228 -0
  29. package/skills/sdd-dependency-cooldown/SKILL.md +40 -0
  30. package/skills/sdd-tree-generator/SKILL.md +40 -0
  31. package/skills-lock.json +35 -0
  32. package/tests/static/dom_structure.test.js +57 -0
  33. package/tests/static/tag_balance.test.js +74 -0
  34. package/tests/unit/harness_structure.test.js +65 -0
  35. package/tools/brain-utils.ts +122 -0
  36. package/tools/check_dependency_cooldown.ts +134 -0
  37. package/tools/index.ts +14 -0
  38. package/tools/sdd_archive_and_commit.ts +207 -0
  39. package/tools/sdd_bdd_tester.ts +163 -0
  40. package/tools/sdd_brain_sync.ts +160 -0
  41. package/tools/sdd_checkpoint.ts +142 -0
  42. package/tools/sdd_compact_context.ts +122 -0
  43. package/tools/sdd_generate_tree.ts +64 -0
  44. package/tools/sdd_install_autoskills.ts +100 -0
  45. package/tools/sdd_regression_detector.ts +241 -0
  46. package/tools/sdd_requirement_tracker.ts +236 -0
  47. package/tools/sdd_secret_scanner.ts +205 -0
  48. package/tools/sdd_spec_validator.ts +139 -0
  49. package/tools/sdd_transition.ts +375 -0
  50. package/tools/sdd_ui_auditor.ts +310 -0
  51. package/tsconfig.json +28 -0
  52. package/zugz-models.json +23 -0
@@ -0,0 +1,54 @@
1
+ import type { Plugin } from "@opencode-ai/plugin"
2
+ import fs from "fs"
3
+ import path from "path"
4
+
5
+ export const SddCorePlugin: Plugin = async ({ project, client, $, directory, worktree }) => {
6
+ return {
7
+ "experimental.session.compacting": async (input, output) => {
8
+ const projectRoot = worktree || directory;
9
+ const lockfilePath = path.join(projectRoot, ".openspec/sdd-lock.json");
10
+ const altLockPath = path.join(projectRoot, "openspec/sdd-lock.json");
11
+ const activeLockPath = fs.existsSync(lockfilePath) ? lockfilePath : (fs.existsSync(altLockPath) ? altLockPath : null);
12
+
13
+ let lockInfo = "No active change or SDD phase detected.";
14
+ let amnesiaInstruction = "";
15
+
16
+ if (activeLockPath) {
17
+ try {
18
+ const lockObj = JSON.parse(fs.readFileSync(activeLockPath, "utf-8"));
19
+ lockInfo = `
20
+ ### SDD SWARM ACTIVE STATUS (DO NOT FORGET)
21
+ - Active Change: \`${lockObj.change_name || "Ninguno"}\`
22
+ - Active Phase: \`${lockObj.active_phase ?? 0}\` (@${lockObj.active_subagent || "sdd-planner"})
23
+ - Status: \`${lockObj.status || "idle"}\`
24
+ - Iteration: \`${lockObj.iteration || 0}\`
25
+ - Last Updated: \`${lockObj.last_updated || ""}\`
26
+ - Complexity: \`${lockObj.complexity || "high"}\`
27
+ `;
28
+
29
+ // Si ya estamos en Fase 2 (Construcción) o posterior, instruimos al compactador a purgar todo el chat del planner anterior
30
+ if (lockObj.active_phase >= 2) {
31
+ amnesiaInstruction = `
32
+ 5. AMNESIA DE PLANIFICACIÓN [CRÍTICO]: Since we are already in Phase ${lockObj.active_phase} (Implementation/Closure) and the technical spec is approved, you MUST completely purge all conversational Q&A and planning discussions from Phase 1. Condense all Phase 1 conversations into a single high-density status line (e.g. "[Phase 1 Q&A resolved. Spec approved: see specs/spec.md]"). Focus 100% of the active context on code files, logical edits, and test validation logs.`;
33
+ }
34
+ } catch (e) {}
35
+ }
36
+
37
+ // 1. Inyectar estado activo en el contexto de compactación para preservar memoria metodológica
38
+ output.context.push(lockInfo);
39
+
40
+ // 2. Personalizar el prompt de compactación para recortar logs redundantes de terminal y discusiones previas de planificación
41
+ output.prompt = `
42
+ You are generating a highly condensed continuation prompt for an active Spec-Driven Development (SDD) multi-agent session.
43
+
44
+ To maximize token savings and avoid long latency, you MUST:
45
+ 1. Summarize any long command terminal output, compiler errors, or test logs into a single high-density line (e.g., "[Linter: 3 syntax errors resolved in auth.ts]" or "[Vitest: 12 tests passed, 0 failed in 1.2s]"). Do NOT repeat full traceback logs.
46
+ 2. Keep the current task status, active file modifications, and direct goals in a concise, bulleted list.
47
+ 3. Keep the active SDD phase and change name verbatim.
48
+ 4. Keep the overall continuation prompt extremely brief (under 15 lines if possible).${amnesiaInstruction}
49
+ `;
50
+ }
51
+ }
52
+ }
53
+
54
+ export default SddCorePlugin;
@@ -0,0 +1,318 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
3
+ import { createSignal, onCleanup } from "solid-js"
4
+ import fs from "fs"
5
+ import path from "path"
6
+
7
+ const PluginTuiSidebar: TuiPlugin = async (api) => {
8
+ api.slots.register({
9
+ order: 100,
10
+ slots: {
11
+ sidebar_content(_ctx, props: { session_id: string; children?: any }) {
12
+ // --- Estado reactivo y Polling de IDs de Sesión ---
13
+ const [sessionIds, setSessionIds] = createSignal<string[]>([props.session_id])
14
+
15
+ // --- Helper para leer el progreso SDD ---
16
+ const getSddProgress = () => {
17
+ try {
18
+ const lockPath = path.join(process.cwd(), ".openspec/sdd-lock.json")
19
+ const altPath = path.join(process.cwd(), "openspec/sdd-lock.json")
20
+ const actualPath = fs.existsSync(lockPath) ? lockPath : (fs.existsSync(altPath) ? altPath : null)
21
+
22
+ if (actualPath) {
23
+ const data = JSON.parse(fs.readFileSync(actualPath, "utf-8"))
24
+ return {
25
+ changeName: data.change_name || "Ninguno",
26
+ activePhase: typeof data.active_phase === "number" ? data.active_phase : 0,
27
+ status: data.status || "idle",
28
+ }
29
+ }
30
+ } catch (e) { }
31
+ return null
32
+ }
33
+
34
+ // Función para actualizar recursivamente la lista de sesión IDs usando api.client
35
+ const updateSessionIds = async () => {
36
+ try {
37
+ const results: string[] = []
38
+ const visited = new Set<string>()
39
+
40
+ async function traverse(sid: string) {
41
+ if (!sid || visited.has(sid)) return
42
+ visited.add(sid)
43
+ results.push(sid)
44
+
45
+ try {
46
+ const response = await api.client.session.children({ sessionID: sid })
47
+ const children = Array.isArray(response) ? response : (response?.data || [])
48
+ for (const child of children) {
49
+ const childId = child?.id
50
+ if (childId) {
51
+ await traverse(childId)
52
+ }
53
+ }
54
+ } catch { }
55
+ }
56
+
57
+ await traverse(props.session_id)
58
+
59
+ // Solo actualizamos el signal si el conjunto de IDs cambió, para evitar re-renderizados innecesarios
60
+ if (JSON.stringify(results) !== JSON.stringify(sessionIds())) {
61
+ setSessionIds(results)
62
+ }
63
+ } catch { }
64
+ }
65
+
66
+ function extractAgentName(messages: any[], sessionInfo: any, sessionId: string): string {
67
+ const userMsg = messages.find((m) => m.role === "user")
68
+ if (userMsg && "agent" in userMsg && userMsg.agent) {
69
+ return userMsg.agent
70
+ }
71
+ if (sessionInfo?.title) {
72
+ return sessionInfo.title
73
+ }
74
+ return `Sesión ${sessionId.slice(0, 4)}`
75
+ }
76
+
77
+ function formatCost(cost: number): string {
78
+ if (cost === 0) return "$0"
79
+ if (cost < 0.001) return "$" + cost.toFixed(4)
80
+ return "$" + cost.toFixed(3)
81
+ }
82
+
83
+ function formatTokens(num: number): string {
84
+ if (num >= 1000000) return Math.round(num / 1000000) + "M"
85
+ if (num >= 1000) return Math.round(num / 1000) + "k"
86
+ return num.toString()
87
+ }
88
+
89
+ interface AgentMetrics {
90
+ name: string
91
+ cost: number
92
+ tokensInput: number
93
+ tokensOutput: number
94
+ isSubagent: boolean
95
+ }
96
+
97
+ interface TotalMetrics {
98
+ agents: AgentMetrics[]
99
+ totalCost: number
100
+ totalInput: number
101
+ totalOutput: number
102
+ }
103
+
104
+ function getMetrics(currentSessionIds: string[]): TotalMetrics {
105
+ let totalCost = 0
106
+ let totalInput = 0
107
+ let totalOutput = 0
108
+
109
+ const agentMap: Record<string, AgentMetrics> = {}
110
+
111
+ for (const sid of currentSessionIds) {
112
+ const isSubagent = sid !== props.session_id
113
+ let messages: any[] = []
114
+ try {
115
+ messages = api.state.session.messages(sid) || []
116
+ } catch { }
117
+
118
+ let sessionInfo: any = null
119
+ try {
120
+ sessionInfo = api.state.session.get?.(sid)
121
+ } catch { }
122
+
123
+ const defaultAgentName = extractAgentName(messages, sessionInfo, sid)
124
+
125
+ for (let i = 0; i < messages.length; i++) {
126
+ const msg = messages[i]
127
+ if (msg.role === "assistant" && "cost" in msg) {
128
+ const cost = typeof msg.cost === "number" && Number.isFinite(msg.cost) ? msg.cost : 0
129
+ const input = msg.tokens?.input ?? 0
130
+ const output = msg.tokens?.output ?? 0
131
+
132
+ totalCost += cost
133
+ totalInput += input
134
+ totalOutput += output
135
+
136
+ let agentName = ""
137
+ if (msg.agent) {
138
+ agentName = msg.agent
139
+ } else {
140
+ for (let j = i; j >= 0; j--) {
141
+ if (messages[j]?.agent) {
142
+ agentName = messages[j].agent
143
+ break
144
+ }
145
+ }
146
+ }
147
+
148
+ if (!agentName) {
149
+ agentName = defaultAgentName
150
+ }
151
+
152
+ if (!agentMap[agentName]) {
153
+ agentMap[agentName] = {
154
+ name: agentName,
155
+ cost: 0,
156
+ tokensInput: 0,
157
+ tokensOutput: 0,
158
+ isSubagent: isSubagent,
159
+ }
160
+ }
161
+
162
+ agentMap[agentName].cost += cost
163
+ agentMap[agentName].tokensInput += input
164
+ agentMap[agentName].tokensOutput += output
165
+ }
166
+ }
167
+
168
+ if (!agentMap[defaultAgentName]) {
169
+ agentMap[defaultAgentName] = {
170
+ name: defaultAgentName,
171
+ cost: 0,
172
+ tokensInput: 0,
173
+ tokensOutput: 0,
174
+ isSubagent: isSubagent,
175
+ }
176
+ }
177
+ }
178
+
179
+ const agents = Object.values(agentMap)
180
+
181
+ return {
182
+ agents,
183
+ totalCost,
184
+ totalInput,
185
+ totalOutput,
186
+ }
187
+ }
188
+
189
+ // --- Estado reactivo y Polling ---
190
+ const [metrics, setMetrics] = createSignal<TotalMetrics>(getMetrics([props.session_id]))
191
+ const [sddProgress, setSddProgress] = createSignal<{ changeName: string; activePhase: number; status: string } | null>(getSddProgress())
192
+ const [colorIndex, setColorIndex] = createSignal(0)
193
+
194
+ // Actualizamos los IDs de las sesiones cada 2 segundos
195
+ const idsInterval = setInterval(() => {
196
+ updateSessionIds()
197
+ setSddProgress(getSddProgress())
198
+ }, 2000)
199
+
200
+ // Actualizamos las métricas cada segundo basadas en el signal de sesión IDs
201
+ const metricsInterval = setInterval(() => {
202
+ setMetrics(getMetrics(sessionIds()))
203
+ }, 1000)
204
+
205
+ // Ticker lento para una animación de ola vertical suave
206
+ const colorInterval = setInterval(() => {
207
+ setColorIndex((prev) => (prev + 1) % 100)
208
+ }, 500)
209
+
210
+ // Paleta premium de naranjas fuertes, ámbar y cobrizos
211
+ const orangePalette = [
212
+ "#E04F00", // Naranjo oscuro / Fuego
213
+ "#FF7300", // Naranjo brillante / Ámbar
214
+ "#B33600", // Siena tostado / Óxido
215
+ "#FF8C00", // Ámbar oscuro
216
+ ]
217
+
218
+ const getLineColor = (lineIdx: number) => {
219
+ const targetIndex = (colorIndex() + lineIdx) % orangePalette.length
220
+ return orangePalette[targetIndex]
221
+ }
222
+
223
+ const logoLines = [
224
+ "███████╗██╗ ██╗ ██████╗ ███████╗",
225
+ "╚══███╔╝██║ ██║██╔════╝ ╚══███╔╝",
226
+ " ███╔╝ ██║ ██║██║ ███╗ ███╔╝ ",
227
+ " ███╔╝ ██║ ██║██║ ██║ ███╔╝ ",
228
+ "███████╗╚██████╔╝╚██████╔╝███████╗",
229
+ "╚══════╝ ╚═════╝ ╚═════╝ ╚══════╝"
230
+ ]
231
+
232
+ // Ejecutar inmediatamente al inicio
233
+ updateSessionIds()
234
+ setSddProgress(getSddProgress())
235
+
236
+ onCleanup(() => {
237
+ clearInterval(idsInterval)
238
+ clearInterval(metricsInterval)
239
+ clearInterval(colorInterval)
240
+ })
241
+
242
+ return (
243
+ <box gap={0}>
244
+ {/* Cabecera Logo ZUGZ con Efecto Ola Vertical Naranja */}
245
+ <box gap={0} paddingTop={1} paddingLeft={1}>
246
+ {logoLines.map((line, idx) => (
247
+ <text fg={getLineColor(idx)}>
248
+ {line}
249
+ </text>
250
+ ))}
251
+ </box>
252
+
253
+ {/* Componente Visual de Progreso SDD de 3 Fases */}
254
+ {sddProgress() && sddProgress()?.changeName !== "nuevo-cambio" && sddProgress()?.changeName !== "Ninguno" && (
255
+ <box gap={0} paddingLeft={1} paddingTop={1} paddingBottom={0}>
256
+ <text fg="#FF7300">
257
+ {`🌱 SDD: ${sddProgress()?.changeName ?? ""}`}
258
+ </text>
259
+ <box gap={0} paddingTop={1}>
260
+ {[
261
+ { id: 0, name: "Diagnóstico", agent: "@sdd-explorer" },
262
+ { id: 1, name: "Planificación", agent: "@sdd-planner" },
263
+ { id: 2, name: "Construcción", agent: "@sdd-builder" },
264
+ { id: 3, name: "Pruebas & Deploy", agent: "@sdd-tester" },
265
+ { id: 4, name: "Cierre & Git", agent: "@sdd-archiver" }
266
+ ].map((ph) => {
267
+ const isActive = sddProgress()?.activePhase === ph.id
268
+ const isCompleted = (sddProgress()?.activePhase ?? 0) > ph.id
269
+
270
+ let prefix = " ○ "
271
+ let fgColor = api.theme.current.textMuted
272
+
273
+ if (isCompleted) {
274
+ prefix = " ✓ "
275
+ fgColor = api.theme.current.success
276
+ } else if (isActive) {
277
+ prefix = " ⚡ "
278
+ fgColor = "#FF7300"
279
+ }
280
+
281
+ return (
282
+ <text fg={fgColor}>
283
+ {`${prefix}${ph.name} ${ph.agent}`}
284
+ </text>
285
+ )
286
+ })}
287
+ </box>
288
+ <text fg={api.theme.current.borderSubtle} paddingTop={1}>
289
+ {"────────────────────────────────────"}
290
+ </text>
291
+ </box>
292
+ )}
293
+
294
+ {/* Monitor de Agentes Compacto y Plano (Efecto Sándwich) */}
295
+ <box gap={0} paddingLeft={1} paddingTop={1} paddingBottom={0}>
296
+ {metrics().agents.map((agent) => (
297
+ <text fg={agent.isSubagent ? api.theme.current.textMuted : api.theme.current.text}>
298
+ {`${agent.isSubagent ? " " : ""}${agent.name}: ${formatCost(agent.cost)} (${formatTokens(agent.tokensInput)}/${formatTokens(agent.tokensOutput)})`}
299
+ </text>
300
+ ))}
301
+ <text fg={api.theme.current.borderSubtle}>
302
+ {"────────────────────────────────────"}
303
+ </text>
304
+ <text fg={api.theme.current.success}>
305
+ {`Total: ${formatCost(metrics().totalCost)} (${formatTokens(metrics().totalInput)}/${formatTokens(metrics().totalOutput)})`}
306
+ </text>
307
+ </box>
308
+
309
+ {/* Chat original de OpenCode */}
310
+ {props.children}
311
+ </box>
312
+ )
313
+ }
314
+ }
315
+ })
316
+ }
317
+
318
+ export default { id: "plugin_tui", tui: PluginTuiSidebar } satisfies TuiPluginModule & { id: string }