@hegemonart/get-design-done 1.19.6 → 1.21.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 (140) hide show
  1. package/.claude-plugin/marketplace.json +11 -14
  2. package/.claude-plugin/plugin.json +9 -32
  3. package/CHANGELOG.md +138 -0
  4. package/README.md +54 -1
  5. package/agents/design-reflector.md +13 -0
  6. package/bin/gdd-sdk +55 -0
  7. package/connections/connections.md +3 -0
  8. package/connections/figma.md +2 -0
  9. package/connections/gdd-state.md +186 -0
  10. package/hooks/budget-enforcer.ts +716 -0
  11. package/hooks/context-exhaustion.ts +251 -0
  12. package/hooks/gdd-read-injection-scanner.ts +172 -0
  13. package/hooks/hooks.json +3 -3
  14. package/package.json +32 -51
  15. package/reference/codex-tools.md +53 -0
  16. package/reference/config-schema.md +2 -2
  17. package/reference/error-recovery.md +58 -0
  18. package/reference/gemini-tools.md +53 -0
  19. package/reference/registry.json +21 -0
  20. package/reference/schemas/budget.schema.json +42 -0
  21. package/reference/schemas/events.schema.json +55 -0
  22. package/reference/schemas/generated.d.ts +419 -0
  23. package/reference/schemas/iteration-budget.schema.json +36 -0
  24. package/reference/schemas/mcp-gdd-state-tools.schema.json +89 -0
  25. package/reference/schemas/rate-limits.schema.json +31 -0
  26. package/scripts/aggregate-agent-metrics.ts +282 -0
  27. package/scripts/codegen-schema-types.ts +149 -0
  28. package/scripts/e2e/run-headless.ts +514 -0
  29. package/scripts/lib/cli/commands/audit.ts +382 -0
  30. package/scripts/lib/cli/commands/init.ts +217 -0
  31. package/scripts/lib/cli/commands/query.ts +329 -0
  32. package/scripts/lib/cli/commands/run.ts +656 -0
  33. package/scripts/lib/cli/commands/stage.ts +468 -0
  34. package/scripts/lib/cli/index.ts +167 -0
  35. package/scripts/lib/cli/parse-args.ts +336 -0
  36. package/scripts/lib/context-engine/index.ts +116 -0
  37. package/scripts/lib/context-engine/manifest.ts +69 -0
  38. package/scripts/lib/context-engine/truncate.ts +282 -0
  39. package/scripts/lib/context-engine/types.ts +59 -0
  40. package/scripts/lib/discuss-parallel-runner/aggregator.ts +448 -0
  41. package/scripts/lib/discuss-parallel-runner/discussants.ts +430 -0
  42. package/scripts/lib/discuss-parallel-runner/index.ts +223 -0
  43. package/scripts/lib/discuss-parallel-runner/types.ts +184 -0
  44. package/scripts/lib/error-classifier.cjs +232 -0
  45. package/scripts/lib/error-classifier.d.cts +44 -0
  46. package/scripts/lib/event-stream/emitter.ts +88 -0
  47. package/scripts/lib/event-stream/index.ts +164 -0
  48. package/scripts/lib/event-stream/types.ts +127 -0
  49. package/scripts/lib/event-stream/writer.ts +154 -0
  50. package/scripts/lib/explore-parallel-runner/index.ts +294 -0
  51. package/scripts/lib/explore-parallel-runner/mappers.ts +290 -0
  52. package/scripts/lib/explore-parallel-runner/synthesizer.ts +295 -0
  53. package/scripts/lib/explore-parallel-runner/types.ts +139 -0
  54. package/scripts/lib/gdd-errors/classification.ts +124 -0
  55. package/scripts/lib/gdd-errors/index.ts +218 -0
  56. package/scripts/lib/gdd-state/gates.ts +216 -0
  57. package/scripts/lib/gdd-state/index.ts +167 -0
  58. package/scripts/lib/gdd-state/lockfile.ts +232 -0
  59. package/scripts/lib/gdd-state/mutator.ts +574 -0
  60. package/scripts/lib/gdd-state/parser.ts +523 -0
  61. package/scripts/lib/gdd-state/types.ts +179 -0
  62. package/scripts/lib/harness/detect.ts +90 -0
  63. package/scripts/lib/harness/index.ts +64 -0
  64. package/scripts/lib/harness/tool-map.ts +142 -0
  65. package/scripts/lib/init-runner/index.ts +396 -0
  66. package/scripts/lib/init-runner/researchers.ts +245 -0
  67. package/scripts/lib/init-runner/scaffold.ts +224 -0
  68. package/scripts/lib/init-runner/synthesizer.ts +224 -0
  69. package/scripts/lib/init-runner/types.ts +143 -0
  70. package/scripts/lib/iteration-budget.cjs +205 -0
  71. package/scripts/lib/iteration-budget.d.cts +32 -0
  72. package/scripts/lib/jittered-backoff.cjs +112 -0
  73. package/scripts/lib/jittered-backoff.d.cts +38 -0
  74. package/scripts/lib/lockfile.cjs +177 -0
  75. package/scripts/lib/lockfile.d.cts +21 -0
  76. package/scripts/lib/logger/index.ts +251 -0
  77. package/scripts/lib/logger/sinks.ts +269 -0
  78. package/scripts/lib/logger/types.ts +110 -0
  79. package/scripts/lib/pipeline-runner/human-gate.ts +134 -0
  80. package/scripts/lib/pipeline-runner/index.ts +527 -0
  81. package/scripts/lib/pipeline-runner/stage-handlers.ts +339 -0
  82. package/scripts/lib/pipeline-runner/state-machine.ts +144 -0
  83. package/scripts/lib/pipeline-runner/types.ts +183 -0
  84. package/scripts/lib/prompt-sanitizer/index.ts +435 -0
  85. package/scripts/lib/prompt-sanitizer/patterns.ts +173 -0
  86. package/scripts/lib/rate-guard.cjs +365 -0
  87. package/scripts/lib/rate-guard.d.cts +38 -0
  88. package/scripts/lib/session-runner/errors.ts +406 -0
  89. package/scripts/lib/session-runner/index.ts +715 -0
  90. package/scripts/lib/session-runner/transcript.ts +189 -0
  91. package/scripts/lib/session-runner/types.ts +144 -0
  92. package/scripts/lib/tool-scoping/index.ts +219 -0
  93. package/scripts/lib/tool-scoping/parse-agent-tools.ts +207 -0
  94. package/scripts/lib/tool-scoping/stage-scopes.ts +139 -0
  95. package/scripts/lib/tool-scoping/types.ts +77 -0
  96. package/scripts/mcp-servers/gdd-state/schemas/add_blocker.schema.json +67 -0
  97. package/scripts/mcp-servers/gdd-state/schemas/add_decision.schema.json +68 -0
  98. package/scripts/mcp-servers/gdd-state/schemas/add_must_have.schema.json +68 -0
  99. package/scripts/mcp-servers/gdd-state/schemas/checkpoint.schema.json +51 -0
  100. package/scripts/mcp-servers/gdd-state/schemas/frontmatter_update.schema.json +62 -0
  101. package/scripts/mcp-servers/gdd-state/schemas/get.schema.json +51 -0
  102. package/scripts/mcp-servers/gdd-state/schemas/probe_connections.schema.json +75 -0
  103. package/scripts/mcp-servers/gdd-state/schemas/resolve_blocker.schema.json +66 -0
  104. package/scripts/mcp-servers/gdd-state/schemas/set_status.schema.json +47 -0
  105. package/scripts/mcp-servers/gdd-state/schemas/transition_stage.schema.json +70 -0
  106. package/scripts/mcp-servers/gdd-state/schemas/update_progress.schema.json +58 -0
  107. package/scripts/mcp-servers/gdd-state/server.ts +288 -0
  108. package/scripts/mcp-servers/gdd-state/tools/add_blocker.ts +72 -0
  109. package/scripts/mcp-servers/gdd-state/tools/add_decision.ts +89 -0
  110. package/scripts/mcp-servers/gdd-state/tools/add_must_have.ts +113 -0
  111. package/scripts/mcp-servers/gdd-state/tools/checkpoint.ts +60 -0
  112. package/scripts/mcp-servers/gdd-state/tools/frontmatter_update.ts +91 -0
  113. package/scripts/mcp-servers/gdd-state/tools/get.ts +51 -0
  114. package/scripts/mcp-servers/gdd-state/tools/index.ts +51 -0
  115. package/scripts/mcp-servers/gdd-state/tools/probe_connections.ts +73 -0
  116. package/scripts/mcp-servers/gdd-state/tools/resolve_blocker.ts +84 -0
  117. package/scripts/mcp-servers/gdd-state/tools/set_status.ts +54 -0
  118. package/scripts/mcp-servers/gdd-state/tools/shared.ts +194 -0
  119. package/scripts/mcp-servers/gdd-state/tools/transition_stage.ts +80 -0
  120. package/scripts/mcp-servers/gdd-state/tools/update_progress.ts +81 -0
  121. package/scripts/validate-frontmatter.ts +114 -0
  122. package/scripts/validate-schemas.ts +401 -0
  123. package/skills/brief/SKILL.md +15 -6
  124. package/skills/design/SKILL.md +31 -13
  125. package/skills/explore/SKILL.md +41 -17
  126. package/skills/health/SKILL.md +15 -4
  127. package/skills/optimize/SKILL.md +3 -3
  128. package/skills/pause/SKILL.md +16 -10
  129. package/skills/plan/SKILL.md +33 -17
  130. package/skills/progress/SKILL.md +15 -11
  131. package/skills/resume/SKILL.md +19 -10
  132. package/skills/settings/SKILL.md +11 -3
  133. package/skills/todo/SKILL.md +12 -3
  134. package/skills/verify/SKILL.md +65 -29
  135. package/hooks/budget-enforcer.js +0 -329
  136. package/hooks/context-exhaustion.js +0 -127
  137. package/hooks/gdd-read-injection-scanner.js +0 -39
  138. package/scripts/aggregate-agent-metrics.js +0 -173
  139. package/scripts/validate-frontmatter.cjs +0 -68
  140. package/scripts/validate-schemas.cjs +0 -242
