@opengsd/gsd-core 1.12.0 → 1.14.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 (455) 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-advisor-researcher.compact.md +85 -0
  5. package/agents/gsd-ai-researcher.compact.md +96 -0
  6. package/agents/gsd-assumptions-analyzer.compact.md +81 -0
  7. package/agents/gsd-code-fixer.compact.md +458 -0
  8. package/agents/gsd-code-fixer.md +5 -5
  9. package/agents/gsd-code-reviewer.compact.md +269 -0
  10. package/agents/gsd-code-reviewer.md +15 -3
  11. package/agents/gsd-codebase-mapper.compact.md +760 -0
  12. package/agents/gsd-debug-session-manager.compact.md +345 -0
  13. package/agents/gsd-doc-classifier.compact.md +192 -0
  14. package/agents/gsd-doc-synthesizer.compact.md +200 -0
  15. package/agents/gsd-doc-verifier.compact.md +143 -0
  16. package/agents/gsd-doc-writer.compact.md +440 -0
  17. package/agents/gsd-dom-verifier.compact.md +138 -0
  18. package/agents/gsd-domain-researcher.compact.md +141 -0
  19. package/agents/gsd-eval-auditor.compact.md +160 -0
  20. package/agents/gsd-eval-planner.compact.md +137 -0
  21. package/agents/gsd-executor.md +63 -35
  22. package/agents/gsd-framework-selector.compact.md +82 -0
  23. package/agents/gsd-integration-checker.compact.md +245 -0
  24. package/agents/gsd-intel-updater.compact.md +226 -0
  25. package/agents/gsd-mempalace-curator.compact.md +45 -0
  26. package/agents/gsd-nyquist-auditor.compact.md +179 -0
  27. package/agents/gsd-pattern-mapper.compact.md +275 -0
  28. package/agents/gsd-plan-checker.md +76 -57
  29. package/agents/gsd-planner.md +14 -0
  30. package/agents/gsd-project-researcher.compact.md +587 -0
  31. package/agents/gsd-research-synthesizer.compact.md +212 -0
  32. package/agents/gsd-roadmapper.compact.md +454 -0
  33. package/agents/gsd-roadmapper.md +13 -0
  34. package/agents/gsd-security-auditor.compact.md +162 -0
  35. package/agents/gsd-ui-auditor.compact.md +404 -0
  36. package/agents/gsd-ui-checker.compact.md +277 -0
  37. package/agents/gsd-ui-checker.md +19 -3
  38. package/agents/gsd-ui-researcher.compact.md +282 -0
  39. package/agents/gsd-ui-researcher.md +29 -0
  40. package/agents/gsd-user-profiler.compact.md +108 -0
  41. package/agents/gsd-verifier.md +23 -1
  42. package/bin/install.js +444 -134
  43. package/commands/gsd/cleanup.md +1 -0
  44. package/commands/gsd/code-review.md +2 -1
  45. package/commands/gsd/complete-milestone.md +1 -0
  46. package/commands/gsd/config.md +1 -0
  47. package/commands/gsd/debug.md +1 -0
  48. package/commands/gsd/execute-phase.md +1 -1
  49. package/commands/gsd/graphify.md +1 -0
  50. package/commands/gsd/health.md +1 -0
  51. package/commands/gsd/mempalace-capture.md +1 -0
  52. package/commands/gsd/mempalace-recall.md +1 -0
  53. package/commands/gsd/new-milestone.md +1 -0
  54. package/commands/gsd/new-project.md +1 -0
  55. package/commands/gsd/next.md +1 -0
  56. package/commands/gsd/ns-workflow.md +2 -1
  57. package/commands/gsd/pause-work.md +1 -0
  58. package/commands/gsd/phase.md +2 -1
  59. package/commands/gsd/pr-branch.md +1 -0
  60. package/commands/gsd/quick-batch.md +105 -0
  61. package/commands/gsd/resume-work.md +1 -0
  62. package/commands/gsd/review-backlog.md +1 -0
  63. package/commands/gsd/settings.md +2 -1
  64. package/commands/gsd/stats.md +1 -0
  65. package/commands/gsd/surface.md +18 -8
  66. package/commands/gsd/thread.md +1 -0
  67. package/commands/gsd/workspace.md +1 -0
  68. package/commands/gsd/workstreams.md +1 -0
  69. package/gsd-core/bin/check-latest-version.cjs +8 -3
  70. package/gsd-core/bin/gsd-tools.cjs +532 -174
  71. package/gsd-core/bin/lib/adr-parser.cjs +1 -1
  72. package/gsd-core/bin/lib/artifacts.cjs +2 -1
  73. package/gsd-core/bin/lib/audit.cjs +39 -22
  74. package/gsd-core/bin/lib/broken-windows.cjs +168 -49
  75. package/gsd-core/bin/lib/capability-activation.cjs +27 -0
  76. package/gsd-core/bin/lib/capability-lifecycle.cjs +10 -6
  77. package/gsd-core/bin/lib/capability-loader.cjs +135 -1
  78. package/gsd-core/bin/lib/capability-registry.cjs +528 -116
  79. package/gsd-core/bin/lib/capability-source.cjs +19 -2
  80. package/gsd-core/bin/lib/capability-state.cjs +7 -1
  81. package/gsd-core/bin/lib/capability-validator.cjs +134 -5
  82. package/gsd-core/bin/lib/capability-writer.cjs +14 -4
  83. package/gsd-core/bin/lib/check-command-router.cjs +198 -38
  84. package/gsd-core/bin/lib/claude-orchestration.cjs +10 -25
  85. package/gsd-core/bin/lib/clusters.cjs +1 -0
  86. package/gsd-core/bin/lib/code-review-depth.cjs +2 -2
  87. package/gsd-core/bin/lib/command-aliases.cjs +16 -0
  88. package/gsd-core/bin/lib/commands.cjs +981 -79
  89. package/gsd-core/bin/lib/config-loader.cjs +4 -0
  90. package/gsd-core/bin/lib/config.cjs +153 -38
  91. package/gsd-core/bin/lib/core-utils.cjs +34 -7
  92. package/gsd-core/bin/lib/coverage.cjs +1 -1
  93. package/gsd-core/bin/lib/decisions.cjs +343 -28
  94. package/gsd-core/bin/lib/edge-probe.cjs +14 -1
  95. package/gsd-core/bin/lib/external-descriptor-trust.cjs +29 -14
  96. package/gsd-core/bin/lib/file-overlap-partitioner.cjs +74 -0
  97. package/gsd-core/bin/lib/frontmatter.cjs +137 -23
  98. package/gsd-core/bin/lib/gap-checker.cjs +22 -13
  99. package/gsd-core/bin/lib/git-base-branch.cjs +10 -2
  100. package/gsd-core/bin/lib/gsd2-import.cjs +1 -2
  101. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +8 -2
  102. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +54 -11
  103. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +87 -23
  104. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +1 -1
  105. package/gsd-core/bin/lib/host-integration.cjs +57 -5
  106. package/gsd-core/bin/lib/init-command-router.cjs +14 -0
  107. package/gsd-core/bin/lib/init.cjs +539 -60
  108. package/gsd-core/bin/lib/install-engine.cjs +199 -14
  109. package/gsd-core/bin/lib/install-model-override-resolver.cjs +45 -0
  110. package/gsd-core/bin/lib/install-profiles.cjs +36 -14
  111. package/gsd-core/bin/lib/installer-migration-report.cjs +1 -0
  112. package/gsd-core/bin/lib/installer-migrations.cjs +33 -4
  113. package/gsd-core/bin/lib/io.cjs +35 -0
  114. package/gsd-core/bin/lib/loop-resolver.cjs +64 -39
  115. package/gsd-core/bin/lib/markdown-table.cjs +123 -0
  116. package/gsd-core/bin/lib/mcp-catalog.cjs +2 -2
  117. package/gsd-core/bin/lib/milestone.cjs +41 -10
  118. package/gsd-core/bin/lib/model-resolver.cjs +101 -10
  119. package/gsd-core/bin/lib/phase-command-router.cjs +20 -7
  120. package/gsd-core/bin/lib/phase-id.cjs +412 -31
  121. package/gsd-core/bin/lib/phase-lifecycle.cjs +61 -0
  122. package/gsd-core/bin/lib/phase.cjs +941 -98
  123. package/gsd-core/bin/lib/plan-document.cjs +10 -0
  124. package/gsd-core/bin/lib/planning-inspect.cjs +34 -18
  125. package/gsd-core/bin/lib/planning-snapshot.cjs +206 -30
  126. package/gsd-core/bin/lib/planning-workspace.cjs +153 -29
  127. package/gsd-core/bin/lib/pristine-baseline.cjs +182 -0
  128. package/gsd-core/bin/lib/prohibition-enforcement.cjs +91 -4
  129. package/gsd-core/bin/lib/quick-batch-command-router.cjs +285 -0
  130. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +250 -0
  131. package/gsd-core/bin/lib/quick-batch.cjs +840 -0
  132. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +61 -2
  133. package/gsd-core/bin/lib/research-store.cjs +11 -12
  134. package/gsd-core/bin/lib/review-lane-descriptor.cjs +53 -5
  135. package/gsd-core/bin/lib/review-lane-invocation.cjs +96 -1
  136. package/gsd-core/bin/lib/review-lane-runner.cjs +136 -10
  137. package/gsd-core/bin/lib/reviewer-step-dispatch.cjs +337 -0
  138. package/gsd-core/bin/lib/roadmap-parser.cjs +555 -41
  139. package/gsd-core/bin/lib/roadmap.cjs +292 -69
  140. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +260 -43
  141. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +28 -20
  142. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +294 -108
  143. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +408 -47
  144. package/gsd-core/bin/lib/security.cjs +126 -7
  145. package/gsd-core/bin/lib/shell-command-projection.cjs +4 -0
  146. package/gsd-core/bin/lib/smart-entry.cjs +7 -9
  147. package/gsd-core/bin/lib/state-document.cjs +159 -32
  148. package/gsd-core/bin/lib/state-md-schema.cjs +44 -27
  149. package/gsd-core/bin/lib/state-transition.cjs +465 -62
  150. package/gsd-core/bin/lib/state.cjs +906 -151
  151. package/gsd-core/bin/lib/surface.cjs +83 -10
  152. package/gsd-core/bin/lib/task-command-router.cjs +12 -6
  153. package/gsd-core/bin/lib/tdd-red-evidence.cjs +133 -0
  154. package/gsd-core/bin/lib/uat.cjs +1420 -516
  155. package/gsd-core/bin/lib/update-context.cjs +36 -26
  156. package/gsd-core/bin/lib/validate.cjs +230 -12
  157. package/gsd-core/bin/lib/vendor/js-yaml.cjs +11 -3
  158. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  159. package/gsd-core/bin/lib/verification.cjs +316 -23
  160. package/gsd-core/bin/lib/verify-command-grounding.cjs +1 -1
  161. package/gsd-core/bin/lib/verify-command-router.cjs +1 -0
  162. package/gsd-core/bin/lib/verify.cjs +531 -36
  163. package/gsd-core/bin/lib/workstream-inventory.cjs +21 -2
  164. package/gsd-core/bin/lib/worktree-safety.cjs +21 -7
  165. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  166. package/gsd-core/bin/shared/config-schema.manifest.json +13 -0
  167. package/gsd-core/bin/verify-reapply-patches.cjs +507 -81
  168. package/gsd-core/references/agent-contracts.md +3 -3
  169. package/gsd-core/references/compact-content-gate.md +66 -0
  170. package/gsd-core/references/edge-probe.md +17 -13
  171. package/gsd-core/references/execute-mvp-tdd.md +18 -16
  172. package/gsd-core/references/execute-phase-response-language.md +6 -0
  173. package/gsd-core/references/executor-examples.md +42 -0
  174. package/gsd-core/references/few-shot-examples/plan-checker.md +15 -15
  175. package/gsd-core/references/loop-hook-dispatch.md +18 -0
  176. package/gsd-core/references/model-profiles.md +12 -3
  177. package/gsd-core/references/mvp-concepts.md +2 -2
  178. package/gsd-core/references/plan-checker-examples.md +41 -0
  179. package/gsd-core/references/planner-antipatterns.md +25 -0
  180. package/gsd-core/references/planner-chunked.md +5 -1
  181. package/gsd-core/references/planner-coupling.md +42 -0
  182. package/gsd-core/references/planner-quick-batch.md +71 -0
  183. package/gsd-core/references/planner-reviews.md +47 -0
  184. package/gsd-core/references/planner-revision.md +75 -2
  185. package/gsd-core/references/planning-config.md +5 -1
  186. package/gsd-core/references/response-language-directive.md +9 -0
  187. package/gsd-core/references/revision-loop.md +118 -11
  188. package/gsd-core/references/tdd.md +17 -9
  189. package/gsd-core/references/thinking-models-planning.md +18 -2
  190. package/gsd-core/references/verification-patterns.md +17 -4
  191. package/gsd-core/references/verifier-evidence-gate.md +160 -0
  192. package/gsd-core/references/worktree-path-safety.md +112 -2
  193. package/gsd-core/templates/README.md +7 -1
  194. package/gsd-core/templates/phase-prompt.md +4 -0
  195. package/gsd-core/templates/state.md +6 -3
  196. package/gsd-core/templates/summary.compact.md +212 -0
  197. package/gsd-core/templates/user-setup.compact.md +199 -0
  198. package/gsd-core/templates/user-setup.md +0 -9
  199. package/gsd-core/templates/verification-report.md +5 -0
  200. package/gsd-core/workflows/add-backlog.md +2 -0
  201. package/gsd-core/workflows/add-phase.md +2 -0
  202. package/gsd-core/workflows/add-tests.md +1 -1
  203. package/gsd-core/workflows/add-todo.md +4 -3
  204. package/gsd-core/workflows/ai-integration-phase.md +1 -1
  205. package/gsd-core/workflows/analyze-dependencies.md +2 -0
  206. package/gsd-core/workflows/audit-fix.md +2 -0
  207. package/gsd-core/workflows/audit-milestone.md +2 -0
  208. package/gsd-core/workflows/audit-uat.md +2 -0
  209. package/gsd-core/workflows/autonomous.md +15 -10
  210. package/gsd-core/workflows/check-todos.md +5 -3
  211. package/gsd-core/workflows/cleanup.md +4 -2
  212. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +22 -13
  213. package/gsd-core/workflows/code-review-fix.md +5 -3
  214. package/gsd-core/workflows/code-review.md +211 -43
  215. package/gsd-core/workflows/complete-milestone/detail/elaboration.md +274 -0
  216. package/gsd-core/workflows/complete-milestone.md +40 -254
  217. package/gsd-core/workflows/debug.md +1 -1
  218. package/gsd-core/workflows/diagnose-issues.md +5 -1
  219. package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -0
  220. package/gsd-core/workflows/discuss-phase/modes/all.md +2 -0
  221. package/gsd-core/workflows/discuss-phase/modes/analyze.md +2 -0
  222. package/gsd-core/workflows/discuss-phase/modes/auto.md +2 -0
  223. package/gsd-core/workflows/discuss-phase/modes/batch.md +2 -0
  224. package/gsd-core/workflows/discuss-phase/modes/chain.md +2 -0
  225. package/gsd-core/workflows/discuss-phase/modes/default.md +2 -0
  226. package/gsd-core/workflows/discuss-phase/modes/power.md +2 -0
  227. package/gsd-core/workflows/discuss-phase/modes/text.md +2 -0
  228. package/gsd-core/workflows/discuss-phase/templates/context.md +2 -0
  229. package/gsd-core/workflows/discuss-phase/templates/discussion-log.md +2 -0
  230. package/gsd-core/workflows/discuss-phase-assumptions.md +1 -1
  231. package/gsd-core/workflows/discuss-phase-power.md +2 -0
  232. package/gsd-core/workflows/discuss-phase.md +1 -1
  233. package/gsd-core/workflows/do.md +43 -13
  234. package/gsd-core/workflows/docs-update/detail/elaboration.md +179 -0
  235. package/gsd-core/workflows/docs-update.md +15 -156
  236. package/gsd-core/workflows/edit-phase.md +2 -0
  237. package/gsd-core/workflows/eval-review.md +1 -1
  238. package/gsd-core/workflows/execute-phase/detail/elaboration.md +124 -0
  239. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +20 -3
  240. package/gsd-core/workflows/execute-phase/steps/completion-reconciliation.md +56 -0
  241. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +24 -3
  242. package/gsd-core/workflows/execute-phase/steps/executor-progress-policy.md +43 -0
  243. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +8 -2
  244. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -0
  245. package/gsd-core/workflows/execute-phase/steps/sequential-root-pin.md +35 -0
  246. package/gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md +25 -0
  247. package/gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md +2 -0
  248. package/gsd-core/workflows/execute-phase.md +78 -159
  249. package/gsd-core/workflows/execute-plan.md +28 -15
  250. package/gsd-core/workflows/explore.md +2 -0
  251. package/gsd-core/workflows/extract-learnings.md +2 -0
  252. package/gsd-core/workflows/fast.md +6 -0
  253. package/gsd-core/workflows/forensics.md +2 -0
  254. package/gsd-core/workflows/graduation.md +1 -1
  255. package/gsd-core/workflows/health.md +1 -1
  256. package/gsd-core/workflows/help/modes/brief.md +2 -0
  257. package/gsd-core/workflows/help/modes/default.md +2 -0
  258. package/gsd-core/workflows/help/modes/full.compact.md +398 -0
  259. package/gsd-core/workflows/help/modes/full.md +12 -0
  260. package/gsd-core/workflows/help/modes/topic.md +2 -0
  261. package/gsd-core/workflows/help.md +3 -1
  262. package/gsd-core/workflows/import.md +3 -3
  263. package/gsd-core/workflows/inbox.md +1 -1
  264. package/gsd-core/workflows/ingest-docs.md +1 -1
  265. package/gsd-core/workflows/insert-phase.md +2 -0
  266. package/gsd-core/workflows/list-phase-assumptions.md +2 -0
  267. package/gsd-core/workflows/list-seeds.md +2 -0
  268. package/gsd-core/workflows/list-workspaces.md +2 -0
  269. package/gsd-core/workflows/manager.md +3 -3
  270. package/gsd-core/workflows/map-codebase.md +52 -3
  271. package/gsd-core/workflows/milestone-summary.md +2 -0
  272. package/gsd-core/workflows/mvp-phase.md +1 -1
  273. package/gsd-core/workflows/new-milestone.md +55 -13
  274. package/gsd-core/workflows/new-project/detail/elaboration.md +216 -0
  275. package/gsd-core/workflows/new-project.md +37 -205
  276. package/gsd-core/workflows/new-workspace.md +1 -1
  277. package/gsd-core/workflows/next.md +2 -0
  278. package/gsd-core/workflows/node-repair.md +2 -0
  279. package/gsd-core/workflows/note.md +2 -0
  280. package/gsd-core/workflows/onboard.md +1 -1
  281. package/gsd-core/workflows/pause-work.md +19 -4
  282. package/gsd-core/workflows/plan-phase/detail/elaboration.md +209 -0
  283. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +100 -18
  284. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +2 -0
  285. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +9 -0
  286. package/gsd-core/workflows/plan-phase.md +144 -185
  287. package/gsd-core/workflows/plan-review-convergence.md +102 -10
  288. package/gsd-core/workflows/plant-seed.md +1 -1
  289. package/gsd-core/workflows/pr-branch.md +30 -10
  290. package/gsd-core/workflows/profile-user.md +1 -1
  291. package/gsd-core/workflows/progress/steps/forensic-audit.md +1 -1
  292. package/gsd-core/workflows/progress.md +25 -3
  293. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +37 -2
  294. package/gsd-core/workflows/quick/steps/research-phase.md +3 -3
  295. package/gsd-core/workflows/quick-batch/steps/batch-init.md +55 -0
  296. package/gsd-core/workflows/quick-batch/steps/completion.md +65 -0
  297. package/gsd-core/workflows/quick-batch/steps/merge-wave.md +100 -0
  298. package/gsd-core/workflows/quick-batch/steps/plan-checker-loop.md +147 -0
  299. package/gsd-core/workflows/quick-batch/steps/planner-wave.md +158 -0
  300. package/gsd-core/workflows/quick-batch/steps/research-phase.md +95 -0
  301. package/gsd-core/workflows/quick-batch/steps/resume-mode.md +49 -0
  302. package/gsd-core/workflows/quick-batch/steps/verification-wave.md +73 -0
  303. package/gsd-core/workflows/quick-batch/steps/worktree-dispatch.md +169 -0
  304. package/gsd-core/workflows/quick-batch.md +203 -0
  305. package/gsd-core/workflows/quick.md +21 -4
  306. package/gsd-core/workflows/reapply-patches.md +79 -3
  307. package/gsd-core/workflows/remove-phase.md +2 -0
  308. package/gsd-core/workflows/remove-workspace.md +1 -1
  309. package/gsd-core/workflows/resume-project.md +6 -2
  310. package/gsd-core/workflows/review.md +215 -10
  311. package/gsd-core/workflows/scan.md +2 -0
  312. package/gsd-core/workflows/section-manifest.json +12 -0
  313. package/gsd-core/workflows/secure-phase.md +1 -1
  314. package/gsd-core/workflows/session-report.md +2 -0
  315. package/gsd-core/workflows/settings-advanced.md +2 -0
  316. package/gsd-core/workflows/settings-integrations.md +9 -8
  317. package/gsd-core/workflows/settings.md +19 -6
  318. package/gsd-core/workflows/ship.md +10 -10
  319. package/gsd-core/workflows/sketch-wrap-up.md +2 -0
  320. package/gsd-core/workflows/sketch.md +1 -1
  321. package/gsd-core/workflows/smart-entry.md +1 -1
  322. package/gsd-core/workflows/spec-phase.md +24 -19
  323. package/gsd-core/workflows/spike-wrap-up.md +2 -0
  324. package/gsd-core/workflows/spike.md +1 -1
  325. package/gsd-core/workflows/stats.md +2 -0
  326. package/gsd-core/workflows/sync-skills.md +12 -4
  327. package/gsd-core/workflows/thread.md +2 -0
  328. package/gsd-core/workflows/transition.md +2 -0
  329. package/gsd-core/workflows/ui-phase.md +26 -5
  330. package/gsd-core/workflows/ui-review.md +1 -1
  331. package/gsd-core/workflows/ultraplan-phase.md +2 -0
  332. package/gsd-core/workflows/undo.md +1 -1
  333. package/gsd-core/workflows/update.md +48 -43
  334. package/gsd-core/workflows/validate-phase.md +1 -1
  335. package/gsd-core/workflows/verify-work/detail/elaboration.md +230 -0
  336. package/gsd-core/workflows/verify-work.md +68 -182
  337. package/hooks/dist/gsd-agent-isolation-guard.js +42 -16
  338. package/hooks/dist/gsd-check-update-worker.js +19 -2
  339. package/hooks/dist/gsd-context-monitor.js +371 -27
  340. package/hooks/dist/gsd-cursor-subagent-start.js +34 -14
  341. package/hooks/dist/gsd-node-runner.sh +1 -0
  342. package/hooks/dist/gsd-prompt-guard.js +30 -5
  343. package/hooks/dist/gsd-read-guard.js +2 -0
  344. package/hooks/dist/gsd-read-injection-scanner.js +5 -5
  345. package/hooks/dist/gsd-secret-read-guard.js +1105 -0
  346. package/hooks/dist/gsd-statusline.js +18 -10
  347. package/hooks/dist/gsd-validate-commit.sh +474 -7
  348. package/hooks/dist/gsd-workflow-guard.js +2 -1
  349. package/hooks/dist/gsd-worktree-path-guard.js +25 -14
  350. package/hooks/dist/gsd-write-guard.js +46 -1
  351. package/hooks/dist/lib/dispatch-identity.js +187 -0
  352. package/hooks/dist/lib/filename-classification.js +64 -0
  353. package/hooks/dist/lib/git-cmd.js +210 -1
  354. package/hooks/dist/lib/injection-patterns.js +36 -6
  355. package/hooks/dist/lib/isolation-deny-reason.js +53 -1
  356. package/hooks/dist/lib/isolation-sentinel.js +58 -19
  357. package/hooks/dist/managed-hooks-registry.cjs +1 -0
  358. package/hooks/gsd-agent-isolation-guard.js +42 -16
  359. package/hooks/gsd-check-update-worker.js +19 -2
  360. package/hooks/gsd-context-monitor.js +371 -27
  361. package/hooks/gsd-cursor-subagent-start.js +34 -14
  362. package/hooks/gsd-node-runner.sh +1 -0
  363. package/hooks/gsd-prompt-guard.js +30 -5
  364. package/hooks/gsd-read-guard.js +2 -0
  365. package/hooks/gsd-read-injection-scanner.js +5 -5
  366. package/hooks/gsd-secret-read-guard.js +1105 -0
  367. package/hooks/gsd-statusline.js +18 -10
  368. package/hooks/gsd-validate-commit.sh +474 -7
  369. package/hooks/gsd-workflow-guard.js +2 -1
  370. package/hooks/gsd-worktree-path-guard.js +25 -14
  371. package/hooks/gsd-write-guard.js +46 -1
  372. package/hooks/hooks.json +6 -0
  373. package/hooks/lib/dispatch-identity.js +187 -0
  374. package/hooks/lib/filename-classification.js +64 -0
  375. package/hooks/lib/git-cmd.js +210 -1
  376. package/hooks/lib/injection-patterns.js +36 -6
  377. package/hooks/lib/isolation-deny-reason.js +53 -1
  378. package/hooks/lib/isolation-sentinel.js +58 -19
  379. package/hooks/managed-hooks-registry.cjs +1 -0
  380. package/package.json +13 -9
  381. package/scripts/benchmark-compact-content-variants.cjs +298 -0
  382. package/scripts/benchmark-compact-content.cjs +368 -0
  383. package/scripts/build-hooks.js +11 -4
  384. package/scripts/check-contract-drift.cjs +4 -1
  385. package/scripts/check-env.cjs +36 -8
  386. package/scripts/check-glossary-refs.cjs +25 -21
  387. package/scripts/ci-next-health.cjs +271 -0
  388. package/scripts/ci-prepare-test-scope.cjs +7 -7
  389. package/scripts/ci-test-scope.cjs +133 -20
  390. package/scripts/ci-timeout-report.cjs +1 -1
  391. package/scripts/diff-touches-shipped-paths.cjs +1 -1
  392. package/scripts/docs-guard-registry.cjs +17 -2
  393. package/scripts/gen-adr-index.cjs +8 -2
  394. package/scripts/gen-inventory-manifest.cjs +12 -0
  395. package/scripts/gen-loop-host-contract.cjs +67 -15
  396. package/scripts/gen-platform-conformance-tier.cjs +557 -0
  397. package/scripts/lib/drift-scan.cjs +1 -1
  398. package/scripts/lib/macos-conformance-tier.generated.cjs +210 -0
  399. package/scripts/lib/npm-version-check-diagnosis.cjs +59 -0
  400. package/scripts/lib/platform-conformance-tier.generated.cjs +276 -0
  401. package/scripts/lib/shellcheck-fetch.cjs +247 -0
  402. package/scripts/lib/suite-detection.cjs +32 -0
  403. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -6
  404. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +1 -1
  405. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +1 -1
  406. package/scripts/lint-allowed-tools-parity.cjs +221 -0
  407. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +24 -2
  408. package/scripts/lint-phase-enumeration-drift.cjs +24 -6
  409. package/scripts/lint-phase-id-drift.cjs +465 -15
  410. package/scripts/lint-portable-grep.cjs +176 -0
  411. package/scripts/lint-response-language-coverage.cjs +530 -0
  412. package/scripts/lint-source-test-name-collision.cjs +1 -1
  413. package/scripts/lint-test-file-count.allowlist.json +4 -1
  414. package/scripts/lint-vendored-deps.cjs +128 -17
  415. package/scripts/lint-workflow-shellcheck-baseline.json +1112 -0
  416. package/scripts/lint-workflow-shellcheck.cjs +614 -0
  417. package/scripts/npm-audit-baseline.cjs +376 -0
  418. package/scripts/prompt-injection-scan.sh +22 -0
  419. package/scripts/require-issue-link-policy.cjs +16 -1
  420. package/scripts/workflow-size.cjs +139 -0
  421. package/skills/gsd-cleanup/SKILL.md +1 -0
  422. package/skills/gsd-code-review/SKILL.md +2 -1
  423. package/skills/gsd-complete-milestone/SKILL.md +1 -0
  424. package/skills/gsd-config/SKILL.md +1 -0
  425. package/skills/gsd-debug/SKILL.md +1 -0
  426. package/skills/gsd-execute-phase/SKILL.md +1 -1
  427. package/skills/gsd-graphify/SKILL.md +1 -0
  428. package/skills/gsd-health/SKILL.md +1 -0
  429. package/skills/gsd-mempalace-capture/SKILL.md +1 -0
  430. package/skills/gsd-mempalace-recall/SKILL.md +1 -0
  431. package/skills/gsd-new-milestone/SKILL.md +1 -0
  432. package/skills/gsd-new-project/SKILL.md +1 -0
  433. package/skills/gsd-next/SKILL.md +1 -0
  434. package/skills/gsd-ns-workflow/SKILL.md +1 -0
  435. package/skills/gsd-pause-work/SKILL.md +1 -0
  436. package/skills/gsd-phase/SKILL.md +2 -1
  437. package/skills/gsd-pr-branch/SKILL.md +1 -0
  438. package/skills/gsd-quick-batch/SKILL.md +105 -0
  439. package/skills/gsd-resume-work/SKILL.md +1 -0
  440. package/skills/gsd-review-backlog/SKILL.md +1 -0
  441. package/skills/gsd-settings/SKILL.md +2 -1
  442. package/skills/gsd-stats/SKILL.md +1 -0
  443. package/skills/gsd-surface/SKILL.md +18 -8
  444. package/skills/gsd-thread/SKILL.md +1 -0
  445. package/skills/gsd-workspace/SKILL.md +1 -0
  446. package/skills/gsd-workstreams/SKILL.md +1 -0
  447. package/vscode/package.json +1 -1
  448. package/gsd-core/templates/claude-md.md +0 -145
  449. package/gsd-core/templates/codebase/concerns.md +0 -310
  450. package/gsd-core/templates/codebase/conventions.md +0 -307
  451. package/gsd-core/templates/codebase/integrations.md +0 -280
  452. package/gsd-core/templates/codebase/structure.md +0 -285
  453. package/gsd-core/templates/codebase/testing.md +0 -480
  454. package/gsd-core/templates/debug-subagent-prompt.md +0 -91
  455. package/gsd-core/templates/discovery.md +0 -146
