@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
@@ -697,26 +697,35 @@ const VALID_CONVERTER_NAMES = new Set([
697
697
  'convertClaudeAgentToCodebuddyAgent',
698
698
  'convertClaudeAgentToClineAgent',
699
699
  'convertClaudeAgentToCodexAgent',
700
+ // ADR-1239 / #2092 Phase B Upgrade 1 — native .qwen/agents/*.md subagent projection.
701
+ 'convertClaudeAgentToQwenAgent',
700
702
  ]);
701
703
 
702
704
  // C3: Validate role:runtime body
703
705
  const VALID_CONFIG_FORMATS = new Set(['settings-json', 'toml', 'markdown', 'markdown-dir', 'none']);
704
- const VALID_CONFIG_HOME_KINDS = new Set(['dot-home', 'dot-home-nested', 'xdg', 'generic-agents-root']);
706
+ // 'none' added #2103 — Marketplace/VSIX-distributed hosts (e.g. VS Code) with
707
+ // no file-projected config directory at all.
708
+ const VALID_CONFIG_HOME_KINDS = new Set(['dot-home', 'dot-home-nested', 'xdg', 'generic-agents-root', 'none']);
705
709
  const VALID_COMMAND_STYLES = new Set(['slash-hyphen', 'shell-var']);
706
- const VALID_HOOKS_SURFACES = new Set(['settings-json', 'codex-hooks-json', 'cursor-hooks-json', 'copilot-inline', 'cline-rules', 'none']);
710
+ const VALID_HOOKS_SURFACES = new Set(['settings-json', 'codex-hooks-json', 'cursor-hooks-json', 'copilot-inline', 'cline-rules', 'kimi-hooks-toml', 'windsurf-hooks-json', 'none']);
707
711
  const VALID_HOOK_EVENTS = new Set(['claude', 'gemini']);
708
712
  // extensionEvents — the plugin/extension-system event dialect (ADR-1239 amendment / #1943).
709
713
  // DISTINCT from hookEvents (managed-hook dialect): extensionEvents describes the
710
714
  // plugin-owned event subset imperative hosts expose (opencode / pi); 'none' = the
711
715
  // host exposes no extension surface (engine owns the bus, e.g. VS Code).
712
- const VALID_EXTENSION_EVENTS = new Set(['opencode', 'pi', 'none']);
716
+ const VALID_EXTENSION_EVENTS = new Set(['opencode', 'pi', 'hermes', 'kilo', 'none']);
713
717
  const VALID_SANDBOX_TIERS = new Set(['none', 'codex-agent-sandbox']);
714
718
  const VALID_ARTIFACT_KIND_NAMES = new Set(['commands', 'agents', 'skills', 'kimi-agents']);
715
719
  const VALID_ARTIFACT_NESTINGS = new Set(['flat', 'nested']);
716
720
  const FEATURE_FIELDS_FORBIDDEN_ON_RUNTIME = ['skills', 'agents', 'steps', 'contributions', 'gates', 'hooks', 'activationKey'];
717
- const VALID_INSTALL_SURFACES = new Set(['settings-json', 'codex-toml', 'copilot-instructions', 'cline-rules', 'cursor-hooks-json', 'profile-marker-only']);
718
- const VALID_PERMISSION_WRITERS = new Set(['opencode', 'kilo']);
719
- const VALID_EXTENDED_HOOK_EVENTS = new Set(['SubagentStop', 'Stop', 'PreCompact', 'FileChanged', 'BeforeAgent', 'AfterAgent', 'BeforeModel']);
721
+ // 'none' added #2103 — Marketplace/VSIX-distributed hosts (e.g. VS Code) that
722
+ // are never CLI-installed (no allRuntimes membership, no install flag).
723
+ const VALID_INSTALL_SURFACES = new Set(['settings-json', 'codex-toml', 'copilot-instructions', 'cline-rules', 'cursor-hooks-json', 'profile-marker-only', 'none']);
724
+ // 'antigravity' added #2096 Phase B Upgrade 1 — settings.json permissions.allow writer.
725
+ const VALID_PERMISSION_WRITERS = new Set(['opencode', 'kilo', 'antigravity']);
726
+ // SubagentStart added #2092 Phase B Upgrade 2 (qwen-only today — see
727
+ // capabilities/qwen/capability.json's extendedHookEvents).
728
+ const VALID_EXTENDED_HOOK_EVENTS = new Set(['SubagentStop', 'Stop', 'PreCompact', 'FileChanged', 'BeforeAgent', 'AfterAgent', 'BeforeModel', 'SubagentStart']);
720
729
 
721
730
  // ADR-1239 Phase A: hostIntegration axes (MUST stay parity-identical to HOST_INTEGRATION_AXES in src/host-integration.cts)
722
731
  const VALID_EMBEDDING_MODES = new Set(['imperative', 'declarative']);
