@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
@@ -0,0 +1,209 @@
1
+ "use strict";
2
+ /**
3
+ * Section Manifest — pure `when=` evaluator over `InvocationFacts`, mapping
4
+ * a document-order list of parsed `<!-- gsd:section -->` sections (Phase 3,
5
+ * `src/workflow-fragments.cts`) to an included/excluded partition for one
6
+ * concrete invocation (ADR-1671 epic #1671, Phase 5 / issue #2932,
7
+ * `.gsd/phase/chore-2932-init-section-manifest/40-design.md`).
8
+ *
9
+ * Pure module: no I/O, no dependency beyond node built-ins and the sibling
10
+ * compiled module `workflow-fragments.cjs`, whose {@link
11
+ * workflowFragments.WHEN_VOCABULARY} is imported and never redeclared here
12
+ * (DEFECT.GENERATIVE-FIX — a second frozen copy of the same 4 strings would
13
+ * silently desync from the source of truth the moment either side is edited
14
+ * without the other).
15
+ *
16
+ * ## The evaluator is a LOOKUP, not a parser
17
+ *
18
+ * Derived from Greenspun's Tenth Rule (ADR-1671:69 cites it by name) and
19
+ * binding on this implementation: `when=` is a closed vocabulary, widened
20
+ * from 4 to 14 entries via the ADR-1671 amendment for #2992 (epic #1671
21
+ * Phase 6.1; see `.gsd/phase/chore-2992-widen-when-vocabulary/
22
+ * 40-design.md`), then from 14 to 19 via the ADR-1671 amendment for #2993
23
+ * (epic #1671 Phase 6.2; see `.gsd/phase/chore-2993-fragmentize-plan-phase/
24
+ * 40-design.md`), then from 19 to 20 via the ADR-1671 amendment for #2994
25
+ * (epic #1671 Phase 6.3), then from 20 to 23 via a further #2994 amendment
26
+ * fragmentizing `code-review.md` and `complete-milestone.md`, then from 23
27
+ * to 24 via a still further #2994 amendment fragmentizing `autonomous.md`,
28
+ * then from 24 to 26 via a still further #2994 amendment fragmentizing
29
+ * `review.md` and `discuss-phase-assumptions.md`, then from 26 to 30 — and
30
+ * finally to 29, `flag:--full` having been retired as dead vocabulary — via the
31
+ * final #2994 slice fragmentizing `docs-update.md`, `update.md`,
32
+ * `transition.md`, and `new-milestone.md`.
33
+ * {@link WHEN_PREDICATES} is a total map from each frozen
34
+ * vocabulary entry to exactly one predicate over {@link InvocationFacts}.
35
+ * It MUST NOT tokenize, split on operators, or interpret structure in the
36
+ * `when=` string — the moment it parses, the ad-hoc language has begun.
37
+ * Every entry below is therefore a HAND-WRITTEN LITERAL: deriving a
38
+ * predicate's flag/state name from its atom string (e.g. slicing `--fix`
39
+ * out of `'flag:--fix'`) is tokenization relocated into this map and is
40
+ * forbidden even though it would be shorter — the redundancy between each
41
+ * key and its literal token is deliberate, and the bidirectional parity
42
+ * test below catches any desync a hand-written entry could introduce. An
43
+ * unrecognized `when=` value fails closed via {@link selectSections}
44
+ * throwing a `TypeError` carrying `.reason = REASON.UNKNOWN_WHEN`; it is
45
+ * never silently excluded (Postel's Law: liberal on FORMAT elsewhere in the
46
+ * pipeline, strict on this SEMANTIC boundary — matching the discipline
47
+ * Phase 3 already established for the same vocabulary at parse time).
48
+ *
49
+ * ## Totality over facts
50
+ *
51
+ * Every predicate treats an absent/missing fact key as falsy WITHOUT
52
+ * throwing — {@link InvocationFacts} is a plain data object handed in by a
53
+ * caller (the init CLI seam) that may not always populate every field, and
54
+ * this module must never surprise that caller with an exception for an
55
+ * omission rather than a malformed `when=` value.
56
+ *
57
+ * ## Partition invariant
58
+ *
59
+ * {@link selectSections} returns `included` and `excluded` id arrays that
60
+ * together contain every input section's `id` exactly once, in the SAME
61
+ * relative document order they appeared in the input — never mutating the
62
+ * input array or its elements.
63
+ *
64
+ * ADR-457 build-at-publish: compiled by tsc to
65
+ * gsd-core/bin/lib/section-manifest.cjs (gitignored).
66
+ */
67
+ Object.defineProperty(exports, "__esModule", { value: true });
68
+ exports.WHEN_PREDICATES = exports.REASON = void 0;
69
+ exports.selectSections = selectSections;
70
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- workflow-fragments.cjs is a CommonJS module compiled from a sibling .cts source; `import x = require()` reads its module.exports namespace directly.
71
+ const workflowFragments = require("./workflow-fragments.cjs");
72
+ /**
73
+ * Frozen, stable reason codes for every `fail()` throw site in this module.
74
+ * Tests assert via `assert.equal(err.reason, REASON.X)` rather than
75
+ * regex-/substring-matching the human-readable message (CONTRIBUTING.md
76
+ * "Prohibited: Raw Text Matching on Test Outputs"; shape copied from
77
+ * `src/workflow-fragments.cts`'s own `REASON` export) — a message reword
78
+ * must never silently pass a test that exists to catch a behavior
79
+ * regression.
80
+ *
81
+ * Adding a new reason requires updating this map AND the test that locks
82
+ * `Object.keys(REASON).sort()` as a coordinated change.
83
+ */
84
+ exports.REASON = Object.freeze({
85
+ UNKNOWN_WHEN: 'unknown_when',
86
+ });
87
+ /**
88
+ * Throws a `TypeError` naming the offending `when` value, carrying `reason`
89
+ * (one of {@link REASON}) as a typed property so callers/tests never need
90
+ * to pattern-match the message prose.
91
+ */
92
+ function fail(reason, message) {
93
+ const err = new TypeError(`section-manifest: ${message}`);
94
+ err.reason = reason;
95
+ throw err;
96
+ }
97
+ /**
98
+ * Safely tests whether `facts.flags` contains `flag`, tolerating an absent,
99
+ * `null`, or non-`Set` (e.g. array) `flags` value without throwing —
100
+ * `.has` is checked to be callable before it is called, rather than
101
+ * assuming every {@link InvocationFacts.flags} is a real `Set` (totality
102
+ * over facts; not duck-typed — an array `flags` degrades to "not present",
103
+ * it is never iterated or `.includes`-checked).
104
+ */
105
+ function hasFlag(facts, flag) {
106
+ return typeof facts.flags?.has === 'function' && facts.flags.has(flag) === true;
107
+ }
108
+ /**
109
+ * Total map from each frozen {@link workflowFragments.WHEN_VOCABULARY}
110
+ * entry to exactly one predicate over {@link InvocationFacts}. This is a
111
+ * LOOKUP, never a parser — see the module doc comment's "The evaluator is a
112
+ * LOOKUP, not a parser" section. Every entry is a hand-written literal; see
113
+ * the module doc comment for why deriving a predicate from its atom string
114
+ * is forbidden. Semantics confirmed against the section bodies themselves
115
+ * (design doc "Semantics confirmed against the section bodies themselves,
116
+ * not inferred from the id"):
117
+ *
118
+ * - `gap-closure-artifacts` — "For decimal/polish phases only (X.Y
119
+ * pattern) … Skip if phase number has no decimal" -> `state:gap-closure-phase`.
120
+ * - `regression-gate` — "Skip if: this is the first phase (no prior
121
+ * phases)" -> `state:has-prior-phases`.
122
+ * - `partial-wave` — "If `WAVE_FILTER` was used" -> `flag:--wave`.
123
+ */
124
+ exports.WHEN_PREDICATES = Object.freeze(Object.assign(Object.create(null), {
125
+ always: () => true,
126
+ 'flag:--wave': (facts) => hasFlag(facts, '--wave'),
127
+ 'state:gap-closure-phase': (facts) => typeof facts.phaseNumber === 'string' && facts.phaseNumber.includes('.'),
128
+ 'state:has-prior-phases': (facts) => facts.hasPriorPhases === true,
129
+ 'flag:--auto': (facts) => hasFlag(facts, '--auto'),
130
+ 'flag:--discuss': (facts) => hasFlag(facts, '--discuss'),
131
+ 'flag:--fix': (facts) => hasFlag(facts, '--fix'),
132
+ 'flag:--forensic': (facts) => hasFlag(facts, '--forensic'),
133
+ 'flag:--ingest': (facts) => hasFlag(facts, '--ingest'),
134
+ 'flag:--prd': (facts) => hasFlag(facts, '--prd'),
135
+ 'flag:--research': (facts) => hasFlag(facts, '--research'),
136
+ 'flag:--research-phase': (facts) => hasFlag(facts, '--research-phase'),
137
+ 'flag:--reset-phase-numbers': (facts) => hasFlag(facts, '--reset-phase-numbers'),
138
+ 'flag:--reviews': (facts) => hasFlag(facts, '--reviews'),
139
+ 'flag:--validate': (facts) => hasFlag(facts, '--validate'),
140
+ 'state:auto-advance-active': (facts) => facts.autoAdvanceActive === true,
141
+ 'state:chunked-mode': (facts) => facts.chunkedMode === true,
142
+ 'state:fallow-enabled': (facts) => facts.fallowEnabled === true,
143
+ 'state:flat-mode': (facts) => facts.flatMode === true,
144
+ 'state:git-create-tag': (facts) => facts.gitCreateTag === true,
145
+ 'state:is-monorepo': (facts) => facts.isMonorepo === true,
146
+ 'state:needs-codebase-map': (facts) => facts.needsCodebaseMap === true,
147
+ 'state:next-channel': (facts) => facts.nextChannel === true,
148
+ 'state:phase-mvp-mode': (facts) => facts.phaseMvpMode === true,
149
+ 'state:plan-strategy-converge': (facts) => facts.planStrategyConverge === true,
150
+ 'state:reviewer-instances-configured': (facts) => facts.reviewerInstancesConfigured === true,
151
+ 'state:ui-phase-active': (facts) => facts.uiPhaseActive === true,
152
+ 'state:workstream-active': (facts) => facts.workstreamActive === true,
153
+ 'state:worktrees-enabled': (facts) => facts.worktreesEnabled === true,
154
+ }));
155
+ // Coordinated-change guard, checked at module load: every entry of the
156
+ // frozen WHEN_VOCABULARY (imported, never redeclared — see module doc
157
+ // comment) must have exactly one predicate here, and vice versa. This is
158
+ // the load-bearing half of the DEFECT.GENERATIVE-FIX parity contract; the
159
+ // test-level half (50-test-matrix.md rows 21-23) additionally asserts it
160
+ // from the vocabulary's own export so a 5th vocabulary entry added without
161
+ // a predicate fails loudly rather than silently falling through to
162
+ // REASON.UNKNOWN_WHEN only at run time.
163
+ for (const when of workflowFragments.WHEN_VOCABULARY) {
164
+ if (!Object.hasOwn(exports.WHEN_PREDICATES, when)) {
165
+ throw new Error(`section-manifest: WHEN_VOCABULARY entry "${when}" has no predicate in WHEN_PREDICATES`);
166
+ }
167
+ }
168
+ // Reverse half of the same coordinated-change guard: every own key of
169
+ // WHEN_PREDICATES must also appear in WHEN_VOCABULARY. Without this, a
170
+ // predicate key with no vocabulary entry would let the evaluator accept an
171
+ // atom that the parser (classifyMarker) rejects — a real divergence between
172
+ // the two shared-constant halves (DEFECT.GENERATIVE-FIX; B9/B10 in
173
+ // `.gsd/phase/chore-2992-widen-when-vocabulary/50-test-matrix.md`).
174
+ const VOCABULARY_SET = new Set(workflowFragments.WHEN_VOCABULARY);
175
+ for (const predicateKey of Object.keys(exports.WHEN_PREDICATES)) {
176
+ if (!VOCABULARY_SET.has(predicateKey)) {
177
+ throw new Error(`section-manifest: WHEN_PREDICATES entry "${predicateKey}" has no matching WHEN_VOCABULARY entry`);
178
+ }
179
+ }
180
+ /**
181
+ * Partition `sections` (document order) into `included`/`excluded` id
182
+ * arrays for one set of `facts`, per {@link WHEN_PREDICATES}. Exact
183
+ * partition: every input id appears in exactly one of the two output
184
+ * arrays, in the same relative order it appeared in `sections`. Never
185
+ * mutates `sections` or its elements.
186
+ *
187
+ * @param sections - document-order sections carrying at least `{id, when}`
188
+ * @param facts - the concrete invocation's resolved facts
189
+ * @throws {SectionManifestError} with `.reason = REASON.UNKNOWN_WHEN` when a
190
+ * section's `when` value has no entry in {@link WHEN_PREDICATES} (fail
191
+ * closed — never silently excluded).
192
+ */
193
+ function selectSections(sections, facts) {
194
+ const included = [];
195
+ const excluded = [];
196
+ for (const section of sections) {
197
+ if (!Object.hasOwn(exports.WHEN_PREDICATES, section.when)) {
198
+ fail(exports.REASON.UNKNOWN_WHEN, `section "${section.id}" has unrecognized when= value "${section.when}"`);
199
+ }
200
+ const predicate = exports.WHEN_PREDICATES[section.when];
201
+ if (predicate(facts)) {
202
+ included.push(section.id);
203
+ }
204
+ else {
205
+ excluded.push(section.id);
206
+ }
207
+ }
208
+ return { included, excluded };
209
+ }
@@ -15,6 +15,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
15
15
  return (mod && mod.__esModule) ? mod : { "default": mod };
