@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
@@ -219,6 +219,11 @@ function _isNonTerminal(status) {
219
219
  * - Same `job_id` for the same `plan_id` -> allowed (status progression).
220
220
  * - A prior job for the same `plan_id` that is already terminal -> allowed
221
221
  * (the duplicate guard only protects against re-dispatching live work).
222
+ * - If a SIBLING manifest (not the target) cannot be read or parsed, the
223
+ * duplicate scan cannot be completed and may be hiding a live duplicate
224
+ * -> refuse (`scan_incomplete`), naming the offending file's path so an
225
+ * operator can quarantine or repair it. Never silently skip a sibling the
226
+ * scan could not inspect.
222
227
  */
223
228
  function writeManifest(manifest, planningDir, opts = {}) {
224
229
  const fs = opts.fs ?? node_fs_1.default;
@@ -240,18 +245,28 @@ function writeManifest(manifest, planningDir, opts = {}) {
240
245
  try {
241
246
  raw = String(fs.readFileSync(p));
242
247
  }
243
- catch {
244
- continue;
248
+ catch (e) {
249
+ return {
250
+ ok: false,
251
+ kind: 'scan_incomplete',
252
+ message: `duplicate scan incomplete: failed to READ sibling manifest ${p} (${e.message}); quarantine or repair this file before retrying`,
253
+ offendingPath: p,
254
+ };
245
255
  }
246
256
  let existing;
247
257
  try {
248
258
  existing = JSON.parse(raw);
249
259
  }
250
- catch {
260
+ catch (e) {
251
261
  if (p === target) {
252
262
  return { ok: false, kind: 'malformed_existing', message: `target manifest ${p} is not valid JSON` };
253
263
  }
254
- continue;
264
+ return {
265
+ ok: false,
266
+ kind: 'scan_incomplete',
267
+ message: `duplicate scan incomplete: failed to PARSE sibling manifest ${p} (${e.message}); quarantine or repair this file before retrying`,
268
+ offendingPath: p,
269
+ };
255
270
  }
256
271
  const samePlan = existing.plan_id === manifest.plan_id;
257
272
  const sameJob = existing.job_id === manifest.job_id;
@@ -195,6 +195,18 @@ function parseYamlRegion(yaml) {
195
195
  * hold only an in-memory string; those dedup on a content digest instead.
196
196
  */
197
197
  function extractFrontmatter(content, sourcePath) {
198
+ // #2977: tolerate a single leading UTF-8 BOM (\uFEFF), which Windows tooling
199
+ // (PowerShell `>`/`Out-File` on PS 5.1, several editors) writes by default. Without this
200
+ // strip, the byte-0 `startsWith('---')` fence check below fails on the BOM and the whole
201
+ // parse collapses to {} — every frontmatter field silently disappears, and the engine
202
+ // proceeds as though the file had no frontmatter at all. The BOM is a single codepoint;
203
+ // stripping it here restores byte-0 alignment so the rest of the function is unchanged.
204
+ // Scope: BOM only. Arbitrary non-BOM content before the fence (leading whitespace/blank
205
+ // line/comment) is a separate product-intent decision (tolerate vs diagnose) left to a
206
+ // future change — this fix does not broaden the byte-0 fence rule beyond the BOM.
207
+ if (content.charCodeAt(0) === 0xFEFF) {
208
+ content = content.slice(1);
209
+ }
198
210
  // Match frontmatter only at byte 0 — a `---` block later in the document
199
211
  // body (YAML examples, horizontal rules) must never be treated as frontmatter.
200
212
  const headerEnd = content.startsWith('---\r\n') ? 5 : content.startsWith('---\n') ? 4 : -1;
@@ -607,29 +619,64 @@ function parseMustHavesBlock(content, blockName) {
607
619
  return items;
608
620
  }
609
621
  // ─── Frontmatter CRUD commands ────────────────────────────────────────────────
622
+ // Shared base for 'plan' and 'plan-gap-closure' below — a plain array reference (not
623
+ // FRONTMATTER_SCHEMAS.plan.required) because the object literal that defines
624
+ // FRONTMATTER_SCHEMAS cannot refer to itself mid-initialization (TDZ).
625
+ const PLAN_REQUIRED_FIELDS = ['phase', 'plan', 'type', 'wave', 'depends_on', 'files_modified', 'autonomous', 'must_haves'];
626
+ // `requiredValues` is optional per schema: when a field name is a key here, the
627
+ // field must be PRESENT AND strictly equal (===) to the given value to satisfy
628
+ // the schema — presence alone is not enough. Every other required field (no
629
+ // entry in requiredValues) keeps the original presence-only contract.
610
630
  const FRONTMATTER_SCHEMAS = {
611
- plan: { required: ['phase', 'plan', 'type', 'wave', 'depends_on', 'files_modified', 'autonomous', 'must_haves'] },
631
+ plan: { required: PLAN_REQUIRED_FIELDS },
632
+ // #2847: gap-closure plans carry every 'plan' field PLUS gap_closure — the flag
633
+ // execute-phase --gaps-only filters on. A separate schema (not a change to
634
+ // 'plan') so standard/reviews-mode plans stay unaffected: they validate against
635
+ // 'plan' and are never required to declare or be checked for gap_closure.
636
+ // Derived from PLAN_REQUIRED_FIELDS (never hand-duplicated) so the two can't drift.
637
+ //
638
+ // requiredValues.gap_closure = true (not just presence): --gaps-only filters
639
+ // strictly on gap_closure === true (execute-phase.md, partial-wave.md), so a
640
+ // plan carrying `gap_closure: false` would pass a presence-only check and
641
+ // still be silently skipped at execute time — the exact symptom #2847
642
+ // reports, one value away. Presence-only was flagged in review as a live
643
+ // reproduction of the bug this schema exists to close.
644
+ 'plan-gap-closure': {
645
+ required: [...PLAN_REQUIRED_FIELDS, 'gap_closure'],
646
+ // extractFrontmatter parses every scalar as a string (FrontmatterValue has
647
+ // no boolean member — `gap_closure: true` in YAML becomes the JS string
648
+ // "true", not the boolean true), so the required value is the string here.
649
+ requiredValues: { gap_closure: 'true' },
650
+ },
612
651
  summary: { required: ['phase', 'plan', 'subsystem', 'tags', 'duration', 'completed'] },
613
652
  verification: { required: ['phase', 'verified', 'status', 'score'] },
614
653
  };
615
654
  /**
616
- * Strip ALL frontmatter blocks from the start of `content`.
655
+ * Strip frontmatter blocks from the start of `content`.
656
+ *
657
+ * Handles CRLF line endings and, by default, multiple stacked blocks
658
+ * (corruption recovery): greedily strips consecutive `---...---` blocks
659
+ * separated by optional whitespace, so a doubled/tripled frontmatter header
660
+ * (e.g. from a botched merge) is fully removed, not just the first block.
617
661
  *
618
- * Handles CRLF line endings and multiple stacked blocks (corruption
619
- * recovery): greedily strips consecutive `---...---` blocks separated by
620
- * optional whitespace, so a doubled/tripled frontmatter header (e.g. from a
621
- * botched merge) is fully removed, not just the first block.
662
+ * Pass `{ once: true }` to stop after the first block. Callers whose input is
663
+ * an arbitrary user-authored document — rather than a GSD artefact with a
664
+ * known doubling failure mode — need this: a body that opens with a
665
+ * thematic-break-delimited section is lexically indistinguishable from a
666
+ * second frontmatter block, and the greedy loop deletes it silently (#2703).
622
667
  *
623
668
  * Canonical home for this primitive (#2143 audit dedup): previously
624
669
  * duplicated byte-identically in both `state.cts` and `state-transition.cts`.
625
670
  */
626
- function stripFrontmatter(content) {
671
+ function stripFrontmatter(content, opts = {}) {
627
672
  let result = content;
628
673
  while (true) {
629
674
  const stripped = result.replace(/^\s*---\r?\n[\s\S]*?\r?\n---\s*/, '');
630
675
  if (stripped === result)
631
676
  break;
632
677
  result = stripped;
678
+ if (opts.once)
679
+ break;
633
680
  }
634
681
  return result;
635
682
  }
@@ -750,10 +797,19 @@ function cmdFrontmatterValidate(cwd, filePath, schemaName, raw) {
750
797
  if (filePath.includes('\0')) {
751
798
  error('file path contains null bytes');
752
799
  }
753
- const schema = FRONTMATTER_SCHEMAS[schemaName];
754
- if (!schema) {
800
+ // Guard against prototype-chain keys (__proto__, constructor, toString, hasOwnProperty,
801
+ // valueOf, ...): a bare FRONTMATTER_SCHEMAS[schemaName] lookup resolves those to
802
+ // Object.prototype members instead of undefined, so a `!schema` check on the raw
803
+ // lookup never fires and the code crashes later on `schema.required.filter` with an
804
+ // uncaught TypeError instead of the intended "Unknown schema" message. Confirmed live
805
+ // with --schema __proto__. Now that --schema is agent-bound (agents/gsd-planner.md's
806
+ // $SCHEMA), this is reachable from prompt state, not just an unreachable literal.
807
+ // Checked and rejected BEFORE the lookup (rather than `?? undefined`-ing the lookup
808
+ // itself) so `schema`'s inferred type stays non-optional and needs no assertion below.
809
+ if (!Object.prototype.hasOwnProperty.call(FRONTMATTER_SCHEMAS, schemaName)) {
755
810
  error(`Unknown schema: ${schemaName}. Available: ${Object.keys(FRONTMATTER_SCHEMAS).join(', ')}`);
756
811
  }
812
+ const schema = FRONTMATTER_SCHEMAS[schemaName];
757
813
  const fullPath = node_path_1.default.isAbsolute(filePath) ? filePath : node_path_1.default.join(cwd, filePath);
758
814
  const content = (0, shell_command_projection_cjs_1.platformReadSync)(fullPath);
759
815
  if (!content) {
@@ -771,9 +827,25 @@ function cmdFrontmatterValidate(cwd, filePath, schemaName, raw) {
771
827
  // Pass the resolved path so a truncated file is named in the diagnostic and deduplicated
772
828
  // per file rather than per content digest (#1882, ADR-1411 wiring clause).
773
829
  const fm = extractFrontmatter(content, fullPath);
774
- const missing = schema.required.filter(f => fm[f] === undefined);
775
- const present = schema.required.filter(f => fm[f] !== undefined);
776
- output({ valid: missing.length === 0, missing, present, schema: schemaName }, raw, missing.length === 0 ? 'valid' : 'invalid');
830
+ const requiredValues = schema.requiredValues || {};
831
+ // A field satisfies the schema when it is present AND — for fields with a
832
+ // requiredValues entry — strictly equal to that value. Absent and
833
+ // wrong-value both surface as `missing` (existing `missing`/`present`
834
+ // partition of `required` is unchanged — no consumer reads `present` to
835
+ // mean "physically exists regardless of value," confirmed by searching
836
+ // every caller before choosing this). But #2847 review: folding silently
837
+ // made "missing" misleading for a WRONG-valued field the plan author can
838
+ // plainly see in the file (e.g. `gap_closure: True`) — nothing told them
839
+ // the field is present but the VALUE is wrong, so they could loop trying
840
+ // to add a field that is already there. `invalidValue` names exactly that
841
+ // subset (present, but not the required value) so the message stays
842
+ // actionable without changing what `missing`/`present` mean.
843
+ const wrongValue = (f) => fm[f] !== undefined && Object.prototype.hasOwnProperty.call(requiredValues, f) && fm[f] !== requiredValues[f];
844
+ const satisfies = (f) => fm[f] !== undefined && !wrongValue(f);
845
+ const missing = schema.required.filter(f => !satisfies(f));
846
+ const present = schema.required.filter(f => satisfies(f));
847
+ const invalidValue = schema.required.filter(wrongValue);
848
+ output({ valid: missing.length === 0, missing, present, invalidValue, schema: schemaName }, raw, missing.length === 0 ? 'valid' : 'invalid');
777
849
  }
778
850
  module.exports = {
779
851
  extractFrontmatter,
@@ -18,11 +18,10 @@
18
18
  * non-zero check-command exit, which the workflow's two-step gate contract treats
19
19
  * as a step-1 command failure (routed per the gate's `onError`).
20
20
  *
21
- * Built-in kind (v1): `command-exit-zero` — run a declared command in a bounded
22
- * `sh -c` subprocess at the project root, inheriting the process env; exit 0 =>
23
- * pass, non-zero => block, timeout => block. The production runBoundedShell
24
- * binding is shell-command-projection.execTool (bounded spawnSync). See ADR-2008
25
- * for the full sandbox contract.
21
+ * Built-in kinds: `command-exit-zero` runs a declared command in a bounded
22
+ * `sh -c` subprocess; `artifact-frontmatter-equals` compares a declared value
23
+ * with frontmatter read through injected artifact dependencies. See ADR-2008
24
+ * for the full contracts.
26
25
  *
27
26
  * This is a leaf pure module: no fs, no child_process, no config — the subprocess
28
27
  * seam is injected so the evaluator is trivially testable without spawning.
@@ -35,7 +34,7 @@ const COMMAND_MAX_OUTPUT_CHARS = 2000;
35
34
  /** Hard cap on the declared command length (defense-in-depth against ARGV overflow / abuse). */
36
35
  const COMMAND_MAX_LENGTH = 4096;
37
36
  /** Predicate kinds this evaluator recognises (extensible — add to KIND_TABLE). */
38
- const EVALUATOR_KINDS = Object.freeze(['command-exit-zero']);
37
+ const EVALUATOR_KINDS = Object.freeze(['command-exit-zero', 'artifact-frontmatter-equals']);
39
38
  /** Placeholders interpolated into a declared command, in addition to sh's own vars. */
40
39
  const INTERPOLATION_VAR_NAMES = Object.freeze(['PHASE_NUMBER', 'PHASE_DIR', 'PHASE_REQ_IDS']);
41
40
  // ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -102,9 +101,53 @@ function evaluateCommandExitZero(predicate, ctx, deps) {
102
101
  details: { kind: 'command-exit-zero', exitCode: res.exitCode, signal: res.signal },
103
102
  };
104
103
  }
104
+ // ─── Kind: artifact-frontmatter-equals ──────────────────────────────────────────
105
+ function evaluateArtifactFrontmatterEquals(predicate, ctx, deps) {
106
+ const artifactSuffix = predicate['artifact'];
107
+ if (!isNonEmptyString(artifactSuffix)) {
108
+ throw new Error('artifact-frontmatter-equals predicate requires a non-empty string "artifact"');
109
+ }
110
+ const field = predicate['field'];
111
+ if (!isNonEmptyString(field)) {
112
+ throw new Error('artifact-frontmatter-equals predicate requires a non-empty string "field"');
113
+ }
114
+ const expectedValue = predicate['equals'];
115
+ if (expectedValue === undefined) {
116
+ throw new Error('artifact-frontmatter-equals predicate requires an "equals" key');
117
+ }
118
+ const targetDir = isNonEmptyString(ctx.phaseDir) ? ctx.phaseDir : ctx.cwd;
119
+ const filePath = deps.findPhaseArtifact(targetDir, artifactSuffix);
120
+ if (!filePath) {
121
+ return {
122
+ block: true,
123
+ message: `Artifact matching ${artifactSuffix} not found in ${targetDir}`,
124
+ details: { kind: 'artifact-frontmatter-equals', artifactNotFound: true },
125
+ };
126
+ }
127
+ const fm = deps.readFrontmatter(filePath);
128
+ const actualValue = fm[field];
129
+ // eslint-disable-next-line @typescript-eslint/no-base-to-string
130
+ const expectedStr = typeof expectedValue === 'object' ? JSON.stringify(expectedValue) : String(expectedValue);
131
+ // eslint-disable-next-line @typescript-eslint/no-base-to-string
132
+ const actualStr = typeof actualValue === 'object' ? JSON.stringify(actualValue) : String(actualValue);
133
+ const matches = actualValue === expectedValue || (actualValue !== undefined && actualValue !== null && actualStr === expectedStr);
134
+ if (matches) {
135
+ return {
136
+ block: false,
137
+ message: `Frontmatter field "${field}" matches expected value (${expectedStr})`,
138
+ details: { kind: 'artifact-frontmatter-equals', match: true },
139
+ };
140
+ }
141
+ return {
142
+ block: true,
143
+ message: `Frontmatter field "${field}" in ${artifactSuffix} is ${actualStr}, expected ${expectedStr}`,
144
+ details: { kind: 'artifact-frontmatter-equals', match: false, actual: actualValue, expected: expectedValue },
145
+ };
146
+ }
105
147
  // ─── Kind dispatch table ──────────────────────────────────────────────────────
106
148
  const KIND_TABLE = {
107
149
  'command-exit-zero': evaluateCommandExitZero,
150
+ 'artifact-frontmatter-equals': evaluateArtifactFrontmatterEquals,
108
151
  };
109
152
  // ─── Public entry point ───────────────────────────────────────────────────────
110
153
  /**
@@ -133,6 +176,14 @@ function evaluatePredicate(predicate, context, deps) {
133
176
  if (typeof kind !== 'string' || kind.length === 0) {
134
177
  throw new Error('predicate.kind must be a non-empty string');
135
178
  }
179
+ if (kind === 'artifact-frontmatter-equals') {
180
+ if (typeof d.findPhaseArtifact !== 'function') {
181
+ throw new Error('predicate deps require a "findPhaseArtifact" function');
182
+ }
183
+ if (typeof d.readFrontmatter !== 'function') {
184
+ throw new Error('predicate deps require a "readFrontmatter" function');
185
+ }
186
+ }
136
187
  const handler = KIND_TABLE[kind];
137
188
  if (!handler) {
138
189
  throw new Error(`Unknown predicate kind: "${kind}". Known kinds: ${EVALUATOR_KINDS.join(', ')}`);
@@ -18,7 +18,14 @@
18
18
  * 5. "main" (last-resort default)
19
19
  *
20
20
  * Every git subprocess is bounded with a timeout (≤ 30 s); on timeout/error
21
- * the resolver degrades gracefully to the next tier — it never throws.
21
+ * the resolver degrades gracefully to the next tier — it never throws. Tier 5
22
+ * is reachable two ways that `resolveBaseBranch()` alone cannot tell apart: a
23
+ * repository that genuinely has no candidate branch (every git query on tiers
24
+ * 2-4 completed and cleanly answered "nothing"), or a total resolution
25
+ * failure (some query timed out / could not run). `resolveBaseBranchDiagnostics()`
26
+ * distinguishes the two via `verified`; `cmdGitBaseBranch` surfaces the
27
+ * unverified case as a stderr diagnostic without changing its stdout contract
28
+ * (#3057 B4).
22
29
  *
23
30
  * Pure/testable: all I/O is injectable via the `deps` argument so unit
24
31
  * tests can run without touching the real filesystem or spawning real git.
@@ -31,6 +38,7 @@ exports.readConfigBaseBranch = readConfigBaseBranch;
31
38
  exports.trySymbolicRef = trySymbolicRef;
32
39
  exports.tryRemoteShow = tryRemoteShow;
33
40
  exports.tryLocalBranch = tryLocalBranch;
41
+ exports.resolveBaseBranchDiagnostics = resolveBaseBranchDiagnostics;
34
42
  exports.resolveBaseBranch = resolveBaseBranch;
35
43
  exports.gitWorktreeInfoInternal = gitWorktreeInfoInternal;
36
44
  exports.cmdGitBaseBranch = cmdGitBaseBranch;
@@ -115,7 +123,8 @@ function tryRemoteShow(cwd, execGit) {
115
123
  const branch = m[1];
116
124
  // git emits "(unknown)" when the remote is offline but the local cache
117
125
  // resolved it; treat that as non-authoritative and fall through.
118
- if (!branch || branch === '(unknown)')
126
+ // No `!branch ||` guard: m[1] comes from the `(\S+)` capture group above, so it is never empty.
127
+ if (branch === '(unknown)')
119
128
  return null;
120
129
  return branch;
121
130
  }
@@ -153,41 +162,70 @@ function tryLocalBranch(cwd, execGit) {
153
162
  }
154
163
  }
155
164
  /**
156
- * Resolve the default/base branch for the repository at `cwd`.
165
+ * Resolve the default/base branch for the repository at `cwd`, along with
166
+ * whether the tier-5 last-resort default (if reached) was verified.
157
167
  *
158
168
  * Consults the full precedence ladder and always returns a non-empty string.
159
169
  * Never throws.
160
170
  */
161
- function resolveBaseBranch(cwd, deps) {
162
- const execGit = deps?.execGit ?? shell_command_projection_cjs_1.execGit;
171
+ function resolveBaseBranchDiagnostics(cwd, deps) {
172
+ const rawExecGit = deps?.execGit ?? shell_command_projection_cjs_1.execGit;
173
+ // A genuine execGit failure (timeout, or the call could not even spawn —
174
+ // e.g. git missing, surfaced as exitCode 127 with `error` set) is distinct
175
+ // from git completing and cleanly reporting a negative answer (non-zero
176
+ // exit with no useful output, or exit 0 with empty stdout). Only the former
177
+ // means a tier's answer was never actually obtained. Wrapping execGit here
178
+ // observes every tier's calls uniformly without changing trySymbolicRef /
179
+ // tryRemoteShow / tryLocalBranch's own return contracts.
180
+ let anyGitFailure = false;
181
+ const execGit = (args, opts) => {
182
+ const r = rawExecGit(args, opts);
183
+ if (r.timedOut || r.error)
184
+ anyGitFailure = true;
185
+ return r;
186
+ };
163
187
  // Derive .planning dir relative to cwd (mirrors planningDir() in planning-workspace.cjs)
164
188
  const planningDir = node_path_1.default.join(cwd, '.planning');
165
189
  // 1. Config override
166
190
  const configured = readConfigBaseBranch(planningDir, deps);
167
191
  if (configured)
168
- return configured;
192
+ return { branch: configured, verified: true };
169
193
  // 2. symbolic-ref (fast, no network)
170
194
  const symref = trySymbolicRef(cwd, execGit);
171
195
  if (symref)
172
- return symref;
196
+ return { branch: symref, verified: true };
173
197
  // 3. git remote show origin (authoritative when origin/HEAD unset)
174
198
  const remoteShow = tryRemoteShow(cwd, execGit);
175
199
  if (remoteShow)
176
- return remoteShow;
200
+ return { branch: remoteShow, verified: true };
177
201
  // 4. Local branch existence
178
202
  const local = tryLocalBranch(cwd, execGit);
179
203
  if (local)
180
- return local;
181
- // 5. Last-resort default
182
- return 'main';
204
+ return { branch: local, verified: true };
205
+ // 5. Last-resort default. `verified:false` when at least one tier-2/3/4
206
+ // execGit call timed out or failed to run — the default was never actually
207
+ // checked against this repository, it is just what's left after git could
208
+ // not answer (#3057 B4).
209
+ return { branch: 'main', verified: !anyGitFailure };
210
+ }
211
+ /**
212
+ * Resolve the default/base branch for the repository at `cwd`.
213
+ *
214
+ * Consults the full precedence ladder and always returns a non-empty string.
215
+ * Never throws. See {@link resolveBaseBranchDiagnostics} for a caller that
216
+ * needs to distinguish a verified answer from an unverified fallback.
217
+ */
218
+ function resolveBaseBranch(cwd, deps) {
219
+ return resolveBaseBranchDiagnostics(cwd, deps).branch;
183
220
  }
184
221
  /**
185
222
  * Detect whether `cwd` sits inside a git worktree, and if so, return the
186
223
  * absolute path of the worktree root.
187
224
  */
188
- function gitWorktreeInfoInternal(cwd) {
225
+ function gitWorktreeInfoInternal(cwd, deps) {
226
+ const execGit = deps?.execGit ?? shell_command_projection_cjs_1.execGit;
189
227
  try {
190
- const insideResult = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '--is-inside-work-tree'], { cwd, timeout: 5000 });
228
+ const insideResult = execGit(['rev-parse', '--is-inside-work-tree'], { cwd, timeout: 5000 });
191
229
  if (insideResult.exitCode !== 0) {
192
230
  return { inside: false, worktreeRoot: null };
193
231
  }
@@ -195,7 +233,7 @@ function gitWorktreeInfoInternal(cwd) {
195
233
  if (insideStdout !== 'true') {
196
234
  return { inside: false, worktreeRoot: null };
197
235
  }
198
- const rootResult = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '--show-toplevel'], { cwd, timeout: 5000 });
236
+ const rootResult = execGit(['rev-parse', '--show-toplevel'], { cwd, timeout: 5000 });
199
237
  if (rootResult.exitCode !== 0) {
200
238
  return { inside: true, worktreeRoot: null };
201
239
  }
@@ -213,7 +251,12 @@ function gitWorktreeInfoInternal(cwd) {
213
251
  * Called by workflows via `gsd_run query git.base-branch`.
214
252
  */
215
253
  function cmdGitBaseBranch(cwd, _args, deps) {
216
- const branch = resolveBaseBranch(cwd, deps);
254
+ const { branch, verified } = resolveBaseBranchDiagnostics(cwd, deps);
255
+ if (!verified) {
256
+ const writeDiagnostic = deps?.writeDiagnostic ?? ((s) => process.stderr.write(s));
257
+ writeDiagnostic(`⚠ git-base-branch: defaulted to 'main' WITHOUT verifying against this repository — ` +
258
+ `a git query timed out or could not run. See #3057.\n`);
259
+ }
217
260
  const write = deps?.write ?? ((s) => process.stdout.write(s));
218
261
  write(branch + '\n');
219
262
  return branch;