@@ -736,13 +745,18 @@ const INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES = new Map([
736
745
  ['copilot-instructions', new Set(['copilot-inline'])],
737
746
  ['cline-rules', new Set(['cline-rules'])],
738
747
  ['cursor-hooks-json', new Set(['cursor-hooks-json'])],
739
- ['profile-marker-only', new Set(['none'])],
748
+ ['profile-marker-only', new Set(['none', 'kimi-hooks-toml', 'windsurf-hooks-json'])],
749
+ // 'none' added #2103 — VS Code has no CLI install surface at all; its only
750
+ // valid hooksSurface pairing is the other 'none' (engine owns the hook bus).
751
+ ['none', new Set(['none'])],
740
752
  ]);
741
753
 
742
754
  // GATE B: extended hook event families → required hookEvents value
743
755
  // Gemini agent-events require hookEvents='gemini'; Claude-family events require hookEvents='claude'.
744
756
  const GEMINI_AGENT_EVENTS = new Set(['BeforeAgent', 'AfterAgent', 'BeforeModel']);
745
- const CLAUDE_FAMILY_EVENTS = new Set(['SubagentStop', 'Stop', 'PreCompact', 'FileChanged']);
757
+ // SubagentStart added #2092 Phase B Upgrade 2 — Claude hook-event dialect
758
+ // counterpart of SubagentStop (qwen-only today).
759
+ const CLAUDE_FAMILY_EVENTS = new Set(['SubagentStop', 'Stop', 'PreCompact', 'FileChanged', 'SubagentStart']);
746
760
 
