@hegemonart/get-design-done 1.19.6 → 1.20.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 +4 -4
- package/.claude-plugin/plugin.json +2 -2
- package/CHANGELOG.md +60 -0
- package/README.md +12 -0
- package/agents/design-reflector.md +13 -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 +19 -6
- package/reference/config-schema.md +2 -2
- package/reference/error-recovery.md +58 -0
- package/reference/registry.json +7 -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/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 +154 -0
- package/scripts/lib/event-stream/types.ts +127 -0
- package/scripts/lib/event-stream/writer.ts +154 -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/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/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/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,282 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* aggregate-agent-metrics.ts — Incremental per-agent aggregator.
|
|
4
|
+
*
|
|
5
|
+
* Reads: .design/telemetry/costs.jsonl (append-only ledger from
|
|
6
|
+
* hooks/budget-enforcer.js)
|
|
7
|
+
* agents/{agent}.md (frontmatter source for default-tier, parallel-safe,
|
|
8
|
+
* reads-only, typical-duration-seconds)
|
|
9
|
+
* Writes: .design/agent-metrics.json (atomic overwrite via tmp-file + rename)
|
|
10
|
+
* .design/telemetry/phase-totals.json (same, WR-02)
|
|
11
|
+
*
|
|
12
|
+
* Invoked:
|
|
13
|
+
* 1. Detached child of hooks/budget-enforcer.js after every telemetry write.
|
|
14
|
+
* 2. Directly by /gdd:optimize skill as an explicit refresh step.
|
|
15
|
+
* 3. Manually: `node --experimental-strip-types scripts/aggregate-agent-metrics.ts`
|
|
16
|
+
* 4. With `--help` to print usage (used by the Plan 20-00 smoke check).
|
|
17
|
+
*
|
|
18
|
+
* OPT-09 contract: fields must match Phase 11 reflector's expectations.
|
|
19
|
+
*
|
|
20
|
+
* Converted from scripts/aggregate-agent-metrics.js in Plan 20-00 (Tier-1).
|
|
21
|
+
* Behavior preserved verbatim.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
existsSync,
|
|
26
|
+
mkdirSync,
|
|
27
|
+
readFileSync,
|
|
28
|
+
writeFileSync,
|
|
29
|
+
renameSync,
|
|
30
|
+
} from 'node:fs';
|
|
31
|
+
import { join, dirname, basename } from 'node:path';
|
|
32
|
+
|
|
33
|
+
// Generated-type import (unused at runtime, erased by strip-types) to satisfy
|
|
34
|
+
// Plan 20-00's requirement that every Tier-1 TS file participates in the
|
|
35
|
+
// codegen graph. We pick AuthoritySnapshotSchema as a stable anchor and
|
|
36
|
+
// re-export for downstream callers.
|
|
37
|
+
import type { AuthoritySnapshotSchema } from '../reference/schemas/generated.js';
|
|
38
|
+
export type { AuthoritySnapshotSchema };
|
|
39
|
+
|
|
40
|
+
const CWD: string = process.cwd();
|
|
41
|
+
const TELEMETRY_PATH: string = join(CWD, '.design', 'telemetry', 'costs.jsonl');
|
|
42
|
+
const METRICS_PATH: string = join(CWD, '.design', 'agent-metrics.json');
|
|
43
|
+
const PHASE_TOTALS_PATH: string = join(CWD, '.design', 'telemetry', 'phase-totals.json');
|
|
44
|
+
const AGENTS_DIR: string = join(CWD, 'agents');
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Subset of the agent-markdown frontmatter we care about. `null` means the
|
|
48
|
+
* field is absent or unparseable (aggregator is tolerant — degraded mode
|
|
49
|
+
* preferred over hard-fail per OPT-09).
|
|
50
|
+
*/
|
|
51
|
+
interface AgentFrontmatter {
|
|
52
|
+
default_tier: string | null;
|
|
53
|
+
parallel_safe: boolean | null;
|
|
54
|
+
reads_only: boolean | null;
|
|
55
|
+
typical_duration_seconds: number | null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** ---- frontmatter reader (no YAML dep) ---- */
|
|
59
|
+
function readAgentFrontmatter(agentName: string): Partial<AgentFrontmatter> {
|
|
60
|
+
const p: string = join(AGENTS_DIR, `${agentName}.md`);
|
|
61
|
+
if (!existsSync(p)) return {};
|
|
62
|
+
try {
|
|
63
|
+
const content: string = readFileSync(p, 'utf8');
|
|
64
|
+
const fm = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
65
|
+
if (!fm) return {};
|
|
66
|
+
const body: string = fm[1] ?? '';
|
|
67
|
+
const get = (key: string): string | null => {
|
|
68
|
+
const m = body.match(new RegExp(`^${key}:\\s*"?([^"\\n]+)"?`, 'm'));
|
|
69
|
+
return m && m[1] !== undefined ? m[1].trim() : null;
|
|
70
|
+
};
|
|
71
|
+
const defaultTier: string | null = get('default-tier');
|
|
72
|
+
const parallelSafe: string | null = get('parallel-safe');
|
|
73
|
+
const readsOnly: string | null = get('reads-only');
|
|
74
|
+
const typicalDuration: string | null = get('typical-duration-seconds');
|
|
75
|
+
return {
|
|
76
|
+
default_tier: defaultTier ?? null,
|
|
77
|
+
parallel_safe: parallelSafe === null ? null : /^(true|yes)$/i.test(parallelSafe),
|
|
78
|
+
reads_only: readsOnly === null ? null : /^(true|yes)$/i.test(readsOnly),
|
|
79
|
+
typical_duration_seconds:
|
|
80
|
+
typicalDuration === null ? null : Number(typicalDuration) || null,
|
|
81
|
+
};
|
|
82
|
+
} catch {
|
|
83
|
+
return {};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Shape of a single row in .design/telemetry/costs.jsonl. Mirrors the OPT-09
|
|
89
|
+
* schema: nine mandatory fields + four optional diagnostic fields. Unknown
|
|
90
|
+
* keys are tolerated (Phase 11 reflector ignores them).
|
|
91
|
+
*/
|
|
92
|
+
export interface CostRow {
|
|
93
|
+
ts?: string;
|
|
94
|
+
agent?: string;
|
|
95
|
+
tier?: string;
|
|
96
|
+
tokens_in?: number | string;
|
|
97
|
+
tokens_out?: number | string;
|
|
98
|
+
cache_hit?: boolean;
|
|
99
|
+
est_cost_usd?: number | string;
|
|
100
|
+
cycle?: string;
|
|
101
|
+
phase?: string;
|
|
102
|
+
// Optional / diagnostic
|
|
103
|
+
tier_downgraded?: boolean;
|
|
104
|
+
enforcement_mode?: string;
|
|
105
|
+
lazy_skipped?: boolean;
|
|
106
|
+
block_reason?: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** ---- telemetry reader ---- */
|
|
110
|
+
function readTelemetryRows(): CostRow[] {
|
|
111
|
+
if (!existsSync(TELEMETRY_PATH)) return [];
|
|
112
|
+
const raw: string = readFileSync(TELEMETRY_PATH, 'utf8');
|
|
113
|
+
const out: CostRow[] = [];
|
|
114
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
115
|
+
if (!line.trim()) continue;
|
|
116
|
+
try {
|
|
117
|
+
out.push(JSON.parse(line) as CostRow);
|
|
118
|
+
} catch {
|
|
119
|
+
// tolerant: skip malformed lines (partial write, truncation)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Per-agent roll-up accumulator. */
|
|
126
|
+
interface AgentAccumulator {
|
|
127
|
+
total_spawns: number;
|
|
128
|
+
total_cost_usd: number;
|
|
129
|
+
total_tokens_in: number;
|
|
130
|
+
total_tokens_out: number;
|
|
131
|
+
cache_hits: number;
|
|
132
|
+
lazy_skips: number;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Final per-agent shape written to .design/agent-metrics.json. */
|
|
136
|
+
export interface AgentMetrics {
|
|
137
|
+
typical_duration_seconds: number | null | undefined;
|
|
138
|
+
default_tier: string | null | undefined;
|
|
139
|
+
parallel_safe: boolean | null | undefined;
|
|
140
|
+
reads_only: boolean | null | undefined;
|
|
141
|
+
total_spawns: number;
|
|
142
|
+
total_cost_usd: number;
|
|
143
|
+
total_tokens_in: number;
|
|
144
|
+
total_tokens_out: number;
|
|
145
|
+
cache_hit_rate: number;
|
|
146
|
+
lazy_skip_rate: number;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** ---- aggregator ---- */
|
|
150
|
+
function aggregate(rows: readonly CostRow[]): Record<string, AgentMetrics> {
|
|
151
|
+
const byAgent = new Map<string, AgentAccumulator>();
|
|
152
|
+
for (const r of rows) {
|
|
153
|
+
// Blocked rows represent a spawn that was denied at the hook — the agent
|
|
154
|
+
// never actually ran, so it must not contribute to spawn counts, cost, or
|
|
155
|
+
// token totals. Skip them here (mirror of the filter in aggregateByPhase).
|
|
156
|
+
if (r.block_reason) continue;
|
|
157
|
+
const agent: string = r.agent ?? 'unknown';
|
|
158
|
+
let a = byAgent.get(agent);
|
|
159
|
+
if (!a) {
|
|
160
|
+
a = {
|
|
161
|
+
total_spawns: 0,
|
|
162
|
+
total_cost_usd: 0,
|
|
163
|
+
total_tokens_in: 0,
|
|
164
|
+
total_tokens_out: 0,
|
|
165
|
+
cache_hits: 0,
|
|
166
|
+
lazy_skips: 0,
|
|
167
|
+
};
|
|
168
|
+
byAgent.set(agent, a);
|
|
169
|
+
}
|
|
170
|
+
a.total_spawns += 1;
|
|
171
|
+
a.total_cost_usd += Number(r.est_cost_usd ?? 0);
|
|
172
|
+
a.total_tokens_in += Number(r.tokens_in ?? 0);
|
|
173
|
+
a.total_tokens_out += Number(r.tokens_out ?? 0);
|
|
174
|
+
if (r.cache_hit === true) a.cache_hits += 1;
|
|
175
|
+
if (r.lazy_skipped === true) a.lazy_skips += 1;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const out: Record<string, AgentMetrics> = {};
|
|
179
|
+
for (const [agent, a] of byAgent.entries()) {
|
|
180
|
+
const fm = readAgentFrontmatter(agent);
|
|
181
|
+
const spawns: number = a.total_spawns || 1; // guard div-by-zero
|
|
182
|
+
out[agent] = {
|
|
183
|
+
typical_duration_seconds: fm.typical_duration_seconds,
|
|
184
|
+
default_tier: fm.default_tier,
|
|
185
|
+
parallel_safe: fm.parallel_safe,
|
|
186
|
+
reads_only: fm.reads_only,
|
|
187
|
+
total_spawns: a.total_spawns,
|
|
188
|
+
total_cost_usd: Number(a.total_cost_usd.toFixed(6)),
|
|
189
|
+
total_tokens_in: a.total_tokens_in,
|
|
190
|
+
total_tokens_out: a.total_tokens_out,
|
|
191
|
+
cache_hit_rate: Number((a.cache_hits / spawns).toFixed(4)),
|
|
192
|
+
lazy_skip_rate: Number((a.lazy_skips / spawns).toFixed(4)),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** ---- atomic write ---- */
|
|
199
|
+
function writeAtomic(filePath: string, content: string): void {
|
|
200
|
+
const dir: string = dirname(filePath);
|
|
201
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
202
|
+
const tmp: string = join(
|
|
203
|
+
dir,
|
|
204
|
+
`.${basename(filePath)}.${process.pid}.${Date.now()}.tmp`,
|
|
205
|
+
);
|
|
206
|
+
writeFileSync(tmp, content, 'utf8');
|
|
207
|
+
renameSync(tmp, filePath);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** ---- phase totals aggregator (WR-02: avoids full JSONL replay in budget enforcer) ---- */
|
|
211
|
+
function aggregateByPhase(rows: readonly CostRow[]): Record<string, number> {
|
|
212
|
+
const byPhase: Record<string, number> = {};
|
|
213
|
+
for (const r of rows) {
|
|
214
|
+
// Blocked rows represent spawns that were denied by the hook — the agent
|
|
215
|
+
// never ran, so their est_cost_usd must not inflate cumulative phase spend.
|
|
216
|
+
// Counting them would make future hard-block and soft-threshold checks
|
|
217
|
+
// stricter than intended on every repeat cap hit.
|
|
218
|
+
if (r.block_reason) continue;
|
|
219
|
+
const phase: string = r.phase ?? 'unknown';
|
|
220
|
+
byPhase[phase] = (byPhase[phase] ?? 0) + Number(r.est_cost_usd ?? 0);
|
|
221
|
+
}
|
|
222
|
+
// Round to 6dp to match per-agent precision
|
|
223
|
+
for (const k of Object.keys(byPhase)) {
|
|
224
|
+
const v: number = byPhase[k] ?? 0;
|
|
225
|
+
byPhase[k] = Number(v.toFixed(6));
|
|
226
|
+
}
|
|
227
|
+
return byPhase;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** ---- usage / --help ---- */
|
|
231
|
+
function printHelp(): void {
|
|
232
|
+
console.log(
|
|
233
|
+
`aggregate-agent-metrics.ts — Aggregate per-agent telemetry from .design/telemetry/costs.jsonl.\n` +
|
|
234
|
+
`\n` +
|
|
235
|
+
`Usage:\n` +
|
|
236
|
+
` node --experimental-strip-types scripts/aggregate-agent-metrics.ts\n` +
|
|
237
|
+
` node --experimental-strip-types scripts/aggregate-agent-metrics.ts --help\n` +
|
|
238
|
+
`\n` +
|
|
239
|
+
`Reads: .design/telemetry/costs.jsonl\n` +
|
|
240
|
+
` agents/<agent>.md (frontmatter)\n` +
|
|
241
|
+
`Writes: .design/agent-metrics.json\n` +
|
|
242
|
+
` .design/telemetry/phase-totals.json\n` +
|
|
243
|
+
`\n` +
|
|
244
|
+
`Invoked:\n` +
|
|
245
|
+
` - Detached child of hooks/budget-enforcer.js after every telemetry row.\n` +
|
|
246
|
+
` - Directly by /gdd:optimize as an explicit refresh step.\n` +
|
|
247
|
+
` - Manually, on demand.\n`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** ---- main ---- */
|
|
252
|
+
function main(): void {
|
|
253
|
+
if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|
254
|
+
printHelp();
|
|
255
|
+
process.exit(0);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const rows: CostRow[] = readTelemetryRows();
|
|
259
|
+
const agents = aggregate(rows);
|
|
260
|
+
const payload = {
|
|
261
|
+
generated_at: new Date().toISOString(),
|
|
262
|
+
agents,
|
|
263
|
+
};
|
|
264
|
+
writeAtomic(METRICS_PATH, JSON.stringify(payload, null, 2) + '\n');
|
|
265
|
+
// Write lightweight phase-totals.json so budget-enforcer can read phase
|
|
266
|
+
// spend in O(1) without replaying the full JSONL on every agent spawn
|
|
267
|
+
// (WR-02).
|
|
268
|
+
const phaseTotals = {
|
|
269
|
+
generated_at: new Date().toISOString(),
|
|
270
|
+
totals: aggregateByPhase(rows),
|
|
271
|
+
};
|
|
272
|
+
writeAtomic(PHASE_TOTALS_PATH, JSON.stringify(phaseTotals, null, 2) + '\n');
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
main();
|
|
277
|
+
} catch (err) {
|
|
278
|
+
// Fail open: aggregator must never block the hook or /gdd:optimize flow.
|
|
279
|
+
const msg: string = err instanceof Error ? err.message : String(err);
|
|
280
|
+
process.stderr.write(`aggregate-agent-metrics: ${msg}\n`);
|
|
281
|
+
process.exit(0);
|
|
282
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* codegen-schema-types.ts — Generate TypeScript interface declarations from
|
|
4
|
+
* every Draft-07 JSON Schema under `reference/schemas/*.schema.json`.
|
|
5
|
+
*
|
|
6
|
+
* Output: `reference/schemas/generated.d.ts` — single file containing one
|
|
7
|
+
* `export interface XSchema` per schema, named from the filename stem.
|
|
8
|
+
*
|
|
9
|
+
* Invoked: `npm run codegen:schemas` (requires repo-root cwd, which npm sets
|
|
10
|
+
* automatically). If invoked directly, `--repo-root <path>` can override.
|
|
11
|
+
*
|
|
12
|
+
* Exit codes:
|
|
13
|
+
* 0 — success
|
|
14
|
+
* 1 — any read/parse/compile failure
|
|
15
|
+
*/
|
|
16
|
+
import { readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
17
|
+
import { resolve, join, dirname, basename } from 'node:path';
|
|
18
|
+
import { compile } from 'json-schema-to-typescript';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolve the repo root. Priority:
|
|
22
|
+
* 1. `--repo-root <path>` CLI arg.
|
|
23
|
+
* 2. `process.cwd()` — npm scripts run from the package root, so this is
|
|
24
|
+
* the common case.
|
|
25
|
+
*
|
|
26
|
+
* We deliberately avoid `import.meta.url` / `__dirname` so this module stays
|
|
27
|
+
* valid under both CommonJS type-checking (Node16 + no package "type") and
|
|
28
|
+
* the Node 22+ `--experimental-strip-types` runtime, which auto-detects ESM.
|
|
29
|
+
*/
|
|
30
|
+
function resolveRepoRoot(): string {
|
|
31
|
+
const argv = process.argv.slice(2);
|
|
32
|
+
const idx = argv.indexOf('--repo-root');
|
|
33
|
+
if (idx !== -1 && idx + 1 < argv.length) {
|
|
34
|
+
const v = argv[idx + 1];
|
|
35
|
+
if (typeof v === 'string' && v.length > 0) return resolve(v);
|
|
36
|
+
}
|
|
37
|
+
return resolve(process.cwd());
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const REPO_ROOT = resolveRepoRoot();
|
|
41
|
+
const SCHEMA_DIR = join(REPO_ROOT, 'reference', 'schemas');
|
|
42
|
+
const OUTPUT_PATH = join(SCHEMA_DIR, 'generated.d.ts');
|
|
43
|
+
|
|
44
|
+
const HEADER =
|
|
45
|
+
'// AUTO-GENERATED from reference/schemas/*.schema.json — DO NOT EDIT.\n' +
|
|
46
|
+
'// Regenerate: npm run codegen:schemas\n' +
|
|
47
|
+
'/* eslint-disable */\n';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Map a schema filename stem (e.g. "authority-snapshot" from
|
|
51
|
+
* "authority-snapshot.schema.json") to the canonical interface name per
|
|
52
|
+
* Plan 20-00: PascalCase + `Schema` suffix. Hyphens split word boundaries.
|
|
53
|
+
*/
|
|
54
|
+
function stemToInterfaceName(stem: string): string {
|
|
55
|
+
const pascal = stem
|
|
56
|
+
.split(/[-_.]/)
|
|
57
|
+
.filter(Boolean)
|
|
58
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
|
59
|
+
.join('');
|
|
60
|
+
return `${pascal}Schema`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function main(): Promise<void> {
|
|
64
|
+
const entries = readdirSync(SCHEMA_DIR)
|
|
65
|
+
.filter((f) => f.endsWith('.schema.json'))
|
|
66
|
+
.sort();
|
|
67
|
+
|
|
68
|
+
if (entries.length === 0) {
|
|
69
|
+
console.error(`codegen-schema-types: no *.schema.json files found in ${SCHEMA_DIR}`);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const chunks: string[] = [HEADER];
|
|
74
|
+
|
|
75
|
+
for (const file of entries) {
|
|
76
|
+
const stem = basename(file, '.schema.json');
|
|
77
|
+
const interfaceName = stemToInterfaceName(stem);
|
|
78
|
+
const schemaPath = join(SCHEMA_DIR, file);
|
|
79
|
+
|
|
80
|
+
let schema: unknown;
|
|
81
|
+
try {
|
|
82
|
+
schema = JSON.parse(readFileSync(schemaPath, 'utf8'));
|
|
83
|
+
} catch (err) {
|
|
84
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
85
|
+
console.error(`codegen-schema-types: failed to parse ${file}: ${msg}`);
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
// compile() expects a JSONSchema; we pass our parsed object.
|
|
91
|
+
// bannerComment: '' — we add our own header once at the top.
|
|
92
|
+
const ts = await compile(schema as Parameters<typeof compile>[0], interfaceName, {
|
|
93
|
+
bannerComment: '',
|
|
94
|
+
additionalProperties: false,
|
|
95
|
+
style: { singleQuote: true, trailingComma: 'all' },
|
|
96
|
+
unreachableDefinitions: false,
|
|
97
|
+
});
|
|
98
|
+
chunks.push(`// ---- ${file} ----\n`);
|
|
99
|
+
// json-schema-to-typescript emits the top-level as the requested name,
|
|
100
|
+
// but when the schema's own `title` differs it may prefix. We rename
|
|
101
|
+
// the top-level export to our canonical name for stability.
|
|
102
|
+
const renamed = ensureExportInterface(ts, interfaceName);
|
|
103
|
+
chunks.push(renamed);
|
|
104
|
+
chunks.push('\n');
|
|
105
|
+
} catch (err) {
|
|
106
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
107
|
+
console.error(`codegen-schema-types: failed to compile ${file}: ${msg}`);
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
mkdirSync(dirname(OUTPUT_PATH), { recursive: true });
|
|
113
|
+
writeFileSync(OUTPUT_PATH, chunks.join(''), 'utf8');
|
|
114
|
+
console.log(
|
|
115
|
+
`codegen-schema-types: wrote ${OUTPUT_PATH} (${entries.length} schema(s))`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Ensure that the compiled TS output exports an interface/type alias with the
|
|
121
|
+
* exact canonical name. `json-schema-to-typescript` normally emits this
|
|
122
|
+
* already, but some schemas whose `title` field contains non-identifier
|
|
123
|
+
* characters (e.g. ".design/config.json") get their interface named from a
|
|
124
|
+
* cleaned title rather than our requested name. We add an `export` alias at
|
|
125
|
+
* the end so every generated chunk guarantees `export interface XSchema` (or
|
|
126
|
+
* `export type XSchema = ...`) is available.
|
|
127
|
+
*/
|
|
128
|
+
function ensureExportInterface(ts: string, canonical: string): string {
|
|
129
|
+
const hasCanonical = new RegExp(
|
|
130
|
+
`export\\s+(interface|type)\\s+${canonical}\\b`,
|
|
131
|
+
).test(ts);
|
|
132
|
+
if (hasCanonical) return ts;
|
|
133
|
+
|
|
134
|
+
const firstExport = ts.match(
|
|
135
|
+
/export\s+(interface|type)\s+([A-Za-z_][A-Za-z0-9_]*)/,
|
|
136
|
+
);
|
|
137
|
+
if (!firstExport) {
|
|
138
|
+
return ts + `\nexport type ${canonical} = unknown;\n`;
|
|
139
|
+
}
|
|
140
|
+
const firstName = firstExport[2];
|
|
141
|
+
if (firstName === canonical) return ts;
|
|
142
|
+
return ts + `\nexport type ${canonical} = ${firstName};\n`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
main().catch((err) => {
|
|
146
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
147
|
+
console.error(`codegen-schema-types: ${msg}`);
|
|
148
|
+
process.exit(1);
|
|
149
|
+
});
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// scripts/lib/error-classifier.cjs
|
|
2
|
+
//
|
|
3
|
+
// Plan 20-14 — classify raw errors into a recovery-action vocabulary.
|
|
4
|
+
//
|
|
5
|
+
// Plan 20-04 shipped the GDDError taxonomy (ValidationError /
|
|
6
|
+
// StateConflictError / OperationFailedError). This module is one layer
|
|
7
|
+
// lower: it maps LOW-LEVEL errors (fetch rejections, Anthropic API
|
|
8
|
+
// responses, Node errno rejections) onto a small enum that recovery
|
|
9
|
+
// code can switch on without needing to know which SDK produced the
|
|
10
|
+
// error.
|
|
11
|
+
//
|
|
12
|
+
// Consumers (e.g. budget-enforcer retry, figma probe retry, MCP
|
|
13
|
+
// transport) check `classify(err).reason` and decide whether to retry,
|
|
14
|
+
// compress, surface, or fail.
|
|
15
|
+
//
|
|
16
|
+
// Classification rules — evaluated in order; first match wins:
|
|
17
|
+
// 1. HTTP 429 OR message ~ /rate.?limit/ → RATE_LIMITED (retryable)
|
|
18
|
+
// 2. HTTP 413 OR /context.?(length|window|overflow)/
|
|
19
|
+
// OR /context_length_exceeded/ → CONTEXT_OVERFLOW (retryable with compression)
|
|
20
|
+
// 3. HTTP 401/403 → AUTH_ERROR (NOT retryable)
|
|
21
|
+
// 4. /tool not found|unknown tool/ → TOOL_NOT_FOUND (NOT retryable)
|
|
22
|
+
// 5. HTTP 5xx OR errno ECONNRESET/ETIMEDOUT/EAI_AGAIN/ECONNREFUSED
|
|
23
|
+
// OR /network|timeout|socket/ → NETWORK_TRANSIENT (retryable)
|
|
24
|
+
// 6. HTTP 4xx (non-auth, non-rate, non-overflow) → VALIDATION (NOT retryable)
|
|
25
|
+
// 7. HTTP >= 400 with no other match → NETWORK_PERMANENT (NOT retryable)
|
|
26
|
+
// 8. Anything else (null, undefined, plain Error) → UNKNOWN (NOT retryable)
|
|
27
|
+
//
|
|
28
|
+
// Rule order matters: the tool-not-found string can land inside
|
|
29
|
+
// otherwise-validation-shaped errors, so it's checked early. Anthropic
|
|
30
|
+
// "context_length_exceeded" returns HTTP 400 in some surfaces and HTTP
|
|
31
|
+
// 413 in others — rule 2 catches it either way.
|
|
32
|
+
//
|
|
33
|
+
// Reference: `reference/error-recovery.md` describes the protocol layer
|
|
34
|
+
// that sits on top of this module.
|
|
35
|
+
|
|
36
|
+
'use strict';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @readonly
|
|
40
|
+
* @enum {string}
|
|
41
|
+
*/
|
|
42
|
+
const FailoverReason = Object.freeze({
|
|
43
|
+
RATE_LIMITED: 'rate_limited',
|
|
44
|
+
CONTEXT_OVERFLOW: 'context_overflow',
|
|
45
|
+
AUTH_ERROR: 'auth_error',
|
|
46
|
+
NETWORK_TRANSIENT: 'network_transient',
|
|
47
|
+
NETWORK_PERMANENT: 'network_permanent',
|
|
48
|
+
TOOL_NOT_FOUND: 'tool_not_found',
|
|
49
|
+
VALIDATION: 'validation',
|
|
50
|
+
UNKNOWN: 'unknown',
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/** Suggested actions per reason — keyed by FailoverReason. */
|
|
54
|
+
const SUGGESTED_ACTIONS = Object.freeze({
|
|
55
|
+
[FailoverReason.RATE_LIMITED]:
|
|
56
|
+
'consult scripts/lib/rate-guard.cjs → blockUntilReady(provider); then retry with scripts/lib/jittered-backoff.cjs',
|
|
57
|
+
[FailoverReason.CONTEXT_OVERFLOW]:
|
|
58
|
+
'compress context (drop oldest non-system turns; target 50% reduction) and retry once',
|
|
59
|
+
[FailoverReason.AUTH_ERROR]:
|
|
60
|
+
'surface to user — do not retry; credentials or OAuth session need refresh',
|
|
61
|
+
[FailoverReason.NETWORK_TRANSIENT]:
|
|
62
|
+
'retry with scripts/lib/jittered-backoff.cjs; max 3 attempts',
|
|
63
|
+
[FailoverReason.NETWORK_PERMANENT]:
|
|
64
|
+
'surface to user; do not retry — endpoint is wrong or resource is gone',
|
|
65
|
+
[FailoverReason.TOOL_NOT_FOUND]:
|
|
66
|
+
'do not retry; verify tool name and MCP registration',
|
|
67
|
+
[FailoverReason.VALIDATION]:
|
|
68
|
+
'do not retry same input; surface validation detail to caller',
|
|
69
|
+
[FailoverReason.UNKNOWN]:
|
|
70
|
+
'surface to user — cannot determine safe recovery action',
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/** Which reasons are safe to retry by policy. */
|
|
74
|
+
const RETRYABLE = Object.freeze({
|
|
75
|
+
[FailoverReason.RATE_LIMITED]: true,
|
|
76
|
+
[FailoverReason.CONTEXT_OVERFLOW]: true,
|
|
77
|
+
[FailoverReason.NETWORK_TRANSIENT]: true,
|
|
78
|
+
[FailoverReason.AUTH_ERROR]: false,
|
|
79
|
+
[FailoverReason.NETWORK_PERMANENT]: false,
|
|
80
|
+
[FailoverReason.TOOL_NOT_FOUND]: false,
|
|
81
|
+
[FailoverReason.VALIDATION]: false,
|
|
82
|
+
[FailoverReason.UNKNOWN]: false,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
/** Extract a numeric HTTP status from an error shape. Returns null on miss. */
|
|
86
|
+
function statusOf(err) {
|
|
87
|
+
if (err === null || err === undefined) return null;
|
|
88
|
+
if (typeof err !== 'object') return null;
|
|
89
|
+
// Direct status / statusCode field.
|
|
90
|
+
if (Number.isFinite(err.status)) return Number(err.status);
|
|
91
|
+
if (Number.isFinite(err.statusCode)) return Number(err.statusCode);
|
|
92
|
+
// Fetch / node-fetch responses wrap status under .response.
|
|
93
|
+
if (err.response && typeof err.response === 'object') {
|
|
94
|
+
if (Number.isFinite(err.response.status)) return Number(err.response.status);
|
|
95
|
+
if (Number.isFinite(err.response.statusCode)) return Number(err.response.statusCode);
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Extract a string message; tolerant of anything. */
|
|
101
|
+
function messageOf(err) {
|
|
102
|
+
if (err === null || err === undefined) return '';
|
|
103
|
+
if (typeof err === 'string') return err;
|
|
104
|
+
if (typeof err === 'object') {
|
|
105
|
+
// Gather every string-ish field in priority order; join with ' | ' so
|
|
106
|
+
// classification regexes can match against any of them without the
|
|
107
|
+
// caller needing to know which SDK shaped the error. OpenAI-style
|
|
108
|
+
// wraps the interesting discriminator in `error.code` while keeping
|
|
109
|
+
// a generic top-level message; the join lets both contribute.
|
|
110
|
+
const parts = [];
|
|
111
|
+
if (typeof err.message === 'string' && err.message.length > 0) parts.push(err.message);
|
|
112
|
+
if (err.error && typeof err.error === 'object') {
|
|
113
|
+
if (typeof err.error.code === 'string') parts.push(err.error.code);
|
|
114
|
+
if (typeof err.error.type === 'string') parts.push(err.error.type);
|
|
115
|
+
if (typeof err.error.message === 'string') parts.push(err.error.message);
|
|
116
|
+
}
|
|
117
|
+
// Only use top-level `code` when it is NOT an errno (errnoOf handles
|
|
118
|
+
// those). Errnos always match /^E[A-Z0-9_]+$/, so filter them out.
|
|
119
|
+
if (typeof err.code === 'string' && !/^E[A-Z0-9_]+$/.test(err.code)) {
|
|
120
|
+
parts.push(err.code);
|
|
121
|
+
}
|
|
122
|
+
if (parts.length > 0) return parts.join(' | ');
|
|
123
|
+
}
|
|
124
|
+
return '';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Extract a low-level errno code (ECONNRESET, ETIMEDOUT, ...). */
|
|
128
|
+
function errnoOf(err) {
|
|
129
|
+
if (err === null || err === undefined || typeof err !== 'object') return '';
|
|
130
|
+
if (typeof err.code === 'string' && /^E[A-Z0-9_]+$/.test(err.code)) return err.code;
|
|
131
|
+
// fetch native in newer Node wraps the cause
|
|
132
|
+
if (err.cause && typeof err.cause === 'object') {
|
|
133
|
+
const code = err.cause.code;
|
|
134
|
+
if (typeof code === 'string' && /^E[A-Z0-9_]+$/.test(code)) return code;
|
|
135
|
+
}
|
|
136
|
+
return '';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Classify a raw error into a {@link FailoverReason}.
|
|
141
|
+
*
|
|
142
|
+
* @param {unknown} err
|
|
143
|
+
* @returns {{reason: string, retryable: boolean, suggestedAction: string, raw: unknown}}
|
|
144
|
+
*/
|
|
145
|
+
function classify(err) {
|
|
146
|
+
const status = statusOf(err);
|
|
147
|
+
const message = messageOf(err).toLowerCase();
|
|
148
|
+
const errno = errnoOf(err);
|
|
149
|
+
|
|
150
|
+
// 1. Rate limit.
|
|
151
|
+
if (status === 429 || /rate.?limit/.test(message) || /too many requests/.test(message)) {
|
|
152
|
+
return build(FailoverReason.RATE_LIMITED, err);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 2. Context overflow. Anthropic returns 400 with type=invalid_request and
|
|
156
|
+
// message containing "prompt is too long"; OpenAI returns 400 with
|
|
157
|
+
// code=context_length_exceeded; some edge surfaces use 413.
|
|
158
|
+
if (
|
|
159
|
+
status === 413 ||
|
|
160
|
+
/context_length_exceeded/.test(message) ||
|
|
161
|
+
/context.{0,10}(length|window|overflow|too.?long)/.test(message) ||
|
|
162
|
+
/prompt is too long/.test(message) ||
|
|
163
|
+
/maximum context length/.test(message)
|
|
164
|
+
) {
|
|
165
|
+
return build(FailoverReason.CONTEXT_OVERFLOW, err);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 3. Auth.
|
|
169
|
+
if (status === 401 || status === 403) {
|
|
170
|
+
return build(FailoverReason.AUTH_ERROR, err);
|
|
171
|
+
}
|
|
172
|
+
if (
|
|
173
|
+
/not authenticated/.test(message) ||
|
|
174
|
+
/invalid[_ ]api[_ ]key/.test(message) ||
|
|
175
|
+
/unauthorized/.test(message) ||
|
|
176
|
+
/authentication/.test(message)
|
|
177
|
+
) {
|
|
178
|
+
return build(FailoverReason.AUTH_ERROR, err);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 4. Tool not found.
|
|
182
|
+
if (/tool not found/.test(message) || /unknown tool/.test(message) || /no such tool/.test(message)) {
|
|
183
|
+
return build(FailoverReason.TOOL_NOT_FOUND, err);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 5. Network transient: 5xx or low-level errno.
|
|
187
|
+
if (typeof status === 'number' && status >= 500 && status < 600) {
|
|
188
|
+
return build(FailoverReason.NETWORK_TRANSIENT, err);
|
|
189
|
+
}
|
|
190
|
+
if (
|
|
191
|
+
errno === 'ECONNRESET' ||
|
|
192
|
+
errno === 'ETIMEDOUT' ||
|
|
193
|
+
errno === 'EAI_AGAIN' ||
|
|
194
|
+
errno === 'ECONNREFUSED' ||
|
|
195
|
+
errno === 'ENETUNREACH' ||
|
|
196
|
+
errno === 'EPIPE'
|
|
197
|
+
) {
|
|
198
|
+
return build(FailoverReason.NETWORK_TRANSIENT, err);
|
|
199
|
+
}
|
|
200
|
+
if (/\bsocket\b/.test(message) || /network/.test(message) || /\btimeout\b/.test(message)) {
|
|
201
|
+
return build(FailoverReason.NETWORK_TRANSIENT, err);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 6. Other 4xx → validation.
|
|
205
|
+
if (typeof status === 'number' && status >= 400 && status < 500) {
|
|
206
|
+
return build(FailoverReason.VALIDATION, err);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// 7. Other >= 400 (e.g. 6xx exotic gateway codes).
|
|
210
|
+
if (typeof status === 'number' && status >= 400) {
|
|
211
|
+
return build(FailoverReason.NETWORK_PERMANENT, err);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// 8. Fallthrough.
|
|
215
|
+
return build(FailoverReason.UNKNOWN, err);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function build(reason, raw) {
|
|
219
|
+
return {
|
|
220
|
+
reason,
|
|
221
|
+
retryable: RETRYABLE[reason] === true,
|
|
222
|
+
suggestedAction: SUGGESTED_ACTIONS[reason],
|
|
223
|
+
raw,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
module.exports = {
|
|
228
|
+
FailoverReason,
|
|
229
|
+
classify,
|
|
230
|
+
SUGGESTED_ACTIONS,
|
|
231
|
+
RETRYABLE,
|
|
232
|
+
};
|