@opengsd/gsd-core 1.7.0-rc.4 → 1.7.0-rc.6

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 (113) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +20 -0
  4. package/agents/gsd-doc-classifier.md +105 -0
  5. package/agents/gsd-doc-synthesizer.md +61 -0
  6. package/agents/gsd-ui-checker.md +30 -0
  7. package/agents/gsd-ui-researcher.md +1 -0
  8. package/bin/install.js +1568 -622
  9. package/gsd-core/bin/gsd-tools.cjs +40 -1
  10. package/gsd-core/bin/lib/api-coverage.cjs +466 -0
  11. package/gsd-core/bin/lib/audit.cjs +6 -3
  12. package/gsd-core/bin/lib/capability-loader.cjs +11 -9
  13. package/gsd-core/bin/lib/capability-registry.cjs +761 -84
  14. package/gsd-core/bin/lib/capability-validator.cjs +56 -18
  15. package/gsd-core/bin/lib/capability-writer.cjs +10 -1
  16. package/gsd-core/bin/lib/check-command-router.cjs +242 -3
  17. package/gsd-core/bin/lib/commands.cjs +7 -5
  18. package/gsd-core/bin/lib/config-loader.cjs +1 -0
  19. package/gsd-core/bin/lib/config.cjs +96 -0
  20. package/gsd-core/bin/lib/core-utils.cjs +4 -1
  21. package/gsd-core/bin/lib/host-integration-adapters/cline-sdk-binding.cjs +234 -0
  22. package/gsd-core/bin/lib/host-integration-adapters/imperative-hook-bus.cjs +145 -0
  23. package/gsd-core/bin/lib/host-integration.cjs +45 -4
  24. package/gsd-core/bin/lib/init.cjs +76 -39
  25. package/gsd-core/bin/lib/install-effort-resolver.cjs +213 -0
  26. package/gsd-core/bin/lib/install-engine.cjs +228 -18
  27. package/gsd-core/bin/lib/installer-migration-report.cjs +7 -0
  28. package/gsd-core/bin/lib/loop-resolver.cjs +68 -17
  29. package/gsd-core/bin/lib/markdown-sectionizer.cjs +50 -11
  30. package/gsd-core/bin/lib/mcp-server.cjs +18 -7
  31. package/gsd-core/bin/lib/milestone.cjs +3 -3
  32. package/gsd-core/bin/lib/normalize-test-command.cjs +187 -0
  33. package/gsd-core/bin/lib/phase-id.cjs +132 -3
  34. package/gsd-core/bin/lib/phase.cjs +78 -16
  35. package/gsd-core/bin/lib/planning-workspace.cjs +17 -0
  36. package/gsd-core/bin/lib/review-reviewer-selection.cjs +24 -7
  37. package/gsd-core/bin/lib/roadmap-command-router.cjs +5 -4
  38. package/gsd-core/bin/lib/roadmap-parser.cjs +21 -30
  39. package/gsd-core/bin/lib/roadmap-upgrade.cjs +9 -9
  40. package/gsd-core/bin/lib/roadmap.cjs +42 -56
  41. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +248 -44
  42. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +2 -2
  43. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +39 -23
  44. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +19 -5
  45. package/gsd-core/bin/lib/runtime-homes.cjs +30 -0
  46. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +534 -37
  47. package/gsd-core/bin/lib/runtime-name-policy.cjs +63 -5
  48. package/gsd-core/bin/lib/security.cjs +6 -36
  49. package/gsd-core/bin/lib/shell-command-projection.cjs +115 -2
  50. package/gsd-core/bin/lib/spec-section.cjs +111 -0
  51. package/gsd-core/bin/lib/stale-bake-guard.cjs +30 -10
  52. package/gsd-core/bin/lib/state-transition.cjs +1 -1
  53. package/gsd-core/bin/lib/state.cjs +24 -24
  54. package/gsd-core/bin/lib/surface.cjs +40 -6
  55. package/gsd-core/bin/lib/uat.cjs +4 -1
  56. package/gsd-core/bin/lib/ui-consideration-probe.cjs +249 -0
  57. package/gsd-core/bin/lib/validate.cjs +15 -6
  58. package/gsd-core/bin/lib/verify.cjs +33 -37
  59. package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
  60. package/gsd-core/bin/shared/model-catalog.json +14 -9
  61. package/gsd-core/references/api-coverage.md +104 -0
  62. package/gsd-core/references/model-profiles.md +2 -2
  63. package/gsd-core/references/planning-config.md +2 -0
  64. package/gsd-core/references/specless-probe-fallback.md +172 -0
  65. package/gsd-core/references/ui-consideration-probe.md +73 -0
  66. package/gsd-core/templates/UI-SPEC.md +25 -0
  67. package/gsd-core/templates/VALIDATION.md +2 -0
  68. package/gsd-core/templates/config.json +2 -1
  69. package/gsd-core/workflows/audit-fix.md +9 -1
  70. package/gsd-core/workflows/audit-milestone.md +7 -4
  71. package/gsd-core/workflows/code-review-fix.md +7 -3
  72. package/gsd-core/workflows/code-review.md +4 -1
  73. package/gsd-core/workflows/discuss-phase-assumptions.md +4 -1
  74. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +8 -4
  75. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +42 -0
  76. package/gsd-core/workflows/execute-phase.md +1 -25
  77. package/gsd-core/workflows/plan-phase.md +37 -2
  78. package/gsd-core/workflows/quick.md +2 -2
  79. package/gsd-core/workflows/review.md +59 -13
  80. package/gsd-core/workflows/settings-advanced.md +12 -9
  81. package/gsd-core/workflows/settings.md +2 -2
  82. package/gsd-core/workflows/ui-phase.md +146 -1
  83. package/gsd-core/workflows/validate-phase.md +2 -2
  84. package/gsd-core/workflows/verify-phase.md +3 -2
  85. package/gsd-core/workflows/verify-work.md +38 -0
  86. package/hooks/dist/gsd-cursor-pre-tool.js +76 -0
  87. package/hooks/dist/gsd-cursor-stop.js +48 -0
  88. package/hooks/dist/gsd-cursor-subagent-start.js +50 -0
  89. package/hooks/dist/gsd-cursor-subagent-stop.js +40 -0
  90. package/hooks/dist/gsd-windsurf-pre-command.js +275 -0
  91. package/hooks/dist/gsd-windsurf-pre-write.js +132 -0
  92. package/hooks/dist/managed-hooks-registry.cjs +6 -0
  93. package/hooks/gsd-cursor-pre-tool.js +76 -0
  94. package/hooks/gsd-cursor-stop.js +48 -0
  95. package/hooks/gsd-cursor-subagent-start.js +50 -0
  96. package/hooks/gsd-cursor-subagent-stop.js +40 -0
  97. package/hooks/gsd-windsurf-pre-command.js +275 -0
  98. package/hooks/gsd-windsurf-pre-write.js +132 -0
  99. package/hooks/managed-hooks-registry.cjs +6 -0
  100. package/package.json +9 -4
  101. package/pi/gsd.cjs +354 -0
  102. package/scripts/build-hooks.js +8 -1
  103. package/scripts/gen-golden-install-parity-zcode.cjs +11 -1
  104. package/scripts/gen-registry.cjs +128 -0
  105. package/scripts/lint-phase-id-drift.cjs +150 -0
  106. package/scripts/lint-test-file-count.allowlist.json +2 -1
  107. package/scripts/registry-schema.cjs +565 -0
  108. package/scripts/run-tests.cjs +21 -1
  109. package/scripts/validate-registry.cjs +117 -0
  110. package/vscode/browser.js +197 -0
  111. package/vscode/extension.js +383 -0
  112. package/vscode/host-binding.js +113 -0
  113. package/vscode/package.json +96 -0
