@opengsd/gsd-core 1.12.0 → 1.13.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 (286) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +12 -0
  4. package/agents/gsd-executor.md +63 -35
  5. package/agents/gsd-plan-checker.md +76 -57
  6. package/agents/gsd-planner.md +14 -0
  7. package/agents/gsd-ui-checker.md +19 -3
  8. package/agents/gsd-ui-researcher.md +29 -0
  9. package/agents/gsd-verifier.md +23 -1
  10. package/bin/install.js +239 -67
  11. package/commands/gsd/execute-phase.md +1 -1
  12. package/commands/gsd/ns-workflow.md +2 -1
  13. package/commands/gsd/phase.md +1 -1
  14. package/commands/gsd/quick-batch.md +105 -0
  15. package/commands/gsd/surface.md +18 -8
  16. package/gsd-core/bin/gsd-tools.cjs +195 -50
  17. package/gsd-core/bin/lib/capability-activation.cjs +27 -0
  18. package/gsd-core/bin/lib/capability-registry.cjs +514 -114
  19. package/gsd-core/bin/lib/capability-state.cjs +7 -1
  20. package/gsd-core/bin/lib/capability-validator.cjs +120 -4
  21. package/gsd-core/bin/lib/capability-writer.cjs +14 -4
  22. package/gsd-core/bin/lib/check-command-router.cjs +85 -2
  23. package/gsd-core/bin/lib/claude-orchestration.cjs +10 -25
  24. package/gsd-core/bin/lib/clusters.cjs +1 -0
  25. package/gsd-core/bin/lib/command-aliases.cjs +16 -0
  26. package/gsd-core/bin/lib/commands.cjs +337 -13
  27. package/gsd-core/bin/lib/config-loader.cjs +3 -0
  28. package/gsd-core/bin/lib/core-utils.cjs +34 -7
  29. package/gsd-core/bin/lib/decisions.cjs +213 -1
  30. package/gsd-core/bin/lib/edge-probe.cjs +14 -1
  31. package/gsd-core/bin/lib/file-overlap-partitioner.cjs +74 -0
  32. package/gsd-core/bin/lib/frontmatter.cjs +137 -23
  33. package/gsd-core/bin/lib/gap-checker.cjs +22 -13
  34. package/gsd-core/bin/lib/git-base-branch.cjs +10 -2
  35. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +8 -2
  36. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +54 -11
  37. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +75 -22
  38. package/gsd-core/bin/lib/host-integration.cjs +57 -5
  39. package/gsd-core/bin/lib/init-command-router.cjs +14 -0
  40. package/gsd-core/bin/lib/init.cjs +132 -15
  41. package/gsd-core/bin/lib/install-engine.cjs +184 -12
  42. package/gsd-core/bin/lib/install-model-override-resolver.cjs +45 -0
  43. package/gsd-core/bin/lib/install-profiles.cjs +22 -14
  44. package/gsd-core/bin/lib/installer-migration-report.cjs +1 -0
  45. package/gsd-core/bin/lib/io.cjs +35 -0
  46. package/gsd-core/bin/lib/loop-resolver.cjs +14 -8
  47. package/gsd-core/bin/lib/markdown-table.cjs +123 -0
  48. package/gsd-core/bin/lib/milestone.cjs +22 -2
  49. package/gsd-core/bin/lib/phase-command-router.cjs +13 -6
  50. package/gsd-core/bin/lib/phase-id.cjs +251 -9
  51. package/gsd-core/bin/lib/phase.cjs +774 -35
  52. package/gsd-core/bin/lib/plan-document.cjs +10 -0
  53. package/gsd-core/bin/lib/planning-snapshot.cjs +147 -20
  54. package/gsd-core/bin/lib/planning-workspace.cjs +103 -28
  55. package/gsd-core/bin/lib/quick-batch-command-router.cjs +285 -0
  56. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +250 -0
  57. package/gsd-core/bin/lib/quick-batch.cjs +840 -0
  58. package/gsd-core/bin/lib/review-lane-descriptor.cjs +53 -5
  59. package/gsd-core/bin/lib/review-lane-invocation.cjs +73 -1
  60. package/gsd-core/bin/lib/review-lane-runner.cjs +136 -10
  61. package/gsd-core/bin/lib/roadmap-parser.cjs +499 -26
  62. package/gsd-core/bin/lib/roadmap.cjs +187 -58
  63. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +233 -33
  64. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +16 -17
  65. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +286 -108
  66. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +215 -43
  67. package/gsd-core/bin/lib/shell-command-projection.cjs +4 -0
  68. package/gsd-core/bin/lib/smart-entry.cjs +7 -9
  69. package/gsd-core/bin/lib/state-document.cjs +30 -5
  70. package/gsd-core/bin/lib/state-md-schema.cjs +23 -13
  71. package/gsd-core/bin/lib/state-transition.cjs +333 -44
  72. package/gsd-core/bin/lib/state.cjs +684 -125
  73. package/gsd-core/bin/lib/surface.cjs +23 -8
  74. package/gsd-core/bin/lib/tdd-red-evidence.cjs +133 -0
  75. package/gsd-core/bin/lib/uat.cjs +1419 -515
  76. package/gsd-core/bin/lib/update-context.cjs +6 -2
  77. package/gsd-core/bin/lib/validate.cjs +230 -12
  78. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  79. package/gsd-core/bin/lib/verification.cjs +273 -12
  80. package/gsd-core/bin/lib/verify-command-router.cjs +1 -0
  81. package/gsd-core/bin/lib/verify.cjs +346 -16
  82. package/gsd-core/bin/lib/workstream-inventory.cjs +20 -2
  83. package/gsd-core/bin/lib/worktree-safety.cjs +8 -0
  84. package/gsd-core/bin/shared/config-schema.manifest.json +8 -0
  85. package/gsd-core/bin/verify-reapply-patches.cjs +70 -3
  86. package/gsd-core/references/agent-contracts.md +3 -3
  87. package/gsd-core/references/edge-probe.md +17 -13
  88. package/gsd-core/references/execute-mvp-tdd.md +18 -16
  89. package/gsd-core/references/execute-phase-response-language.md +6 -0
  90. package/gsd-core/references/executor-examples.md +42 -0
  91. package/gsd-core/references/few-shot-examples/plan-checker.md +15 -15
  92. package/gsd-core/references/mvp-concepts.md +2 -2
  93. package/gsd-core/references/plan-checker-examples.md +41 -0
  94. package/gsd-core/references/planner-antipatterns.md +25 -0
  95. package/gsd-core/references/planner-chunked.md +5 -1
  96. package/gsd-core/references/planner-coupling.md +42 -0
  97. package/gsd-core/references/planner-quick-batch.md +71 -0
  98. package/gsd-core/references/planner-reviews.md +47 -0
  99. package/gsd-core/references/planner-revision.md +75 -2
  100. package/gsd-core/references/planning-config.md +2 -1
  101. package/gsd-core/references/response-language-directive.md +9 -0
  102. package/gsd-core/references/revision-loop.md +118 -11
  103. package/gsd-core/references/tdd.md +14 -9
  104. package/gsd-core/references/verifier-evidence-gate.md +160 -0
  105. package/gsd-core/templates/phase-prompt.md +4 -0
  106. package/gsd-core/templates/verification-report.md +5 -0
  107. package/gsd-core/workflows/add-backlog.md +2 -0
  108. package/gsd-core/workflows/add-phase.md +2 -0
  109. package/gsd-core/workflows/add-tests.md +1 -1
  110. package/gsd-core/workflows/add-todo.md +1 -1
  111. package/gsd-core/workflows/ai-integration-phase.md +1 -1
  112. package/gsd-core/workflows/analyze-dependencies.md +2 -0
  113. package/gsd-core/workflows/audit-fix.md +2 -0
  114. package/gsd-core/workflows/audit-milestone.md +2 -0
  115. package/gsd-core/workflows/audit-uat.md +2 -0
  116. package/gsd-core/workflows/autonomous.md +2 -0
  117. package/gsd-core/workflows/check-todos.md +1 -1
  118. package/gsd-core/workflows/cleanup.md +1 -1
  119. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +15 -13
  120. package/gsd-core/workflows/code-review-fix.md +2 -0
  121. package/gsd-core/workflows/code-review.md +73 -31
  122. package/gsd-core/workflows/complete-milestone.md +13 -4
  123. package/gsd-core/workflows/debug.md +1 -1
  124. package/gsd-core/workflows/diagnose-issues.md +5 -1
  125. package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -0
  126. package/gsd-core/workflows/discuss-phase/modes/all.md +2 -0
  127. package/gsd-core/workflows/discuss-phase/modes/analyze.md +2 -0
  128. package/gsd-core/workflows/discuss-phase/modes/auto.md +2 -0
  129. package/gsd-core/workflows/discuss-phase/modes/batch.md +2 -0
  130. package/gsd-core/workflows/discuss-phase/modes/chain.md +2 -0
  131. package/gsd-core/workflows/discuss-phase/modes/default.md +2 -0
  132. package/gsd-core/workflows/discuss-phase/modes/power.md +2 -0
  133. package/gsd-core/workflows/discuss-phase/modes/text.md +2 -0
  134. package/gsd-core/workflows/discuss-phase/templates/context.md +2 -0
  135. package/gsd-core/workflows/discuss-phase/templates/discussion-log.md +2 -0
  136. package/gsd-core/workflows/discuss-phase-assumptions.md +1 -1
  137. package/gsd-core/workflows/discuss-phase-power.md +2 -0
  138. package/gsd-core/workflows/discuss-phase.md +1 -1
  139. package/gsd-core/workflows/do.md +43 -13
  140. package/gsd-core/workflows/docs-update.md +1 -1
  141. package/gsd-core/workflows/edit-phase.md +2 -0
  142. package/gsd-core/workflows/eval-review.md +1 -1
  143. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +2 -0
  144. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +17 -1
  145. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +8 -2
  146. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -0
  147. package/gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md +25 -0
  148. package/gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md +2 -0
  149. package/gsd-core/workflows/execute-phase.md +32 -14
  150. package/gsd-core/workflows/execute-plan.md +8 -8
  151. package/gsd-core/workflows/explore.md +2 -0
  152. package/gsd-core/workflows/extract-learnings.md +2 -0
  153. package/gsd-core/workflows/fast.md +6 -0
  154. package/gsd-core/workflows/forensics.md +2 -0
  155. package/gsd-core/workflows/graduation.md +1 -1
  156. package/gsd-core/workflows/health.md +1 -1
  157. package/gsd-core/workflows/help/modes/brief.md +2 -0
  158. package/gsd-core/workflows/help/modes/default.md +2 -0
  159. package/gsd-core/workflows/help/modes/full.md +12 -0
  160. package/gsd-core/workflows/help/modes/topic.md +2 -0
  161. package/gsd-core/workflows/help.md +2 -0
  162. package/gsd-core/workflows/import.md +3 -3
  163. package/gsd-core/workflows/inbox.md +1 -1
  164. package/gsd-core/workflows/ingest-docs.md +1 -1
  165. package/gsd-core/workflows/insert-phase.md +2 -0
  166. package/gsd-core/workflows/list-phase-assumptions.md +2 -0
  167. package/gsd-core/workflows/list-seeds.md +2 -0
  168. package/gsd-core/workflows/list-workspaces.md +2 -0
  169. package/gsd-core/workflows/manager.md +3 -3
  170. package/gsd-core/workflows/map-codebase.md +2 -0
  171. package/gsd-core/workflows/milestone-summary.md +2 -0
  172. package/gsd-core/workflows/mvp-phase.md +1 -1
  173. package/gsd-core/workflows/new-milestone.md +1 -1
  174. package/gsd-core/workflows/new-project.md +5 -3
  175. package/gsd-core/workflows/new-workspace.md +1 -1
  176. package/gsd-core/workflows/next.md +2 -0
  177. package/gsd-core/workflows/node-repair.md +2 -0
  178. package/gsd-core/workflows/note.md +2 -0
  179. package/gsd-core/workflows/onboard.md +1 -1
  180. package/gsd-core/workflows/pause-work.md +19 -4
  181. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +100 -18
  182. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +2 -0
  183. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +9 -0
  184. package/gsd-core/workflows/plan-phase.md +130 -12
  185. package/gsd-core/workflows/plan-review-convergence.md +102 -10
  186. package/gsd-core/workflows/plant-seed.md +1 -1
  187. package/gsd-core/workflows/pr-branch.md +11 -3
  188. package/gsd-core/workflows/profile-user.md +1 -1
  189. package/gsd-core/workflows/progress/steps/forensic-audit.md +1 -1
  190. package/gsd-core/workflows/progress.md +25 -3
  191. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +37 -2
  192. package/gsd-core/workflows/quick/steps/research-phase.md +3 -3
  193. package/gsd-core/workflows/quick-batch/steps/batch-init.md +55 -0
  194. package/gsd-core/workflows/quick-batch/steps/completion.md +65 -0
  195. package/gsd-core/workflows/quick-batch/steps/merge-wave.md +100 -0
  196. package/gsd-core/workflows/quick-batch/steps/plan-checker-loop.md +147 -0
  197. package/gsd-core/workflows/quick-batch/steps/planner-wave.md +158 -0
  198. package/gsd-core/workflows/quick-batch/steps/research-phase.md +95 -0
  199. package/gsd-core/workflows/quick-batch/steps/resume-mode.md +49 -0
  200. package/gsd-core/workflows/quick-batch/steps/verification-wave.md +73 -0
  201. package/gsd-core/workflows/quick-batch/steps/worktree-dispatch.md +169 -0
  202. package/gsd-core/workflows/quick-batch.md +203 -0
  203. package/gsd-core/workflows/quick.md +13 -3
  204. package/gsd-core/workflows/reapply-patches.md +2 -0
  205. package/gsd-core/workflows/remove-phase.md +2 -0
  206. package/gsd-core/workflows/remove-workspace.md +1 -1
  207. package/gsd-core/workflows/resume-project.md +6 -2
  208. package/gsd-core/workflows/review.md +215 -10
  209. package/gsd-core/workflows/scan.md +2 -0
  210. package/gsd-core/workflows/section-manifest.json +12 -0
  211. package/gsd-core/workflows/secure-phase.md +1 -1
  212. package/gsd-core/workflows/session-report.md +2 -0
  213. package/gsd-core/workflows/settings-advanced.md +2 -0
  214. package/gsd-core/workflows/settings-integrations.md +9 -8
  215. package/gsd-core/workflows/settings.md +1 -1
  216. package/gsd-core/workflows/ship.md +10 -10
  217. package/gsd-core/workflows/sketch-wrap-up.md +2 -0
  218. package/gsd-core/workflows/sketch.md +1 -1
  219. package/gsd-core/workflows/smart-entry.md +1 -1
  220. package/gsd-core/workflows/spec-phase.md +24 -19
  221. package/gsd-core/workflows/spike-wrap-up.md +2 -0
  222. package/gsd-core/workflows/spike.md +1 -1
  223. package/gsd-core/workflows/stats.md +2 -0
  224. package/gsd-core/workflows/sync-skills.md +12 -4
  225. package/gsd-core/workflows/thread.md +2 -0
  226. package/gsd-core/workflows/transition.md +2 -0
  227. package/gsd-core/workflows/ui-phase.md +26 -5
  228. package/gsd-core/workflows/ui-review.md +1 -1
  229. package/gsd-core/workflows/ultraplan-phase.md +2 -0
  230. package/gsd-core/workflows/undo.md +1 -1
  231. package/gsd-core/workflows/update.md +41 -38
  232. package/gsd-core/workflows/validate-phase.md +1 -1
  233. package/gsd-core/workflows/verify-work.md +49 -3
  234. package/hooks/dist/gsd-check-update-worker.js +19 -2
  235. package/hooks/dist/gsd-context-monitor.js +283 -12
  236. package/hooks/dist/gsd-node-runner.sh +1 -0
  237. package/hooks/dist/gsd-prompt-guard.js +30 -5
  238. package/hooks/dist/gsd-read-guard.js +2 -0
  239. package/hooks/dist/gsd-read-injection-scanner.js +5 -5
  240. package/hooks/dist/gsd-secret-read-guard.js +1079 -0
  241. package/hooks/dist/gsd-statusline.js +7 -3
  242. package/hooks/dist/gsd-validate-commit.sh +444 -7
  243. package/hooks/dist/gsd-workflow-guard.js +2 -1
  244. package/hooks/dist/lib/git-cmd.js +210 -1
  245. package/hooks/dist/lib/injection-patterns.js +36 -6
  246. package/hooks/dist/managed-hooks-registry.cjs +1 -0
  247. package/hooks/gsd-check-update-worker.js +19 -2
  248. package/hooks/gsd-context-monitor.js +283 -12
  249. package/hooks/gsd-node-runner.sh +1 -0
  250. package/hooks/gsd-prompt-guard.js +30 -5
  251. package/hooks/gsd-read-guard.js +2 -0
  252. package/hooks/gsd-read-injection-scanner.js +5 -5
  253. package/hooks/gsd-secret-read-guard.js +1079 -0
  254. package/hooks/gsd-statusline.js +7 -3
  255. package/hooks/gsd-validate-commit.sh +444 -7
  256. package/hooks/gsd-workflow-guard.js +2 -1
  257. package/hooks/hooks.json +6 -0
  258. package/hooks/lib/git-cmd.js +210 -1
  259. package/hooks/lib/injection-patterns.js +36 -6
  260. package/hooks/managed-hooks-registry.cjs +1 -0
  261. package/package.json +5 -5
  262. package/scripts/build-hooks.js +11 -4
  263. package/scripts/ci-test-scope.cjs +7 -0
  264. package/scripts/docs-guard-registry.cjs +10 -0
  265. package/scripts/gen-loop-host-contract.cjs +67 -15
  266. package/scripts/lib/shellcheck-fetch.cjs +247 -0
  267. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -6
  268. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +1 -1
  269. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +1 -1
  270. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +5 -0
  271. package/scripts/lint-phase-enumeration-drift.cjs +24 -6
  272. package/scripts/lint-phase-id-drift.cjs +133 -8
  273. package/scripts/lint-portable-grep.cjs +176 -0
  274. package/scripts/lint-response-language-coverage.cjs +524 -0
  275. package/scripts/lint-test-file-count.allowlist.json +3 -1
  276. package/scripts/lint-workflow-shellcheck-baseline.json +1027 -0
  277. package/scripts/lint-workflow-shellcheck.cjs +614 -0
  278. package/scripts/npm-audit-baseline.cjs +376 -0
  279. package/scripts/prompt-injection-scan.sh +8 -0
  280. package/scripts/require-issue-link-policy.cjs +16 -1
  281. package/skills/gsd-execute-phase/SKILL.md +1 -1
  282. package/skills/gsd-ns-workflow/SKILL.md +1 -0
  283. package/skills/gsd-phase/SKILL.md +1 -1
  284. package/skills/gsd-quick-batch/SKILL.md +105 -0
  285. package/skills/gsd-surface/SKILL.md +18 -8
  286. package/vscode/package.json +1 -1