@@ -0,0 +1,716 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * budget-enforcer.ts — PreToolUse hook (matcher: Agent)
4
+ *
5
+ * Phase 20 Plan 20-13 rewrite: the original budget-enforcer.js is ported
6
+ * to TypeScript verbatim in behavior and additionally wires up the event
7
+ * stream as a hook.fired emitter. No policy changes; the enforcement
8
+ * branches (enforce / warn / log) and telemetry row shape (OPT-09) are
9
+ * preserved byte-for-byte against the .js source.
10
+ *
11
+ * Intercepts every Agent tool spawn. Consults:
12
+ * (a) router decision (from tool_input.context.router_decision if supplied)
13
+ * (b) .design/cache-manifest.json for short-circuit cached answers (D-05)
14
+ * (c) .design/budget.json for tier_overrides + caps (D-01, D-04, D-10)
15
+ *
16
+ * Enforcement (D-02, D-03, D-11):
17
+ * - enforcement_mode: "enforce" + 100% cap → block with actionable error
18
+ * - enforcement_mode: "enforce" + 80% soft-threshold + auto_downgrade_on_cap → rewrite tier to haiku
19
+ * - enforcement_mode: "warn" → log warning, allow spawn
20
+ * - enforcement_mode: "log" → advisory only
21
+ *
22
+ * Logs every decision to .design/telemetry/costs.jsonl (OPT-09 schema).
23
+ * Every telemetry write fires a detached child aggregator
24
+ * (scripts/aggregate-agent-metrics.ts) that rebuilds
25
+ * .design/agent-metrics.json incrementally.
26
+ *
27
+ * Every decision also fires a hook.fired event to
28
+ * .design/telemetry/events.jsonl via appendEvent() (Plan 20-06). The
29
+ * event payload uses the pre-registered HookFiredEvent shape with
30
+ * hook="budget-enforcer" and decision in {block|downgrade|warn|log|cache|allow|lazy}.
31
+ *
32
+ * Plan 20-14 note: Plan 20-14 will patch this hook with a rate-guard
33
+ * check before spawn. The current file exposes a `main()` entrypoint
34
+ * and keeps policy pure-ish so that insertion is an additive change.
35
+ *
36
+ * Hook type: PreToolUse
37
+ * Input: JSON on stdin { tool_name, tool_input }
38
+ * Output: JSON on stdout { continue, suppressOutput, message, modified_tool_input? }
39
+ */
40
+
41
+ import { existsSync, mkdirSync, readFileSync, appendFileSync } from 'node:fs';
42
+ import { join, dirname, isAbsolute, resolve } from 'node:path';
43
+ import { spawn } from 'node:child_process';
44
+ import { createInterface } from 'node:readline';
45
+ import { createRequire } from 'node:module';
46
+
47
+ import { appendEvent } from '../scripts/lib/event-stream/index.ts';
48
+ import type { HookFiredEvent } from '../scripts/lib/event-stream/index.ts';
49
+ // Consume the generated BudgetSchema so this hook participates in the
50
+ // Plan 20-00 codegen graph. We treat parsed JSON as BudgetSchema after
51
+ // the structural merge with defaults — the schema permits every field
52
+ // to be optional so defaults-merged objects are always valid.
53
+ import type { BudgetSchema } from '../reference/schemas/generated.js';
54
+
55
+ // Plan 20-14 resilience primitives. These are `.cjs` modules so that
56
+ // `.cjs`-only call sites (future CLIs) can consume them without
57
+ // --experimental-strip-types. From this file — which runs as an ES
58
+ // module under strip-types — we reach them via `createRequire`
59
+ // anchored on an absolute filesystem path derived from `process.argv[1]`
60
+ // (identical pattern to `hooks/gdd-read-injection-scanner.ts`'s
61
+ // loadPatterns). We deliberately avoid `import.meta.url` so this
62
+ // module stays compatible with the `Node16` tsconfig module setting
63
+ // without forcing `"type":"module"` in package.json (which would
64
+ // break the Tier-2 .cjs tests per Plan 20-00).
65
+ function resolveHookPath(): string {
66
+ const a1 = process.argv[1];
67
+ if (typeof a1 === 'string' && a1.length > 0) {
68
+ return isAbsolute(a1) ? a1 : resolve(a1);
69
+ }
70
+ return resolve('hooks/budget-enforcer.ts');
71
+ }
72
+ const nodeRequire = createRequire(resolveHookPath());
73
+ const rateGuard = nodeRequire('../scripts/lib/rate-guard.cjs') as typeof import('../scripts/lib/rate-guard.cjs');
74
+ const iterationBudget = nodeRequire('../scripts/lib/iteration-budget.cjs') as typeof import('../scripts/lib/iteration-budget.cjs');
75
+
76
+ // ── Types ───────────────────────────────────────────────────────────────────
77
+
78
+ /**
79
+ * PreToolUse stdin envelope. Claude Code injects tool_name + tool_input
80
+ * for every hook invocation. The tool_input shape is tool-specific;
81
+ * this hook only consumes Agent-shaped tool_input so we narrow here.
82
+ */
83
+ interface ToolInput {
84
+ subagent_type?: string;
85
+ agent?: string;
86
+ _input_hash?: string;
87
+ _est_cost_usd?: number;
88
+ _tokens_in_est?: number;
89
+ _tokens_out_est?: number;
90
+ _tier_override?: string;
91
+ _default_tier?: string;
92
+ _tier_downgraded?: boolean;
93
+ lazy_skipped?: boolean;
94
+ [key: string]: unknown;
95
+ }
96
+
97
+ interface HookStdin {
98
+ tool_name?: string;
99
+ tool_input?: ToolInput;
100
+ [key: string]: unknown;
101
+ }
102
+
103
+ /**
104
+ * PostToolUse stdout envelope. The `continue` field is the primary
105
+ * dispatch knob; `modified_tool_input` is how we inject tier overrides.
106
+ */
107
+ interface ToolOutput {
108
+ continue: boolean;
109
+ suppressOutput?: boolean;
110
+ message?: string;
111
+ modified_tool_input?: ToolInput;
112
+ cached_result?: unknown;
113
+ }
114
+
115
+ /** Shape of .design/cache-manifest.json — D-05 cache short-circuit. */
116
+ interface CacheManifestEntry {
117
+ ts_unix: number;
118
+ result: unknown;
119
+ }
120
+ interface CacheManifest {
121
+ ttl_seconds?: number;
122
+ entries?: Record<string, CacheManifestEntry>;
123
+ }
124
+
125
+ /** Shape of .design/telemetry/phase-totals.json — WR-02 fast path. */
126
+ interface PhaseTotals {
127
+ totals?: Record<string, number>;
128
+ }
129
+
130
+ /** OPT-09 telemetry row (partial — aggregator enforces required fields). */
131
+ interface TelemetryRowPartial {
132
+ ts?: string;
133
+ agent?: string;
134
+ tier?: string;
135
+ tokens_in?: number;
136
+ tokens_out?: number;
137
+ cache_hit?: boolean;
138
+ est_cost_usd?: number;
139
+ cycle?: string;
140
+ phase?: string;
141
+ tier_downgraded?: boolean;
142
+ enforcement_mode?: string;
143
+ lazy_skipped?: boolean;
144
+ block_reason?: string;
145
+ _cyclePhase?: { cycle: string; phase: string };
146
+ }
147
+
148
+ /**
149
+ * The hook's terminal decision — also the event payload `decision` field.
150
+ * `'rate-limited'` was added in Plan 20-14 to signal that rate-guard
151
+ * saw an upstream provider hit its limit and the hook short-circuited
152
+ * before the budget cap check.
153
+ */
154
+ export type HookDecision =
155
+ | 'lazy'
156
+ | 'cache'
157
+ | 'rate-limited'
158
+ | 'block'
159
+ | 'downgrade'
160
+ | 'warn'
161
+ | 'log'
162
+ | 'allow';
163
+
164
+ // ── Constants ───────────────────────────────────────────────────────────────
165
+
166
+ const BUDGET_PATH = join(process.cwd(), '.design', 'budget.json');
167
+ const MANIFEST_PATH = join(process.cwd(), '.design', 'cache-manifest.json');
168
+ const TELEMETRY_PATH = join(
169
+ process.cwd(),
170
+ '.design',
171
+ 'telemetry',
172
+ 'costs.jsonl',
173
+ );
174
+ const PHASE_TOTALS_PATH = join(
175
+ process.cwd(),
176
+ '.design',
177
+ 'telemetry',
178
+ 'phase-totals.json',
179
+ );
180
+ const STATE_PATH = join(process.cwd(), '.design', 'STATE.md');
181
+
182
+ /** Defaults per D-12 — mirror scripts/bootstrap.sh budget.json bootstrap. */
183
+ const BUDGET_DEFAULTS: Required<
184
+ Pick<
185
+ BudgetSchema,
186
+ | 'per_task_cap_usd'
187
+ | 'per_phase_cap_usd'
188
+ | 'tier_overrides'
189
+ | 'auto_downgrade_on_cap'
190
+ | 'cache_ttl_seconds'
191
+ | 'enforcement_mode'
192
+ >
193
+ > = {
194
+ per_task_cap_usd: 2.0,
195
+ per_phase_cap_usd: 20.0,
196
+ tier_overrides: {},
197
+ auto_downgrade_on_cap: true,
198
+ cache_ttl_seconds: 3600,
199
+ enforcement_mode: 'enforce',
200
+ };
201
+
202
+ /**
203
+ * Concrete budget shape after defaults-merge. Every field becomes
204
+ * non-optional so downstream branches don't have to null-guard. Defined
205
+ * as an intersection of BudgetSchema (to keep the generated-type graph
206
+ * edge alive) and the required fields.
207
+ */
208
+ type ResolvedBudget = BudgetSchema & typeof BUDGET_DEFAULTS;
209
+
210
+ // ── budget.json loader ──────────────────────────────────────────────────────
211
+
212
+ /**
213
+ * Load .design/budget.json with defaults-merge. Returns the defaults
214
+ * when the file is missing or unparseable — fail-open is the documented
215
+ * D-12 behavior so a missing budget file never blocks agent spawns.
216
+ */
217
+ export function loadBudget(): ResolvedBudget {
218
+ if (!existsSync(BUDGET_PATH)) {
219
+ return { ...BUDGET_DEFAULTS };
220
+ }
221
+ try {
222
+ const parsed = JSON.parse(readFileSync(BUDGET_PATH, 'utf8')) as Partial<BudgetSchema>;
223
+ return { ...BUDGET_DEFAULTS, ...parsed };
224
+ } catch {
225
+ return { ...BUDGET_DEFAULTS };
226
+ }
227
+ }
228
+
229
+ // ── cumulative phase spend (WR-02) ──────────────────────────────────────────
230
+
231
+ /**
232
+ * Fast path: read phase-totals.json (written by
233
+ * scripts/aggregate-agent-metrics.ts). Falls back to costs.jsonl replay
234
+ * only on the very first spawn of a session. Returns 0 on any error.
235
+ */
236
+ export function currentPhaseSpend(phase: string): number {
237
+ if (existsSync(PHASE_TOTALS_PATH)) {
238
+ try {
239
+ const data = JSON.parse(
240
+ readFileSync(PHASE_TOTALS_PATH, 'utf8'),
241
+ ) as PhaseTotals;
242
+ const total = data.totals?.[phase];
243
+ return Number(total ?? 0);
244
+ } catch {
245
+ // fall through to replay
246
+ }
247
+ }
248
+ if (!existsSync(TELEMETRY_PATH)) return 0;
249
+ const lines = readFileSync(TELEMETRY_PATH, 'utf8')
250
+ .split(/\r?\n/)
251
+ .filter(Boolean);
252
+ let sum = 0;
253
+ for (const line of lines) {
254
+ try {
255
+ const row = JSON.parse(line) as { phase?: string; est_cost_usd?: number };
256
+ if (row.phase === phase) sum += Number(row.est_cost_usd ?? 0);
257
+ } catch {
258
+ // tolerant — skip malformed lines
259
+ }
260
+ }
261
+ return sum;
262
+ }
263
+
264
+ // ── cycle + phase reader (STATE.md frontmatter) ─────────────────────────────
265
+
266
+ /**
267
+ * Parse `cycle:` and `phase:` from the STATE.md leading frontmatter
268
+ * block. Regex-based rather than YAML-parsed — STATE.md frontmatter is
269
+ * always flat `key: value` per reference/STATE-TEMPLATE.md.
270
+ */
271
+ export function readCycleAndPhase(): { cycle: string; phase: string } {
272
+ const defaults = { cycle: 'unknown', phase: 'unknown' };
273
+ if (!existsSync(STATE_PATH)) return defaults;
274
+ try {
275
+ const content = readFileSync(STATE_PATH, 'utf8');
276
+ const fm = content.match(/^---\s*\n([\s\S]*?)\n---/);
277
+ const body = fm?.[1] ?? content;
278
+ const cycleMatch = body.match(/^cycle:\s*"?([^"\n]+)"?/m);
279
+ const phaseMatch = body.match(/^phase:\s*"?([^"\n]+)"?/m);
280
+ return {
281
+ cycle: cycleMatch?.[1]?.trim() ?? 'unknown',
282
+ phase: phaseMatch?.[1]?.trim() ?? 'unknown',
283
+ };
284
+ } catch {
285
+ return defaults;
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Deprecated alias kept for plan-01 callers that imported the
291
+ * phase-only function from the .js source.
292
+ */
293
+ export function currentPhase(): string {
294
+ return readCycleAndPhase().phase;
295
+ }
296
+
297
+ // ── cache short-circuit (D-05) ──────────────────────────────────────────────
298
+
299
+ /**
300
+ * Look up a cached result for `agent:inputHash`. Returns null on miss,
301
+ * stale (past TTL), or any read/parse error.
302
+ */
303
+ export function cacheLookup(agent: string, inputHash: string): unknown {
304
+ if (!existsSync(MANIFEST_PATH)) return null;
305
+ try {
306
+ const manifest = JSON.parse(
307
+ readFileSync(MANIFEST_PATH, 'utf8'),
308
+ ) as CacheManifest;
309
+ const entry = manifest.entries?.[`${agent}:${inputHash}`];
310
+ if (entry === undefined) return null;
311
+ const age = Date.now() / 1000 - entry.ts_unix;
312
+ if (age > (manifest.ttl_seconds ?? 3600)) return null;
313
+ return entry.result;
314
+ } catch {
315
+ return null;
316
+ }
317
+ }
318
+
319
+ // ── tier resolution (D-04) ──────────────────────────────────────────────────
320
+
321
+ export function resolveTier(
322
+ agent: string,
323
+ agentDefaultTier: string | undefined,
324
+ overrides: Record<string, string> | undefined,
325
+ ): string {
326
+ return overrides?.[agent] ?? agentDefaultTier ?? 'sonnet';
327
+ }
328
+
329
+ // ── detached aggregator ─────────────────────────────────────────────────────
330
+
331
+ /**
332
+ * Fire-and-forget: spawn the aggregator as a detached child. Failures
333
+ * here MUST NOT break the hook — silently swallow everything. Uses the
334
+ * .ts entrypoint via --experimental-strip-types since Plan 20-00.
335
+ */
336
+ function spawnAggregator(): void {
337
+ try {
338
+ const aggregatorPath = join(
339
+ process.cwd(),
340
+ 'scripts',
341
+ 'aggregate-agent-metrics.ts',
342
+ );
343
+ if (!existsSync(aggregatorPath)) return;
344
+ // IN-02: minimal env; aggregator reads only filesystem artifacts.
345
+ const childEnv: NodeJS.ProcessEnv = {};
346
+ if (typeof process.env['PATH'] === 'string') {
347
+ childEnv['PATH'] = process.env['PATH'];
348
+ }
349
+ const child = spawn(
350
+ 'node',
351
+ ['--experimental-strip-types', aggregatorPath],
352
+ {
353
+ cwd: process.cwd(),
354
+ detached: true,
355
+ stdio: 'ignore',
356
+ env: childEnv,
357
+ },
358
+ );
359
+ child.unref();
360
+ } catch {
361
+ // Aggregator failures are non-fatal.
362
+ }
363
+ }
364
+
365
+ // ── OPT-09 locked-schema telemetry row builder ──────────────────────────────
366
+
367
+ interface TelemetryRow {
368
+ ts: string;
369
+ agent: string;
370
+ tier: string;
371
+ tokens_in: number;
372
+ tokens_out: number;
373
+ cache_hit: boolean;
374
+ est_cost_usd: number;
375
+ cycle: string;
376
+ phase: string;
377
+ tier_downgraded?: boolean;
378
+ enforcement_mode?: string;
379
+ lazy_skipped?: boolean;
380
+ block_reason?: string;
381
+ }
382
+
383
+ function buildTelemetryRow(partial: TelemetryRowPartial): TelemetryRow {
384
+ const { cycle, phase } = partial._cyclePhase ?? readCycleAndPhase();
385
+ const row: TelemetryRow = {
386
+ ts: partial.ts ?? new Date().toISOString(),
387
+ agent: String(partial.agent ?? 'unknown'),
388
+ tier: String(partial.tier ?? 'unknown'),
389
+ tokens_in: Number(partial.tokens_in ?? 0),
390
+ tokens_out: Number(partial.tokens_out ?? 0),
391
+ cache_hit: Boolean(partial.cache_hit),
392
+ est_cost_usd: Number(partial.est_cost_usd ?? 0),
393
+ cycle: partial.cycle ?? cycle,
394
+ phase: partial.phase ?? phase,
395
+ };
396
+ if (partial.tier_downgraded !== undefined) {
397
+ row.tier_downgraded = Boolean(partial.tier_downgraded);
398
+ }
399
+ if (partial.enforcement_mode !== undefined) {
400
+ row.enforcement_mode = String(partial.enforcement_mode);
401
+ }
402
+ if (partial.lazy_skipped !== undefined) {
403
+ row.lazy_skipped = Boolean(partial.lazy_skipped);
404
+ }
405
+ if (partial.block_reason !== undefined) {
406
+ row.block_reason = String(partial.block_reason);
407
+ }
408
+ return row;
409
+ }
410
+
411
+ /**
412
+ * Append one OPT-09 row to costs.jsonl. Directory is created if
413
+ * missing. Every write fires a detached aggregator child so the
414
+ * per-agent + per-phase rollups stay current. Fail-open — telemetry
415
+ * write errors MUST NEVER block the hook.
416
+ */
417
+ export function writeTelemetry(partial: TelemetryRowPartial): void {
418
+ const dir = dirname(TELEMETRY_PATH);
419
+ try {
420
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
421
+ const row = buildTelemetryRow(partial);
422
+ appendFileSync(TELEMETRY_PATH, JSON.stringify(row) + '\n', 'utf8');
423
+ spawnAggregator();
424
+ } catch {
425
+ // Fail open.
426
+ }
427
+ }
428
+
429
+ // ── event-stream decision emitter ───────────────────────────────────────────
430
+
431
+ /**
432
+ * Emit one hook.fired event per hook decision. Uses the pre-registered
433
+ * HookFiredEvent subtype from scripts/lib/event-stream/types.ts and
434
+ * stamps sessionId from the process PID + boot time — same scheme as
435
+ * scripts/mcp-servers/gdd-state/tools/shared.ts but inlined here so the
436
+ * hook stays dependency-light.
437
+ */
438
+ let CACHED_SESSION_ID: string | null = null;
439
+ function getSessionId(): string {
440
+ if (CACHED_SESSION_ID === null) {
441
+ const iso = new Date().toISOString().replace(/[:.]/g, '-');
442
+ CACHED_SESSION_ID = `gdd-hook-${iso}-${process.pid}`;
443
+ }
444
+ return CACHED_SESSION_ID;
445
+ }
446
+
447
+ function emitHookFired(decision: HookDecision, cycle?: string): void {
448
+ const ev: HookFiredEvent = {
449
+ type: 'hook.fired',
450
+ timestamp: new Date().toISOString(),
451
+ sessionId: getSessionId(),
452
+ ...(cycle !== undefined && cycle !== 'unknown' ? { cycle } : {}),
453
+ payload: { hook: 'budget-enforcer', decision },
454
+ };
455
+ try {
456
+ appendEvent(ev);
457
+ } catch {
458
+ // Fail open — event-stream errors must never block the hook.
459
+ }
460
+ }
461
+
462
+ // ── main ────────────────────────────────────────────────────────────────────
463
+
464
+ async function readStdin(): Promise<string> {
465
+ const rl = createInterface({ input: process.stdin });
466
+ let data = '';
467
+ for await (const line of rl) data += line + '\n';
468
+ return data;
469
+ }
470
+
471
+ export async function main(): Promise<void> {
472
+ const inputData = await readStdin();
473
+
474
+ let parsed: HookStdin;
475
+ try {
476
+ parsed = JSON.parse(inputData) as HookStdin;
477
+ } catch {
478
+ process.exit(0);
479
+ }
480
+
481
+ if (parsed.tool_name !== 'Agent') process.exit(0);
482
+
483
+ const toolInput: ToolInput = parsed.tool_input ?? {};
484
+ const agent =
485
+ typeof toolInput.subagent_type === 'string' && toolInput.subagent_type.length > 0
486
+ ? toolInput.subagent_type
487
+ : typeof toolInput.agent === 'string' && toolInput.agent.length > 0
488
+ ? toolInput.agent
489
+ : 'unknown';
490
+ const inputHash =
491
+ typeof toolInput._input_hash === 'string' ? toolInput._input_hash : null;
492
+
493
+ const { cycle, phase } = readCycleAndPhase();
494
+ const cyclePhase = { cycle, phase };
495
+
496
+ // Branch A: lazy-gate passthrough.
497
+ if (toolInput.lazy_skipped === true) {
498
+ writeTelemetry({
499
+ agent,
500
+ tier: 'gate',
501
+ tokens_in: 0,
502
+ tokens_out: 0,
503
+ cache_hit: false,
504
+ est_cost_usd: 0,
505
+ lazy_skipped: true,
506
+ _cyclePhase: cyclePhase,
507
+ });
508
+ emitHookFired('lazy', cycle);
509
+ const response: ToolOutput = { continue: true, suppressOutput: true };
510
+ process.stdout.write(JSON.stringify(response));
511
+ return;
512
+ }
513
+
514
+ const budget = loadBudget();
515
+
516
+ // Branch B: cache short-circuit (D-05).
517
+ if (inputHash !== null) {
518
+ const cached = cacheLookup(agent, inputHash);
519
+ if (cached !== null) {
520
+ // Plan 20-14: refund one iteration-budget unit — cached answers did
521
+ // no real work and shouldn't count against the fix-loop ceiling.
522
+ // The refund call is fire-and-forget; failures are swallowed so
523
+ // telemetry/iteration-budget errors never block the hook. We also
524
+ // silence the auto-init path (refund on a fresh state file is a
525
+ // no-op at full budget, which is what we want).
526
+ try {
527
+ void iterationBudget.refund(1).catch(() => { /* fail open */ });
528
+ } catch {
529
+ // fail open
530
+ }
531
+ writeTelemetry({
532
+ agent,
533
+ tier: 'cache',
534
+ tokens_in: 0,
535
+ tokens_out: 0,
536
+ cache_hit: true,
537
+ est_cost_usd: 0,
538
+ _cyclePhase: cyclePhase,
539
+ });
540
+ emitHookFired('cache', cycle);
541
+ const response: ToolOutput = {
542
+ continue: false,
543
+ suppressOutput: false,
544
+ message: `gdd-budget-enforcer: SkippedCached — returning cached result for ${agent}:${inputHash}`,
545
+ cached_result: cached,
546
+ };
547
+ process.stdout.write(JSON.stringify(response));
548
+ return;
549
+ }
550
+ }
551
+
552
+ // Plan 20-14: rate-guard short-circuit. Inserted AFTER the cache
553
+ // check (cached answers bypass every network call so rate-limits are
554
+ // irrelevant for them) and BEFORE the budget cap so a rate-limited
555
+ // provider surfaces a clean "wait N seconds" message instead of a
556
+ // "cap reached" one. rate-guard state is per-provider — we key on
557
+ // 'anthropic' because every Agent spawn in this project goes through
558
+ // the Anthropic API; future multi-provider routing would branch here
559
+ // on toolInput._provider.
560
+ const rateState = rateGuard.remaining('anthropic');
561
+ if (rateState !== null && rateState.remaining <= 0) {
562
+ const waitSeconds = Math.max(
563
+ 0,
564
+ Math.ceil((Date.parse(rateState.resetAt) - Date.now()) / 1000),
565
+ );
566
+ writeTelemetry({
567
+ agent,
568
+ tier:
569
+ toolInput._tier_override ??
570
+ toolInput._default_tier ??
571
+ 'sonnet',
572
+ tokens_in: Number(toolInput._tokens_in_est ?? 0),
573
+ tokens_out: Number(toolInput._tokens_out_est ?? 0),
574
+ cache_hit: false,
575
+ est_cost_usd: Number(toolInput._est_cost_usd ?? 0),
576
+ block_reason: 'rate_limited',
577
+ _cyclePhase: cyclePhase,
578
+ });
579
+ emitHookFired('rate-limited', cycle);
580
+ const response: ToolOutput = {
581
+ continue: false,
582
+ suppressOutput: false,
583
+ message: `gdd-budget-enforcer: rate-limited on anthropic, retry in ${waitSeconds}s (resetAt=${rateState.resetAt})`,
584
+ };
585
+ process.stdout.write(JSON.stringify(response));
586
+ return;
587
+ }
588
+
589
+ const estCost = Number(toolInput._est_cost_usd ?? 0);
590
+ const phaseSpend = currentPhaseSpend(phase);
591
+
592
+ if (budget.enforcement_mode === 'enforce') {
593
+ // Branch C: 100% per_task cap hard block.
594
+ if (estCost >= budget.per_task_cap_usd) {
595
+ writeTelemetry({
596
+ agent,
597
+ tier:
598
+ toolInput._tier_override ??
599
+ toolInput._default_tier ??
600
+ 'sonnet',
601
+ tokens_in: Number(toolInput._tokens_in_est ?? 0),
602
+ tokens_out: Number(toolInput._tokens_out_est ?? 0),
603
+ cache_hit: false,
604
+ est_cost_usd: estCost,
605
+ enforcement_mode: budget.enforcement_mode,
606
+ block_reason: 'per_task_cap',
607
+ _cyclePhase: cyclePhase,
608
+ });
609
+ emitHookFired('block', cycle);
610
+ const response: ToolOutput = {
611
+ continue: false,
612
+ suppressOutput: false,
613
+ message: `Budget cap reached for per-task. Estimated: $${estCost.toFixed(4)}, cap: $${budget.per_task_cap_usd.toFixed(2)}. Raise cap in .design/budget.json or retry after next task.`,
614
+ };
615
+ process.stdout.write(JSON.stringify(response));
616
+ return;
617
+ }
618
+ // Branch D: 100% per_phase cap hard block.
619
+ if (phaseSpend + estCost >= budget.per_phase_cap_usd) {
620
+ writeTelemetry({
621
+ agent,
622
+ tier:
623
+ toolInput._tier_override ??
624
+ toolInput._default_tier ??
625
+ 'sonnet',
626
+ tokens_in: Number(toolInput._tokens_in_est ?? 0),
627
+ tokens_out: Number(toolInput._tokens_out_est ?? 0),
628
+ cache_hit: false,
629
+ est_cost_usd: estCost,
630
+ enforcement_mode: budget.enforcement_mode,
631
+ block_reason: 'per_phase_cap',
632
+ _cyclePhase: cyclePhase,
633
+ });
634
+ emitHookFired('block', cycle);
635
+ const response: ToolOutput = {
636
+ continue: false,
637
+ suppressOutput: false,
638
+ message: `Budget cap reached for per-phase (${phase}). Cumulative: $${(phaseSpend + estCost).toFixed(4)}, cap: $${budget.per_phase_cap_usd.toFixed(2)}. Raise cap in .design/budget.json or retry after next phase.`,
639
+ };
640
+ process.stdout.write(JSON.stringify(response));
641
+ return;
642
+ }
643
+ // 80% soft-threshold downgrade (D-03): task-scoped.
644
+ if (
645
+ budget.auto_downgrade_on_cap &&
646
+ estCost >= 0.8 * budget.per_task_cap_usd
647
+ ) {
648
+ toolInput._tier_override = 'haiku';
649
+ toolInput._tier_downgraded = true;
650
+ }
651
+ } else if (budget.enforcement_mode === 'warn') {
652
+ if (estCost >= budget.per_task_cap_usd) {
653
+ process.stderr.write(
654
+ `gdd-budget-enforcer WARN: per-task cap will be exceeded ($${estCost.toFixed(4)} >= $${budget.per_task_cap_usd})\n`,
655
+ );
656
+ }
657
+ }
658
+ // enforcement_mode === 'log': telemetry only.
659
+
660
+ // D-04: tier_overrides rewrite.
661
+ if (budget.tier_overrides[agent] !== undefined) {
662
+ toolInput._tier_override = budget.tier_overrides[agent];
663
+ }
664
+
665
+ // Branch E: standard spawn-allowed (includes tier-downgraded path).
666
+ writeTelemetry({
667
+ agent,
668
+ tier:
669
+ toolInput._tier_override ??
670
+ toolInput._default_tier ??
671
+ 'sonnet',
672
+ tokens_in: Number(toolInput._tokens_in_est ?? 0),
673
+ tokens_out: Number(toolInput._tokens_out_est ?? 0),
674
+ cache_hit: false,
675
+ est_cost_usd: estCost,
676
+ tier_downgraded: Boolean(toolInput._tier_downgraded),
677
+ enforcement_mode: budget.enforcement_mode,
678
+ _cyclePhase: cyclePhase,
679
+ });
680
+
681
+ // Decision tag for the event stream. downgrade takes precedence over
682
+ // allow/warn/log since it's a user-visible rewrite.
683
+ let decision: HookDecision;
684
+ if (toolInput._tier_downgraded === true) {
685
+ decision = 'downgrade';
686
+ } else if (budget.enforcement_mode === 'warn') {
687
+ decision = 'warn';
688
+ } else if (budget.enforcement_mode === 'log') {
689
+ decision = 'log';
690
+ } else {
691
+ decision = 'allow';
692
+ }
693
+ emitHookFired(decision, cycle);
694
+
695
+ const response: ToolOutput = {
696
+ continue: true,
697
+ suppressOutput: true,
698
+ modified_tool_input: toolInput,
699
+ };
700
+ process.stdout.write(JSON.stringify(response));
701
+ }
702
+
703
+ // Run only when invoked as the hook entrypoint. Guards against test
704
+ // files that may import from this module (e.g. to call loadBudget()
705
+ // directly).
706
+ const isDirectInvocation =
707
+ process.argv[1] !== undefined &&
708
+ /budget-enforcer\.ts$/.test(process.argv[1]);
709
+
710
+ if (isDirectInvocation) {
711
+ main().catch((err: unknown) => {
712
+ const msg = err instanceof Error ? err.message : String(err);
713
+ process.stderr.write(`budget-enforcer hook error: ${msg}\n`);
714
+ process.exit(0);
715
+ });
716
+ }