@@ -688,7 +688,7 @@ async function main() {
688
688
  // phase / roadmap / milestone / progress / etc.
689
689
  const TOP_LEVEL_USAGE = 'Usage: gsd-tools <command> [args] [--raw] [--pick <field>] [--cwd <path>] [--ws <name>] [--json-errors]\n' +
690
690
  'Commands: agent, agent-skills, assumption-delta, audit-open, audit-uat, check, check-commit, commit, commit-to-subrepo, pr-subrepo, ' +
691
- 'config-ensure-section, config-get, config-new-project, config-path, config-set, migrate-config, ' +
691
+ 'config-ensure-section, config-get, config-new-project, config-path, config-set, migrate-config, normalize-test-command, ' +
692
692
  'current-timestamp, detect-custom-files, docs-init, drift-guard, effort, extract-messages, find-phase, ' +
693
693
  'from-gsd2, frontmatter, gap-analysis, generate-claude-md, generate-claude-profile, ' +
694
694
  'generate-dev-preferences, generate-slug, graphify, history-digest, init, intel, ' +
@@ -1291,6 +1291,16 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
1291
1291
  break;
1292
1292
  }
1293
1293
 
1294
+ case 'normalize-test-command': {
1295
+ // #1857: rewrite a resolved test command to a one-shot form so a
1296
+ // watch-mode runner (vitest/jest) cannot hang a verification gate. Shared
1297
+ // by the regression gate and the post-merge gate. args[1] is the raw
1298
+ // resolved command; --cwd (already parsed into `cwd`) locates package.json.
1299
+ const testCommandNormalizer = require('./lib/normalize-test-command.cjs');
1300
+ testCommandNormalizer.cmdNormalizeTestCommand(cwd, args[1]);
1301
+ break;
1302
+ }
1303
+
1294
1304
  case 'dispatch-should-flatten': {
1295
1305
  // #1708 / #853: typed query replacing the `RUNTIME === 'codex'` prose rule.
1296
1306
  //
@@ -2140,6 +2150,28 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
2140
2150
  }
2141
2151
  }
2142
2152
  } catch { /* best-effort — list still works without the inactive annotation */ }
