@hegemonart/get-design-done 1.31.0 → 1.31.5
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 +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +44 -0
- package/NOTICE +224 -0
- package/README.md +1 -1
- package/agents/design-authority-watcher.md +1 -1
- package/agents/perf-analyzer.md +2 -2
- package/bin/gdd-mcp +78 -0
- package/bin/gdd-sdk +34 -24
- package/bin/gdd-state-mcp +78 -0
- package/{README.de.md → docs/i18n/README.de.md} +1 -1
- package/{README.fr.md → docs/i18n/README.fr.md} +1 -1
- package/{README.it.md → docs/i18n/README.it.md} +1 -1
- package/{README.ja.md → docs/i18n/README.ja.md} +1 -1
- package/{README.ko.md → docs/i18n/README.ko.md} +1 -1
- package/{README.zh-CN.md → docs/i18n/README.zh-CN.md} +1 -1
- package/hooks/_hook-emit.js +1 -1
- package/hooks/budget-enforcer.ts +5 -5
- package/hooks/context-exhaustion.ts +2 -2
- package/hooks/gdd-precompact-snapshot.js +3 -3
- package/hooks/gdd-read-injection-scanner.ts +2 -2
- package/hooks/gdd-sessionstart-recap.js +1 -1
- package/hooks/gdd-turn-closeout.js +1 -1
- package/package.json +20 -9
- package/recipes/.gitkeep +0 -0
- package/reference/schemas/recipe.schema.json +33 -0
- package/scripts/cli/gdd-events.mjs +5 -5
- package/scripts/lib/cache/gdd-cache-manager.cjs +1 -1
- package/scripts/lib/cli/index.ts +22 -160
- package/scripts/lib/connection-probe/index.cjs +1 -1
- package/scripts/lib/discuss-parallel-runner/aggregator.ts +1 -1
- package/scripts/lib/discuss-parallel-runner/index.ts +1 -1
- package/scripts/lib/error-classifier.cjs +24 -227
- package/scripts/lib/event-stream/index.ts +25 -193
- package/scripts/lib/gdd-errors/index.ts +24 -213
- package/scripts/lib/gdd-state/index.ts +23 -161
- package/scripts/lib/iteration-budget.cjs +23 -199
- package/scripts/lib/jittered-backoff.cjs +24 -107
- package/scripts/lib/lockfile.cjs +23 -195
- package/scripts/lib/logger/index.ts +1 -1
- package/scripts/lib/parallelism-engine/concurrency-tuner.cjs +1 -1
- package/scripts/lib/perf-analyzer/index.cjs +1 -1
- package/scripts/lib/pipeline-runner/index.ts +4 -4
- package/scripts/lib/pipeline-runner/state-machine.ts +1 -1
- package/scripts/lib/prompt-dedup/index.cjs +1 -1
- package/scripts/lib/rate-guard.cjs +2 -2
- package/scripts/lib/recipe-loader.cjs +142 -0
- package/scripts/lib/session-runner/errors.ts +3 -3
- package/scripts/lib/session-runner/index.ts +3 -3
- package/scripts/lib/session-runner/transcript.ts +1 -1
- package/scripts/lib/tool-scoping/index.ts +1 -1
- package/scripts/mcp-servers/gdd-mcp/server.ts +29 -311
- package/scripts/mcp-servers/gdd-state/server.ts +28 -282
- package/sdk/README.md +45 -0
- package/{scripts/lib → sdk}/cli/commands/audit.ts +3 -3
- package/{scripts/lib → sdk}/cli/commands/init.ts +3 -3
- package/{scripts/lib → sdk}/cli/commands/query.ts +4 -4
- package/{scripts/lib → sdk}/cli/commands/run.ts +5 -5
- package/{scripts/lib → sdk}/cli/commands/stage.ts +5 -5
- package/sdk/cli/index.js +8091 -0
- package/sdk/cli/index.ts +172 -0
- package/{scripts/lib → sdk}/cli/parse-args.ts +2 -2
- package/{scripts/lib/gdd-errors → sdk/errors}/classification.ts +1 -1
- package/sdk/errors/index.ts +218 -0
- package/{scripts/lib → sdk}/event-stream/emitter.ts +1 -1
- package/sdk/event-stream/index.ts +197 -0
- package/{scripts/lib → sdk}/event-stream/reader.ts +1 -1
- package/{scripts/lib → sdk}/event-stream/types.ts +2 -2
- package/{scripts/lib → sdk}/event-stream/writer.ts +1 -1
- package/sdk/index.ts +19 -0
- package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/README.md +3 -3
- package/sdk/mcp/gdd-mcp/server.js +1924 -0
- package/sdk/mcp/gdd-mcp/server.ts +325 -0
- package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/tools/gdd_cycle_recap.ts +3 -3
- package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/tools/gdd_decisions_list.ts +2 -2
- package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/tools/gdd_events_tail.ts +3 -3
- package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/tools/gdd_health.ts +2 -2
- package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/tools/gdd_intel_get.ts +2 -2
- package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/tools/gdd_learnings_digest.ts +2 -2
- package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/tools/gdd_phase_current.ts +2 -2
- package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/tools/gdd_phases_list.ts +2 -2
- package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/tools/gdd_plans_list.ts +2 -2
- package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/tools/gdd_reflections_latest.ts +2 -2
- package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/tools/gdd_status.ts +3 -3
- package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/tools/gdd_telemetry_query.ts +3 -3
- package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/tools/index.ts +2 -2
- package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/tools/shared.ts +3 -3
- package/sdk/mcp/gdd-state/server.js +2790 -0
- package/sdk/mcp/gdd-state/server.ts +294 -0
- package/{scripts/mcp-servers → sdk/mcp}/gdd-state/tools/add_blocker.ts +3 -3
- package/{scripts/mcp-servers → sdk/mcp}/gdd-state/tools/add_decision.ts +3 -3
- package/{scripts/mcp-servers → sdk/mcp}/gdd-state/tools/add_must_have.ts +3 -3
- package/{scripts/mcp-servers → sdk/mcp}/gdd-state/tools/checkpoint.ts +2 -2
- package/{scripts/mcp-servers → sdk/mcp}/gdd-state/tools/frontmatter_update.ts +2 -2
- package/{scripts/mcp-servers → sdk/mcp}/gdd-state/tools/get.ts +3 -3
- package/{scripts/mcp-servers → sdk/mcp}/gdd-state/tools/index.ts +1 -1
- package/{scripts/mcp-servers → sdk/mcp}/gdd-state/tools/probe_connections.ts +3 -3
- package/{scripts/mcp-servers → sdk/mcp}/gdd-state/tools/resolve_blocker.ts +3 -3
- package/{scripts/mcp-servers → sdk/mcp}/gdd-state/tools/set_status.ts +2 -2
- package/{scripts/mcp-servers → sdk/mcp}/gdd-state/tools/shared.ts +8 -8
- package/{scripts/mcp-servers → sdk/mcp}/gdd-state/tools/transition_stage.ts +4 -4
- package/{scripts/mcp-servers → sdk/mcp}/gdd-state/tools/update_progress.ts +2 -2
- package/sdk/primitives/error-classifier.cjs +232 -0
- package/sdk/primitives/iteration-budget.cjs +205 -0
- package/sdk/primitives/jittered-backoff.cjs +112 -0
- package/sdk/primitives/lockfile.cjs +201 -0
- package/{scripts/lib/gdd-state → sdk/state}/gates.ts +1 -1
- package/sdk/state/index.ts +167 -0
- package/{scripts/lib/gdd-state → sdk/state}/lockfile.ts +1 -1
- package/{scripts/lib/gdd-state → sdk/state}/mutator.ts +1 -1
- package/{scripts/lib/gdd-state → sdk/state}/parser.ts +1 -1
- package/{scripts/lib/gdd-state → sdk/state}/types.ts +4 -4
- package/skills/quality-gate/SKILL.md +2 -2
- package/scripts/aggregate-agent-metrics.ts +0 -282
- package/scripts/bootstrap-manifest.txt +0 -3
- package/scripts/bootstrap.sh +0 -80
- package/scripts/build-distribution-bundles.cjs +0 -549
- package/scripts/build-intel.cjs +0 -486
- package/scripts/codegen-schema-types.ts +0 -149
- package/scripts/detect-stale-refs.cjs +0 -107
- package/scripts/e2e/run-headless.ts +0 -514
- package/scripts/extract-changelog-section.cjs +0 -58
- package/scripts/gsd-cleanup-incubator.cjs +0 -367
- package/scripts/injection-patterns.cjs +0 -58
- package/scripts/lint-agentskills-spec.cjs +0 -457
- package/scripts/release-smoke-test.cjs +0 -200
- package/scripts/rollback-release.sh +0 -42
- package/scripts/run-injection-scanner-ci.cjs +0 -83
- package/scripts/tests/test-authority-rejected-kinds.sh +0 -58
- package/scripts/tests/test-authority-watcher-diff.sh +0 -113
- package/scripts/tests/test-motion-provenance.sh +0 -64
- package/scripts/validate-frontmatter.ts +0 -409
- package/scripts/validate-incubator-scope.cjs +0 -133
- package/scripts/validate-schemas.ts +0 -401
- package/scripts/validate-skill-length.cjs +0 -283
- package/scripts/verify-version-sync.cjs +0 -30
- /package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/schemas/gdd_cycle_recap.schema.json +0 -0
- /package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/schemas/gdd_decisions_list.schema.json +0 -0
- /package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/schemas/gdd_events_tail.schema.json +0 -0
- /package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/schemas/gdd_health.schema.json +0 -0
- /package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/schemas/gdd_intel_get.schema.json +0 -0
- /package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/schemas/gdd_learnings_digest.schema.json +0 -0
- /package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/schemas/gdd_phase_current.schema.json +0 -0
- /package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/schemas/gdd_phases_list.schema.json +0 -0
- /package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/schemas/gdd_plans_list.schema.json +0 -0
- /package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/schemas/gdd_reflections_latest.schema.json +0 -0
- /package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/schemas/gdd_status.schema.json +0 -0
- /package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/schemas/gdd_telemetry_query.schema.json +0 -0
- /package/{scripts/mcp-servers → sdk/mcp}/gdd-state/schemas/add_blocker.schema.json +0 -0
- /package/{scripts/mcp-servers → sdk/mcp}/gdd-state/schemas/add_decision.schema.json +0 -0
- /package/{scripts/mcp-servers → sdk/mcp}/gdd-state/schemas/add_must_have.schema.json +0 -0
- /package/{scripts/mcp-servers → sdk/mcp}/gdd-state/schemas/checkpoint.schema.json +0 -0
- /package/{scripts/mcp-servers → sdk/mcp}/gdd-state/schemas/frontmatter_update.schema.json +0 -0
- /package/{scripts/mcp-servers → sdk/mcp}/gdd-state/schemas/get.schema.json +0 -0
- /package/{scripts/mcp-servers → sdk/mcp}/gdd-state/schemas/probe_connections.schema.json +0 -0
- /package/{scripts/mcp-servers → sdk/mcp}/gdd-state/schemas/resolve_blocker.schema.json +0 -0
- /package/{scripts/mcp-servers → sdk/mcp}/gdd-state/schemas/set_status.schema.json +0 -0
- /package/{scripts/mcp-servers → sdk/mcp}/gdd-state/schemas/transition_stage.schema.json +0 -0
- /package/{scripts/mcp-servers → sdk/mcp}/gdd-state/schemas/update_progress.schema.json +0 -0
- /package/{scripts/lib → sdk/primitives}/error-classifier.d.cts +0 -0
- /package/{scripts/lib → sdk/primitives}/iteration-budget.d.cts +0 -0
- /package/{scripts/lib → sdk/primitives}/jittered-backoff.d.cts +0 -0
- /package/{scripts/lib → sdk/primitives}/lockfile.d.cts +0 -0
|
@@ -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,205 @@
|
|
|
1
|
+
// scripts/lib/iteration-budget.cjs
|
|
2
|
+
//
|
|
3
|
+
// Plan 20-14 — bounded fix-loop iteration budget.
|
|
4
|
+
//
|
|
5
|
+
// Stops infinite fix cycles from burning unbounded context. Every fix-
|
|
6
|
+
// iteration consumes 1 unit; Layer-B cache hits refund 1 unit so
|
|
7
|
+
// cached answers don't count against the ceiling. When the budget
|
|
8
|
+
// reaches 0, consume() throws and the caller must surface to user.
|
|
9
|
+
//
|
|
10
|
+
// State file: `.design/iteration-budget.json`. All mutations go through
|
|
11
|
+
// `scripts/lib/lockfile.cjs` with atomic temp+rename writes so
|
|
12
|
+
// concurrent callers (hook + fix-loop + verify) don't clobber each
|
|
13
|
+
// other. The lock scope is the state file itself, so refund + consume
|
|
14
|
+
// from different children serialize correctly.
|
|
15
|
+
//
|
|
16
|
+
// Shape on disk matches reference/schemas/iteration-budget.schema.json:
|
|
17
|
+
// { budget, remaining, consumed, refunded, updatedAt }
|
|
18
|
+
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
const fs = require('node:fs');
|
|
22
|
+
const path = require('node:path');
|
|
23
|
+
|
|
24
|
+
const { acquire, renameWithRetry } = require('./lockfile.cjs');
|
|
25
|
+
|
|
26
|
+
const STATE_PATH_REL = path.join('.design', 'iteration-budget.json');
|
|
27
|
+
const DEFAULT_BUDGET = 50;
|
|
28
|
+
const LOCK_MAX_WAIT_MS = 5_000;
|
|
29
|
+
|
|
30
|
+
/** Error thrown by `consume()` when the remaining budget would go below 0. */
|
|
31
|
+
class IterationBudgetExhaustedError extends Error {
|
|
32
|
+
constructor(amount, state) {
|
|
33
|
+
super(
|
|
34
|
+
`IterationBudgetExhausted: cannot consume ${amount} — remaining=${state.remaining}, budget=${state.budget}, consumed=${state.consumed}. Caller must surface to user (fix-loop has stopped converging).`,
|
|
35
|
+
);
|
|
36
|
+
this.name = 'IterationBudgetExhaustedError';
|
|
37
|
+
this.amount = amount;
|
|
38
|
+
this.state = state;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function stateAbsPath() {
|
|
43
|
+
return path.join(process.cwd(), STATE_PATH_REL);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Read and validate the state file. Returns null on missing/corrupt. */
|
|
47
|
+
function readStateSync() {
|
|
48
|
+
const p = stateAbsPath();
|
|
49
|
+
if (!fs.existsSync(p)) return null;
|
|
50
|
+
try {
|
|
51
|
+
const raw = fs.readFileSync(p, 'utf8');
|
|
52
|
+
const parsed = JSON.parse(raw);
|
|
53
|
+
if (
|
|
54
|
+
parsed &&
|
|
55
|
+
typeof parsed === 'object' &&
|
|
56
|
+
Number.isInteger(parsed.budget) && parsed.budget >= 0 &&
|
|
57
|
+
Number.isInteger(parsed.remaining) && parsed.remaining >= 0 &&
|
|
58
|
+
Number.isInteger(parsed.consumed) && parsed.consumed >= 0 &&
|
|
59
|
+
Number.isInteger(parsed.refunded) && parsed.refunded >= 0 &&
|
|
60
|
+
typeof parsed.updatedAt === 'string'
|
|
61
|
+
) {
|
|
62
|
+
return parsed;
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Atomically write state under a file-lock. */
|
|
71
|
+
async function writeStateAtomic(state) {
|
|
72
|
+
const p = stateAbsPath();
|
|
73
|
+
const dir = path.dirname(p);
|
|
74
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
75
|
+
const release = await acquire(p, { maxWaitMs: LOCK_MAX_WAIT_MS });
|
|
76
|
+
try {
|
|
77
|
+
// Re-read inside the lock so we merge against the very latest
|
|
78
|
+
// on-disk state, not the value the caller observed pre-lock. This
|
|
79
|
+
// is what makes concurrent consume() from 10 children add up to
|
|
80
|
+
// consumed=10 rather than racing each other.
|
|
81
|
+
const latest = readStateSync();
|
|
82
|
+
const merged = state.mergeFn ? state.mergeFn(latest || state.seed) : state.seed;
|
|
83
|
+
const tmp = `${p}.tmp.${process.pid}.${Date.now()}`;
|
|
84
|
+
fs.writeFileSync(tmp, JSON.stringify(merged, null, 2) + '\n', 'utf8');
|
|
85
|
+
await renameWithRetry(tmp, p);
|
|
86
|
+
return merged;
|
|
87
|
+
} finally {
|
|
88
|
+
await release();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Initialize or restart the iteration budget.
|
|
94
|
+
*
|
|
95
|
+
* @param {number} [budget] default 50
|
|
96
|
+
* @returns {Promise<{budget, remaining, consumed, refunded, updatedAt}>}
|
|
97
|
+
*/
|
|
98
|
+
async function reset(budget = DEFAULT_BUDGET) {
|
|
99
|
+
if (!Number.isFinite(budget) || budget < 0) {
|
|
100
|
+
throw new Error(`iteration-budget.reset: budget must be a non-negative finite number, got ${budget}`);
|
|
101
|
+
}
|
|
102
|
+
const b = Math.floor(budget);
|
|
103
|
+
const state = {
|
|
104
|
+
budget: b,
|
|
105
|
+
remaining: b,
|
|
106
|
+
consumed: 0,
|
|
107
|
+
refunded: 0,
|
|
108
|
+
updatedAt: new Date().toISOString(),
|
|
109
|
+
};
|
|
110
|
+
return writeStateAtomic({ seed: state, mergeFn: () => state });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Consume N units from the remaining budget. Throws
|
|
115
|
+
* IterationBudgetExhaustedError when N would send remaining below zero.
|
|
116
|
+
*
|
|
117
|
+
* @param {number} [amount] default 1
|
|
118
|
+
* @returns {Promise<{budget, remaining, consumed, refunded, updatedAt}>}
|
|
119
|
+
* the new on-disk state after consumption.
|
|
120
|
+
*/
|
|
121
|
+
async function consume(amount = 1) {
|
|
122
|
+
const n = normalizeAmount(amount);
|
|
123
|
+
// Seed for the case when no state exists yet: auto-init to default budget.
|
|
124
|
+
const seed = defaultState();
|
|
125
|
+
return writeStateAtomic({
|
|
126
|
+
seed,
|
|
127
|
+
mergeFn: (current) => {
|
|
128
|
+
const base = current || seed;
|
|
129
|
+
const nextRemaining = base.remaining - n;
|
|
130
|
+
if (nextRemaining < 0) {
|
|
131
|
+
// Throw without writing — atomic: either consume fully or not at all.
|
|
132
|
+
throw new IterationBudgetExhaustedError(n, base);
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
budget: base.budget,
|
|
136
|
+
remaining: nextRemaining,
|
|
137
|
+
consumed: base.consumed + n,
|
|
138
|
+
refunded: base.refunded,
|
|
139
|
+
updatedAt: new Date().toISOString(),
|
|
140
|
+
};
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Refund N units to the remaining budget, capped at `budget`.
|
|
147
|
+
*
|
|
148
|
+
* @param {number} [amount] default 1
|
|
149
|
+
* @returns {Promise<{budget, remaining, consumed, refunded, updatedAt}>}
|
|
150
|
+
*/
|
|
151
|
+
async function refund(amount = 1) {
|
|
152
|
+
const n = normalizeAmount(amount);
|
|
153
|
+
const seed = defaultState();
|
|
154
|
+
return writeStateAtomic({
|
|
155
|
+
seed,
|
|
156
|
+
mergeFn: (current) => {
|
|
157
|
+
const base = current || seed;
|
|
158
|
+
const nextRemaining = Math.min(base.budget, base.remaining + n);
|
|
159
|
+
// Only count the portion that actually landed — if we were already
|
|
160
|
+
// at budget, the refund is a no-op.
|
|
161
|
+
const actuallyRefunded = nextRemaining - base.remaining;
|
|
162
|
+
return {
|
|
163
|
+
budget: base.budget,
|
|
164
|
+
remaining: nextRemaining,
|
|
165
|
+
consumed: base.consumed,
|
|
166
|
+
refunded: base.refunded + actuallyRefunded,
|
|
167
|
+
updatedAt: new Date().toISOString(),
|
|
168
|
+
};
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Return the current on-disk state. Reads without the lock — callers
|
|
175
|
+
* using this for UI display only see a best-effort snapshot; mutating
|
|
176
|
+
* paths (consume/refund) always re-read inside the lock.
|
|
177
|
+
*/
|
|
178
|
+
function remaining() {
|
|
179
|
+
return readStateSync() || defaultState();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function normalizeAmount(amount) {
|
|
183
|
+
if (!Number.isFinite(amount) || amount <= 0) {
|
|
184
|
+
throw new Error(`iteration-budget: amount must be a positive finite number, got ${amount}`);
|
|
185
|
+
}
|
|
186
|
+
return Math.floor(amount);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function defaultState() {
|
|
190
|
+
return {
|
|
191
|
+
budget: DEFAULT_BUDGET,
|
|
192
|
+
remaining: DEFAULT_BUDGET,
|
|
193
|
+
consumed: 0,
|
|
194
|
+
refunded: 0,
|
|
195
|
+
updatedAt: new Date().toISOString(),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
module.exports = {
|
|
200
|
+
consume,
|
|
201
|
+
refund,
|
|
202
|
+
remaining,
|
|
203
|
+
reset,
|
|
204
|
+
IterationBudgetExhaustedError,
|
|
205
|
+
};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// scripts/lib/jittered-backoff.cjs
|
|
2
|
+
//
|
|
3
|
+
// Plan 20-14 — jittered exponential backoff primitive.
|
|
4
|
+
//
|
|
5
|
+
// Replaces fixed-interval retry sleeps across the codebase (update-check,
|
|
6
|
+
// watch-authorities, figma probes, connection probes, hook retry loops) with
|
|
7
|
+
// a deterministic, capped, jittered backoff curve.
|
|
8
|
+
//
|
|
9
|
+
// Formula:
|
|
10
|
+
// base = min(maxMs, baseMs * factor^attempt)
|
|
11
|
+
// delay = base * (1 + rand(-jitter, +jitter))
|
|
12
|
+
// clamp to [0, maxMs * (1 + jitter)]
|
|
13
|
+
//
|
|
14
|
+
// The zero-based `attempt` counter gives `baseMs` on the first call, then
|
|
15
|
+
// multiplies by `factor` per attempt up to the cap. Jitter is full-width
|
|
16
|
+
// symmetric (not AWS-style equal/decorrelated) because our consumers are
|
|
17
|
+
// single-threaded retry loops — there's no thundering-herd problem to
|
|
18
|
+
// smooth; the jitter is cosmetic protection against synchronized retries
|
|
19
|
+
// between siblings like the watcher + update-check running at the same
|
|
20
|
+
// session boundary.
|
|
21
|
+
//
|
|
22
|
+
// Defaults (baseMs=100, maxMs=30_000, factor=2, jitter=0.2) yield:
|
|
23
|
+
// attempt 0 → 80-120ms
|
|
24
|
+
// attempt 1 → 160-240ms
|
|
25
|
+
// attempt 2 → 320-480ms
|
|
26
|
+
// ...
|
|
27
|
+
// attempt 9 → ~24s-30s (capped)
|
|
28
|
+
//
|
|
29
|
+
// This module is `.cjs` (not `.ts`) per Plan 20-14 D-01 so it can be
|
|
30
|
+
// `require()`d from both the `.ts` runtime (hooks, MCP server) and future
|
|
31
|
+
// `.cjs` CLI invocations without needing `--experimental-strip-types` at
|
|
32
|
+
// every consumer site. Types live in the paired `jittered-backoff.d.cts`.
|
|
33
|
+
//
|
|
34
|
+
// No external dependencies; pure Math.random + setTimeout.
|
|
35
|
+
|
|
36
|
+
'use strict';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Default backoff parameters — chosen to cover retry-after-Xms through
|
|
40
|
+
* retry-after-30s with reasonable mid-range distribution.
|
|
41
|
+
*/
|
|
42
|
+
const DEFAULTS = Object.freeze({
|
|
43
|
+
baseMs: 100,
|
|
44
|
+
maxMs: 30_000,
|
|
45
|
+
factor: 2,
|
|
46
|
+
jitter: 0.2,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Compute the jittered delay in milliseconds for a given attempt number.
|
|
51
|
+
*
|
|
52
|
+
* @param {number} attempt zero-based attempt counter (0 = first retry)
|
|
53
|
+
* @param {object} [opts]
|
|
54
|
+
* @param {number} [opts.baseMs] initial delay before any jitter. Default 100.
|
|
55
|
+
* @param {number} [opts.maxMs] maximum un-jittered base. Default 30_000.
|
|
56
|
+
* @param {number} [opts.factor] per-attempt multiplier. Default 2.
|
|
57
|
+
* @param {number} [opts.jitter] symmetric jitter fraction in [0, 1). Default 0.2.
|
|
58
|
+
* @returns {number} delay in ms. Never negative. May exceed `maxMs` by up
|
|
59
|
+
* to `jitter * maxMs` on the high side.
|
|
60
|
+
*
|
|
61
|
+
* Invariants:
|
|
62
|
+
* - `delayMs(n, opts) >= 0` for every non-negative `n`.
|
|
63
|
+
* - `delayMs(n, opts) <= maxMs * (1 + jitter)` for every non-negative `n`.
|
|
64
|
+
* - The distribution has nonzero stddev whenever `jitter > 0`.
|
|
65
|
+
*/
|
|
66
|
+
function delayMs(attempt, opts) {
|
|
67
|
+
const baseMs = (opts && Number.isFinite(opts.baseMs)) ? opts.baseMs : DEFAULTS.baseMs;
|
|
68
|
+
const maxMs = (opts && Number.isFinite(opts.maxMs)) ? opts.maxMs : DEFAULTS.maxMs;
|
|
69
|
+
const factor = (opts && Number.isFinite(opts.factor)) ? opts.factor : DEFAULTS.factor;
|
|
70
|
+
const jitter = (opts && Number.isFinite(opts.jitter)) ? opts.jitter : DEFAULTS.jitter;
|
|
71
|
+
|
|
72
|
+
// Guard against nonsense inputs — callers that pass garbage shouldn't
|
|
73
|
+
// cause NaN or Infinity to propagate into setTimeout.
|
|
74
|
+
const a = Math.max(0, Number.isFinite(attempt) ? Math.floor(attempt) : 0);
|
|
75
|
+
const safeBase = Math.max(0, baseMs);
|
|
76
|
+
const safeMax = Math.max(safeBase, maxMs);
|
|
77
|
+
const safeFactor = factor > 0 ? factor : DEFAULTS.factor;
|
|
78
|
+
// Clamp jitter to [0, 1) — full-range (>=1) would allow negative values
|
|
79
|
+
// after subtraction, which we want to forbid by invariant.
|
|
80
|
+
const safeJitter = Math.min(0.999, Math.max(0, jitter));
|
|
81
|
+
|
|
82
|
+
// Exponential growth capped at safeMax.
|
|
83
|
+
// Math.pow with a huge attempt count can overflow to Infinity; the
|
|
84
|
+
// Math.min picks up safeMax before Infinity escapes.
|
|
85
|
+
const unjittered = Math.min(safeMax, safeBase * Math.pow(safeFactor, a));
|
|
86
|
+
|
|
87
|
+
// Symmetric jitter in [-jitter, +jitter).
|
|
88
|
+
// Math.random() returns [0, 1); 2r - 1 maps to [-1, 1); scale by jitter.
|
|
89
|
+
const noise = (Math.random() * 2 - 1) * safeJitter;
|
|
90
|
+
const delay = unjittered * (1 + noise);
|
|
91
|
+
|
|
92
|
+
// Floor at zero (noise could in theory go slightly negative due to FP
|
|
93
|
+
// precision even with safeJitter < 1, for very small unjittered values).
|
|
94
|
+
return Math.max(0, delay);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Sleep for a jittered backoff interval. Convenience wrapper around
|
|
99
|
+
* `delayMs` + `setTimeout`.
|
|
100
|
+
*
|
|
101
|
+
* @param {number} attempt zero-based attempt counter
|
|
102
|
+
* @param {object} [opts] see {@link delayMs}
|
|
103
|
+
* @returns {Promise<number>} resolves to the actual delay that was applied
|
|
104
|
+
*/
|
|
105
|
+
function sleep(attempt, opts) {
|
|
106
|
+
const ms = delayMs(attempt, opts);
|
|
107
|
+
return new Promise((resolve) => {
|
|
108
|
+
setTimeout(() => resolve(ms), ms);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
module.exports = { delayMs, sleep, DEFAULTS };
|