16
16
  };
17
17
  Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.PATH_ACTION_REASON = void 0;
18
19
  exports.toPosixPath = toPosixPath;
19
20
  exports.toNativePath = toNativePath;
20
21
  exports.posixNormalize = posixNormalize;
@@ -35,9 +36,11 @@ exports.projectCodexHookTomlCommand = projectCodexHookTomlCommand;
35
36
  exports.escapePowerShellSingleQuoted = escapePowerShellSingleQuoted;
36
37
  exports.escapePosixDoubleQuoted = escapePosixDoubleQuoted;
37
38
  exports.escapeSingleQuotedShellLiteral = escapeSingleQuotedShellLiteral;
39
+ exports.projectPathExportLine = projectPathExportLine;
38
40
  exports.renderShellActionLines = renderShellActionLines;
39
41
  exports.projectPathActionProjection = projectPathActionProjection;
40
42
  exports.projectPersistentPathExportActions = projectPersistentPathExportActions;
43
+ exports.isSpawnTimeout = isSpawnTimeout;
41
44
  exports.execGit = execGit;
42
45
  exports.execNpm = execNpm;
43
46
  exports.execTool = execTool;
@@ -351,8 +354,34 @@ function projectLegacySettingsHookCommand({ absoluteRunner, scriptPath, scriptTo
351
354
  platform,
352
355
  });