2153
+ // Issue #2045 (DEFECT 3): derive each capability's SURFACED state from the
2154
+ // SAME resolver `capability state` uses (resolveCapabilityRuntimeState), so
2155
+ // `list` and `state` stop disagreeing. `list` previously derived `status`
2156
+ // purely from ledger-entry existence — an installed-but-not-surfaced cap
2157
+ // reported active in `list` and absent in `state`. Surfaced is evaluated at
2158
+ // the default runtime config dir (the resolver resolves it when undefined),
2159
+ // matching `capability state <id>` with no --config-dir. Best-effort: a
2160
+ // resolver failure leaves surfacedById empty (rows report surfaced:null).
2161
+ const surfacedById = {};
2162
+ // surfacedById is keyed by capId only (NOT `${scope} ${capId}`): surface
2163
+ // state is single-source — one runtime config dir → one .gsd-surface.json
2164
+ // → one surfaced truth per capId — and the loader dedupes overlay caps to
2165
+ // one registry entry per id (first-party-wins). So a cap installed in both
2166
+ // scopes correctly shares one surfaced value across its list rows.
2167
+ try {
2168
+ const surfaceState = capabilityState.resolveCapabilityRuntimeState(cwd, undefined);
2169
+ for (const cap of (surfaceState && surfaceState.capabilities) || []) {
2170
+ if (cap && typeof cap.id === 'string') {
2171
+ surfacedById[cap.id] = cap.surfaced === true;
2172
+ }
2173
+ }
2174
+ } catch { /* best-effort — list still works without the surfaced annotation */ }
2143
2175
  for (const capId of Object.keys(fp)) {
2144
2176
  const cap = fp[capId] || {};
2145
2177
  rows.push({
@@ -2150,6 +2182,7 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
2150
2182
  source: 'first-party',
2151
2183
  scope: 'first-party',
2152
2184
  status: 'active',
2185
+ surfaced: Object.prototype.hasOwnProperty.call(surfacedById, capId) ? surfacedById[capId] === true : null,
2153
2186
  title: cap.title || null,
2154
2187
  });
2155
2188
  }
@@ -2199,6 +2232,12 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
2199
2232
  scope: sc,
2200
2233
  status,
2201
2234
  reason,
2235
+ // Issue #2045 (DEFECT 3): surfaced reflects surface composition, so
2236
+ // list and state agree. An inactive (unconsented/incompatible) cap is
2237
+ // surfaced:false by definition; otherwise defer to the resolver.
2238
+ surfaced: status === 'active'
2239
+ ? (Object.prototype.hasOwnProperty.call(surfacedById, capId) ? surfacedById[capId] === true : null)
2240
+ : false,
2202
2241
  title: manifest.title || null,
2203
2242
  });
2204
2243
  }