@@ -625,6 +625,11 @@ function cmdResolveExecution(cwd, agentType, raw, opts) {
625
625
  * applies here exactly as everywhere else: an unknown host, a missing axis, or the
626
626
  * `undocumented` sentinel all degrade to the safe floor rather than being trusted.
627
627
  * Never throws — a lookup failure yields `'none'`, which renders no argument.
628
+ *
629
+ * On the module's export surface for #4255: the reviewer-lane effort resolver renders a lane's own
630
+ * configured level through this same negotiation, so a lane can never emit an argument for a host
631
+ * whose negotiated surface does not accept one. One negotiation, both channels — a second copy in
632
+ * the lane path is exactly how the two would drift.
628
633
  */
629
634
  function effortSurfaceForHost(cwd, host) {
630
635
  void cwd;
@@ -1655,12 +1660,232 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
1655
1660
  // During a merge, git refuses partial commits — fall back to a bare commit.
1656
1661
  // --amend is left without a pathspec: amending with -- <paths> is a different
1657
1662
  // operation that rewrites the tip with only those paths.
1658
- if (explicitFiles && stagedPaths.length === 0 && !amend) {
1663
+ const mergeHeadProbe = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '-q', '--verify', 'MERGE_HEAD'], { cwd });
1664
+ const isMergeInProgress = mergeHeadProbe.exitCode === 0;
1665
+ // PROVENANCE FOR THIS WHOLE BLOCK: every behavioural claim below was DRIVEN
1666
+ // against git 2.54, not reasoned by analogy. Individual claims state what was
1667
+ // observed and omit the version; where a claim is version-SENSITIVE rather
1668
+ // than merely version-observed, it says so at the claim.
1669
+ //
1670
+ // #3776: git refuses a PARTIAL commit (`git commit -- <paths>`) while a merge
1671
+ // or a cherry-pick is in progress, so in those states the pathspec describes
1672
+ // nothing about what would actually land and the empty-diff decision below
1673
+ // must not be made from it. The three sequencer states do NOT agree:
1674
+ // MERGE_HEAD -> `fatal: cannot do a partial commit during a merge.`
1675
+ // CHERRY_PICK_HEAD -> `fatal: cannot do a partial commit during a cherry-pick.`
1676
+ // REVERT_HEAD -> permitted; behaves like an ordinary commit.
1677
+ // REVERT_HEAD is therefore deliberately absent: including it would suppress
1678
+ // this fix during a revert, reintroducing the very misreport it removes.
1679
+ // `canScope` below keeps its narrower merge-only test on purpose — widening it
1680
+ // would change pre-existing cherry-pick behaviour, which is outside this fix.
1681
+ // Only the scoped, non-amend call can return through the guard below, so the
1682
+ // cherry-pick probe and the guard's own probes are gated on that — an
1683
+ // unscoped commit or an --amend would otherwise pay for git invocations whose
1684
+ // answer it can never use. The MERGE_HEAD probe above predates this fix and
1685
+ // stays unconditional: `canScope` needs it on every path.
1686
+ // A non-zero exit from either sequencer probe means "not in that state" AND
1687
+ // "the probe never answered" — `execGit` surfaces a spawn timeout as
1688
+ // `exitCode: 1` (`_spawnResult`: `result.status ?? 1`), which is the exact
1689
+ // code `rev-parse --verify` returns for a ref that does not exist. Conflating
1690
+ // them is the one path in this fix that does NOT fail toward the old
1691
+ // behaviour: a timeout during a real merge would leave `partialCommitRefused`
1692
+ // false, the guard would decide `nothing_to_commit` from a pathspec git will
1693
+ // not honour, and the merge would be silently abandoned where it previously
1694
+ // reported a loud `commit_failed`. So an unanswered probe is treated as
1695
+ // "assume the partial commit would be refused" — the conservative reading,
1696
+ // which falls through to `git commit` and lets git speak for itself.
1697
+ //
1698
+ // This is deliberately routed into `partialCommitRefused` ONLY, never into
1699
+ // `isMergeInProgress`: that flag also feeds the pre-existing `canScope` below,
1700
+ // where a spurious timeout would convert a scoped commit into a bare one and
1701
+ // record the whole index instead of the named paths. Suppressing a misreport
1702
+ // must not be paid for by committing content the caller never named.
1703
+ const guardApplies = explicitFiles && !amend;
1704
+ const cherryPickProbe = guardApplies
1705
+ ? (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '-q', '--verify', 'CHERRY_PICK_HEAD'], { cwd })
1706
+ : null;
1707
+ const partialCommitRefused = isMergeInProgress
1708
+ || (0, shell_command_projection_cjs_1.isSpawnTimeout)(mergeHeadProbe)
1709
+ || (cherryPickProbe !== null
1710
+ && (cherryPickProbe.exitCode === 0 || (0, shell_command_projection_cjs_1.isSpawnTimeout)(cherryPickProbe)));
1711
+ // `stagedPaths` records paths whose `git add` exited 0 — that is "did
1712
+ // staging succeed", not "is there anything to commit". Staging an
1713
+ // already-committed, unmodified file succeeds while contributing no diff, so
1714
+ // `length === 0` is reachable only when EVERY named path was missing from
1715
+ // disk. For the ordinary empty-diff case control fell through to `git commit`,
1716
+ // and the only thing converting that back to `nothing_to_commit` was the
1717
+ // string match on git's output below — which a rejecting pre-commit hook
1718
+ // pre-empts, because git runs the hook before it decides there is nothing to
1719
+ // commit. The caller was then handed `commit_failed` carrying a gate message
1720
+ // about a commit that had nothing to gate. Ask git whether the named paths
1721
+ // actually differ instead. Three things about that probe are load-bearing:
1722
+ // - it compares the WORKING TREE to HEAD (`diff HEAD`), not the index
1723
+ // (`diff --cached`). `git commit -- <paths>` is a partial commit: it takes
1724
+ // the working-tree content of those paths and ignores what is staged. A
1725
+ // probe against the index therefore answers a different question than the
1726
+ // commit asks, and a working-tree write landing between the `git add`
1727
+ // above and this line — another process in a shared checkout — would make
1728
+ // the index say "empty" while the commit would still have recorded the new
1729
+ // content. Driven: `diff --cached` rc 0 and `diff HEAD` rc 1 on the same
1730
+ // path, with `git commit -- <path>` then committing it.
1731
+ // - the `length === 0` short-circuit keeps the all-missing-paths case exact.
1732
+ // Spreading an empty array yields a pathspec-less `diff`, which tests the
1733
+ // WHOLE tree — unrelated work elsewhere would then suppress the guard and
1734
+ // regress the skip-missing contract (#2014).
1735
+ // It is deliberately NOT gated on `partialCommitRefused`, and gating it
1736
+ // would be a REGRESSION rather than a hardening. With every named path
1737
+ // missing, `stagedPaths` is empty, so `canScope` is false and the
1738
+ // fall-through reaches a BARE `git commit` — which git PERMITS during a
1739
+ // merge, and which then CONCLUDES that merge: rc 0, a two-parent merge
1740
+ // commit recording the entire index, under a message naming a path that
1741
+ // does not exist, reported to the caller as `committed: true` (driven).
1742
+ // Today's answer writes nothing at all. That is the same trade the timeout
1743
+ // routing above already refuses — a misreport must not be paid for by
1744
+ // committing content the caller never named — which is why the sequencer
1745
+ // states gate the DIFF branch only. The behaviour is also PRE-EXISTING and
1746
+ // unchanged by this fix: before it the identical short-circuit ran ABOVE
1747
+ // the MERGE_HEAD probe, so it never consulted the sequencer either. The
1748
+ // residual it leaves — a merge held open behind a `nothing_to_commit`
1749
+ // report — is offered as a separate issue with the other three, not folded
1750
+ // in here. Both sequencer shapes are pinned in
1751
+ // tests/commit-files-pathspec.test.cjs.
1752
+ // - `!partialCommitRefused`: see above — deciding "nothing to commit" from a
1753
+ // pathspec git will not honour would abandon an in-progress merge, so those
1754
+ // states keep their pre-existing behaviour untouched.
1755
+ // - the probe is pinned against user configuration that would make `git diff`
1756
+ // answer a DIFFERENT question than `git commit -- <paths>` asks. `git diff`
1757
+ // is porcelain and honours settings the commit does not, so without these
1758
+ // flags a caller's config decides whether the guard fires. Each vector
1759
+ // below was driven with the paired `git commit -- <path>` confirmed to
1760
+ // record the change the probe reported as absent:
1761
+ // `diff.ignoreSubmodules=all` -> a gitlink bump is invisible to the probe
1762
+ // `.gitmodules` `ignore = all` -> the same, and it needs NO local config:
1763
+ // it is checked in, so it arrives with a
1764
+ // clone
1765
+ // `diff=<driver>` + `textconv` -> two different blobs converge to one
1766
+ // text, so the probe sees no change at
1767
+ // all; no submodule involved
1768
+ // `--ignore-submodules=dirty` rather than `=none`, because `dirty` is what
1769
+ // a partial commit of a submodule path actually means: it records the
1770
+ // GITLINK, and the gitlink moves only when the submodule's HEAD does. Under
1771
+ // `=none` a merely dirty submodule WORKTREE reports a difference the commit
1772
+ // would not record, sending an empty call back to `git commit` — the #3776
1773
+ // misreport, re-entered from the other side. `dirty` still overrides both
1774
+ // `diff.ignoreSubmodules` and a checked-in `.gitmodules` `ignore`, so the
1775
+ // gitlink vectors above stay closed (driven: rc 1 under every one of them).
1776
+ // `--no-ext-diff` is deliberately absent: `--quiet` short-circuits ahead of
1777
+ // an external diff driver, so an external `diff.<driver>.command` cannot
1778
+ // invert the probe (driven: rc 1 with and without the flag).
1779
+ // Any other non-zero exit from the probe (a genuine git error, or an unborn
1780
+ // HEAD) leaves the guard shut and falls through to the commit — failing toward
1781
+ // today's path rather than manufacturing a no-op.
1782
+ // THE ONE STATE WHERE `git diff` AND `git commit -- <paths>` GENUINELY DISAGREE.
1783
+ // `--assume-unchanged` tells git to skip the worktree stat for a path, so
1784
+ // `git add` stages nothing and BOTH diff forms report no difference — while
1785
+ // `git commit -- <path>` reads the working tree directly and records it
1786
+ // (driven: probe rc 0, commit rc 0, new content in the tree). Left
1787
+ // to the diff probe alone the guard reports `nothing_to_commit` about content
1788
+ // the caller explicitly named in `--files` and git would have written. #3776
1789
+ // is a purely diagnostic bug — nothing is corrupted and no wrong commit is
1790
+ // made — so suppressing its misreport must not be paid for by dropping named
1791
+ // content. The same rule the timeout routing already follows one block up.
1792
+ //
1793
+ // `git ls-files -v` is the discriminator for the STATE: it tags an
1794
+ // assume-unchanged path with a LOWERCASE letter (`h`), where
1795
+ // `--skip-worktree` is an uppercase `S` and never reaches THIS branch:
1796
+ // `git add` exits 1 under it, so a present-but-modified skip-worktree path
1797
+ // fails closed as `staging_failed` above the guard. (An ABSENT one is skipped
1798
+ // before `git add` runs at all per #2014, and is answered by the
1799
+ // `stagedPaths.length === 0` arm above — correctly, and exactly as it was
1800
+ // pre-fix. Both shapes are pinned.)
1801
+ //
1802
+ // Then ASK GIT, rather than reconstructing its answer. `git commit --dry-run`
1803
+ // is the same decision the real commit makes, and `--no-verify` is what keeps
1804
+ // it a DECISION rather than an execution. git 2.54 already declines to run
1805
+ // `pre-commit` on a dry run (driven: a rejecting one neither fires nor writes
1806
+ // its marker), which is the property that matters here, because a firing
1807
+ // `pre-commit` is the whole of #3776 — but that is an observed behaviour of
1808
+ // one version, and the failure it would produce on a version that differs is
1809
+ // SILENT. A `pre-commit` that fires and rejects exits 1, the same code git
1810
+ // returns for `nothing to record`, so the closure below would read it as a
1811
+ // CONFIRMED empty answer, drop the content the caller named, and report
1812
+ // `nothing_to_commit` — #3776's exact shape, in #3776's exact configuration.
1813
+ // `--no-verify` forecloses that structurally instead of resting on the
1814
+ // version, and is behaviour-neutral where the version already agrees (driven:
1815
+ // rc 0 would-record / rc 1 nothing, identical with and without it). This is
1816
+ // VERSION-SENSITIVE reasoning, hence stated at the claim per the provenance
1817
+ // note above.
1818
+ //
1819
+ // It is still NOT hook-free in general, and `--no-verify` does not widen that
1820
+ // claim: `post-index-change` fires on this call with or without the flag
1821
+ // (driven both ways), so a repo using that hook sees TWO extra invocations
1822
+ // for the probe — git fires it twice per `commit --dry-run`, and twice again
1823
+ // for the real commit (driven: 2/2/2 across flagged probe, unflagged probe
1824
+ // and real commit). Stated rather than claimed away; the narrower
1825
+ // claim is the true one. `--porcelain` keeps the output to a couple
1826
+ // of machine-readable lines instead of a full status listing — the rc is
1827
+ // identical either way (driven: 0 would-record / 1 nothing), but the plain
1828
+ // form prints every untracked path, which on a large tree is output this
1829
+ // probe has no use for and `execGit` would have to buffer. rc 0 means the
1830
+ // commit would record something, so the guard must stand aside.
1831
+ //
1832
+ // Reconstructing it was tried and is WRONG in three measured ways, all of
1833
+ // them silent drops of named content. Comparing `git hash-object` against
1834
+ // `HEAD:<path>` misses a mode-only change (`chmod +x` leaves the blob
1835
+ // identical while `git commit -- <path>` records `100755`); it cannot hash a
1836
+ // submodule path at all (`fatal: Unable to hash sub`, while the commit
1837
+ // advances the gitlink); and the path it needs must be parsed out of
1838
+ // `ls-files` output, which `core.quotePath` renders as `"caf\303\251.md"`
1839
+ // by default, so the probe reads a filename that does not exist. Asking git
1840
+ // needs no path parsed and no case enumerated.
1841
+ //
1842
+ // Scoped to this branch on purpose. The diff probe above answers the ordinary
1843
+ // case cheaply and is pinned against the configuration vectors below; the
1844
+ // dry run is the heavier, exact answer, and it runs only when an
1845
+ // assume-unchanged path is actually present.
1846
+ //
1847
+ // The `ls-files` read is an OPTIMISATION, never a gate — so an unreadable one
1848
+ // must not decide anything. It exists only to keep the dry run off the hot
1849
+ // path when no assume-unchanged entry is present; when it cannot answer, the
1850
+ // dry run simply runs, because the dry run needs nothing from it. Both
1851
+ // failing-closed (drop the content) and failing-open (re-enter #3776) are
1852
+ // wrong answers to a question we can just ask directly.
1853
+ const assumeUnchangedWouldRecord = () => {
1854
+ const listed = (0, shell_command_projection_cjs_1.execGit)(['ls-files', '-v', '--', ...stagedPaths], { cwd });
1855
+ // Only the TAG is read; the path is deliberately never parsed out — see the
1856
+ // `core.quotePath` note above, and the dry run below needs no path anyway.
1857
+ if (listed.exitCode === 0
1858
+ && !listed.stdout.split('\n').some((line) => /^[a-z] /.test(line)))
1859
+ return false;
1860
+ const dryRun = (0, shell_command_projection_cjs_1.execGit)(['commit', '--dry-run', '--porcelain', '--no-verify', '-m', sanitizedMessage, '--', ...stagedPaths], { cwd });
1861
+ // Only a CONFIRMED "nothing to record" closes the path: rc 1 from a git
1862
+ // that actually answered. This is the one probe in the guard whose rc 0
1863
+ // is the REASSURING answer, so it inverts the diff probe's safety: there
1864
+ // a timeout can only yield non-zero and reads as "not clean"; here
1865
+ // `execGit` collapses a spawn timeout (or any spawn error) to
1866
+ // `exitCode: 1` (`_spawnResult`: `result.status ?? 1`), byte-identical to
1867
+ // git's own "nothing to record" — and the guard then reports
1868
+ // `nothing_to_commit` about content it never asked git to write. Same
1869
+ // conflation the sequencer probes above defend against, same remedy: an
1870
+ // unanswered probe falls toward the commit, where git speaks for itself
1871
+ // (and a genuine error there is reported loudly, as it always was). rc 128
1872
+ // is likewise not an answer. Timeout kill of a dry run CAN leave a stale
1873
+ // `index.lock` behind (it refreshes the index); the real commit then
1874
+ // fails on it, loudly — never silently.
1875
+ if ((0, shell_command_projection_cjs_1.isSpawnTimeout)(dryRun) || dryRun.error !== null)
1876
+ return true;
1877
+ return dryRun.exitCode !== 1;
1878
+ };
1879
+ const nothingToCommit = guardApplies
1880
+ && (stagedPaths.length === 0
1881
+ || (!partialCommitRefused
1882
+ && (0, shell_command_projection_cjs_1.execGit)(['diff', '--quiet', '--ignore-submodules=dirty', '--no-textconv', 'HEAD', '--', ...stagedPaths], { cwd }).exitCode === 0
1883
+ && !assumeUnchangedWouldRecord()));
1884
+ if (nothingToCommit) {
1659
1885
  const result = { committed: false, hash: null, reason: 'nothing_to_commit' };
1660
1886
  output(result, raw, 'nothing');
1661
1887
  return;
1662
1888
  }