@@ -17,7 +17,11 @@ const pattern_cjs_1 = require("./pattern.cjs");
17
17
  const security_cjs_1 = require("./security.cjs");
18
18
  // eslint-disable-next-line @typescript-eslint/no-require-imports
19
19
  const ioMod = require("./io.cjs");
20
- const { output, error, ERROR_REASON } = ioMod;
20
+ const { output, ERROR_REASON } = ioMod;
21
+ // Explicitly annotated so TypeScript applies never-return control-flow narrowing.
22
+ // See the identical note in check-command-router.cts: a destructured const carries no
23
+ // type annotation, so TS will not narrow after `error(...)` without this.
24
+ const error = ioMod.error;
21
25
  // eslint-disable-next-line @typescript-eslint/no-require-imports
22
26
  const configLoaderMod = require("./config-loader.cjs");
23
27
  const { loadConfig, isGitIgnored } = configLoaderMod;
@@ -26,7 +30,7 @@ const coreUtilsMod = require("./core-utils.cjs");
26
30
  const { toPosixPath, generateSlugInternal, extractOneLinerFromBody } = coreUtilsMod;
27
31
  // eslint-disable-next-line @typescript-eslint/no-require-imports
28
32
  const phaseIdMod = require("./phase-id.cjs");
29
- const { normalizePhaseName, comparePhaseNum, extractPhaseToken, PHASE_NUMBER_TOKEN_SOURCE, isSentinelPhaseId } = phaseIdMod;
33
+ const { normalizePhaseName, comparePhaseNum, extractPhaseToken, PHASE_NUMBER_TOKEN_SOURCE, isSentinelPhaseId, renderPhaseBranchName } = phaseIdMod;
30
34
  // eslint-disable-next-line @typescript-eslint/no-require-imports
