@opengsd/gsd-core 1.9.0 → 1.10.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 (223) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +2 -3
  3. package/.opencode/plugins/gsd-core.js +8 -1
  4. package/agents/gsd-code-fixer.md +131 -34
  5. package/agents/gsd-debugger.md +12 -246
  6. package/agents/gsd-executor.md +7 -5
  7. package/agents/gsd-integration-checker.md +3 -0
  8. package/agents/gsd-plan-checker.md +9 -0
  9. package/agents/gsd-planner.md +5 -8
  10. package/agents/gsd-roadmapper.md +21 -3
  11. package/agents/gsd-verifier.md +14 -70
  12. package/bin/install.js +503 -341
  13. package/commands/gsd/mempalace-capture.md +1 -1
  14. package/commands/gsd/new-milestone.md +1 -1
  15. package/commands/gsd/plan-phase.md +1 -1
  16. package/gsd-core/bin/gsd-tools.cjs +607 -63
  17. package/gsd-core/bin/lib/active-workstream-store.cjs +25 -0
  18. package/gsd-core/bin/lib/agent-install-check.cjs +38 -6
  19. package/gsd-core/bin/lib/api-coverage.cjs +120 -0
  20. package/gsd-core/bin/lib/audit.cjs +89 -1
  21. package/gsd-core/bin/lib/broken-windows.cjs +36 -6
  22. package/gsd-core/bin/lib/capability-registry.cjs +96 -110
  23. package/gsd-core/bin/lib/capability-validator.cjs +12 -2
  24. package/gsd-core/bin/lib/check-command-router.cjs +43 -1
  25. package/gsd-core/bin/lib/command-aliases.cjs +72 -0
  26. package/gsd-core/bin/lib/commands.cjs +26 -25
  27. package/gsd-core/bin/lib/commonjs-marker.cjs +136 -0
  28. package/gsd-core/bin/lib/config-loader.cjs +1 -0
  29. package/gsd-core/bin/lib/config.cjs +12 -1
  30. package/gsd-core/bin/lib/context-composer.cjs +278 -0
  31. package/gsd-core/bin/lib/context-predicates.cjs +506 -0
  32. package/gsd-core/bin/lib/core-utils.cjs +91 -12
  33. package/gsd-core/bin/lib/docs.cjs +3 -2
  34. package/gsd-core/bin/lib/external-job.cjs +19 -4
  35. package/gsd-core/bin/lib/frontmatter.cjs +84 -12
  36. package/gsd-core/bin/lib/gate-predicate-evaluator.cjs +57 -6
  37. package/gsd-core/bin/lib/git-base-branch.cjs +58 -15
  38. package/gsd-core/bin/lib/graphify.cjs +142 -27
  39. package/gsd-core/bin/lib/gsd2-import.cjs +27 -4
  40. package/gsd-core/bin/lib/host-integration.cjs +13 -1
  41. package/gsd-core/bin/lib/init-command-router.cjs +83 -8
  42. package/gsd-core/bin/lib/init.cjs +1021 -57
  43. package/gsd-core/bin/lib/install-engine.cjs +64 -10
  44. package/gsd-core/bin/lib/install-profiles.cjs +27 -1
  45. package/gsd-core/bin/lib/installer-migration-authoring.cjs +3 -1
  46. package/gsd-core/bin/lib/installer-migration-report.cjs +4 -0
  47. package/gsd-core/bin/lib/installer-migrations/007-retire-config-root-commonjs-marker.cjs +149 -0
  48. package/gsd-core/bin/lib/installer-migrations/008-cursor-retire-commands-surface.cjs +55 -0
  49. package/gsd-core/bin/lib/installer-migrations/009-pi-retire-reserved-hooks-dir.cjs +199 -0
  50. package/gsd-core/bin/lib/installer-migrations.cjs +87 -1
  51. package/gsd-core/bin/lib/io.cjs +28 -3
  52. package/gsd-core/bin/lib/markdown-sectionizer.cjs +6 -0
  53. package/gsd-core/bin/lib/mcp-catalog.cjs +518 -0
  54. package/gsd-core/bin/lib/mcp-server.cjs +135 -3
  55. package/gsd-core/bin/lib/milestone.cjs +106 -51
  56. package/gsd-core/bin/lib/phase-id.cjs +63 -0
  57. package/gsd-core/bin/lib/phase-locator.cjs +138 -45
  58. package/gsd-core/bin/lib/phase.cjs +260 -25
  59. package/gsd-core/bin/lib/plan-dependency-graph.cjs +232 -0
  60. package/gsd-core/bin/lib/planning-workspace.cjs +4 -0
  61. package/gsd-core/bin/lib/project-root.cjs +48 -0
  62. package/gsd-core/bin/lib/prompt-budget.cjs +128 -165
  63. package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +80 -0
  64. package/gsd-core/bin/lib/review-lane-descriptor.cjs +99 -0
  65. package/gsd-core/bin/lib/review-lane-runner.cjs +30 -6
  66. package/gsd-core/bin/lib/roadmap-command-router.cjs +42 -9
  67. package/gsd-core/bin/lib/roadmap-parser.cjs +100 -18
  68. package/gsd-core/bin/lib/roadmap.cjs +37 -7
  69. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +195 -62
  70. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +15 -3
  71. package/gsd-core/bin/lib/runtime-homes.cjs +154 -41
  72. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +105 -41
  73. package/gsd-core/bin/lib/section-manifest.cjs +209 -0
  74. package/gsd-core/bin/lib/shell-command-projection.cjs +113 -27
  75. package/gsd-core/bin/lib/smart-entry.cjs +12 -0
  76. package/gsd-core/bin/lib/state-transition.cjs +73 -8
  77. package/gsd-core/bin/lib/state.cjs +151 -62
  78. package/gsd-core/bin/lib/surface.cjs +12 -1
  79. package/gsd-core/bin/lib/uat-predicate.cjs +11 -1
  80. package/gsd-core/bin/lib/uat.cjs +320 -21
  81. package/gsd-core/bin/lib/unusable-input.cjs +9 -0
  82. package/gsd-core/bin/lib/verification.cjs +29 -12
  83. package/gsd-core/bin/lib/verify.cjs +29 -5
  84. package/gsd-core/bin/lib/workflow-fragments.cjs +557 -0
  85. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +181 -18
  86. package/gsd-core/bin/lib/workstream-inventory.cjs +519 -27
  87. package/gsd-core/bin/lib/workstream.cjs +6 -0
  88. package/gsd-core/bin/lib/worktree-base-ref.cjs +50 -6
  89. package/gsd-core/bin/lib/worktree-safety.cjs +276 -118
  90. package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
  91. package/gsd-core/references/artifact-types.md +10 -3
  92. package/gsd-core/references/autonomous-ui-design-contract.md +42 -0
  93. package/gsd-core/references/debugger-techniques.md +255 -0
  94. package/gsd-core/references/research-documentation-lookup.md +5 -3
  95. package/gsd-core/references/specless-probe-fallback.md +7 -6
  96. package/gsd-core/references/verifier-wiring-patterns.md +100 -0
  97. package/gsd-core/references/worktree-branch-check.md +2 -2
  98. package/gsd-core/templates/summary-complex.md +2 -0
  99. package/gsd-core/templates/summary-minimal.md +2 -0
  100. package/gsd-core/templates/summary-standard.md +2 -0
  101. package/gsd-core/templates/summary.md +2 -0
  102. package/gsd-core/workflows/audit-milestone.md +3 -0
  103. package/gsd-core/workflows/autonomous/steps/converge-banner.md +1 -0
  104. package/gsd-core/workflows/autonomous/steps/converge-dispatch-bg.md +11 -0
  105. package/gsd-core/workflows/autonomous/steps/converge-dispatch-inline.md +7 -0
  106. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +21 -0
  107. package/gsd-core/workflows/autonomous/steps/converge-loop.md +7 -0
  108. package/gsd-core/workflows/autonomous.md +32 -69
  109. package/gsd-core/workflows/code-review/steps/dispatch-fix.md +39 -0
  110. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +83 -0
  111. package/gsd-core/workflows/code-review.md +42 -145
  112. package/gsd-core/workflows/complete-milestone/steps/git-tag.md +29 -0
  113. package/gsd-core/workflows/complete-milestone.md +23 -81
  114. package/gsd-core/workflows/debug.md +9 -12
  115. package/gsd-core/workflows/diagnose-issues.md +22 -0
  116. package/gsd-core/workflows/discovery-phase.md +4 -4
  117. package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +15 -0
  118. package/gsd-core/workflows/discuss-phase-assumptions.md +5 -16
  119. package/gsd-core/workflows/docs-update/steps/dispatch-monorepo-packages.md +51 -0
  120. package/gsd-core/workflows/docs-update.md +8 -51
  121. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +34 -2
  122. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +50 -0
  123. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +31 -0
  124. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +19 -0
  125. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +42 -0
  126. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +43 -37
  127. package/gsd-core/workflows/execute-phase.md +65 -137
  128. package/gsd-core/workflows/execute-plan.md +1 -1
  129. package/gsd-core/workflows/help/modes/full.md +6 -1
  130. package/gsd-core/workflows/ingest-docs.md +2 -1
  131. package/gsd-core/workflows/new-milestone/steps/project-md-milestone-write.md +16 -0
  132. package/gsd-core/workflows/new-milestone/steps/reset-phase-safety.md +19 -0
  133. package/gsd-core/workflows/new-milestone.md +21 -38
  134. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +176 -0
  135. package/gsd-core/workflows/new-project/steps/auto-mode-detection.md +32 -0
  136. package/gsd-core/workflows/new-project/steps/codebase-map-offer.md +18 -0
  137. package/gsd-core/workflows/new-project.md +13 -226
  138. package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +15 -0
  139. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +110 -0
  140. package/gsd-core/workflows/plan-phase/steps/prd-express-gate.md +8 -0
  141. package/gsd-core/workflows/plan-phase/steps/research-only-early-exit.md +17 -0
  142. package/gsd-core/workflows/plan-phase/steps/research-only-modifiers.md +16 -0
  143. package/gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md +17 -0
  144. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +149 -0
  145. package/gsd-core/workflows/plan-phase.md +49 -193
  146. package/gsd-core/workflows/progress/steps/forensic-audit.md +125 -0
  147. package/gsd-core/workflows/progress/steps/mvp-display.md +18 -0
  148. package/gsd-core/workflows/progress.md +11 -153
  149. package/gsd-core/workflows/quick/steps/discussion-phase.md +124 -0
  150. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +111 -0
  151. package/gsd-core/workflows/quick/steps/quick-verification.md +46 -0
  152. package/gsd-core/workflows/quick/steps/research-phase.md +72 -0
  153. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +37 -0
  154. package/gsd-core/workflows/quick.md +20 -390
  155. package/gsd-core/workflows/resume-project.md +3 -0
  156. package/gsd-core/workflows/review/steps/reviewer-instances-note-1.md +4 -0
  157. package/gsd-core/workflows/review/steps/reviewer-instances-note-2.md +3 -0
  158. package/gsd-core/workflows/review.md +15 -8
  159. package/gsd-core/workflows/section-manifest.json +219 -0
  160. package/gsd-core/workflows/sketch.md +1 -1
  161. package/gsd-core/workflows/spec-phase.md +17 -14
  162. package/gsd-core/workflows/spike-wrap-up.md +20 -5
  163. package/gsd-core/workflows/spike.md +50 -16
  164. package/gsd-core/workflows/sync-skills.md +49 -11
  165. package/gsd-core/workflows/transition/steps/workstream-collision-check.md +17 -0
  166. package/gsd-core/workflows/transition.md +8 -21
  167. package/gsd-core/workflows/ui-phase.md +8 -7
  168. package/gsd-core/workflows/update/steps/channel-banner.md +7 -0
  169. package/gsd-core/workflows/update.md +18 -7
  170. package/gsd-core/workflows/verify-phase.md +4 -7
  171. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +36 -0
  172. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +21 -0
  173. package/gsd-core/workflows/verify-work.md +8 -58
  174. package/hooks/dist/gsd-agent-isolation-guard.js +428 -0
  175. package/hooks/dist/gsd-check-update-worker.js +14 -5
  176. package/hooks/dist/gsd-cursor-subagent-start.js +532 -26
  177. package/hooks/dist/gsd-read-injection-scanner.js +7 -0
  178. package/hooks/dist/gsd-statusline.js +72 -6
  179. package/hooks/dist/gsd-worktree-path-guard.js +2 -1
  180. package/hooks/dist/gsd-write-guard.js +359 -0
  181. package/hooks/dist/lib/isolation-sentinel.js +268 -0
  182. package/hooks/dist/managed-hooks-registry.cjs +2 -0
  183. package/hooks/gsd-agent-isolation-guard.js +428 -0
  184. package/hooks/gsd-check-update-worker.js +14 -5
  185. package/hooks/gsd-cursor-subagent-start.js +532 -26
  186. package/hooks/gsd-read-injection-scanner.js +7 -0
  187. package/hooks/gsd-statusline.js +72 -6
  188. package/hooks/gsd-worktree-path-guard.js +2 -1
  189. package/hooks/gsd-write-guard.js +359 -0
  190. package/hooks/hooks.json +12 -0
  191. package/hooks/lib/isolation-sentinel.js +268 -0
  192. package/hooks/managed-hooks-registry.cjs +2 -0
  193. package/package.json +14 -5
  194. package/pi/gsd.cjs +57 -12
  195. package/scripts/build-hooks.js +9 -0
  196. package/scripts/changeset/lint.cjs +9 -2
  197. package/scripts/changeset/serialize.cjs +5 -1
  198. package/scripts/gen-capability-matrix.cjs +1 -1
  199. package/scripts/gen-context-index.cjs +448 -0
  200. package/scripts/gen-inventory-manifest.cjs +101 -1
  201. package/scripts/gen-prompt-budget-parity-corpus.cjs +645 -0
  202. package/scripts/gen-registry.cjs +39 -15
  203. package/scripts/gen-section-manifest.cjs +638 -0
  204. package/scripts/generate-package-identity.cjs +4 -2
  205. package/scripts/lint-allow-test-rule-refs.allowlist.json +17 -31
  206. package/scripts/lint-compiled-artifact-sync.cjs +6 -1
  207. package/scripts/lint-docs-command-form.cjs +195 -0
  208. package/scripts/lint-docs-required.cjs +9 -1
  209. package/scripts/lint-emitted-drift-ack.cjs +215 -20
  210. package/scripts/lint-example-parser-parity.cjs +395 -0
  211. package/scripts/lint-test-file-count.allowlist.json +27 -1
  212. package/scripts/mutation-matrix.cjs +13 -0
  213. package/scripts/prompt-injection-scan.sh +27 -6
  214. package/scripts/registry-schema.cjs +323 -94
  215. package/scripts/run-tests.cjs +3 -2
  216. package/scripts/validate-registry.cjs +10 -6
  217. package/skills/gsd-autonomous/SKILL.md +1 -1
  218. package/skills/gsd-execute-phase/SKILL.md +1 -1
  219. package/skills/gsd-mempalace-capture/SKILL.md +1 -1
  220. package/skills/gsd-new-milestone/SKILL.md +1 -1
  221. package/skills/gsd-plan-phase/SKILL.md +2 -2
  222. package/vscode/package.json +1 -1
  223. package/scripts/gen-emitted-baseline.cjs +0 -145