1663
- const isMergeInProgress = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '-q', '--verify', 'MERGE_HEAD'], { cwd }).exitCode === 0;
1664
1889
  const canScope = explicitFiles && stagedPaths.length > 0 && !amend
1665
1890
  && !isMergeInProgress;
1666
1891
  const commitArgs = amend
@@ -1671,10 +1896,45 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
1671
1896
  if (canScope) {
1672
1897
  commitArgs.push('--', ...stagedPaths);
1673
1898
  }
1899
+ // #3859 follow-up: on git 2.39.5 (confirmed on the CI Linux bench image,
1900
+ // ghcr.io/open-gsd/gsd-tester-linux:v1.8.0-node24; NOT reproducible on git
1901
+ // 2.50.1) `git commit` itself — not just `git diff` — consults
1902
+ // `diff.ignoreSubmodules` when deciding whether there is anything to
1903
+ // record. With a local `diff.ignoreSubmodules=all` and a submodule gitlink
1904
+ // genuinely bumped, that git version silently REFUSES the commit (prints a
1905
+ // `git status`-style "Changes to be committed" dump and exits 1, having
1906
+ // written nothing) even though the diff probe above (already pinned with
1907
+ // its own `--ignore-submodules=dirty`) correctly reported the change as
1908
+ // present. The result was misclassified as generic `commit_failed` because
1909
+ // git's refusal text does not contain "nothing to commit".
1910
+ // Originally scoped to `canScope` on the assumption that only a
1911
+ // PATHSPEC-LIMITED `git commit -- <paths>` exercises this git internal
1912
+ // path. That assumption was wrong: reproduced directly against the pinned
1913
+ // v1.8.0-node24 tester image, a bare WHOLE-INDEX `git commit -m ...` (no
1914
+ // pathspec at all) is refused identically when the only staged change is a
1915
+ // submodule gitlink and `diff.ignoreSubmodules=all` — git's "nothing to
1916
+ // commit" check is a real diff (HEAD vs. index) honouring
1917
+ // `diff.ignoreSubmodules` regardless of whether a pathspec narrows it.
1918
+ // `--amend` is the one shape confirmed NOT to hit this: it always
1919
+ // recreates the commit from the current index and never runs the
1920
+ // empty-diff refusal a plain `git commit` does, override or not. The
1921
+ // override is therefore applied unconditionally here (not gated on
1922
+ // `canScope`) — it is a documented no-op everywhere it is not needed
1923
+ // (dry-run, git 2.50.1, and `--amend` already behave this way with or
1924
+ // without it; see `#3859 follow-up (canScope gap)` regression tests).
1925
+ // The override rides in via `GIT_CONFIG_*` env vars rather than a `-c`
1926
+ // argv flag so `commitArgs[0]` stays `'commit'` — several #3859 regression
1927
+ // tests assert on the raw argv captured at the `execGit` seam (e.g.
1928
+ // `gitCalls.some((a) => a[0] === 'commit')`), and a leading `-c` would shift
1929
+ // every element and break that pinning. Same override the probe already
1930
+ // carries, so the two can never disagree again.
1931
+ const commitEnv = {
1932
+ GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'diff.ignoreSubmodules', GIT_CONFIG_VALUE_0: 'dirty',
1933
+ };
1674
1934
  // #3886: `git commit` runs pre-commit hooks (husky/lint-staged routinely