@@ -0,0 +1,466 @@
1
+ "use strict";
2
+ /**
3
+ * API-Coverage detector + matrix validator (#1562).
4
+ *
5
+ * The enforcement half of "Full API Coverage by Default — Opt Out, Never Opt In."
6
+ * When a phase integrates an external API/service/SDK, the planner must produce a
7
+ * coverage matrix (COVERAGE.md) enumerating the API's capability surface; every
8
+ * non-integrated capability is an explicit, reasoned opt-out. The seal-time gate
9
+ * (capabilities/ai-integration, verify:pre) consumes this module to (a) detect
10
+ * whether a phase integrates an external API and (b) validate the produced matrix.
11
+ *
12
+ * Design notes (rubber-duck'd):
13
+ * - DETERMINISTIC + TYPED IR. Both the "does this phase integrate an external
14
+ * API?" decision and the "is this matrix complete?" decision are pure
15
+ * functions returning typed IR, not LLM judgments — so the low-false-positive
16
+ * guarantee (acceptance criterion #4) and the completeness guarantee
17
+ * (acceptance #2) are testable. Mirrors assumption-delta.cts (#1561).
18
+ * - COMPOUND SIGNAL for low false positives. A bare word like "api" appears in
19
+ * countless non-integration phases ("the public API of UserController"). The
20
+ * detector requires an INTEGRATION VERB co-occurring with an EXTERNAL-API
21
+ * NOUN (or an explicit "<Service> API/SDK" phrase). Single weak tokens do not
22
+ * fire. This is the issue's "low false-positive trigger" made mechanical.
23
+ * - FENCED CODE BLOCKS ARE STRIPPED first (markdown-sectionizer seam) so a
24
+ * trigger term inside a code snippet does not fire.
25
+ * - THE DETECTOR IS A FALLBACK. The primary path is the plan:pre contribution
26
+ * prompting COVERAGE.md creation. The detector runs only when COVERAGE.md is
27
+ * ABSENT, to catch the "nobody decided" case (acceptance #1). Its precision
28
+ * therefore matters but is not the only line of defense.
29
+ * - MATRIX FORMAT. The matrix is a markdown table (human-editable, diff-friendly)
30
+ * with a header row `| capability | decision | reason |` and one row per
31
+ * capability. decision ∈ {INTEGRATE, OPT-OUT}. An OPT-OUT row MUST carry a
32
+ * non-empty reason. A fenced ```coverage JSON block is also accepted for
33
+ * machine-generated matrices. This dual shape is bijective (parse/render
34
+ * round-trip) and covered by a fast-check property test.
35
+ * - ADDITIVE-ONLY VOCABULARY (Hyrum's Law). Once shipped, the verb/noun sets
36
+ * are depended-upon interfaces; they only grow. Tunable via the `terms`
37
+ * parameter so teams can widen them without forking.
38
+ *
39
+ * Public API:
40
+ * detectApiIntegration(text, terms?) -> { detected, signals, terms }
41
+ * parseCoverageMatrix(text) -> { rows, errors, format }
42
+ * validateCoverageMatrix(text) -> { valid, errors, counts }
43
+ * renderCoverageMatrix(rows) -> string
44
+ * DEFAULT_API_COVERAGE_TERMS
45
+ *
46
+ * CLI:
47
+ * echo "$SCOPE" | node gsd-core/bin/lib/api-coverage.cjs [--json]
48
+ * exit 0 = integration detected, 1 = none, 2 = startup error
49
+ */
50
+ Object.defineProperty(exports, "__esModule", { value: true });
51
+ exports.DEFAULT_API_COVERAGE_TERMS = void 0;
52
+ exports.detectApiIntegration = detectApiIntegration;
53
+ exports.parseCoverageMatrix = parseCoverageMatrix;
54
+ exports.validateCoverageMatrix = validateCoverageMatrix;
55
+ exports.renderCoverageMatrix = renderCoverageMatrix;
56
+ const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
57
+ /**
58
+ * Curated default trigger vocabulary. ADDITIVE-ONLY (Hyrum's Law). Tunable via
59
+ * the `terms` parameter.
60
+ *
61
+ * VERBS are deliberately conservative: common verbs like "add", "use", "call",
62
+ * "implement" are EXCLUDED because they appear in nearly every phase and would
63
+ * make the gate fire on prose that has nothing to do with an external API. The
64
+ * verbs kept all connote BRINGING IN an external surface.
65
+ *
66
+ * NOUNS name an external-API surface. Bare "client" is excluded — too ambiguous
67
+ * (client-side UI vs API client). "service" alone is excluded (internal
68
+ * services); a phase integrating an external service virtually always pairs it
69
+ * with "API"/"SDK"/"REST"/etc., which the compound verb+noun rule captures.
70
+ */
71
+ exports.DEFAULT_API_COVERAGE_TERMS = {
72
+ verbs: [
73
+ 'integrate',
74
+ 'integrates',
75
+ 'integrating',
76
+ 'integration',
77
+ 'wrap',
78
+ 'wraps',
79
+ 'wrapping',
80
+ 'connect',
81
+ 'connects',
82
+ 'connecting',
83
+ 'consume',
84
+ 'consumes',
85
+ 'consuming',
86
+ 'wire',
87
+ 'wires',
88
+ 'wiring',
89
+ 'onboard',
90
+ 'onboarding',
91
+ 'adopt',
92
+ 'adopts',
93
+ 'adopting',
94
+ ],
95
+ nouns: [
96
+ 'api',
97
+ 'apis',
98
+ 'sdk',
99
+ 'sdks',
100
+ 'rest',
101
+ 'graphql',
102
+ 'grpc',
103
+ 'endpoint',
104
+ 'endpoints',
105
+ 'oauth',
106
+ 'oauth2',
107
+ 'webhook',
108
+ 'webhooks',
109
+ 'mcp',
110
+ ],
111
+ };
112
+ /** Hardening caps for the tunable vocabulary (hostile `--terms` defense). */
113
+ const MAX_TERMS_PER_KIND = 200;
114
+ const MAX_TERM_LEN = 32;
115
+ /**
116
+ * Field-length caps for matrix cell values. Cell content flows from a
117
+ * semi-trusted COVERAGE.md into the gate `message` that the orchestrator LLM
118
+ * reads, so it is bounded to keep the prompt-injection surface small and to
119
+ * document the format contract (short, single-line prose — not paragraphs).
120
+ */
121
+ const CAPABILITY_MAX_LEN = 80;
122
+ const REASON_MAX_LEN = 200;
123
+ function normalizeTerms(list) {
124
+ if (!Array.isArray(list))
125
+ return [];
126
+ const seen = new Set();
127
+ const out = [];
128
+ for (const raw of list) {
129
+ if (typeof raw !== 'string')
130
+ continue;
131
+ const t = raw.trim().toLowerCase().slice(0, MAX_TERM_LEN);
132
+ if (!t || !/[a-z0-9]/.test(t))
133
+ continue;
134
+ if (seen.has(t))
135
+ continue;
136
+ seen.add(t);
137
+ out.push(t);
138
+ if (out.length >= MAX_TERMS_PER_KIND)
139
+ break;
140
+ }
141
+ return out;
142
+ }
143
+ function resolveTerms(terms) {
144
+ const merge = (key) => {
145
+ const t = terms && terms[key];
146
+ return Array.isArray(t) ? normalizeTerms(t) : [...exports.DEFAULT_API_COVERAGE_TERMS[key]];
147
+ };
148
+ return { verbs: merge('verbs'), nouns: merge('nouns') };
149
+ }
150
+ function escapeRegex(s) {
151
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
152
+ }
153
+ function makeSnippet(line, anchor) {
154
+ const cleaned = line.replace(/\s+/g, ' ').trim();
155
+ if (cleaned.length <= 120)
156
+ return cleaned;
157
+ const idx = cleaned.toLowerCase().indexOf(anchor);
158
+ if (idx < 0)
159
+ return cleaned.slice(0, 120);
160
+ const start = Math.max(0, idx - 50);
161
+ const end = Math.min(cleaned.length, idx + anchor.length + 50);
162
+ const prefix = start > 0 ? '…' : '';
163
+ const suffix = end < cleaned.length ? '…' : '';
164
+ return `${prefix}${cleaned.slice(start, end)}${suffix}`;
165
+ }
166
+ /** `<Service> API` / `<Service> SDK` — a capitalized proper noun immediately
167
+ * followed by API/SDK. Strong signal on its own (no verb required).
168
+ *
169
+ * STOPWORDS guard against the false positive where an ordinary capitalized
170
+ * sentence starter ("The API …", "An SDK …", "Our REST …") matches the
171
+ * `[A-Z]\w+ API` shape. Those are common English, not a service name, so they
172
+ * are rejected before counting as a surface signal (acceptance #4 — low false
173
+ * positives). */
174
+ const SERVICE_SURFACE_API_RE = /\b([A-Z][A-Za-z0-9_-]{1,})\s+(API|SDK|REST|GraphQL)\b/;
175
+ const SERVICE_STOPWORDS = new Set([
176
+ 'the', 'an', 'a', 'our', 'this', 'these', 'that', 'those', 'new', 'add',
177
+ 'use', 'your', 'my', 'no', 'some', 'any', 'all', 'each', 'every', 'both',
178
+ 'if', 'when', 'while', 'with', 'via', 'using', 'into', 'its', 'their',
179
+ 'we', 'you', 'they', 'it',
180
+ ]);
181
+ /**
182
+ * Detect whether phase-scope prose describes integrating an external API/SDK.
183
+ *
184
+ * Fires when EITHER:
185
+ * (a) a compound verb+noun signal co-occurs on the same line, OR
186
+ * (b) an explicit `<Service> API|SDK|REST|GraphQL` surface appears.
187
+ *
188
+ * Non-string inputs degrade to `{ detected: false }` without throwing.
189
+ */
190
+ function detectApiIntegration(text, terms) {
191
+ const effective = resolveTerms(terms);
192
+ if (typeof text !== 'string') {
193
+ return { detected: false, signals: [], terms: effective };
194
+ }
195
+ const stripped = (0, markdown_sectionizer_cjs_1.stripFencedCode)(text.replace(/\r\n/g, '\n')).text;
196
+ if (stripped.trim().length === 0) {
197
+ return { detected: false, signals: [], terms: effective };
198
+ }
199
+ const signals = [];
200
+ const seen = new Set();
201
+ const lines = stripped.split('\n');
202
+ // (a) compound verb+noun on the same line.
203
+ if (effective.verbs.length > 0 && effective.nouns.length > 0) {
204
+ const verbRe = new RegExp('(^|[^a-zA-Z0-9])(' + effective.verbs.map(escapeRegex).join('|') + ')([^a-zA-Z0-9]|$)', 'gi');
205
+ const nounRe = new RegExp('(^|[^a-zA-Z0-9])(' + effective.nouns.map(escapeRegex).join('|') + ')([^a-zA-Z0-9]|$)', 'gi');
206
+ for (const line of lines) {
207
+ verbRe.lastIndex = 0;
208
+ nounRe.lastIndex = 0;
209
+ const vMatch = verbRe.exec(line);
210
+ if (!vMatch)
211
+ continue;
212
+ const nMatch = nounRe.exec(line);
213
+ if (!nMatch)
214
+ continue;
215
+ const verb = (vMatch[2] || '').toLowerCase();
216
+ const noun = (nMatch[2] || '').toLowerCase();
217
+ const key = `${verb}+${noun}`;
218
+ if (seen.has(key))
219
+ continue;
220
+ seen.add(key);
221
+ signals.push({ verb, noun, snippet: makeSnippet(line, noun) });
222
+ }
223
+ }
224
+ // (b) explicit <Service> API|SDK|REST|GraphQL surface.
225
+ for (const line of lines) {
226
+ SERVICE_SURFACE_API_RE.lastIndex = 0;
227
+ const m = SERVICE_SURFACE_API_RE.exec(line);
228
+ if (!m)
229
+ continue;
230
+ // Reject ordinary capitalized sentence starters ("The API …", "Our REST …").
231
+ if (SERVICE_STOPWORDS.has((m[1] || '').toLowerCase()))
232
+ continue;
233
+ const noun = (m[2] || '').toLowerCase();
234
+ const key = `surface+${noun}`;
235
+ if (seen.has(key))
236
+ continue;
237
+ seen.add(key);
238
+ signals.push({ verb: '(surface)', noun, snippet: makeSnippet(line, m[1]) });
239
+ }
240
+ return { detected: signals.length > 0, signals, terms: effective };
241
+ }
242
+ const VALID_DECISIONS = new Set(['INTEGRATE', 'OPT-OUT']);
243
+ /**
244
+ * Parse a coverage matrix from COVERAGE.md. Accepts two bijective formats:
245
+ *
246
+ * 1. Markdown table (canonical, human-editable):
247
+ * | capability | decision | reason |
248
+ * |---|---|---|
249
+ * | search | INTEGRATE | |
250
+ * | playlists | OPT-OUT | not needed yet |
251
+ *
252
+ * 2. Fenced ```coverage JSON block (machine-generated):
253
+ * ```coverage
254
+ * [ {"capability":"search","decision":"INTEGRATE","reason":""}, ... ]
255
+ * ```
256
+ *
257
+ * Rows are trimmed; decisions upper-cased; missing reason → "". Returns
258
+ * `{ rows: [], errors: [], format: 'none' }` for empty/non-matrix input.
259
+ */
260
+ function parseCoverageMatrix(text) {
261
+ const out = { rows: [], errors: [], format: 'none' };
262
+ if (typeof text !== 'string')
263
+ return out;
264
+ const src = text.replace(/\r\n/g, '\n');
265
+ // (1) fenced ```coverage JSON block takes precedence if present.
266
+ // Case-insensitive info string (```coverage and ```Coverage are both legal CommonMark).
267
+ // allow-adhoc-markdown: extracting a NAMED ```coverage fence (extraction of one tagged block), not stripping all fences — stripFencedCode/extractTaggedBlocks do not cover named-fence extraction.
268
+ const fenceMatch = src.match(/```coverage\s*\n([\s\S]*?)\n```/i);
269
+ if (fenceMatch && fenceMatch[1]) {
270
+ out.format = 'json';
271
+ let parsed;
272
+ try {
273
+ parsed = JSON.parse(fenceMatch[1]);
274
+ }
275
+ catch {
276
+ out.errors.push('fenced ```coverage block is not valid JSON');
277
+ return out;
278
+ }
279
+ if (!Array.isArray(parsed)) {
280
+ out.errors.push('fenced ```coverage block must be a JSON array');
281
+ return out;
282
+ }
283
+ for (let i = 0; i < parsed.length; i++) {
284
+ const row = rowFromJson(parsed[i]);
285
+ if ('error' in row) {
286
+ out.errors.push(`row[${i}]: ${row.error}`);
287
+ continue;
288
+ }
289
+ out.rows.push(row);
290
+ }
291
+ return out;
292
+ }
293
+ // (2) markdown table — collect table rows whose decision column parses.
294
+ const lines = src.split('\n');
295
+ let sawHeader = false;
296
+ for (const line of lines) {
297
+ const trimmed = line.trim();
298
+ if (!trimmed.startsWith('|'))
299
+ continue;
300
+ const cells = trimmed.slice(1, trimmed.endsWith('|') ? -1 : trimmed.length).split('|');
301
+ if (cells.length < 2)
302
+ continue;
303
+ const cleaned = cells.map((c) => c.trim());
304
+ // skip separator rows (|---|---|); require ≥3 dashes so a literal "-" cell
305
+ // is not mistaken for a separator.
306
+ if (cleaned.every((c) => /^:?-{3,}:?$/.test(c)))
307
+ continue;
308
+ const decisionCell = (cleaned[1] || '').toUpperCase();
309
+ // header detection
310
+ if (!sawHeader && cleaned[0].toLowerCase() === 'capability') {
311
+ sawHeader = true;
312
+ out.format = 'table';
313
+ continue;
314
+ }
315
+ if (!VALID_DECISIONS.has(decisionCell)) {
316
+ // A row that otherwise looks like data (≥3 cells, non-empty capability)
317
+ // but carries a malformed decision is a real error, not a row to skip
318
+ // silently — otherwise a single typo'd row collapses the matrix to
319
+ // "empty" and the user sees a confusing message.
320
+ if (cleaned.length >= 3 && cleaned[0]) {
321
+ out.errors.push(`row: decision "${decisionCell}" not in {INTEGRATE, OPT-OUT}`);
322
+ }
323
+ continue;
324
+ }
325
+ if (out.format === 'none')
326
+ out.format = 'table';
327
+ // A coverage row has exactly 3 cells. Extra cells mean an unescaped pipe in
328
+ // a value silently corrupted the row — surface it rather than parse garbage.
329
+ if (cleaned.length > 3) {
330
+ out.errors.push(`row: ${cleaned.length} columns (expected 3 — unescaped pipe in a cell?)`);
331
+ }
332
+ out.rows.push({
333
+ capability: cleaned[0] || '',
334
+ decision: decisionCell,
335
+ reason: (cleaned[2] ?? '').trim(),
336
+ });
337
+ }
338
+ return out;
339
+ }
340
+ function rowFromJson(v) {
341
+ if (!v || typeof v !== 'object' || Array.isArray(v))
342
+ return { error: 'not an object' };
343
+ const o = v;
344
+ const capability = typeof o['capability'] === 'string' ? o['capability'].trim() : '';
345
+ if (!capability)
346
+ return { error: 'missing/empty "capability"' };
347
+ const dRaw = typeof o['decision'] === 'string' ? o['decision'].trim().toUpperCase() : '';
348
+ if (!VALID_DECISIONS.has(dRaw)) {
349
+ return { error: `decision "${dRaw}" not in {INTEGRATE, OPT-OUT}` };
350
+ }
351
+ const reason = typeof o['reason'] === 'string' ? o['reason'].trim() : '';
352
+ return { capability, decision: dRaw, reason };
353
+ }
354
+ /**
355
+ * Validate a parsed matrix. A matrix is valid when:
356
+ * - it is non-empty (acceptance #1: "enumerating the API surface"),
357
+ * - every capability name is non-empty,
358
+ * - every decision is INTEGRATE or OPT-OUT (enforced by parser, re-checked
359
+ * here for defense-in-depth),
360
+ * - every OPT-OUT row carries a non-empty reason (acceptance #2).
361
+ *
362
+ * Un-enumerated remainder is not representable in the format — the gate blocks
363
+ * when an integration is detected and NO matrix exists. This validator catches
364
+ * a malformed/partial matrix that does exist.
365
+ */
366
+ function validateCoverageMatrix(text) {
367
+ const parsed = parseCoverageMatrix(text);
368
+ const errors = [...parsed.errors];
369
+ const rows = parsed.rows;
370
+ if (rows.length === 0) {
371
+ if (errors.length === 0)
372
+ errors.push('matrix is empty — no capabilities enumerated');
373
+ return { valid: false, errors, counts: { surface: 0, integrate: 0, optout: 0 } };
374
+ }
375
+ const seen = new Set();
376
+ for (let i = 0; i < rows.length; i++) {
377
+ const row = rows[i];
378
+ if (!row.capability) {
379
+ errors.push(`row[${i}]: empty capability name`);
380
+ }
381
+ else {
382
+ // Format contract + prompt-injection bound: cell values must be short,
383
+ // single-line, pipe-free prose (the matrix is a markdown table whose
384
+ // content flows into the gate message). Pipes/newlines would corrupt the
385
+ // table and let a COVERAGE.md inject unbounded text into the seal message.
386
+ if (/[|\n\r]/.test(row.capability)) {
387
+ errors.push(`row[${i}]: capability contains a pipe or newline (unsupported in a table cell)`);
388
+ }
389
+ if (row.capability.length > CAPABILITY_MAX_LEN) {
390
+ errors.push(`row[${i}]: capability exceeds ${CAPABILITY_MAX_LEN} chars`);
391
+ }
392
+ }
393
+ if (row.reason && /[|\n\r]/.test(row.reason)) {
394
+ errors.push(`row[${i}]: reason contains a pipe or newline (unsupported in a table cell)`);
395
+ }
396
+ if (row.reason.length > REASON_MAX_LEN) {
397
+ errors.push(`row[${i}]: reason exceeds ${REASON_MAX_LEN} chars`);
398
+ }
399
+ const key = row.capability.toLowerCase();
400
+ if (key && seen.has(key))
401
+ errors.push(`row[${i}]: duplicate capability`);
402
+ if (key)
403
+ seen.add(key);
404
+ if (!VALID_DECISIONS.has(row.decision)) {
405
+ errors.push(`row[${i}]: decision not in {INTEGRATE, OPT-OUT}`);
406
+ }
407
+ if (row.decision === 'OPT-OUT' && !row.reason) {
408
+ errors.push(`row[${i}]: OPT-OUT missing reason`);
409
+ }
410
+ }
411
+ const counts = {
412
+ surface: rows.length,
413
+ integrate: rows.filter((r) => r.decision === 'INTEGRATE').length,
414
+ optout: rows.filter((r) => r.decision === 'OPT-OUT').length,
415
+ };
416
+ return { valid: errors.length === 0, errors, counts };
417
+ }
418
+ /** Render rows back to the canonical markdown-table format (bijective with parse). */
419
+ function renderCoverageMatrix(rows) {
420
+ const body = rows
421
+ .map((r) => `| ${r.capability} | ${r.decision} | ${r.reason} |`)
422
+ .join('\n');
423
+ return `| capability | decision | reason |\n|---|---|---|\n${body}`;
424
+ }
425
+ // ── CLI entry point ──────────────────────────────────────────────────────────
426
+ // Reads phase-scope text from STDIN (not argv) to avoid OS ARG_MAX limits.
427
+ // Invoked by workflow bash as: echo "$SCOPE" | node .../api-coverage.cjs [--json]
428
+ // Exit 0 = integration detected, 1 = none, 2 = startup error. Mirrors
429
+ // assumption-delta.cjs / ui-safety-gate.cjs.
430
+ if (require.main === module) {
431
+ const argv = process.argv.slice(2);
432
+ const wantJson = argv.includes('--json');
433
+ let termsOverride;
434
+ const verbsIdx = argv.indexOf('--verbs');
435
+ const verbsVal = verbsIdx !== -1 ? argv[verbsIdx + 1] : undefined;
436
+ const nounsIdx = argv.indexOf('--nouns');
437
+ const nounsVal = nounsIdx !== -1 ? argv[nounsIdx + 1] : undefined;
438
+ // A non-empty, non-flag value is an override. An EMPTY value ("") restores
439
+ // the curated defaults (does NOT silently zero the vocabulary).
440
+ const verbsOverride = typeof verbsVal === 'string' && verbsVal.length > 0 && !verbsVal.startsWith('-');
441
+ const nounsOverride = typeof nounsVal === 'string' && nounsVal.length > 0 && !nounsVal.startsWith('-');
442
+ if (verbsOverride || nounsOverride) {
443
+ termsOverride = {};
444
+ if (verbsOverride) {
445
+ termsOverride.verbs = verbsVal.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean);
446
+ }
447
+ if (nounsOverride) {
448
+ termsOverride.nouns = nounsVal.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean);
449
+ }
450
+ }
451
+ const chunks = [];
452
+ process.stdin.setEncoding('utf-8');
453
+ process.stdin.on('data', (chunk) => chunks.push(chunk));
454
+ process.stdin.on('end', () => {
455
+ const input = chunks.join('');
456
+ const result = detectApiIntegration(input, termsOverride);
457
+ if (wantJson) {
458
+ process.stdout.write(JSON.stringify(result) + '\n');
459
+ }
460
+ process.exit(result.detected ? 0 : 1);
461
+ });
462
+ process.stdin.on('error', (err) => {
463
+ process.stderr.write(`ERROR: api-coverage.cjs stdin read failed: ${err.message}\n`);
464
+ process.exit(2);
465
+ });
466
+ }
@@ -23,6 +23,9 @@ const { planningDir } = planningWorkspace;
23
23
  // eslint-disable-next-line @typescript-eslint/no-require-imports
