@hegemonart/get-design-done 1.32.0 → 1.33.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.
Files changed (49) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +57 -0
  4. package/NOTICE +43 -5
  5. package/README.md +13 -0
  6. package/package.json +4 -2
  7. package/reference/gdd-runtime-audit.md +111 -0
  8. package/reference/gdd-threat-model.md +336 -0
  9. package/reference/registry.json +14 -0
  10. package/reference/schemas/pressure-scenario.schema.json +69 -0
  11. package/scripts/lib/peer-cli/acp-client.cjs +9 -1
  12. package/scripts/lib/peer-cli/asp-client.cjs +10 -1
  13. package/scripts/lib/peer-cli/sanitize-env.cjs +198 -0
  14. package/scripts/lib/redact.cjs +20 -1
  15. package/scripts/lib/skill-behavior/runner.cjs +187 -0
  16. package/scripts/lib/skill-behavior/stub-invoker.cjs +95 -0
  17. package/scripts/lib/skill-behavior/telemetry.cjs +379 -0
  18. package/scripts/lib/transports/ws.cjs +67 -3
  19. package/sdk/mcp/gdd-state/schemas/add_blocker.schema.json +2 -0
  20. package/sdk/mcp/gdd-state/schemas/add_decision.schema.json +1 -0
  21. package/sdk/mcp/gdd-state/schemas/add_must_have.schema.json +1 -0
  22. package/sdk/mcp/gdd-state/schemas/checkpoint.schema.json +1 -0
  23. package/sdk/mcp/gdd-state/schemas/frontmatter_update.schema.json +1 -1
  24. package/sdk/mcp/gdd-state/schemas/get.schema.json +2 -1
  25. package/sdk/mcp/gdd-state/schemas/probe_connections.schema.json +2 -0
  26. package/sdk/mcp/gdd-state/schemas/resolve_blocker.schema.json +1 -0
  27. package/sdk/mcp/gdd-state/server.js +137 -48
  28. package/sdk/mcp/gdd-state/tools/add_blocker.ts +2 -0
  29. package/sdk/mcp/gdd-state/tools/add_decision.ts +2 -0
  30. package/sdk/mcp/gdd-state/tools/add_must_have.ts +2 -0
  31. package/sdk/mcp/gdd-state/tools/checkpoint.ts +2 -0
  32. package/sdk/mcp/gdd-state/tools/frontmatter_update.ts +2 -0
  33. package/sdk/mcp/gdd-state/tools/get.ts +2 -0
  34. package/sdk/mcp/gdd-state/tools/probe_connections.ts +2 -0
  35. package/sdk/mcp/gdd-state/tools/resolve_blocker.ts +2 -0
  36. package/sdk/mcp/gdd-state/tools/set_status.ts +2 -0
  37. package/sdk/mcp/gdd-state/tools/shared.ts +117 -7
  38. package/sdk/mcp/gdd-state/tools/transition_stage.ts +2 -0
  39. package/sdk/mcp/gdd-state/tools/update_progress.ts +2 -0
  40. package/scripts/lib/cli/index.ts +0 -29
  41. package/scripts/lib/error-classifier.cjs +0 -29
  42. package/scripts/lib/event-stream/index.ts +0 -29
  43. package/scripts/lib/gdd-errors/index.ts +0 -29
  44. package/scripts/lib/gdd-state/index.ts +0 -29
  45. package/scripts/lib/iteration-budget.cjs +0 -29
  46. package/scripts/lib/jittered-backoff.cjs +0 -29
  47. package/scripts/lib/lockfile.cjs +0 -29
  48. package/scripts/mcp-servers/gdd-mcp/server.ts +0 -35
  49. package/scripts/mcp-servers/gdd-state/server.ts +0 -34