1675
1935
  // idles ~4s on Windows before any task) — 10s is too tight, and a timeout
1676
1936
  // kill is NOT an ordinary failure. Same band as the push call below.
1677
- const commitResult = (0, shell_command_projection_cjs_1.execGit)(commitArgs, { cwd, timeout: COMMIT_TIMEOUT_MS });
1937
+ const commitResult = (0, shell_command_projection_cjs_1.execGit)(commitArgs, { cwd, env: commitEnv, timeout: COMMIT_TIMEOUT_MS });
1678
1938
  if (commitResult.exitCode !== 0) {
1679
1939
  // #3886: a SIGTERM'd git commit is a timeout, not commit_failed — the
1680
1940
  // partial stderr it flushed (often incidental CRLF warnings) is noise,
@@ -1830,7 +2090,12 @@ function cmdCommitToSubrepo(cwd, message, files, raw) {
1830
2090
  const commitArgs = canScopeSub
1831
2091
  ? ['commit', '-m', message, '--', ...stagedRelPaths]
1832
2092
  : ['commit', '-m', message];
1833
- const commitResult = (0, shell_command_projection_cjs_1.execGit)(commitArgs, { cwd: repoCwd, timeout: COMMIT_TIMEOUT_MS });
2093
+ // #3859 follow-up fix as cmdCommit above (line ~2081) — git 2.39.5 needs
2094
+ // this override for pathspec-scoped AND whole-index commits alike.
2095
+ const commitEnvSub = {
2096
+ GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'diff.ignoreSubmodules', GIT_CONFIG_VALUE_0: 'dirty',
2097
+ };
2098
+ const commitResult = (0, shell_command_projection_cjs_1.execGit)(commitArgs, { cwd: repoCwd, timeout: COMMIT_TIMEOUT_MS, env: commitEnvSub });
1834
2099
  if (commitResult.exitCode !== 0) {
1835
2100
  if ((0, shell_command_projection_cjs_1.isSpawnTimeout)(commitResult)) {
1836
2101
  // #3886 (subrepo counterpart): timeout ≠ error; surface the stale-lock
@@ -1905,7 +2170,16 @@ function cmdPrSubrepo(cwd, repo, branch, commitMessage, raw) {
1905
2170
  }
1906
2171
  // 1. Collect changed files via porcelain status — explicit, never git add -A.
1907
2172
  // ?? (untracked) lines are excluded — only stage tracked modifications.
1908
- const statusResult = (0, shell_command_projection_cjs_1.execGit)(['-c', 'core.quotePath=false', 'status', '--porcelain'], { cwd: repoCwd });
2173
+ // #3859 follow-up: `git status --porcelain` honors `diff.ignoreSubmodules`
2174
+ // the same way the empty-diff probe fixed for cmdCommit did — under a local
2175
+ // `diff.ignoreSubmodules=all`, a genuinely bumped submodule gitlink is
2176
+ // invisible here too, so `changedFiles` comes back empty and the function
2177
+ // reports `nothing_to_commit` before ever reaching the (now-fixed) commit
2178
+ // call. `--ignore-submodules=dirty` pins this the same way, reported
2179
+ // verbatim: `git -C repo status --porcelain` (no flag) shows nothing for a
2180
+ // pure gitlink bump under `diff.ignoreSubmodules=all`, while
2181
+ // `--ignore-submodules=dirty` reports ` M nested` (reproduced directly).
2182
+ const statusResult = (0, shell_command_projection_cjs_1.execGit)(['-c', 'core.quotePath=false', 'status', '--porcelain', '--ignore-submodules=dirty'], { cwd: repoCwd });
1909
2183
  if (statusResult.exitCode !== 0) {
1910
2184
  error(`git status failed in ${repo}: ${statusResult.stderr}`);
1911
2185
  }
@@ -1974,7 +2248,12 @@ function cmdPrSubrepo(cwd, repo, branch, commitMessage, raw) {
1974
2248
  const commitArgs = canScopePr
1975
2249
  ? ['commit', '-m', commitMessage, '--', ...changedFiles]
1976
2250
  : ['commit', '-m', commitMessage];
1977
- const commitResult = (0, shell_command_projection_cjs_1.execGit)(commitArgs, { cwd: repoCwd, timeout: COMMIT_TIMEOUT_MS });
2251
+ // #3859 follow-up fix as cmdCommit above (line ~2081) — git 2.39.5 needs
2252
+ // this override for pathspec-scoped AND whole-index commits alike.
2253
+ const commitEnvPr = {
2254
+ GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'diff.ignoreSubmodules', GIT_CONFIG_VALUE_0: 'dirty',
2255
+ };
2256
+ const commitResult = (0, shell_command_projection_cjs_1.execGit)(commitArgs, { cwd: repoCwd, timeout: COMMIT_TIMEOUT_MS, env: commitEnvPr });
1978
2257
  if (commitResult.exitCode !== 0) {
1979
2258
  rollback();
1980
2259
  if ((0, shell_command_projection_cjs_1.isSpawnTimeout)(commitResult)) {
@@ -2375,7 +2654,34 @@ function cmdTodoMatchPhase(cwd, phase, raw) {
2375
2654
  matches.sort((a, b) => b.score - a.score);
2376
2655
  output({ phase, matches, todo_count: todos.length }, raw, undefined);
2377
2656
  }
2378
- function cmdTodoComplete(cwd, filename, raw) {
2657
+ // #4096: upsert completion keys INSIDE the leading frontmatter block. Never a
2658
+ // bare prefix line above the opening `---` (that displaces the fence to line 2
2659
+ // and breaks every fence-locating reader). A file with no well-formed block
2660
+ // (absent, or an unterminated opening fence) gains a complete block.
2661
+ function upsertTodoCompletionFields(content, today) {
2662
+ const lines = content.split('\n');
2663
+ const fields = [`completed: ${today}`, 'status: completed'];
2664
+ const hasOpeningFence = lines[0] !== undefined && lines[0].trim() === '---';
2665
+ const closeIdx = hasOpeningFence ? lines.findIndex((l, i) => i > 0 && l.trim() === '---') : -1;
2666
+ if (!hasOpeningFence || closeIdx === -1) {
2667
+ // No parseable frontmatter: wrap the whole content in a complete block
2668
+ // rather than prefixing bare keys (#4096 fix 2).
2669
+ return `---\n${fields.join('\n')}\n---\n\n${content}`;
2670
+ }
2671
+ const block = lines.slice(1, closeIdx);
2672
+ for (const field of fields) {
2673
+ const key = `${field.slice(0, field.indexOf(':'))}:`;
2674
+ const idx = block.findIndex(l => l.startsWith(key));
2675
+ if (idx === -1) {
2676
+ block.push(field);
2677
+ }
2678
+ else {
2679
+ block[idx] = field;
2680
+ }
2681
+ }
2682
+ return [...lines.slice(0, 1), ...block, ...lines.slice(closeIdx)].join('\n');
2683
+ }
2684
+ function cmdTodoComplete(cwd, filename, options, raw) {
2379
2685
  if (!filename) {
2380
2686
  error('filename required for todo complete');
2381
2687
  }
@@ -2385,13 +2691,30 @@ function cmdTodoComplete(cwd, filename, raw) {
2385
2691
  if (!node_fs_1.default.existsSync(sourcePath)) {
2386
2692
  error(`Todo not found: ${filename}`);
2387
2693
  }
2388
- // Ensure completed directory exists
2389
- (0, shell_command_projection_cjs_1.platformEnsureDir)(completedDir);
2390
- // Read, add completion timestamp, move
2391
- let content = node_fs_1.default.readFileSync(sourcePath, 'utf-8');
2694
+ const content = node_fs_1.default.readFileSync(sourcePath, 'utf-8');
2392
2695
  const today = clock_cjs_1.realClock.localToday();
2393
- content = `completed: ${today}\n` + content;
2394
- (0, shell_command_projection_cjs_1.platformWriteSync)(node_path_1.default.join(completedDir, filename), content);
2696
+ // #4096: --dry-run mirrors `milestone complete --dry-run` (#2118) — every
2697
+ // existence check above still runs, nothing below mutates, and the payload
2698
+ // is preview-shaped (`dry_run`/`would_*`), never `completed: true`.
2699
+ if (options.dryRun) {
2700
+ output({
2701
+ dry_run: true,
2702
+ would_complete: true,
2703
+ file: filename,
2704
+ date: today,
2705
+ would_move: {
2706
+ source: node_path_1.default.relative(cwd, sourcePath).split(node_path_1.default.sep).join('/'),
2707
+ target: node_path_1.default.relative(cwd, node_path_1.default.join(completedDir, filename)).split(node_path_1.default.sep).join('/'),
2708
+ },
2709
+ would_set: { completed: today, status: 'completed' },
2710
+ }, raw);
2711
+ return;
2712
+ }
2713
+ // Ensure completed directory exists (only on the real run — a dry run
2714
+ // creates nothing).
2715
+ (0, shell_command_projection_cjs_1.platformEnsureDir)(completedDir);
2716
+ const completedContent = upsertTodoCompletionFields(content, today);
2717
+ (0, shell_command_projection_cjs_1.platformWriteSync)(node_path_1.default.join(completedDir, filename), completedContent);
2395
2718
  node_fs_1.default.unlinkSync(sourcePath);
2396
2719
  output({ completed: true, file: filename, date: today }, raw, 'completed');
2397
2720
  }
@@ -2865,6 +3188,7 @@ function cmdCommitDocsGuardDisable(cwd, raw) {
2865
3188
  output({ disabled: true, action: 'removed', path: hookPath }, raw, 'disabled');
2866
3189
  }
2867
3190
  module.exports = {
3191
+ effortSurfaceForHost,
2868
3192
  groupFilesBySubrepo,
2869
3193
  determinePhaseStatus,
2870
3194
  foldPhaseStatus,
@@ -831,6 +831,7 @@ function loadConfigResolved(cwd, options = {}) {
831
831
  branching_strategy: get('branching_strategy', { section: 'git', field: 'branching_strategy' }) ?? defaults.branching_strategy,
832
832
  base_branch: get('base_branch', { section: 'git', field: 'base_branch' }),
833
833
  protected_branches: getNested('git', 'protected_branches'),
834
+ allow_default_branch_commits: getNested('git', 'allow_default_branch_commits'),
834
835
  phase_branch_template: get('phase_branch_template', { section: 'git', field: 'phase_branch_template' }) ?? defaults.phase_branch_template,
835
836
  milestone_branch_template: get('milestone_branch_template', { section: 'git', field: 'milestone_branch_template' }) ?? defaults.milestone_branch_template,
836
837
  quick_branch_template: get('quick_branch_template', { section: 'git', field: 'quick_branch_template' }) ?? defaults.quick_branch_template,
@@ -844,6 +845,7 @@ function loadConfigResolved(cwd, options = {}) {
844
845
  firecrawl: get('firecrawl') ?? defaults.firecrawl,
845
846
  exa_search: get('exa_search') ?? defaults.exa_search,
846
847
  mvp_mode: get('mvp_mode', { section: 'workflow', field: 'mvp_mode' }) ?? false,
848
+ tdd_mode: getNested('workflow', 'tdd_mode') ?? false,
847
849
  text_mode: get('text_mode', { section: 'workflow', field: 'text_mode' }) ?? defaults.text_mode,
848
850
  auto_advance: get('auto_advance', { section: 'workflow', field: 'auto_advance' }) ?? false,
849
851
  _auto_chain_active: get('_auto_chain_active', { section: 'workflow', field: '_auto_chain_active' }) ?? false,
@@ -856,6 +858,7 @@ function loadConfigResolved(cwd, options = {}) {
856
858
  project_code: get('project_code') ?? defaults.project_code,
857
859
  subagent_timeout: get('subagent_timeout', { section: 'workflow', field: 'subagent_timeout' }) ?? defaults.subagent_timeout,
858
860
  model_overrides: (parsed['model_overrides']) || null,
861
+ agent_tools: (parsed['agent_tools']) || null,
859
862
  models: (parsed['models']) || null,
860
863
  granularity: parsed['granularity'] !== undefined ? parsed['granularity'] : null,
861
864
  granularities: (parsed['granularities']) || null,
@@ -43,6 +43,11 @@ const phaseIdModule = require("./phase-id.cjs");
43
43
  const planningWorkspace = require("./planning-workspace.cjs");
44
44
  // eslint-disable-next-line @typescript-eslint/no-require-imports
45
45
  const shellCommandProjection = require("./shell-command-projection.cjs");
46
+ // planning-scope.cjs is a leaf module (no imports of its own — see its file
47
+ // header), so importing it here directly cannot introduce a cycle.
48
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
49
+ const planningScopeMod = require("./planning-scope.cjs");
50
+ const { SCOPE } = planningScopeMod;
46
51
  // ─── Line-ending normalization ─────────────────────────────────────────────────
47
52
  /**
48
53
  * Normalize every line ending in `content` to a bare `\n`, ONCE — the shared
@@ -271,16 +276,38 @@ function transliterateForSlug(text) {
271
276
  * Degrades on an unreadable directory instead of throwing: empty arrays,
272
277
  * every flag false, scope UNREADABLE (mirroring scanPhasePlans's own
273
278
  * degrade path).
279
+ *
280
+ * #4014 (epic #3473 B4-unreadable): this function's OWN readdirSync used to
281
+ * catch its own failure and then return `scope: scan.scope` — the scope of
282
+ * the UNRELATED, already-successful scanPhasePlans call — silently dropping
283
+ * that THIS read failed. The own-readdirSync is now routed through
284
+ * findContextMdIn's directory-string form (which never throws and reports
285
+ * its own SCOPE.UNREADABLE), and the two independently-scoped answers are
286
+ * combined into the worse of the two below — never letting a successful
287
+ * scan.scope mask this function's own failed read.
288
+ *
289
+ * The combination is written out as a two-value comparison rather than
290
+ * calling planning-snapshot.cts's `worstScope` directly: that module
291
+ * requires core-utils.cjs (for `findOrphanSummaries`) at its own top level,
292
+ * so importing it back here would create a tight, direct
293
+ * core-utils.cjs <-> planning-snapshot.cjs cycle — a materially different
294
+ * (and riskier) shape than this file's existing, documented lazy-access
295
+ * cyclic partners (planning-workspace.cjs, phase-id.cjs), which
296
+ * planning-snapshot.cts sits *above* in the dependency graph, not beside.
297
+ * `findContextMdIn`'s directory-string form can only ever report
298
+ * SCOPE.COMPLETE or SCOPE.UNREADABLE (a single readdirSync is binary — see
299
+ * its own doc comment), so per planning-snapshot.cts's SCOPE_SEVERITY
300
+ * ordering (UNREADABLE is maximal), `worstScope(scan.scope, ownScope)` is
301
+ * exactly `ownScope === UNREADABLE ? UNREADABLE : scan.scope` — the
302
+ * expression below is that identity, not a re-derivation of the ordering.
274
303
  */
275
304
  function getPhaseFileStats(phaseDir) {
276
305
  // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
277
306
  const scanPhasePlans = require('./plan-scan.cjs');
278
307
  const scan = scanPhasePlans(phaseDir);
279
- let files;
280
- try {
281
- files = node_fs_1.default.readdirSync(phaseDir);
282
- }
283
- catch {
308
+ const { files, scope: ownScope } = planningWorkspace.findContextMdIn(phaseDir);
309
+ const scope = ownScope === SCOPE.UNREADABLE ? SCOPE.UNREADABLE : scan.scope;
310
+ if (ownScope === SCOPE.UNREADABLE) {
284
311
  return {
285
312
  plans: scan.planFiles,
286
313
  summaries: scan.summaryFiles,
@@ -288,7 +315,7 @@ function getPhaseFileStats(phaseDir) {
288
315
  hasContext: false,
289
316
  hasVerification: false,
290
317
  hasReviews: false,
291
- scope: scan.scope,
318
+ scope,
292
319
  };
293
320
  }
294
321
  const scopedFiles = phaseIdModule.scopeToPhase(files, node_path_1.default.basename(phaseDir));
@@ -299,7 +326,7 @@ function getPhaseFileStats(phaseDir) {
299
326
  hasContext: planningWorkspace.findContextMdIn(scopedFiles) !== null,
300
327
  hasVerification: scopedFiles.some(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md'),
301
328
  hasReviews: scopedFiles.some(f => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md'),
302
- scope: scan.scope,
329
+ scope,
303
330
  };
304
331
  }
305
332
  /**