24
24
  const frontmatter = require("./frontmatter.cjs");
25
25
  const { extractFrontmatter } = frontmatter;
26
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
27
+ const phaseIdMod = require("./phase-id.cjs");
28
+ const { PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
26
29
  const security_cjs_1 = require("./security.cjs");
27
30
  // Terminal UAT states: `complete` (legacy) and `resolved` (post-gap-closure
28
31
  // per workflows/execute-phase.md). Hoisted outside scanUatGaps so the Set is
@@ -357,7 +360,7 @@ function scanUatGaps(planDir) {
357
360
  const results = [];
358
361
  for (const dir of dirs) {
359
362
  const phaseDir = node_path_1.default.join(phasesDir, dir);
360
- const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
363
+ const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
361
364
  const phaseNum = phaseMatch ? phaseMatch[1] : dir;
362
365
  let files;
363
366
  try {
@@ -420,7 +423,7 @@ function scanVerificationGaps(planDir) {
420
423
  const results = [];
421
424
  for (const dir of dirs) {
422
425
  const phaseDir = node_path_1.default.join(phasesDir, dir);
423
- const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
426
+ const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
424
427
  const phaseNum = phaseMatch ? phaseMatch[1] : dir;
425
428
  let files;
426
429
  try {
@@ -475,7 +478,7 @@ function scanContextQuestions(planDir) {
475
478
  const results = [];
476
479
  for (const dir of dirs) {
477
480
  const phaseDir = node_path_1.default.join(phasesDir, dir);
478
- const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
481
+ const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
479
482
  const phaseNum = phaseMatch ? phaseMatch[1] : dir;
480
483
  let files;
481
484
  try {
@@ -18,10 +18,12 @@
18
18
  * `gsd-core-` / `anthropic-` id prefix) is rejected.
19
19
  * - Load-time re-gate (default-resilient): an overlay that fails validation or
20
20
  * whose `engines.gsd` does not satisfy the running GSD version is SKIPPED
21
- * with a warning — it never crashes the loop. EXCEPTION (per-hook-kind
22
- * policy): a skipped capability that declares a `gate` is recorded in
23
- * `_overlay.incompatibleGateCapIds` so the loop resolver can fail CLOSED for
24
- * that gate rather than silently proceeding as if it had passed.
21
+ * with a warning — it never crashes the loop. A skipped capability that
22
+ * declares a `gate` is additionally recorded in
23
+ * `_overlay.incompatibleGateCapIds` / `_overlay.blockedGates` so the loop
24
+ * resolver can surface a loud fail-OPEN advisory for that gate (#2009): the
25
+ * un-evaluable gate is skipped (not enforced) with a remediation message,
26
+ * rather than silently vanishing.
25
27
  *
26
28
  * The merged registry is materialized by the canonical `buildRegistry`
27
29
  * (re-exported from the generator, which ships) over a cap-map reconstructed
@@ -779,12 +781,12 @@ function loadRegistry(options = {}) {
779
781
  // to load. Clear the map (the first-party base never lists overlay commandRoots — first-party
780
782
  // command modules ship in bin/lib/, not via _overlay.commandRoots).
781
783
  meta.commandRoots = {};
782
- // #1461 OVL-2 fail-CLOSED on compose failure (HIGH): the fallback DROPS every accepted overlay,
783
- // so any accepted overlay that DECLARED a gate would have its gate silently vanish → a blocking
784
- // gate FAILS OPEN, violating ADR-1244 (a skipped capability declaring a gate must FAIL CLOSED).
784
+ // #1461 OVL-2 (HIGH): on compose failure the fallback DROPS every accepted overlay, so any
785
+ // accepted overlay that DECLARED a gate would have its gate silently vanish with no trace.
785
786
  // Record each dropped gate-declaring overlay's gate as blocked using the SAME extraction the
786
- // per-candidate `skip()` closure uses (gatePointsOf), so loop-resolver injects the synthetic
787
- // blocking gate at each declared point exactly as it would for a per-candidate skip.
787
+ // per-candidate `skip()` closure uses (gatePointsOf), so loop-resolver surfaces the loud
788
+ // fail-OPEN advisory (#2009) at each declared point exactly as it would for a per-candidate
789
+ // skip — the gate does not silently disappear.
788
790
  for (const cap of overlayCaps) {
789
791
  const gatePoints = gatePointsOf(cap);
790
792
  if (gatePoints.length === 0)