@@ -264,6 +264,9 @@ const { resolveActiveWorkstream, applyResolvedWorkstreamEnv } = require('./lib/a
264
264
  const state = require('./lib/state.cjs');
265
265
  const phase = require('./lib/phase.cjs');
266
266
  const roadmap = require('./lib/roadmap.cjs');
267
+ // #3024: resolve skills root for the sync-skills workflow (install.js is not
268
+ // shipped in installed trees; gsd-tools IS shipped, so the workflow calls this).
269
+ const { getGlobalSkillsBase, isRegisteredRuntimeId } = require('./lib/runtime-homes.cjs');
267
270
  // #1561 — assumption-delta advisory checkpoint detector (pure function).
268
271
  const { detectAssumptionDelta } = require('./lib/assumption-delta.cjs');
269
272
  const verify = require('./lib/verify.cjs');
@@ -1044,6 +1047,37 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1044
1047
  commands.cmdCurrentTimestamp(args[1] || 'full', raw);
1045
1048
  }
1046
1049
 
1050
+ function routeSkillsRoot({ args, raw, error }) {
1051
+ // #3024: resolve the global skills base directory for a runtime.
1052
+ // The sync-skills workflow previously shelled out to install.js --skills-root,
1053
+ // but install.js is not shipped in installed trees. gsd-tools IS shipped, so
1054
+ // the workflow now calls `gsd-tools query skills-root <runtime>` instead.
1055
+ const runtime = args[1];
1056
+ if (!runtime) {
1057
+ error('Usage: gsd-tools query skills-root <runtime>');
1058
+ }
1059
+ // Defect B (#3024): validate the runtime id against the shipped capability
1060
+ // registry's canonical runtime set BEFORE resolving anything.
1061
+ // getGlobalSkillsBase falls through getGlobalConfigDir's unknown-runtime
1062
+ // branch to claude's skills root for ANY id it doesn't recognize, so an
1063
+ // unknown, empty/whitespace-only, path-traversal, or shell-metacharacter
1064
+ // runtime arg would otherwise silently resolve to claude's path instead of
1065
+ // failing loudly. isRegisteredRuntimeId does an own-property lookup (not a
1066
+ // bare index), rejecting `__proto__`/`constructor`/`prototype` runtime
1067
+ // ids, and is the SAME validator install.js's `--skills-root` entry point
1068
+ // calls, so the two shipped entry points can never diverge on which
1069
+ // runtime ids they accept.
1070
+ if (!isRegisteredRuntimeId(runtime)) {
1071
+ error(`Unknown runtime "${runtime}" — must be a registered runtime id`);
1072
+ }
1073
+ const trimmedRuntime = typeof runtime === 'string' ? runtime.trim() : '';
1074
+ const skillsRoot = getGlobalSkillsBase(trimmedRuntime);
1075
+ if (skillsRoot === null) {
1076
+ error(`No skills root found for runtime "${trimmedRuntime}"`);
1077
+ }
1078
+ output({ skills_root: skillsRoot }, raw, skillsRoot);
1079
+ }
1080
+
1047
1081
  function routeProjectInstructionFile({ args, cwd, raw, error }) {
1048
1082
  // #1529: pure runtime→filename projection. Backs the
1049
1083
  // `gsd_run query project-instruction-file --runtime <r>` call in
@@ -1145,16 +1179,33 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1145
1179
  const cp = require('node:child_process');
1146
1180
  const fsx = require('node:fs');
1147
1181
  const os = require('node:os');
1148
- const { REVIEWER_LANES } = require('./lib/review-lane-descriptor.cjs');
1182
+ const { REVIEWER_LANES, mergeReviewerLanes } = require('./lib/review-lane-descriptor.cjs');
1149
1183
  const { resolveLanePlan } = require('./lib/review-lane-invocation.cjs');
1150
1184
  const runner = require('./lib/review-lane-runner.cjs');
1151
1185
  const cfgLoader = require('./lib/config-loader.cjs');
1186
+ const capabilityLoader = require('./lib/capability-loader.cjs');
1152
1187
 
1153
1188
  const flag = (name) => {
1154
1189
  const i = args.indexOf(name);
1155
1190
  return i !== -1 && args[i + 1] && !String(args[i + 1]).startsWith('--') ? args[i + 1] : null;
1156
1191
  };
1157
1192
  const sub = args[1];
1193
+ // Fail fast on an unrecognized subcommand. Without this check, `sub` fell through
1194
+ // to the `sub !== 'invoke'` usage-error branch far below (after loading the
1195
+ // capability registry AND building a per-lane plan for every lane — which itself
1196
+ // spawns one child `query resolve-execution` process per lane via `effortFor`,
1197
+ // up to 12 subprocess spawns for the default lane set) before ever reporting the
1198
+ // error. That made an invalid subcommand slow instead of instant, and under bench
1199
+ // load (many sequential node spawns) `review-lane bogus` could exceed a caller's
1200
+ // spawn timeout and be killed before writing anything to stderr — the CI-observed
1201
+ // failure was empty stdout AND stderr, not the expected usage message (#3148).
1202
+ // `plan`/`invoke` are the only subs that need the expensive plan-building path
1203
+ // below; `sections`/`flags` return earlier still. Anything else errors here, before
1204
+ // any of that work starts.
1205
+ if (!['plan', 'invoke', 'sections', 'flags'].includes(sub)) {
1206
+ error("Usage: review-lane <plan|invoke|sections|flags> [--selected a,b] [--run-dir D] [--repo-root R]");
1207
+ return;
1208
+ }
1158
1209
  const runDir = flag('--run-dir') || '.';
1159
1210
  const repoRoot = flag('--repo-root') || cwd;
1160
1211
 
@@ -1176,8 +1227,30 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1176
1227
 
1177
1228
  const selected = (flag('--selected') || '')
1178
1229
  .split(',').map((s) => s.trim()).filter(Boolean);
1179
- const laneBySlug = new Map(REVIEWER_LANES.map((l) => [l.slug, l]));
1180
- const chosen = selected.length ? selected : REVIEWER_LANES.map((l) => l.slug);
1230
+ // ADR-2782 D8 (#2927): the lane map is first-party ∪ INSTALLED overlay
1231
+ // `reviewer` bodies, first-party winning on slug collision. Before this merge
1232
+ // the map was built from the frozen REVIEWER_LANES array alone, so an installed,
1233
+ // consented third-party reviewer lane was roster-visible (deriveReviewerSlugs)
1234
+ // and disclosed at install (collectReviewerLaneSurfaces) but never selectable,
1235
+ // plannable, or invocable — `sections`/`flags`/`plan`/`invoke` all consumed this
1236
+ // one map. The overlay body is field-identical to a ReviewerLane (ADR-2782 D1,
1237
+ // "no translation layer"), so `mergeReviewerLanes` is a pure merge, not a
1238
+ // projection. loadRegistry is TOTAL and never throws on a malformed overlay
1239
+ // (it skips the cap with a warning), and mergeReviewerLanes is total in turn,
1240
+ // so a bad third-party manifest cannot take the first-party lanes down with it.
1241
+ // `includeInstalled` is what merges project + global overlay caps into the
1242
+ // registry; without it the base is first-party-only and this is a no-op.
1243
+ let mergedLanes = REVIEWER_LANES;
1244
+ try {
1245
+ const registry = capabilityLoader.loadRegistry({ includeInstalled: true, cwd });
1246
+ mergedLanes = mergeReviewerLanes(REVIEWER_LANES, registry);
1247
+ } catch {
1248
+ // A registry load failure must never block first-party review. Degrade to the
1249
+ // static set — identical to pre-fix behavior — rather than crashing review-lane.
1250
+ mergedLanes = REVIEWER_LANES;
1251
+ }
1252
+ const laneBySlug = new Map(mergedLanes.map((l) => [l.slug, l]));
1253
+ const chosen = selected.length ? selected : mergedLanes.map((l) => l.slug);
1181
1254
 
1182
1255
  if (sub === 'sections') {
1183
1256
  const rows = chosen
@@ -1307,7 +1380,16 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1307
1380
  // cannot be interrupted by --test-force-exit and hangs a whole CI chunk to its 10-minute kill.
1308
1381
  const deps = {
1309
1382
  spawn: (binary, argv, opts) => {
1310
- const r = cp.spawnSync(binary, argv, {
1383
+ // #3086: on Windows, reviewer CLIs (gemini, codex, etc.) are installed
1384
+ // as .cmd shims. spawnSync with a bare name + shell:false fails with
1385
+ // ENOENT (CreateProcess cannot start .cmd). Apply the same #2667 shim
1386
+ // gate used in runWithTimeout: detect .cmd/.bat and mediate through
1387
+ // cmd.exe /d /s /c with an explicit argv array (no shell:true).
1388
+ const isWin = process.platform === 'win32';
1389
+ const winShim = isWin && /\.(cmd|bat)$/i.test(path.basename(binary));
1390
+ const spawnBinary = winShim ? (process.env.ComSpec || 'cmd.exe') : binary;
1391
+ const spawnArgv = winShim ? ['/d', '/s', '/c', binary, ...argv] : argv;
1392
+ const r = cp.spawnSync(spawnBinary, spawnArgv, {
1311
1393
  input: opts.input,
1312
1394
  encoding: 'utf8',
1313
1395
  timeout: opts.timeoutMs,
@@ -1564,6 +1646,24 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1564
1646
  // `orchestrator-worktree`. It requires `--cwd-target` (the GSD-created
1565
1647
  // worktree path) and optionally `--prompt`; without a target there is
1566
1648
  // nothing to bind, so `exec` is null.
1649
+ //
1650
+ // #3045 CORE REDESIGN: this is now the SOLE resolver of "what isolation
1651
+ // applies to this dispatch", and — as an unconditional side effect — it
1652
+ // PERSISTS that resolved decision (mode + harnessFlag + phase/plan
1653
+ // identifiers, written together in one atomic write) to the sentinel the
1654
+ // guard hooks read. Previously the sentinel was written by prose-gated
1655
+ // shell blocks in `executor-isolation-dispatch.md` that a model was told
1656
+ // to "read and run" — a prose-gated writer for a guard against
1657
+ // prose-gated values is the same defect class the guard exists to close.
1658
+ // The workflow MUST call this query to learn ISOLATION at all, so
1659
+ // recording here is structurally unskippable. `--phase`/`--plan` are
1660
+ // optional identifiers threaded through from the caller (workflow shell
1661
+ // variables); `--force-isolation <mode>` lets a caller that has
1662
+ // additional context this resolver cannot see (the #2474 per-plan
1663
+ // submodule intersection, computed in shell in
1664
+ // `per-plan-worktree-gate.md`) override the naturally-resolved mode
1665
+ // while still going through this single write path. Best-effort: a
1666
+ // sentinel write failure here must never fail the wave.
1567
1667
  const VALID_ISOLATION = new Set(['harness-worktree', 'orchestrator-worktree', 'none']);
1568
1668
  let isolation = 'none';
1569
1669
  let runtimeId = null;
@@ -1622,6 +1722,40 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1622
1722
  harnessFlag = null;
1623
1723
  }
1624
1724
 
1725
+ // `--force-isolation <mode>` overrides the naturally-resolved mode with
1726
+ // context this resolver has no way to see on its own (e.g. the #2474
1727
+ // per-plan submodule intersection). Invalid/unrecognized values are
1728
+ // ignored rather than erroring — this is a best-effort recording call,
1729
+ // not a hard usage gate. Forcing to 'none' clears harnessFlag/exec since
1730
+ // neither applies to sequential dispatch.
1731
+ const forceIdx = args.indexOf('--force-isolation');
1732
+ const forcedIsolation = forceIdx !== -1 ? args[forceIdx + 1] : undefined;
1733
+ if (forcedIsolation && VALID_ISOLATION.has(forcedIsolation)) {
1734
+ isolation = forcedIsolation;
1735
+ if (isolation === 'none') {
1736
+ harnessFlag = null;
1737
+ exec = null;
1738
+ }
1739
+ }
1740
+
1741
+ const phaseIdx = args.indexOf('--phase');
1742
+ const phaseArg = phaseIdx !== -1 && args[phaseIdx + 1] && !args[phaseIdx + 1].startsWith('--')
1743
+ ? args[phaseIdx + 1]
1744
+ : null;
1745
+ const planIdx = args.indexOf('--plan');
1746
+ const planArg = planIdx !== -1 && args[planIdx + 1] && !args[planIdx + 1].startsWith('--')
1747
+ ? args[planIdx + 1]
1748
+ : null;
1749
+
1750
+ // Side-effect write (#3045 CORE REDESIGN) — see the doc comment above.
1751
+ // Never allowed to affect this query's own stdout contract or throw.
1752
+ try {
1753
+ writeDispatchIsolationSentinel(cwd, { isolation, harnessFlag, phase: phaseArg, plan: planArg });
1754
+ } catch {
1755
+ // writeDispatchIsolationSentinel already swallows its own errors into
1756
+ // a { recorded: false } result; this catch is defense in depth only.
1757
+ }
1758
+
1625
1759
  if (args.indexOf('--json') !== -1) {
1626
1760
  output({ runtime: runtimeId, isolation, exec, harnessFlag }, raw);
1627
1761
  } else {
@@ -1629,6 +1763,110 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
1629
1763
  }
1630
1764
  }
1631
1765
 
1766
+ /**
1767
+ * Atomically persist the resolved dispatch-isolation decision to the
1768
+ * run-scoped sentinel both isolation guard hooks read
1769
+ * (hooks/gsd-agent-isolation-guard.js, hooks/gsd-cursor-subagent-start.js;
1770
+ * shared reader hooks/lib/isolation-sentinel.js). Extracted so
1771
+ * `routeDispatchIsolation` (the #3045 CORE REDESIGN primary write path)
1772
+ * and `routeRecordDispatchIsolation` (the explicit verb, kept for the
1773
+ * per-plan degrade call site and back-compat/tests) share exactly one
1774
+ * write implementation. Never throws — returns `{ recorded, path, error? }`.
1775
+ */
1776
+ function writeDispatchIsolationSentinel(cwd, { isolation, harnessFlag = null, phase = null, plan = null }) {
1777
+ const nodePath = require('path');
1778
+ const nodeFs = require('fs');
1779
+ const sentinelDir = nodePath.join(cwd, '.gsd');
1780
+ const sentinelPath = nodePath.join(sentinelDir, 'dispatch-isolation-sentinel.json');
1781
+ const payload = {
1782
+ isolation,
1783
+ harness_flag: harnessFlag || null,
1784
+ phase: phase || null,
1785
+ plan: plan || null,
1786
+ written_at: Date.now(),
1787
+ };
1788
+ try {
1789
+ nodeFs.mkdirSync(sentinelDir, { recursive: true });
1790
+ // Atomic write: unique temp file + rename, so a concurrent reader (a
1791
+ // guard hook firing mid-write) never observes a partially-written
1792
+ // sentinel. Unique per-process+time so concurrent orchestrator-worktree
1793
+ // invocations sharing the same sentinelDir never collide on the temp name.
1794
+ const tmpPath = `${sentinelPath}.tmp-${process.pid}-${Date.now()}`;
1795
+ nodeFs.writeFileSync(tmpPath, JSON.stringify(payload));
1796
+ nodeFs.renameSync(tmpPath, sentinelPath);
1797
+ return { recorded: true, path: '.gsd/dispatch-isolation-sentinel.json' };
1798
+ } catch (err) {
1799
+ return { recorded: false, path: '.gsd/dispatch-isolation-sentinel.json', error: err && err.message };
1800
+ }
1801
+ }
1802
+
1803
+ function routeRecordDispatchIsolation({ args, cwd, raw, error }) {
1804
+ // #3045: `routeDispatchIsolation` (the `dispatch-isolation` query) is now
1805
+ // the PRIMARY write path for the sentinel (CORE REDESIGN) — it records
1806
+ // as an unconditional side effect of resolving ISOLATION, which the
1807
+ // workflow must call to learn the value at all. This verb remains as an
1808
+ // explicit fallback for callers that resolve isolation through some
1809
+ // other means (or need to force a specific value, e.g. a caller with no
1810
+ // access to `--force-isolation` context) and for direct test coverage of
1811
+ // the write primitive. Both verbs share exactly one write implementation
1812
+ // (`writeDispatchIsolationSentinel`) so there is only one atomic-write
1813
+ // code path to reason about.
1814
+ //
1815
+ // Best-effort: a write failure here must never fail the workflow — the
1816
+ // guard hooks' own sentinel-absent path degrades to a conservative
1817
+ // registry+config check, so a missing sentinel is safe, just less precise.
1818
+ //
1819
+ // Output: { recorded: true|false, path, error? }
1820
+ const VALID_ISOLATION = new Set(['harness-worktree', 'orchestrator-worktree', 'none']);
1821
+ const isoIdx = args.indexOf('--isolation');
1822
+ const isolation = isoIdx !== -1 ? args[isoIdx + 1] : undefined;
1823
+ if (!isolation || !VALID_ISOLATION.has(isolation)) {
1824
+ error(
1825
+ 'Usage: record-dispatch-isolation --isolation <harness-worktree|orchestrator-worktree|none> ' +
1826
+ '[--harness-flag <flag>|--harness-flag=<flag>] [--phase <n>] [--plan <id>]',
1827
+ ERROR_REASON.USAGE,
1828
+ );
1829
+ return;
1830
+ }
1831
+ // #3045 MAJOR: the space-separated form rejects any value starting with
1832
+ // `--` (to avoid swallowing a missing value followed by another flag),
1833
+ // but that is exactly the shape of Cursor's real `harnessIsolationFlag`
1834
+ // — it declares the bare CLI flag `--worktree`
1835
+ // (gsd-core/bin/lib/capability-registry.cjs), which could therefore
1836
+ // never be persisted. (Windsurf declares NO `harnessIsolationFlag` at
1837
+ // all — its `hostIntegration.dispatch.isolation` is `none`; per
1838
+ // ADR-1239 it "genuinely cannot benefit" from worktree isolation
1839
+ // because it lacks named/concurrent subagent dispatch, so this is not a
1840
+ // gap to close for Windsurf.) The `--harness-flag=<value>` equals form
1841
+ // (mirrors the `--cwd=<path>` convention already used by this
1842
+ // dispatcher's top-level arg parsing above) carries the value
1843
+ // unambiguously and is never subject to that guard — any future runtime
1844
+ // whose registered flag happens to be bare-CLI-shaped benefits the same
1845
+ // way Cursor's does.
1846
+ let harnessFlag = null;
1847
+ const flagEqArg = args.find((a) => a.startsWith('--harness-flag='));
1848
+ if (flagEqArg) {
1849
+ const value = flagEqArg.slice('--harness-flag='.length);
1850
+ harnessFlag = value.length > 0 ? value : null;
1851
+ } else {
1852
+ const flagIdx = args.indexOf('--harness-flag');
1853
+ harnessFlag = flagIdx !== -1 && args[flagIdx + 1] && !args[flagIdx + 1].startsWith('--')
1854
+ ? args[flagIdx + 1]
1855
+ : null;
1856
+ }
1857
+ const phaseIdx = args.indexOf('--phase');
1858
+ const phase = phaseIdx !== -1 && args[phaseIdx + 1] && !args[phaseIdx + 1].startsWith('--')
1859
+ ? args[phaseIdx + 1]
1860
+ : null;
1861
+ const planIdx = args.indexOf('--plan');
1862
+ const plan = planIdx !== -1 && args[planIdx + 1] && !args[planIdx + 1].startsWith('--')
1863
+ ? args[planIdx + 1]
1864
+ : null;
1865
+
1866
+ const result = writeDispatchIsolationSentinel(cwd, { isolation, harnessFlag, phase, plan });
1867
+ output(result, raw);
1868
+ }
1869
+
1632
1870
  function routeResolveDispatchType({ args, cwd, raw, error }) {
1633
1871
  // #2508 Phase 4 Option A: resolve a requested GSD subagent name to the
1634
1872
  // type an Agent() call should use on the current runtime. On
@@ -2033,6 +2271,86 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
2033
2271
  teamsStatus.cmdTeamsStatus(cwd, { active: args.includes('--active') });
2034
2272
  }
2035
2273
 
2274
+ // #3023 follow-up (adversarial review finding): the shared hook bundle's
2275
+ // directory name is runtime-descriptor-driven (bin/install.js
2276
+ // `hostBehaviors.sharedHooksDirName`; default 'hooks', pi renames it to
2277
+ // 'gsd-hooks'). A hardcoded 'hooks' literal in GSD_PREFIX_MANAGED_DIRS left
2278
+ // this scan blind to a renamed bundle: `fs.existsSync(configDir/hooks)` is
2279
+ // false for a pi install, so the ENTIRE gsd-hooks/ tree — including any
2280
+ // user-added file inside it — was invisible to detect-custom-files and
2281
+ // therefore never backed up before the next clean-install wipe (silent
2282
+ // data loss).
2283
+ //
2284
+ // Resolution order, mirroring bin/install.js's own resolveSharedHooksDirName:
2285
+ // 1. Read the per-install runtime marker written by the installer at
2286
+ // <configDir>/gsd-core/.gsd-runtime (#2297).
2287
+ // 2. Look up that runtime's `hostBehaviors.sharedHooksDirName` in the
2288
+ // SHIPPED capability registry (./lib/capability-registry.cjs — a data
2289
+ // module in the same installed tree as this file). Deliberately NOT
2290
+ // `require('bin/install.js')`: that file is never shipped into an
2291
+ // installed tree (the #3024/#2071 bug class), so only the shipped data
2292
+ // module is read here.
2293
+ //
2294
+ // Asymmetric fallback: when the runtime or its descriptor cannot be
2295
+ // determined (an install predating the marker, an unreadable/corrupt
2296
+ // registry, or an unrecognized runtime id) this does NOT guess a single
2297
+ // name — it returns every known candidate name instead. Over-scanning is
2298
+ // safe here: a candidate directory that does not exist is silently skipped
2299
+ // by the caller's `fs.existsSync` guard, and a file already tracked in the
2300
+ // manifest is never reported as custom. Under-scanning is the actual bug
2301
+ // being fixed: it would make a user's file vanish on the next wipe without
2302
+ // ever being backed up.
2303
+ function resolveSharedHooksDirCandidates(configDir) {
2304
+ const DEFAULT_NAME = 'hooks';
2305
+ // A resolved name is joined onto configDir and read back — reject
2306
+ // anything that isn't a plain, separator-free segment so a corrupt
2307
+ // registry value can never walk the scan outside the config root.
2308
+ const isSafeSegment = (name) =>
2309
+ typeof name === 'string' &&
2310
+ name.trim() !== '' &&
2311
+ name.trim() === name &&
2312
+ name !== '.' &&
2313
+ name !== '..' &&
2314
+ !name.includes('/') &&
2315
+ !name.includes('\\');
2316
+
2317
+ let registry = null;
2318
+ try {
2319
+ registry = require('./lib/capability-registry.cjs');
2320
+ } catch {
2321
+ registry = null;
2322
+ }
2323
+
2324
+ const knownNames = new Set([DEFAULT_NAME]);
2325
+ if (registry && registry.runtimes && typeof registry.runtimes === 'object') {
2326
+ for (const desc of Object.values(registry.runtimes)) {
2327
+ const name = desc && desc.runtime && desc.runtime.hostBehaviors &&
2328
+ desc.runtime.hostBehaviors.sharedHooksDirName;
2329
+ if (isSafeSegment(name)) knownNames.add(name);
2330
+ }
2331
+ }
2332
+
2333
+ let runtimeId = null;
2334
+ try {
2335
+ const markerPath = path.join(configDir, 'gsd-core', '.gsd-runtime');
2336
+ const raw = fs.readFileSync(markerPath, 'utf8').trim();
2337
+ runtimeId = raw || null;
2338
+ } catch {
2339
+ runtimeId = null;
2340
+ }
2341
+
2342
+ if (runtimeId && registry && registry.runtimes && registry.runtimes[runtimeId]) {
2343
+ const desc = registry.runtimes[runtimeId];
2344
+ const name = desc && desc.runtime && desc.runtime.hostBehaviors &&
2345
+ desc.runtime.hostBehaviors.sharedHooksDirName;
2346
+ return [isSafeSegment(name) ? name : DEFAULT_NAME];
2347
+ }
2348
+
2349
+ // Runtime undeterminable: scan every known candidate (see asymmetric
2350
+ // fallback comment above).
2351
+ return Array.from(knownNames);
2352
+ }
2353
+
2036
2354
  async function routeDetectCustomFiles({ args, cwd, raw, error }) {
2037
2355
  const configDirIdx = args.indexOf('--config-dir');
2038
2356
  const configDir = configDirIdx !== -1 ? args[configDirIdx + 1] : null;
@@ -2073,7 +2391,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
2073
2391
  ];
2074
2392
  const GSD_PREFIX_MANAGED_DIRS = [
2075
2393
  'agents',
2076
- 'hooks',
2394
+ ...resolveSharedHooksDirCandidates(resolvedConfigDir),
2077
2395
  'skills',
2078
2396
  ];
2079
2397
 
@@ -2605,6 +2923,133 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
2605
2923
  }
2606
2924
  }
2607
2925
 
2926
+ // `gsd_run query context-predicates` — selector surface for the CONTEXT.md
2927
+ // predicate fact-store (ADR-1671, #2928 Phase 1 row S9). Parses the
2928
+ // repo-root CONTEXT.md LIVE via the compiled context-predicates.cjs on
2929
+ // every call — it never reads the committed docs/CONTEXT-INDEX.json (that
2930
+ // artifact is a CI drift-guard byproduct, not a query source, so it can
2931
+ // never go stale relative to the live predicates it answers about).
2932
+ //
2933
+ // Selectors: --class <CLASS>, --prefix <dotted.prefix>, --contains <text>.
2934
+ // At least one is required. When more than one is given they are ANDed
2935
+ // together — the same documented precedence selectPredicates() itself
2936
+ // implements (see context-predicates.cjs doc comment: "Select predicates
2937
+ // by one or more optional criteria (ANDed together)"); no selector is
2938
+ // silently dropped or overridden by another.
2939
+ //
2940
+ // Flag parsing mirrors routePromptBudget's Map-based flagMap: the three
2941
+ // known flags are recognized in both the space-separated `--flag value`
2942
+ // form and the inline-assignment `--flag=value` form (the latter is the
2943
+ // escape hatch for a flag-shaped selector value, e.g. `--contains=--dry-run`
2944
+ // — #2928 review finding C; the space-separated form has no such escape by
2945
+ // design, since a following `--...` token always reads as a missing value).
2946
+ // `--class=` (empty value) and `--class==A` (double-equals typo shape)
2947
+ // are rejected the same way under either form. On a duplicate flag the
2948
+ // FIRST occurrence wins (`Map.set` only fires when the key is absent),
2949
+ // which is deterministic across repeated invocations with identical argv.
2950
+ //
2951
+ // Prototype-pollution safety: selector values are only ever compared via
2952
+ // `===`/`.startsWith()`/`.includes()` against ordinary string fields — this
2953
+ // route never uses a user-supplied string as an object property key
2954
+ // (`obj[userValue] = ...`), so `--class __proto__` / `constructor` /
2955
+ // `prototype` are just non-matching ordinary strings, not property-access
2956
+ // vectors. `flagMap` itself is a `Map`, immune to prototype pollution by
2957
+ // construction.
2958
+ function routeContextPredicates({ args, cwd, raw, error }) {
2959
+ const { parsePredicates, selectPredicates } = require('./lib/context-predicates.cjs');
2960
+
2961
+ const KNOWN_FLAGS = new Set(['--class', '--prefix', '--contains']);
2962
+ const flagMap = new Map();
2963
+ for (let i = 1; i < args.length; i++) {
2964
+ const current = args[i];
2965
+ if (typeof current !== 'string' || !current.startsWith('--')) continue;
2966
+
2967
+ // Inline-assignment escape hatch (`--flag=value`, mirrors the `--config-dir=`/
2968
+ // `--runtime=` convention in routeUpdateContext elsewhere in this file). This is
2969
+ // the ONLY way to pass a flag-shaped selector value (e.g. searching CONTEXT.md
2970
+ // for the literal substring "--dry-run"): the space-separated form below always
2971
+ // treats a following `--...` token as a missing value, by design, so it has no
2972
+ // escape hatch on its own (#2928 review finding C).
2973
+ const eqFlag = [...KNOWN_FLAGS].find((f) => current.startsWith(`${f}=`));
2974
+ if (eqFlag) {
2975
+ const value = current.slice(eqFlag.length + 1);
2976
+ // Reject an empty value (`--class=`) and the `--class==A` double-equals typo
2977
+ // shape (a value starting with `=`) the same way the pre-existing malformed-
2978
+ // assignment behavior did — never silently accept "=A" as a literal value.
2979
+ if (value === '' || value.startsWith('=')) {
2980
+ error(`context-predicates: ${eqFlag} requires a non-empty value`, ERROR_REASON.USAGE);
2981
+ return;
2982
+ }
2983
+ if (!flagMap.has(eqFlag)) flagMap.set(eqFlag, value);
2984
+ continue;
2985
+ }
2986
+
2987
+ if (!KNOWN_FLAGS.has(current)) {
2988
+ error(`Unknown flag for context-predicates: ${current}`, ERROR_REASON.USAGE);
2989
+ return;
2990
+ }
2991
+ const next = args[i + 1];
2992
+ if (next === undefined || next.startsWith('--')) {
2993
+ if (!flagMap.has(current)) flagMap.set(current, null);
2994
+ continue;
2995
+ }
2996
+ if (!flagMap.has(current)) flagMap.set(current, next);
2997
+ i++;
2998
+ }
2999
+
3000
+ const hasClass = flagMap.has('--class');
3001
+ const hasPrefix = flagMap.has('--prefix');
3002
+ const hasContains = flagMap.has('--contains');
3003
+
3004
+ if (!hasClass && !hasPrefix && !hasContains) {
3005
+ error(
3006
+ 'Usage: gsd-tools query context-predicates --class <CLASS> | --prefix <dotted.prefix> | --contains <text> ' +
3007
+ '(selectors are ANDed when combined)',
3008
+ ERROR_REASON.USAGE,
3009
+ );
3010
+ return;
3011
+ }
3012
+
3013
+ const requireNonEmpty = (flagName, rawValue) => {
3014
+ if (rawValue === null || rawValue === undefined || rawValue.trim() === '') {
3015
+ error(`context-predicates: ${flagName} requires a non-empty value`, ERROR_REASON.USAGE);
3016
+ return null;
3017
+ }
3018
+ return rawValue;
3019
+ };
3020
+
3021
+ const opts = {};
3022
+ if (hasClass) {
3023
+ const v = requireNonEmpty('--class', flagMap.get('--class'));
3024
+ if (v === null) return;
3025
+ opts.klass = v;
3026
+ }
3027
+ if (hasPrefix) {
3028
+ const v = requireNonEmpty('--prefix', flagMap.get('--prefix'));
3029
+ if (v === null) return;
3030
+ opts.prefix = v;
3031
+ }
3032
+ if (hasContains) {
3033
+ const v = requireNonEmpty('--contains', flagMap.get('--contains'));
3034
+ if (v === null) return;
3035
+ opts.contains = v;
3036
+ }
3037
+
3038
+ const contextMdPath = path.join(__dirname, '..', '..', 'CONTEXT.md');
3039
+ let markdown;
3040
+ try {
3041
+ markdown = fs.readFileSync(contextMdPath, 'utf8');
3042
+ } catch (err) {
3043
+ error(`context-predicates: cannot read ${contextMdPath}: ${err && err.message}`, ERROR_REASON.USAGE);
3044
+ return;
3045
+ }
3046
+
3047
+ const { predicates } = parsePredicates(markdown);
3048
+ const matches = selectPredicates(predicates, opts);
3049
+
3050
+ output({ matched: matches.length, predicates: matches }, raw);
3051
+ }
3052
+
2608
3053
  function routeUpdateContext({ args, cwd, raw, error }) {
2609
3054
  // #498: resolve the installed GSD version, scope, runtime, and config dir
2610
3055
  // for /gsd:update. Replaces ~280 lines of inline bash in update.md with a
@@ -2912,6 +3357,7 @@ const HOST_COMMAND_ROUTERS = {
2912
3357
  'normalize-test-command': routeNormalizeTestCommand,
2913
3358
  'dispatch-should-flatten': routeDispatchShouldFlatten,
2914
3359
  'dispatch-isolation': routeDispatchIsolation,
3360
+ 'record-dispatch-isolation': routeRecordDispatchIsolation,
2915
3361
  'resolve-dispatch-type': routeResolveDispatchType,
2916
3362
  'agent-skills': routeAgentSkills,
2917
3363
  'skill-manifest': routeSkillManifest,
@@ -2940,6 +3386,7 @@ const HOST_COMMAND_ROUTERS = {
2940
3386
  'restore-custom-files': routeRestoreCustomFiles,
2941
3387
  'from-gsd2': routeFromGsd2,
2942
3388
  'prompt-budget': routePromptBudget,
3389
+ 'context-predicates': routeContextPredicates,
2943
3390
  'review-lane': routeReviewLane,
2944
3391
  'update-context': routeUpdateContext,
2945
3392
  'classify-confidence': routeClassifyConfidence,
@@ -2948,6 +3395,7 @@ const HOST_COMMAND_ROUTERS = {
2948
3395
  'user-story': routeUserStory,
2949
3396
  'drift-guard': routeDriftGuard,
2950
3397
  'windows': routeWindows,
3398
+ 'skills-root': routeSkillsRoot,
2951
3399
  };
2952
3400
 
2953
3401
  // Returns true when consumed (suppress "Unknown command"), false to fall
@@ -3033,6 +3481,29 @@ function runWithTimeout(argv) {
3033
3481
  const detached = !isWin && secs > 0;
3034
3482
  const spawnFailureCode = (err) =>
3035
3483
  (err && err.code === 'ENOENT' ? 127 : err && err.code === 'EACCES' ? 126 : 125);
3484
+ // #2667: on Windows, a `.cmd`/`.bat`/`.exe` command cannot be spawned directly
3485
+ // — Node's CVE-2024-27980 hardening (April 2024, all active lines incl. 22.x)
3486
+ // throws EINVAL when child_process.spawn is given a `.cmd`/`.bat` without a
3487
+ // shell, so e.g. `run-with-timeout 120 -- node_modules/.bin/fallow.cmd` silently
3488
+ // produced empty stdout + exit 125 and the fallow pre-pass no-op'd.
3489
+ //
3490
+ // We do NOT use `shell: true` for this: with `shell:true`, Node space-joins the
3491
+ // unescaped cmdArgs into a cmd.exe command string (DEP0190) — that would re-open
3492
+ // a shell-injection surface and violate the recorded no-shell-for-argv-array
3493
+ // contract (DEFECT.UNBOUNDED-SUBPROCESS, CONTEXT.md:772). Instead we spawn
3494
+ // `cmd.exe /c <cmd> <args>` with an explicit argv ARRAY, which is what Node's
3495
+ // own exec does internally and keeps every arg a discrete, un-interpolated
3496
+ // token. The gate is NARROW: it fires ONLY for the Windows shim extensions,
3497
+ // never for the `bash -c` callers (command is `bash`, no such suffix), so the 7
3498
+ // bash callers keep their array-only argv on every platform. POSIX untouched.
3499
+ // NOTE: .exe is INTENTIONALLY excluded — real PE executables (node.exe, etc.)
3500
+ // spawn fine directly and mediating them through cmd.exe /c breaks the timeout
3501
+ // cap's process-group kill (the wrapped child escapes reap → exit 124 never
3502
+ // fires) and risks cmd.exe mis-parsing an arg like `-e "setTimeout(()=>{})"`.
3503
+ // Only .cmd/.bat are the CVE-2024-27980 EINVAL cases that require mediation.
3504
+ const winShim = isWin && /\.(cmd|bat)$/i.test(path.basename(cmd));
3505
+ const spawnCmd = winShim ? (process.env.ComSpec || 'cmd.exe') : cmd;
3506
+ const spawnArgs = winShim ? ['/d', '/s', '/c', cmd, ...cmdArgs] : cmdArgs;
3036
3507
  // Node's setTimeout delay is a 32-bit signed ms int; a larger value silently
3037
3508
  // clamps to 1ms → a spurious immediate timeout. Cap the budget (~24.8 days).
3038
3509
  const timerMs = Math.min(Math.round(secs * 1000), 2 ** 31 - 1);
@@ -3043,7 +3514,11 @@ function runWithTimeout(argv) {
3043
3514
  return new Promise((resolve) => {
3044
3515
  let child;
3045
3516
  try {
3046
- child = spawn(cmd, cmdArgs, { stdio: 'inherit', detached });
3517
+ // #2667: on win32 `.cmd`/`.bat`/`.exe`, spawn cmd.exe with an explicit argv
3518
+ // array (spawnCmd/spawnArgs) rather than the shim directly — preserves the
3519
+ // array-only, no-shell-string argv contract. `detached` is always false on
3520
+ // win32, so it never co-occurs with the cmd.exe mediation.
3521
+ child = spawn(spawnCmd, spawnArgs, { stdio: 'inherit', detached });
3047
3522
  } catch (err) {
3048
3523
  process.stderr.write(`run-with-timeout: ${cmd}: ${err && err.message ? err.message : 'failed to start'}\n`);
3049
3524
  resolve(spawnFailureCode(err));
@@ -3116,6 +3591,121 @@ function runWithTimeout(argv) {
3116
3591
 
3117
3592
  // ─── CLI Router ───────────────────────────────────────────────────────────────
3118
3593
 
3594
+ // Top-level usage string — emitted by `gsd-tools` (no args) and by
3595
+ // `gsd-tools --help` / any `--help` request below.
3596
+ // CR feedback: the command list must enumerate every top-level command
3597
+ // supported by the dispatcher so `--help` is actually useful for
3598
+ // discovery; previously it was a partial subset that didn't include
3599
+ // phase / roadmap / milestone / progress / etc.
3600
+ //
3601
+ // Module-scoped (not function-local) so it can be exported and compared
3602
+ // against HOST_COMMAND_ROUTERS in a parity test (DEFECT.GENERATIVE-FIX) —
3603
+ // this string and HOST_COMMAND_ROUTERS/SKIP_ROOT_RESOLUTION are three
3604
+ // independently hand-maintained sites and nothing previously caught them
3605
+ // drifting apart when a query command was added to only one or two.
3606
+ const TOP_LEVEL_USAGE = 'Usage: gsd-tools <command> [args] [--raw] [--pick <field>] [--cwd <path>] [--ws <name>] [--json-errors]\n' +
3607
+ 'Commands: agent, agent-skills, assumption-delta, audit-open, audit-uat, check, check-commit, commit, commit-to-subrepo, pr-subrepo, ' +
3608
+ 'config-ensure-section, config-get, config-new-project, config-path, config-set, migrate-config, normalize-test-command, ' +
3609
+ 'context-predicates, current-timestamp, detect-custom-files, docs-init, drift-guard, effort, extract-messages, find-phase, ' +
3610
+ 'from-gsd2, frontmatter, gap-analysis, generate-claude-md, generate-claude-profile, ' +
3611
+ 'generate-dev-preferences, generate-slug, graphify, history-digest, init, intel, ' +
3612
+ 'capability, classify-confidence, git, learnings, list-seeds, list-todos, loop, milestone, package-legitimacy, phase, phase-plan-index, phases, profile-questionnaire, ' +
3613
+ 'profile-sample, progress, project-instruction-file, prompt-budget, quick-tasks-append, requirements, research-plan, research-store, resolve-granularity, resolve-model, restore-custom-files, roadmap, scaffold, smart-entry, state, ' +
3614
+ 'config-set-model-profile, dispatch-isolation, dispatch-should-flatten, record-dispatch-isolation, estimate-calibrate, estimate-calibration, estimate-check, resolve-dispatch-type, ' +
3615
+ 'resolve-execution, review-lane, skill-manifest, skills-root, state-snapshot, stats, summary-extract, teams-status, todo, uat, update-context, verification, websearch, windows, ' +
3616
+ 'task, template, user-story, validate, verify, verify-path-exists, verify-summary, eval, workstream, worktree\n\n' +
3617
+ 'Global flags:\n' +
3618
+ ' --raw Emit raw output without post-processing\n' +
3619
+ ' --pick <field> Extract a single field from JSON output (dot/bracket notation)\n' +
3620
+ ' --cwd <path> Override working directory for project-root resolution\n' +
3621
+ ' --ws <name> Override active workstream (or set GSD_WORKSTREAM)\n' +
3622
+ ' --json-errors Emit structured JSON error objects on stderr (or set GSD_JSON_ERRORS=1)\n\n' +
3623
+ 'For command-specific argument requirements, invoke the command without args ' +
3624
+ '(e.g. `gsd-tools phase add`) — the resulting error lists what is required.';
3625
+
3626
+ // Multi-repo guard: resolve project root for commands that read/write .planning/.
3627
+ // Skip for pure-utility commands that don't touch .planning/ to avoid unnecessary
3628
+ // filesystem traversal on every invocation.
3629
+ // 'loop' and 'capability' are intentionally NOT in SKIP_ROOT_RESOLUTION.
3630
+ // Both are registry/config queries that resolve activation via
3631
+ // .planning/config.json; they need the project root (cwd) for correct
3632
+ // `when` key resolution. If one is ever moved to SKIP_ROOT_RESOLUTION,
3633
+ // move the other at the same time (keep them consistent).
3634
+ //
3635
+ // Module-scoped for the same reason as TOP_LEVEL_USAGE above — kept
3636
+ // module-private and exposed to the dispatch-table/help-string/skip-list
3637
+ // parity test only through the read-only skipsRootResolution() predicate
3638
+ // below (never as the live Set itself; see that function's doc comment).
3639
+ const SKIP_ROOT_RESOLUTION = new Set([
3640
+ 'generate-slug', 'current-timestamp', 'verify-path-exists',
3641
+ // #2844: verify-summary was previously skipped, leaving relative file-claim
3642
+ // paths resolved against the raw process.cwd() — invoking from a subdirectory
3643
+ // manufactured "missing files" on an otherwise-correct SUMMARY. It now goes
3644
+ // through findProjectRoot so claims resolve against the project root.
3645
+ 'template', 'frontmatter', 'detect-custom-files',
3646
+ // #1854: restore-custom-files operates on a runtime config dir passed
3647
+ // explicitly via --config-dir; it never reads .planning/.
3648
+ 'restore-custom-files',
3649
+ 'worktree', 'prompt-budget',
3650
+ // context-predicates is a pure repo-root CONTEXT.md read (like
3651
+ // prompt-budget); it never touches .planning/, so it needs no project
3652
+ // root resolution and must work from any cwd (including one with no
3653
+ // .planning/ directory at all).
3654
+ 'context-predicates',
3655
+ 'research-store', 'research-plan', 'package-legitimacy', 'classify-confidence',
3656
+ 'user-story', // pure string validation — no .planning/ access needed
3657
+ // #1529: pure runtime→filename projection via getProjectInstructionFile; no
3658
+ // .planning/ access needed, and resolving project root would break workflow
3659
+ // invocations that run before .planning/ exists (new-project Step 1).
3660
+ 'project-instruction-file',
3661
+ // #1579: eval.score is pure arithmetic (covered/total + infra weights); it
3662
+ // needs no .planning/ access, so skip the findProjectRoot traversal.
3663
+ 'eval',
3664
+ ]);
3665
+
3666
+ // Read-only accessor for SKIP_ROOT_RESOLUTION (DEFECT.MUTABLE-EXPORTED-SET,
3667
+ // #2928 review). The Set above stays module-private and mutable internally
3668
+ // (main() only ever calls .has() on it), but exporting the live Set directly
3669
+ // would let any importer call .add()/.delete() on it — Object.freeze() does
3670
+ // not lock Set.prototype.add/delete, so freezing the instance would not have
3671
+ // closed this — and silently change dispatch behavior for every caller in the
3672
+ // process. Export this predicate instead; it exposes membership without
3673
+ // exposing a mutation surface.
3674
+ function skipsRootResolution(command) {
3675
+ return SKIP_ROOT_RESOLUTION.has(command);
3676
+ }
3677
+
3678
+ /**
3679
+ * Resolve the worktree root for a given cwd, warning to stderr when git
3680
+ * could not determine it (reason 'git_timed_out') rather than silently
3681
+ * trusting a best-effort fallback (#3050). Extracted from main() so it can
3682
+ * be driven directly in tests via injected deps.
3683
+ *
3684
+ * @param {string} cwd
3685
+ * @param {{ existsSync?: (p: string) => boolean, resolveWorktreeRoot?: (cwd: string) => { root: string, reason: string }, writeWarning?: (msg: string) => void }} [deps]
3686
+ * @returns {string} resolved cwd
3687
+ */
3688
+ function resolveMainWorktreeCwd(cwd, deps = {}) {
3689
+ const existsSync = deps.existsSync || fs.existsSync;
3690
+ const resolveWorktreeRoot = deps.resolveWorktreeRoot || require('./lib/worktree-safety.cjs').resolveWorktreeRoot;
3691
+ const writeWarning = deps.writeWarning || ((msg) => process.stderr.write(msg));
3692
+
3693
+ if (existsSync(path.join(cwd, '.planning'))) {
3694
+ return cwd;
3695
+ }
3696
+ const { root: worktreeRoot, reason: worktreeRootReason } = resolveWorktreeRoot(cwd);
3697
+ if (worktreeRootReason === 'git_timed_out') {
3698
+ writeWarning(
3699
+ 'WARNING: could not determine the git worktree root (git timed out). ' +
3700
+ 'Planning artifacts (STATE.md, ROADMAP.md, etc.) may be written to the ' +
3701
+ `wrong tree — proceeding with "${worktreeRoot}" as a best-effort fallback. ` +
3702
+ 'Retry the command; if this persists, check for a stalled filesystem mount ' +
3703
+ 'or a stale git index lock (.git/index.lock) in this worktree.\n'
3704
+ );
3705
+ }
3706
+ return worktreeRoot;
3707
+ }
3708
+
3119
3709
  async function main() {
3120
3710
  let args = process.argv.slice(2);
3121
3711
 
@@ -3173,13 +3763,7 @@ async function main() {
3173
3763
  // Resolve worktree root: in a linked worktree, .planning/ lives in the main worktree.
3174
3764
  // However, in monorepo worktrees where the subdirectory itself owns .planning/,
3175
3765
  // skip worktree resolution — the CWD is already the correct project root.
3176
- const { resolveWorktreeRoot } = require('./lib/worktree-safety.cjs');
3177
- if (!fs.existsSync(path.join(cwd, '.planning'))) {
3178
- const worktreeRoot = resolveWorktreeRoot(cwd);
3179
- if (worktreeRoot !== cwd) {
3180
- cwd = worktreeRoot;
3181
- }
3182
- }
3766
+ cwd = resolveMainWorktreeCwd(cwd);
3183
3767
 
3184
3768
  // Optional workstream override for parallel milestone work.
3185
3769
  // Priority: --ws flag > GSD_WORKSTREAM env var > session/shared pointer > null.
@@ -3249,30 +3833,6 @@ async function main() {
3249
3833
  }
3250
3834
  }
3251
3835
 
3252
- // Top-level usage string — emitted by `gsd-tools` (no args) and by
3253
- // `gsd-tools --help` / any `--help` request below.
3254
- // CR feedback: the command list must enumerate every top-level command
3255
- // supported by the dispatcher so `--help` is actually useful for
3256
- // discovery; previously it was a partial subset that didn't include
3257
- // phase / roadmap / milestone / progress / etc.
3258
- const TOP_LEVEL_USAGE = 'Usage: gsd-tools <command> [args] [--raw] [--pick <field>] [--cwd <path>] [--ws <name>] [--json-errors]\n' +
3259
- 'Commands: agent, agent-skills, assumption-delta, audit-open, audit-uat, check, check-commit, commit, commit-to-subrepo, pr-subrepo, ' +
3260
- 'config-ensure-section, config-get, config-new-project, config-path, config-set, migrate-config, normalize-test-command, ' +
3261
- 'current-timestamp, detect-custom-files, docs-init, drift-guard, effort, extract-messages, find-phase, ' +
3262
- 'from-gsd2, frontmatter, gap-analysis, generate-claude-md, generate-claude-profile, ' +
3263
- 'generate-dev-preferences, generate-slug, graphify, history-digest, init, intel, ' +
3264
- 'capability, classify-confidence, git, learnings, list-seeds, list-todos, loop, milestone, package-legitimacy, phase, phase-plan-index, phases, profile-questionnaire, ' +
3265
- 'profile-sample, progress, project-instruction-file, prompt-budget, quick-tasks-append, requirements, research-plan, research-store, resolve-granularity, resolve-model, restore-custom-files, roadmap, scaffold, smart-entry, state, ' +
3266
- 'task, template, user-story, validate, verify, verify-path-exists, verify-summary, eval, workstream, worktree\n\n' +
3267
- 'Global flags:\n' +
3268
- ' --raw Emit raw output without post-processing\n' +
3269
- ' --pick <field> Extract a single field from JSON output (dot/bracket notation)\n' +
3270
- ' --cwd <path> Override working directory for project-root resolution\n' +
3271
- ' --ws <name> Override active workstream (or set GSD_WORKSTREAM)\n' +
3272
- ' --json-errors Emit structured JSON error objects on stderr (or set GSD_JSON_ERRORS=1)\n\n' +
3273
- 'For command-specific argument requirements, invoke the command without args ' +
3274
- '(e.g. `gsd-tools phase add`) — the resulting error lists what is required.';
3275
-
3276
3836
  if (!command) {
3277
3837
  error(TOP_LEVEL_USAGE);
3278
3838
  }
@@ -3299,31 +3859,6 @@ async function main() {
3299
3859
  }
3300
3860
  }
3301
3861
 
3302
- // Multi-repo guard: resolve project root for commands that read/write .planning/.
3303
- // Skip for pure-utility commands that don't touch .planning/ to avoid unnecessary
3304
- // filesystem traversal on every invocation.
3305
- // 'loop' and 'capability' are intentionally NOT in SKIP_ROOT_RESOLUTION.
3306
- // Both are registry/config queries that resolve activation via
3307
- // .planning/config.json; they need the project root (cwd) for correct
3308
- // `when` key resolution. If one is ever moved to SKIP_ROOT_RESOLUTION,
3309
- // move the other at the same time (keep them consistent).
3310
- const SKIP_ROOT_RESOLUTION = new Set([
3311
- 'generate-slug', 'current-timestamp', 'verify-path-exists',
3312
- 'verify-summary', 'template', 'frontmatter', 'detect-custom-files',
3313
- // #1854: restore-custom-files operates on a runtime config dir passed
3314
- // explicitly via --config-dir; it never reads .planning/.
3315
- 'restore-custom-files',
3316
- 'worktree', 'prompt-budget',
3317
- 'research-store', 'research-plan', 'package-legitimacy', 'classify-confidence',
3318
- 'user-story', // pure string validation — no .planning/ access needed
3319
- // #1529: pure runtime→filename projection via getProjectInstructionFile; no
3320
- // .planning/ access needed, and resolving project root would break workflow
3321
- // invocations that run before .planning/ exists (new-project Step 1).
3322
- 'project-instruction-file',
3323
- // #1579: eval.score is pure arithmetic (covered/total + infra weights); it
3324
- // needs no .planning/ access, so skip the findProjectRoot traversal.
3325
- 'eval',
3326
- ]);
3327
3862
  if (!SKIP_ROOT_RESOLUTION.has(command)) {
3328
3863
  cwd = findProjectRoot(cwd);
3329
3864
  }
@@ -3480,5 +4015,14 @@ if (require.main === module) {
3480
4015
  // synthetic registry + requireModule injections.
3481
4016
  // ADR-1244 Phase 5: export dispatchOverlayCapabilityCommand + defaultRequireFromInstallRoot for
3482
4017
  // the third-party overlay dispatch + install-root confinement tests.
3483
- module.exports = { dispatchCapabilityCommand, dispatchOverlayCapabilityCommand, defaultRequireFromInstallRoot, dispatchHostCommand, HOST_COMMAND_ROUTERS };
4018
+ module.exports = {
4019
+ dispatchCapabilityCommand,
4020
+ dispatchOverlayCapabilityCommand,
4021
+ defaultRequireFromInstallRoot,
4022
+ dispatchHostCommand,
4023
+ HOST_COMMAND_ROUTERS,
4024
+ TOP_LEVEL_USAGE,
4025
+ skipsRootResolution,
4026
+ resolveMainWorktreeCwd,
4027
+ };
3484
4028