@opengsd/gsd-core 1.7.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (261) 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 +45 -1
  4. package/README.md +2 -0
  5. package/agents/gsd-code-fixer.md +1 -1
  6. package/agents/gsd-codebase-mapper.md +1 -1
  7. package/agents/gsd-debug-session-manager.md +78 -4
  8. package/agents/gsd-debugger.md +87 -29
  9. package/agents/gsd-executor.md +49 -9
  10. package/agents/gsd-intel-updater.md +3 -3
  11. package/agents/gsd-phase-researcher.md +4 -2
  12. package/agents/gsd-plan-checker.md +20 -0
  13. package/agents/gsd-planner.md +44 -59
  14. package/agents/gsd-project-researcher.md +2 -2
  15. package/agents/gsd-ui-auditor.md +0 -40
  16. package/agents/gsd-verifier.md +2 -2
  17. package/bin/install.js +1338 -135
  18. package/commands/gsd/ai-integration-phase.md +1 -1
  19. package/commands/gsd/mempalace-capture.md +9 -5
  20. package/commands/gsd/new-milestone.md +1 -1
  21. package/commands/gsd/plan-phase.md +5 -3
  22. package/commands/gsd/plan-review-convergence.md +7 -2
  23. package/gsd-core/bin/gsd-tools.cjs +2690 -2472
  24. package/gsd-core/bin/lib/adapter-imperative.cjs +8 -1
  25. package/gsd-core/bin/lib/agent-command-router.cjs +20 -5
  26. package/gsd-core/bin/lib/api-coverage.cjs +360 -53
  27. package/gsd-core/bin/lib/audit.cjs +8 -8
  28. package/gsd-core/bin/lib/broken-windows.cjs +716 -0
  29. package/gsd-core/bin/lib/capability-command-router.cjs +733 -0
  30. package/gsd-core/bin/lib/capability-consent.cjs +40 -1
  31. package/gsd-core/bin/lib/capability-lifecycle.cjs +58 -0
  32. package/gsd-core/bin/lib/capability-loader.cjs +23 -1
  33. package/gsd-core/bin/lib/capability-registry.cjs +1450 -160
  34. package/gsd-core/bin/lib/capability-trust.cjs +468 -33
  35. package/gsd-core/bin/lib/capability-validator.cjs +882 -6
  36. package/gsd-core/bin/lib/capability-writer.cjs +6 -1
  37. package/gsd-core/bin/lib/check-command-router.cjs +140 -27
  38. package/gsd-core/bin/lib/cjs-command-router-adapter.cjs +15 -0
  39. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +209 -31
  40. package/gsd-core/bin/lib/claude-orchestration.cjs +203 -25
  41. package/gsd-core/bin/lib/command-aliases.cjs +14 -0
  42. package/gsd-core/bin/lib/commands.cjs +326 -21
  43. package/gsd-core/bin/lib/config-loader.cjs +214 -30
  44. package/gsd-core/bin/lib/config.cjs +158 -22
  45. package/gsd-core/bin/lib/core-utils.cjs +6 -1
  46. package/gsd-core/bin/lib/decisions.cjs +32 -8
  47. package/gsd-core/bin/lib/docs.cjs +6 -0
  48. package/gsd-core/bin/lib/estimate-cli.cjs +336 -0
  49. package/gsd-core/bin/lib/external-descriptor-trust.cjs +14 -2
  50. package/gsd-core/bin/lib/frontmatter.cjs +125 -15
  51. package/gsd-core/bin/lib/gap-checker.cjs +17 -2
  52. package/gsd-core/bin/lib/host-integration.cjs +215 -8
  53. package/gsd-core/bin/lib/init.cjs +155 -66
  54. package/gsd-core/bin/lib/install-engine.cjs +299 -23
  55. package/gsd-core/bin/lib/install-profiles.cjs +239 -1
  56. package/gsd-core/bin/lib/installer-migrations/005-opencode-baseline-commands-dir.cjs +146 -0
  57. package/gsd-core/bin/lib/installer-migrations/006-pi-extension-cjs-to-js.cjs +91 -0
  58. package/gsd-core/bin/lib/installer-migrations.cjs +44 -5
  59. package/gsd-core/bin/lib/markdown-sectionizer.cjs +107 -0
  60. package/gsd-core/bin/lib/milestone.cjs +248 -14
  61. package/gsd-core/bin/lib/model-catalog.cjs +69 -4
  62. package/gsd-core/bin/lib/model-resolver.cjs +189 -7
  63. package/gsd-core/bin/lib/observability/logger.cjs +7 -2
  64. package/gsd-core/bin/lib/onboard-projection.cjs +11 -8
  65. package/gsd-core/bin/lib/phase-command-router.cjs +10 -1
  66. package/gsd-core/bin/lib/phase-estimation.cjs +398 -0
  67. package/gsd-core/bin/lib/phase-id.cjs +304 -9
  68. package/gsd-core/bin/lib/phase.cjs +258 -17
  69. package/gsd-core/bin/lib/plan-drift-guard.cjs +1 -1
  70. package/gsd-core/bin/lib/plan-scan.cjs +70 -2
  71. package/gsd-core/bin/lib/planning-workspace.cjs +9 -2
  72. package/gsd-core/bin/lib/profile-output.cjs +34 -8
  73. package/gsd-core/bin/lib/review-lane-descriptor.cjs +927 -0
  74. package/gsd-core/bin/lib/review-lane-invocation.cjs +348 -0
  75. package/gsd-core/bin/lib/review-lane-runner.cjs +594 -0
  76. package/gsd-core/bin/lib/review-reviewer-selection.cjs +114 -32
  77. package/gsd-core/bin/lib/roadmap-parser.cjs +61 -10
  78. package/gsd-core/bin/lib/roadmap.cjs +23 -7
  79. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +38 -5
  80. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +23 -9
  81. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +156 -0
  82. package/gsd-core/bin/lib/runtime-name-policy.cjs +15 -2
  83. package/gsd-core/bin/lib/smart-entry.cjs +70 -5
  84. package/gsd-core/bin/lib/state-document.cjs +171 -24
  85. package/gsd-core/bin/lib/state-transition.cjs +50 -11
  86. package/gsd-core/bin/lib/state.cjs +206 -32
  87. package/gsd-core/bin/lib/surface.cjs +51 -9
  88. package/gsd-core/bin/lib/uat-predicate.cjs +6 -4
  89. package/gsd-core/bin/lib/uat.cjs +428 -11
  90. package/gsd-core/bin/lib/ui-consideration-probe.cjs +2 -2
  91. package/gsd-core/bin/lib/unusable-input.cjs +216 -0
  92. package/gsd-core/bin/lib/validate.cjs +44 -8
  93. package/gsd-core/bin/lib/verification.cjs +163 -31
  94. package/gsd-core/bin/lib/verify.cjs +348 -42
  95. package/gsd-core/bin/lib/worktree-safety.cjs +360 -15
  96. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  97. package/gsd-core/bin/shared/config-schema.manifest.json +4 -15
  98. package/gsd-core/bin/shared/model-catalog.json +5 -0
  99. package/gsd-core/bin/shared/runtime-aliases.manifest.json +5 -0
  100. package/gsd-core/references/api-coverage.md +37 -7
  101. package/gsd-core/references/checkpoints.md +1 -1
  102. package/gsd-core/references/common-bug-patterns.md +13 -0
  103. package/gsd-core/references/context-budget.md +40 -0
  104. package/gsd-core/references/debugger-bug-taxonomy.md +111 -0
  105. package/gsd-core/references/debugger-fix-acceptance.md +157 -0
  106. package/gsd-core/references/debugger-philosophy.md +1 -0
  107. package/gsd-core/references/debugger-prevention.md +98 -0
  108. package/gsd-core/references/debugger-rca-branching.md +98 -0
  109. package/gsd-core/references/debugger-repro-hardening.md +130 -0
  110. package/gsd-core/references/debugger-sbfl.md +110 -0
  111. package/gsd-core/references/debugger-semantic-recall.md +81 -0
  112. package/gsd-core/references/execute-phase-quota-recovery.md +55 -0
  113. package/gsd-core/references/execute-phase-requirement-revert.md +8 -0
  114. package/gsd-core/references/execute-phase-response-language.md +7 -0
  115. package/gsd-core/references/gate-prompts.md +6 -3
  116. package/gsd-core/references/model-profile-resolution.md +64 -13
  117. package/gsd-core/references/offer-next.md +88 -0
  118. package/gsd-core/references/planner-antipatterns.md +6 -0
  119. package/gsd-core/references/planner-mvp-mode.md +12 -13
  120. package/gsd-core/references/planner-preconditions.md +156 -0
  121. package/gsd-core/references/planner-reversibility.md +132 -0
  122. package/gsd-core/references/planning-config.md +2 -1
  123. package/gsd-core/references/reviewer-instances.md +28 -19
  124. package/gsd-core/references/runtime-aware-dispatch.md +42 -0
  125. package/gsd-core/references/skeleton-template.md +1 -1
  126. package/gsd-core/references/thinking-models-planning.md +3 -1
  127. package/gsd-core/references/ui-consideration-probe.md +2 -2
  128. package/gsd-core/references/worktree-branch-check.md +4 -4
  129. package/gsd-core/templates/DEBUG.md +5 -3
  130. package/gsd-core/templates/summary-minimal.md +4 -0
  131. package/gsd-core/templates/summary-standard.md +4 -0
  132. package/gsd-core/templates/summary.md +7 -0
  133. package/gsd-core/workflows/add-phase.md +2 -0
  134. package/gsd-core/workflows/add-tests.md +3 -1
  135. package/gsd-core/workflows/add-todo.md +32 -1
  136. package/gsd-core/workflows/ai-integration-phase.md +8 -6
  137. package/gsd-core/workflows/audit-fix.md +6 -2
  138. package/gsd-core/workflows/audit-milestone.md +8 -0
  139. package/gsd-core/workflows/autonomous.md +19 -15
  140. package/gsd-core/workflows/check-todos.md +5 -3
  141. package/gsd-core/workflows/cleanup.md +7 -1
  142. package/gsd-core/workflows/code-review-fix.md +14 -6
  143. package/gsd-core/workflows/code-review.md +93 -24
  144. package/gsd-core/workflows/complete-milestone.md +3 -0
  145. package/gsd-core/workflows/debug.md +35 -7
  146. package/gsd-core/workflows/diagnose-issues.md +5 -1
  147. package/gsd-core/workflows/discovery-phase.md +7 -0
  148. package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -4
  149. package/gsd-core/workflows/discuss-phase/modes/auto.md +0 -6
  150. package/gsd-core/workflows/discuss-phase/templates/context.md +16 -2
  151. package/gsd-core/workflows/discuss-phase-assumptions.md +18 -9
  152. package/gsd-core/workflows/discuss-phase.md +2 -2
  153. package/gsd-core/workflows/do.md +7 -1
  154. package/gsd-core/workflows/docs-update.md +9 -0
  155. package/gsd-core/workflows/eval-review.md +4 -1
  156. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +4 -0
  157. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +160 -0
  158. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +4 -4
  159. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +2 -2
  160. package/gsd-core/workflows/execute-phase.md +110 -149
  161. package/gsd-core/workflows/execute-plan.md +20 -8
  162. package/gsd-core/workflows/explore.md +4 -0
  163. package/gsd-core/workflows/extract-learnings.md +21 -0
  164. package/gsd-core/workflows/graduation.md +3 -0
  165. package/gsd-core/workflows/health.md +7 -1
  166. package/gsd-core/workflows/help/modes/full.md +9 -5
  167. package/gsd-core/workflows/import.md +11 -2
  168. package/gsd-core/workflows/inbox.md +7 -0
  169. package/gsd-core/workflows/ingest-docs.md +19 -10
  170. package/gsd-core/workflows/manager.md +3 -1
  171. package/gsd-core/workflows/map-codebase.md +17 -10
  172. package/gsd-core/workflows/mvp-phase.md +3 -0
  173. package/gsd-core/workflows/new-milestone.md +79 -23
  174. package/gsd-core/workflows/new-project.md +28 -19
  175. package/gsd-core/workflows/new-workspace.md +3 -1
  176. package/gsd-core/workflows/next.md +5 -2
  177. package/gsd-core/workflows/onboard.md +3 -0
  178. package/gsd-core/workflows/plan-phase.md +56 -51
  179. package/gsd-core/workflows/plan-review-convergence.md +61 -12
  180. package/gsd-core/workflows/plant-seed.md +3 -0
  181. package/gsd-core/workflows/profile-user.md +7 -1
  182. package/gsd-core/workflows/progress.md +31 -3
  183. package/gsd-core/workflows/quick.md +33 -10
  184. package/gsd-core/workflows/remove-workspace.md +3 -0
  185. package/gsd-core/workflows/review.md +172 -585
  186. package/gsd-core/workflows/scan.md +10 -2
  187. package/gsd-core/workflows/secure-phase.md +13 -2
  188. package/gsd-core/workflows/settings-integrations.md +3 -0
  189. package/gsd-core/workflows/settings.md +3 -0
  190. package/gsd-core/workflows/ship.md +88 -11
  191. package/gsd-core/workflows/sketch.md +3 -0
  192. package/gsd-core/workflows/smart-entry.md +4 -1
  193. package/gsd-core/workflows/spike.md +7 -1
  194. package/gsd-core/workflows/ui-phase.md +11 -2
  195. package/gsd-core/workflows/ui-review.md +11 -1
  196. package/gsd-core/workflows/undo.md +7 -0
  197. package/gsd-core/workflows/update.md +106 -5
  198. package/gsd-core/workflows/validate-phase.md +13 -2
  199. package/gsd-core/workflows/verify-phase.md +2 -2
  200. package/gsd-core/workflows/verify-work.md +15 -4
  201. package/hooks/dist/gsd-context-monitor.js +27 -9
  202. package/hooks/dist/gsd-cursor-session-start.js +6 -2
  203. package/hooks/dist/gsd-cursor-stop.js +6 -2
  204. package/hooks/dist/gsd-cursor-subagent-start.js +6 -2
  205. package/hooks/dist/gsd-graphify-update.sh +9 -0
  206. package/hooks/dist/gsd-phase-boundary.sh +14 -2
  207. package/hooks/dist/gsd-prompt-guard.js +101 -2
  208. package/hooks/dist/gsd-read-guard.js +100 -2
  209. package/hooks/dist/gsd-read-injection-scanner.js +109 -2
  210. package/hooks/dist/gsd-statusline.js +97 -9
  211. package/hooks/dist/gsd-workflow-guard.js +110 -6
  212. package/hooks/dist/gsd-worktree-path-guard.js +132 -8
  213. package/hooks/dist/lib/cursor-workspace.js +74 -0
  214. package/hooks/gsd-context-monitor.js +27 -9
  215. package/hooks/gsd-cursor-session-start.js +6 -2
  216. package/hooks/gsd-cursor-stop.js +6 -2
  217. package/hooks/gsd-cursor-subagent-start.js +6 -2
  218. package/hooks/gsd-graphify-update.sh +9 -0
  219. package/hooks/gsd-phase-boundary.sh +14 -2
  220. package/hooks/gsd-prompt-guard.js +101 -2
  221. package/hooks/gsd-read-guard.js +100 -2
  222. package/hooks/gsd-read-injection-scanner.js +109 -2
  223. package/hooks/gsd-statusline.js +97 -9
  224. package/hooks/gsd-workflow-guard.js +110 -6
  225. package/hooks/gsd-worktree-path-guard.js +132 -8
  226. package/hooks/lib/cursor-workspace.js +74 -0
  227. package/package.json +10 -8
  228. package/pi/gsd.cjs +34 -3
  229. package/scripts/changeset/lint.cjs +1 -0
  230. package/scripts/changeset/parse.cjs +26 -0
  231. package/scripts/check-coverage-gate.cjs +51 -0
  232. package/scripts/check-glossary-refs.cjs +244 -0
  233. package/scripts/ci-rebase-check.cjs +48 -4
  234. package/scripts/ci-test-scope.cjs +67 -17
  235. package/scripts/gen-adr-index.cjs +528 -0
  236. package/scripts/gen-capability-matrix.cjs +26 -2
  237. package/scripts/gen-capability-registry.cjs +132 -34
  238. package/scripts/gen-emitted-baseline.cjs +145 -0
  239. package/scripts/gen-test-timings.cjs +201 -0
  240. package/scripts/lint-compiled-artifact-sync.cjs +146 -0
  241. package/scripts/lint-emitted-drift-ack.cjs +149 -0
  242. package/scripts/lint-fix-has-regression-test.cjs +131 -0
  243. package/scripts/lint-portable-timeout.cjs +140 -0
  244. package/scripts/lint-resolution-provenance.cjs +9 -0
  245. package/scripts/lint-test-file-count.allowlist.json +1 -0
  246. package/scripts/mutation-matrix.cjs +4 -0
  247. package/scripts/prompt-injection-scan.sh +6 -0
  248. package/scripts/registry-schema.cjs +57 -8
  249. package/scripts/release-notes/conventional-title.cjs +19 -1
  250. package/scripts/release-notes/format-github-release-notes.cjs +7 -3
  251. package/scripts/release-tarball-smoke.cjs +18 -11
  252. package/scripts/run-tests.cjs +420 -58
  253. package/scripts/workflow-size.cjs +16 -8
  254. package/skills/gsd-ai-integration-phase/SKILL.md +1 -1
  255. package/skills/gsd-mempalace-capture/SKILL.md +9 -5
  256. package/skills/gsd-new-milestone/SKILL.md +1 -1
  257. package/skills/gsd-plan-phase/SKILL.md +5 -3
  258. package/skills/gsd-plan-review-convergence/SKILL.md +7 -2
  259. package/vscode/package.json +1 -1
  260. package/scripts/gen-golden-install-parity-zcode.cjs +0 -77
  261. package/scripts/update-size-baseline.cjs +0 -68