31
35
  const phaseLocatorMod = require("./phase-locator.cjs");
32
36
  const { getArchivedPhaseDirs, findPhaseInternal, listMilestonePhaseDirs } = phaseLocatorMod;
@@ -52,7 +56,7 @@ const codex_agent_toml_cjs_1 = require("./codex-agent-toml.cjs");
52
56
  const hostIntegrationMod = require("./host-integration.cjs");
53
57
  // eslint-disable-next-line @typescript-eslint/no-require-imports
54
58
  const planningWorkspace = require("./planning-workspace.cjs");
55
- const { planningDir, planningPaths } = planningWorkspace;
59
+ const { planningDir, planningPaths, todosDir } = planningWorkspace;
56
60
  // eslint-disable-next-line @typescript-eslint/no-require-imports
57
61
  const frontmatter = require("./frontmatter.cjs");
58
62
  const { extractFrontmatter, agentScalarNeedsDoubleQuoting, escapeDoubleQuotedScalar } = frontmatter;
@@ -186,7 +190,10 @@ function cmdCurrentTimestamp(format, raw) {
186
190
  output({ timestamp: result }, raw, result);
187
191
  }
188
192
  function cmdListTodos(cwd, area, raw) {
189
- const pendingDir = node_path_1.default.join(planningDir(cwd), 'todos', 'pending');
193
+ // #4256: todos are root-scoped shared state — resolve via todosDir(cwd),
194
+ // never planningDir(cwd) (workstream-scoped), or the listing goes empty
195
+ // under a workstream.
196
+ const pendingDir = node_path_1.default.join(todosDir(cwd), 'pending');
190
197
  let count = 0;
191
198
  const todos = [];
192
199
  try {
@@ -282,7 +289,7 @@ function cmdListSeeds(cwd, statusFilter, raw) {
282
289
  continue;
283
290
  let safeFilePath;
284
291
  try {
285
- safeFilePath = (0, security_cjs_1.requireSafePath)(node_path_1.default.join(seedsDir, entry.name), planDir, 'seed file', { allowAbsolute: true });
292
+ safeFilePath = (0, security_cjs_1.requireSafePath)(node_path_1.default.join(seedsDir, entry.name), planDir, 'seed file', security_cjs_1.PathAcceptance.AbsoluteInsideRoot);
286
293
  }
287
294
  catch {
288
295
  continue;
@@ -553,13 +560,11 @@ function cmdResolveExecution(cwd, agentType, raw, opts) {
553
560
  // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
554
561
  const { getGlobalConfigDir } = require('./runtime-homes.cjs');
555
562
  const agentsDirEff = node_path_1.default.join(getGlobalConfigDir(runtime), 'agents');
556
- const agentPath = node_path_1.default.join(agentsDirEff, `${agentType}.md`);
557
563
  // agentType is an unvalidated CLI positional: keep the read inside the
558
564
  // agents dir so `../../x` cannot point it elsewhere (defense in depth —
559
- // the reflected surface is only a frontmatter effort line).
560
- if (!node_path_1.default.resolve(agentPath).startsWith(node_path_1.default.resolve(agentsDirEff) + node_path_1.default.sep)) {
561
- throw new Error('agent path escapes the agents directory');
562
- }
565
+ // the reflected surface is only a frontmatter effort line). Untrusted
566
+ // input feeding a real read → realpath family (ADR-4650 decision 6).
567
+ const agentPath = (0, security_cjs_1.assertWithinRoot)(`${agentType}.md`, agentsDirEff, 'agent file');
563
568
  const agentContent = node_fs_1.default.readFileSync(agentPath, 'utf8');
564
569
  // eslint-disable-next-line local/no-unbounded-quantifier -- same lazy `*?` bounded by the `^---$/m` closing anchor as the sibling frontmatter regexes in this file
565
570
  const fmMatchEff = /^---\r?\n([\s\S]*?)^---\r?$/m.exec(agentContent);
@@ -625,6 +630,11 @@ function cmdResolveExecution(cwd, agentType, raw, opts) {
625
630
  * applies here exactly as everywhere else: an unknown host, a missing axis, or the
626
631
  * `undocumented` sentinel all degrade to the safe floor rather than being trusted.
627
632
  * Never throws — a lookup failure yields `'none'`, which renders no argument.
633
+ *
634
+ * On the module's export surface for #4255: the reviewer-lane effort resolver renders a lane's own
635
+ * configured level through this same negotiation, so a lane can never emit an argument for a host
636
+ * whose negotiated surface does not accept one. One negotiation, both channels — a second copy in
637
+ * the lane path is exactly how the two would drift.
628
638
  */
629
639
  function effortSurfaceForHost(cwd, host) {
630
640
  void cwd;
@@ -1352,19 +1362,33 @@ function detectPhaseNumberFromFiles(files) {
1352
1362
  if (!phaseDir)
1353
1363
  continue;
1354
1364
  const token = extractPhaseToken(phaseDir);
1355
- // extractPhaseToken falls back to returning dirName unchanged when no
1356
- // numeric token is found. normalizePhaseName is the canonical arbiter
1357
- // of "is this a real phase token": it strips the project-code prefix
1358
- // and returns a zero-padded numeric form for a genuine phase token, or
1359
- // the input unchanged otherwise. Accept the token only when it
1360
- // normalizes to a numeric phase form (the single-owner rule shared by
1361
- // every other phase-token reader — see #2528).
1362
- const normalized = normalizePhaseName(token);
1365
+ // normalizePhaseName is the canonical arbiter of "is this a real phase
1366
+ // token": it strips the project-code prefix and returns a zero-padded
1367
+ // numeric form for a genuine phase token, or the input unchanged
1368
+ // otherwise. Accept the token whenever it normalizes to a numeric
1369
+ // phase form (the single-owner rule shared by every other phase-token
1370
+ // reader — see #2528).
1371
+ //
1372
+ // #4126 fix: this used to also require `token !== phaseDir`, on the
1373
+ // assumption that extractPhaseToken returning its input unchanged
1374
+ // always means "no numeric token found" (its no-match fallback).
1375
+ // That assumption is false for a BARE phase directory with no slug
1376
+ // remainder (e.g. `.planning/phases/01/`): extractPhaseToken correctly
1377
+ // reads "01" as the token, which is simply identical to the directory
1378
+ // name in that case — not a fallback. The stale equality check
1379
+ // rejected every such directory, leaving `phaseNum` null and silently
1380
+ // skipping the whole phase-branch block below (undetected because
1381
+ // `phaseTokenShape.test(normalized)` already excludes genuine
1382
+ // non-phase fallbacks — e.g. `docs`, `CK-docs` — on its own, since
1383
+ // extractPhaseToken's real no-match fallback only fires for dirNames
1384
+ // that do not start with a digit or short letter+digit prefix, which
1385
+ // normalizePhaseName's leading-`\d+` requirement rejects regardless).
1363
1386
  // Built from the single-owner PHASE_NUMBER_TOKEN_SOURCE (the canonical
1364
1387
  // phase-number grammar — #2128 anti-divergence guard) so this read-side
1365
1388
  // acceptance check cannot drift from every other phase-token reader.
1389
+ const normalized = normalizePhaseName(token);
1366
1390
  const phaseTokenShape = new RegExp(`^${PHASE_NUMBER_TOKEN_SOURCE}$`, 'i');
1367
- if (token !== phaseDir && phaseTokenShape.test(normalized)) {
1391
+ if (phaseTokenShape.test(normalized)) {
1368
1392
  return token;
1369
1393
  }
1370
1394
  }
@@ -1437,7 +1461,296 @@ const COMMIT_DOCS_SKIP_REASON = {
1437
1461
  config: 'skipped_commit_docs_false',
1438
1462
  gitignore: 'skipped_gitignored',
1439
1463
  };
1440
- function cmdCommit(cwd, message, files, raw, amend, noVerify) {
1464
+ // #4208 review: the declared-removal staging lifted out of cmdCommit, which was
1465
+ // already a critical-risk hotspot before this flag existed. Pure motion -- the
1466
+ // classification, canonicalisation and entry recording below are unchanged; only
1467
+ // the two accumulators are local names that the caller merges. `removedPathspec`
1468
+ // is what joins the commit's pathspec; `removedEntries` is what the caller's
1469
+ // rollback and its no-change exits restore from.
1470
+ function stageDeclaredRemovals(cwd, removedDeclared) {
1471
+ const failures = [];
1472
+ const removedPathspec = [];
1473
+ // The empty blob under SHA-1 and SHA-256 object formats — intent-to-add's tell.
1474
+ const EMPTY_BLOBS = new Set(['e69de29bb2d1d6434b8b29ae775ad8c2e48c5391', '473a0f4c3be8a93681a267e3b1e9a7dcda1185436fe141f7749120a303721813']);
1475
+ // A PATH FROM THE INDEX IS NOT A PATHSPEC. `git rm`, `ls-files` and friends
1476
+ // parse their operands as pathspecs, so a tracked file literally named
1477
+ // `.planning/*.md` GLOBS when handed back to git: driven, `rm --cached` on it
1478
+ // also removed `peer.md` and `stays.md`, and only the declared entry was
1479
+ // recorded — so the rollback restored one of three and the other two rode out
1480
+ // as undisclosed staged deletions. The magic-prefix twin is quieter still: a
1481
+ // file named `:(literal)mine` has its prefix PARSED, so the rm matches nothing,
1482
+ // exits 0, and the entry silently survives a removal this call then claims.
1483
+ // `:(literal)` disables every other magic, including globbing, so the operand
1484
+ // means the file it names.
1485
+ const lit = (p) => `:(literal)${p}`;
1486
+ const notARemoval = (e) => {
1487
+ if (e.mode === '160000')
1488
+ return 'a submodule gitlink, not a file';
1489
+ if (e.tag === 'S')
1490
+ return 'skip-worktree (sparse-checkout): absent by checkout, not removed';
1491
+ if (e.tag === 'h')
1492
+ return 'assume-unchanged: git does not consult its worktree state';
1493
+ if (e.stage !== '0')
1494
+ return 'an unmerged index entry';
1495
+ if (e.tag !== 'H')
1496
+ return `index state '${e.tag}'`;
1497
+ return null;
1498
+ };
1499
+ const lstatState = (p) => {
1500
+ try {
1501
+ node_fs_1.default.lstatSync(p);
1502
+ return 'present';
1503
+ }
1504
+ catch (e) {
1505
+ const err = e;
1506
+ return err.code === 'ENOENT' || err.code === 'ENOTDIR' ? 'absent' : err;
1507
+ }
1508
+ };
1509
+ // `rev-parse -q --verify HEAD` exits 1 both for an unborn HEAD and for a
1510
+ // spawn timeout (`execGit` collapses one to `exitCode: 1`). Only a probe that
1511
+ // actually answered may downgrade the union to index-only; an unanswered one
1512
+ // fails closed, because silently dropping the HEAD half re-opens the
1513
+ // pre-staged-deletion omission this union exists to close.
1514
+ let headExists = false;
1515
+ let headProbeFailure = null;
1516
+ if (removedDeclared.length > 0) {
1517
+ const headProbe = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '-q', '--verify', 'HEAD'], { cwd });
1518
+ if (headProbe.exitCode === 0) {
1519
+ headExists = true;
1520
+ }
1521
+ else if ((0, shell_command_projection_cjs_1.isSpawnTimeout)(headProbe) || headProbe.error !== null) {
1522
+ headProbeFailure = { error: headProbe.stderr || headProbe.stdout || 'HEAD probe failed', timed_out: (0, shell_command_projection_cjs_1.isSpawnTimeout)(headProbe) };
1523
+ }
1524
+ }
1525
+ // Every index entry this call removes, recorded BEFORE the `rm --cached`
1526
+ // so the rollback below can put it back exactly — mode and blob — with
1527
+ // `update-index --cacheinfo`. `git reset -- <path>` cannot do that: it
1528
+ // restores from HEAD, which does not exist on an unborn branch (so a root
1529
+ // commit's failed call used to leave every earlier removal unstaged, in
1530
+ // violation of the only-what-THIS-call-staged invariant above) and which
1531
+ // is not what the index held when the caller had pre-staged a modified
1532
+ // blob at that path. Recording the entry answers both without putting the
1533
+ // path on the commit pathspec, where an unborn HEAD makes `git commit`
1534
+ // refuse it (driven; see the union note above).
1535
+ const removedEntries = [];
1536
+ for (const entry of removedDeclared) {
1537
+ if (headProbeFailure !== null) {
1538
+ failures.push({ file: entry, ...headProbeFailure });
1539
+ continue;
1540
+ }
1541
+ // `-v -s`: tag, mode, blob, stage and path per record — see notARemoval.
1542
+ // `lit` here too: the caller's declared entry is a PATH, not a glob —
1543
+ // that is `--files-removed`'s whole contract — and :(literal) still
1544
+ // resolves a directory to its descendants (driven), so the directory form
1545
+ // is unaffected while a file literally named `*.md` or `:(literal)x` means
1546
+ // itself.
1547
+ const listed = (0, shell_command_projection_cjs_1.execGit)(['ls-files', '-v', '-s', '-z', '--', lit(entry)], { cwd });
1548
+ if (listed.exitCode !== 0) {
1549
+ failures.push({
1550
+ file: entry,
1551
+ error: listed.stderr || listed.stdout,
1552
+ timed_out: (0, shell_command_projection_cjs_1.isSpawnTimeout)(listed),
1553
+ });
1554
+ continue;
1555
+ }
1556
+ const indexed = new Map();
1557
+ let unparseable = null;
1558
+ for (const rec of listed.stdout.split('\0').filter(Boolean)) {
1559
+ const m = /^(\S) (\d{6}) ([0-9a-f]+) ([0-3])\t([\s\S]+)$/.exec(rec);
1560
+ if (m === null) {
1561
+ unparseable = rec;
1562
+ break;
1563
+ }
1564
+ indexed.set(m[5], { tag: m[1], mode: m[2], sha: m[3], stage: m[4] });
1565
+ }
1566
+ if (unparseable !== null) {
1567
+ // A record this code cannot read is not a path it may remove.
1568
+ failures.push({ file: entry, error: `unparseable ls-files record: ${unparseable}`, timed_out: false });
1569
+ continue;
1570
+ }
1571
+ const tracked = new Set(indexed.keys());
1572
+ // Does the entry name THIS tracked path itself (the caller declared a
1573
+ // FILE removed) or a directory above it? Decided on RESOLVED paths, never
1574
+ // on the strings: `ls-files` prints cwd-relative paths, and a caller may
1575
+ // pass an absolute path, `./x`, a trailing slash, or run under `--cwd`,
1576
+ // any of which fails a string compare and would silently take the
1577
+ // directory polarity — a directly named gitlink then SKIPS instead of
1578
+ // refusing (found by the round's review, driven with an absolute path).
1579
+ const entryAbs = node_path_1.default.resolve(cwd, entry);
1580
+ const entryRel = node_path_1.default.relative(cwd, entryAbs).split(node_path_1.default.sep).join('/');
1581
+ // Canonical form: realpath of the longest EXISTING prefix, with the absent
1582
+ // tail re-appended. The declared path is usually absent (that is the
1583
+ // point), and `process.cwd()` returns the real path where the caller may
1584
+ // hold a symlinked spelling — macOS `/var` → `/private/var` is the live
1585
+ // instance (CI, this PR's own test) — so a resolve-only compare still
1586
+ // took the directory polarity there.
1587
+ const canon = (p) => {
1588
+ let cur = node_path_1.default.resolve(cwd, p);
1589
+ const tail = [];
1590
+ for (;;) {
1591
+ try {
1592
+ return node_path_1.default.join(node_fs_1.default.realpathSync.native(cur), ...tail);
1593
+ }
1594
+ catch { /* absent: climb */ }
1595
+ const parent = node_path_1.default.dirname(cur);
1596
+ if (parent === cur)
1597
+ return node_path_1.default.join(cur, ...tail);
1598
+ tail.unshift(node_path_1.default.basename(cur));
1599
+ cur = parent;
1600
+ }
1601
+ };
1602
+ const namesItself = (p) => p === entryRel || node_path_1.default.resolve(cwd, p) === entryAbs || canon(p) === canon(entry);
1603
+ const inHeadPaths = new Set();
1604
+ if (headExists) {
1605
+ const inHead = (0, shell_command_projection_cjs_1.execGit)(['ls-tree', '-r', '-z', '--name-only', 'HEAD', '--', lit(entry)], { cwd });
1606
+ if (inHead.exitCode !== 0) {
1607
+ failures.push({
1608
+ file: entry,
1609
+ error: inHead.stderr || inHead.stdout,
1610
+ timed_out: (0, shell_command_projection_cjs_1.isSpawnTimeout)(inHead),
1611
+ });
1612
+ continue;
1613
+ }
1614
+ for (const p of inHead.stdout.split('\0').filter(Boolean)) {
1615
+ tracked.add(p);
1616
+ inHeadPaths.add(p);
1617
+ }
1618
+ }
1619
+ if (tracked.size === 0)
1620
+ continue;
1621
+ const entryState = lstatState(node_path_1.default.resolve(cwd, entry));
1622
+ if (entryState !== 'present' && entryState !== 'absent') {
1623
+ failures.push({ file: entry, error: `lstat ${entryState.code ?? ''}: ${entryState.message}`, timed_out: false });
1624
+ continue;
1625
+ }
1626
+ let entryIsDirectory = false;
1627
+ if (entryState === 'present') {
1628
+ try {
1629
+ entryIsDirectory = node_fs_1.default.lstatSync(node_path_1.default.resolve(cwd, entry)).isDirectory();
1630
+ }
1631
+ catch { /* raced away: treat as a present non-directory below */ }
1632
+ }
1633
+ if (entryState === 'present' && !entryIsDirectory) {
1634
+ // A present non-directory entry (a file, or ANY symlink — a link to a
1635
+ // directory is still one tracked path) contradicts the declaration.
1636
+ failures.push({
1637
+ file: entry,
1638
+ error: `declared in --files-removed but still present on disk: ${entry}`,
1639
+ timed_out: false,
1640
+ });
1641
+ continue;
1642
+ }
1643
+ for (const trackedPath of tracked) {
1644
+ const indexEntry = indexed.get(trackedPath);
1645
+ let reason = indexEntry === undefined ? null : notARemoval(indexEntry);
1646
+ // Intent-to-add (`git add -N`) renders as a plain `H 100644 <empty
1647
+ // blob> 0` — the flag is not in the listing — yet nothing tracked exists
1648
+ // to remove, and a rollback via `--cacheinfo` cannot restore the flag.
1649
+ // It is the one state whose blob is the empty blob, whose path is not in
1650
+ // HEAD, and which `diff --cached` treats as absent from the index; an
1651
+ // ordinary staged empty file shows there as added. Three probes, on the
1652
+ // rare empty-blob path only.
1653
+ if (reason === null && indexEntry !== undefined && EMPTY_BLOBS.has(indexEntry.sha) && !inHeadPaths.has(trackedPath)) {
1654
+ const cached = (0, shell_command_projection_cjs_1.execGit)(['diff', '--cached', '--name-only', '-z', '--', lit(trackedPath)], { cwd });
1655
+ if (cached.exitCode === 0 && cached.stdout.split('\0').filter(Boolean).length === 0)
1656
+ reason = 'an intent-to-add entry (git add -N), not tracked content';
1657
+ }
1658
+ if (reason !== null) {
1659
+ if (namesItself(trackedPath)) {
1660
+ failures.push({
1661
+ file: entry,
1662
+ error: `declared in --files-removed but is ${reason}: ${trackedPath}`,
1663
+ timed_out: false,
1664
+ });
1665
+ }
1666
+ continue;
1667
+ }
1668
+ const state = lstatState(node_path_1.default.resolve(cwd, trackedPath));
1669
+ if (state === 'present')
1670
+ continue;
1671
+ if (state !== 'absent') {
1672
+ failures.push({ file: trackedPath, error: `lstat ${state.code ?? ''}: ${state.message}`, timed_out: false });
1673
+ continue;
1674
+ }
1675
+ // A HEAD-only path (the caller already `git rm`'d it) has no index entry
1676
+ // to record or restore; the `rm` below is then a no-op.
1677
+ // READ the entry before the mutation, RECORD it only after the mutation
1678
+ // SUCCEEDS. The read must precede (the rm is what destroys the mode/blob
1679
+ // the restore needs); the record must not, because `removedEntries` is
1680
+ // the set this call claims to have staged. Recording ahead of the rm made
1681
+ // a FAILED rm — a stale `index.lock` is the driven case — contribute an
1682
+ // entry the rollback then reported as "still staged in the index" when
1683
+ // nothing had been staged at all: a false disclosure, the mirror of the
1684
+ // silent one the disclosure was added to fix.
1685
+ const recordable = indexEntry !== undefined
1686
+ ? { path: trackedPath, mode: indexEntry.mode, sha: indexEntry.sha }
1687
+ : null;
1688
+ // `--ignore-unmatch` makes "no such index entry" a success, so a non-zero
1689
+ // exit is a real I/O failure — same reading as the default-mode branch.
1690
+ const rmResult = (0, shell_command_projection_cjs_1.execGit)(['rm', '--cached', '--ignore-unmatch', '--', lit(trackedPath)], { cwd });
1691
+ if (rmResult.exitCode === 0) {
1692
+ if (recordable !== null)
1693
+ removedEntries.push(recordable);
1694
+ // Re-check AFTER the index mutation. The absence test and the `rm` are
1695
+ // not atomic, and the scoped `git commit -- <paths>` below reads the
1696
+ // WORKTREE, so a path recreated in between would be committed as its
1697
+ // new content under a message that declared it removed. A reappearance
1698
+ // is a contradiction like any other: staging failure, and the rollback
1699
+ // restores the recorded entry. Narrows the window; does not close it.
1700
+ if (lstatState(node_path_1.default.resolve(cwd, trackedPath)) !== 'absent') {
1701
+ failures.push({
1702
+ file: trackedPath,
1703
+ error: `declared in --files-removed but reappeared on disk: ${trackedPath}`,
1704
+ timed_out: false,
1705
+ });
1706
+ continue;
1707
+ }
1708
+ // Unborn HEAD: nothing to delete FROM, so the path is unstaged only and
1709
+ // never joins the pathspec; its rollback is the recorded entry above.
1710
+ if (headExists)
1711
+ removedPathspec.push(trackedPath);
1712
+ }
1713
+ else {
1714
+ // A NON-ZERO rm is NOT proof the index is untouched. `execGit` collapses
1715
+ // a spawn timeout to a non-zero exit, and a killed `git rm` can already
1716
+ // have written the index — so keying the record on the exit code alone
1717
+ // drops a real mutation on the timeout path (driven: a post-index-change
1718
+ // hook that outlives the timeout leaves `D <path>` staged and reported
1719
+ // nowhere). The exit code answers "did the command succeed", never "did
1720
+ // the index change". ASK THE INDEX instead — three honest arms, and no
1721
+ // arm asserts a state it did not observe.
1722
+ // THE ORIGINAL FAILURE IS PUSHED FIRST. `failures[0]` sets the result's
1723
+ // `reason`, `file`, `error` and timeout classification, so appending the
1724
+ // probe's diagnostic ahead of it renamed the cause: a timed-out rm was
1725
+ // reported as a permission error and lost its `timed_out: true`.
1726
+ failures.push({
1727
+ file: trackedPath,
1728
+ error: rmResult.stderr || rmResult.stdout,
1729
+ timed_out: (0, shell_command_projection_cjs_1.isSpawnTimeout)(rmResult),
1730
+ });
1731
+ if (recordable !== null) {
1732
+ const after = (0, shell_command_projection_cjs_1.execGit)(['ls-files', '-s', '-z', '--', lit(trackedPath)], { cwd });
1733
+ if (after.exitCode !== 0) {
1734
+ // Could not determine. Say so; never silently assume either way.
1735
+ failures.push({
1736
+ file: trackedPath,
1737
+ error: `removal failed and the index state for this path could NOT be determined: ${after.stderr || after.stdout}`,
1738
+ timed_out: (0, shell_command_projection_cjs_1.isSpawnTimeout)(after),
1739
+ });
1740
+ }
1741
+ else if (after.stdout.replace(/\0/g, '').trim() === '') {
1742
+ // The entry is gone: the rm mutated the index before it failed, so
1743
+ // this call owns the removal and must restore/disclose it.
1744
+ removedEntries.push(recordable);
1745
+ }
1746
+ // else: the entry is still there — nothing was staged, nothing to undo.
1747
+ }
1748
+ }
1749
+ }
1750
+ }
1751
+ return { removedEntries, removedPathspec, failures };
1752
+ }
1753
+ function cmdCommit(cwd, message, files, raw, amend, noVerify, filesRemoved) {
1441
1754
  if (!message && !amend) {
1442
1755
  error('commit message required');
1443
1756
  }
@@ -1473,6 +1786,10 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
1473
1786
  const branchingStrategy = config['branching_strategy'];
1474
1787
  if (branchingStrategy && branchingStrategy !== 'none') {
1475
1788
  let branchName = null;
1789
+ // #4055: the phase directory (cwd-relative POSIX path from
1790
+ // findPhaseInternal) captured while resolving the phase identity — the
1791
+ // state-3 guard below needs it for the committed-history check.
1792
+ let phaseDirRelative = null;
1476
1793
  if (branchingStrategy === 'phase') {
1477
1794
  // Determine which phase we're committing for from the file paths.
1478
1795
  // #2539: the extraction is anchored to the directory SEGMENT immediately
@@ -1494,9 +1811,16 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
1494
1811
  if (phaseNum && !isSentinelPhaseId(phaseNum)) {
1495
1812
  const phaseInfo = findPhaseInternal(cwd, phaseNum);
1496
1813
  if (phaseInfo) {
1497
- branchName = config['phase_branch_template']
1498
- .replace('{phase}', normalizePhaseName(phaseInfo['phase_number']))
1499
- .replace('{slug}', phaseInfo['phase_slug'] || 'phase');
1814
+ // #4126: shared with init.cts's cmdInitExecutePhase branch_name field
1815
+ // via the one canonical renderer (src/phase-id.cts) so an undeliverable
1816
+ // phase_slug degrades identically at both call sites instead of each
1817
+ // independently substituting the literal word 'phase'.
1818
+ branchName = renderPhaseBranchName(config['phase_branch_template'], phaseInfo['phase_number'], phaseInfo['phase_slug']);
1819
+ // #4055: findPhaseInternal already returns the directory as a
1820
+ // cwd-relative POSIX path.
1821
+ const dir = phaseInfo['directory'];
1822
+ if (typeof dir === 'string' && dir !== '')
1823
+ phaseDirRelative = dir;
1500
1824
  }
1501
1825
  }
1502
1826
  }
@@ -1521,6 +1845,14 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
1521
1845
  }
1522
1846
  }
1523
1847
  if (branchName) {
1848
+ // #4055: state-3 discriminator for the create arm. `rev-parse --verify`
1849
+ // alone cannot distinguish "branch never existed" (create is the #1278
1850
+ // intent) from "branch existed, was merged, then deleted" (the phase is
1851
+ // over — recreating it hijacks the close-out commit onto a resurrected
1852
+ // ref, the #3079 bug #3363 reopened). Both extra conditions come from
1853
+ // the confirmed issue: the create arm may fire only for a phase whose
1854
+ // directory has NO committed history on the current line (a genuinely
1855
+ // new phase) while the caller sits on the resolved base branch.
1524
1856
  const currentBranch = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd });
1525
1857
  if (currentBranch.exitCode === 0 && currentBranch.stdout.trim() !== branchName) {
1526
1858
  // #2539/#3079/#3207: two cases the prior (#3079) code collapsed into one.
@@ -1535,18 +1867,63 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
1535
1867
  // EXISTING branch is never switched to (the else arm logs + commits in
1536
1868
  // place). The fresh create is logged so the first phase-scoped commit is
1537
1869
  // not silent about where the work is landing (#3207 AC3).
1870
+ // #4055: "brand-new" is now VERIFIED, not assumed — see the state-3
1871
+ // guard between the verify and the create below.
1538
1872
  const verify = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '--verify', `refs/heads/${branchName}`], { cwd });
1539
1873
  if (verify.exitCode !== 0) {
1540
- // Branch does not exist — CREATE AND SWITCH (the #1278 first-commit
1541
- // case). checkout -b cannot resurrect anything: the branch was just
1542
- // verified absent, so it is created fresh at HEAD.
1543
- const create = (0, shell_command_projection_cjs_1.execGit)(['checkout', '-b', branchName], { cwd });
1544
- if (create.exitCode === 0) {
1545
- process.stderr.write(`${branchingStrategy} branch "${branchName}" created; switched to it for this commit.\n`);
1874
+ // Branch does not exist — but absence alone cannot distinguish a
1875
+ // genuinely new phase from a merged-and-deleted one (#4055).
1876
+ let createBlockReason = null;
1877
+ if (branchingStrategy === 'phase' && phaseDirRelative) {
1878
+ // #4055 residual: searchPhaseInDir's #2237 fail-safe can return an
1879
+ // empty `directory` for ambiguous phase names (leaving
1880
+ // phaseDirRelative null) — there the history half is skipped and
1881
+ // only the base check below guards; shallow clones can also show
1882
+ // an empty probe for old merged phases (depth-sensitive).
1883
+ const history = (0, shell_command_projection_cjs_1.execGit)(['log', 'HEAD', '--oneline', '--', phaseDirRelative], { cwd });
1884
+ if (history.exitCode === 0 && history.stdout.trim() !== '') {
1885
+ createBlockReason =
1886
+ 'its phase directory already has committed history (the phase is resolved)';
1887
+ }
1888
+ }
1889
+ if (!createBlockReason) {
1890
+ // The base half of the guard applies to BOTH strategies (it does
1891
+ // not need a directory): a phase/milestone branch is created only
1892
+ // from the resolved base branch. NOTE the milestone arm keeps its
1893
+ // existence-only guard for the HISTORY half — a merged-and-deleted
1894
+ // milestone branch remains resurrectable by an on-base caller
1895
+ // until a milestone-directory derivation exists here (#4055
1896
+ // follow-up candidate).
1897
+ /* eslint-disable @typescript-eslint/no-require-imports */
1898
+ const gitBaseBranch = require('./git-base-branch.cjs');
1899
+ /* eslint-enable @typescript-eslint/no-require-imports */
1900
+ const resolvedBase = gitBaseBranch.resolveBaseBranch(cwd);
1901
+ if (resolvedBase && resolvedBase !== currentBranch.stdout.trim()) {
1902
+ createBlockReason =
1903
+ `the current branch "${currentBranch.stdout.trim()}" is not the ` +
1904
+ `resolved base branch "${resolvedBase}"`;
1905
+ }
1906
+ }
1907
+ if (createBlockReason === null) {
1908
+ // State 1 confirmed: brand-new phase, first phase-scoped commit
1909
+ // from the base branch. CREATE AND SWITCH (the #1278 first-commit
1910
+ // case). checkout -b cannot resurrect anything: the branch was
1911
+ // just verified absent, so it is created fresh at HEAD.
1912
+ const create = (0, shell_command_projection_cjs_1.execGit)(['checkout', '-b', branchName], { cwd });
1913
+ if (create.exitCode === 0) {
1914
+ process.stderr.write(`${branchingStrategy} branch "${branchName}" created; switched to it for this commit.\n`);
1915
+ }
1916
+ else {
1917
+ process.stderr.write(`Warning: could not create ${branchingStrategy} branch "${branchName}" ` +
1918
+ `(${create.stderr.trim()}); committing on the current branch "${currentBranch.stdout.trim()}".\n`);
1919
+ }
1546
1920
  }
1547
1921
  else {
1548
- process.stderr.write(`Warning: could not create ${branchingStrategy} branch "${branchName}" ` +
1549
- `(${create.stderr.trim()}); committing on the current branch "${currentBranch.stdout.trim()}".\n`);
1922
+ // State 3 (or a non-base caller): the phase is resolved — commit
1923
+ // in place, disclosed (#2539 AC2), never recreate the branch.
1924
+ process.stderr.write(`Warning: resolved ${branchingStrategy} branch "${branchName}" is absent and ` +
1925
+ `will not be recreated (${createBlockReason}); committing on the current ` +
1926
+ `branch "${currentBranch.stdout.trim()}" instead of recreating it.\n`);
1550
1927
  }
1551
1928
  }
1552
1929
  else {
@@ -1558,8 +1935,12 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
1558
1935
  }
1559
1936
  }
1560
1937
  // Stage files
1561
- const explicitFiles = files && files.length > 0;
1562
- const filesToStage = explicitFiles ? files : ['.planning/'];
1938
+ // #4208: `--files-removed` is a declared scope in its own right — a caller
1939
+ // that names only removals must not fall through to the unscoped
1940
+ // `.planning/` sweep, which would commit everything under it.
1941
+ const removedDeclared = filesRemoved ?? [];
1942
+ const explicitFiles = (files && files.length > 0) || removedDeclared.length > 0;
1943
+ const filesToStage = explicitFiles ? (files ?? []) : ['.planning/'];
1563
1944
  const stagedPaths = [];
1564
1945
  // #2608: a `git add` that fails must abort the commit, not be skipped.
1565
1946
  // #2523 stopped a failed path entering the commit pathspec, but skipping it
@@ -1569,11 +1950,27 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
1569
1950
  // a linked worktree, timeout) was discarded and the operator saw a downstream
1570
1951
  // pathspec error pointing at an innocent file.
1571
1952
  const stagingFailures = [];
1953
+ // #4454: explicit --files paths skipped because they were missing from disk
1954
+ // (the #2014 guard below). Tracked so the caller can tell a partial commit
1955
+ // from a complete one instead of an unqualified `committed: true`.
1956
+ const skippedFiles = [];
1572
1957
  // Paths already in the index BEFORE this call. On a staging failure the
1573
1958
  // rollback below unstages only what THIS call added — unstaging a path the
1574
1959
  // caller had staged themselves would destroy their work.
1575
- const preStaged = new Set((0, shell_command_projection_cjs_1.execGit)(['diff', '--cached', '--name-only'], { cwd })
1576
- .stdout.split('\n').map(s => s.trim()).filter(Boolean));
1960
+ // `-z`: without it `core.quotePath` renders a non-ASCII name as
1961
+ // `"caf\303\251.md"`, which never equals the raw path in `stagedPaths`, so
1962
+ // the rollback below would treat a caller-pre-staged `café.md` as this
1963
+ // call's own and unstage it (#4208 review, driven).
1964
+ // `--relative`: `diff --cached` prints REPO-relative paths whatever the cwd,
1965
+ // while `stagedPaths` holds the caller's own cwd-relative names. In a project
1966
+ // nested inside its repo (`<repo>/sub/.planning/...`) the two name spaces
1967
+ // never intersect, so `preStaged` matched NOTHING and the rollback unstaged
1968
+ // every path including the caller's own pre-staged work. Driven on a nested
1969
+ // fixture: a caller-staged deletion vanished from `diff --cached` after an
1970
+ // unrelated declaration failed. Pre-existing -- it governs the `--files` side
1971
+ // too -- and a no-op when the project IS the repo root.
1972
+ const preStaged = new Set((0, shell_command_projection_cjs_1.execGit)(['diff', '--cached', '--name-only', '-z', '--relative'], { cwd })
1973
+ .stdout.split('\0').filter(Boolean));
1577
1974
  for (const file of filesToStage) {
1578
1975
  const fullPath = node_path_1.default.resolve(cwd, file);
1579
1976
  if (!node_fs_1.default.existsSync(fullPath)) {
@@ -1581,6 +1978,9 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
1581
1978
  // Caller passed an explicit --files list: missing files are skipped.
1582
1979
  // Staging a deletion here would silently remove tracked planning files
1583
1980
  // (e.g. STATE.md, ROADMAP.md) when they are temporarily absent (#2014).
1981
+ // #4454: record what was skipped so the caller can tell a partial
1982
+ // commit from a complete one, instead of an unqualified success.
1983
+ skippedFiles.push(file);
1584
1984
  continue;
1585
1985
  }
1586
1986
  // Default mode (staging all of .planning/): stage the deletion so
@@ -1618,6 +2018,75 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
1618
2018
  }
1619
2019
  }
1620
2020
  }
2021
+ // #4208: caller-declared removals -- see stageDeclaredRemovals.
2022
+ const declaredRemovals = stageDeclaredRemovals(cwd, removedDeclared);
2023
+ const removedEntries = declaredRemovals.removedEntries;
2024
+ stagingFailures.push(...declaredRemovals.failures);
2025
+ stagedPaths.push(...declaredRemovals.removedPathspec);
2026
+ // A REMOVAL'S PATH IS A PATH DOWNSTREAM TOO. Literalising the staging alone
2027
+ // does not protect the COMMIT's own pathspec: with a tracked file literally
2028
+ // named `.planning/*.md` declared removed beside a MODIFIED `peer.md`, the
2029
+ // `git commit -- <paths>` below globs and commits `M peer.md` the caller
2030
+ // never declared — the sweep this flag exists to remove, arriving one step
2031
+ // later. Driven. Only the removal-derived entries are literalised: `--files`
2032
+ // entries keep whatever pathspec behaviour they have today, which is not this
2033
+ // change's to alter.
2034
+ const removalPathspecs = new Set(declaredRemovals.removedPathspec);
2035
+ const asPathspec = (p) => (removalPathspecs.has(p) ? `:(literal)${p}` : p);
2036
+ const restoreRemovedEntries = () => {
2037
+ if (removedEntries.length === 0)
2038
+ return 'restored';
2039
+ (0, shell_command_projection_cjs_1.execGit)(['update-index', '--add', ...removedEntries.flatMap(e => ['--cacheinfo', `${e.mode},${e.sha},${e.path}`])], { cwd });
2040
+ // VERIFY BY READING THE INDEX BACK, never by the exit code. `execGit`
2041
+ // collapses a spawn timeout to a non-zero exit, and a killed `update-index`
2042
+ // can already have written the index — so an exit code answers "did the
2043
+ // command succeed", never "is the entry back". Driven: a post-index-change
2044
+ // hook outliving the timeout made the restore report failure over an index
2045
+ // it had in fact restored, publishing a disclosure that was simply false.
2046
+ //
2047
+ // `-z` IS LOAD-BEARING, and its absence is the #2014-era defect this PR
2048
+ // already fixed once for `preStaged`: without it `core.quotePath` renders a
2049
+ // non-ASCII name as `"caf\303\251.md"`, which never equals the raw path, so
2050
+ // an exactly-restored `café.md` (and any name carrying a tab or a newline)
2051
+ // read as NOT restored. Driven on all three shapes.
2052
+ const back = (0, shell_command_projection_cjs_1.execGit)(['ls-files', '-s', '-z', '--', ...removedEntries.map(e => `:(literal)${e.path}`)], { cwd });
2053
+ if (back.exitCode !== 0)
2054
+ return 'unverified'; // no observation — never an assertion of failure
2055
+ // COMPARE THE WHOLE ENTRY, not just the path. `--cacheinfo` restores mode,
2056
+ // blob and stage; a path present at a DIFFERENT mode or blob is not the
2057
+ // entry this call removed. Driven: a hook that rewrote the restored entry
2058
+ // 100644 -> 100755 was reported as restored by a path-only test.
2059
+ const present = new Map();
2060
+ for (const rec of back.stdout.split('\0')) {
2061
+ if (rec === '')
2062
+ continue;
2063
+ const tab = rec.indexOf('\t');
2064
+ if (tab === -1)
2065
+ continue;
2066
+ present.set(rec.slice(tab + 1), rec.slice(0, tab));
2067
+ }
2068
+ const ok = removedEntries.every(e => present.get(e.path) === `${e.mode} ${e.sha} 0`);
2069
+ return ok ? 'restored' : 'not-restored';
2070
+ };
2071
+ // The no-change exits' shared arm: restore, and if the restore failed, say so
2072
+ // instead of claiming nothing changed. `staging_failed` is the honest reason —
2073
+ // the index carries a mutation this call made and could not undo.
2074
+ const removalsLeftStaged = (verdict) => ({
2075
+ committed: false,
2076
+ hash: null,
2077
+ reason: 'staging_failed',
2078
+ file: removedEntries[0]?.path ?? null,
2079
+ error: verdict === 'not-restored'
2080
+ ? `declared removal(s) staged but could not be restored after the commit recorded nothing: ${removedEntries.map(e => e.path).join(', ')}`
2081
+ : `declared removal(s) staged and the restore could NOT be VERIFIED after the commit recorded nothing: ${removedEntries.map(e => e.path).join(', ')}`,
2082
+ failures: removedEntries.map(e => ({
2083
+ file: e.path,
2084
+ error: verdict === 'not-restored'
2085
+ ? 'update-index --cacheinfo restore failed'
2086
+ : 'update-index --cacheinfo restore could not be verified — the index was not readable',
2087
+ timed_out: false,
2088
+ })),
2089
+ });
1621
2090
  // #2608: fail closed before `git commit` runs. Checked ahead of the
1622
2091
  // nothing_to_commit branch below so a run where EVERY path failed to stage
1623
2092
  // reports the staging cause rather than "nothing to commit", and ahead of the
@@ -1632,10 +2101,37 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
1632
2101
  // best-effort: if the index is unwritable — the very failure being reported
1633
2102
  // — the reset cannot succeed either, and the staging error is still what
1634
2103
  // gets returned.
1635
- const toUnstage = stagedPaths.filter(p => !preStaged.has(p));
2104
+ const removedPaths = new Set(removedEntries.map(e => e.path));
2105
+ const toUnstage = stagedPaths.filter(p => !preStaged.has(p) && !removedPaths.has(p));
1636
2106
  if (toUnstage.length > 0) {
1637
- (0, shell_command_projection_cjs_1.execGit)(['reset', '-q', '--', ...toUnstage], { cwd });
1638
- }
2107
+ // `asPathspec` here too. This reset is the LAST place a removal-derived
2108
+ // name reaches git as a pathspec, and it is the most damaging: driven,
2109
+ // a wildcard-named entry that slipped into `toUnstage` globbed and
2110
+ // unstaged the CALLER'S OWN pre-staged deletion and modification, then
2111
+ // reported only the contradiction that triggered the rollback.
2112
+ (0, shell_command_projection_cjs_1.execGit)(['reset', '-q', '--', ...toUnstage.map(asPathspec)], { cwd });
2113
+ }
2114
+ // Removals are restored from the recorded entries, never via `reset`
2115
+ // (no HEAD to reset to on an unborn branch; not the pre-staged blob when
2116
+ // the caller had one) — and unconditionally, since a removal this call
2117
+ // performed is this call's to undo whether or not the path was pre-staged.
2118
+ // DISCLOSE a failed restore here too. The earlier reading -- that this exit
2119
+ // is already reporting a failure, so the restore's result adds nothing --
2120
+ // is wrong, and the counterexample is the ordinary one: the reported
2121
+ // failure is usually a DIFFERENT cause (a contradictory declaration, a
2122
+ // reappeared path), so a caller reading `failures` sees only that cause
2123
+ // and learns nothing about the removal still sitting in its index. Append
2124
+ // rather than replace: the original failure is still the reason.
2125
+ const restoreVerdict = restoreRemovedEntries();
2126
+ const failures = restoreVerdict === 'restored'
2127
+ ? stagingFailures
2128
+ : [...stagingFailures, ...removedEntries.map(e => ({
2129
+ file: e.path,
2130
+ error: restoreVerdict === 'not-restored'
2131
+ ? 'staged removal could NOT be restored during rollback — it is still staged in the index'
2132
+ : 'staged removal was rolled back but the result could NOT be VERIFIED — the index was not readable',
2133
+ timed_out: false,
2134
+ }))];
1639
2135
  const first = stagingFailures[0];
1640
2136
  const result = {
1641
2137
  committed: false,
@@ -1643,7 +2139,7 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
1643
2139
  reason: first.timed_out ? 'staging_timeout' : 'staging_failed',
1644
2140
  file: first.file,
1645
2141
  error: first.error,
1646
- failures: stagingFailures,
2142
+ failures,
1647
2143
  };
1648
2144
  output(result, raw, 'failed');
1649
2145
  return;
@@ -1655,12 +2151,252 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
1655
2151
  // During a merge, git refuses partial commits — fall back to a bare commit.
1656
2152
  // --amend is left without a pathspec: amending with -- <paths> is a different
1657
2153
  // operation that rewrites the tip with only those paths.
1658
- if (explicitFiles && stagedPaths.length === 0 && !amend) {
1659
- const result = { committed: false, hash: null, reason: 'nothing_to_commit' };
2154
+ const mergeHeadProbe = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '-q', '--verify', 'MERGE_HEAD'], { cwd });
2155
+ const isMergeInProgress = mergeHeadProbe.exitCode === 0;
2156
+ // PROVENANCE FOR THIS WHOLE BLOCK: every behavioural claim below was DRIVEN
2157
+ // against git 2.54, not reasoned by analogy. Individual claims state what was
2158
+ // observed and omit the version; where a claim is version-SENSITIVE rather
2159
+ // than merely version-observed, it says so at the claim.
2160
+ //
2161
+ // #3776: git refuses a PARTIAL commit (`git commit -- <paths>`) while a merge
2162
+ // or a cherry-pick is in progress, so in those states the pathspec describes
2163
+ // nothing about what would actually land and the empty-diff decision below
2164
+ // must not be made from it. The three sequencer states do NOT agree:
2165
+ // MERGE_HEAD -> `fatal: cannot do a partial commit during a merge.`
2166
+ // CHERRY_PICK_HEAD -> `fatal: cannot do a partial commit during a cherry-pick.`
2167
+ // REVERT_HEAD -> permitted; behaves like an ordinary commit.
2168
+ // REVERT_HEAD is therefore deliberately absent: including it would suppress
2169
+ // this fix during a revert, reintroducing the very misreport it removes.
2170
+ // `canScope` below keeps its narrower merge-only test on purpose — widening it
2171
+ // would change pre-existing cherry-pick behaviour, which is outside this fix.
2172
+ // Only the scoped, non-amend call can return through the guard below, so the
2173
+ // cherry-pick probe and the guard's own probes are gated on that — an
2174
+ // unscoped commit or an --amend would otherwise pay for git invocations whose
2175
+ // answer it can never use. The MERGE_HEAD probe above predates this fix and
2176
+ // stays unconditional: `canScope` needs it on every path.
2177
+ // A non-zero exit from either sequencer probe means "not in that state" AND
2178
+ // "the probe never answered" — `execGit` surfaces a spawn timeout as
2179
+ // `exitCode: 1` (`_spawnResult`: `result.status ?? 1`), which is the exact
2180
+ // code `rev-parse --verify` returns for a ref that does not exist. Conflating
2181
+ // them is the one path in this fix that does NOT fail toward the old
2182
+ // behaviour: a timeout during a real merge would leave `partialCommitRefused`
2183
+ // false, the guard would decide `nothing_to_commit` from a pathspec git will
2184
+ // not honour, and the merge would be silently abandoned where it previously
2185
+ // reported a loud `commit_failed`. So an unanswered probe is treated as
2186
+ // "assume the partial commit would be refused" — the conservative reading,
2187
+ // which falls through to `git commit` and lets git speak for itself.
2188
+ //
2189
+ // This is deliberately routed into `partialCommitRefused` ONLY, never into
2190
+ // `isMergeInProgress`: that flag also feeds the pre-existing `canScope` below,
2191
+ // where a spurious timeout would convert a scoped commit into a bare one and
2192
+ // record the whole index instead of the named paths. Suppressing a misreport
2193
+ // must not be paid for by committing content the caller never named.
2194
+ const guardApplies = explicitFiles && !amend;
2195
+ const cherryPickProbe = guardApplies
2196
+ ? (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '-q', '--verify', 'CHERRY_PICK_HEAD'], { cwd })
2197
+ : null;
2198
+ const partialCommitRefused = isMergeInProgress
2199
+ || (0, shell_command_projection_cjs_1.isSpawnTimeout)(mergeHeadProbe)
2200
+ || (cherryPickProbe !== null
2201
+ && (cherryPickProbe.exitCode === 0 || (0, shell_command_projection_cjs_1.isSpawnTimeout)(cherryPickProbe)));
2202
+ // `stagedPaths` records paths whose `git add` exited 0 — that is "did
2203
+ // staging succeed", not "is there anything to commit". Staging an
2204
+ // already-committed, unmodified file succeeds while contributing no diff, so
2205
+ // `length === 0` is reachable only when EVERY named path was missing from
2206
+ // disk. For the ordinary empty-diff case control fell through to `git commit`,
2207
+ // and the only thing converting that back to `nothing_to_commit` was the
2208
+ // string match on git's output below — which a rejecting pre-commit hook
2209
+ // pre-empts, because git runs the hook before it decides there is nothing to
2210
+ // commit. The caller was then handed `commit_failed` carrying a gate message
2211
+ // about a commit that had nothing to gate. Ask git whether the named paths
2212
+ // actually differ instead. Three things about that probe are load-bearing:
2213
+ // - it compares the WORKING TREE to HEAD (`diff HEAD`), not the index
2214
+ // (`diff --cached`). `git commit -- <paths>` is a partial commit: it takes
2215
+ // the working-tree content of those paths and ignores what is staged. A
2216
+ // probe against the index therefore answers a different question than the
2217
+ // commit asks, and a working-tree write landing between the `git add`
2218
+ // above and this line — another process in a shared checkout — would make
2219
+ // the index say "empty" while the commit would still have recorded the new
2220
+ // content. Driven: `diff --cached` rc 0 and `diff HEAD` rc 1 on the same
2221
+ // path, with `git commit -- <path>` then committing it.
2222
+ // - the `length === 0` short-circuit keeps the all-missing-paths case exact.
2223
+ // Spreading an empty array yields a pathspec-less `diff`, which tests the
2224
+ // WHOLE tree — unrelated work elsewhere would then suppress the guard and
2225
+ // regress the skip-missing contract (#2014).
2226
+ // It is deliberately NOT gated on `partialCommitRefused`, and gating it
2227
+ // would be a REGRESSION rather than a hardening. With every named path
2228
+ // missing, `stagedPaths` is empty, so `canScope` is false and the
2229
+ // fall-through reaches a BARE `git commit` — which git PERMITS during a
2230
+ // merge, and which then CONCLUDES that merge: rc 0, a two-parent merge
2231
+ // commit recording the entire index, under a message naming a path that
2232
+ // does not exist, reported to the caller as `committed: true` (driven).
2233
+ // Today's answer writes nothing at all. That is the same trade the timeout
2234
+ // routing above already refuses — a misreport must not be paid for by
2235
+ // committing content the caller never named — which is why the sequencer
2236
+ // states gate the DIFF branch only. The behaviour is also PRE-EXISTING and
2237
+ // unchanged by this fix: before it the identical short-circuit ran ABOVE
2238
+ // the MERGE_HEAD probe, so it never consulted the sequencer either. The
2239
+ // residual it leaves — a merge held open behind a `nothing_to_commit`
2240
+ // report — is offered as a separate issue with the other three, not folded
2241
+ // in here. Both sequencer shapes are pinned in
2242
+ // tests/commit-files-pathspec.test.cjs.
2243
+ // - `!partialCommitRefused`: see above — deciding "nothing to commit" from a
2244
+ // pathspec git will not honour would abandon an in-progress merge, so those
2245
+ // states keep their pre-existing behaviour untouched.
2246
+ // - the probe is pinned against user configuration that would make `git diff`
2247
+ // answer a DIFFERENT question than `git commit -- <paths>` asks. `git diff`
2248
+ // is porcelain and honours settings the commit does not, so without these
2249
+ // flags a caller's config decides whether the guard fires. Each vector
2250
+ // below was driven with the paired `git commit -- <path>` confirmed to
2251
+ // record the change the probe reported as absent:
2252
+ // `diff.ignoreSubmodules=all` -> a gitlink bump is invisible to the probe
2253
+ // `.gitmodules` `ignore = all` -> the same, and it needs NO local config:
2254
+ // it is checked in, so it arrives with a
2255
+ // clone
2256
+ // `diff=<driver>` + `textconv` -> two different blobs converge to one
2257
+ // text, so the probe sees no change at
2258
+ // all; no submodule involved
2259
+ // `--ignore-submodules=dirty` rather than `=none`, because `dirty` is what
2260
+ // a partial commit of a submodule path actually means: it records the
2261
+ // GITLINK, and the gitlink moves only when the submodule's HEAD does. Under
2262
+ // `=none` a merely dirty submodule WORKTREE reports a difference the commit
2263
+ // would not record, sending an empty call back to `git commit` — the #3776
2264
+ // misreport, re-entered from the other side. `dirty` still overrides both
2265
+ // `diff.ignoreSubmodules` and a checked-in `.gitmodules` `ignore`, so the
2266
+ // gitlink vectors above stay closed (driven: rc 1 under every one of them).
2267
+ // `--no-ext-diff` is deliberately absent: `--quiet` short-circuits ahead of
2268
+ // an external diff driver, so an external `diff.<driver>.command` cannot
2269
+ // invert the probe (driven: rc 1 with and without the flag).
2270
+ // Any other non-zero exit from the probe (a genuine git error, or an unborn
2271
+ // HEAD) leaves the guard shut and falls through to the commit — failing toward
2272
+ // today's path rather than manufacturing a no-op.
2273
+ // THE ONE STATE WHERE `git diff` AND `git commit -- <paths>` GENUINELY DISAGREE.
2274
+ // `--assume-unchanged` tells git to skip the worktree stat for a path, so
2275
+ // `git add` stages nothing and BOTH diff forms report no difference — while
2276
+ // `git commit -- <path>` reads the working tree directly and records it
2277
+ // (driven: probe rc 0, commit rc 0, new content in the tree). Left
2278
+ // to the diff probe alone the guard reports `nothing_to_commit` about content
2279
+ // the caller explicitly named in `--files` and git would have written. #3776
2280
+ // is a purely diagnostic bug — nothing is corrupted and no wrong commit is
2281
+ // made — so suppressing its misreport must not be paid for by dropping named
2282
+ // content. The same rule the timeout routing already follows one block up.
2283
+ //
2284
+ // `git ls-files -v` is the discriminator for the STATE: it tags an
2285
+ // assume-unchanged path with a LOWERCASE letter (`h`), where
2286
+ // `--skip-worktree` is an uppercase `S` and never reaches THIS branch:
2287
+ // `git add` exits 1 under it, so a present-but-modified skip-worktree path
2288
+ // fails closed as `staging_failed` above the guard. (An ABSENT one is skipped
2289
+ // before `git add` runs at all per #2014, and is answered by the
2290
+ // `stagedPaths.length === 0` arm above — correctly, and exactly as it was
2291
+ // pre-fix. Both shapes are pinned.)
2292
+ //
2293
+ // Then ASK GIT, rather than reconstructing its answer. `git commit --dry-run`
2294
+ // is the same decision the real commit makes, and `--no-verify` is what keeps
2295
+ // it a DECISION rather than an execution. git 2.54 already declines to run
2296
+ // `pre-commit` on a dry run (driven: a rejecting one neither fires nor writes
2297
+ // its marker), which is the property that matters here, because a firing
2298
+ // `pre-commit` is the whole of #3776 — but that is an observed behaviour of
2299
+ // one version, and the failure it would produce on a version that differs is
2300
+ // SILENT. A `pre-commit` that fires and rejects exits 1, the same code git
2301
+ // returns for `nothing to record`, so the closure below would read it as a
2302
+ // CONFIRMED empty answer, drop the content the caller named, and report
2303
+ // `nothing_to_commit` — #3776's exact shape, in #3776's exact configuration.
2304
+ // `--no-verify` forecloses that structurally instead of resting on the
2305
+ // version, and is behaviour-neutral where the version already agrees (driven:
2306
+ // rc 0 would-record / rc 1 nothing, identical with and without it). This is
2307
+ // VERSION-SENSITIVE reasoning, hence stated at the claim per the provenance
2308
+ // note above.
2309
+ //
2310
+ // It is still NOT hook-free in general, and `--no-verify` does not widen that
2311
+ // claim: `post-index-change` fires on this call with or without the flag
2312
+ // (driven both ways), so a repo using that hook sees TWO extra invocations
2313
+ // for the probe — git fires it twice per `commit --dry-run`, and twice again
2314
+ // for the real commit (driven: 2/2/2 across flagged probe, unflagged probe
2315
+ // and real commit). Stated rather than claimed away; the narrower
2316
+ // claim is the true one. `--porcelain` keeps the output to a couple
2317
+ // of machine-readable lines instead of a full status listing — the rc is
2318
+ // identical either way (driven: 0 would-record / 1 nothing), but the plain
2319
+ // form prints every untracked path, which on a large tree is output this
2320
+ // probe has no use for and `execGit` would have to buffer. rc 0 means the
2321
+ // commit would record something, so the guard must stand aside.
2322
+ //
2323
+ // Reconstructing it was tried and is WRONG in three measured ways, all of
2324
+ // them silent drops of named content. Comparing `git hash-object` against
2325
+ // `HEAD:<path>` misses a mode-only change (`chmod +x` leaves the blob
2326
+ // identical while `git commit -- <path>` records `100755`); it cannot hash a
2327
+ // submodule path at all (`fatal: Unable to hash sub`, while the commit
2328
+ // advances the gitlink); and the path it needs must be parsed out of
2329
+ // `ls-files` output, which `core.quotePath` renders as `"caf\303\251.md"`
2330
+ // by default, so the probe reads a filename that does not exist. Asking git
2331
+ // needs no path parsed and no case enumerated.
2332
+ //
2333
+ // Scoped to this branch on purpose. The diff probe above answers the ordinary
2334
+ // case cheaply and is pinned against the configuration vectors below; the
2335
+ // dry run is the heavier, exact answer, and it runs only when an
2336
+ // assume-unchanged path is actually present.
2337
+ //
2338
+ // The `ls-files` read is an OPTIMISATION, never a gate — so an unreadable one
2339
+ // must not decide anything. It exists only to keep the dry run off the hot
2340
+ // path when no assume-unchanged entry is present; when it cannot answer, the
2341
+ // dry run simply runs, because the dry run needs nothing from it. Both
2342
+ // failing-closed (drop the content) and failing-open (re-enter #3776) are
2343
+ // wrong answers to a question we can just ask directly.
2344
+ const assumeUnchangedWouldRecord = () => {
2345
+ const listed = (0, shell_command_projection_cjs_1.execGit)(['ls-files', '-v', '--', ...stagedPaths.map(asPathspec)], { cwd });
2346
+ // Only the TAG is read; the path is deliberately never parsed out — see the
2347
+ // `core.quotePath` note above, and the dry run below needs no path anyway.
2348
+ if (listed.exitCode === 0
2349
+ && !listed.stdout.split('\n').some((line) => /^[a-z] /.test(line)))
2350
+ return false;
2351
+ const dryRun = (0, shell_command_projection_cjs_1.execGit)(['commit', '--dry-run', '--porcelain', '--no-verify', '-m', sanitizedMessage, '--', ...stagedPaths.map(asPathspec)], { cwd });
2352
+ // Only a CONFIRMED "nothing to record" closes the path: rc 1 from a git
2353
+ // that actually answered. This is the one probe in the guard whose rc 0
2354
+ // is the REASSURING answer, so it inverts the diff probe's safety: there
2355
+ // a timeout can only yield non-zero and reads as "not clean"; here
2356
+ // `execGit` collapses a spawn timeout (or any spawn error) to
2357
+ // `exitCode: 1` (`_spawnResult`: `result.status ?? 1`), byte-identical to
2358
+ // git's own "nothing to record" — and the guard then reports
2359
+ // `nothing_to_commit` about content it never asked git to write. Same
2360
+ // conflation the sequencer probes above defend against, same remedy: an
2361
+ // unanswered probe falls toward the commit, where git speaks for itself
2362
+ // (and a genuine error there is reported loudly, as it always was). rc 128
2363
+ // is likewise not an answer. Timeout kill of a dry run CAN leave a stale
2364
+ // `index.lock` behind (it refreshes the index); the real commit then
2365
+ // fails on it, loudly — never silently.
2366
+ if ((0, shell_command_projection_cjs_1.isSpawnTimeout)(dryRun) || dryRun.error !== null)
2367
+ return true;
2368
+ return dryRun.exitCode !== 1;
2369
+ };
2370
+ const nothingToCommit = guardApplies
2371
+ && (stagedPaths.length === 0
2372
+ || (!partialCommitRefused
2373
+ && (0, shell_command_projection_cjs_1.execGit)(['diff', '--quiet', '--ignore-submodules=dirty', '--no-textconv', 'HEAD', '--', ...stagedPaths.map(asPathspec)], { cwd }).exitCode === 0
2374
+ && !assumeUnchangedWouldRecord()));
2375
+ if (nothingToCommit) {
2376
+ // Nothing is being recorded, so any removal this call staged has no commit
2377
+ // to land in. Put it back before reporting no state change. Reachable on
2378
+ // two shapes, and keying on either one alone leaves the other broken:
2379
+ // an unborn HEAD (a removal never joins `stagedPaths`, so the pathspec is
2380
+ // empty), and a HEAD that simply does not carry the removed path -- an
2381
+ // index-only entry the caller `git add`ed but never committed, where the
2382
+ // `diff HEAD` probe reads clean because the path is absent on both sides.
2383
+ const rv = restoreRemovedEntries();
2384
+ if (rv !== 'restored') {
2385
+ output(removalsLeftStaged(rv), raw, 'failed');
2386
+ return;
2387
+ }
2388
+ // #4454: an explicit --files list where every named path was missing
2389
+ // reaches this branch via `stagedPaths.length === 0` above — surface
2390
+ // which path(s) were the reason, same as the success result below.
2391
+ const result = {
2392
+ committed: false,
2393
+ hash: null,
2394
+ reason: 'nothing_to_commit',
2395
+ ...(skippedFiles.length > 0 ? { skipped_files: skippedFiles } : {}),
2396
+ };
1660
2397
  output(result, raw, 'nothing');
1661
2398
  return;
1662
2399
  }