@@ -0,0 +1,198 @@
1
+ // scripts/lib/peer-cli/sanitize-env.cjs
2
+ //
3
+ // Plan 33.5-04 — peer-CLI environment sandbox (SC#4; CONTEXT D-03).
4
+ //
5
+ // ============================================================================
6
+ // WHY THIS EXISTS
7
+ // ============================================================================
8
+ //
9
+ // The two peer-CLI clients (acp-client.cjs / asp-client.cjs) spawn external
10
+ // peer binaries (Gemini / Cursor / Copilot / Qwen / Codex) over stdio. Before
11
+ // this module, both clients defaulted the child's environment to the FULL
12
+ // `process.env` whenever the caller did not supply `opts.env` (acp line ~102,
13
+ // asp line ~122). That leaks GDD's own secrets — ANTHROPIC_API_KEY, GH_TOKEN,
14
+ // any GDD_* var — into every spawned peer, even though peers authenticate with
15
+ // their OWN logged-in credentials and have no need for GDD's keys.
16
+ //
17
+ // D-03 (locked) makes the sandbox ALLOWLIST-FORWARD / DEFAULT-DENY: the child
18
+ // env is built from (a) an OS-essential baseline (just enough for a binary to
19
+ // launch on Windows + macOS + Linux) PLUS (b) an explicit caller allowlist read
20
+ // from `.design/config.json#peer_cli.env_allowlist`. Everything else is dropped.
21
+ // GDD secrets and any secret-shaped var are NEVER forwarded unless the operator
22
+ // explicitly allowlists them — a one-line escape hatch for the rare peer that
23
+ // genuinely needs an inherited provider key.
24
+ //
25
+ // No new runtime dependency (D-12): plain JS + a defensive config read that
26
+ // mirrors registry.cjs's `readEnabledPeers` idiom.
27
+
28
+ 'use strict';
29
+
30
+ const fs = require('node:fs');
31
+ const path = require('node:path');
32
+
33
+ // ── OS-essential baseline ────────────────────────────────────────────────────
34
+ //
35
+ // Exact variable names a child process generally needs to *launch* and behave
36
+ // correctly across Windows + POSIX. Kept deliberately pragmatic: anything not
37
+ // here (and not explicitly allowlisted) is dropped. The test only pins that
38
+ // PATH + HOME survive, so this set can evolve without breaking the contract.
39
+
40
+ const BASELINE = Object.freeze([
41
+ // PATH resolution (Windows uses `Path`; PATHEXT picks executable suffixes).
42
+ 'PATH',
43
+ 'Path',
44
+ 'PATHEXT',
45
+ // Home / profile (POSIX HOME; Windows USERPROFILE + HOMEDRIVE/HOMEPATH).
46
+ 'HOME',
47
+ 'USERPROFILE',
48
+ 'HOMEDRIVE',
49
+ 'HOMEPATH',
50
+ // System roots (Windows).
51
+ 'SystemRoot',
52
+ 'windir',
53
+ 'SystemDrive',
54
+ // Temp dirs (cross-platform variants).
55
+ 'TEMP',
56
+ 'TMP',
57
+ 'TMPDIR',
58
+ // Locale / shell.
59
+ 'LANG',
60
+ 'SHELL',
61
+ // Windows command interpreter + platform descriptors.
62
+ 'COMSPEC',
63
+ 'OS',
64
+ 'NUMBER_OF_PROCESSORS',
65
+ 'PROCESSOR_ARCHITECTURE',
66
+ ]);
67
+
68
+ // Documented baseline PREFIXES — any var whose name starts with one of these is
69
+ // treated as baseline (locale family + Node runtime knobs like NODE_OPTIONS).
70
+ const BASELINE_PREFIXES = Object.freeze(['LC_', 'NODE_']);
71
+
72
+ // ── Secret matchers (extra guard on the baseline) ─────────────────────────────
73
+ //
74
+ // SECRET_NAME — exact GDD-held secret variable names that must never leak.
75
+ // SECRET_PREFIX — any GDD_* var is GDD-internal and never forwarded.
76
+ // SECRET_SHAPE — generic secret-shaped suffixes; catches third-party keys a
77
+ // future baseline addition might otherwise let through.
78
+ //
79
+ // All three are overridden ONLY by an explicit entry in opts.allowlist
80
+ // (explicit allowlist WINS — see sanitizeEnv below).
81
+
82
+ const SECRET_NAME = Object.freeze([
83
+ 'ANTHROPIC_API_KEY',
84
+ 'GH_TOKEN',
85
+ 'GITHUB_TOKEN',
86
+ ]);
87
+
88
+ const SECRET_PREFIX = Object.freeze(['GDD_']);
89
+
90
+ const SECRET_SHAPE = /(_KEY|_TOKEN|_SECRET|_PASSWORD|_AUTH)$/i;
91
+
92
+ // ── Helpers ───────────────────────────────────────────────────────────────────
93
+
94
+ function isBaseline(key) {
95
+ if (BASELINE.includes(key)) return true;
96
+ for (const pfx of BASELINE_PREFIXES) {
97
+ if (key.startsWith(pfx)) return true;
98
+ }
99
+ return false;
100
+ }
101
+
102
+ function isSecret(key) {
103
+ if (SECRET_NAME.includes(key)) return true;
104
+ for (const pfx of SECRET_PREFIX) {
105
+ if (key.startsWith(pfx)) return true;
106
+ }
107
+ return SECRET_SHAPE.test(key);
108
+ }
109
+
110
+ /**
111
+ * Defensively read `<cwd>/.design/config.json` and extract
112
+ * `peer_cli.env_allowlist` (a string[]). Returns [] on ANY failure path
113
+ * (file missing, unparsable, wrong shape) — never throws. Mirrors
114
+ * registry.cjs's `readEnabledPeers` idiom so both share a defensive reader.
115
+ *
116
+ * @param {string} [cwd] defaults to process.cwd()
117
+ * @returns {string[]} allowlisted env var names (deduped); empty by default
118
+ */
119
+ function readPeerCliAllowlist(cwd) {
120
+ const root = typeof cwd === 'string' && cwd.length > 0 ? cwd : process.cwd();
121
+ const cfgPath = path.join(root, '.design', 'config.json');
122
+ let raw;
123
+ try {
124
+ raw = fs.readFileSync(cfgPath, 'utf8');
125
+ } catch {
126
+ return [];
127
+ }
128
+ let parsed;
129
+ try {
130
+ parsed = JSON.parse(raw);
131
+ } catch {
132
+ return [];
133
+ }
134
+ const peerCli = parsed && typeof parsed === 'object' ? parsed.peer_cli : null;
135
+ const list = peerCli && Array.isArray(peerCli.env_allowlist) ? peerCli.env_allowlist : [];
136
+ const out = [];
137
+ const seen = new Set();
138
+ for (const item of list) {
139
+ if (typeof item !== 'string' || item.length === 0) continue;
140
+ if (seen.has(item)) continue;
141
+ seen.add(item);
142
+ out.push(item);
143
+ }
144
+ return out;
145
+ }
146
+
147
+ // ── sanitizeEnv ─────────────────────────────────────────────────────────────--
148
+
149
+ /**
150
+ * Build a sanitized child environment (allowlist-forward / default-deny).
151
+ *
152
+ * For each KEY in sourceEnv, forward it iff:
153
+ * - KEY is explicitly in opts.allowlist (explicit allowlist WINS — even over
154
+ * the secret filters), OR
155
+ * - KEY is in the OS-essential BASELINE (exact name or a documented prefix)
156
+ * AND KEY is NOT a GDD secret / secret-shaped var.
157
+ *
158
+ * Everything else is dropped. Pure: never mutates the input.
159
+ *
160
+ * @param {Record<string,string>} [sourceEnv=process.env]
161
+ * @param {{ allowlist?: string[] }} [opts]
162
+ * @returns {Record<string,string>}
163
+ */
164
+ function sanitizeEnv(sourceEnv, opts) {
165
+ const src = sourceEnv && typeof sourceEnv === 'object' ? sourceEnv : process.env;
166
+ const o = opts && typeof opts === 'object' ? opts : {};
167
+ const allowlist = Array.isArray(o.allowlist) ? new Set(o.allowlist) : new Set();
168
+
169
+ const result = {};
170
+ for (const key of Object.keys(src)) {
171
+ const value = src[key];
172
+ // A value that is not a string (e.g. inherited prototype noise) is skipped;
173
+ // child env entries must be strings.
174
+ if (typeof value !== 'string') continue;
175
+
176
+ // Explicit allowlist wins over everything, including the secret filters.
177
+ if (allowlist.has(key)) {
178
+ result[key] = value;
179
+ continue;
180
+ }
181
+ // Otherwise the key must be baseline AND not secret-shaped.
182
+ if (isBaseline(key) && !isSecret(key)) {
183
+ result[key] = value;
184
+ }
185
+ // Default-deny: anything else is dropped.
186
+ }
187
+ return result;
188
+ }
189
+
190
+ module.exports = {
191
+ sanitizeEnv,
192
+ readPeerCliAllowlist,
193
+ BASELINE,
194
+ BASELINE_PREFIXES,
195
+ SECRET_NAME,
196
+ SECRET_PREFIX,
197
+ SECRET_SHAPE,
198
+ };
@@ -45,11 +45,30 @@ const PATTERNS = [
45
45
  type: 'slack',
46
46
  re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g,
47
47
  },