747
761
  /**
748
762
  * Validate a runtime.configHome object per ADR-1016 Decision 1.
@@ -771,9 +785,18 @@ function validateConfigHome(capId, ch) {
771
785
  );
772
786
  }
773
787
 
774
- // name — required string
775
- if (typeof ch.name !== 'string' || ch.name.length === 0) {
776
- errors.push(ctx + '.name must be a non-empty string');
788
+ // name — required string, except when kind === 'none': the runtime has no
789
+ // file-projected config directory at all, so a descriptive name is
790
+ // optional (a carve-out mirroring the dot-home-nested⇒parent conditional
791
+ // below, not a new validation mechanism). If present it must still be a
792
+ // non-empty string (e.g. vscode's configHome.name stays a descriptive
793
+ // "vscode" string even though it is never used to build a path).
794
+ if (ch.kind !== 'none') {
795
+ if (typeof ch.name !== 'string' || ch.name.length === 0) {
796
+ errors.push(ctx + '.name must be a non-empty string');
797
+ }
798
+ } else if (ch.name !== undefined && (typeof ch.name !== 'string' || ch.name.length === 0)) {
799
+ errors.push(ctx + '.name must be a non-empty string if present when kind is "none"');
777
800
  }
778
801
 
779
802
  // parent — required when kind == dot-home-nested
@@ -973,7 +996,7 @@ function validateRuntimeBody(cap) {
973
996
  );
974
997
  }
975
998
 
976
- // hooksSurface — closed 6-enum (ADR-1016 Decision 5); inline literal guard (CodeQL barrier)
999
+ // hooksSurface — closed 7-enum (ADR-1016 Decision 5); inline literal guard (CodeQL barrier)
977
1000
  if (r.hooksSurface === '__proto__' || r.hooksSurface === 'constructor' || r.hooksSurface === 'prototype') {
978
1001
  errors.push('runtime.hooksSurface "' + r.hooksSurface + '" is a reserved name');
979
1002
  } else if (!VALID_HOOKS_SURFACES.has(r.hooksSurface)) {
@@ -1054,15 +1077,27 @@ function validateRuntimeBody(cap) {
1054
1077
  // localConfigDir — REQUIRED non-empty dot-dir string (ADR-1239 Phase B #1679)
1055
1078
  // Must start with '.' (e.g. ".claude", ".cursor"). Validated here so the registry
1056
1079
  // generator catches any descriptor missing the field before regenerating.
1057
- if (typeof r.localConfigDir !== 'string' || r.localConfigDir.length === 0) {
1080
+ //
1081
+ // #2103: conditional on configHome.kind !== 'none' — a Marketplace/VSIX
1082
+ // host with no file-projected config directory (e.g. VS Code) has no
1083
+ // local dir to name; localConfigDir may be null/absent for such runtimes.
1084
+ const configHomeKind = (r.configHome && typeof r.configHome === 'object') ? r.configHome.kind : undefined;
1085
+ if (configHomeKind !== 'none') {
1086
+ if (typeof r.localConfigDir !== 'string' || r.localConfigDir.length === 0) {
1087
+ errors.push(
1088
+ 'runtime.localConfigDir is required and must be a non-empty string (e.g. ".claude"); ' +
1089
+ 'got: ' + JSON.stringify(r.localConfigDir),
1090
+ );
1091
+ } else if (!r.localConfigDir.startsWith('.')) {
1092
+ errors.push(
1093
+ 'runtime.localConfigDir must start with "." (a dot-dir); got: ' + JSON.stringify(r.localConfigDir),
1094
+ );
1095
+ }
1096
+ } else if (r.localConfigDir !== null && r.localConfigDir !== undefined) {
1058
1097
  errors.push(
1059
- 'runtime.localConfigDir is required and must be a non-empty string (e.g. ".claude"); ' +
1098
+ 'runtime.localConfigDir must be null or absent when configHome.kind is "none"; ' +
1060
1099
  'got: ' + JSON.stringify(r.localConfigDir),
1061
1100
  );
1062
- } else if (!r.localConfigDir.startsWith('.')) {
1063
- errors.push(
1064
- 'runtime.localConfigDir must start with "." (a dot-dir); got: ' + JSON.stringify(r.localConfigDir),
1065
- );
1066
1101
  }
1067
1102
 
1068
1103
  // extendedHookEvents — required array; every element must be in closed enum
@@ -2168,6 +2203,9 @@ const INSTALL_SURFACE_TO_CONFIG_FORMAT = new Map([
2168
2203
  ['cline-rules', 'markdown-dir'],
2169
2204
  ['cursor-hooks-json', 'none'],
2170
2205
  ['profile-marker-only', 'none'],
2206
+ // 'none' added #2103 — a runtime with NO CLI install surface at all (e.g.
2207
+ // VS Code) has no config-file format to write either.
2208
+ ['none', 'none'],
2171
2209
  ]);
2172
2210
 
2173
2211
  /**
@@ -77,8 +77,17 @@ function setCapabilityState(cwd, runtimeConfigDir, desired, opts) {
77
77
  const before = resolveCapabilityRuntimeState(cwd, runtimeConfigDir);
78
78
  const resolvedConfigDir = before.runtimeConfigDir;
79
79
  // ── Load registry ─────────────────────────────────────────────────────────
80
+ // Issue #2045 (DEFECT 2): validate against the COMPOSED overlay-aware registry
81
+ // (first-party ∪ accepted overlays), mirroring capability-state.cts:547-551.
82
+ // The frozen capability-registry.cjs only knows first-party ids, so a third-
83
+ // party cap failed the membership check below → "unknown capability" even
84
+ // though resolveCapabilityRuntimeState (the `before` snapshot, line 150) already
85
+ // knew about it. loadRegistry is non-throwing and first-party-wins, so a
86
+ // malformed overlay is skipped (never crashes the writer); a truly-unknown id
87
+ // is STILL rejected because it is absent from the composed capabilities map.
80
88
  // eslint-disable-next-line @typescript-eslint/no-require-imports
81
- const registry = require('./capability-registry.cjs');
89
+ const { loadRegistry } = require('./capability-loader.cjs');
90
+ const registry = loadRegistry({ includeInstalled: true, cwd, gsdHome: process.env['GSD_HOME'] });
82
91
  const capabilitiesMap = (registry['capabilities'] && typeof registry['capabilities'] === 'object' && !Array.isArray(registry['capabilities'])
83
92
  ? registry['capabilities']
84
93
  : {});
@@ -37,6 +37,9 @@ const prohibition_enforcement_cjs_1 = require("./prohibition-enforcement.cjs");
37
37
  // eslint-disable-next-line @typescript-eslint/no-require-imports
38
38
  const gatePredicateEval = require("./gate-predicate-evaluator.cjs");
39
39
  const { evaluatePredicate } = gatePredicateEval;
40
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
41
+ const apiCoverageMod = require("./api-coverage.cjs");
42
+ const { detectApiIntegration, validateCoverageMatrix } = apiCoverageMod;
40
43
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
41
44
  // ─── Helpers ──────────────────────────────────────────────────────────────────
42
45
  function normalizePhrase(text) {
@@ -131,10 +134,13 @@ function loadPlanContents(phaseDir) {
131
134
  }
132
135
  }
133
136
  const DESIGNATED_HEADINGS_RE = /^#{1,6}\s+(?:must[_ ]haves?|truths?|tasks?|objective)\b/i;
134
- const XML_DECISION_TAGS_RE = /<(?:objective|tasks?|action)(?:\s[^>]*)?>([\s\S]*?)<\/(?:objective|tasks?|action)>/gi;
137
+ const XML_DECISION_TAGS_RE = /<(?:objective|tasks?|action)(?:\s[^>]{0,1000})?>((?:(?!<(?:objective|tasks?|action)[\s>])[\s\S])*?)<\/(?:objective|tasks?|action)>/gi;
135
138
  function stripCommentsAndFences(text) {
136
139
  // HTML-comment stripping stays caller-side (the seam does not strip HTML comments).
137
- const htmlStripped = text.replace(/<!--[\s\S]*?-->/g, ' ');
140
+ // Stop-at-next-open body (ReDoS-safe, #2128); an UNCLOSED `<!--` does not match,
141
+ // so downstream tags are preserved (unlike a `(?:-->|$)` fallback, which would
142
+ // wipe to EOF and fail-close the decision-coverage gate).
143
+ const htmlStripped = text.replace(/<!--(?:(?!<!--)[\s\S])*?-->/g, ' ');
138
144
  // Fenced-code stripping: delegate to the canonical CommonMark-correct seam.
139
145
  // replaces the prior independent regex copy (```` ``` ``` ```` + `~~~ ~~~`).
140
146
  return (0, markdown_sectionizer_cjs_1.stripFencedCode)(htmlStripped).text;
@@ -876,6 +882,233 @@ function cmdCheckPredicate(projectDir, args, raw) {
876
882
  }
877
883
  output(result, raw, undefined);
878
884
  }
885
+ // ─── api-coverage-verify-pre ──────────────────────────────────────────────────
886
+ /**
887
+ * api-coverage.verify-pre: BLOCKING seal-time gate for the ai-integration
888
+ * capability (#1562). Enforces "Full API Coverage by Default — Opt Out, Never
889
+ * Opt In." A phase that integrates an external API/SDK/service may not seal
890
+ * until a COVERAGE.md matrix enumerates the surface and every non-integrated
891
+ * capability is an explicit, reasoned opt-out.
892
+ *
893
+ * Contract (two touch points composed into one check):
894
+ * 1. If COVERAGE.md exists in the phase dir → validate it (acceptance #2).
895
+ * Block on any validation error (empty matrix, OPT-OUT without reason,
896
+ * duplicate/empty capability).
897
+ * 2. If COVERAGE.md is absent → run detectApiIntegration over the phase scope
898
+ * (PLAN.md body, then ROADMAP phase section as fallback). If a strong
899
+ * external-API-integration signal is detected → BLOCK ("integration
900
+ * detected without coverage matrix"). If no signal → PASS (treat as a
901
+ * non-API phase; acceptance #4 — low false positives).
902
+ *
903
+ * The detector is the FALLBACK for the "nobody decided / forgot the matrix"
904
+ * case; the primary path is the plan:pre contribution prompting COVERAGE.md.
905
+ *
906
+ * Args: check api-coverage.verify-pre <phase-dir>
907
+ * Emits the uniform gate contract: { block, passed, message, ...details }.
908
+ */
909
+ function cmdApiCoverageVerifyPre(projectDir, args, raw) {
910
+ const phaseArg = typeof args[2] === 'string' ? args[2] : '';
911
+ if (!phaseArg) {
912
+ error('api-coverage.verify-pre requires a phase argument: check api-coverage.verify-pre <phase-dir-or-token>', ERROR_REASON.SDK_MISSING_ARG);
913
+ return;
914
+ }
915
+ const pDir = planningDir(projectDir);
916
+ const phasesRoot = node_path_1.default.join(pDir, 'phases');
917
+ // SECURITY (path traversal): the phase argument is taken ONLY as a phase
918
+ // token — its basename — and resolved by findPhaseInternal strictly under
919
+ // .planning/phases/ (or a milestone archive). The raw arg is never used as a
920
+ // path, so `..`, absolute paths, and arbitrary directories cannot reach a
921
+ // file read. Mirrors cmdVerifySchemaDrift's token-match approach.
922
+ let token = phaseArg.replace(/\\/g, '/').split('/').filter(Boolean).pop() || '';
923
+ // A token like ".." or "." carries no phase identity → unresolvable.
924
+ if (token === '.' || token === '..')
925
+ token = '';
926
+ // Not a GSD project (no phases tree at all) → fail-open: nothing to gate.
927
+ if (!node_fs_1.default.existsSync(phasesRoot)) {
928
+ output({
929
+ block: false,
930
+ passed: true,
931
+ coverage_present: false,
932
+ detected: false,
933
+ message: 'api-coverage: no .planning/phases directory; gate skipped (not a GSD project layout)',
934
+ }, raw, undefined);
935
+ return;
936
+ }
937
+ // Resolve the phase dir under the contained phases root.
938
+ let resolvedDir = null;
939
+ let phaseNumber = '';
940
+ if (token) {
941
+ const found = findPhaseInternal(projectDir, token);
942
+ if (found && found.directory) {
943
+ resolvedDir = found.directory;
944
+ phaseNumber = found.phase_number || '';
945
+ }
946
+ }
947
+ if (!resolvedDir) {
948
+ // The phases tree EXISTS but THIS phase could not be resolved. For a
949
+ // BLOCKING gate, fail-closed: a missing phase dir must not silently bypass
950
+ // the coverage requirement. (Distinguished from "no .planning at all"
951
+ // above, which is a genuine non-GSD-project → pass.)
952
+ output({
953
+ block: true,
954
+ passed: false,
955
+ coverage_present: false,
956
+ detected: false,
957
+ phase_lookup_failed: true,
958
+ message: `api-coverage: could not resolve phase "${phaseArg}" under .planning/phases/. ` +
959
+ 'Resolve the phase directory (or produce COVERAGE.md) before sealing.',
960
+ }, raw, undefined);
961
+ return;
962
+ }
963
+ // Defense-in-depth: the resolved dir must be inside the phases root (or a
964
+ // milestone archive under .planning/milestones).
965
+ const milestonesRoot = node_path_1.default.join(pDir, 'milestones');
966
+ if (!isInsideRoot(resolvedDir, phasesRoot) && !isInsideRoot(resolvedDir, milestonesRoot)) {
967
+ output({
968
+ block: true,
969
+ passed: false,
970
+ coverage_present: false,
971
+ detected: false,
972
+ message: 'api-coverage: resolved phase dir escapes .planning/ — refusing to evaluate',
973
+ }, raw, undefined);
974
+ return;
975
+ }
976
+ // (1) locate COVERAGE.md — prefer the exact name, then a single *-COVERAGE.md.
977
+ let coverageFile = '';
978
+ let suffixed = [];
979
+ try {
980
+ const entries = node_fs_1.default.readdirSync(resolvedDir, { withFileTypes: true });
981
+ const files = entries.filter((e) => e.isFile()).map((e) => e.name);
982
+ const exact = files.find((f) => /^COVERAGE\.md$/i.test(f));
983
+ if (exact) {
984
+ coverageFile = exact;
985
+ }
986
+ else {
987
+ suffixed = files.filter((f) => /-COVERAGE\.md$/i.test(f)).sort();
988
+ if (suffixed.length === 1)
989
+ coverageFile = suffixed[0];
990
+ }
991
+ }
992
+ catch {
993
+ // readdir failure → treat as no matrix readable; fall through to detection.
994
+ }
995
+ if (coverageFile) {
996
+ let matrixText;
997
+ try {
998
+ matrixText = node_fs_1.default.readFileSync(node_path_1.default.join(resolvedDir, coverageFile), 'utf8');
999
+ }
1000
+ catch {
1001
+ // COVERAGE.md exists but is unreadable (EACCES/EIO/encoding). Fail-closed
1002
+ // with a useful message rather than a raw throw.
1003
+ output({
1004
+ block: true,
1005
+ passed: false,
1006
+ coverage_present: true,
1007
+ message: `api-coverage: COVERAGE.md exists but is unreadable — fix file permissions/encoding before sealing`,
1008
+ }, raw, undefined);
1009
+ return;
1010
+ }
1011
+ const v = validateCoverageMatrix(matrixText);
1012
+ if (v.valid) {
1013
+ output({
1014
+ block: false,
1015
+ passed: true,
1016
+ coverage_present: true,
1017
+ matrix: coverageFile,
1018
+ counts: v.counts,
1019
+ message: `api-coverage: matrix present (${v.counts.surface} capabilities, ${v.counts.optout} opt-out)`,
1020
+ }, raw, undefined);
1021
+ return;
1022
+ }
1023
+ // Fixed-template message (no raw cell content echoed into the LLM-facing
1024
+ // message). The structured `errors` array is safe (row-indexed, no cell
1025
+ // values) and travels as data for tooling that wants detail.
1026
+ output({
1027
+ block: true,
1028
+ passed: false,
1029
+ coverage_present: true,
1030
+ matrix: coverageFile,
1031
+ error_count: v.errors.length,
1032
+ errors: v.errors,
1033
+ message: `api-coverage: COVERAGE.md has ${v.errors.length} problem(s) — fix the matrix (every capability INTEGRATE or OPT-OUT with a reason) before sealing`,
1034
+ }, raw, undefined);
1035
+ return;
1036
+ }
1037
+ if (suffixed.length > 1) {
1038
+ output({
1039
+ block: true,
1040
+ passed: false,
1041
+ coverage_present: false,
1042
+ message: `api-coverage: multiple *-COVERAGE.md files found (${suffixed.length}) — consolidate into one COVERAGE.md before sealing`,
1043
+ }, raw, undefined);
1044
+ return;
1045
+ }
1046
+ // (2) no matrix — detect whether this phase integrates an external API.
1047
+ const scopeText = readPhaseScope(projectDir, resolvedDir, phaseNumber);
1048
+ const detection = detectApiIntegration(scopeText);
1049
+ if (detection.detected) {
1050
+ // Surface only verb/noun (typed, bounded) — NOT raw prose snippets — so the
1051
+ // gate output cannot relay injected PLAN.md instructions to the orchestrator.
1052
+ const signals = detection.signals.map((s) => ({ verb: s.verb, noun: s.noun }));
1053
+ output({
1054
+ block: true,
1055
+ passed: false,
1056
+ coverage_present: false,
1057
+ detected: true,
1058
+ signals,
1059
+ message: 'api-coverage: external-API integration detected without a coverage matrix. ' +
1060
+ 'Produce COVERAGE.md enumerating the API surface (every capability INTEGRATE or ' +
1061
+ 'OPT-OUT with a reason) before sealing. Full coverage is the default.',
1062
+ }, raw, undefined);
1063
+ return;
1064
+ }
1065
+ output({
1066
+ block: false,
1067
+ passed: true,
1068
+ coverage_present: false,
1069
+ detected: false,
1070
+ message: 'api-coverage: no external-API integration detected; coverage matrix not required',
1071
+ }, raw, undefined);
1072
+ }
1073
+ /**
1074
+ * Read the phase-scope text used for API-integration detection. Uses the
1075
+ * resolved plan files (PLAN.md bodies — the planner's own words about what the
1076
+ * phase does) and, as a fallback, ONLY THIS PHASE'S ROADMAP section (not the
1077
+ * whole roadmap, which would cross-contaminate sibling phases). Strips nothing
1078
+ * here — detectApiIntegration strips fenced code itself.
1079
+ */
1080
+ function readPhaseScope(projectDir, phaseDir, phaseNumber) {
1081
+ const chunks = [];
1082
+ try {
1083
+ const entries = node_fs_1.default.readdirSync(phaseDir, { withFileTypes: true });
1084
+ const plans = entries
1085
+ .filter((e) => e.isFile() && /-PLAN\.md$/i.test(e.name))
1086
+ .map((e) => e.name)
1087
+ .sort();
1088
+ for (const p of plans) {
1089
+ chunks.push(node_fs_1.default.readFileSync(node_path_1.default.join(phaseDir, p), 'utf8'));
1090
+ }
1091
+ }
1092
+ catch {
1093
+ // ignore — fall through to roadmap
1094
+ }
1095
+ if (chunks.join('').trim().length > 0)
1096
+ return chunks.join('\n\n');
1097
+ // Fallback: ONLY this phase's ROADMAP section (not the whole file, which
1098
+ // would pollute detection with sibling-phase prose). Best-effort; absence or
1099
+ // an unresolvable section is non-fatal (detector returns not-detected).
1100
+ if (phaseNumber) {
1101
+ try {
1102
+ const section = getRoadmapPhaseWithFallback(projectDir, phaseNumber);
1103
+ if (section)
1104
+ return section;
1105
+ }
1106
+ catch {
1107
+ // ignore
1108
+ }
1109
+ }
1110
+ return '';
1111
+ }
879
1112
  function routeCheckCommand({ args, cwd, raw }) {
880
1113
  // Normalize dots to hyphens in the subcommand so both forms are accepted.
881
1114
  // This makes `check.query = "ui.plan-gate"` (dotted form in capability.json gates)
@@ -905,6 +1138,12 @@ function routeCheckCommand({ args, cwd, raw }) {
905
1138
  cmdGapAnalysisPlanPost(cwd, args, raw);
906
1139
  return;
907
1140
  }
1141
+ if (subcommand === 'api-coverage-verify-pre') {
1142
+ // ai-integration capability blocking gate at verify:pre (#1562). Dot-to-
1143
+ // hyphen normalization means query "api-coverage.verify-pre" routes here.
1144
+ cmdApiCoverageVerifyPre(cwd, args, raw);
1145
+ return;
1146
+ }
908
1147
  if (subcommand === 'tdd-review-checkpoint') {
909
1148
  cmdTddReviewCheckpoint(cwd, args, raw);
910
1149
  return;
@@ -945,7 +1184,7 @@ function routeCheckCommand({ args, cwd, raw }) {
945
1184
  (0, prohibition_enforcement_cjs_1.routeProhibitionEnforcement)(args, raw);
946
1185
  return;
947
1186
  }
948
- error('Unknown check subcommand. Available: auto-mode, decision-coverage-plan, decision-coverage-verify, gap-analysis-plan-post, predicate, prohibition-enforcement, tdd-review-checkpoint, ui-plan-gate, ui-safety-gate, verify-schema-drift, verify-codebase-drift', ERROR_REASON.SDK_UNKNOWN_COMMAND);
1187
+ error('Unknown check subcommand. Available: api-coverage-verify-pre, auto-mode, decision-coverage-plan, decision-coverage-verify, gap-analysis-plan-post, predicate, prohibition-enforcement, tdd-review-checkpoint, ui-plan-gate, ui-safety-gate, verify-schema-drift, verify-codebase-drift', ERROR_REASON.SDK_UNKNOWN_COMMAND);
949
1188
  }
950
1189
  module.exports = {
951
1190
  routeCheckCommand,
@@ -471,9 +471,11 @@ function cmdEffortSync(cwd, raw, opts) {
471
471
  // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
472
472
  const { getGlobalConfigDir } = require('./runtime-homes.cjs');
473
473
  // Use install-time resolvers: they merge ~/.gsd/defaults.json with project config,
474
- // matching the exact logic used when agents were originally installed.
474
+ // matching the exact logic used when agents were originally installed. #2071: these
475
+ // live in the shipped sibling install-effort-resolver.cjs (extracted from the
476
+ // package-root bin/install.js, which the installer never copies into a runtime home).
475
477
  // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
476
- const { readGsdEffectiveEffortConfig, resolveInstallTimeEffort } = require('../../../bin/install.js');
478
+ const { readGsdEffectiveEffortConfig, resolveInstallTimeEffort } = require('./install-effort-resolver.cjs');
477
479
  const effortCfg = readGsdEffectiveEffortConfig(cwd);
478
480
  const agentsDir = node_path_1.default.join(opts.configDir || getGlobalConfigDir(runtime), 'agents');
479
481
  if (!node_fs_1.default.existsSync(agentsDir)) {
@@ -1157,7 +1159,7 @@ function cmdTodoMatchPhase(cwd, phase, raw) {
1157
1159
  const planContent = (0, shell_command_projection_cjs_1.platformReadSync)(node_path_1.default.join(phaseDir, pf));
1158
1160
  if (planContent === null)
1159
1161
  continue;
1160
- const fmFiles = planContent.match(/files_modified:\s*\[([^\]]*)\]/);
1162
+ const fmFiles = planContent.match(/files_modified:\s*\[([^\]]{0,8000})\]/);
1161
1163
  if (fmFiles) {
1162
1164
  phasePlans.push(...fmFiles[1].split(',').map(s => s.trim().replace(/['"]/g, '')).filter(Boolean));
1163
1165
  }
@@ -1302,8 +1304,8 @@ function cmdStats(cwd, format, raw) {
1302
1304
  const roadmapContent = extractCurrentMilestone(roadmapRaw, cwd);
1303
1305
  // Matches both plain numeric (Phase 1:) and milestone-prefixed (Phase 2-01:) headings.
1304
1306
  // Also tolerates optional [bracket-token] scope prefix on phase headings.
1305
- // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
1306
- const headingPattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n]+)/gi;
1307
+ // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
1308
+ const headingPattern = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:\s*([^\n]+)/gi;
1307
1309
  let match;
1308
1310
  while ((match = headingPattern.exec(roadmapContent)) !== null) {
1309
1311
  const key = normalizePhaseName(match[1]);
@@ -104,6 +104,7 @@ const CONFIG_DEFAULTS = {
104
104
  verifier: _getNestedConfigDefault('workflow', 'verifier'),
105
105
  nyquist_validation: _getNestedConfigDefault('workflow', 'nyquist_validation'),
106
106
  ai_integration_phase: _getNestedConfigDefault('workflow', 'ai_integration_phase'),
107
+ api_coverage_gate: _getNestedConfigDefault('workflow', 'api_coverage_gate'),
107
108
  parallelization: _getConfigDefault('parallelization'),
108
109
  brave_search: _getConfigDefault('brave_search'),
109
110
  firecrawl: _getConfigDefault('firecrawl'),
@@ -209,6 +209,7 @@ function buildNewProjectConfig(userChoices) {
209
209
  ui_phase: true,
210
210
  ui_safety_gate: true,
211
211
  ai_integration_phase: true,
212
+ api_coverage_gate: true,
212
213
  human_verify_mode: 'end-of-phase',
213
214
  context_guard_mode: 'warn',
214
215
  text_mode: false,
@@ -408,6 +409,81 @@ function _setNestedValue(config, keyPath, parsedValue) {
408
409
  current[lastKey] = parsedValue;
409
410
  return previousValue;
410
411
  }
412
+ /**
413
+ * Deletes a value from the config object, allowing nested values via dot
414
+ * notation (e.g., "review.models.gemini"). Mirrors `_setNestedValue`'s
415
+ * prototype-pollution guard on every path segment (including intermediates).
416
+ *
417
+ * Unlike `_setNestedValue`, this NEVER creates missing intermediate objects —
418
+ * if any segment along the path is missing (or not a plain, non-array
419
+ * object), the key doesn't exist and we return early without mutating
420
+ * `config` at all.
421
+ *
422
+ * Does not prune now-empty parent objects after deletion (matches the
423
+ * conservative, structure-preserving behaviour callers expect from a bare
424
+ * unset).
425
+ *
426
+ * Returns { previousValue, existed } — existed is false when the leaf key
427
+ * (or an intermediate segment) was never present.
428
+ * Calls error() (process.exit(1)) on prototype-pollution attempts.
429
+ */
430
+ function _unsetNestedValue(config, keyPath) {
431
+ const keys = keyPath.split('.');
432
+ let current = config;
433
+ for (let i = 0; i < keys.length - 1; i++) {
434
+ const key = keys[i];
435
+ if (key === '__proto__' || key === 'prototype' || key === 'constructor') {
436
+ error('Invalid config key (prototype pollution guard): ' + keyPath, ERROR_REASON.CONFIG_PARSE_FAILED);
437
+ }
438
+ const existingChild = current[key];
439
+ if (existingChild === undefined || existingChild === null || typeof existingChild !== 'object' || Array.isArray(existingChild)) {
440
+ // Path doesn't exist — nothing to unset, and we must not create it.
441
+ return { previousValue: undefined, existed: false };
442
+ }
443
+ current = existingChild;
444
+ }
445
+ const lastKey = keys[keys.length - 1];
446
+ if (lastKey === '__proto__' || lastKey === 'prototype' || lastKey === 'constructor') {
447
+ error('Invalid config key (prototype pollution guard): ' + keyPath, ERROR_REASON.CONFIG_PARSE_FAILED);
448
+ }
449
+ const existed = Object.prototype.hasOwnProperty.call(current, lastKey);
450
+ const previousValue = current[lastKey];
451
+ if (existed) {
452
+ delete current[lastKey];
453
+ }
454
+ return { previousValue, existed };
455
+ }
456
+ /**
457
+ * Deletes a key from the config file, allowing nested values via dot
458
+ * notation. Mirrors `setConfigValue`'s load/lock/write cycle.
459
+ *
460
+ * Does not call `output()`, so can be used as one step in a command without triggering `exit(0)` in
461
+ * the happy path. But note that `error()` will still `exit(1)` out of the process.
462
+ */
463
+ function unsetConfigValue(cwd, keyPath) {
464
+ const configPath = node_path_1.default.join(planningDir(cwd), 'config.json');
465
+ return withPlanningLock(cwd, () => {
466
+ // Load existing config or start with empty object
467
+ let config = {};
468
+ try {
469
+ if (node_fs_1.default.existsSync(configPath)) {
470
+ config = JSON.parse(node_fs_1.default.readFileSync(configPath, 'utf-8'));
471
+ }
472
+ }
473
+ catch (err) {
474
+ error('Failed to read config.json: ' + err.message, ERROR_REASON.CONFIG_PARSE_FAILED);
475
+ }
476
+ const { previousValue, existed } = _unsetNestedValue(config, keyPath);
477
+ // Write back
478
+ try {
479
+ (0, shell_command_projection_cjs_1.platformWriteSync)(configPath, JSON.stringify(config, null, 2));
480
+ return { updated: existed, unset: true, key: keyPath, value: null, previousValue };
481
+ }
482
+ catch (err) {
483
+ error('Failed to write config.json: ' + err.message);
484
+ }
485
+ });
486
+ }
411
487
  /**
412
488
  * Sets a value in the config file, allowing nested values via dot notation (e.g.,
413
489
  * "workflow.research").
@@ -531,6 +607,8 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
531
607
  parsedValue = true;
532
608
  else if (val === 'false')
533
609
  parsedValue = false;
610
+ else if (val === 'null')
611
+ parsedValue = null;
534
612
  // #1581: Number.isFinite (not !isNaN) so 'Infinity'/'-Infinity' are NOT
535
613
  // coerced to non-finite numbers that JSON.stringify later renders as `null`
536
614
  // (disk=null while the CLI echoed 'Infinity'). They fall through to the
@@ -544,6 +622,24 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
544
622
  }
545
623
  catch { /* keep as string */ }
546
624
  }
625
+ // #2046: a bare `null` unsets (deletes) the key — the documented "Clear" action.
626
+ // Short-circuits before every typed per-key validator so clearing a typed key
627
+ // (enum/boolean/number) removes it rather than being rejected. Deleting (not
628
+ // persisting JSON null) is the correct "clear": a persisted null is still a
629
+ // present, truthy-adjacent value that consumers must special-case — worst for
630
+ // secret keys where a leftover value can be passed as a real credential.
631
+ if (parsedValue === null) {
632
+ const unsetResult = unsetConfigValue(cwd, kp);
633
+ if ((0, secrets_cjs_1.isSecretKey)(kp)) {
634
+ const maskedPrev = unsetResult.previousValue === undefined
635
+ ? undefined
636
+ : (0, secrets_cjs_1.maskSecret)(unsetResult.previousValue);
637
+ output({ ...unsetResult, value: null, previousValue: maskedPrev, masked: true }, raw, `${kp} unset`);
638
+ return;
639
+ }
640
+ output(unsetResult, raw, `${kp} unset`);
641
+ return;
642
+ }
547
643
  // #1581: project_code is an identifier string — never number-coerce it. A
548
644
  // leading-zero code like '007' must persist verbatim (not collapse to 7).
549
645
  if (kp === 'project_code') {
@@ -176,7 +176,10 @@ function timeAgo(date) {
176
176
  function extractCanonicalPlanId(filename) {
177
177
  const base = filename.replace(/-PLAN\.md$/i, '').replace(/-SUMMARY\.md$/i, '').replace(/\.md$/i, '');
178
178
  const parts = base.split('-').filter(Boolean);
179
- const tokenRe = /^\d+[A-Z]?(?:\.\d+)*$/i;
179
+ // #2043: a phase/plan token component is either a zero-padded number (≥2 digits)
180
+ // or a single-digit-plus-letter id ("3A"); a *bare* single digit is a slug word,
181
+ // so "46-6-rs-…" is not paired into a "46-6" id while "3A-01" stays intact.
182
+ const tokenRe = /^(?:\d{2,}[A-Z]?|\d[A-Z])(?:\.\d+)*$/i;
180
183
  const phaseIdx = parts.findIndex(p => tokenRe.test(p));
181
184
  if (phaseIdx >= 0 && phaseIdx + 1 < parts.length && tokenRe.test(parts[phaseIdx + 1])) {
182
185
  return `${parts[phaseIdx]}-${parts[phaseIdx + 1]}`;