1663
- const isMergeInProgress = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '-q', '--verify', 'MERGE_HEAD'], { cwd }).exitCode === 0;
1664
2400
  const canScope = explicitFiles && stagedPaths.length > 0 && !amend
1665
2401
  && !isMergeInProgress;
1666
2402
  const commitArgs = amend
@@ -1669,12 +2405,47 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
1669
2405
  if (noVerify)
1670
2406
  commitArgs.push('--no-verify');
1671
2407
  if (canScope) {
1672
- commitArgs.push('--', ...stagedPaths);
1673
- }
2408
+ commitArgs.push('--', ...stagedPaths.map(asPathspec));
2409
+ }
2410
+ // #3859 follow-up: on git 2.39.5 (confirmed on the CI Linux bench image,
2411
+ // ghcr.io/open-gsd/gsd-tester-linux:v1.8.0-node24; NOT reproducible on git
2412
+ // 2.50.1) `git commit` itself — not just `git diff` — consults
2413
+ // `diff.ignoreSubmodules` when deciding whether there is anything to
2414
+ // record. With a local `diff.ignoreSubmodules=all` and a submodule gitlink
2415
+ // genuinely bumped, that git version silently REFUSES the commit (prints a
2416
+ // `git status`-style "Changes to be committed" dump and exits 1, having
2417
+ // written nothing) even though the diff probe above (already pinned with
2418
+ // its own `--ignore-submodules=dirty`) correctly reported the change as
2419
+ // present. The result was misclassified as generic `commit_failed` because
2420
+ // git's refusal text does not contain "nothing to commit".
2421
+ // Originally scoped to `canScope` on the assumption that only a
2422
+ // PATHSPEC-LIMITED `git commit -- <paths>` exercises this git internal
2423
+ // path. That assumption was wrong: reproduced directly against the pinned
2424
+ // v1.8.0-node24 tester image, a bare WHOLE-INDEX `git commit -m ...` (no
2425
+ // pathspec at all) is refused identically when the only staged change is a
2426
+ // submodule gitlink and `diff.ignoreSubmodules=all` — git's "nothing to
2427
+ // commit" check is a real diff (HEAD vs. index) honouring
2428
+ // `diff.ignoreSubmodules` regardless of whether a pathspec narrows it.
2429
+ // `--amend` is the one shape confirmed NOT to hit this: it always
2430
+ // recreates the commit from the current index and never runs the
2431
+ // empty-diff refusal a plain `git commit` does, override or not. The
2432
+ // override is therefore applied unconditionally here (not gated on
2433
+ // `canScope`) — it is a documented no-op everywhere it is not needed
2434
+ // (dry-run, git 2.50.1, and `--amend` already behave this way with or
2435
+ // without it; see `#3859 follow-up (canScope gap)` regression tests).
2436
+ // The override rides in via `GIT_CONFIG_*` env vars rather than a `-c`
2437
+ // argv flag so `commitArgs[0]` stays `'commit'` — several #3859 regression
2438
+ // tests assert on the raw argv captured at the `execGit` seam (e.g.
2439
+ // `gitCalls.some((a) => a[0] === 'commit')`), and a leading `-c` would shift
2440
+ // every element and break that pinning. Same override the probe already
2441
+ // carries, so the two can never disagree again.
2442
+ const commitEnv = {
2443
+ GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'diff.ignoreSubmodules', GIT_CONFIG_VALUE_0: 'dirty',
2444
+ };
1674
2445
  // #3886: `git commit` runs pre-commit hooks (husky/lint-staged routinely