48
- // GitHub personal access token.
48
+ // GitHub personal access token (classic).
49
49
  {
50
50
  type: 'github_pat',
51
51
  re: /\bghp_[A-Za-z0-9]{36,}\b/g,
52
52
  },
53
+ // Google / Gemini / GCP API key (AIza…). Distinct shape — no collision
54
+ // with any existing pattern; placed with the specific patterns (D-07, 33.5-05).
55
+ {
56
+ type: 'gemini',
57
+ re: /\bAIza[0-9A-Za-z_\-]{35}\b/g,
58
+ },
59
+ // GitHub fine-grained PAT (github_pat_…). Distinct prefix from classic
60
+ // `ghp_` — both coexist (D-07, 33.5-05).
61
+ {
62
+ type: 'github_pat_fine_grained',
63
+ re: /\bgithub_pat_[0-9A-Za-z_]{22,}\b/g,
64
+ },
65
+ // GitHub server/oauth/user/refresh tokens (ghs_/gho_/ghu_/ghr_). The
66
+ // `[sour]` class excludes `p`, so this never collides with `ghp_` above
67
+ // (D-07, 33.5-05).
68
+ {
69
+ type: 'github_token',
70
+ re: /\bgh[sour]_[A-Za-z0-9]{36,}\b/g,
71
+ },
53
72
  // AWS access key ID.
