@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.
- package/.claude-plugin/marketplace.json +11 -14
- package/.claude-plugin/plugin.json +9 -32
- package/CHANGELOG.md +138 -0
- package/README.md +54 -1
- package/agents/design-reflector.md +13 -0
- package/bin/gdd-sdk +55 -0
- package/connections/connections.md +3 -0
- package/connections/figma.md +2 -0
- package/connections/gdd-state.md +186 -0
- package/hooks/budget-enforcer.ts +716 -0
- package/hooks/context-exhaustion.ts +251 -0
- package/hooks/gdd-read-injection-scanner.ts +172 -0
- package/hooks/hooks.json +3 -3
- package/package.json +32 -51
- package/reference/codex-tools.md +53 -0
- package/reference/config-schema.md +2 -2
- package/reference/error-recovery.md +58 -0
- package/reference/gemini-tools.md +53 -0
- package/reference/registry.json +21 -0
- package/reference/schemas/budget.schema.json +42 -0
- package/reference/schemas/events.schema.json +55 -0
- package/reference/schemas/generated.d.ts +419 -0
- package/reference/schemas/iteration-budget.schema.json +36 -0
- package/reference/schemas/mcp-gdd-state-tools.schema.json +89 -0
- package/reference/schemas/rate-limits.schema.json +31 -0
- package/scripts/aggregate-agent-metrics.ts +282 -0
- package/scripts/codegen-schema-types.ts +149 -0
- package/scripts/e2e/run-headless.ts +514 -0
- package/scripts/lib/cli/commands/audit.ts +382 -0
- package/scripts/lib/cli/commands/init.ts +217 -0
- package/scripts/lib/cli/commands/query.ts +329 -0
- package/scripts/lib/cli/commands/run.ts +656 -0
- package/scripts/lib/cli/commands/stage.ts +468 -0
- package/scripts/lib/cli/index.ts +167 -0
- package/scripts/lib/cli/parse-args.ts +336 -0
- package/scripts/lib/context-engine/index.ts +116 -0
- package/scripts/lib/context-engine/manifest.ts +69 -0
- package/scripts/lib/context-engine/truncate.ts +282 -0
- package/scripts/lib/context-engine/types.ts +59 -0
- package/scripts/lib/discuss-parallel-runner/aggregator.ts +448 -0
- package/scripts/lib/discuss-parallel-runner/discussants.ts +430 -0
- package/scripts/lib/discuss-parallel-runner/index.ts +223 -0
- package/scripts/lib/discuss-parallel-runner/types.ts +184 -0
- package/scripts/lib/error-classifier.cjs +232 -0
- package/scripts/lib/error-classifier.d.cts +44 -0
- package/scripts/lib/event-stream/emitter.ts +88 -0
- package/scripts/lib/event-stream/index.ts +164 -0
- package/scripts/lib/event-stream/types.ts +127 -0
- package/scripts/lib/event-stream/writer.ts +154 -0
- package/scripts/lib/explore-parallel-runner/index.ts +294 -0
- package/scripts/lib/explore-parallel-runner/mappers.ts +290 -0
- package/scripts/lib/explore-parallel-runner/synthesizer.ts +295 -0
- package/scripts/lib/explore-parallel-runner/types.ts +139 -0
- package/scripts/lib/gdd-errors/classification.ts +124 -0
- package/scripts/lib/gdd-errors/index.ts +218 -0
- package/scripts/lib/gdd-state/gates.ts +216 -0
- package/scripts/lib/gdd-state/index.ts +167 -0
- package/scripts/lib/gdd-state/lockfile.ts +232 -0
- package/scripts/lib/gdd-state/mutator.ts +574 -0
- package/scripts/lib/gdd-state/parser.ts +523 -0
- package/scripts/lib/gdd-state/types.ts +179 -0
- package/scripts/lib/harness/detect.ts +90 -0
- package/scripts/lib/harness/index.ts +64 -0
- package/scripts/lib/harness/tool-map.ts +142 -0
- package/scripts/lib/init-runner/index.ts +396 -0
- package/scripts/lib/init-runner/researchers.ts +245 -0
- package/scripts/lib/init-runner/scaffold.ts +224 -0
- package/scripts/lib/init-runner/synthesizer.ts +224 -0
- package/scripts/lib/init-runner/types.ts +143 -0
- package/scripts/lib/iteration-budget.cjs +205 -0
- package/scripts/lib/iteration-budget.d.cts +32 -0
- package/scripts/lib/jittered-backoff.cjs +112 -0
- package/scripts/lib/jittered-backoff.d.cts +38 -0
- package/scripts/lib/lockfile.cjs +177 -0
- package/scripts/lib/lockfile.d.cts +21 -0
- package/scripts/lib/logger/index.ts +251 -0
- package/scripts/lib/logger/sinks.ts +269 -0
- package/scripts/lib/logger/types.ts +110 -0
- package/scripts/lib/pipeline-runner/human-gate.ts +134 -0
- package/scripts/lib/pipeline-runner/index.ts +527 -0
- package/scripts/lib/pipeline-runner/stage-handlers.ts +339 -0
- package/scripts/lib/pipeline-runner/state-machine.ts +144 -0
- package/scripts/lib/pipeline-runner/types.ts +183 -0
- package/scripts/lib/prompt-sanitizer/index.ts +435 -0
- package/scripts/lib/prompt-sanitizer/patterns.ts +173 -0
- package/scripts/lib/rate-guard.cjs +365 -0
- package/scripts/lib/rate-guard.d.cts +38 -0
- package/scripts/lib/session-runner/errors.ts +406 -0
- package/scripts/lib/session-runner/index.ts +715 -0
- package/scripts/lib/session-runner/transcript.ts +189 -0
- package/scripts/lib/session-runner/types.ts +144 -0
- package/scripts/lib/tool-scoping/index.ts +219 -0
- package/scripts/lib/tool-scoping/parse-agent-tools.ts +207 -0
- package/scripts/lib/tool-scoping/stage-scopes.ts +139 -0
- package/scripts/lib/tool-scoping/types.ts +77 -0
- package/scripts/mcp-servers/gdd-state/schemas/add_blocker.schema.json +67 -0
- package/scripts/mcp-servers/gdd-state/schemas/add_decision.schema.json +68 -0
- package/scripts/mcp-servers/gdd-state/schemas/add_must_have.schema.json +68 -0
- package/scripts/mcp-servers/gdd-state/schemas/checkpoint.schema.json +51 -0
- package/scripts/mcp-servers/gdd-state/schemas/frontmatter_update.schema.json +62 -0
- package/scripts/mcp-servers/gdd-state/schemas/get.schema.json +51 -0
- package/scripts/mcp-servers/gdd-state/schemas/probe_connections.schema.json +75 -0
- package/scripts/mcp-servers/gdd-state/schemas/resolve_blocker.schema.json +66 -0
- package/scripts/mcp-servers/gdd-state/schemas/set_status.schema.json +47 -0
- package/scripts/mcp-servers/gdd-state/schemas/transition_stage.schema.json +70 -0
- package/scripts/mcp-servers/gdd-state/schemas/update_progress.schema.json +58 -0
- package/scripts/mcp-servers/gdd-state/server.ts +288 -0
- package/scripts/mcp-servers/gdd-state/tools/add_blocker.ts +72 -0
- package/scripts/mcp-servers/gdd-state/tools/add_decision.ts +89 -0
- package/scripts/mcp-servers/gdd-state/tools/add_must_have.ts +113 -0
- package/scripts/mcp-servers/gdd-state/tools/checkpoint.ts +60 -0
- package/scripts/mcp-servers/gdd-state/tools/frontmatter_update.ts +91 -0
- package/scripts/mcp-servers/gdd-state/tools/get.ts +51 -0
- package/scripts/mcp-servers/gdd-state/tools/index.ts +51 -0
- package/scripts/mcp-servers/gdd-state/tools/probe_connections.ts +73 -0
- package/scripts/mcp-servers/gdd-state/tools/resolve_blocker.ts +84 -0
- package/scripts/mcp-servers/gdd-state/tools/set_status.ts +54 -0
- package/scripts/mcp-servers/gdd-state/tools/shared.ts +194 -0
- package/scripts/mcp-servers/gdd-state/tools/transition_stage.ts +80 -0
- package/scripts/mcp-servers/gdd-state/tools/update_progress.ts +81 -0
- package/scripts/validate-frontmatter.ts +114 -0
- package/scripts/validate-schemas.ts +401 -0
- package/skills/brief/SKILL.md +15 -6
- package/skills/design/SKILL.md +31 -13
- package/skills/explore/SKILL.md +41 -17
- package/skills/health/SKILL.md +15 -4
- package/skills/optimize/SKILL.md +3 -3
- package/skills/pause/SKILL.md +16 -10
- package/skills/plan/SKILL.md +33 -17
- package/skills/progress/SKILL.md +15 -11
- package/skills/resume/SKILL.md +19 -10
- package/skills/settings/SKILL.md +11 -3
- package/skills/todo/SKILL.md +12 -3
- package/skills/verify/SKILL.md +65 -29
- package/hooks/budget-enforcer.js +0 -329
- package/hooks/context-exhaustion.js +0 -127
- package/hooks/gdd-read-injection-scanner.js +0 -39
- package/scripts/aggregate-agent-metrics.js +0 -173
- package/scripts/validate-frontmatter.cjs +0 -68
- 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
|
+
}
|