1675
2446
  // idles ~4s on Windows before any task) — 10s is too tight, and a timeout
1676
2447
  // 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 });
2448
+ const commitResult = (0, shell_command_projection_cjs_1.execGit)(commitArgs, { cwd, env: commitEnv, timeout: COMMIT_TIMEOUT_MS });
1678
2449
  if (commitResult.exitCode !== 0) {
1679
2450
  // #3886: a SIGTERM'd git commit is a timeout, not commit_failed — the
1680
2451
  // partial stderr it flushed (often incidental CRLF warnings) is noise,
@@ -1692,7 +2463,28 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
1692
2463
  return;
1693
2464
  }
1694
2465
  if (commitResult.stdout.includes('nothing to commit') || commitResult.stderr.includes('nothing to commit')) {
1695
- const result = { committed: false, hash: null, reason: 'nothing_to_commit' };
2466
+ // Same reading as the guard above: git recorded nothing, so a removal
2467
+ // this call staged must not be left behind under a `nothing_to_commit`
2468
+ // report. The failure exits below are deliberately NOT restored -- they
2469
+ // report a failure rather than "no state changed", and the addition side
2470
+ // leaves its own staged paths in place there too.
2471
+ const rv = restoreRemovedEntries();
2472
+ if (rv !== 'restored') {
2473
+ output(removalsLeftStaged(rv), raw, 'failed');
2474
+ return;
2475
+ }
2476
+ // #4454: this is the residual window the surrounding comments already
2477
+ // document (a partial skip + partialCommitRefused bypassing the diff
2478
+ // probe + git's own empty-commit refusal) — skippedFiles can be
2479
+ // non-empty here too, and omitting it would be the same misreport
2480
+ // this fix exists to close, just on the other branch that reaches
2481
+ // "nothing to commit".
2482
+ const result = {
2483
+ committed: false,
2484
+ hash: null,
2485
+ reason: 'nothing_to_commit',
2486
+ ...(skippedFiles.length > 0 ? { skipped_files: skippedFiles } : {}),
2487
+ };
1696
2488
  output(result, raw, 'nothing');
1697
2489
  return;
1698
2490
  }