54
73
  {
55
74
  type: 'aws',
@@ -0,0 +1,187 @@
1
+ /**
2
+ * runner.cjs — manifest-driven pressure-scenario runner (Plan 33-01).
3
+ *
4
+ * The ROOT engine of Phase 33: every later plan (33-03 scenarios, 33-04 A/B,
5
+ * 33-05 telemetry) builds on this. It loads a parsed pressure-scenario
6
+ * manifest, invokes an agent via an INJECTABLE `invokeAgent(prompt, opts) ->
7
+ * { text }` seam, runs N attempts (default 3), scores each response against
8
+ * the manifest's expected_compliance[] (must-match regexes) and
9
+ * expected_violations[] (failure regexes), applies a STRICT 2/3 majority
10
+ * rule, and emits a structured result.
11
+ *
12
+ * D-03 — invoker-agnostic, NO direct Anthropic SDK dependency:
13
+ * This file deps on node:fs + node:path ONLY. It NEVER requires the
14
+ * Anthropic SDK package. The default invoker is the deterministic stub at
15
+ * ./stub-invoker.cjs so CI/tests run with no API key and no network. A
16
+ * maintainer later wires a real invoker (peer-CLI ACP spawn or a thin keyed
17
+ * SDK adapter) by passing opts.invokeAgent. (The guard test asserts the
18
+ * exact package name never appears in this source.)
19
+ *
20
+ * Purity / injectability:
21
+ * invokeAgent, the clock (now), and fs are all injectable via opts so every
22
+ * test drives the stub with a fixed clock.
23
+ *
24
+ * Result (EXACT shape):
25
+ * {
26
+ * scenario: string, // = manifest.name
27
+ * attempts: Array<{ // one entry per attempt (length === attempts)
28
+ * text: string,
29
+ * pass: boolean, // ALL compliance matched AND zero violations
30
+ * compliance_hits: number, // # expected_compliance regexes matching this text
31
+ * violation_hits: number, // # expected_violations regexes matching this text
32
+ * }>,
33
+ * pass: boolean, // MAJORITY: (#passing attempts) * 2 > attempts.length
34
+ * compliance_hits: number, // aggregate sum across attempts
35
+ * violation_hits: number, // aggregate sum across attempts
36
+ * }
37
+ *
38
+ * Pattern reference (NOT a dependency): scripts/lib/event-chain.cjs shows the
39
+ * house CommonJS idiom (defensive fs, pure functions). Style mirrored, not imported.
40
+ */
41
+
42
+ 'use strict';
43
+
44
+ const nodeFs = require('node:fs');
45
+ const path = require('node:path');
46
+
47
+ const DEFAULT_ATTEMPTS = 3;
48
+
49
+ /**
50
+ * Load a pressure-scenario manifest. Accepts either an already-parsed object
51
+ * (returned as-is) or a path to a JSON file (read + parsed via the injectable
52
+ * fs). Keeping this injectable lets later plans (33-03) load real manifest
53
+ * files while tests pass inline objects.
54
+ *
55
+ * @param {object | string} input parsed manifest OR a path to a JSON manifest
56
+ * @param {{ fs?: typeof import('node:fs') }} [deps]
57
+ * @returns {object} the parsed manifest
58
+ */
59
+ function loadManifest(input, deps) {
60
+ if (input && typeof input === 'object') {
61
+ return input;
62
+ }
63
+ if (typeof input === 'string') {
64
+ const fs = (deps && deps.fs) || nodeFs;
65
+ const abs = path.isAbsolute(input) ? input : path.resolve(process.cwd(), input);
66
+ const raw = fs.readFileSync(abs, 'utf8');
67
+ return JSON.parse(raw);
68
+ }
69
+ throw new TypeError('loadManifest: input must be a parsed manifest object or a path string');
70
+ }
71
+
72
+ /**
73
+ * Compile an array of regex SOURCE strings into RegExp objects. Manifests
74
+ * author patterns as plain strings (NOT pre-compiled) so they stay JSON-safe;
75
+ * the runner owns compilation.
76
+ *
77
+ * @param {unknown} sources
78
+ * @returns {RegExp[]}
79
+ */
80
+ function compilePatterns(sources) {
81
+ if (!Array.isArray(sources)) return [];
82
+ return sources.map((src) => new RegExp(String(src)));
83
+ }
84
+
85
+ /**
86
+ * Coerce an invoker's `.text` to a string. A non-string (or absent) value
87
+ * becomes '' so scoring never throws and is treated as a compliance-miss.
88
+ *
89
+ * @param {unknown} response
90
+ * @returns {string}
91
+ */
92
+ function textOf(response) {
93
+ if (response && typeof response.text === 'string') return response.text;
94
+ return '';
95
+ }
96
+
97
+ /**
98
+ * Score a single response text against pre-compiled compliance/violation
99
+ * regexes.
100
+ *
101
+ * @param {string} text
102
+ * @param {RegExp[]} complianceRes
103
+ * @param {RegExp[]} violationRes
104
+ * @returns {{ text: string, pass: boolean, compliance_hits: number, violation_hits: number }}
105
+ */
106
+ function scoreAttempt(text, complianceRes, violationRes) {
107
+ const compliance_hits = complianceRes.filter((re) => re.test(text)).length;
108
+ const violation_hits = violationRes.filter((re) => re.test(text)).length;
109
+ // An attempt PASSES iff ALL compliance regexes matched AND zero violations did.
110
+ const pass = compliance_hits === complianceRes.length && violation_hits === 0;
111
+ return { text, pass, compliance_hits, violation_hits };
112
+ }
113
+
114
+ /**
115
+ * Run a pressure scenario: invoke the seam N times, score each response, and
116
+ * apply a strict majority rule.
117
+ *
118
+ * @param {object} manifest parsed pressure-scenario manifest
119
+ * { name, target_skill, pressures[], setup_prompt, expected_compliance[], expected_violations[] }
120
+ * @param {{
121
+ * invokeAgent?: (prompt: string, opts: object) => { text: string },
122
+ * attempts?: number,
123
+ * now?: () => number,
124
+ * fs?: typeof import('node:fs'),
125
+ * }} [opts]
126
+ * @returns {{
127
+ * scenario: string,
128
+ * attempts: Array<{ text: string, pass: boolean, compliance_hits: number, violation_hits: number }>,
129
+ * pass: boolean,
130
+ * compliance_hits: number,
131
+ * violation_hits: number,
132
+ * }}
133
+ */
134
+ function runScenario(manifest, opts) {
135
+ const o = opts || {};
136
+ // D-03: default to the deterministic stub invoker — never the real SDK.
137
+ const invokeAgent = o.invokeAgent || require('./stub-invoker.cjs').invokeAgent;
138
+ const attempts =
139
+ Number.isInteger(o.attempts) && o.attempts > 0 ? o.attempts : DEFAULT_ATTEMPTS;
140
+ // Injectable clock (reserved for future telemetry timestamps; called so the
141
+ // seam is exercised and a fixed now() is honored).
142
+ const now = typeof o.now === 'function' ? o.now : Date.now;
143
+
144
+ const complianceRes = compilePatterns(manifest && manifest.expected_compliance);
145
+ const violationRes = compilePatterns(manifest && manifest.expected_violations);
146
+ const scenario = manifest && manifest.name;
147
+ const prompt = (manifest && manifest.setup_prompt) || '';
148
+
149
+ const attemptResults = [];
150
+ for (let i = 0; i < attempts; i++) {
151
+ now(); // exercise the injectable clock (deterministic under a fixed now)
152
+ let text = '';
153
+ try {
154
+ // Pass the scenario key through so the stub (or a real invoker) can key on it.
155
+ const response = invokeAgent(prompt, { scenario, attempt: i });
156
+ text = textOf(response);
157
+ } catch (_err) {
158
+ // A thrown invoker must NOT crash the run — record a failed empty attempt.
159
+ text = '';
160
+ }
161
+ attemptResults.push(scoreAttempt(text, complianceRes, violationRes));
162
+ }
163
+
164
+ const passed = attemptResults.filter((a) => a.pass).length;
165
+ // STRICT majority: 2/3 and 3/3 pass; 0/3 and 1/3 fail.
166
+ const pass = passed * 2 > attemptResults.length;
167
+
168
+ const compliance_hits = attemptResults.reduce((sum, a) => sum + a.compliance_hits, 0);
169
+ const violation_hits = attemptResults.reduce((sum, a) => sum + a.violation_hits, 0);
170
+
171
+ return {
172
+ scenario,
173
+ attempts: attemptResults,
174
+ pass,
175
+ compliance_hits,
176
+ violation_hits,
177
+ };
178
+ }
179
+
180
+ module.exports = {
181
+ runScenario,
182
+ loadManifest,
183
+ // Exposed for unit-level reuse / later plans; not part of the core contract.
184
+ scoreAttempt,
185
+ compilePatterns,
186
+ DEFAULT_ATTEMPTS,
187
+ };
@@ -0,0 +1,95 @@
1
+ /**
2
+ * stub-invoker.cjs — deterministic, scenario-keyed agent invoker (Plan 33-01).
3
+ *
4
+ * The DEFAULT invokeAgent seam for `runner.cjs` (D-03): the runner is
5
+ * invoker-agnostic and exposes an injectable `invokeAgent(prompt, opts) ->
6
+ * { text }` seam. A maintainer later wires a REAL invoker (a peer-CLI ACP
7
+ * spawn of a local `claude`/`codex`, or a thin keyed SDK adapter); this stub
8
+ * is what every Phase-33 CI/structural test drives so runs are reproducible
9
+ * with NO API key and NO network.
10
+ *
11
+ * Determinism contract:
12
+ * * NO randomness, NO network, NO @anthropic-ai/sdk.
13
+ * * A canned response is resolved by a KEY derived from
14
+ * opts.scenario || opts.stubKey, falling back to scanning `prompt` for a
15
+ * registered key marker.
16
+ * * An UNKNOWN key returns a neutral { text: '' } so the runner never throws.
17
+ *
18
+ * Tests MAY instead pass their own inline invokeAgent to runScenario — both
19
+ * paths are valid (D-03). This module is the no-arg default.
20
+ */
21
+
22
+ 'use strict';
23
+
24
+ // Internal canned-response table: key -> response text. Seeded with one
25
+ // illustrative scenario; callers extend it via register().
26
+ const TABLE = new Map([
27
+ // A neutral, compliance-shaped sample so the default stub is non-empty for a
28
+ // known demo key. Real scenarios register their own canned text.
29
+ [
30
+ 'runner-demo',
31
+ 'A <HARD-GATE> blocks me — I must write the brief before any other stage.',
32
+ ],
33
+ ]);
34
+
35
+ /**
36
+ * Seed or overwrite a canned response for a scenario key.
37
+ *
38
+ * @param {string} key scenario name / stub key
39
+ * @param {string} text canned response text the stub returns for that key
40
+ * @returns {void}
41
+ */
42
+ function register(key, text) {
43
+ if (typeof key !== 'string' || key.length === 0) {
44
+ throw new TypeError('register: key must be a non-empty string');
45
+ }
46
+ TABLE.set(key, typeof text === 'string' ? text : String(text == null ? '' : text));
47
+ }
48
+
49
+ /**
50
+ * Resolve a response key from opts, then (as a fallback) by scanning the
51
+ * prompt for any registered key as a substring marker.
52
+ *
53
+ * @param {string} prompt
54
+ * @param {{scenario?: string, stubKey?: string} | undefined} opts
55
+ * @returns {string | undefined}
56
+ */
57
+ function resolveKey(prompt, opts) {
58
+ if (opts && typeof opts.scenario === 'string' && opts.scenario.length > 0) {
59
+ return opts.scenario;
60
+ }
61
+ if (opts && typeof opts.stubKey === 'string' && opts.stubKey.length > 0) {
62
+ return opts.stubKey;
63
+ }
64
+ if (typeof prompt === 'string' && prompt.length > 0) {
65
+ for (const key of TABLE.keys()) {
66
+ if (prompt.includes(key)) return key;
67
+ }
68
+ }
69
+ return undefined;
70
+ }
71
+
72
+ /**
73
+ * Deterministic invokeAgent-shaped function. Returns a canned { text } for a
74
+ * known scenario key, or a neutral { text: '' } for an unknown key (so the
75
+ * runner can score it as a compliance-miss without throwing).
76
+ *
77
+ * @param {string} prompt
78
+ * @param {{scenario?: string, stubKey?: string}} [opts]
79
+ * @returns {{ text: string }}
80
+ */
81
+ function invokeAgent(prompt, opts) {
82
+ const key = resolveKey(prompt, opts);
83
+ if (key !== undefined && TABLE.has(key)) {
84
+ return { text: TABLE.get(key) };
85
+ }
86
+ // Unknown key -> neutral default; never throw.
87
+ return { text: '' };
88
+ }
89
+
90
+ module.exports = {
91
+ invokeAgent,
92
+ register,
93
+ // Exposed for advanced callers/tests that want to inspect or reset seeds.
94
+ _table: TABLE,
95
+ };