@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.
Files changed (93) hide show
  1. package/.claude-plugin/marketplace.json +4 -4
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/CHANGELOG.md +60 -0
  4. package/README.md +12 -0
  5. package/agents/design-reflector.md +13 -0
  6. package/connections/connections.md +3 -0
  7. package/connections/figma.md +2 -0
  8. package/connections/gdd-state.md +186 -0
  9. package/hooks/budget-enforcer.ts +716 -0
  10. package/hooks/context-exhaustion.ts +251 -0
  11. package/hooks/gdd-read-injection-scanner.ts +172 -0
  12. package/hooks/hooks.json +3 -3
  13. package/package.json +19 -6
  14. package/reference/config-schema.md +2 -2
  15. package/reference/error-recovery.md +58 -0
  16. package/reference/registry.json +7 -0
  17. package/reference/schemas/budget.schema.json +42 -0
  18. package/reference/schemas/events.schema.json +55 -0
  19. package/reference/schemas/generated.d.ts +419 -0
  20. package/reference/schemas/iteration-budget.schema.json +36 -0
  21. package/reference/schemas/mcp-gdd-state-tools.schema.json +89 -0
  22. package/reference/schemas/rate-limits.schema.json +31 -0
  23. package/scripts/aggregate-agent-metrics.ts +282 -0
  24. package/scripts/codegen-schema-types.ts +149 -0
  25. package/scripts/lib/error-classifier.cjs +232 -0
  26. package/scripts/lib/error-classifier.d.cts +44 -0
  27. package/scripts/lib/event-stream/emitter.ts +88 -0
  28. package/scripts/lib/event-stream/index.ts +154 -0
  29. package/scripts/lib/event-stream/types.ts +127 -0
  30. package/scripts/lib/event-stream/writer.ts +154 -0
  31. package/scripts/lib/gdd-errors/classification.ts +124 -0
  32. package/scripts/lib/gdd-errors/index.ts +218 -0
  33. package/scripts/lib/gdd-state/gates.ts +216 -0
  34. package/scripts/lib/gdd-state/index.ts +167 -0
  35. package/scripts/lib/gdd-state/lockfile.ts +232 -0
  36. package/scripts/lib/gdd-state/mutator.ts +574 -0
  37. package/scripts/lib/gdd-state/parser.ts +523 -0
  38. package/scripts/lib/gdd-state/types.ts +179 -0
  39. package/scripts/lib/iteration-budget.cjs +205 -0
  40. package/scripts/lib/iteration-budget.d.cts +32 -0
  41. package/scripts/lib/jittered-backoff.cjs +112 -0
  42. package/scripts/lib/jittered-backoff.d.cts +38 -0
  43. package/scripts/lib/lockfile.cjs +177 -0
  44. package/scripts/lib/lockfile.d.cts +21 -0
  45. package/scripts/lib/prompt-sanitizer/index.ts +435 -0
  46. package/scripts/lib/prompt-sanitizer/patterns.ts +173 -0
  47. package/scripts/lib/rate-guard.cjs +365 -0
  48. package/scripts/lib/rate-guard.d.cts +38 -0
  49. package/scripts/mcp-servers/gdd-state/schemas/add_blocker.schema.json +67 -0
  50. package/scripts/mcp-servers/gdd-state/schemas/add_decision.schema.json +68 -0
  51. package/scripts/mcp-servers/gdd-state/schemas/add_must_have.schema.json +68 -0
  52. package/scripts/mcp-servers/gdd-state/schemas/checkpoint.schema.json +51 -0
  53. package/scripts/mcp-servers/gdd-state/schemas/frontmatter_update.schema.json +62 -0
  54. package/scripts/mcp-servers/gdd-state/schemas/get.schema.json +51 -0
  55. package/scripts/mcp-servers/gdd-state/schemas/probe_connections.schema.json +75 -0
  56. package/scripts/mcp-servers/gdd-state/schemas/resolve_blocker.schema.json +66 -0
  57. package/scripts/mcp-servers/gdd-state/schemas/set_status.schema.json +47 -0
  58. package/scripts/mcp-servers/gdd-state/schemas/transition_stage.schema.json +70 -0
  59. package/scripts/mcp-servers/gdd-state/schemas/update_progress.schema.json +58 -0
  60. package/scripts/mcp-servers/gdd-state/server.ts +288 -0
  61. package/scripts/mcp-servers/gdd-state/tools/add_blocker.ts +72 -0
  62. package/scripts/mcp-servers/gdd-state/tools/add_decision.ts +89 -0
  63. package/scripts/mcp-servers/gdd-state/tools/add_must_have.ts +113 -0
  64. package/scripts/mcp-servers/gdd-state/tools/checkpoint.ts +60 -0
  65. package/scripts/mcp-servers/gdd-state/tools/frontmatter_update.ts +91 -0
  66. package/scripts/mcp-servers/gdd-state/tools/get.ts +51 -0
  67. package/scripts/mcp-servers/gdd-state/tools/index.ts +51 -0
  68. package/scripts/mcp-servers/gdd-state/tools/probe_connections.ts +73 -0
  69. package/scripts/mcp-servers/gdd-state/tools/resolve_blocker.ts +84 -0
  70. package/scripts/mcp-servers/gdd-state/tools/set_status.ts +54 -0
  71. package/scripts/mcp-servers/gdd-state/tools/shared.ts +194 -0
  72. package/scripts/mcp-servers/gdd-state/tools/transition_stage.ts +80 -0
  73. package/scripts/mcp-servers/gdd-state/tools/update_progress.ts +81 -0
  74. package/scripts/validate-frontmatter.ts +114 -0
  75. package/scripts/validate-schemas.ts +401 -0
  76. package/skills/brief/SKILL.md +15 -6
  77. package/skills/design/SKILL.md +31 -13
  78. package/skills/explore/SKILL.md +41 -17
  79. package/skills/health/SKILL.md +15 -4
  80. package/skills/optimize/SKILL.md +3 -3
  81. package/skills/pause/SKILL.md +16 -10
  82. package/skills/plan/SKILL.md +33 -17
  83. package/skills/progress/SKILL.md +15 -11
  84. package/skills/resume/SKILL.md +19 -10
  85. package/skills/settings/SKILL.md +11 -3
  86. package/skills/todo/SKILL.md +12 -3
  87. package/skills/verify/SKILL.md +65 -29
  88. package/hooks/budget-enforcer.js +0 -329
  89. package/hooks/context-exhaustion.js +0 -127
  90. package/hooks/gdd-read-injection-scanner.js +0 -39
  91. package/scripts/aggregate-agent-metrics.js +0 -173
  92. package/scripts/validate-frontmatter.cjs +0 -68
  93. 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
+ };