@@ -43,7 +43,14 @@ function createImperativeAdapter({ runtime }, options = {}) {
43
43
  runtime,
44
44
  registry,
45
45
  install(intent) {
46
- installEngine.installRuntimeArtifacts(runtime, intent.configDir, intent.scope, intent.resolvedProfile, intent.resolveAttribution);
46
+ // #2322: thread the SAME composed registry (loaded above, includeInstalled:true)
47
+ // this adapter exposes via `.registry` into the engine call, so the skills
48
+ // kind's stage() closure can bind an installed third-party capability
49
+ // skill to its declaring capId at staging time — this is the PRIMARY
50
+ // install path (bin/install.js prefers the adapter over the direct
51
+ // installRuntimeArtifacts fallback), so without this the adapter path
52
+ // never staged a third-party capability skill regardless of registration.
53
+ installEngine.installRuntimeArtifacts(runtime, intent.configDir, intent.scope, intent.resolvedProfile, intent.resolveAttribution, registry);
47
54
  },
48
55
  uninstall(intent) {
49
56
  installEngine.uninstallRuntimeArtifacts(runtime, intent.configDir, intent.scope);
@@ -10,6 +10,20 @@
10
10
  const io = require("./io.cjs");
11
11
  const { output, error, ERROR_REASON } = io;
12
12
  // ─── Constants ────────────────────────────────────────────────────────────────
13
+ /**
14
+ * #2296 — The runtime enum of failure classes `classifyAgentFailure` can emit.
15
+ *
16
+ * `AgentFailureResult`'s class strings are TypeScript types, which erase at
17
+ * runtime. Any second surface that needs to validate a class (the
18
+ * `resolve-execution --failure-class` flag) would otherwise have to re-declare
19
+ * the literals, giving two lists that can silently diverge. This frozen enum is
20
+ * the single runtime source both surfaces consume.
21
+ */
22
+ const AGENT_FAILURE_CLASSES = Object.freeze({
23
+ QUOTA_EXCEEDED: 'quota-exceeded',
24
+ CLASSIFY_HANDOFF_BUG: 'classify-handoff-bug',
25
+ UNKNOWN_FAILURE: 'unknown-failure',
26
+ });
13
27
  const QUOTA_SENTINELS = [
14
28
  '429',
15
29
  'usage_limit_reached',
@@ -36,23 +50,23 @@ function classifyAgentFailure(body) {
36
50
  // eslint-disable-next-line @typescript-eslint/no-base-to-string
37
51
  const normalized = String(body ?? '').toLowerCase();
38
52
  if (normalized.trim() === '') {
39
- return { class: 'unknown-failure' };
53
+ return { class: AGENT_FAILURE_CLASSES.UNKNOWN_FAILURE };
40
54
  }
41
55
  for (const sentinel of QUOTA_SENTINELS) {
42
56
  if (normalized.includes(sentinel)) {
43
57
  const retryAfterSeconds = parseRetryAfter(body);
44
58
  return retryAfterSeconds === undefined
45
- ? { class: 'quota-exceeded', sentinel }
46
- : { class: 'quota-exceeded', sentinel, retryAfterSeconds };
59
+ ? { class: AGENT_FAILURE_CLASSES.QUOTA_EXCEEDED, sentinel }
60
+ : { class: AGENT_FAILURE_CLASSES.QUOTA_EXCEEDED, sentinel, retryAfterSeconds };
47
61
  }
48
62
  }
49
63
  if (normalized.includes(CLASSIFY_HANDOFF_SENTINEL)) {
50
64
  return {
51
- class: 'classify-handoff-bug',
65
+ class: AGENT_FAILURE_CLASSES.CLASSIFY_HANDOFF_BUG,
52
66
  sentinel: CLASSIFY_HANDOFF_SENTINEL,
53
67
  };
54
68
  }
55
- return { class: 'unknown-failure' };
69
+ return { class: AGENT_FAILURE_CLASSES.UNKNOWN_FAILURE };
56
70
  }
57
71
  function routeAgentCommand({ args, raw }) {
58
72
  const subcommand = args[1];
@@ -63,6 +77,7 @@ function routeAgentCommand({ args, raw }) {
63
77
  output(classifyAgentFailure(bodyArgs.join(' ')), raw, undefined);
64
78
  }
65
79
  module.exports = {
80
+ AGENT_FAILURE_CLASSES,
66
81
  classifyAgentFailure,
67
82
  routeAgentCommand,
68
83
  };
@@ -17,11 +17,20 @@
17
17
  * (acceptance #2) are testable. Mirrors assumption-delta.cts (#1561).
18
18
  * - COMPOUND SIGNAL for low false positives. A bare word like "api" appears in
19
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.
20
+ * detector requires an INTEGRATION VERB and an EXTERNAL-API NOUN in the SAME
21
+ * CLAUSE (#2365 — same-line co-occurrence across unrelated clauses over-fired;
22
+ * the clause boundary, not a word-gap cap, is the relationship test), or an
23
+ * explicit "<Service> API/SDK" phrase naming a real service. Single weak
24
+ * tokens do not fire. This is the issue's "low false-positive trigger" made
25
+ * mechanical.
26
+ * - CODE AND PATHS ARE NOT PROSE. Fenced code blocks and inline code spans are
27
+ * stripped first (markdown-sectionizer seam), and path-shaped tokens
28
+ * (`src/app/api/...`, URLs) are masked, so a trigger term inside code or a
29
+ * first-party route path does not fire (#2365).
30
+ * - NO-INTEGRATION DECLARATION (#2365 acceptance #5). A COVERAGE.md consisting
31
+ * of `No external API integration: <reason>` is a valid, reasoned way for a
32
+ * phase to state that no external surface exists — the alternative to
33
+ * fabricating a matrix row when the detector is overruled by a human.
25
34
  * - THE DETECTOR IS A FALLBACK. The primary path is the plan:pre contribution
26
35
  * prompting COVERAGE.md creation. The detector runs only when COVERAGE.md is
27
36
  * ABSENT, to catch the "nobody decided" case (acceptance #1). Its precision
@@ -171,19 +180,185 @@ function makeSnippet(line, anchor) {
171
180
  * `[A-Z]\w+ API` shape. Those are common English, not a service name, so they
172
181
  * are rejected before counting as a surface signal (acceptance #4 — low false
173
182
  * positives). */
174
- const SERVICE_SURFACE_API_RE = /\b([A-Z][A-Za-z0-9_-]{1,})\s+(API|SDK|REST|GraphQL)\b/;
183
+ // Service-name length is bounded ({1,40}) so a hostile "A-A-A-…-A-x" run cannot
184
+ // drive the greedy group into O(n^2) backtracking (#2365 review). Nearly all
185
+ // vendor names fit; a >41-char service token before API/SDK would be missed by
186
+ // this surface path (it would still fire via the compound verb+noun rule) —
187
+ // an accepted bound.
188
+ const SERVICE_SURFACE_API_RE = /\b([A-Z][A-Za-z0-9_-]{1,40})\s+(API|SDK|REST|GraphQL)\b/;
175
189
  const SERVICE_STOPWORDS = new Set([
176
190
  'the', 'an', 'a', 'our', 'this', 'these', 'that', 'those', 'new', 'add',
177
191
  'use', 'your', 'my', 'no', 'some', 'any', 'all', 'each', 'every', 'both',
178
192
  'if', 'when', 'while', 'with', 'via', 'using', 'into', 'its', 'their',
179
193
  'we', 'you', 'they', 'it',
180
194
  ]);
195
+ /** #2365 — the detector is FAIL-CLOSED: it leans toward detecting, because a
196
+ * false positive is cheaply dismissed by a one-line COVERAGE.md "no external
197
+ * API integration" declaration, whereas a false NEGATIVE silently lets a real
198
+ * external-API phase past a BLOCKING gate. So the only prose the detector
199
+ * actively suppresses is the classes that are unambiguously NOT external
200
+ * integration: first-party route paths, verb/noun in unrelated clauses, and
201
+ * descriptive/protocol "<Word> API" prose with no named service.
202
+ *
203
+ * CLAUSE_BOUNDARY_RE: a verb and a noun form ONE compound action only inside
204
+ * one grammatical clause — sentence punctuation and table-cell walls (`|`)
205
+ * end a clause. `-` is deliberately absent (it would split hyphenated words).
206
+ * There is deliberately NO word-gap cap inside a clause: a cap cannot separate
207
+ * a genuine long integration clause (F4, 21 words) from a long internal-UI
208
+ * clause (18 words) — the clause boundary is the only sound signal, and the
209
+ * declaration handles the residual false positives. */
210
+ const CLAUSE_BOUNDARY_RE = /[,;:.!?|()—–]/;
211
+ /** Same character class as CLAUSE_BOUNDARY_RE, as a set — for scanning a token's
212
+ * trailing punctuation without an unanchored `[…]+$` regex, whose backtracking
213
+ * is O(n^2) on a long punctuation run (#2365 review). */
214
+ const CLAUSE_BOUNDARY_CHARS = new Set([',', ';', ':', '.', '!', '?', '|', '(', ')', '—', '–']);
215
+ /* DELIBERATELY NO cross-clause binding. Detection is same-clause only. Binding
216
+ * a verb in one clause to a noun in another ("Integrate Stripe, exposing its
217
+ * endpoints"; "Integrate Stripe; use its endpoints") requires knowing "Stripe"
218
+ * is a vendor and "its" refers to it — a vendor dictionary + coreference, which
219
+ * trek-e's brief rules out in principle. Every lexical cross-clause rule tried
220
+ * (word-gap cap, participle continuation) traded a false negative for a false
221
+ * positive across four review rounds. So a service named ONLY in a clause
222
+ * separate from its API noun, with no explicit `<Service> API` surface, is a
223
+ * DOCUMENTED fail-open limitation — cheaply covered by the COVERAGE.md
224
+ * declaration and rare in real phase prose, which says "integrate the X API". */
225
+ /** In the `<Service> API|SDK` surface position, these capture words are NOT a
226
+ * named third-party service: locality/scope descriptors ("Internal API",
227
+ * "Public API") and bare protocol names ("REST API", "GraphQL API"). A real
228
+ * vendor name (Stripe, Shopify) is none of these, so rejecting them costs no
229
+ * true positives while killing the descriptive-prose false positives (#2365
230
+ * acceptance #3, review F8). */
231
+ const SURFACE_DESCRIPTOR_WORDS = new Set([
232
+ 'internal', 'external', 'public', 'private', 'local', 'in-house', 'first-party',
233
+ 'generic', 'shared', 'common', 'legacy', 'rest', 'restful', 'graphql', 'grpc',
234
+ 'soap', 'rpc', 'http', 'https', 'json', 'xml',
235
+ ]);
236
+ /** Locality qualifiers that, when they immediately precede a `<Service> API`,
237
+ * mark it as first-party ("internal Payments API") — negative evidence for an
238
+ * EXTERNAL-API surface signal. Only unambiguously-internal words: "external"
239
+ * is deliberately absent (an external API IS external). */
240
+ const INTERNAL_DESCRIPTORS = new Set(['internal', 'in-house', 'local', 'first-party', 'private']);
241
+ /** A capitalized compound modifier ("Resolver-only", "Read-only", "E-commerce"
242
+ * — lowercase letter right after the hyphen) is an adjective phrase, not a
243
+ * service name. Real hyphenated services capitalize the second segment
244
+ * ("T-Mobile"). */
245
+ const COMPOUND_MODIFIER_RE = /^[A-Z][A-Za-z0-9]*-[a-z]/;
246
+ const URL_TOKEN_RE = /^[([<"'`]*[a-z][a-z0-9+.-]*:\/\//i;
247
+ const LOCAL_URL_RE = /^[([<"'`]*[a-z][a-z0-9+.-]*:\/\/(?:localhost|127(?:\.\d{1,3}){1,3}|0\.0\.0\.0|\[::1\])(?=[:/?#]|$)/i;
248
+ /** A scheme-less token that STARTS with a dotted hostname whose final label is
249
+ * alphabetic ("api.stripe.com/v1") — a bare external API host. A first-party
250
+ * route path ("src/app/api/…") has no dotted head, and an IP host ("127.1/…")
251
+ * has a numeric final label, so neither matches (#2365 review F2). */
252
+ const DOMAIN_HEAD_RE = /^[([<"'`]*(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}(?=[:/?#]|$)/i;
253
+ /** Mask whitespace-delimited tokens with an interior `/` — file paths, framework
254
+ * routes (`src/app/api/...`), URLs. They are references, not integration prose
255
+ * (#2365 root cause 2: `/` counted as a word boundary, so first-party route
256
+ * paths matched the noun vocabulary). Two carve-outs keep genuine signals:
257
+ * - a slashed token whose segments are ALL noun-vocabulary words ("API/SDK",
258
+ * "REST/GraphQL") is prose shorthand, not a path — left unmasked;
259
+ * - a non-local URL is masked, but noun terms inside it are collected as
260
+ * compound-rule evidence (the old detector caught "connect to
261
+ * https://api.stripe.com" via the `api` segment; losing that would
262
+ * fail-open). */
263
+ function scanLineTokens(line, nounRe, nounSet) {
264
+ const urlNouns = [];
265
+ let masked = '';
266
+ const tokenRe = /\S+/g;
267
+ let last = 0;
268
+ let m;
269
+ while ((m = tokenRe.exec(line)) !== null) {
270
+ const rawTok = m[0];
271
+ masked += line.slice(last, m.index);
272
+ last = m.index + rawTok.length;
273
+ // Peel trailing clause-boundary punctuation off the token and keep it
274
+ // LITERAL in `masked` — masking it away would erase a clause split and pair
275
+ // unrelated verb/noun across it (#2365 review F6: "…example.com, document…").
276
+ // A backward char scan (not a `[…]+$` regex) keeps this linear.
277
+ let trailLen = 0;
278
+ while (trailLen < rawTok.length && CLAUSE_BOUNDARY_CHARS.has(rawTok[rawTok.length - 1 - trailLen])) {
279
+ trailLen++;
280
+ }
281
+ const trail = trailLen ? rawTok.slice(rawTok.length - trailLen) : '';
282
+ const tok = trailLen ? rawTok.slice(0, rawTok.length - trailLen) : rawTok;
283
+ if (!/\S[\\/]\S/.test(tok)) {
284
+ masked += rawTok;
285
+ continue;
286
+ }
287
+ const segments = tok.split(/[\\/]/).map((s) => s.replace(/[^A-Za-z0-9]/g, ''));
288
+ if (segments.every((s) => s.length > 0 && (nounSet.has(s.toLowerCase()) || /^v\d+$/i.test(s))) &&
289
+ segments.some((s) => nounSet.has(s.toLowerCase()))) {
290
+ masked += rawTok; // "API/SDK", "API/v2" — noun shorthand, not a path
291
+ continue;
292
+ }
293
+ // A scheme URL or a bare external hostname is an external dependency
294
+ // reference: mask it from prose but keep it as compound-rule evidence. A
295
+ // first-party route path has neither a scheme nor a dotted host, so it is
296
+ // masked WITHOUT contributing nouns (#2365 root cause 2).
297
+ // A non-local URL that NAMES an API vocabulary word ("api.stripe.com/v1")
298
+ // is external-dependency evidence, so its vocab nouns feed the compound
299
+ // rule. We deliberately do NOT treat every path-bearing URL as an endpoint:
300
+ // that fired on ordinary asset/link URLs ("…/theme.css", "…?next=/x") and
301
+ // recreated routine UI-phase false positives (#2365 review). A bare external
302
+ // host that names no vocabulary word ("graph.microsoft.com") and is not
303
+ // written as "<Service> API" is therefore a DOCUMENTED fail-open limitation.
304
+ const isSchemeUrl = URL_TOKEN_RE.test(tok) && !LOCAL_URL_RE.test(tok);
305
+ const isDomainUrl = !URL_TOKEN_RE.test(tok) && DOMAIN_HEAD_RE.test(tok);
306
+ if (nounRe && (isSchemeUrl || isDomainUrl)) {
307
+ for (const f of collectTermMatches(nounRe, tok)) {
308
+ urlNouns.push({ term: f.term, start: m.index, end: m.index + tok.length });
309
+ }
310
+ }
311
+ masked += ' '.repeat(tok.length) + trail;
312
+ }
313
+ masked += line.slice(last);
314
+ return { masked, urlNouns };
315
+ }
316
+ /** All term matches in a clause, with offsets. `re` must be global with the
317
+ * term in group 2 and a consumed leading boundary in group 1. */
318
+ function collectTermMatches(re, clause) {
319
+ const out = [];
320
+ re.lastIndex = 0;
321
+ let m;
322
+ while ((m = re.exec(clause)) !== null) {
323
+ const start = m.index + (m[1] || '').length;
324
+ out.push({ term: (m[2] || '').toLowerCase(), start, end: start + (m[2] || '').length });
325
+ if (m[0].length === 0)
326
+ re.lastIndex++;
327
+ }
328
+ return out;
329
+ }
330
+ /** Split a line into clause segments, keeping each segment's start offset so
331
+ * line-level spans (masked URL tokens) can be mapped into their clause. */
332
+ function splitClauses(masked) {
333
+ const out = [];
334
+ let start = 0;
335
+ for (let i = 0; i <= masked.length; i++) {
336
+ if (i === masked.length || CLAUSE_BOUNDARY_RE.test(masked[i])) {
337
+ out.push({ text: masked.slice(start, i), start });
338
+ start = i + 1;
339
+ }
340
+ }
341
+ return out;
342
+ }
181
343
  /**
182
344
  * Detect whether phase-scope prose describes integrating an external API/SDK.
183
345
  *
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.
346
+ * FAIL-CLOSED: it leans toward detecting, because a false positive is dismissed
347
+ * by a one-line COVERAGE.md declaration while a false negative silently slips a
348
+ * real external-API phase past a blocking gate. It fires when EITHER:
349
+ * (a) an integration VERB and an API NOUN share one CLAUSE ("integrate the
350
+ * Stripe API", "Connect … to api.stripe.com") — the clause boundary is the
351
+ * whole relationship test, so verb/noun in DIFFERENT clauses do not pair
352
+ * (#2365 acceptance #2). There is NO cross-clause binding: a service named
353
+ * only in a clause separate from its API noun is a documented limitation.
354
+ * (b) an explicit `<Service> API|SDK|REST|GraphQL` surface names a service
355
+ * that is not a stopword, a locality/protocol descriptor, a compound
356
+ * modifier, or first-party-qualified ("Stripe API", "Spotify SDK").
357
+ *
358
+ * Fenced code, inline code spans, and path-shaped tokens are excluded before
359
+ * matching. A package-shaped inline span (`@stripe/stripe-js`, `stripe-sdk`)
360
+ * and a URL that NAMES an API vocab word ("api.stripe.com/v1") still count as
361
+ * noun/dependency evidence; a bare host that names none does not.
187
362
  *
188
363
  * Non-string inputs degrade to `{ detected: false }` without throwing.
189
364
  */
@@ -199,46 +374,137 @@ function detectApiIntegration(text, terms) {
199
374
  const signals = [];
200
375
  const seen = new Set();
201
376
  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}`;
377
+ const hasCompoundTerms = effective.verbs.length > 0 && effective.nouns.length > 0;
378
+ // Trailing boundary is a LOOKAHEAD (not consumed) so back-to-back terms
379
+ // separated by one boundary char are both found.
380
+ const verbRe = hasCompoundTerms
381
+ ? new RegExp('(^|[^a-zA-Z0-9])(' + effective.verbs.map(escapeRegex).join('|') + ')(?=[^a-zA-Z0-9]|$)', 'gi')
382
+ : null;
383
+ const nounRe = hasCompoundTerms
384
+ ? new RegExp('(^|[^a-zA-Z0-9])(' + effective.nouns.map(escapeRegex).join('|') + ')(?=[^a-zA-Z0-9]|$)', 'gi')
385
+ : null;
386
+ const surfaceRe = new RegExp(SERVICE_SURFACE_API_RE.source, 'g');
387
+ const nounSet = new Set(effective.nouns);
388
+ const emitPair = (vTerm, nTerm, snippetLine) => {
389
+ const key = `${vTerm}+${nTerm}`;
235
390
  if (seen.has(key))
236
- continue;
391
+ return;
237
392
  seen.add(key);
238
- signals.push({ verb: '(surface)', noun, snippet: makeSnippet(line, m[1]) });
393
+ signals.push({ verb: vTerm, noun: nTerm, snippet: makeSnippet(snippetLine, nTerm) });
394
+ };
395
+ for (const rawLine of lines) {
396
+ // Inline code spans are code, not prose — mask them (length-preserving so
397
+ // offsets keep lining up), but keep package-shaped span content as noun
398
+ // evidence (#2365 review FN-4: `stripe-sdk` names a dependency).
399
+ const inlineSpans = (0, markdown_sectionizer_cjs_1.scanInlineCodeSpans)(rawLine);
400
+ let line = rawLine;
401
+ const spanNouns = [];
402
+ for (const s of inlineSpans) {
403
+ line = line.slice(0, s.start) + ' '.repeat(s.end - s.start) + line.slice(s.end);
404
+ const content = s.content.trim();
405
+ if (content.length === 0 || /\s/.test(content))
406
+ continue;
407
+ const segs = content.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
408
+ if (segs.length < 2)
409
+ continue; // a bare `api` span is a code identifier
410
+ const hit = segs.find((seg) => nounSet.has(seg));
411
+ if (hit)
412
+ spanNouns.push({ term: hit, start: s.start, end: s.end });
413
+ }
414
+ // Path-shaped tokens (routes, file names, URLs) are references, not prose.
415
+ const { masked, urlNouns } = scanLineTokens(line, nounRe, nounSet);
416
+ const clauses = splitClauses(masked);
417
+ const extraNouns = urlNouns.concat(spanNouns);
418
+ // (a) compound verb+noun — SAME CLAUSE ONLY. There is no word-gap cap (a cap
419
+ // cannot tell a long genuine clause from a long internal one) and no
420
+ // cross-clause binding (see the note by CLAUSE_BOUNDARY_CHARS): the clause
421
+ // boundary is the whole relationship test. Nouns are NOT filtered on
422
+ // "internal" qualification here — "integrate the internal API" is a
423
+ // fail-closed positive; the declaration dismisses it if wrong.
424
+ if (verbRe && nounRe) {
425
+ for (const clause of clauses) {
426
+ const verbs = collectTermMatches(verbRe, clause.text);
427
+ if (verbs.length === 0)
428
+ continue;
429
+ const nouns = collectTermMatches(nounRe, clause.text);
430
+ const nounTerms = new Set(nouns.map((t) => t.term));
431
+ for (const u of extraNouns) {
432
+ if (u.start >= clause.start && u.end <= clause.start + clause.text.length) {
433
+ nounTerms.add(u.term);
434
+ }
435
+ }
436
+ if (nounTerms.size === 0)
437
+ continue;
438
+ for (const vTerm of new Set(verbs.map((t) => t.term))) {
439
+ for (const nTerm of nounTerms)
440
+ emitPair(vTerm, nTerm, rawLine);
441
+ }
442
+ }
443
+ }
444
+ // (b) explicit <Service> API|SDK|REST|GraphQL surface — scan every candidate
445
+ // in every clause (a rejected first candidate must not shadow a later
446
+ // genuine service; #2365 review C-1).
447
+ for (const clause of clauses) {
448
+ surfaceRe.lastIndex = 0;
449
+ let m;
450
+ while ((m = surfaceRe.exec(clause.text)) !== null) {
451
+ const svc = m[1] || '';
452
+ const svcLower = svc.toLowerCase();
453
+ // Reject capitalized sentence starters ("The API"), locality/protocol
454
+ // descriptors ("Internal API", "REST API"), compound modifiers
455
+ // ("Resolver-only API"), and services qualified first-party
456
+ // ("internal Payments API"). A real vendor name is none of these.
457
+ if (SERVICE_STOPWORDS.has(svcLower))
458
+ continue;
459
+ if (SURFACE_DESCRIPTOR_WORDS.has(svcLower))
460
+ continue;
461
+ if (COMPOUND_MODIFIER_RE.test(svc))
462
+ continue;
463
+ if (isInternallyQualified(masked, clause.start + m.index))
464
+ continue;
465
+ const noun = (m[2] || '').toLowerCase();
466
+ const key = `surface+${noun}`;
467
+ if (seen.has(key))
468
+ continue;
469
+ seen.add(key);
470
+ signals.push({ verb: '(surface)', noun, snippet: makeSnippet(rawLine, svc) });
471
+ }
472
+ }
239
473
  }
240
474
  return { detected: signals.length > 0, signals, terms: effective };
241
475
  }
476
+ /** True when the word IMMEDIATELY ADJACENT before `offset` is a locality
477
+ * descriptor ("internal Payments API") — first-party qualification is negative
478
+ * evidence for an EXTERNAL-API signal. Only plain spaces/tabs may separate the
479
+ * descriptor from the service: any intervening punctuation means the descriptor
480
+ * belongs to a prior clause/sentence and must NOT qualify ("The cache is
481
+ * private. Stripe API …" — `private` is a different sentence; #2365 review).
482
+ * Looks back through a BOUNDED window, not the whole prefix, to stay linear. */
483
+ const QUALIFIER_LOOKBACK = 24; // longest descriptor ("first-party") + separators
484
+ function isInternallyQualified(masked, offset) {
485
+ const from = offset > QUALIFIER_LOOKBACK ? offset - QUALIFIER_LOOKBACK : 0;
486
+ const window = masked.slice(from, offset);
487
+ // Only whitespace and markdown emphasis/wrapper markers (`*_~\`) may separate
488
+ // the descriptor from the service, so "The **internal** Payments API" still
489
+ // qualifies — but NOT a clause/sentence boundary, so "…is private. Stripe API"
490
+ // does not (the descriptor is a different sentence; #2365 review).
491
+ const m = /([A-Za-z0-9'-]+)[\s*_~`]*$/.exec(window);
492
+ if (!m)
493
+ return false;
494
+ // A word truncated by the window start is not a descriptor match (its real
495
+ // start lies before the window) — fail toward detection.
496
+ if (from > 0 && m.index === 0 && /[A-Za-z0-9'-]/.test(masked[from - 1]))
497
+ return false;
498
+ return INTERNAL_DESCRIPTORS.has(m[1].toLowerCase());
499
+ }
500
+ /** Matches a declaration line such as
501
+ * `No external API integration: <reason>` (also `**bold**` and em-dash
502
+ * separators). The reason is REQUIRED — a bare declaration does not parse.
503
+ * Deliberately NOT matched: blockquoted lines (`> No external …` is quoted
504
+ * text, not a declaration) and anything inside fenced code or HTML comments
505
+ * (both stripped before the scan; #2365 review C-3). */
506
+ const NO_INTEGRATION_DECLARATION_RE = /^\s*(?:\*\*)?no external api integration(?:\*\*)?\s*(?:[:—–-]|--)\s*(\S[^\n]*)$/im;
507
+ const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
242
508
  const VALID_DECISIONS = new Set(['INTEGRATE', 'OPT-OUT']);
243
509
  /**
244
510
  * Parse a coverage matrix from COVERAGE.md. Accepts two bijective formats:
@@ -258,10 +524,17 @@ const VALID_DECISIONS = new Set(['INTEGRATE', 'OPT-OUT']);
258
524
  * `{ rows: [], errors: [], format: 'none' }` for empty/non-matrix input.
259
525
  */
260
526
  function parseCoverageMatrix(text) {
261
- const out = { rows: [], errors: [], format: 'none' };
527
+ const out = { rows: [], errors: [], format: 'none', declaration: null };
262
528
  if (typeof text !== 'string')
263
529
  return out;
264
530
  const src = text.replace(/\r\n/g, '\n');
531
+ // #2365 acceptance #5: a "no external API integration" declaration. Scanned
532
+ // on fence-stripped, comment-stripped text so an example inside a code block
533
+ // or an HTML comment does not count.
534
+ const declMatch = NO_INTEGRATION_DECLARATION_RE.exec((0, markdown_sectionizer_cjs_1.stripFencedCode)(src).text.replace(HTML_COMMENT_RE, ''));
535
+ if (declMatch) {
536
+ out.declaration = { none: true, reason: (declMatch[1] || '').trim() };
537
+ }
265
538
  // (1) fenced ```coverage JSON block takes precedence if present.
266
539
  // Case-insensitive info string (```coverage and ```Coverage are both legal CommonMark).
267
540
  const fenceBody = (0, markdown_sectionizer_cjs_1.extractFencedBlock)(src, 'coverage');
@@ -289,13 +562,19 @@ function parseCoverageMatrix(text) {
289
562
  }
290
563
  return out;
291
564
  }
292
- // (2) markdown table — collect table rows whose decision column parses.
565
+ // (2) markdown table — collect rows from coverage matrix tables only (#2366).
566
+ // Track whether we are inside a recognized coverage matrix (after a header
567
+ // row, before a non-pipe line ends the table). This prevents summary tables
568
+ // elsewhere in the file from being parsed as data (#2366 bug 1) and allows
569
+ // multi-section matrices with repeated headers (#2366 bug 2).
293
570
  const lines = src.split('\n');
294
- let sawHeader = false;
571
+ let inMatrix = false;
295
572
  for (const line of lines) {
296
573
  const trimmed = line.trim();
297
- if (!trimmed.startsWith('|'))
574
+ if (!trimmed.startsWith('|')) {
575
+ inMatrix = false;
298
576
  continue;
577
+ }
299
578
  const cells = trimmed.slice(1, trimmed.endsWith('|') ? -1 : trimmed.length).split('|');
300
579
  if (cells.length < 2)
301
580
  continue;
@@ -304,13 +583,21 @@ function parseCoverageMatrix(text) {
304
583
  // is not mistaken for a separator.
305
584
  if (cleaned.every((c) => /^:?-{3,}:?$/.test(c)))
306
585
  continue;
307
- const decisionCell = (cleaned[1] || '').toUpperCase();
308
- // header detection
309
- if (!sawHeader && cleaned[0].toLowerCase() === 'capability') {
310
- sawHeader = true;
311
- out.format = 'table';
586
+ // Strip markdown emphasis (**, *, __, _, `) from the decision cell before
587
+ // comparison so **OPT-OUT** parses correctly (#2366 bug 3).
588
+ const decisionCell = (cleaned[1] || '').replace(/[*_`]/g, '').trim().toUpperCase();
589
+ // header detection — recognized by 'capability' in column 0; allows multiple
590
+ // headers for multi-section matrices (#2366 bug 2).
591
+ if (cleaned[0].toLowerCase() === 'capability') {
592
+ inMatrix = true;
593
+ if (out.format === 'none')
594
+ out.format = 'table';
312
595
  continue;
313
596
  }
597
+ // Only parse data rows from inside a recognized coverage matrix table.
598
+ // A pipe-table outside the matrix (e.g., a summary table) is ignored (#2366 bug 1).
599
+ if (!inMatrix)
600
+ continue;
314
601
  if (!VALID_DECISIONS.has(decisionCell)) {
315
602
  // A row that otherwise looks like data (≥3 cells, non-empty capability)
316
603
  // but carries a malformed decision is a real error, not a row to skip
@@ -366,6 +653,26 @@ function validateCoverageMatrix(text) {
366
653
  const parsed = parseCoverageMatrix(text);
367
654
  const errors = [...parsed.errors];
368
655
  const rows = parsed.rows;
656
+ // #2365 acceptance #5: a reasoned no-integration declaration with no rows
657
+ // satisfies the gate. A declaration ALONGSIDE rows is contradictory — the
658
+ // file must say one thing.
659
+ if (parsed.declaration) {
660
+ if (rows.length > 0) {
661
+ errors.push('declares "no external API integration" but also contains coverage rows — remove the declaration or the rows');
662
+ }
663
+ else {
664
+ if (parsed.declaration.reason.length > REASON_MAX_LEN) {
665
+ errors.push(`declaration reason exceeds ${REASON_MAX_LEN} chars`);
666
+ }
667
+ const valid = errors.length === 0;
668
+ return {
669
+ valid,
670
+ errors,
671
+ counts: { surface: 0, integrate: 0, optout: 0 },
672
+ none_declared: valid,
673
+ };
674
+ }
675
+ }
369
676
  if (rows.length === 0) {
370
677
  if (errors.length === 0)
371
678
  errors.push('matrix is empty — no capabilities enumerated');
@@ -66,7 +66,7 @@ function scanDebugSessions(planDir) {
66
66
  const content = (0, shell_command_projection_cjs_1.platformReadSync)(safeFilePath);
67
67
  if (content === null)
68
68
  continue;
69
- const fm = extractFrontmatter(content);
69
+ const fm = extractFrontmatter(content, safeFilePath);
70
70
  const status = (fm.status || 'unknown').toLowerCase();
71
71
  if (status === 'resolved' || status === 'complete')
72
72
  continue;
@@ -148,7 +148,7 @@ function scanQuickTasks(planDir) {
148
148
  status = 'unreadable';
149
149
  }
150
150
  else {
151
- const fm = extractFrontmatter(content);
151
+ const fm = extractFrontmatter(content, safeSum);
152
152
  status = (fm.status || 'unknown').toLowerCase();
153
153
  }
154
154
  }
@@ -205,7 +205,7 @@ function scanThreads(planDir) {
205
205
  const content = (0, shell_command_projection_cjs_1.platformReadSync)(safeFilePath);
206
206
  if (content === null)
207
207
  continue;
208
- const fm = extractFrontmatter(content);
208
+ const fm = extractFrontmatter(content, safeFilePath);
209
209
  let status = (fm.status || '').toLowerCase().trim();
210
210
  // Fall back to scanning body for ## Status: OPEN / IN PROGRESS
211
211
  if (!status) {
@@ -266,7 +266,7 @@ function scanTodos(planDir) {
266
266
  const content = (0, shell_command_projection_cjs_1.platformReadSync)(safeFilePath);
267
267
  if (content === null)
268
268
  continue;
269
- const fm = extractFrontmatter(content);
269
+ const fm = extractFrontmatter(content, safeFilePath);
270
270
  // Extract first line of body after frontmatter
271
271
  const bodyMatch = content.replace(/^---[\s\S]*?---\n?/, '');
272
272
  const firstLine = bodyMatch.trim().split('\n')[0] || '';
@@ -317,7 +317,7 @@ function scanSeeds(planDir) {
317
317
  const content = (0, shell_command_projection_cjs_1.platformReadSync)(safeFilePath);
318
318
  if (content === null)
319
319
  continue;
320
- const fm = extractFrontmatter(content);
320
+ const fm = extractFrontmatter(content, safeFilePath);
321
321
  const status = (fm.status || 'dormant').toLowerCase();
322
322
  if (!unimplementedStatuses.has(status))
323
323
  continue;
@@ -382,7 +382,7 @@ function scanUatGaps(planDir) {
382
382
  const content = (0, shell_command_projection_cjs_1.platformReadSync)(safeFilePath);
383
383
  if (content === null)
384
384
  continue;
385
- const fm = extractFrontmatter(content);
385
+ const fm = extractFrontmatter(content, safeFilePath);
386
386
  const status = (fm.status || 'unknown').toLowerCase();
387
387
  const result = (fm.result || '').toLowerCase();
388
388
  // Also accept `result: all_pass` as a fallback when status is absent
@@ -445,7 +445,7 @@ function scanVerificationGaps(planDir) {
445
445
  const content = (0, shell_command_projection_cjs_1.platformReadSync)(safeFilePath);
446
446
  if (content === null)
447
447
  continue;
448
- const fm = extractFrontmatter(content);
448
+ const fm = extractFrontmatter(content, safeFilePath);
449
449
  const status = (fm.status || 'unknown').toLowerCase();
450
450
  if (status !== 'gaps_found' && status !== 'human_needed')
451
451
  continue;
@@ -500,7 +500,7 @@ function scanContextQuestions(planDir) {
500
500
  const content = (0, shell_command_projection_cjs_1.platformReadSync)(safeFilePath);
501
501
  if (content === null)
502
502
  continue;
503
- const fm = extractFrontmatter(content);
503
+ const fm = extractFrontmatter(content, safeFilePath);
504
504
  // Check frontmatter open_questions field
505
505
  let questions = [];
506
506
  if (fm.open_questions) {