@@ -1708,7 +2500,15 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
1708
2500
  // Get short hash
1709
2501
  const hashResult = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '--short', 'HEAD'], { cwd });
1710
2502
  const hash = hashResult.exitCode === 0 ? hashResult.stdout : null;
1711
- const result = { committed: true, hash, reason: 'committed' };
2503
+ // #4454: report explicit --files paths that were skipped as missing (the
2504
+ // #2014 guard above) so a caller can tell a partial commit from a complete
2505
+ // one, without changing the payload shape when nothing was skipped.
2506
+ const result = {
2507
+ committed: true,
2508
+ hash,
2509
+ reason: 'committed',
2510
+ ...(skippedFiles.length > 0 ? { skipped_files: skippedFiles } : {}),
2511
+ };
1712
2512
  output(result, raw, hash || 'committed');
1713
2513
  }
1714
2514
  /**
@@ -1747,7 +2547,7 @@ function groupFilesBySubrepo(files, subRepos) {
1747
2547
  let matchLen = -1;
1748
2548
  if (candidates) {
1749
2549
  for (const repo of candidates) {
1750
- if (file.startsWith(repo + '/')) {
2550
+ if (file.startsWith(repo + '/')) { // allow-handrolled-containment: sub-repo file grouping, not a safety decision
1751
2551
  const repoLen = String(repo).length;
1752
2552
  if (repoLen > matchLen) {
1753
2553
  match = repo;
@@ -1830,7 +2630,12 @@ function cmdCommitToSubrepo(cwd, message, files, raw) {
1830
2630
  const commitArgs = canScopeSub
1831
2631
  ? ['commit', '-m', message, '--', ...stagedRelPaths]
1832
2632
  : ['commit', '-m', message];
1833
- const commitResult = (0, shell_command_projection_cjs_1.execGit)(commitArgs, { cwd: repoCwd, timeout: COMMIT_TIMEOUT_MS });
2633
+ // #3859 follow-up fix as cmdCommit above (line ~2081) — git 2.39.5 needs
2634
+ // this override for pathspec-scoped AND whole-index commits alike.
2635
+ const commitEnvSub = {
2636
+ GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'diff.ignoreSubmodules', GIT_CONFIG_VALUE_0: 'dirty',
2637
+ };
2638
+ const commitResult = (0, shell_command_projection_cjs_1.execGit)(commitArgs, { cwd: repoCwd, timeout: COMMIT_TIMEOUT_MS, env: commitEnvSub });
1834
2639
  if (commitResult.exitCode !== 0) {
1835
2640
  if ((0, shell_command_projection_cjs_1.isSpawnTimeout)(commitResult)) {
1836
2641
  // #3886 (subrepo counterpart): timeout ≠ error; surface the stale-lock
@@ -1891,21 +2696,28 @@ function cmdPrSubrepo(cwd, repo, branch, commitMessage, raw) {
1891
2696
  error(`Branch name must not start with '-': ${branch}`);
1892
2697
  }
1893
2698
  // 0. Security: validate repo path is contained within the workspace root.
1894
- // Uses security.cjs validatePath (symlink-safe realpathSync + startsWith guard)
2699
+ // Uses security.cjs tryWithinRoot (symlink-safe realpathSync + startsWith guard)
1895
2700
  // to reject ../escape, absolute paths, and symlink traversal.
1896
- // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
1897
- const { validatePath } = require('./security.cjs');
1898
- const pathCheck = validatePath(repo, cwd);
1899
- if (!pathCheck.safe) {
1900
- error(`Sub-repo path is unsafe: ${pathCheck.error}`);
2701
+ const repoContained = (0, security_cjs_1.tryWithinRoot)(repo, cwd);
2702
+ if (repoContained === null) {
2703
+ error(`Sub-repo path is unsafe: resolves outside the workspace root`);
1901
2704
  }
1902
- const repoCwd = pathCheck.resolved;
2705
+ const repoCwd = repoContained;
1903
2706
  if (!node_fs_1.default.existsSync(repoCwd)) {
1904
2707
  error(`Sub-repo not found: ${repoCwd}`);
1905
2708
  }
1906
2709
  // 1. Collect changed files via porcelain status — explicit, never git add -A.
1907
2710
  // ?? (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 });
2711
+ // #3859 follow-up: `git status --porcelain` honors `diff.ignoreSubmodules`
2712
+ // the same way the empty-diff probe fixed for cmdCommit did — under a local
2713
+ // `diff.ignoreSubmodules=all`, a genuinely bumped submodule gitlink is
2714
+ // invisible here too, so `changedFiles` comes back empty and the function
2715
+ // reports `nothing_to_commit` before ever reaching the (now-fixed) commit
2716
+ // call. `--ignore-submodules=dirty` pins this the same way, reported
2717
+ // verbatim: `git -C repo status --porcelain` (no flag) shows nothing for a
2718
+ // pure gitlink bump under `diff.ignoreSubmodules=all`, while
2719
+ // `--ignore-submodules=dirty` reports ` M nested` (reproduced directly).
2720
+ const statusResult = (0, shell_command_projection_cjs_1.execGit)(['-c', 'core.quotePath=false', 'status', '--porcelain', '--ignore-submodules=dirty'], { cwd: repoCwd });
1909
2721
  if (statusResult.exitCode !== 0) {
1910
2722
  error(`git status failed in ${repo}: ${statusResult.stderr}`);
1911
2723
  }
@@ -1974,7 +2786,12 @@ function cmdPrSubrepo(cwd, repo, branch, commitMessage, raw) {
1974
2786
  const commitArgs = canScopePr
1975
2787
  ? ['commit', '-m', commitMessage, '--', ...changedFiles]
1976
2788
  : ['commit', '-m', commitMessage];
1977
- const commitResult = (0, shell_command_projection_cjs_1.execGit)(commitArgs, { cwd: repoCwd, timeout: COMMIT_TIMEOUT_MS });
2789
+ // #3859 follow-up fix as cmdCommit above (line ~2081) — git 2.39.5 needs
2790
+ // this override for pathspec-scoped AND whole-index commits alike.
2791
+ const commitEnvPr = {
2792
+ GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'diff.ignoreSubmodules', GIT_CONFIG_VALUE_0: 'dirty',
2793
+ };
2794
+ const commitResult = (0, shell_command_projection_cjs_1.execGit)(commitArgs, { cwd: repoCwd, timeout: COMMIT_TIMEOUT_MS, env: commitEnvPr });
1978
2795
  if (commitResult.exitCode !== 0) {
1979
2796
  rollback();
1980
2797
  if ((0, shell_command_projection_cjs_1.isSpawnTimeout)(commitResult)) {
@@ -2229,9 +3046,7 @@ function cmdProgressRender(cwd, format, raw) {
2229
3046
  : null;
2230
3047
  if (format === 'table') {
2231
3048
  // Render markdown table
2232
- const barWidth = 10;
2233
- const filled = percent === null ? 0 : Math.round((percent / 100) * barWidth);
2234
- const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
3049
+ const bar = (0, phase_lifecycle_cjs_1.renderProgressBar)(percent, 10);
2235
3050
  const percentSuffix = percent === null ? '' : ` (${percent}%)`;
2236
3051
  let out = `# ${milestone?.version ?? ''} ${milestone?.name ?? ''}\n\n`;
2237
3052
  out += `**Progress:** [${bar}] ${totalSummaries}/${totalPlans} plans${percentSuffix}\n\n`;
@@ -2243,9 +3058,7 @@ function cmdProgressRender(cwd, format, raw) {
2243
3058
  output({ rendered: out }, raw, out);
2244
3059
  }
2245
3060
  else if (format === 'bar') {
2246
- const barWidth = 20;
2247
- const filled = percent === null ? 0 : Math.round((percent / 100) * barWidth);
2248
- const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
3061
+ const bar = (0, phase_lifecycle_cjs_1.renderProgressBar)(percent, 20);
2249
3062
  const percentSuffix = percent === null ? '' : ` (${percent}%)`;
2250
3063
  const text = `[${bar}] ${totalSummaries}/${totalPlans} plans${percentSuffix}`;
2251
3064
  output({ bar: text, percent, completed: totalSummaries, total: totalPlans }, raw, text);
@@ -2274,7 +3087,8 @@ function cmdTodoMatchPhase(cwd, phase, raw) {
2274
3087
  if (!phase) {
2275
3088
  error('phase required for todo match-phase');
2276
3089
  }
2277
- const pendingDir = node_path_1.default.join(planningDir(cwd), 'todos', 'pending');
3090
+ // #4256: root-scoped todos read — see cmdListTodos.
3091
+ const pendingDir = node_path_1.default.join(todosDir(cwd), 'pending');
2278
3092
  const todos = [];
2279
3093
  // Load pending todos
2280
3094
  try {
@@ -2375,24 +3189,113 @@ function cmdTodoMatchPhase(cwd, phase, raw) {
2375
3189
  matches.sort((a, b) => b.score - a.score);
2376
3190
  output({ phase, matches, todo_count: todos.length }, raw, undefined);
2377
3191
  }
2378
- function cmdTodoComplete(cwd, filename, raw) {
3192
+ // #4096: upsert completion keys INSIDE the leading frontmatter block. Never a
3193
+ // bare prefix line above the opening `---` (that displaces the fence to line 2
3194
+ // and breaks every fence-locating reader). A file with no well-formed block
3195
+ // (absent, or an unterminated opening fence) gains a complete block.
3196
+ function upsertTodoCompletionFields(content, today) {
3197
+ const lines = content.split('\n');
3198
+ const fields = [`completed: ${today}`, 'status: completed'];
3199
+ const hasOpeningFence = lines[0] !== undefined && lines[0].trim() === '---';
3200
+ const closeIdx = hasOpeningFence ? lines.findIndex((l, i) => i > 0 && l.trim() === '---') : -1;
3201
+ if (!hasOpeningFence || closeIdx === -1) {
3202
+ // No parseable frontmatter: wrap the whole content in a complete block
3203
+ // rather than prefixing bare keys (#4096 fix 2).
3204
+ return `---\n${fields.join('\n')}\n---\n\n${content}`;
3205
+ }
3206
+ const block = lines.slice(1, closeIdx);
3207
+ for (const field of fields) {
3208
+ const key = `${field.slice(0, field.indexOf(':'))}:`;
3209
+ const idx = block.findIndex(l => l.startsWith(key));
3210
+ if (idx === -1) {
3211
+ block.push(field);
3212
+ }
3213
+ else {
3214
+ block[idx] = field;
3215
+ }
3216
+ }
3217
+ return [...lines.slice(0, 1), ...block, ...lines.slice(closeIdx)].join('\n');
3218
+ }
3219
+ function cmdTodoComplete(cwd, filename, options, raw) {
2379
3220
  if (!filename) {
2380
3221
  error('filename required for todo complete');
2381
3222
  }
2382
- const pendingDir = node_path_1.default.join(planningDir(cwd), 'todos', 'pending');
2383
- const completedDir = node_path_1.default.join(planningDir(cwd), 'todos', 'completed');
3223
+ // #4256: root-scoped todos read/write — see cmdListTodos. The pending and
3224
+ // completed halves of the move must resolve from the SAME root or the
3225
+ // completion would strand files where no reader looks.
3226
+ const todosRoot = todosDir(cwd);
3227
+ const pendingDir = node_path_1.default.join(todosRoot, 'pending');
3228
+ const completedDir = node_path_1.default.join(todosRoot, 'completed');
3229
+ // #4652: containment against todosRoot only rejects paths that leave the
3230
+ // root — it cannot express "a todo name is a basename, not a path" (see
3231
+ // #4327). `../sibling.md`, `a/../../b.md`, and `sub/name.md` all resolve
3232
+ // to a location inside todosRoot (or inside pending/) and would pass
3233
+ // containment, yet none of them is a bare filename. Reject on basename
3234
+ // shape FIRST, before any path is even joined — same predicate shape as
3235
+ // findPhaseArtifact in check-command-router.cts. Checking both `/` and
3236
+ // `\` explicitly (not just path.basename) matters on POSIX, where a
3237
+ // literal backslash is just an ordinary filename character to
3238
+ // path.basename but not to path.win32.basename or to the user's intent.
3239
+ const rawFilename = filename;
3240
+ if (rawFilename === '.' ||
3241
+ rawFilename === '..' ||
3242
+ rawFilename.includes('\0') ||
3243
+ rawFilename.includes('/') ||
3244
+ rawFilename.includes('\\') ||
3245
+ node_path_1.default.basename(rawFilename) !== rawFilename ||
3246
+ node_path_1.default.win32.basename(rawFilename) !== rawFilename) {
3247
+ error(`todo name must be a plain filename inside the pending directory, not a path: ${rawFilename}`, ERROR_REASON.USAGE);
3248
+ }
2384
3249
  const sourcePath = node_path_1.default.join(pendingDir, filename);
2385
- if (!node_fs_1.default.existsSync(sourcePath)) {
3250
+ const targetPath = node_path_1.default.join(completedDir, filename);
3251
+ const sourceContained = (0, security_cjs_1.tryWithinRoot)(sourcePath, todosRoot, security_cjs_1.PathAcceptance.AbsoluteInsideRoot);
3252
+ if (sourceContained === null) {
3253
+ error(`todo file escapes its allowed directory: ${filename}`, ERROR_REASON.USAGE);
3254
+ }
3255
+ const targetContained = (0, security_cjs_1.tryWithinRoot)(targetPath, todosRoot, security_cjs_1.PathAcceptance.AbsoluteInsideRoot);
3256
+ if (targetContained === null) {
3257
+ error(`todo file escapes its allowed directory: ${filename}`, ERROR_REASON.USAGE);
3258
+ }
3259
+ const resolvedSource = sourceContained;
3260
+ const resolvedTarget = targetContained;
3261
+ if (!node_fs_1.default.existsSync(resolvedSource)) {
2386
3262
  error(`Todo not found: ${filename}`);
2387
3263
  }
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');
3264
+ // #4652: a name that IS a bare basename can still resolve to something that
3265
+ // is not a regular file — a directory, symlink-to-directory, FIFO or socket
3266
+ // sitting in pending/ under an ordinary-looking name. `.` and `..` no longer
3267
+ // reach here (the basename guard above rejects them first), so this is not
3268
+ // about traversal; it stops fs.readFileSync from throwing an uncaught EISDIR
3269
+ // with an absolute-path stack trace where every sibling case gives a clean
3270
+ // USAGE rejection.
3271
+ if (!node_fs_1.default.statSync(resolvedSource).isFile()) {
3272
+ error(`todo name is not a file: ${filename}`, ERROR_REASON.USAGE);
3273
+ }
3274
+ const content = node_fs_1.default.readFileSync(resolvedSource, 'utf-8');
2392
3275
  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);
2395
- node_fs_1.default.unlinkSync(sourcePath);
3276
+ // #4096: --dry-run mirrors `milestone complete --dry-run` (#2118) — every
3277
+ // existence check above still runs, nothing below mutates, and the payload
3278
+ // is preview-shaped (`dry_run`/`would_*`), never `completed: true`.
3279
+ if (options.dryRun) {
3280
+ output({
3281
+ dry_run: true,
3282
+ would_complete: true,
3283
+ file: filename,
3284
+ date: today,
3285
+ would_move: {
3286
+ source: node_path_1.default.relative(cwd, resolvedSource).split(node_path_1.default.sep).join('/'),
3287
+ target: node_path_1.default.relative(cwd, resolvedTarget).split(node_path_1.default.sep).join('/'),
3288
+ },
3289
+ would_set: { completed: today, status: 'completed' },
3290
+ }, raw);
3291
+ return;
3292
+ }
3293
+ // Ensure completed directory exists (only on the real run — a dry run
3294
+ // creates nothing).
3295
+ (0, shell_command_projection_cjs_1.platformEnsureDir)(completedDir);
3296
+ const completedContent = upsertTodoCompletionFields(content, today);
3297
+ (0, shell_command_projection_cjs_1.platformWriteSync)(resolvedTarget, completedContent);
3298
+ node_fs_1.default.unlinkSync(resolvedSource);
2396
3299
  output({ completed: true, file: filename, date: today }, raw, 'completed');
2397
3300
  }
2398
3301
  function cmdScaffold(cwd, type, options, raw) {
@@ -2602,9 +3505,7 @@ function cmdStats(cwd, format, raw) {
2602
3505
  phase_scope: phaseScope,
2603
3506
  };
2604
3507
  if (format === 'table') {
2605
- const barWidth = 10;
2606
- const filled = percent === null ? 0 : Math.round((percent / 100) * barWidth);
2607
- const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
3508
+ const bar = (0, phase_lifecycle_cjs_1.renderProgressBar)(percent, 10);
2608
3509
  let out = `# ${milestone?.version ?? ''} ${milestone?.name ?? ''} — Statistics\n\n`;
2609
3510
  const percentSuffix = percent === null ? '' : ` (${percent}%)`;
2610
3511
  out += `**Progress:** [${bar}] ${completedPhases}/${phases.length} phases${percentSuffix}\n`;
@@ -2865,6 +3766,7 @@ function cmdCommitDocsGuardDisable(cwd, raw) {
2865
3766
  output({ disabled: true, action: 'removed', path: hookPath }, raw, 'disabled');
2866
3767
  }
2867
3768
  module.exports = {
3769
+ effortSurfaceForHost,
2868
3770
  groupFilesBySubrepo,
2869
3771
  determinePhaseStatus,
2870
3772
  foldPhaseStatus,