@hegemonart/get-design-done 1.19.6 → 1.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +11 -14
- package/.claude-plugin/plugin.json +9 -32
- package/CHANGELOG.md +138 -0
- package/README.md +54 -1
- package/agents/design-reflector.md +13 -0
- package/bin/gdd-sdk +55 -0
- package/connections/connections.md +3 -0
- package/connections/figma.md +2 -0
- package/connections/gdd-state.md +186 -0
- package/hooks/budget-enforcer.ts +716 -0
- package/hooks/context-exhaustion.ts +251 -0
- package/hooks/gdd-read-injection-scanner.ts +172 -0
- package/hooks/hooks.json +3 -3
- package/package.json +32 -51
- package/reference/codex-tools.md +53 -0
- package/reference/config-schema.md +2 -2
- package/reference/error-recovery.md +58 -0
- package/reference/gemini-tools.md +53 -0
- package/reference/registry.json +21 -0
- package/reference/schemas/budget.schema.json +42 -0
- package/reference/schemas/events.schema.json +55 -0
- package/reference/schemas/generated.d.ts +419 -0
- package/reference/schemas/iteration-budget.schema.json +36 -0
- package/reference/schemas/mcp-gdd-state-tools.schema.json +89 -0
- package/reference/schemas/rate-limits.schema.json +31 -0
- package/scripts/aggregate-agent-metrics.ts +282 -0
- package/scripts/codegen-schema-types.ts +149 -0
- package/scripts/e2e/run-headless.ts +514 -0
- package/scripts/lib/cli/commands/audit.ts +382 -0
- package/scripts/lib/cli/commands/init.ts +217 -0
- package/scripts/lib/cli/commands/query.ts +329 -0
- package/scripts/lib/cli/commands/run.ts +656 -0
- package/scripts/lib/cli/commands/stage.ts +468 -0
- package/scripts/lib/cli/index.ts +167 -0
- package/scripts/lib/cli/parse-args.ts +336 -0
- package/scripts/lib/context-engine/index.ts +116 -0
- package/scripts/lib/context-engine/manifest.ts +69 -0
- package/scripts/lib/context-engine/truncate.ts +282 -0
- package/scripts/lib/context-engine/types.ts +59 -0
- package/scripts/lib/discuss-parallel-runner/aggregator.ts +448 -0
- package/scripts/lib/discuss-parallel-runner/discussants.ts +430 -0
- package/scripts/lib/discuss-parallel-runner/index.ts +223 -0
- package/scripts/lib/discuss-parallel-runner/types.ts +184 -0
- package/scripts/lib/error-classifier.cjs +232 -0
- package/scripts/lib/error-classifier.d.cts +44 -0
- package/scripts/lib/event-stream/emitter.ts +88 -0
- package/scripts/lib/event-stream/index.ts +164 -0
- package/scripts/lib/event-stream/types.ts +127 -0
- package/scripts/lib/event-stream/writer.ts +154 -0
- package/scripts/lib/explore-parallel-runner/index.ts +294 -0
- package/scripts/lib/explore-parallel-runner/mappers.ts +290 -0
- package/scripts/lib/explore-parallel-runner/synthesizer.ts +295 -0
- package/scripts/lib/explore-parallel-runner/types.ts +139 -0
- package/scripts/lib/gdd-errors/classification.ts +124 -0
- package/scripts/lib/gdd-errors/index.ts +218 -0
- package/scripts/lib/gdd-state/gates.ts +216 -0
- package/scripts/lib/gdd-state/index.ts +167 -0
- package/scripts/lib/gdd-state/lockfile.ts +232 -0
- package/scripts/lib/gdd-state/mutator.ts +574 -0
- package/scripts/lib/gdd-state/parser.ts +523 -0
- package/scripts/lib/gdd-state/types.ts +179 -0
- package/scripts/lib/harness/detect.ts +90 -0
- package/scripts/lib/harness/index.ts +64 -0
- package/scripts/lib/harness/tool-map.ts +142 -0
- package/scripts/lib/init-runner/index.ts +396 -0
- package/scripts/lib/init-runner/researchers.ts +245 -0
- package/scripts/lib/init-runner/scaffold.ts +224 -0
- package/scripts/lib/init-runner/synthesizer.ts +224 -0
- package/scripts/lib/init-runner/types.ts +143 -0
- package/scripts/lib/iteration-budget.cjs +205 -0
- package/scripts/lib/iteration-budget.d.cts +32 -0
- package/scripts/lib/jittered-backoff.cjs +112 -0
- package/scripts/lib/jittered-backoff.d.cts +38 -0
- package/scripts/lib/lockfile.cjs +177 -0
- package/scripts/lib/lockfile.d.cts +21 -0
- package/scripts/lib/logger/index.ts +251 -0
- package/scripts/lib/logger/sinks.ts +269 -0
- package/scripts/lib/logger/types.ts +110 -0
- package/scripts/lib/pipeline-runner/human-gate.ts +134 -0
- package/scripts/lib/pipeline-runner/index.ts +527 -0
- package/scripts/lib/pipeline-runner/stage-handlers.ts +339 -0
- package/scripts/lib/pipeline-runner/state-machine.ts +144 -0
- package/scripts/lib/pipeline-runner/types.ts +183 -0
- package/scripts/lib/prompt-sanitizer/index.ts +435 -0
- package/scripts/lib/prompt-sanitizer/patterns.ts +173 -0
- package/scripts/lib/rate-guard.cjs +365 -0
- package/scripts/lib/rate-guard.d.cts +38 -0
- package/scripts/lib/session-runner/errors.ts +406 -0
- package/scripts/lib/session-runner/index.ts +715 -0
- package/scripts/lib/session-runner/transcript.ts +189 -0
- package/scripts/lib/session-runner/types.ts +144 -0
- package/scripts/lib/tool-scoping/index.ts +219 -0
- package/scripts/lib/tool-scoping/parse-agent-tools.ts +207 -0
- package/scripts/lib/tool-scoping/stage-scopes.ts +139 -0
- package/scripts/lib/tool-scoping/types.ts +77 -0
- package/scripts/mcp-servers/gdd-state/schemas/add_blocker.schema.json +67 -0
- package/scripts/mcp-servers/gdd-state/schemas/add_decision.schema.json +68 -0
- package/scripts/mcp-servers/gdd-state/schemas/add_must_have.schema.json +68 -0
- package/scripts/mcp-servers/gdd-state/schemas/checkpoint.schema.json +51 -0
- package/scripts/mcp-servers/gdd-state/schemas/frontmatter_update.schema.json +62 -0
- package/scripts/mcp-servers/gdd-state/schemas/get.schema.json +51 -0
- package/scripts/mcp-servers/gdd-state/schemas/probe_connections.schema.json +75 -0
- package/scripts/mcp-servers/gdd-state/schemas/resolve_blocker.schema.json +66 -0
- package/scripts/mcp-servers/gdd-state/schemas/set_status.schema.json +47 -0
- package/scripts/mcp-servers/gdd-state/schemas/transition_stage.schema.json +70 -0
- package/scripts/mcp-servers/gdd-state/schemas/update_progress.schema.json +58 -0
- package/scripts/mcp-servers/gdd-state/server.ts +288 -0
- package/scripts/mcp-servers/gdd-state/tools/add_blocker.ts +72 -0
- package/scripts/mcp-servers/gdd-state/tools/add_decision.ts +89 -0
- package/scripts/mcp-servers/gdd-state/tools/add_must_have.ts +113 -0
- package/scripts/mcp-servers/gdd-state/tools/checkpoint.ts +60 -0
- package/scripts/mcp-servers/gdd-state/tools/frontmatter_update.ts +91 -0
- package/scripts/mcp-servers/gdd-state/tools/get.ts +51 -0
- package/scripts/mcp-servers/gdd-state/tools/index.ts +51 -0
- package/scripts/mcp-servers/gdd-state/tools/probe_connections.ts +73 -0
- package/scripts/mcp-servers/gdd-state/tools/resolve_blocker.ts +84 -0
- package/scripts/mcp-servers/gdd-state/tools/set_status.ts +54 -0
- package/scripts/mcp-servers/gdd-state/tools/shared.ts +194 -0
- package/scripts/mcp-servers/gdd-state/tools/transition_stage.ts +80 -0
- package/scripts/mcp-servers/gdd-state/tools/update_progress.ts +81 -0
- package/scripts/validate-frontmatter.ts +114 -0
- package/scripts/validate-schemas.ts +401 -0
- package/skills/brief/SKILL.md +15 -6
- package/skills/design/SKILL.md +31 -13
- package/skills/explore/SKILL.md +41 -17
- package/skills/health/SKILL.md +15 -4
- package/skills/optimize/SKILL.md +3 -3
- package/skills/pause/SKILL.md +16 -10
- package/skills/plan/SKILL.md +33 -17
- package/skills/progress/SKILL.md +15 -11
- package/skills/resume/SKILL.md +19 -10
- package/skills/settings/SKILL.md +11 -3
- package/skills/todo/SKILL.md +12 -3
- package/skills/verify/SKILL.md +65 -29
- package/hooks/budget-enforcer.js +0 -329
- package/hooks/context-exhaustion.js +0 -127
- package/hooks/gdd-read-injection-scanner.js +0 -39
- package/scripts/aggregate-agent-metrics.js +0 -173
- package/scripts/validate-frontmatter.cjs +0 -68
- package/scripts/validate-schemas.cjs +0 -242
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// scripts/lib/init-runner/types.ts — public type surface for the
|
|
2
|
+
// `gdd-sdk init` runner (Plan 21-08, SDK-20).
|
|
3
|
+
//
|
|
4
|
+
// The init runner bootstraps a new project's `.design/` directory by
|
|
5
|
+
// spawning a fixed roster of 4 researchers in parallel through the
|
|
6
|
+
// session-runner (Plan 21-01) and then running a synthesizer pass that
|
|
7
|
+
// composes `.design/DESIGN-CONTEXT.md` from the researcher outputs.
|
|
8
|
+
//
|
|
9
|
+
// These types are consumed by:
|
|
10
|
+
// * `researchers.ts` — dispatch + outcome shape.
|
|
11
|
+
// * `synthesizer.ts` — synthesizer I/O contract.
|
|
12
|
+
// * `scaffold.ts` — STATE.md + backup helpers.
|
|
13
|
+
// * `index.ts` — top-level `run()` orchestrator.
|
|
14
|
+
// * CLI wiring (Plan 21-09) — consumes `InitRunnerResult`.
|
|
15
|
+
//
|
|
16
|
+
// No file outside `scripts/lib/init-runner/` should construct any of
|
|
17
|
+
// these shapes directly; `index.ts` re-exports each one.
|
|
18
|
+
|
|
19
|
+
import type { BudgetCap, QueryOverride } from '../session-runner/types.ts';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Locked roster of researcher names. The init cycle always spawns
|
|
23
|
+
* exactly these four; adding a fifth is a breaking change because it
|
|
24
|
+
* widens every `ResearcherOutcome[]` consumer's input.
|
|
25
|
+
*/
|
|
26
|
+
export type ResearcherName =
|
|
27
|
+
| 'design-system-audit'
|
|
28
|
+
| 'brand-context'
|
|
29
|
+
| 'accessibility-baseline'
|
|
30
|
+
| 'competitive-references';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* One researcher's spec. The `agentPath` is optional — if the file is
|
|
34
|
+
* absent on disk, the runner falls back to the `init` stage scope from
|
|
35
|
+
* `tool-scoping` (Plan 21-03), which grants the broad research-capable
|
|
36
|
+
* toolset (Read, Write, Grep, Glob, Bash, Task, WebSearch, WebFetch).
|
|
37
|
+
*/
|
|
38
|
+
export interface ResearcherSpec {
|
|
39
|
+
readonly name: ResearcherName;
|
|
40
|
+
/** Optional agent frontmatter path; missing → init stage scope. */
|
|
41
|
+
readonly agentPath?: string;
|
|
42
|
+
readonly prompt: string;
|
|
43
|
+
/** Where the researcher's markdown output lands (relative to cwd). */
|
|
44
|
+
readonly outputPath: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Per-researcher outcome. Never-throws contract: if the session errored
|
|
49
|
+
* internally, `status` is `'error'` and the `error` field is populated —
|
|
50
|
+
* the runner never re-raises.
|
|
51
|
+
*
|
|
52
|
+
* `output_exists` + `output_bytes` are measured on disk AFTER the
|
|
53
|
+
* session returns; a successful session that failed to write its file
|
|
54
|
+
* lands with `status: 'completed'` and `output_exists: false` so the
|
|
55
|
+
* synthesizer can drop the slot cleanly.
|
|
56
|
+
*/
|
|
57
|
+
export interface ResearcherOutcome {
|
|
58
|
+
readonly name: ResearcherName;
|
|
59
|
+
readonly status: 'completed' | 'error';
|
|
60
|
+
readonly output_exists: boolean;
|
|
61
|
+
readonly output_bytes: number;
|
|
62
|
+
readonly usage: {
|
|
63
|
+
readonly input_tokens: number;
|
|
64
|
+
readonly output_tokens: number;
|
|
65
|
+
readonly usd_cost: number;
|
|
66
|
+
};
|
|
67
|
+
readonly duration_ms: number;
|
|
68
|
+
readonly error?: { readonly code: string; readonly message: string };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Terminal status for a full `run()` invocation.
|
|
73
|
+
*
|
|
74
|
+
* * `completed` — every step ran; scaffold + synth produced.
|
|
75
|
+
* * `already-initialized` — `.design/STATE.md` exists and `force` is off.
|
|
76
|
+
* * `no-researchers-succeeded` — all 4 researchers errored; synth skipped.
|
|
77
|
+
* * `error` — precondition failure (e.g., STATE-TEMPLATE.md missing).
|
|
78
|
+
*/
|
|
79
|
+
export type InitStatus =
|
|
80
|
+
| 'completed'
|
|
81
|
+
| 'already-initialized'
|
|
82
|
+
| 'no-researchers-succeeded'
|
|
83
|
+
| 'error';
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Options for the top-level `run()`. Every researcher and the synthesizer
|
|
87
|
+
* get their own budget envelope so a runaway researcher cannot starve
|
|
88
|
+
* the synthesizer's token budget.
|
|
89
|
+
*
|
|
90
|
+
* `runOverride` + `synthesizerPromptOverride` are test injection points;
|
|
91
|
+
* production callers leave them unset.
|
|
92
|
+
*/
|
|
93
|
+
export interface InitRunnerOptions {
|
|
94
|
+
/** Researchers to run. Default: `DEFAULT_RESEARCHERS` (the 4-locked roster). */
|
|
95
|
+
readonly researchers?: readonly ResearcherSpec[];
|
|
96
|
+
/** Per-researcher budget envelope. Each researcher gets a fresh copy. */
|
|
97
|
+
readonly budget: BudgetCap;
|
|
98
|
+
/** Max assistant turns per researcher session. */
|
|
99
|
+
readonly maxTurnsPerResearcher: number;
|
|
100
|
+
/** Synthesizer budget envelope. */
|
|
101
|
+
readonly synthesizerBudget: BudgetCap;
|
|
102
|
+
/** Max assistant turns for the synthesizer session. */
|
|
103
|
+
readonly synthesizerMaxTurns: number;
|
|
104
|
+
/** Parallelism cap for researcher dispatch. Default: 4. */
|
|
105
|
+
readonly concurrency?: number;
|
|
106
|
+
/** Working directory; `.design/` resolved relative to this. Default: `process.cwd()`. */
|
|
107
|
+
readonly cwd?: string;
|
|
108
|
+
/** Force re-init: backup existing `.design/` to `.design.backup.<ISO>/`. */
|
|
109
|
+
readonly force?: boolean;
|
|
110
|
+
/** Path to `reference/STATE-TEMPLATE.md`; defaults to plugin-package-local. */
|
|
111
|
+
readonly stateTemplatePath?: string;
|
|
112
|
+
/** Test-injectable session-runner `run()` replacement. */
|
|
113
|
+
readonly runOverride?: QueryOverride;
|
|
114
|
+
/** Override the synthesizer's embedded prompt. */
|
|
115
|
+
readonly synthesizerPromptOverride?: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Terminal result from `run()`. The union discriminant is `status`;
|
|
120
|
+
* `researchers` + `scaffold` + `total_usage` are populated on all
|
|
121
|
+
* branches (empty arrays / false flags / zeroed usage) so callers can
|
|
122
|
+
* present a uniform summary.
|
|
123
|
+
*/
|
|
124
|
+
export interface InitRunnerResult {
|
|
125
|
+
readonly status: InitStatus;
|
|
126
|
+
/** Resolved working directory the run targeted. */
|
|
127
|
+
readonly cwd: string;
|
|
128
|
+
/** Resolved `.design/` directory path (absolute). */
|
|
129
|
+
readonly design_dir: string;
|
|
130
|
+
readonly researchers: readonly ResearcherOutcome[];
|
|
131
|
+
readonly scaffold: {
|
|
132
|
+
readonly state_md_written: boolean;
|
|
133
|
+
readonly design_context_md_written: boolean;
|
|
134
|
+
/** When `opts.force` triggered a backup, the backup dir path. */
|
|
135
|
+
readonly backup_dir?: string;
|
|
136
|
+
};
|
|
137
|
+
/** Aggregated usage across all researchers + the synthesizer. */
|
|
138
|
+
readonly total_usage: {
|
|
139
|
+
readonly input_tokens: number;
|
|
140
|
+
readonly output_tokens: number;
|
|
141
|
+
readonly usd_cost: number;
|
|
142
|
+
};
|
|
143
|
+
}
|
|
@@ -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 } = 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
|
+
fs.renameSync(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,32 @@
|
|
|
1
|
+
// scripts/lib/iteration-budget.d.cts — types for iteration-budget.cjs.
|
|
2
|
+
|
|
3
|
+
export interface BudgetState {
|
|
4
|
+
budget: number;
|
|
5
|
+
remaining: number;
|
|
6
|
+
consumed: number;
|
|
7
|
+
refunded: number;
|
|
8
|
+
updatedAt: string; // ISO-8601
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Error thrown by {@link consume} when remaining would drop below 0. */
|
|
12
|
+
export class IterationBudgetExhaustedError extends Error {
|
|
13
|
+
readonly name: 'IterationBudgetExhaustedError';
|
|
14
|
+
readonly amount: number;
|
|
15
|
+
readonly state: BudgetState;
|
|
16
|
+
constructor(amount: number, state: BudgetState);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Consume N units from the remaining budget. Throws
|
|
21
|
+
* {@link IterationBudgetExhaustedError} when N would send remaining below zero.
|
|
22
|
+
*/
|
|
23
|
+
export function consume(amount?: number): Promise<BudgetState>;
|
|
24
|
+
|
|
25
|
+
/** Refund N units, capped at `budget`. */
|
|
26
|
+
export function refund(amount?: number): Promise<BudgetState>;
|
|
27
|
+
|
|
28
|
+
/** Current snapshot; non-locking read. */
|
|
29
|
+
export function remaining(): BudgetState;
|
|
30
|
+
|
|
31
|
+
/** Initialize or restart the budget. Default 50. */
|
|
32
|
+
export function reset(budget?: number): Promise<BudgetState>;
|
|
@@ -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 };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// scripts/lib/jittered-backoff.d.cts — types for jittered-backoff.cjs.
|
|
2
|
+
//
|
|
3
|
+
// Paired ambient declaration for TS consumers that `require()` the .cjs
|
|
4
|
+
// module. `.d.cts` suffix matches the `.cjs` runtime file so TS's Node16
|
|
5
|
+
// resolver picks it up for CommonJS require calls from .ts files.
|
|
6
|
+
|
|
7
|
+
export interface BackoffOptions {
|
|
8
|
+
/** Initial delay before any jitter. Default 100ms. */
|
|
9
|
+
baseMs?: number;
|
|
10
|
+
/** Maximum un-jittered base. Default 30_000ms. */
|
|
11
|
+
maxMs?: number;
|
|
12
|
+
/** Per-attempt multiplier. Default 2. */
|
|
13
|
+
factor?: number;
|
|
14
|
+
/** Symmetric jitter fraction in [0, 1). Default 0.2. */
|
|
15
|
+
jitter?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Default backoff parameters (frozen). */
|
|
19
|
+
export const DEFAULTS: Readonly<Required<BackoffOptions>>;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Compute the jittered delay in milliseconds for a given attempt number.
|
|
23
|
+
*
|
|
24
|
+
* @param attempt zero-based attempt counter (0 = first retry)
|
|
25
|
+
* @param opts optional overrides for the four backoff parameters
|
|
26
|
+
* @returns delay in ms. Never negative. May exceed `maxMs` by up to
|
|
27
|
+
* `jitter * maxMs` on the high side.
|
|
28
|
+
*/
|
|
29
|
+
export function delayMs(attempt: number, opts?: BackoffOptions): number;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Sleep for a jittered backoff interval.
|
|
33
|
+
*
|
|
34
|
+
* @param attempt zero-based attempt counter
|
|
35
|
+
* @param opts see {@link BackoffOptions}
|
|
36
|
+
* @returns the actual delay that was applied
|
|
37
|
+
*/
|
|
38
|
+
export function sleep(attempt: number, opts?: BackoffOptions): Promise<number>;
|