353
356
  }
357
+ // Implements the TOML v1.0.0 basic-string escaping grammar (toml.md, "Basic
358
+ // strings" section, https://toml.io/en/v1.0.0#string): a basic string must
359
+ // escape the quotation mark, backslash, and control characters other than
360
+ // tab (U+0000-U+0008, U+000A-U+001F, U+007F). Compact escapes are used where
361
+ // TOML defines them (\b \t \n \f \r \" \\); every other character in the
362
+ // required ranges falls back to \uXXXX. See #3118 — an earlier version
363
+ // escaped only backslash and quote, so a raw newline/CR/NUL in a value
364
+ // produced an unparseable config.toml.
365
+ const TOML_COMPACT_ESCAPES = {
366
+ '\x08': '\\b',
367
+ '\x09': '\\t',
368
+ '\x0A': '\\n',
369
+ '\x0C': '\\f',
370
+ '\x0D': '\\r',
371
+ };
372
+ // U+0000-U+0008, U+000A-U+001F, U+007F — control characters other than tab
373
+ // (U+0009), which the grammar permits unescaped.
374
+ const TOML_MUST_ESCAPE_CONTROL_CHARS = /[\x00-\x08\x0A-\x1F\x7F]/g;
354
375
  function escapeTomlDoubleQuotedString(value) {
355
- return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
376
+ return String(value)
377
+ .replace(/\\/g, '\\\\')
378
+ .replace(/"/g, '\\"')
379
+ .replace(TOML_MUST_ESCAPE_CONTROL_CHARS, (ch) => {
380
+ const compact = TOML_COMPACT_ESCAPES[ch];
381
+ if (compact)
382
+ return compact;
383
+ return `\\u${ch.codePointAt(0).toString(16).padStart(4, '0')}`;
384
+ });
356
385
  }
357
386
  function projectCodexHookTomlCommand({ absoluteRunner, scriptPath, platform = process.platform }) {
358
387
  const command = projectManagedHookCommand({
@@ -372,6 +401,27 @@ function escapePosixDoubleQuoted(value) {
372
401
  function escapeSingleQuotedShellLiteral(value) {
373
402
  return String(value).replace(/'/g, "'\\''");
374
403
  }
404
+ /**
405
+ * The `export PATH="<dir>:$PATH"` line every persistence lane appends, plus the escaped directory
406
+ * token it embeds. One builder because three lanes emit this line: a lane that re-escapes it
407
+ * locally is how #3118 shipped a `$(…)` into ~/.bashrc, where it ran on every new shell. The
408
+ * escaping is for the line's FINAL context — a double-quoted string in an rc file — not for
409
+ * whatever transport (an `echo`, a paste) it passes through on the way there.
410
+ */
411
+ function projectPathExportLine(targetDir) {
412
+ const escapedDir = escapePosixDoubleQuoted(String(targetDir));
413
+ return { escapedDir, line: `export PATH="${escapedDir}:$PATH"` };
414
+ }
415
+ /**
416
+ * Why a PATH suggestion produced no actions. An empty `shellActions` alone folds two different
417
+ * facts together — "no target directory was given" and "this target directory cannot be
418
+ * expressed as a shell command" — and a caller that cannot tell them apart prints a header with
419
+ * nothing under it (#3118).
420
+ */
421
+ exports.PATH_ACTION_REASON = Object.freeze({
422
+ NO_TARGET_DIR: 'no_target_dir',
423
+ WIN32_RESERVED_QUOTE: 'win32_reserved_quote',
424
+ });
375
425
  function renderShellActionLines(shellActions = []) {
376
426
  return shellActions.map((action) => {
377
427
  if (!action || !action.command)
@@ -381,12 +431,18 @@ function renderShellActionLines(shellActions = []) {
381
431
  }
382
432
  function projectPathActionProjection({ mode = 'repair', targetDir, platform = process.platform, }) {
383
433
  if (!targetDir)
384
- return { shellActions: [], actionLines: [] };
434
+ return { shellActions: [], actionLines: [], reason: exports.PATH_ACTION_REASON.NO_TARGET_DIR };
435
+ // #3118: `"` is reserved on Windows, so a path containing one cannot exist — and it would close
436
+ // cmd's quoted region in the `powershell -Command "…"` lane below, turning the rest into cmd
437
+ // input. There is no correct command to suggest for an impossible path: fail closed rather than
438
+ // emit one whose quoting can be broken.
439
+ if (platform === 'win32' && String(targetDir).includes('"'))
440
+ return { shellActions: [], actionLines: [], reason: exports.PATH_ACTION_REASON.WIN32_RESERVED_QUOTE };
385
441
  const isWin32 = platform === 'win32';
386
442
  let shellActions;
387
443
  if (isWin32) {
388
444
  const psTargetDir = escapePowerShellSingleQuoted(targetDir);
389
- const bashTargetDir = escapeSingleQuotedShellLiteral(posixNormalize(String(targetDir)));
445
+ const bashExportLine = escapeSingleQuotedShellLiteral(projectPathExportLine(posixNormalize(String(targetDir))).line);
390
446
  shellActions = [
391
447
  {
392
448
  label: 'PowerShell',
@@ -401,42 +457,51 @@ function projectPathActionProjection({ mode = 'repair', targetDir, platform = pr
401
457
  {
402
458
  label: 'Git Bash',
403
459
  shell: 'bash',
404
- command: `echo 'export PATH="${bashTargetDir}:$PATH"' >> ~/.bashrc`,
460
+ command: `echo '${bashExportLine}' >> ~/.bashrc`,
405
461
  },
406
462
  ];
407
463
  }
408
464
  else if (mode === 'persist') {
409
- const bashTargetDir = escapeSingleQuotedShellLiteral(String(targetDir));
465
+ const exportLine = escapeSingleQuotedShellLiteral(projectPathExportLine(targetDir).line);
466
+ const fishTargetDir = escapeSingleQuotedShellLiteral(String(targetDir));
410
467
  shellActions = [
411
468
  {
412
469
  label: 'zsh',
413
470
  shell: 'zsh',
414
- command: `echo 'export PATH="${bashTargetDir}:$PATH"' >> ~/.zshrc`,
471
+ command: `echo '${exportLine}' >> ~/.zshrc`,
415
472
  },
416
473
  {
417
474
  label: 'bash',
418
475
  shell: 'bash',
419
- command: `echo 'export PATH="${bashTargetDir}:$PATH"' >> ~/.bashrc`,
476
+ command: `echo '${exportLine}' >> ~/.bashrc`,
420
477
  },
421
478
  // #323: fish has no `export`/`$PATH`-list syntax. `fish_add_path` is the
422
479
  // fish-native API (>= fish 3.2, 2021) that persists to the universal
423
480
  // variable store and de-duplicates. The directory is single-quoted with
424
481
  // the same POSIX literal escaping as the zsh/bash siblings — `'\''` is
425
482
  // also a valid escaped single quote in fish between quote spans.
483
+ //
484
+ // #3118 review MINOR: a `targetDir` with a leading `-` (e.g. `-v`) is a
485
+ // legal directory name, but fish's argparse-based option scanning
486
+ // treats a leading-dash token as a flag REGARDLESS of quoting, so
487
+ // `fish_add_path '-v'` misparses it and prints "No paths to add, not
488
+ // setting anything." (exit 1) instead of adding the path. `--` is
489
+ // fish's standard end-of-options separator; verified empirically
490
+ // against a real fish 4.8.1 install that `fish_add_path -- '-v'`
491
+ // succeeds where the unseparated form fails.
426
492
  {
427
493
  label: 'fish',
428
494
  shell: 'fish',
429
- command: `fish_add_path '${bashTargetDir}'`,
495
+ command: `fish_add_path -- '${fishTargetDir}'`,
430
496
  },
431
497
  ];
432
498
  }
433
499
  else {
434
- const posixTargetDir = escapePosixDoubleQuoted(targetDir);
435
500
  shellActions = [
436
501
  {
437
502
  label: null,
438
503
  shell: 'posix',
439
- command: `export PATH="${posixTargetDir}:$PATH"`,
504
+ command: projectPathExportLine(targetDir).line,
440
505
  },
441
506
  ];
442
507
  }
@@ -451,18 +516,46 @@ function projectPersistentPathExportActions({ targetDir, platform = process.plat
451
516
  targetDir,
452
517
  platform,
453
518
  });
454
- return { shellActions: projected.shellActions };
519
+ return projected.reason === undefined
520
+ ? { shellActions: projected.shellActions }
521
+ : { shellActions: projected.shellActions, reason: projected.reason };
522
+ }
523
+ /**
524
+ * Returns true when a spawn/exec result indicates the subprocess was killed
525
+ * by a timeout, i.e. it never completed and reported a real answer. This is
526
+ * the single shared definition of "did this subprocess time out" — worktree
527
+ * safety (src/worktree-safety.cts) and worktree base-ref detection
528
+ * (src/worktree-base-ref.cts) both call this instead of maintaining their
529
+ * own copies (#3050 — "Generative Fix Divergence").
530
+ *
531
+ * Only `error.code === 'ETIMEDOUT'` is checked. Node.js guarantees this
532
+ * cross-platform when `spawnSync`'s `timeout` option fires. The `signal ===
533
+ * 'SIGTERM'` check some earlier code paired with it is platform-fragile —
534
+ * Windows does not necessarily report SIGTERM the same way — and pairing it
535
+ * in as a REQUIRED conjunct risks a false NEGATIVE (a timeout that silently
536
+ * fails to trip the guard) on that platform. There is no false-positive risk
537
+ * from dropping it: an externally-delivered SIGTERM (not a timeout) leaves
538
+ * `error` null, so `error.code === 'ETIMEDOUT'` alone still won't match it.
539
+ */
540
+ function isSpawnTimeout(result) {
541
+ return result.error?.code === 'ETIMEDOUT';
455
542
  }
456
543
  function _spawnResult(result, program) {
457
544
  if (result.error && result.error.code === 'ENOENT') {
458
- return { exitCode: 127, stdout: '', stderr: `${program}: not found`, signal: null, error: result.error };
545
+ return { exitCode: 127, stdout: '', stderr: `${program}: not found`, signal: null, error: result.error, timedOut: false };
459
546
  }
547
+ const signal = result.signal ?? null;
548
+ const error = result.error ?? null;
460
549
  return {
461
550
  exitCode: result.status ?? 1,
462
551
  stdout: (result.stdout ?? '').toString().trim(),
463
552
  stderr: (result.stderr ?? '').toString().trim(),
464
- signal: result.signal ?? null,
465
- error: result.error ?? null,
553
+ signal,
554
+ error,
555
+ // Reuse the single shared timeout predicate (isSpawnTimeout, below) rather
556
+ // than re-deriving it here — see that function's docstring for why only
557
+ // error.code === 'ETIMEDOUT' is checked (not signal === 'SIGTERM').
558
+ timedOut: isSpawnTimeout({ error }),
466
559
  };
467
560
  }
468
561
  function execGit(args, opts = {}) {
@@ -549,9 +642,9 @@ function resolveGsdToolsPath() {
549
642
  * NEVER throws. Degrades to `{ ok:false, ... }` on:
550
643
  * - a missing/invalid "family" (validated locally, no subprocess spawned)
551
644
  * - ENOENT / a missing gsd-tools.cjs (via the injectable `gsdToolsPath`)
552
- * - a wall-clock timeout (`timedOut:true`, mirroring the
553
- * `signal === 'SIGTERM' && error.code === 'ETIMEDOUT'` idiom already used
554
- * by worktree-safety.cts)
645
+ * - a wall-clock timeout (`timedOut:true`, via the shared `isSpawnTimeout`
646
+ * predicate defined above in this file — also used by worktree-safety.cts
647
+ * and worktree-base-ref.cts)
555
648
  * - any other unanticipated throw from the underlying spawn (defensive
556
649
  * try/catch — execTool itself is spawnSync-based and does not throw).
557
650
  */
@@ -592,16 +685,9 @@ function dispatchGsdCommand({ family, subcommand, args = [], cwd, timeout = 30_0
592
685
  timedOut: false,
593
686
  };
594
687
  }
595
- // Mirrors the established `result.error && (result.error as
596
- // NodeJS.ErrnoException).code === ...` idiom (graphify.cts, worktree-safety.cts):
597
- // narrow away null via `!== null` FIRST, then cast — asserting `Error | null`
598
- // to `NodeJS.ErrnoException | null` directly (paired with optional chaining)
599
- // trips a typescript-eslint no-unnecessary-type-assertion false positive for
600
- // this exact narrowing shape (all of ErrnoException's extra fields over Error
601
- // are optional).
602
- const timedOut = result.signal === 'SIGTERM'
603
- && result.error !== null
604
- && result.error.code === 'ETIMEDOUT';
688
+ // Delegates to the single shared predicate defined above in this file
689
+ // (#3050 — "Generative Fix Divergence") instead of a local inline copy.
690
+ const timedOut = isSpawnTimeout(result);
605
691
  return {
606
692
  ok: result.exitCode === 0 && !timedOut,
607
693
  stdout: result.stdout,
@@ -61,6 +61,9 @@ const { stateExtractField } = stateDocument;
61
61
  // eslint-disable-next-line @typescript-eslint/no-require-imports
62
62
  const phaseId = require("./phase-id.cjs");
63
63
  const { comparePhaseNum, extractPhaseToken, normalizePhaseName, phaseTokenMatches } = phaseId;
64
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
65
+ const unusableInput = require("./unusable-input.cjs");
66
+ const { warnUnusableInput, UNUSABLE_REASON } = unusableInput;
64
67
  // ─── Constants ────────────────────────────────────────────────────────────────
65
68
  /**
66
69
  * Staleness threshold for idle-stranded detection. A clean tree whose last
@@ -290,7 +293,16 @@ function detectSignals(cwd, now = Date.now) {
290
293
  // Stale = no recorded activity for IDLE_STALE_MS. Used only by idle-stranded.
291
294
  // Computed here (with the clock seam) so the pure classify() stays a function
292
295
  // of (signals, staleActivity) and detectSignals owns all disk reads.
296
+ // #3099 (ADR-1411 amendment): if last_activity is present but unparseable,
297
+ // emit a diagnostic so the silent fallback (stale_activity: false) is visible.
298
+ // The fallback itself stays — continuity is correct, the silence was the defect.
293
299
  const lastActivityMs = parseActivityTimestamp(lastActivityRaw);
300
+ if (lastActivityRaw && lastActivityMs === null) {
301
+ warnUnusableInput({
302
+ reason: UNUSABLE_REASON.LAST_ACTIVITY_UNPARSEABLE,
303
+ source: paths.state,
304
+ });
305
+ }
294
306
  const staleActivity = lastActivityMs !== null && now() - lastActivityMs > IDLE_STALE_MS;
295
307
  // Verify-failed may be signalled either by STATE.md status or by a failed
296
308
  // STATUS: marker on the current phase's summary/verify artifact.
@@ -111,8 +111,28 @@ function applyStatePreservation(input) {
111
111
  const derived = (postFm['progress'] ?? {});
112
112
  const merged = { ...derived };
113
113
  if (curated) {
114
+ // #2440: total_plans and total_phases always take the derived value.
115
+ // #2969: completed_plans and completed_phases take the derived value
116
+ // when it is GREATER than the curated value (gap-closure plans that
117
+ // completed after the plan count grew) — ratcheting UP only, never
118
+ // deriving downward (preserves the #3242 curated-progress protection
119
+ // for cases unrelated to plan-count growth, e.g. a deleted SUMMARY).
120
+ // percent also takes the derived value — the resync recomputed it from
121
+ // disk counts, and a stale curated percent would be incoherent against
122
+ // the ratcheted-up completed counts (e.g. 54/54 at 93%).
123
+ const ratchetUpKeys = new Set(['completed_plans', 'completed_phases']);
114
124
  for (const [key, value] of Object.entries(curated)) {
115
- if (key !== 'total_plans' && key !== 'total_phases') {
125
+ if (key === 'total_plans' || key === 'total_phases' || key === 'percent')
126
+ continue;
127
+ if (ratchetUpKeys.has(key)) {
128
+ const derivedNum = typeof derived[key] === 'number' ? derived[key] : -Infinity;
129
+ const curatedNum = typeof value === 'number' ? value : -Infinity;
130
+ // Take the derived value only when it ratchets up; else keep curated.
131
+ if (derivedNum > curatedNum)
132
+ continue;
133
+ merged[key] = value;
134
+ }
135
+ else {
116
136
  merged[key] = value;
117
137
  }
118
138
  }
@@ -339,7 +359,24 @@ function locateCurrentPosition(body) {
339
359
  let end = body.length;
340
360
  for (let j = idx + 1; j < hs.length; j++) {
341
361
  if (STOP_H2_PLUS(hs[j].level)) {
342
- end = hs[j].offset - 1;
362
+ // Exclude the newline that separates this section from the next
363
+ // heading. Walk back over a bare `\n`, then over a `\r` if one
364
+ // immediately precedes it (CRLF), so a CRLF document's slice does not
365
+ // retain a stray unpaired trailing `\r` (#3118).
366
+ let e = hs[j].offset;
367
+ if (e > 0 && body[e - 1] === '\n') {
368
+ e -= 1;
369
+ if (e > 0 && body[e - 1] === '\r')
370
+ e -= 1;
371
+ }
372
+ // Clamp so the span can never invert (#3118 review): when the section
373
+ // is empty and the next heading follows with no blank line between,
374
+ // walking back over the newline(s) can land `e` before `start`. An
375
+ // inverted span makes every mutator's `body.slice(0, start) +
376
+ // sectionBody + body.slice(end)` reassembly duplicate the bytes in
377
+ // `[end, start)`. A zero-length span (`end === start`) is the correct
378
+ // representation of an empty-but-present section.
379
+ end = Math.max(e, start);
343
380
  break;
344
381
  }
345
382
  }
@@ -744,8 +781,17 @@ function completePhaseCore(content, intent, deps) {
744
781
  if (derived.totalPhases !== null)
745
782
  derivedTotalPhases = derived.totalPhases;
746
783
  }
784
+ // #3057 B9: only mark 'Completed Phases' updated when the text actually
785
+ // changed. `stateReplaceField` returns the full (re-)substituted content
786
+ // whenever the field pattern matches, REGARDLESS of whether newCompleted
787
+ // differs from the value already in `body` — so a truthy-only check here
788
+ // marked the field 'updated' even when the roadmap was unavailable and
789
+ // newCompleted is just completedRaw parsed back to itself. That collapsed
790
+ // "recomputed from roadmap" and "left as-is" into the same `updated`
791
+ // signal. Comparing to `body` (the idiom every other field in this
792
+ // function already uses) restores the distinction.
747
793
  const completedAfter = (0, state_document_cjs_1.stateReplaceField)(body, 'Completed Phases', String(newCompleted));
748
- if (completedAfter) {
794
+ if (completedAfter !== null && completedAfter !== body) {
749
795
  body = completedAfter;
750
796
  updated.push('Completed Phases');
751
797
  }
@@ -753,8 +799,9 @@ function completePhaseCore(content, intent, deps) {
753
799
  const totalPhases = derivedTotalPhases || (totalRaw ? parseInt(totalRaw, 10) : null);
754
800
  if (totalPhases && totalPhases > 0) {
755
801
  const newPercent = (0, phase_lifecycle_cjs_1.clampPercent)(newCompleted, totalPhases);
802
+ // Same guard as 'Completed Phases' above, and for the same reason.
756
803
  const progAfter = (0, state_document_cjs_1.stateReplaceField)(body, 'Progress', `${newPercent}%`);
757
- if (progAfter) {
804
+ if (progAfter !== null && progAfter !== body) {
758
805
  body = progAfter;
759
806
  updated.push('Progress');
760
807
  }
@@ -1352,13 +1399,14 @@ function truncateForLog(s) {
1352
1399
  function rebuildCore(content, _intent, deps) {
1353
1400
  const timestamp = deps.clock.nowIso();
1354
1401
  const log = [];
1402
+ const phaseInventoryScan = { failed: false, reason: null };
1355
1403
  let modified = content;
1356
1404
  // §2 Decision: re-derive derived sections, preserve others. Order is
1357
1405
  // oldest-section-first so log entries appear in body order.
1358
1406
  // sourcePath threaded so `state rebuild --dry-run` names the file: that branch reads STATE.md
1359
1407
  // directly rather than through readModifyWriteStateMd, so nothing upstream has named it yet.
1360
1408
  modified = reconcileCurrentPosition(modified, timestamp, log, deps.sourcePath);
1361
- modified = reconcileByPhaseTable(modified, deps, timestamp, log);
1409
+ modified = reconcileByPhaseTable(modified, deps, timestamp, log, phaseInventoryScan);
1362
1410
  modified = stripTemplatePlaceholders(modified, timestamp, log);
1363
1411
  modified = deduplicateSessionArchive(modified, timestamp, log);
1364
1412
  // §3 + §4: append the audit log ONLY when mutations occurred. The
@@ -1375,6 +1423,11 @@ function rebuildCore(content, _intent, deps) {
1375
1423
  mutated: log.length > 0,
1376
1424
  mutations: log.length,
1377
1425
  log,
1426
+ // #3057 B1: distinguishable from a clean "nothing to reconcile" — a
1427
+ // failed phase-inventory scan means the by-phase table was NOT
1428
+ // verified against disk, even though `mutated` may still be false.
1429
+ phase_inventory_scan_failed: phaseInventoryScan.failed,
1430
+ ...(phaseInventoryScan.reason !== null ? { phase_inventory_scan_reason: phaseInventoryScan.reason } : {}),
1378
1431
  },
1379
1432
  };
1380
1433
  }
@@ -1451,12 +1504,24 @@ function reconcileCurrentPosition(content, timestamp, log, sourcePath) {
1451
1504
  * Leaky-Abstractions guard (ADR-1817 §1): when `phaseInventoryProvider` is
1452
1505
  * absent (no disk scan wired), this step is a no-op. The core stays pure and
1453
1506
  * testable without disk I/O.
1507
+ *
1508
+ * A DIFFERENT case is a scan that ran but failed (`ok:false`): that is NOT a
1509
+ * no-op-equivalent "nothing to reconcile" — the table is left untouched (we
1510
+ * have no trustworthy inventory to reconcile against) but the failure is
1511
+ * recorded into `meta` so the caller (`rebuildCore`) can surface it instead
1512
+ * of reporting a clean, fully-reconciled rebuild (#3057 B1).
1454
1513
  */
1455
- function reconcileByPhaseTable(content, deps, timestamp, log) {
1514
+ function reconcileByPhaseTable(content, deps, timestamp, log, meta) {
1456
1515
  if (!deps.phaseInventoryProvider)
1457
1516
  return content;
1458
- const inventory = deps.phaseInventoryProvider();
1459
- if (!inventory || inventory.length === 0)
1517
+ const result = deps.phaseInventoryProvider();
1518
+ if (!result.ok) {
1519
+ meta.failed = true;
1520
+ meta.reason = result.reason;
1521
+ return content; // cannot reconcile without a trustworthy inventory — leave the table as-is
1522
+ }
1523
+ const inventory = result.phases;
1524
+ if (inventory.length === 0)
1460
1525
  return content;
1461
1526
  // The canonical table shape (from gsd-core/templates/state.md):
1462
1527
  // | Phase | Plans | Total | Avg/Plan |