@hegemonart/get-design-done 1.19.6 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/.claude-plugin/marketplace.json +11 -14
  2. package/.claude-plugin/plugin.json +9 -32
  3. package/CHANGELOG.md +138 -0
  4. package/README.md +54 -1
  5. package/agents/design-reflector.md +13 -0
  6. package/bin/gdd-sdk +55 -0
  7. package/connections/connections.md +3 -0
  8. package/connections/figma.md +2 -0
  9. package/connections/gdd-state.md +186 -0
  10. package/hooks/budget-enforcer.ts +716 -0
  11. package/hooks/context-exhaustion.ts +251 -0
  12. package/hooks/gdd-read-injection-scanner.ts +172 -0
  13. package/hooks/hooks.json +3 -3
  14. package/package.json +32 -51
  15. package/reference/codex-tools.md +53 -0
  16. package/reference/config-schema.md +2 -2
  17. package/reference/error-recovery.md +58 -0
  18. package/reference/gemini-tools.md +53 -0
  19. package/reference/registry.json +21 -0
  20. package/reference/schemas/budget.schema.json +42 -0
  21. package/reference/schemas/events.schema.json +55 -0
  22. package/reference/schemas/generated.d.ts +419 -0
  23. package/reference/schemas/iteration-budget.schema.json +36 -0
  24. package/reference/schemas/mcp-gdd-state-tools.schema.json +89 -0
  25. package/reference/schemas/rate-limits.schema.json +31 -0
  26. package/scripts/aggregate-agent-metrics.ts +282 -0
  27. package/scripts/codegen-schema-types.ts +149 -0
  28. package/scripts/e2e/run-headless.ts +514 -0
  29. package/scripts/lib/cli/commands/audit.ts +382 -0
  30. package/scripts/lib/cli/commands/init.ts +217 -0
  31. package/scripts/lib/cli/commands/query.ts +329 -0
  32. package/scripts/lib/cli/commands/run.ts +656 -0
  33. package/scripts/lib/cli/commands/stage.ts +468 -0
  34. package/scripts/lib/cli/index.ts +167 -0
  35. package/scripts/lib/cli/parse-args.ts +336 -0
  36. package/scripts/lib/context-engine/index.ts +116 -0
  37. package/scripts/lib/context-engine/manifest.ts +69 -0
  38. package/scripts/lib/context-engine/truncate.ts +282 -0
  39. package/scripts/lib/context-engine/types.ts +59 -0
  40. package/scripts/lib/discuss-parallel-runner/aggregator.ts +448 -0
  41. package/scripts/lib/discuss-parallel-runner/discussants.ts +430 -0
  42. package/scripts/lib/discuss-parallel-runner/index.ts +223 -0
  43. package/scripts/lib/discuss-parallel-runner/types.ts +184 -0
  44. package/scripts/lib/error-classifier.cjs +232 -0
  45. package/scripts/lib/error-classifier.d.cts +44 -0
  46. package/scripts/lib/event-stream/emitter.ts +88 -0
  47. package/scripts/lib/event-stream/index.ts +164 -0
  48. package/scripts/lib/event-stream/types.ts +127 -0
  49. package/scripts/lib/event-stream/writer.ts +154 -0
  50. package/scripts/lib/explore-parallel-runner/index.ts +294 -0
  51. package/scripts/lib/explore-parallel-runner/mappers.ts +290 -0
  52. package/scripts/lib/explore-parallel-runner/synthesizer.ts +295 -0
  53. package/scripts/lib/explore-parallel-runner/types.ts +139 -0
  54. package/scripts/lib/gdd-errors/classification.ts +124 -0
  55. package/scripts/lib/gdd-errors/index.ts +218 -0
  56. package/scripts/lib/gdd-state/gates.ts +216 -0
  57. package/scripts/lib/gdd-state/index.ts +167 -0
  58. package/scripts/lib/gdd-state/lockfile.ts +232 -0
  59. package/scripts/lib/gdd-state/mutator.ts +574 -0
  60. package/scripts/lib/gdd-state/parser.ts +523 -0
  61. package/scripts/lib/gdd-state/types.ts +179 -0
  62. package/scripts/lib/harness/detect.ts +90 -0
  63. package/scripts/lib/harness/index.ts +64 -0
  64. package/scripts/lib/harness/tool-map.ts +142 -0
  65. package/scripts/lib/init-runner/index.ts +396 -0
  66. package/scripts/lib/init-runner/researchers.ts +245 -0
  67. package/scripts/lib/init-runner/scaffold.ts +224 -0
  68. package/scripts/lib/init-runner/synthesizer.ts +224 -0
  69. package/scripts/lib/init-runner/types.ts +143 -0
  70. package/scripts/lib/iteration-budget.cjs +205 -0
  71. package/scripts/lib/iteration-budget.d.cts +32 -0
  72. package/scripts/lib/jittered-backoff.cjs +112 -0
  73. package/scripts/lib/jittered-backoff.d.cts +38 -0
  74. package/scripts/lib/lockfile.cjs +177 -0
  75. package/scripts/lib/lockfile.d.cts +21 -0
  76. package/scripts/lib/logger/index.ts +251 -0
  77. package/scripts/lib/logger/sinks.ts +269 -0
  78. package/scripts/lib/logger/types.ts +110 -0
  79. package/scripts/lib/pipeline-runner/human-gate.ts +134 -0
  80. package/scripts/lib/pipeline-runner/index.ts +527 -0
  81. package/scripts/lib/pipeline-runner/stage-handlers.ts +339 -0
  82. package/scripts/lib/pipeline-runner/state-machine.ts +144 -0
  83. package/scripts/lib/pipeline-runner/types.ts +183 -0
  84. package/scripts/lib/prompt-sanitizer/index.ts +435 -0
  85. package/scripts/lib/prompt-sanitizer/patterns.ts +173 -0
  86. package/scripts/lib/rate-guard.cjs +365 -0
  87. package/scripts/lib/rate-guard.d.cts +38 -0
  88. package/scripts/lib/session-runner/errors.ts +406 -0
  89. package/scripts/lib/session-runner/index.ts +715 -0
  90. package/scripts/lib/session-runner/transcript.ts +189 -0
  91. package/scripts/lib/session-runner/types.ts +144 -0
  92. package/scripts/lib/tool-scoping/index.ts +219 -0
  93. package/scripts/lib/tool-scoping/parse-agent-tools.ts +207 -0
  94. package/scripts/lib/tool-scoping/stage-scopes.ts +139 -0
  95. package/scripts/lib/tool-scoping/types.ts +77 -0
  96. package/scripts/mcp-servers/gdd-state/schemas/add_blocker.schema.json +67 -0
  97. package/scripts/mcp-servers/gdd-state/schemas/add_decision.schema.json +68 -0
  98. package/scripts/mcp-servers/gdd-state/schemas/add_must_have.schema.json +68 -0
  99. package/scripts/mcp-servers/gdd-state/schemas/checkpoint.schema.json +51 -0
  100. package/scripts/mcp-servers/gdd-state/schemas/frontmatter_update.schema.json +62 -0
  101. package/scripts/mcp-servers/gdd-state/schemas/get.schema.json +51 -0
  102. package/scripts/mcp-servers/gdd-state/schemas/probe_connections.schema.json +75 -0
  103. package/scripts/mcp-servers/gdd-state/schemas/resolve_blocker.schema.json +66 -0
  104. package/scripts/mcp-servers/gdd-state/schemas/set_status.schema.json +47 -0
  105. package/scripts/mcp-servers/gdd-state/schemas/transition_stage.schema.json +70 -0
  106. package/scripts/mcp-servers/gdd-state/schemas/update_progress.schema.json +58 -0
  107. package/scripts/mcp-servers/gdd-state/server.ts +288 -0
  108. package/scripts/mcp-servers/gdd-state/tools/add_blocker.ts +72 -0
  109. package/scripts/mcp-servers/gdd-state/tools/add_decision.ts +89 -0
  110. package/scripts/mcp-servers/gdd-state/tools/add_must_have.ts +113 -0
  111. package/scripts/mcp-servers/gdd-state/tools/checkpoint.ts +60 -0
  112. package/scripts/mcp-servers/gdd-state/tools/frontmatter_update.ts +91 -0
  113. package/scripts/mcp-servers/gdd-state/tools/get.ts +51 -0
  114. package/scripts/mcp-servers/gdd-state/tools/index.ts +51 -0
  115. package/scripts/mcp-servers/gdd-state/tools/probe_connections.ts +73 -0
  116. package/scripts/mcp-servers/gdd-state/tools/resolve_blocker.ts +84 -0
  117. package/scripts/mcp-servers/gdd-state/tools/set_status.ts +54 -0
  118. package/scripts/mcp-servers/gdd-state/tools/shared.ts +194 -0
  119. package/scripts/mcp-servers/gdd-state/tools/transition_stage.ts +80 -0
  120. package/scripts/mcp-servers/gdd-state/tools/update_progress.ts +81 -0
  121. package/scripts/validate-frontmatter.ts +114 -0
  122. package/scripts/validate-schemas.ts +401 -0
  123. package/skills/brief/SKILL.md +15 -6
  124. package/skills/design/SKILL.md +31 -13
  125. package/skills/explore/SKILL.md +41 -17
  126. package/skills/health/SKILL.md +15 -4
  127. package/skills/optimize/SKILL.md +3 -3
  128. package/skills/pause/SKILL.md +16 -10
  129. package/skills/plan/SKILL.md +33 -17
  130. package/skills/progress/SKILL.md +15 -11
  131. package/skills/resume/SKILL.md +19 -10
  132. package/skills/settings/SKILL.md +11 -3
  133. package/skills/todo/SKILL.md +12 -3
  134. package/skills/verify/SKILL.md +65 -29
  135. package/hooks/budget-enforcer.js +0 -329
  136. package/hooks/context-exhaustion.js +0 -127
  137. package/hooks/gdd-read-injection-scanner.js +0 -39
  138. package/scripts/aggregate-agent-metrics.js +0 -173
  139. package/scripts/validate-frontmatter.cjs +0 -68
  140. package/scripts/validate-schemas.cjs +0 -242
