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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) 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 +28 -0
  7. package/bin/install.js +624 -456
  8. package/gsd-core/bin/gsd-tools.cjs +40 -1
  9. package/gsd-core/bin/lib/api-coverage.cjs +466 -0
  10. package/gsd-core/bin/lib/audit.cjs +6 -3
  11. package/gsd-core/bin/lib/capability-loader.cjs +11 -9
  12. package/gsd-core/bin/lib/capability-registry.cjs +325 -64
  13. package/gsd-core/bin/lib/capability-validator.cjs +1 -1
  14. package/gsd-core/bin/lib/capability-writer.cjs +10 -1
  15. package/gsd-core/bin/lib/check-command-router.cjs +242 -3
  16. package/gsd-core/bin/lib/commands.cjs +7 -5
  17. package/gsd-core/bin/lib/config-loader.cjs +1 -0
  18. package/gsd-core/bin/lib/config.cjs +96 -0
  19. package/gsd-core/bin/lib/core-utils.cjs +4 -1
  20. package/gsd-core/bin/lib/host-integration-adapters/cline-sdk-binding.cjs +234 -0
  21. package/gsd-core/bin/lib/host-integration-adapters/imperative-hook-bus.cjs +145 -0
  22. package/gsd-core/bin/lib/host-integration.cjs +16 -0
  23. package/gsd-core/bin/lib/init.cjs +74 -30
  24. package/gsd-core/bin/lib/install-effort-resolver.cjs +213 -0
  25. package/gsd-core/bin/lib/install-engine.cjs +151 -11
  26. package/gsd-core/bin/lib/installer-migration-report.cjs +4 -0
  27. package/gsd-core/bin/lib/loop-resolver.cjs +68 -17
  28. package/gsd-core/bin/lib/markdown-sectionizer.cjs +50 -11
  29. package/gsd-core/bin/lib/milestone.cjs +3 -3
  30. package/gsd-core/bin/lib/normalize-test-command.cjs +187 -0
  31. package/gsd-core/bin/lib/phase-id.cjs +132 -3
  32. package/gsd-core/bin/lib/phase.cjs +78 -16
  33. package/gsd-core/bin/lib/planning-workspace.cjs +17 -0
  34. package/gsd-core/bin/lib/roadmap-command-router.cjs +5 -4
  35. package/gsd-core/bin/lib/roadmap-parser.cjs +21 -30
  36. package/gsd-core/bin/lib/roadmap-upgrade.cjs +9 -9
  37. package/gsd-core/bin/lib/roadmap.cjs +42 -56
  38. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +13 -6
  39. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +2 -2
  40. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +17 -10
  41. package/gsd-core/bin/lib/runtime-homes.cjs +8 -0
  42. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +33 -25
  43. package/gsd-core/bin/lib/spec-section.cjs +111 -0
  44. package/gsd-core/bin/lib/state-transition.cjs +1 -1
  45. package/gsd-core/bin/lib/state.cjs +24 -24
  46. package/gsd-core/bin/lib/surface.cjs +30 -0
  47. package/gsd-core/bin/lib/uat.cjs +4 -1
  48. package/gsd-core/bin/lib/validate.cjs +15 -6
  49. package/gsd-core/bin/lib/verify.cjs +33 -37
  50. package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
  51. package/gsd-core/bin/shared/model-catalog.json +6 -6
  52. package/gsd-core/references/api-coverage.md +104 -0
  53. package/gsd-core/references/model-profiles.md +2 -2
  54. package/gsd-core/references/planning-config.md +2 -0
  55. package/gsd-core/references/specless-probe-fallback.md +172 -0
  56. package/gsd-core/templates/config.json +2 -1
  57. package/gsd-core/workflows/audit-fix.md +9 -1
  58. package/gsd-core/workflows/code-review-fix.md +7 -3
  59. package/gsd-core/workflows/code-review.md +4 -1
  60. package/gsd-core/workflows/discuss-phase-assumptions.md +4 -1
  61. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +8 -4
  62. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +42 -0
  63. package/gsd-core/workflows/execute-phase.md +1 -25
  64. package/gsd-core/workflows/plan-phase.md +31 -2
  65. package/gsd-core/workflows/quick.md +2 -2
  66. package/gsd-core/workflows/review.md +59 -13
  67. package/gsd-core/workflows/settings-advanced.md +5 -5
  68. package/gsd-core/workflows/settings.md +2 -2
  69. package/gsd-core/workflows/verify-phase.md +3 -2
  70. package/gsd-core/workflows/verify-work.md +38 -0
  71. package/hooks/dist/gsd-cursor-pre-tool.js +76 -0
  72. package/hooks/dist/gsd-cursor-stop.js +48 -0
  73. package/hooks/dist/gsd-cursor-subagent-start.js +50 -0
  74. package/hooks/dist/gsd-cursor-subagent-stop.js +40 -0
  75. package/hooks/dist/managed-hooks-registry.cjs +4 -0
  76. package/hooks/gsd-cursor-pre-tool.js +76 -0
  77. package/hooks/gsd-cursor-stop.js +48 -0
  78. package/hooks/gsd-cursor-subagent-start.js +50 -0
  79. package/hooks/gsd-cursor-subagent-stop.js +40 -0
  80. package/hooks/managed-hooks-registry.cjs +4 -0
  81. package/package.json +2 -1
  82. package/scripts/build-hooks.js +5 -1
  83. package/scripts/lint-phase-id-drift.cjs +150 -0
  84. package/scripts/run-tests.cjs +21 -1
@@ -709,7 +709,7 @@ const VALID_HOOK_EVENTS = new Set(['claude', 'gemini']);
709
709
  // DISTINCT from hookEvents (managed-hook dialect): extensionEvents describes the
710
710
  // plugin-owned event subset imperative hosts expose (opencode / pi); 'none' = the
711
711
  // host exposes no extension surface (engine owns the bus, e.g. VS Code).
712
- const VALID_EXTENSION_EVENTS = new Set(['opencode', 'pi', 'none']);
712
+ const VALID_EXTENSION_EVENTS = new Set(['opencode', 'pi', 'hermes', 'none']);
713
713
  const VALID_SANDBOX_TIERS = new Set(['none', 'codex-agent-sandbox']);
714
714
  const VALID_ARTIFACT_KIND_NAMES = new Set(['commands', 'agents', 'skills', 'kimi-agents']);
715
715
  const VALID_ARTIFACT_NESTINGS = new Set(['flat', 'nested']);
@@ -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]}`;