@@ -0,0 +1,184 @@
1
+ // scripts/lib/discuss-parallel-runner/types.ts — Plan 21-07 (SDK-19).
2
+ //
3
+ // Public type surface for the parallel discussion runner. The runner
4
+ // spawns N design-discussant variants (each a different persona / angle
5
+ // — user-journey, technical-constraint, brand-fit, accessibility)
6
+ // concurrently via `session-runner`, collects each discussant's open
7
+ // questions and concerns as a structured `DiscussionContribution`, and
8
+ // runs an aggregator pass that deduplicates, clusters by theme, and
9
+ // surfaces a single ranked question list for the user.
10
+ //
11
+ // Consumers: the `discuss` skill (standalone leaf) and the `gdd-sdk
12
+ // discuss` CLI subcommand (Plan 21-09).
13
+ //
14
+ // Design invariants (see PLAN.md Context):
15
+ // * Discussant sessions are independent — none write to shared files;
16
+ // parallelism is always safe.
17
+ // * The aggregator runs AFTER all discussants complete.
18
+ // * Error isolation: one discussant's failure never cascades into
19
+ // other discussant sessions.
20
+ // * `AggregatedQuestion.key` is SHA-256-based (stable across runs)
21
+ // per the aggregator prompt contract.
22
+
23
+ import type {
24
+ BudgetCap,
25
+ SessionResult,
26
+ SessionRunnerOptions,
27
+ } from '../session-runner/types.ts';
28
+
29
+ /**
30
+ * Named discussant variants. The default roster is the four values in
31
+ * the union below, but callers can pass any string (arbitrary custom
32
+ * discussant).
33
+ */
34
+ export type DiscussantName =
35
+ | 'user-journey'
36
+ | 'technical-constraint'
37
+ | 'brand-fit'
38
+ | 'accessibility'
39
+ | string;
40
+
41
+ /** Severity ordering is blocker > major > minor > nice-to-have. */
42
+ export type Severity = 'blocker' | 'major' | 'minor' | 'nice-to-have';
43
+
44
+ /**
45
+ * One discussant specification. The runner passes `prompt` verbatim
46
+ * through `session-runner.run()` as the prompt body.
47
+ *
48
+ * `agentPath` is optional; when absent, the runner defaults to the
49
+ * stage scope from tool-scoping (`discuss` maps to `custom` — see
50
+ * Plan 21-03). When present, the runner reads the agent markdown's
51
+ * `tools:` frontmatter via `parseAgentToolsByName` and passes the
52
+ * resolved list as `allowedTools`.
53
+ */
54
+ export interface DiscussantSpec {
55
+ name: DiscussantName;
56
+ /** Optional agent frontmatter path; missing → stage scope from tool-scoping. */
57
+ agentPath?: string;
58
+ /** Per-discussant prompt body. */
59
+ prompt: string;
60
+ }
61
+
62
+ /**
63
+ * One parsed item from a discussant's DISCUSSION COMPLETE block.
64
+ *
65
+ * `kind` discriminates questions (things the discussant wants
66
+ * answered) from concerns (things they want to flag).
67
+ *
68
+ * `tag` captures the per-item annotation from the discussant output
69
+ * (`Concern: <stakeholder>` for questions, `Area: <scope>` for
70
+ * concerns). Optional because lenient parse allows missing.
71
+ */
72
+ export interface DiscussionItem {
73
+ kind: 'question' | 'concern';
74
+ text: string;
75
+ /** Area / angle / concern tag per discussant output. */
76
+ tag?: string;
77
+ severity: Severity;
78
+ rationale?: string;
79
+ }
80
+
81
+ /**
82
+ * One discussant's complete contribution. `items` is empty when the
83
+ * session errored or the block was missing/malformed.
84
+ *
85
+ * `status`:
86
+ * * `completed` — session ended cleanly AND a DISCUSSION COMPLETE
87
+ * block was parsed successfully.
88
+ * * `parse-error` — session ended cleanly but the block was absent
89
+ * or malformed (items: []).
90
+ * * `error` — session failed (budget, turn cap, aborted, error);
91
+ * `error` populated; items: [].
92
+ */
93
+ export interface DiscussionContribution {
94
+ discussant: DiscussantName;
95
+ items: readonly DiscussionItem[];
96
+ /** Raw final_text captured for audit / aggregator input. */
97
+ raw: string;
98
+ usage: { input_tokens: number; output_tokens: number; usd_cost: number };
99
+ status: 'completed' | 'error' | 'parse-error';
100
+ error?: { code: string; message: string };
101
+ }
102
+
103
+ /**
104
+ * One aggregated (post-dedup, post-cluster) question.
105
+ *
106
+ * * `key` — SHA-256 of the normalized question text (lowercase,
107
+ * whitespace-collapsed) truncated to 8 hex chars.
108
+ * Stable across runs.
109
+ * * `raised_by` — discussants that raised (a semantic variant of)
110
+ * this question.
111
+ * * `theme` — cluster/theme name from aggregator.
112
+ * * `rank` — 0-indexed priority (0 = highest). Ranking combines
113
+ * severity + frequency per the aggregator prompt.
114
+ */
115
+ export interface AggregatedQuestion {
116
+ /** Stable key across runs (hash of normalized question text). */
117
+ key: string;
118
+ text: string;
119
+ severity: Severity;
120
+ /** Discussants that raised this question. */
121
+ raised_by: readonly DiscussantName[];
122
+ /** Cluster/theme assignment from aggregator. */
123
+ theme: string;
124
+ /** Aggregator-assigned rank (0 = highest priority). */
125
+ rank: number;
126
+ }
127
+
128
+ /**
129
+ * The aggregator's final output. `output_path` is the Markdown file
130
+ * written to disk (`.design/DISCUSSION.md` by default). `usage` is
131
+ * the aggregator session's token/cost spend — separate from the
132
+ * per-discussant usage aggregated in `DiscussRunnerResult.total_usage`.
133
+ */
134
+ export interface AggregatedDiscussion {
135
+ themes: readonly { name: string; summary: string }[];
136
+ questions: readonly AggregatedQuestion[];
137
+ /** Output path. */
138
+ output_path: string;
139
+ /** Aggregator session usage. */
140
+ usage: { input_tokens: number; output_tokens: number; usd_cost: number };
141
+ }
142
+
143
+ /**
144
+ * Options for `run()` — the top-level orchestrator entry point.
145
+ *
146
+ * * `discussants` — omit to use `DEFAULT_DISCUSSANTS` (4 variants).
147
+ * * `budget` + `maxTurnsPerDiscussant` — applied per-discussant.
148
+ * * `aggregatorBudget` + `aggregatorMaxTurns` — applied to the
149
+ * aggregator session specifically.
150
+ * * `concurrency` — defaults to 4 (matches the default roster size).
151
+ * * `runOverride` — test injection for `session-runner.run()`. All
152
+ * discussants AND the aggregator receive the SAME override so one
153
+ * mock controls the entire run.
154
+ * * `aggregatorPrompt` — replace the default aggregator prompt
155
+ * (advanced / debug use).
156
+ */
157
+ export interface DiscussRunnerOptions {
158
+ /** Discussants to run. Default: the 4-variant roster. */
159
+ discussants?: readonly DiscussantSpec[];
160
+ budget: BudgetCap;
161
+ maxTurnsPerDiscussant: number;
162
+ aggregatorBudget: BudgetCap;
163
+ aggregatorMaxTurns: number;
164
+ concurrency?: number;
165
+ runOverride?: (opts: SessionRunnerOptions) => Promise<SessionResult>;
166
+ cwd?: string;
167
+ /** Custom aggregator prompt override. */
168
+ aggregatorPrompt?: string;
169
+ }
170
+
171
+ /**
172
+ * The final return value from `run()`.
173
+ *
174
+ * * `contributions` — one per discussant, in input spec order (NOT
175
+ * completion order). Failed discussants are included with
176
+ * `status !== 'completed'`.
177
+ * * `aggregated` — the aggregator's parsed output.
178
+ * * `total_usage` — sum of per-discussant + aggregator usage.
179
+ */
180
+ export interface DiscussRunnerResult {
181
+ contributions: readonly DiscussionContribution[];
182
+ aggregated: AggregatedDiscussion;
183
+ total_usage: { input_tokens: number; output_tokens: number; usd_cost: number };
184
+ }
@@ -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
+ };
@@ -0,0 +1,44 @@
1
+ // scripts/lib/error-classifier.d.cts — types for error-classifier.cjs.
2
+
3
+ /** Recovery-action categories. Values are stable strings — safe to log. */
4
+ export type FailoverReasonValue =
5
+ | 'rate_limited'
6
+ | 'context_overflow'
7
+ | 'auth_error'
8
+ | 'network_transient'
9
+ | 'network_permanent'
10
+ | 'tool_not_found'
11
+ | 'validation'
12
+ | 'unknown';
13
+
14
+ /** String-constant map of FailoverReason names to values. */
15
+ export const FailoverReason: {
16
+ readonly RATE_LIMITED: 'rate_limited';
17
+ readonly CONTEXT_OVERFLOW: 'context_overflow';
18
+ readonly AUTH_ERROR: 'auth_error';
19
+ readonly NETWORK_TRANSIENT: 'network_transient';
20
+ readonly NETWORK_PERMANENT: 'network_permanent';
21
+ readonly TOOL_NOT_FOUND: 'tool_not_found';
22
+ readonly VALIDATION: 'validation';
23
+ readonly UNKNOWN: 'unknown';
24
+ };
25
+
26
+ /** Result of {@link classify}. */
27
+ export interface ClassifiedError {
28
+ reason: FailoverReasonValue;
29
+ retryable: boolean;
30
+ suggestedAction: string;
31
+ raw: unknown;
32
+ }
33
+
34
+ /**
35
+ * Map a raw error value onto a stable recovery-action category.
36
+ * Tolerant of null/undefined/non-Error inputs.
37
+ */
38
+ export function classify(err: unknown): ClassifiedError;
39
+
40
+ /** Keyed suggested-action strings (read-only). */
41
+ export const SUGGESTED_ACTIONS: Readonly<Record<FailoverReasonValue, string>>;
42
+
43
+ /** Which reasons are considered safe to retry by policy. */
44
+ export const RETRYABLE: Readonly<Record<FailoverReasonValue, boolean>>;
@@ -0,0 +1,88 @@
1
+ // scripts/lib/event-stream/emitter.ts — in-process pub-sub for Phase 20+
2
+ // telemetry events (Plan 20-06, SDK-08).
3
+ //
4
+ // Thin wrapper around Node's built-in `EventEmitter`. Two additions over
5
+ // the raw primitive:
6
+ //
7
+ // 1. `subscribe(type, handler)` returns an unsubscribe closure — the
8
+ // standard `on()` / `off()` dance is easy to forget, and our
9
+ // subscribers (hook consumers, Phase 22 transports) need tidy
10
+ // lifecycle management.
11
+ //
12
+ // 2. `subscribeAll(handler)` lets observability infra (dashboard,
13
+ // log transport) see every event without enumerating known types.
14
+ // We re-emit on a dedicated `'*'` channel so listeners on that
15
+ // channel observe every `emit()`.
16
+ //
17
+ // Replay semantics:
18
+ // The bus is live-only. Subscribing does NOT deliver historical
19
+ // events from `events.jsonl` — that's a Phase 22 transport concern.
20
+
21
+ import { EventEmitter } from 'node:events';
22
+
23
+ import type { BaseEvent } from './types.ts';
24
+
25
+ /**
26
+ * Default max listeners raised above Node's 10-listener default. Mapper
27
+ * parallelism + multiple hook consumers + a dashboard transport can
28
+ * easily stack above 10; 50 is conservative headroom before Node warns.
29
+ */
30
+ export const DEFAULT_MAX_LISTENERS = 50;
31
+
32
+ /**
33
+ * Typed handler for a specific event subtype. `T extends BaseEvent`
34
+ * means callers can narrow via `subscribe<StateMutationEvent>(…)` and
35
+ * the handler sees the narrowed shape.
36
+ */
37
+ export type EventHandler<T extends BaseEvent = BaseEvent> = (ev: T) => void;
38
+
39
+ /** Unsubscribe closure returned from `subscribe` / `subscribeAll`. */
40
+ export type Unsubscribe = () => void;
41
+
42
+ /**
43
+ * In-process event bus. Extends `EventEmitter` so raw consumers can
44
+ * still call `on()` / `off()` if they need Node-native semantics, but
45
+ * prefer `subscribe` / `subscribeAll` for ergonomic cleanup.
46
+ */
47
+ export class EventBus extends EventEmitter {
48
+ constructor() {
49
+ super();
50
+ this.setMaxListeners(DEFAULT_MAX_LISTENERS);
51
+ }
52
+
53
+ /**
54
+ * Subscribe to one specific event type. The handler fires for every
55
+ * subsequent `emit(type, ev)` call where `ev.type === type`. Returns
56
+ * a closure that detaches the listener on invocation.
57
+ *
58
+ * @example
59
+ * const off = bus.subscribe<StateMutationEvent>('state.mutation', (ev) => {
60
+ * console.log(ev.payload.tool);
61
+ * });
62
+ * // …later
63
+ * off();
64
+ */
65
+ subscribe<T extends BaseEvent = BaseEvent>(
66
+ type: T['type'],
67
+ handler: EventHandler<T>,
68
+ ): Unsubscribe {
69
+ const listener = handler as unknown as (...args: unknown[]) => void;
70
+ this.on(type, listener);
71
+ return () => {
72
+ this.off(type, listener);
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Subscribe to *every* event regardless of type. Listeners registered
78
+ * here fire on the special `'*'` channel, which `appendEvent()`
79
+ * re-emits to on every event. Returns an unsubscribe closure.
80
+ */
81
+ subscribeAll(handler: EventHandler<BaseEvent>): Unsubscribe {
82
+ const listener = handler as unknown as (...args: unknown[]) => void;
83
+ this.on('*', listener);
84
+ return () => {
85
+ this.off('*', listener);
86
+ };
87
+ }
88
+ }