@opengsd/gsd-core 1.11.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (498) 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-code-fixer.md +1 -1
  5. package/agents/gsd-debug-session-manager.md +1 -1
  6. package/agents/gsd-debugger.md +1 -1
  7. package/agents/gsd-dom-verifier.md +169 -0
  8. package/agents/gsd-eval-auditor.md +1 -1
  9. package/agents/gsd-executor.md +78 -42
  10. package/agents/gsd-framework-selector.md +1 -3
  11. package/agents/gsd-intel-updater.md +1 -1
  12. package/agents/gsd-mempalace-curator.md +0 -1
  13. package/agents/gsd-pattern-mapper.md +11 -0
  14. package/agents/gsd-phase-researcher.md +3 -1
  15. package/agents/gsd-plan-checker.md +91 -112
  16. package/agents/gsd-planner.md +20 -4
  17. package/agents/gsd-project-researcher.md +1 -1
  18. package/agents/gsd-research-synthesizer.md +2 -2
  19. package/agents/gsd-roadmapper.md +15 -11
  20. package/agents/gsd-ui-checker.md +82 -7
  21. package/agents/gsd-ui-researcher.md +70 -3
  22. package/agents/gsd-verifier.md +24 -2
  23. package/bin/install.js +847 -200
  24. package/commands/gsd/discuss-phase.md +1 -1
  25. package/commands/gsd/execute-phase.md +1 -1
  26. package/commands/gsd/import.md +1 -1
  27. package/commands/gsd/ns-workflow.md +2 -1
  28. package/commands/gsd/phase.md +1 -1
  29. package/commands/gsd/quick-batch.md +105 -0
  30. package/commands/gsd/quick.md +8 -4
  31. package/commands/gsd/surface.md +18 -8
  32. package/gsd-core/bin/gsd-tools.cjs +761 -100
  33. package/gsd-core/bin/lib/active-workstream-store.cjs +8 -0
  34. package/gsd-core/bin/lib/adr-parser.cjs +13 -7
  35. package/gsd-core/bin/lib/agent-install-check.cjs +162 -0
  36. package/gsd-core/bin/lib/api-coverage.cjs +30 -9
  37. package/gsd-core/bin/lib/artifacts.cjs +2 -0
  38. package/gsd-core/bin/lib/assumption-delta.cjs +30 -11
  39. package/gsd-core/bin/lib/audit.cjs +163 -41
  40. package/gsd-core/bin/lib/broken-windows.cjs +306 -28
  41. package/gsd-core/bin/lib/capability-activation.cjs +27 -0
  42. package/gsd-core/bin/lib/capability-lock.cjs +10 -4
  43. package/gsd-core/bin/lib/capability-registry.cjs +785 -144
  44. package/gsd-core/bin/lib/capability-state.cjs +25 -4
  45. package/gsd-core/bin/lib/capability-validator.cjs +321 -18
  46. package/gsd-core/bin/lib/capability-writer.cjs +14 -4
  47. package/gsd-core/bin/lib/check-command-router.cjs +229 -6
  48. package/gsd-core/bin/lib/claude-orchestration.cjs +10 -25
  49. package/gsd-core/bin/lib/cli-exit.cjs +496 -10
  50. package/gsd-core/bin/lib/clusters.cjs +1 -0
  51. package/gsd-core/bin/lib/code-review-depth.cjs +288 -0
  52. package/gsd-core/bin/lib/codex-agent-toml.cjs +410 -4
  53. package/gsd-core/bin/lib/command-aliases.cjs +16 -0
  54. package/gsd-core/bin/lib/command-arg-projection.cjs +144 -14
  55. package/gsd-core/bin/lib/command-routing-hub.cjs +31 -2
  56. package/gsd-core/bin/lib/commands.cjs +877 -54
  57. package/gsd-core/bin/lib/complexity-trigger.cjs +26 -6
  58. package/gsd-core/bin/lib/config-loader.cjs +121 -29
  59. package/gsd-core/bin/lib/config.cjs +92 -2
  60. package/gsd-core/bin/lib/configuration.cjs +129 -37
  61. package/gsd-core/bin/lib/core-utils.cjs +118 -14
  62. package/gsd-core/bin/lib/decisions.cjs +213 -1
  63. package/gsd-core/bin/lib/edge-probe.cjs +23 -2
  64. package/gsd-core/bin/lib/estimate-cli.cjs +55 -11
  65. package/gsd-core/bin/lib/exit-code-registry.cjs +98 -0
  66. package/gsd-core/bin/lib/file-overlap-partitioner.cjs +74 -0
  67. package/gsd-core/bin/lib/frontmatter.cjs +975 -326
  68. package/gsd-core/bin/lib/gap-checker.cjs +41 -8
  69. package/gsd-core/bin/lib/git-base-branch.cjs +182 -39
  70. package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +7 -3
  71. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +8 -2
  72. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +60 -14
  73. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +75 -22
  74. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +22 -8
  75. package/gsd-core/bin/lib/health-diagnostic.cjs +23 -3
  76. package/gsd-core/bin/lib/host-integration.cjs +96 -11
  77. package/gsd-core/bin/lib/init-command-router.cjs +132 -21
  78. package/gsd-core/bin/lib/init.cjs +252 -56
  79. package/gsd-core/bin/lib/install-engine.cjs +252 -15
  80. package/gsd-core/bin/lib/install-model-override-resolver.cjs +78 -1
  81. package/gsd-core/bin/lib/install-profiles.cjs +100 -18
  82. package/gsd-core/bin/lib/installer-migration-report.cjs +4 -0
  83. package/gsd-core/bin/lib/installer-migrations/010-antigravity-retire-confighome-artifacts.cjs +169 -0
  84. package/gsd-core/bin/lib/installer-migrations.cjs +10 -7
  85. package/gsd-core/bin/lib/intel.cjs +101 -26
  86. package/gsd-core/bin/lib/io.cjs +195 -15
  87. package/gsd-core/bin/lib/learnings.cjs +85 -14
  88. package/gsd-core/bin/lib/legacy-cleanup.cjs +8 -2
  89. package/gsd-core/bin/lib/loop-resolver.cjs +14 -8
  90. package/gsd-core/bin/lib/markdown-table.cjs +175 -4
  91. package/gsd-core/bin/lib/milestone.cjs +112 -7
  92. package/gsd-core/bin/lib/model-catalog.cjs +177 -19
  93. package/gsd-core/bin/lib/model-resolver.cjs +10 -28
  94. package/gsd-core/bin/lib/onboard-projection.cjs +5 -1
  95. package/gsd-core/bin/lib/phase-command-router.cjs +13 -6
  96. package/gsd-core/bin/lib/phase-estimation.cjs +17 -8
  97. package/gsd-core/bin/lib/phase-id.cjs +321 -13
  98. package/gsd-core/bin/lib/phase-lifecycle.cjs +24 -16
  99. package/gsd-core/bin/lib/phase-locator.cjs +138 -17
  100. package/gsd-core/bin/lib/phase.cjs +1175 -115
  101. package/gsd-core/bin/lib/plan-document.cjs +273 -0
  102. package/gsd-core/bin/lib/plan-scan.cjs +13 -2
  103. package/gsd-core/bin/lib/planning-command-router.cjs +61 -0
  104. package/gsd-core/bin/lib/planning-inspect.cjs +1168 -0
  105. package/gsd-core/bin/lib/planning-snapshot.cjs +165 -34
  106. package/gsd-core/bin/lib/planning-workspace.cjs +159 -28
  107. package/gsd-core/bin/lib/probe-core.cjs +4 -1
  108. package/gsd-core/bin/lib/profile-pipeline-command-router.cjs +50 -7
  109. package/gsd-core/bin/lib/profile-pipeline.cjs +6 -3
  110. package/gsd-core/bin/lib/quick-batch-command-router.cjs +285 -0
  111. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +250 -0
  112. package/gsd-core/bin/lib/quick-batch.cjs +840 -0
  113. package/gsd-core/bin/lib/real-home-guard.cjs +419 -0
  114. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +71 -45
  115. package/gsd-core/bin/lib/review-lane-descriptor.cjs +62 -14
  116. package/gsd-core/bin/lib/review-lane-invocation.cjs +73 -1
  117. package/gsd-core/bin/lib/review-lane-runner.cjs +136 -10
  118. package/gsd-core/bin/lib/roadmap-command-router.cjs +45 -31
  119. package/gsd-core/bin/lib/roadmap-parser.cjs +577 -41
  120. package/gsd-core/bin/lib/roadmap.cjs +248 -64
  121. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +329 -41
  122. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +16 -17
  123. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +320 -109
  124. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +487 -83
  125. package/gsd-core/bin/lib/runtime-identity.cjs +234 -0
  126. package/gsd-core/bin/lib/runtime-slash.cjs +72 -2
  127. package/gsd-core/bin/lib/shell-command-projection.cjs +75 -8
  128. package/gsd-core/bin/lib/smart-entry.cjs +19 -31
  129. package/gsd-core/bin/lib/spec-section.cjs +12 -7
  130. package/gsd-core/bin/lib/state-command-router.cjs +47 -18
  131. package/gsd-core/bin/lib/state-contract.cjs +359 -0
  132. package/gsd-core/bin/lib/state-document.cjs +216 -5
  133. package/gsd-core/bin/lib/state-md-schema.cjs +231 -0
  134. package/gsd-core/bin/lib/state-transition.cjs +850 -145
  135. package/gsd-core/bin/lib/state.cjs +1629 -287
  136. package/gsd-core/bin/lib/surface.cjs +33 -10
  137. package/gsd-core/bin/lib/task-command-router.cjs +111 -1
  138. package/gsd-core/bin/lib/task-content-resolution.cjs +368 -0
  139. package/gsd-core/bin/lib/tdd-red-evidence.cjs +133 -0
  140. package/gsd-core/bin/lib/teams-status.cjs +4 -1
  141. package/gsd-core/bin/lib/uat-predicate.cjs +58 -20
  142. package/gsd-core/bin/lib/uat.cjs +2542 -387
  143. package/gsd-core/bin/lib/ui-consideration-probe.cjs +9 -1
  144. package/gsd-core/bin/lib/ui-safety-gate.cjs +37 -7
  145. package/gsd-core/bin/lib/unusable-input.cjs +13 -0
  146. package/gsd-core/bin/lib/update-context.cjs +6 -2
  147. package/gsd-core/bin/lib/validate-command-router.cjs +2 -2
  148. package/gsd-core/bin/lib/validate.cjs +230 -12
  149. package/gsd-core/bin/lib/vendor/README.md +43 -5
  150. package/gsd-core/bin/lib/vendor/js-yaml.cjs +3014 -0
  151. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  152. package/gsd-core/bin/lib/verification.cjs +287 -13
  153. package/gsd-core/bin/lib/verify-command-grounding.cjs +846 -0
  154. package/gsd-core/bin/lib/verify-command-router.cjs +1 -0
  155. package/gsd-core/bin/lib/verify.cjs +441 -56
  156. package/gsd-core/bin/lib/workstream-inventory.cjs +20 -2
  157. package/gsd-core/bin/lib/workstream-name-policy.cjs +25 -4
  158. package/gsd-core/bin/lib/worktree-base-ref.cjs +66 -12
  159. package/gsd-core/bin/lib/worktree-safety.cjs +185 -21
  160. package/gsd-core/bin/shared/config-defaults.manifest.json +7 -1
  161. package/gsd-core/bin/shared/config-schema.manifest.json +13 -0
  162. package/gsd-core/bin/shared/exit-codes.json +8 -0
  163. package/gsd-core/bin/shared/exit-codes.sh +20 -0
  164. package/gsd-core/bin/shared/model-catalog.json +8 -1
  165. package/gsd-core/bin/verify-reapply-patches.cjs +70 -3
  166. package/gsd-core/references/agent-contracts.md +6 -5
  167. package/gsd-core/references/api-coverage.md +24 -2
  168. package/gsd-core/references/autonomous-smart-discuss.md +3 -3
  169. package/gsd-core/references/checkpoints.md +37 -19
  170. package/gsd-core/references/decimal-phase-calculation.md +5 -5
  171. package/gsd-core/references/edge-probe.md +17 -5
  172. package/gsd-core/references/execute-mvp-tdd.md +18 -18
  173. package/gsd-core/references/execute-phase-between-wave-reset.md +9 -12
  174. package/gsd-core/references/execute-phase-response-language.md +6 -0
  175. package/gsd-core/references/execute-phase-wave-guard.md +11 -9
  176. package/gsd-core/references/executor-examples.md +42 -0
  177. package/gsd-core/references/failing-direction.md +78 -0
  178. package/gsd-core/references/few-shot-examples/plan-checker.md +15 -15
  179. package/gsd-core/references/gate-prompts.md +1 -1
  180. package/gsd-core/references/git-integration.md +5 -5
  181. package/gsd-core/references/git-planning-commit.md +3 -3
  182. package/gsd-core/references/gsd-run-resolver.md +1 -1
  183. package/gsd-core/references/loop-hook-dispatch.md +22 -0
  184. package/gsd-core/references/model-profiles.md +1 -1
  185. package/gsd-core/references/mvp-concepts.md +2 -2
  186. package/gsd-core/references/nyquist-compliance.md +74 -0
  187. package/gsd-core/references/offer-next.md +3 -5
  188. package/gsd-core/references/phase-argument-parsing.md +3 -3
  189. package/gsd-core/references/plan-checker-examples.md +41 -0
  190. package/gsd-core/references/planner-antipatterns.md +25 -0
  191. package/gsd-core/references/planner-chunked.md +5 -1
  192. package/gsd-core/references/planner-coupling.md +42 -0
  193. package/gsd-core/references/planner-failing-direction.md +53 -0
  194. package/gsd-core/references/planner-human-verify-mode.md +15 -1
  195. package/gsd-core/references/planner-quick-batch.md +71 -0
  196. package/gsd-core/references/planner-reviews.md +47 -0
  197. package/gsd-core/references/planner-revision.md +76 -3
  198. package/gsd-core/references/planner-verify-command-grounding.md +17 -0
  199. package/gsd-core/references/planning-config.md +39 -9
  200. package/gsd-core/references/response-language-directive.md +9 -0
  201. package/gsd-core/references/reviewer-instances.md +31 -0
  202. package/gsd-core/references/revision-loop.md +118 -11
  203. package/gsd-core/references/runtime-aware-dispatch.md +1 -1
  204. package/gsd-core/references/tdd.md +15 -12
  205. package/gsd-core/references/ui-brand.md +65 -21
  206. package/gsd-core/references/ui-consideration-probe.md +1 -1
  207. package/gsd-core/references/universal-anti-patterns.md +2 -2
  208. package/gsd-core/references/verifier-evidence-gate.md +160 -0
  209. package/gsd-core/references/verify-command-path-resolvability.md +42 -0
  210. package/gsd-core/references/verify-mvp-mode.md +1 -1
  211. package/gsd-core/references/workstream-flag.md +11 -11
  212. package/gsd-core/templates/README.md +1 -1
  213. package/gsd-core/templates/SECURITY.md +3 -3
  214. package/gsd-core/templates/UI-SPEC.md +25 -3
  215. package/gsd-core/templates/VALIDATION.md +3 -3
  216. package/gsd-core/templates/phase-prompt.md +7 -0
  217. package/gsd-core/templates/state.md +7 -0
  218. package/gsd-core/templates/verification-report.md +5 -0
  219. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
  220. package/gsd-core/workflows/add-backlog.md +3 -1
  221. package/gsd-core/workflows/add-phase.md +5 -3
  222. package/gsd-core/workflows/add-tests.md +4 -9
  223. package/gsd-core/workflows/add-todo.md +2 -2
  224. package/gsd-core/workflows/ai-integration-phase.md +5 -10
  225. package/gsd-core/workflows/analyze-dependencies.md +2 -0
  226. package/gsd-core/workflows/audit-fix.md +14 -3
  227. package/gsd-core/workflows/audit-milestone.md +11 -9
  228. package/gsd-core/workflows/audit-uat.md +19 -2
  229. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +2 -2
  230. package/gsd-core/workflows/autonomous.md +12 -26
  231. package/gsd-core/workflows/check-todos.md +2 -2
  232. package/gsd-core/workflows/cleanup.md +3 -3
  233. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +16 -14
  234. package/gsd-core/workflows/code-review-fix.md +3 -1
  235. package/gsd-core/workflows/code-review.md +192 -69
  236. package/gsd-core/workflows/complete-milestone.md +28 -14
  237. package/gsd-core/workflows/debug.md +6 -4
  238. package/gsd-core/workflows/diagnose-issues.md +17 -7
  239. package/gsd-core/workflows/discuss-phase/modes/advisor.md +3 -1
  240. package/gsd-core/workflows/discuss-phase/modes/all.md +2 -0
  241. package/gsd-core/workflows/discuss-phase/modes/analyze.md +2 -0
  242. package/gsd-core/workflows/discuss-phase/modes/auto.md +2 -0
  243. package/gsd-core/workflows/discuss-phase/modes/batch.md +2 -0
  244. package/gsd-core/workflows/discuss-phase/modes/chain.md +5 -7
  245. package/gsd-core/workflows/discuss-phase/modes/default.md +2 -0
  246. package/gsd-core/workflows/discuss-phase/modes/power.md +2 -0
  247. package/gsd-core/workflows/discuss-phase/modes/text.md +3 -1
  248. package/gsd-core/workflows/discuss-phase/templates/context.md +2 -0
  249. package/gsd-core/workflows/discuss-phase/templates/discussion-log.md +2 -0
  250. package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +1 -3
  251. package/gsd-core/workflows/discuss-phase-assumptions.md +3 -3
  252. package/gsd-core/workflows/discuss-phase-power.md +2 -0
  253. package/gsd-core/workflows/discuss-phase.md +2 -2
  254. package/gsd-core/workflows/do.md +46 -19
  255. package/gsd-core/workflows/docs-update.md +6 -5
  256. package/gsd-core/workflows/edit-phase.md +3 -1
  257. package/gsd-core/workflows/eval-review.md +5 -10
  258. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +3 -1
  259. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +129 -11
  260. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +1 -1
  261. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +1 -1
  262. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +1 -1
  263. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +29 -5
  264. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +2 -2
  265. package/gsd-core/workflows/execute-phase/steps/protected-branch.md +21 -0
  266. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +4 -2
  267. package/gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md +25 -0
  268. package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +39 -0
  269. package/gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md +2 -0
  270. package/gsd-core/workflows/execute-phase.md +68 -66
  271. package/gsd-core/workflows/execute-plan.md +25 -20
  272. package/gsd-core/workflows/explore.md +3 -1
  273. package/gsd-core/workflows/extract-learnings.md +3 -1
  274. package/gsd-core/workflows/fast.md +8 -2
  275. package/gsd-core/workflows/forensics.md +3 -1
  276. package/gsd-core/workflows/graduation.md +6 -6
  277. package/gsd-core/workflows/health.md +4 -7
  278. package/gsd-core/workflows/help/modes/brief.md +2 -0
  279. package/gsd-core/workflows/help/modes/default.md +2 -0
  280. package/gsd-core/workflows/help/modes/full.md +12 -0
  281. package/gsd-core/workflows/help/modes/topic.md +2 -0
  282. package/gsd-core/workflows/help.md +2 -0
  283. package/gsd-core/workflows/import.md +17 -14
  284. package/gsd-core/workflows/inbox.md +5 -6
  285. package/gsd-core/workflows/ingest-docs.md +45 -12
  286. package/gsd-core/workflows/insert-phase.md +7 -5
  287. package/gsd-core/workflows/list-phase-assumptions.md +2 -0
  288. package/gsd-core/workflows/list-seeds.md +7 -3
  289. package/gsd-core/workflows/list-workspaces.md +3 -1
  290. package/gsd-core/workflows/manager.md +15 -26
  291. package/gsd-core/workflows/map-codebase.md +3 -1
  292. package/gsd-core/workflows/milestone-summary.md +3 -1
  293. package/gsd-core/workflows/mvp-phase.md +3 -3
  294. package/gsd-core/workflows/new-milestone.md +10 -22
  295. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +1 -1
  296. package/gsd-core/workflows/new-project.md +17 -29
  297. package/gsd-core/workflows/new-workspace.md +2 -2
  298. package/gsd-core/workflows/next.md +4 -2
  299. package/gsd-core/workflows/node-repair.md +2 -0
  300. package/gsd-core/workflows/note.md +2 -0
  301. package/gsd-core/workflows/onboard.md +1 -1
  302. package/gsd-core/workflows/pause-work.md +20 -5
  303. package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +1 -1
  304. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +100 -18
  305. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +4 -4
  306. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +12 -3
  307. package/gsd-core/workflows/plan-phase.md +251 -54
  308. package/gsd-core/workflows/plan-review-convergence.md +148 -19
  309. package/gsd-core/workflows/plant-seed.md +3 -3
  310. package/gsd-core/workflows/pr-branch.md +195 -51
  311. package/gsd-core/workflows/profile-user.md +17 -15
  312. package/gsd-core/workflows/progress/steps/forensic-audit.md +1 -1
  313. package/gsd-core/workflows/progress.md +52 -15
  314. package/gsd-core/workflows/quick/steps/discussion-phase.md +1 -3
  315. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +38 -5
  316. package/gsd-core/workflows/quick/steps/quick-verification.md +2 -4
  317. package/gsd-core/workflows/quick/steps/research-phase.md +5 -7
  318. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +3 -3
  319. package/gsd-core/workflows/quick-batch/steps/batch-init.md +55 -0
  320. package/gsd-core/workflows/quick-batch/steps/completion.md +65 -0
  321. package/gsd-core/workflows/quick-batch/steps/merge-wave.md +100 -0
  322. package/gsd-core/workflows/quick-batch/steps/plan-checker-loop.md +147 -0
  323. package/gsd-core/workflows/quick-batch/steps/planner-wave.md +158 -0
  324. package/gsd-core/workflows/quick-batch/steps/research-phase.md +95 -0
  325. package/gsd-core/workflows/quick-batch/steps/resume-mode.md +49 -0
  326. package/gsd-core/workflows/quick-batch/steps/verification-wave.md +73 -0
  327. package/gsd-core/workflows/quick-batch/steps/worktree-dispatch.md +169 -0
  328. package/gsd-core/workflows/quick-batch.md +203 -0
  329. package/gsd-core/workflows/quick.md +33 -32
  330. package/gsd-core/workflows/reapply-patches.md +2 -0
  331. package/gsd-core/workflows/remove-phase.md +6 -4
  332. package/gsd-core/workflows/remove-workspace.md +3 -3
  333. package/gsd-core/workflows/resume-project.md +14 -14
  334. package/gsd-core/workflows/review.md +404 -21
  335. package/gsd-core/workflows/scan.md +3 -1
  336. package/gsd-core/workflows/section-manifest.json +12 -0
  337. package/gsd-core/workflows/secure-phase.md +3 -3
  338. package/gsd-core/workflows/session-report.md +2 -0
  339. package/gsd-core/workflows/settings-advanced.md +9 -9
  340. package/gsd-core/workflows/settings-integrations.md +66 -32
  341. package/gsd-core/workflows/settings.md +4 -6
  342. package/gsd-core/workflows/ship.md +22 -16
  343. package/gsd-core/workflows/sketch-wrap-up.md +13 -17
  344. package/gsd-core/workflows/sketch.md +13 -19
  345. package/gsd-core/workflows/smart-entry.md +4 -6
  346. package/gsd-core/workflows/spec-phase.md +31 -4
  347. package/gsd-core/workflows/spike-wrap-up.md +9 -11
  348. package/gsd-core/workflows/spike.md +21 -32
  349. package/gsd-core/workflows/stats.md +4 -2
  350. package/gsd-core/workflows/sync-skills.md +13 -5
  351. package/gsd-core/workflows/thread.md +13 -7
  352. package/gsd-core/workflows/transition.md +7 -5
  353. package/gsd-core/workflows/ui-phase.md +36 -21
  354. package/gsd-core/workflows/ui-review.md +7 -11
  355. package/gsd-core/workflows/ultraplan-phase.md +7 -13
  356. package/gsd-core/workflows/undo.md +9 -17
  357. package/gsd-core/workflows/update.md +47 -48
  358. package/gsd-core/workflows/validate-phase.md +3 -3
  359. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +25 -1
  360. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +1 -1
  361. package/gsd-core/workflows/verify-work.md +106 -21
  362. package/hooks/dist/gsd-agent-isolation-guard.js +77 -38
  363. package/hooks/dist/gsd-check-update-worker.js +19 -2
  364. package/hooks/dist/gsd-config-reload.js +18 -12
  365. package/hooks/dist/gsd-context-monitor.js +302 -22
  366. package/hooks/dist/gsd-cursor-post-tool.js +3 -1
  367. package/hooks/dist/gsd-cursor-pre-tool.js +3 -1
  368. package/hooks/dist/gsd-cursor-session-start.js +2 -1
  369. package/hooks/dist/gsd-cursor-stop.js +2 -1
  370. package/hooks/dist/gsd-cursor-subagent-start.js +28 -23
  371. package/hooks/dist/gsd-cursor-subagent-stop.js +3 -1
  372. package/hooks/dist/gsd-ensure-canonical-path.js +2 -1
  373. package/hooks/dist/gsd-graphify-update.sh +22 -18
  374. package/hooks/dist/gsd-node-runner.sh +77 -0
  375. package/hooks/dist/gsd-phase-boundary.sh +1 -0
  376. package/hooks/dist/gsd-prompt-guard.js +46 -12
  377. package/hooks/dist/gsd-read-guard.js +18 -7
  378. package/hooks/dist/gsd-read-injection-scanner.js +22 -13
  379. package/hooks/dist/gsd-secret-read-guard.js +1079 -0
  380. package/hooks/dist/gsd-session-state.sh +1 -0
  381. package/hooks/dist/gsd-statusline.js +222 -29
  382. package/hooks/dist/gsd-validate-commit.sh +523 -12
  383. package/hooks/dist/gsd-windsurf-pre-command.js +16 -11
  384. package/hooks/dist/gsd-windsurf-pre-write.js +22 -13
  385. package/hooks/dist/gsd-workflow-guard.js +36 -17
  386. package/hooks/dist/gsd-worktree-path-guard.js +36 -21
  387. package/hooks/dist/gsd-write-guard.js +35 -25
  388. package/hooks/dist/lib/cli-exit.js +560 -0
  389. package/hooks/dist/lib/exit-code-registry.js +98 -0
  390. package/hooks/dist/lib/git-cmd.js +210 -1
  391. package/hooks/dist/lib/git-probe.js +84 -0
  392. package/hooks/dist/lib/hook-exit.js +81 -0
  393. package/hooks/dist/lib/injection-patterns.js +36 -6
  394. package/hooks/dist/managed-hooks-registry.cjs +4 -0
  395. package/hooks/gsd-agent-isolation-guard.js +77 -38
  396. package/hooks/gsd-check-update-worker.js +19 -2
  397. package/hooks/gsd-config-reload.js +18 -12
  398. package/hooks/gsd-context-monitor.js +302 -22
  399. package/hooks/gsd-cursor-post-tool.js +3 -1
  400. package/hooks/gsd-cursor-pre-tool.js +3 -1
  401. package/hooks/gsd-cursor-session-start.js +2 -1
  402. package/hooks/gsd-cursor-stop.js +2 -1
  403. package/hooks/gsd-cursor-subagent-start.js +28 -23
  404. package/hooks/gsd-cursor-subagent-stop.js +3 -1
  405. package/hooks/gsd-ensure-canonical-path.js +2 -1
  406. package/hooks/gsd-graphify-update.sh +22 -18
  407. package/hooks/gsd-node-runner.sh +77 -0
  408. package/hooks/gsd-phase-boundary.sh +1 -0
  409. package/hooks/gsd-prompt-guard.js +46 -12
  410. package/hooks/gsd-read-guard.js +18 -7
  411. package/hooks/gsd-read-injection-scanner.js +22 -13
  412. package/hooks/gsd-secret-read-guard.js +1079 -0
  413. package/hooks/gsd-session-state.sh +1 -0
  414. package/hooks/gsd-statusline.js +222 -29
  415. package/hooks/gsd-validate-commit.sh +523 -12
  416. package/hooks/gsd-windsurf-pre-command.js +16 -11
  417. package/hooks/gsd-windsurf-pre-write.js +22 -13
  418. package/hooks/gsd-workflow-guard.js +36 -17
  419. package/hooks/gsd-worktree-path-guard.js +36 -21
  420. package/hooks/gsd-write-guard.js +35 -25
  421. package/hooks/hooks.json +6 -0
  422. package/hooks/lib/cli-exit.js +560 -0
  423. package/hooks/lib/exit-code-registry.js +98 -0
  424. package/hooks/lib/git-cmd.js +210 -1
  425. package/hooks/lib/git-probe.js +84 -0
  426. package/hooks/lib/hook-exit.js +81 -0
  427. package/hooks/lib/injection-patterns.js +36 -6
  428. package/hooks/managed-hooks-registry.cjs +4 -0
  429. package/package.json +14 -9
  430. package/scripts/base64-scan.sh +74 -12
  431. package/scripts/build-hooks.js +12 -0
  432. package/scripts/check-glossary-refs.cjs +77 -15
  433. package/scripts/check-mutation-score-ratchet.cjs +156 -0
  434. package/scripts/ci-check-job-near-cap.cjs +49 -0
  435. package/scripts/ci-pr-mergeability.cjs +262 -0
  436. package/scripts/ci-test-scope.cjs +52 -12
  437. package/scripts/ci-timeout-report.cjs +230 -0
  438. package/scripts/docs-guard-registry.cjs +406 -0
  439. package/scripts/gen-capability-registry.cjs +8 -6
  440. package/scripts/gen-exit-code-docs.cjs +318 -0
  441. package/scripts/gen-exit-code-registry.cjs +891 -0
  442. package/scripts/gen-features.cjs +836 -0
  443. package/scripts/gen-hooks-cli-exit.cjs +239 -0
  444. package/scripts/gen-install-tree-fixtures.cjs +2 -2
  445. package/scripts/gen-loop-host-contract.cjs +189 -4
  446. package/scripts/gen-scripts-cli-exit.cjs +185 -0
  447. package/scripts/gen-state-md-docs.cjs +727 -0
  448. package/scripts/{test-failure-reasons.cjs → gsd-test-gate-reasons.cjs} +6 -0
  449. package/scripts/lib/ci-job-timing.cjs +72 -0
  450. package/scripts/lib/cli-exit.cjs +546 -44
  451. package/scripts/lib/drift-scan.cjs +32 -2
  452. package/scripts/lib/exit-code-registry.cjs +98 -0
  453. package/scripts/lib/ndjson-reporter.cjs +119 -0
  454. package/scripts/lib/shellcheck-fetch.cjs +247 -0
  455. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -6
  456. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +1 -1
  457. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +1 -1
  458. package/scripts/lint-docs-guard-registration.cjs +495 -0
  459. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +198 -0
  460. package/scripts/lint-eslint-glob-coverage.allowlist.json +4 -0
  461. package/scripts/{lint-fix-has-regression-test.cjs → lint-fix-has-regression-tests.cjs} +12 -6
  462. package/scripts/lint-health-diagnostic-rule-table.cjs +65 -8
  463. package/scripts/lint-mutation-test-derivation-drift.cjs +86 -0
  464. package/scripts/lint-phase-enumeration-drift.cjs +45 -14
  465. package/scripts/lint-phase-id-drift.cjs +133 -8
  466. package/scripts/lint-planning-prompt-drift.cjs +38 -1
  467. package/scripts/lint-portable-grep.cjs +176 -0
  468. package/scripts/lint-removed-but-needed.cjs +184 -16
  469. package/scripts/lint-response-language-coverage.cjs +524 -0
  470. package/scripts/lint-seam-enforcement.cjs +182 -0
  471. package/scripts/lint-slug-derivation-drift.cjs +921 -0
  472. package/scripts/lint-source-test-name-collision.cjs +241 -0
  473. package/scripts/lint-state-write-path-drift.cjs +337 -432
  474. package/scripts/lint-test-file-count.allowlist.json +124 -4
  475. package/scripts/lint-test-file-count.cjs +25 -3
  476. package/scripts/lint-unreachable-guard-drift.cjs +51 -64
  477. package/scripts/lint-vendored-deps.cjs +208 -35
  478. package/scripts/lint-workflow-shellcheck-baseline.json +1027 -0
  479. package/scripts/lint-workflow-shellcheck.cjs +614 -0
  480. package/scripts/mutation-matrix.cjs +599 -50
  481. package/scripts/npm-audit-baseline.cjs +376 -0
  482. package/scripts/prompt-injection-scan.sh +83 -14
  483. package/scripts/require-issue-link-policy.cjs +16 -1
  484. package/scripts/secret-scan.sh +75 -13
  485. package/scripts/select-docs-guards.cjs +56 -0
  486. package/scripts/sync-runtime-launcher.cjs +22 -3
  487. package/skills/gsd-discuss-phase/SKILL.md +1 -1
  488. package/skills/gsd-execute-phase/SKILL.md +1 -1
  489. package/skills/gsd-import/SKILL.md +1 -1
  490. package/skills/gsd-ns-workflow/SKILL.md +1 -0
  491. package/skills/gsd-phase/SKILL.md +1 -1
  492. package/skills/gsd-quick/SKILL.md +8 -4
  493. package/skills/gsd-quick-batch/SKILL.md +105 -0
  494. package/skills/gsd-surface/SKILL.md +18 -8
  495. package/vscode/package.json +1 -1
  496. package/bin/lib/ui-safety-gate.cjs +0 -109
  497. package/scripts/lint-emitted-drift-ack.cjs +0 -344
  498. package/scripts/state-write-path-drift-baseline.json +0 -19
@@ -13,6 +13,7 @@ const node_fs_1 = __importDefault(require("node:fs"));
13
13
  const node_path_1 = __importDefault(require("node:path"));
14
14
  const text_lines_cjs_1 = require("./text-lines.cjs");
15
15
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
16
+ const pattern_cjs_1 = require("./pattern.cjs");
16
17
  const security_cjs_1 = require("./security.cjs");
17
18
  // eslint-disable-next-line @typescript-eslint/no-require-imports
18
19
  const ioMod = require("./io.cjs");
@@ -54,7 +55,7 @@ const planningWorkspace = require("./planning-workspace.cjs");
54
55
  const { planningDir, planningPaths } = planningWorkspace;
55
56
  // eslint-disable-next-line @typescript-eslint/no-require-imports
56
57
  const frontmatter = require("./frontmatter.cjs");
57
- const { extractFrontmatter } = frontmatter;
58
+ const { extractFrontmatter, agentScalarNeedsDoubleQuoting, escapeDoubleQuotedScalar } = frontmatter;
58
59
  // eslint-disable-next-line @typescript-eslint/no-require-imports
59
60
  const modelProfiles = require("./model-profiles.cjs");
60
61
  const { MODEL_PROFILES, VALID_PHASE_TYPES } = modelProfiles;
@@ -159,11 +160,11 @@ function cmdGenerateSlug(text, raw) {
159
160
  if (!text) {
160
161
  error('text required for slug generation');
161
162
  }
162
- const slug = text
163
- .toLowerCase()
164
- .replace(/[^a-z0-9]+/g, '-')
165
- .replace(/^-+|-+$/g, '')
166
- .substring(0, 60);
163
+ // #3883 (ADR-3473 §8.3): delegate to the canonical slug formula
164
+ // (generateSlugInternal, core-utils.cts) instead of re-implementing it —
165
+ // this call site previously diverged from it (Cyrillic collapsed to "",
166
+ // and truncation could leave a trailing hyphen; #2848/#2849).
167
+ const slug = coreUtilsMod.generateSlugInternal(text) ?? '';
167
168
  const result = { slug };
168
169
  output(result, raw, slug);
169
170
  }
@@ -529,7 +530,12 @@ function cmdResolveExecution(cwd, agentType, raw, opts) {
529
530
  : resolveEffortInternal(cwd, agentType, effortOpts);
530
531
  const fastMode = resolveFastModeInternal(cwd, agentType, fastModeOpts);
531
532
  const runtime = config['runtime'] || 'claude';
532
- const rendered = (0, model_catalog_cjs_1.renderEffortForRuntime)(runtime, effort);
533
+ // #3007: pass the resolved model so the per-model advertised-effort ceiling
534
+ // (CODEX_MODEL_EFFORT) is reachable from this production seam. `model` may
535
+ // be a tier alias or a non-Codex id for other runtimes — that's fine and
536
+ // must not be special-cased here: advertisedCodexEffort() falls back to the
537
+ // family baseline for any id it doesn't recognize.
538
+ const rendered = (0, model_catalog_cjs_1.renderEffortForRuntime)(runtime, effort, model);
533
539
  const fastModeSupported = model_catalog_cjs_1.RUNTIMES_WITH_FAST_MODE.has(runtime);
534
540
  // #3534 (10a): the effective effort — what the installed agent will actually
535
541
  // run at. `effort` above is the config cascade; for the claude runtime the
@@ -584,6 +590,9 @@ function cmdResolveExecution(cwd, agentType, raw, opts) {
584
590
  effort_rendered: rendered.value,
585
591
  effort_param: rendered.param,
586
592
  effort_propagation: rendered.channel,
593
+ effort_requested: rendered.requested,
594
+ effort_clamped: rendered.clamped,
595
+ effort_clamp_reason: rendered.reason,
587
596
  effort_effective: effortEffective,
588
597
  effort_effective_source: effortEffectiveSource,
589
598
  fast_mode: fastMode,
@@ -616,6 +625,11 @@ function cmdResolveExecution(cwd, agentType, raw, opts) {
616
625
  * applies here exactly as everywhere else: an unknown host, a missing axis, or the
617
626
  * `undocumented` sentinel all degrade to the safe floor rather than being trusted.
618
627
  * Never throws — a lookup failure yields `'none'`, which renders no argument.
628
+ *
629
+ * On the module's export surface for #4255: the reviewer-lane effort resolver renders a lane's own
630
+ * configured level through this same negotiation, so a lane can never emit an argument for a host
631
+ * whose negotiated surface does not accept one. One negotiation, both channels — a second copy in
632
+ * the lane path is exactly how the two would drift.
619
633
  */
620
634
  function effortSurfaceForHost(cwd, host) {
621
635
  void cwd;
@@ -638,46 +652,109 @@ function effortSurfaceForHost(cwd, host) {
638
652
  }
639
653
  }
640
654
  /**
641
- * #488 — Replace or inject the `effort:` value in YAML frontmatter.
655
+ * #488 — Replace or inject the `<key>:` value in YAML frontmatter.
642
656
  * Unlike injectEffortFrontmatter (install.js), this overwrites an existing value.
657
+ * #3706: key-parameterised so the same line-editor serves both claude's
658
+ * `effort:` and OpenCode's `variant:`. #3706: all offsets (eol, openLen,
659
+ * closingStart) are derived from the MATCHED BLOCK, not the start of the
660
+ * file, and the existing-key replace is scoped to the frontmatter span only.
643
661
  */
644
- function setEffortFrontmatter(content, effortValue) {
645
- const eol = /^---\r\n/.test(content) ? '\r\n' : '\n';
662
+ function setFrontmatterKeyLine(content, key, value) {
646
663
  const fmRe = /^---\r?\n([\s\S]*?)^---\r?$/m;
647
664
  const match = fmRe.exec(content);
648
665
  if (!match)
649
666
  return content;
650
667
  const fmBody = match[1];
651
- if (/^effort:/m.test(fmBody)) {
652
- return content.replace(/^(effort:)[ \t]*.*$/m, `$1 ${effortValue}`);
653
- }
668
+ // Both writers of these frontmatter keys — this sync path and the
669
+ // install-side `frontmatterScalar` in runtime-artifact-conversion.cts —
670
+ // now share one escaping rule: quote via `agentScalarNeedsDoubleQuoting` +
671
+ // `escapeDoubleQuotedScalar` (both from frontmatter.cts) rather than each
672
+ // interpolating `value` raw/differently.
673
+ const renderedValue = agentScalarNeedsDoubleQuoting(value) ? `"${escapeDoubleQuotedScalar(value)}"` : value;
674
+ // EOL comes from the MATCHED BLOCK, not the start of the file. With a
675
+ // preamble the two can disagree, and on a CRLF document that misaligns every
676
+ // offset below by one byte and mangles the opening fence.
677
+ const eol = /^---\r\n/.test(match[0]) ? '\r\n' : '\n';
654
678
  const openLen = 3 + eol.length;
655
- const closingStart = match.index + openLen + fmBody.length;
656
- return content.slice(0, closingStart) + `effort: ${effortValue}${eol}` + content.slice(closingStart);
679
+ const bodyStart = match.index + openLen;
680
+ const closingStart = bodyStart + fmBody.length;
681
+ // #3706: key is now generic (not just the literal 'effort'/'variant'
682
+ // callers happen to pass today) — escape it before interpolating into the
683
+ // RegExp so a future caller can't have its key metacharacters reinterpreted.
684
+ const keyLineRe = new RegExp(`^(${(0, pattern_cjs_1.escapeRegex)(key)}:)[ \\t]*.*$`, 'm');
685
+ if (keyLineRe.test(fmBody)) {
686
+ // #3706: a duplicated `<key>:` line is already invalid YAML, but a
687
+ // non-first-wins reader (last-wins) would otherwise honour a stale
688
+ // second occurrence left behind by a naive single-hit replace, while
689
+ // this function's own single-hit read reports "in sync" — a
690
+ // permanently non-converging state. Use a GLOBAL replace with a
691
+ // first-hit flag so every occurrence collapses to exactly one, IN THE
692
+ // POSITION of the first occurrence (never delete-then-append, which
693
+ // would move the key to the end of the frontmatter and churn every
694
+ // already-generated single-occurrence file).
695
+ const escaped = (0, pattern_cjs_1.escapeRegex)(key);
696
+ let seen = false;
697
+ const newBody = fmBody.replace(new RegExp(`^${escaped}:[ \\t]*.*(\\r?\\n?)`, 'gm'), (_m, nl) => {
698
+ if (!seen) {
699
+ seen = true;
700
+ return `${key}: ${renderedValue}${nl}`;
701
+ }
702
+ return '';
703
+ });
704
+ // Replace INSIDE the frontmatter span only: a whole-file /m replace would
705
+ // rewrite an earlier preamble line that happens to start with this key.
706
+ return content.slice(0, bodyStart) + newBody + content.slice(closingStart);
707
+ }
708
+ return content.slice(0, closingStart) + `${key}: ${renderedValue}${eol}` + content.slice(closingStart);
657
709
  }
658
710
  /**
659
- * #3533 (10d) — remove exactly the frontmatter `effort:` line (and its line
711
+ * #3533 (10d) — remove exactly the frontmatter `<key>:` line (and its line
660
712
  * ending) so an agent configured for `inherit` carries NO key. Mirrors the
661
713
  * codex-agent-toml strip discipline: targeted line removal, EOL-aware, every
662
714
  * other byte (comments, sibling keys, the body) untouched.
715
+ * #3706: key-parameterised so the same line-editor serves both claude's
716
+ * `effort:` and OpenCode's `variant:`. #3706: openLen is derived from the
717
+ * MATCHED BLOCK, not the start of the file — a preamble on a CRLF document
718
+ * would otherwise misalign every offset below.
663
719
  */
664
- function removeEffortFrontmatter(content) {
720
+ function removeFrontmatterKeyLine(content, key) {
665
721
  // Scoped to the FIRST frontmatter block (not a whole-file /m match): a
666
- // preamble or body line starting with `effort:` (a fenced config example,
722
+ // preamble or body line starting with `<key>:` (a fenced config example,
667
723
  // a thematic-break flanked fragment) must never be the line removed.
668
724
  const fmRe = /^---\r?\n([\s\S]*?)^---\r?$/m;
669
725
  const match = fmRe.exec(content);
670
726
  if (!match)
671
727
  return content;
672
728
  const fmBody = match[1];
673
- const lineRe = /^effort:[ \t]*.*\r?\n?/m;
729
+ // #3706: same generic-key escape as setFrontmatterKeyLine above.
730
+ const lineRe = new RegExp(`^${(0, pattern_cjs_1.escapeRegex)(key)}:[ \\t]*.*\\r?\\n?`, 'm');
674
731
  if (!lineRe.test(fmBody))
675
732
  return content;
676
- const strippedFm = fmBody.replace(lineRe, '');
677
- const openLen = 3 + (/^---\r\n/.test(content) ? 2 : 1);
733
+ // A duplicate `<key>:` mapping key is already invalid YAML (a document with
734
+ // two `effort:`/`variant:` lines does not parse), so this is robustness
735
+ // against a malformed document, not a live corruption path. Still, "a null
736
+ // target means the key must not exist" is an invariant this function must
737
+ // leave true on disk — a non-global replace here would strip only the
738
+ // FIRST occurrence and require a second run to converge. Use a fresh
739
+ // global RegExp for the strip so every occurrence in the frontmatter body
740
+ // is removed in one pass.
741
+ const stripAllRe = new RegExp(`^${(0, pattern_cjs_1.escapeRegex)(key)}:[ \\t]*.*\\r?\\n?`, 'gm');
742
+ const strippedFm = fmBody.replace(stripAllRe, '');
743
+ // Same rule as setFrontmatterKeyLine: the EOL must come from the matched
744
+ // block, not the start of the file, or a preambled CRLF document misaligns.
745
+ const eol = /^---\r\n/.test(match[0]) ? '\r\n' : '\n';
746
+ const openLen = 3 + eol.length;
678
747
  const closingStart = match.index + openLen + fmBody.length;
679
748
  return content.slice(0, match.index + openLen) + strippedFm + content.slice(closingStart);
680
749
  }
750
+ /** #488 — Replace or inject the `effort:` value in YAML frontmatter. */
751
+ function setEffortFrontmatter(content, effortValue) {
752
+ return setFrontmatterKeyLine(content, 'effort', effortValue);
753
+ }
754
+ /** #3533 (10d) — remove exactly the frontmatter `effort:` line (and its line ending). */
755
+ function removeEffortFrontmatter(content) {
756
+ return removeFrontmatterKeyLine(content, 'effort');
757
+ }
681
758
  /**
682
759
  * #488 — Re-sync effort: frontmatter in all installed gsd-*.md agent files to
683
760
  * match the current effort config, without requiring a full reinstall.
@@ -701,6 +778,14 @@ function cmdEffortSync(cwd, raw, opts) {
701
778
  cmdEffortSyncCodex(raw, dryRun, opts.configDir);
702
779
  return;
703
780
  }
781
+ // #3706: install now bakes OpenCode's resolved effort into agent
782
+ // frontmatter under the `variant:` key (not `effort:`), so OpenCode gets
783
+ // its own sync path — mirroring the codex branch above — rather than
784
+ // falling into the generic "does not use effort: frontmatter" skip.
785
+ if (runtime === 'opencode') {
786
+ cmdEffortSyncOpencode(cwd, raw, dryRun, opts.configDir);
787
+ return;
788
+ }
704
789
  if (runtime !== 'claude') {
705
790
  output({ synced: 0, skipped: 0, changes: [], dry_run: dryRun, reason: `runtime '${runtime}' does not use effort: frontmatter` }, raw, '');
706
791
  return;
@@ -729,14 +814,46 @@ function cmdEffortSync(cwd, raw, opts) {
729
814
  catch {
730
815
  return false;
731
816
  }
732
- });
817
+ }).sort(); // #3706: sorted like the codex and
818
+ // opencode branches — readdir order is platform-dependent, so leaving it unsorted makes the
819
+ // reported `changes` ordering differ across machines for identical inputs.
733
820
  const changes = [];
734
821
  let synced = 0;
735
822
  let skipped = 0;
823
+ // Local-only counter: reads AND writes are both guarded in this loop (an
824
+ // unreadable or unwritable agent file must not abort the whole sweep), but
825
+ // this result shape (`{synced, skipped, changes, dry_run, agents_dir}`) is
826
+ // long-standing and widely consumed, so it deliberately gains NO new key
827
+ // (no `read_failures`/`write_failures`, unlike the codex/opencode branches
828
+ // below). Instead every per-file failure — read or write — is folded into
829
+ // `skipped` and rides the raw-mode summary token below — `output()`'s
830
+ // third argument is never merged into the emitted JSON object (see io.cts
831
+ // `output()`: it is only read when `raw === true`, entirely replacing the
832
+ // JSON payload), so flipping it to `'failed'` costs nothing in the wire
833
+ // shape while still surfacing the failure to a raw-mode caller. The three
834
+ // branches differ on that reporting shape, but are now also consistent in
835
+ // HOW they publish: every write below goes through the same tmp-file +
836
+ // chmod + retryRenameSync atomic-publish sequence used by
837
+ // cmdEffortSyncCodex and cmdEffortSyncOpencode, so a fault mid-write can
838
+ // never leave an agent file truncated or empty.
839
+ let fileFailureCount = 0;
736
840
  for (const file of files) {
737
841
  const agentName = file.replace(/\.md$/, '');
738
842
  const filePath = node_path_1.default.join(agentsDir, file);
739
- const content = node_fs_1.default.readFileSync(filePath, 'utf8');
843
+ let content;
844
+ try {
845
+ content = node_fs_1.default.readFileSync(filePath, 'utf8');
846
+ }
847
+ catch {
848
+ // An unreadable agent file must not abort the whole sweep. Deliberately
849
+ // NOT adding a new field here: this result shape (`{synced, skipped,
850
+ // changes, dry_run, agents_dir}`) is long-standing and widely consumed,
851
+ // so the failure is folded into `skipped` only, with no
852
+ // read_failures/write_failures list — see `fileFailureCount` above.
853
+ skipped++;
854
+ fileFailureCount++;
855
+ continue;
856
+ }
740
857
  // Resolve using install-time logic: home defaults merged with project config.
741
858
  const universalEffort = resolveInstallTimeEffort(effortCfg, agentName);
742
859
  // #3533 (10d): 'inherit' means the key must NOT exist. An absent key is
@@ -744,45 +861,154 @@ function cmdEffortSync(cwd, raw, opts) {
744
861
  // drift and the sync re-added a hand-stripped key on every apply. A
745
862
  // present key under inherit is stripped, reported as {from, to: null}.
746
863
  if (universalEffort === 'inherit') {
747
- // eslint-disable-next-line local/no-unbounded-quantifier -- same lazy `*?` bounded by the `^---$/m` closing anchor as the concrete-path fmMatch below; duplicated here so the inherit branch validates against the same frontmatter span the strip targets
748
864
  const fmMatchInherit = /^---\r?\n([\s\S]*?)^---\r?$/m.exec(content);
749
865
  if (!fmMatchInherit) {
750
866
  skipped++;
751
867
  continue;
752
868
  }
753
- const effortMatchInherit = /^effort:[ \t]*(.+?)[ \t]*$/m.exec(fmMatchInherit[1]);
754
- if (!effortMatchInherit) {
869
+ // Presence and value are distinct questions: `effort:` with an EMPTY
870
+ // value is a key that IS present but whose captured value is null (the
871
+ // `(.+?)` group requires at least one char). Deciding "already correct"
872
+ // from a null value alone is wrong here — it would leave an
873
+ // unresolvable `effort: null` key on disk forever. Test presence with
874
+ // its own regex, and only compare values once presence is known.
875
+ const effortPresentInherit = /^effort:/m.test(fmMatchInherit[1]);
876
+ if (!effortPresentInherit) {
755
877
  skipped++;
756
878
  continue;
757
879
  }
758
- changes.push({ agent: agentName, from: effortMatchInherit[1], to: null });
759
- synced++;
880
+ const effortMatchInherit = /^effort:[ \t]*(.+?)[ \t]*$/m.exec(fmMatchInherit[1]);
881
+ // `effortPresentInherit` is guaranteed true here (checked above), so a
882
+ // failed value match means the key is present with an EMPTY value —
883
+ // report `''`, not `null`, so "present-but-empty" is never conflated
884
+ // with "absent" in the sync output.
760
885
  if (!dryRun) {
761
- node_fs_1.default.writeFileSync(filePath, removeEffortFrontmatter(content));
886
+ // Atomic publish AND mode preservation, same discipline as
887
+ // cmdEffortSyncCodex/cmdEffortSyncOpencode: write to a sibling tmp
888
+ // file, chmod it to match filePath's existing (masked) mode, then
889
+ // retryRenameSync it over the target so filePath is either the old
890
+ // bytes or the new ones, never half-written and never dropped to a
891
+ // default mode. On any failure the tmp file is unlinked (best-effort)
892
+ // and the write is reported (folded into `skipped`/`fileFailureCount`,
893
+ // no new field), not thrown, so the remaining agents still get
894
+ // processed. ONE failure path for this site — no nested try/catch.
895
+ const tmpPathInherit = `${filePath}.tmp.${process.pid}`;
896
+ // Stat filePath BEFORE the write so its mode can be passed at
897
+ // CREATION time — a plain `writeFileSync(tmpPath, data)` creates the
898
+ // tmp file at the default `0666 & ~umask` even when filePath is more
899
+ // restrictive. Best-effort only: a stat failure must not abort the
900
+ // sync, since the content write is what matters, not the mode.
901
+ let originalModeInherit;
902
+ try {
903
+ originalModeInherit = node_fs_1.default.statSync(filePath).mode & 0o7777;
904
+ }
905
+ catch { /* non-fatal: fall back to writing without an explicit mode */ }
906
+ try {
907
+ node_fs_1.default.writeFileSync(tmpPathInherit, removeEffortFrontmatter(content), originalModeInherit !== undefined ? { mode: originalModeInherit } : undefined);
908
+ // Not redundant with the `mode` option above: `mode` only applies
909
+ // when the file is actually created (O_CREAT). A leftover tmp file
910
+ // from an earlier crashed run would be reused (truncated) at its
911
+ // OLD mode instead, and this chmod is what corrects that case.
912
+ // Best-effort only: a chmod failure must not abort the sync, since
913
+ // the content write is what matters, not the mode.
914
+ try {
915
+ if (originalModeInherit !== undefined)
916
+ node_fs_1.default.chmodSync(tmpPathInherit, originalModeInherit);
917
+ }
918
+ catch { /* non-fatal: proceed with default tmp-file mode */ }
919
+ (0, shell_command_projection_cjs_1.retryRenameSync)(tmpPathInherit, filePath);
920
+ }
921
+ catch {
922
+ try {
923
+ node_fs_1.default.unlinkSync(tmpPathInherit);
924
+ }
925
+ catch { /* already gone or never created */ }
926
+ skipped++;
927
+ fileFailureCount++;
928
+ continue;
929
+ }
762
930
  }
931
+ changes.push({ agent: agentName, from: effortMatchInherit ? effortMatchInherit[1] : '', to: null });
932
+ synced++;
763
933
  continue;
764
934
  }
935
+ // `runtime` is guaranteed 'claude' by the guard above (#3007: only
936
+ // codex's 'ultra' rejection can produce a null value).
765
937
  const rendered = (0, model_catalog_cjs_1.renderEffortForRuntime)(runtime, universalEffort);
766
938
  const newEffortValue = rendered.value;
767
- // eslint-disable-next-line local/no-unbounded-quantifier -- lazy `*?` bounded by the `^---$/m` closing anchor, no nested quantifier, measured linear to 5MB (no-closing-marker adversarial input)
768
939
  const fmMatch = /^---\r?\n([\s\S]*?)^---\r?$/m.exec(content);
769
940
  if (!fmMatch) {
770
941
  skipped++;
771
942
  continue;
772
943
  }
944
+ // Presence and value are distinct questions here too: `currentEffort`
945
+ // reads null both when the key is ABSENT and when it is present with an
946
+ // EMPTY value. `effortPresent` disambiguates those two for the reported
947
+ // `from` below (never `null` when the key is present but empty) — but it
948
+ // has no bearing on the skip check that follows: `newEffortValue` is
949
+ // never null on this path (guarded above), so an absent key already
950
+ // yields `currentEffort === null !== newEffortValue` without consulting
951
+ // presence separately.
952
+ const effortPresent = /^effort:/m.test(fmMatch[1]);
773
953
  const effortMatch = /^effort:[ \t]*(.+?)[ \t]*$/m.exec(fmMatch[1]);
774
- const currentEffort = effortMatch ? effortMatch[1] : null;
954
+ // `null` (key absent) and `''` (key present, value empty) are distinct
955
+ // states `effortPresent` deliberately disambiguates — collapsing both to
956
+ // `null` here would make the reported `from` lie about which case fired.
957
+ const currentEffort = effortPresent ? (effortMatch ? effortMatch[1] : '') : null;
775
958
  if (currentEffort === newEffortValue) {
776
959
  skipped++;
777
960
  continue;
778
961
  }
779
- changes.push({ agent: agentName, from: currentEffort, to: newEffortValue });
780
- synced++;
781
962
  if (!dryRun) {
782
- node_fs_1.default.writeFileSync(filePath, setEffortFrontmatter(content, newEffortValue));
963
+ // Atomic publish AND mode preservation, same discipline as
964
+ // cmdEffortSyncCodex/cmdEffortSyncOpencode: write to a sibling tmp
965
+ // file, chmod it to match filePath's existing (masked) mode, then
966
+ // retryRenameSync it over the target so filePath is either the old
967
+ // bytes or the new ones, never half-written and never dropped to a
968
+ // default mode. On any failure the tmp file is unlinked (best-effort)
969
+ // and the write is reported (folded into `skipped`/`fileFailureCount`,
970
+ // no new field), not thrown, so the remaining agents still get
971
+ // processed. ONE failure path for this site — no nested try/catch.
972
+ const tmpPathSet = `${filePath}.tmp.${process.pid}`;
973
+ // Stat filePath BEFORE the write so its mode can be passed at CREATION
974
+ // time — a plain `writeFileSync(tmpPath, data)` creates the tmp file
975
+ // at the default `0666 & ~umask` even when filePath is more
976
+ // restrictive. Best-effort only: a stat failure must not abort the
977
+ // sync, since the content write is what matters, not the mode.
978
+ let originalModeSet;
979
+ try {
980
+ originalModeSet = node_fs_1.default.statSync(filePath).mode & 0o7777;
981
+ }
982
+ catch { /* non-fatal: fall back to writing without an explicit mode */ }
983
+ try {
984
+ node_fs_1.default.writeFileSync(tmpPathSet, setEffortFrontmatter(content, newEffortValue), originalModeSet !== undefined ? { mode: originalModeSet } : undefined);
985
+ // Not redundant with the `mode` option above: `mode` only applies
986
+ // when the file is actually created (O_CREAT). A leftover tmp file
987
+ // from an earlier crashed run would be reused (truncated) at its OLD
988
+ // mode instead, and this chmod is what corrects that case.
989
+ // Best-effort only: a chmod failure must not abort the sync, since
990
+ // the content write is what matters, not the mode.
991
+ try {
992
+ if (originalModeSet !== undefined)
993
+ node_fs_1.default.chmodSync(tmpPathSet, originalModeSet);
994
+ }
995
+ catch { /* non-fatal: proceed with default tmp-file mode */ }
996
+ (0, shell_command_projection_cjs_1.retryRenameSync)(tmpPathSet, filePath);
997
+ }
998
+ catch {
999
+ try {
1000
+ node_fs_1.default.unlinkSync(tmpPathSet);
1001
+ }
1002
+ catch { /* already gone or never created */ }
1003
+ skipped++;
1004
+ fileFailureCount++;
1005
+ continue;
1006
+ }
783
1007
  }
1008
+ changes.push({ agent: agentName, from: currentEffort, to: newEffortValue });
1009
+ synced++;
784
1010
  }
785
- output({ synced, skipped, changes, dry_run: dryRun, agents_dir: agentsDir }, raw, synced > 0 ? 'changed' : 'ok');
1011
+ output({ synced, skipped, changes, dry_run: dryRun, agents_dir: agentsDir }, raw, fileFailureCount > 0 ? 'failed' : synced > 0 ? 'changed' : 'ok');
786
1012
  }
787
1013
  /**
788
1014
  * ADR-2313 D7 (#3243) — the Codex branch of `cmdEffortSync`. Strips a stale
@@ -793,8 +1019,9 @@ function cmdEffortSync(cwd, raw, opts) {
793
1019
  * document is refused and reported, never partially rewritten (40-design.md
794
1020
  * "Reconciliation" — parseCodexAgentToml is the STRICT half of the reader/
795
1021
  * writer split). Result shape is additive over the claude branch's
796
- * `{synced, skipped, changes, dry_run, agents_dir}` — `refused` and
797
- * `write_failures` are new fields, never a reshape of the existing ones.
1022
+ * `{synced, skipped, changes, dry_run, agents_dir}` — `refused`,
1023
+ * `write_failures`, and `read_failures` are new fields, never a reshape of
1024
+ * the existing ones.
798
1025
  */
799
1026
  function cmdEffortSyncCodex(raw, dryRun, configDir) {
800
1027
  // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
@@ -822,12 +1049,25 @@ function cmdEffortSyncCodex(raw, dryRun, configDir) {
822
1049
  const changes = [];
823
1050
  const refused = [];
824
1051
  const writeFailures = [];
1052
+ const readFailures = [];
825
1053
  let synced = 0;
826
1054
  let skipped = 0;
827
1055
  for (const file of files) {
828
1056
  const agentName = file.replace(/\.toml$/, '');
829
1057
  const filePath = node_path_1.default.join(agentsDir, file);
830
- const content = node_fs_1.default.readFileSync(filePath, 'utf8');
1058
+ let content;
1059
+ try {
1060
+ content = node_fs_1.default.readFileSync(filePath, 'utf8');
1061
+ }
1062
+ catch (err) {
1063
+ // An unreadable agent file must not abort the whole sweep — mirrors the
1064
+ // opencode branch's own read guard, reported under its own
1065
+ // `read_failures` key so a caller can tell "never read" apart from
1066
+ // "read but write failed".
1067
+ skipped++;
1068
+ readFailures.push({ agent: agentName, file: filePath, error: err instanceof Error ? err.message : String(err) });
1069
+ continue;
1070
+ }
831
1071
  const parsed = (0, codex_agent_toml_cjs_1.parseCodexAgentToml)(content);
832
1072
  if (!parsed.ok) {
833
1073
  // Never partially rewritten (40-design.md, ADR-2313 reader/writer
@@ -869,8 +1109,32 @@ function cmdEffortSyncCodex(raw, dryRun, configDir) {
869
1109
  // bare fs.renameSync) carries the transient-Windows-lock retry per
870
1110
  // DEFECT.WINDOWS-FS-OPS.
871
1111
  const tmpPath = `${filePath}.tmp.${process.pid}`;
1112
+ // Stat filePath BEFORE the write so the original mode is available to
1113
+ // pass at creation time, not just at chmod time afterward — otherwise
1114
+ // the tmp file is briefly created at the default `0666 & ~umask`
1115
+ // (world-readable under a typical 022 umask) even when filePath is
1116
+ // e.g. 0600, exposing its contents for the window between creation and
1117
+ // chmod. Best-effort: a stat failure must not abort the sync, since the
1118
+ // content write is what matters, not the mode.
1119
+ let originalMode;
872
1120
  try {
873
- node_fs_1.default.writeFileSync(tmpPath, (0, codex_agent_toml_cjs_1.renderCodexAgentToml)(doc));
1121
+ originalMode = node_fs_1.default.statSync(filePath).mode & 0o7777;
1122
+ }
1123
+ catch { /* non-fatal: fall back to writing without an explicit mode */ }
1124
+ try {
1125
+ node_fs_1.default.writeFileSync(tmpPath, (0, codex_agent_toml_cjs_1.renderCodexAgentToml)(doc), originalMode !== undefined ? { mode: originalMode } : undefined);
1126
+ // Not redundant with the `mode` option above: `mode` only applies
1127
+ // when the file is actually created (O_CREAT). A leftover tmp file
1128
+ // from an earlier crashed run would be reused (truncated) at its OLD
1129
+ // mode instead, and this chmod is what corrects that case. Mask off
1130
+ // the file-type bits fs.statSync().mode carries (POSIX leaves
1131
+ // chmod's handling of those unspecified); best-effort only, since
1132
+ // the content write is what matters, not the mode.
1133
+ try {
1134
+ if (originalMode !== undefined)
1135
+ node_fs_1.default.chmodSync(tmpPath, originalMode);
1136
+ }
1137
+ catch { /* non-fatal: proceed with default tmp-file mode */ }
874
1138
  (0, shell_command_projection_cjs_1.retryRenameSync)(tmpPath, filePath);
875
1139
  }
876
1140
  catch (err) {
@@ -888,7 +1152,177 @@ function cmdEffortSyncCodex(raw, dryRun, configDir) {
888
1152
  changes.push(...pendingChanges);
889
1153
  synced++;
890
1154
  }
891
- output({ synced, skipped, changes, dry_run: dryRun, agents_dir: agentsDir, refused, write_failures: writeFailures }, raw, synced > 0 ? 'changed' : 'ok');
1155
+ output({ synced, skipped, changes, dry_run: dryRun, agents_dir: agentsDir, refused, write_failures: writeFailures, read_failures: readFailures }, raw, writeFailures.length > 0 || readFailures.length > 0 ? 'failed' : synced > 0 ? 'changed' : 'ok');
1156
+ }
1157
+ /**
1158
+ * #3706 — the OpenCode branch of `cmdEffortSync`. Maintains the `variant:`
1159
+ * frontmatter key install now bakes into every `~/.config/opencode/agents/
1160
+ * gsd-*.md` (or configDir-relative equivalent), mirroring exactly what
1161
+ * install writes: a resolved universal effort clamped through
1162
+ * `clampEffortForHost('opencode', ...)`. Null means the key must be ABSENT —
1163
+ * #3533 (10d): an absent key is the correct state under `inherit`, and a
1164
+ * level OpenCode does not accept must never be written, so both collapse to
1165
+ * the same `target: null` and the same removal path. Result shape is
1166
+ * additive over the claude branch, matching the CODEX branch's
1167
+ * `{synced, skipped, changes, dry_run, agents_dir, write_failures}`.
1168
+ */
1169
+ function cmdEffortSyncOpencode(cwd, raw, dryRun, configDir) {
1170
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
1171
+ const { getGlobalConfigDir } = require('./runtime-homes.cjs');
1172
+ const agentsDir = node_path_1.default.join(configDir || getGlobalConfigDir('opencode'), 'agents');
1173
+ if (!node_fs_1.default.existsSync(agentsDir)) {
1174
+ output({ synced: 0, skipped: 0, changes: [], dry_run: dryRun, agents_dir: agentsDir, reason: 'agents directory not found' }, raw, '');
1175
+ return;
1176
+ }
1177
+ // Skip symlinks — matches the claude branch's existing guard (only write
1178
+ // regular files, never follow a symlink into clobbering its target).
1179
+ const files = node_fs_1.default
1180
+ .readdirSync(agentsDir)
1181
+ .filter(f => {
1182
+ if (!f.startsWith('gsd-') || !f.endsWith('.md'))
1183
+ return false;
1184
+ try {
1185
+ return node_fs_1.default.lstatSync(node_path_1.default.join(agentsDir, f)).isFile();
1186
+ }
1187
+ catch {
1188
+ return false;
1189
+ }
1190
+ })
1191
+ .sort();
1192
+ // Use install-time resolvers: they merge ~/.gsd/defaults.json with project
1193
+ // config, matching the exact logic used when agents were originally
1194
+ // installed. Resolved once, outside the loop, like the claude branch.
1195
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
1196
+ const { readGsdEffectiveEffortConfig, resolveInstallTimeEffort } = require('./install-effort-resolver.cjs');
1197
+ const effortCfg = readGsdEffectiveEffortConfig(cwd);
1198
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
1199
+ const { clampEffortForHost } = require('./model-catalog.cjs');
1200
+ const changes = [];
1201
+ const writeFailures = [];
1202
+ const readFailures = [];
1203
+ let synced = 0;
1204
+ let skipped = 0;
1205
+ for (const file of files) {
1206
+ const agentName = file.replace(/\.md$/, '');
1207
+ const filePath = node_path_1.default.join(agentsDir, file);
1208
+ let content;
1209
+ try {
1210
+ content = node_fs_1.default.readFileSync(filePath, 'utf8');
1211
+ }
1212
+ catch (err) {
1213
+ // An unreadable agent file must not abort the whole sweep — degrade
1214
+ // like the write path below does, and report it under its own
1215
+ // `read_failures` key so a caller can tell "never read" apart from
1216
+ // "read but write failed".
1217
+ skipped++;
1218
+ readFailures.push({ agent: agentName, file: filePath, error: err instanceof Error ? err.message : String(err) });
1219
+ continue;
1220
+ }
1221
+ // `target === null` covers both "no effort configured" (inherit) and "a
1222
+ // level OpenCode does not accept" — both must produce NO `variant:` key,
1223
+ // exactly what install writes.
1224
+ const universal = effortCfg ? resolveInstallTimeEffort(effortCfg, agentName) : null;
1225
+ const target = universal ? clampEffortForHost('opencode', universal) : null;
1226
+ const fmMatch = /^---\r?\n([\s\S]*?)^---\r?$/m.exec(content);
1227
+ if (!fmMatch) {
1228
+ skipped++;
1229
+ continue;
1230
+ }
1231
+ // Presence and value are distinct questions: `variant:` with an EMPTY
1232
+ // value is a key that IS present but whose captured value is null (the
1233
+ // `(.+?)` group requires at least one char). Deciding "already correct"
1234
+ // from a null-vs-null comparison alone is wrong when target is also
1235
+ // null — it would leave an unresolvable `variant: null` key on disk
1236
+ // forever. Test presence with its own regex, and only compare values
1237
+ // once presence is known.
1238
+ const variantPresent = /^variant:/m.test(fmMatch[1]);
1239
+ const variantMatch = /^variant:[ \t]*(.+?)[ \t]*$/m.exec(fmMatch[1]);
1240
+ // `null` (key absent) and `''` (key present, value empty) are distinct
1241
+ // states this code deliberately tracks via `variantPresent` above — a
1242
+ // reported `from` that collapses both to `null` would make "no key" and
1243
+ // "empty key" indistinguishable in the sync output, even though only one
1244
+ // of them actually has a `variant:` line to remove.
1245
+ const currentVariant = variantPresent ? (variantMatch ? variantMatch[1] : '') : null;
1246
+ if (target === null) {
1247
+ if (!variantPresent) {
1248
+ skipped++;
1249
+ continue;
1250
+ }
1251
+ }
1252
+ else if (variantPresent && currentVariant === target) {
1253
+ skipped++;
1254
+ continue;
1255
+ }
1256
+ changes.push({ agent: agentName, from: currentVariant, to: target });
1257
+ synced++;
1258
+ if (!dryRun) {
1259
+ // Atomic publish AND mode preservation, same discipline as
1260
+ // cmdEffortSyncCodex above: write to a sibling tmp file, chmod it to
1261
+ // match filePath's existing (masked) mode, then retryRenameSync it over
1262
+ // the target so filePath is either the old bytes or the new ones, never
1263
+ // half-written and never dropped to a default mode. On failure the
1264
+ // write is reported, not thrown, so the remaining agents still get
1265
+ // processed.
1266
+ const tmpPath = `${filePath}.tmp.${process.pid}`;
1267
+ // Stat filePath BEFORE the write so its mode can be passed at CREATION
1268
+ // time — a plain `writeFileSync(tmpPath, data)` creates the tmp file at
1269
+ // the default `0666 & ~umask` (world-readable under a typical 022
1270
+ // umask) even when filePath is e.g. 0600, exposing its contents for
1271
+ // the window between creation and the chmod below. Mask off the
1272
+ // file-type bits (e.g. S_IFREG 0o100000) that fs.statSync().mode
1273
+ // carries alongside the permission bits — POSIX leaves chmod's
1274
+ // handling of those bits unspecified, and the remote matrix runs Linux
1275
+ // only (Darwin tolerating the full mode is not evidence it is safe
1276
+ // there). Best-effort only: a stat failure must not abort the sync,
1277
+ // since the content write is what matters, not the mode.
1278
+ let originalMode;
1279
+ try {
1280
+ originalMode = node_fs_1.default.statSync(filePath).mode & 0o7777;
1281
+ }
1282
+ catch { /* non-fatal: fall back to writing without an explicit mode */ }
1283
+ try {
1284
+ node_fs_1.default.writeFileSync(tmpPath, target === null ? removeFrontmatterKeyLine(content, 'variant') : setFrontmatterKeyLine(content, 'variant', target), originalMode !== undefined ? { mode: originalMode } : undefined);
1285
+ // Not redundant with the `mode` option above: `mode` only applies
1286
+ // when the file is actually created (O_CREAT). A leftover tmp file
1287
+ // from an earlier crashed run would be reused (truncated) at its OLD
1288
+ // mode instead, and this chmod is what corrects that case.
1289
+ // Best-effort only: a chmod failure must not abort the sync, since
1290
+ // the content write is what matters, not the mode.
1291
+ try {
1292
+ if (originalMode !== undefined)
1293
+ node_fs_1.default.chmodSync(tmpPath, originalMode);
1294
+ }
1295
+ catch { /* non-fatal: proceed with default tmp-file mode */ }
1296
+ (0, shell_command_projection_cjs_1.retryRenameSync)(tmpPath, filePath);
1297
+ }
1298
+ catch (err) {
1299
+ try {
1300
+ node_fs_1.default.unlinkSync(tmpPath);
1301
+ }
1302
+ catch { /* already gone or never created */ }
1303
+ changes.pop();
1304
+ synced--;
1305
+ skipped++;
1306
+ writeFailures.push({ agent: agentName, file: filePath, error: err instanceof Error ? err.message : String(err) });
1307
+ continue;
1308
+ }
1309
+ }
1310
+ }
1311
+ // Any failure — a write OR a read — must not report 'ok' or 'changed':
1312
+ // either would hide that at least one agent's on-disk state is now unknown
1313
+ // (unread) or unchanged despite being reported as a pending change (write
1314
+ // failed after being pushed onto `changes`/`synced`). `write_failures` and
1315
+ // `read_failures` take priority over the synced-count-derived summary below,
1316
+ // even when other agents in the same run succeeded.
1317
+ //
1318
+ // Known limitation, deliberately not fixed here: `output()` only honors its
1319
+ // third argument when `raw === true`, and this command's process always
1320
+ // exits 0 regardless of the summary string — so `if gsd-tools effort sync;
1321
+ // then` reads success in a shell even on a run where every write failed.
1322
+ // Making the exit code reflect failure would be a CLI-contract change
1323
+ // affecting all three cmdEffortSync* branches (claude, codex, opencode) and
1324
+ // is out of scope for this fix.
1325
+ output({ synced, skipped, changes, dry_run: dryRun, agents_dir: agentsDir, write_failures: writeFailures, read_failures: readFailures }, raw, writeFailures.length > 0 || readFailures.length > 0 ? 'failed' : synced > 0 ? 'changed' : 'ok');
892
1326
  }
893
1327
  /**
894
1328
  * Detect the phase number for a commit from its `--files` path list.
@@ -1059,7 +1493,10 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
1059
1493
  // — see #2528 for the parallel drift problem in phase-locator/phase),
1060
1494
  // so this is the canonical path-segment-bound read, not a fourth copy.
1061
1495
  const phaseNum = detectPhaseNumberFromFiles(files);
1062
- if (phaseNum) {
1496
+ // #3734: a 999.x/0.x backlog sentinel is a parking-lot entry, not a real
1497
+ // phase — the phase arm must never branch-mutate for it (isSentinelPhaseId
1498
+ // is the invariant's single owner, src/phase-id.cts).
1499
+ if (phaseNum && !isSentinelPhaseId(phaseNum)) {
1063
1500
  const phaseInfo = findPhaseInternal(cwd, phaseNum);
1064
1501
  if (phaseInfo) {
1065
1502
  branchName = config['phase_branch_template']
@@ -1223,12 +1660,232 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
1223
1660
  // During a merge, git refuses partial commits — fall back to a bare commit.
1224
1661
  // --amend is left without a pathspec: amending with -- <paths> is a different
1225
1662
  // operation that rewrites the tip with only those paths.
1226
- if (explicitFiles && stagedPaths.length === 0 && !amend) {
1663
+ const mergeHeadProbe = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '-q', '--verify', 'MERGE_HEAD'], { cwd });
1664
+ const isMergeInProgress = mergeHeadProbe.exitCode === 0;
1665
+ // PROVENANCE FOR THIS WHOLE BLOCK: every behavioural claim below was DRIVEN
1666
+ // against git 2.54, not reasoned by analogy. Individual claims state what was
1667
+ // observed and omit the version; where a claim is version-SENSITIVE rather
1668
+ // than merely version-observed, it says so at the claim.
1669
+ //
1670
+ // #3776: git refuses a PARTIAL commit (`git commit -- <paths>`) while a merge
1671
+ // or a cherry-pick is in progress, so in those states the pathspec describes
1672
+ // nothing about what would actually land and the empty-diff decision below
1673
+ // must not be made from it. The three sequencer states do NOT agree:
1674
+ // MERGE_HEAD -> `fatal: cannot do a partial commit during a merge.`
1675
+ // CHERRY_PICK_HEAD -> `fatal: cannot do a partial commit during a cherry-pick.`
1676
+ // REVERT_HEAD -> permitted; behaves like an ordinary commit.
1677
+ // REVERT_HEAD is therefore deliberately absent: including it would suppress
1678
+ // this fix during a revert, reintroducing the very misreport it removes.
1679
+ // `canScope` below keeps its narrower merge-only test on purpose — widening it
1680
+ // would change pre-existing cherry-pick behaviour, which is outside this fix.
1681
+ // Only the scoped, non-amend call can return through the guard below, so the
1682
+ // cherry-pick probe and the guard's own probes are gated on that — an
1683
+ // unscoped commit or an --amend would otherwise pay for git invocations whose
1684
+ // answer it can never use. The MERGE_HEAD probe above predates this fix and
1685
+ // stays unconditional: `canScope` needs it on every path.
1686
+ // A non-zero exit from either sequencer probe means "not in that state" AND
1687
+ // "the probe never answered" — `execGit` surfaces a spawn timeout as
1688
+ // `exitCode: 1` (`_spawnResult`: `result.status ?? 1`), which is the exact
1689
+ // code `rev-parse --verify` returns for a ref that does not exist. Conflating
1690
+ // them is the one path in this fix that does NOT fail toward the old
1691
+ // behaviour: a timeout during a real merge would leave `partialCommitRefused`
1692
+ // false, the guard would decide `nothing_to_commit` from a pathspec git will
1693
+ // not honour, and the merge would be silently abandoned where it previously
1694
+ // reported a loud `commit_failed`. So an unanswered probe is treated as
1695
+ // "assume the partial commit would be refused" — the conservative reading,
1696
+ // which falls through to `git commit` and lets git speak for itself.
1697
+ //
1698
+ // This is deliberately routed into `partialCommitRefused` ONLY, never into
1699
+ // `isMergeInProgress`: that flag also feeds the pre-existing `canScope` below,
1700
+ // where a spurious timeout would convert a scoped commit into a bare one and
1701
+ // record the whole index instead of the named paths. Suppressing a misreport
1702
+ // must not be paid for by committing content the caller never named.
1703
+ const guardApplies = explicitFiles && !amend;
1704
+ const cherryPickProbe = guardApplies
1705
+ ? (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '-q', '--verify', 'CHERRY_PICK_HEAD'], { cwd })
1706
+ : null;
1707
+ const partialCommitRefused = isMergeInProgress
1708
+ || (0, shell_command_projection_cjs_1.isSpawnTimeout)(mergeHeadProbe)
1709
+ || (cherryPickProbe !== null
1710
+ && (cherryPickProbe.exitCode === 0 || (0, shell_command_projection_cjs_1.isSpawnTimeout)(cherryPickProbe)));
1711
+ // `stagedPaths` records paths whose `git add` exited 0 — that is "did
1712
+ // staging succeed", not "is there anything to commit". Staging an
1713
+ // already-committed, unmodified file succeeds while contributing no diff, so
1714
+ // `length === 0` is reachable only when EVERY named path was missing from
1715
+ // disk. For the ordinary empty-diff case control fell through to `git commit`,
1716
+ // and the only thing converting that back to `nothing_to_commit` was the
1717
+ // string match on git's output below — which a rejecting pre-commit hook
1718
+ // pre-empts, because git runs the hook before it decides there is nothing to
1719
+ // commit. The caller was then handed `commit_failed` carrying a gate message
1720
+ // about a commit that had nothing to gate. Ask git whether the named paths
1721
+ // actually differ instead. Three things about that probe are load-bearing:
1722
+ // - it compares the WORKING TREE to HEAD (`diff HEAD`), not the index
1723
+ // (`diff --cached`). `git commit -- <paths>` is a partial commit: it takes
1724
+ // the working-tree content of those paths and ignores what is staged. A
1725
+ // probe against the index therefore answers a different question than the
1726
+ // commit asks, and a working-tree write landing between the `git add`
1727
+ // above and this line — another process in a shared checkout — would make
1728
+ // the index say "empty" while the commit would still have recorded the new
1729
+ // content. Driven: `diff --cached` rc 0 and `diff HEAD` rc 1 on the same
1730
+ // path, with `git commit -- <path>` then committing it.
1731
+ // - the `length === 0` short-circuit keeps the all-missing-paths case exact.
1732
+ // Spreading an empty array yields a pathspec-less `diff`, which tests the
1733
+ // WHOLE tree — unrelated work elsewhere would then suppress the guard and
1734
+ // regress the skip-missing contract (#2014).
1735
+ // It is deliberately NOT gated on `partialCommitRefused`, and gating it
1736
+ // would be a REGRESSION rather than a hardening. With every named path
1737
+ // missing, `stagedPaths` is empty, so `canScope` is false and the
1738
+ // fall-through reaches a BARE `git commit` — which git PERMITS during a
1739
+ // merge, and which then CONCLUDES that merge: rc 0, a two-parent merge
1740
+ // commit recording the entire index, under a message naming a path that
1741
+ // does not exist, reported to the caller as `committed: true` (driven).
1742
+ // Today's answer writes nothing at all. That is the same trade the timeout
1743
+ // routing above already refuses — a misreport must not be paid for by
1744
+ // committing content the caller never named — which is why the sequencer
1745
+ // states gate the DIFF branch only. The behaviour is also PRE-EXISTING and
1746
+ // unchanged by this fix: before it the identical short-circuit ran ABOVE
1747
+ // the MERGE_HEAD probe, so it never consulted the sequencer either. The
1748
+ // residual it leaves — a merge held open behind a `nothing_to_commit`
1749
+ // report — is offered as a separate issue with the other three, not folded
1750
+ // in here. Both sequencer shapes are pinned in
1751
+ // tests/commit-files-pathspec.test.cjs.
1752
+ // - `!partialCommitRefused`: see above — deciding "nothing to commit" from a
1753
+ // pathspec git will not honour would abandon an in-progress merge, so those
1754
+ // states keep their pre-existing behaviour untouched.
1755
+ // - the probe is pinned against user configuration that would make `git diff`
1756
+ // answer a DIFFERENT question than `git commit -- <paths>` asks. `git diff`
1757
+ // is porcelain and honours settings the commit does not, so without these
1758
+ // flags a caller's config decides whether the guard fires. Each vector
1759
+ // below was driven with the paired `git commit -- <path>` confirmed to
1760
+ // record the change the probe reported as absent:
1761
+ // `diff.ignoreSubmodules=all` -> a gitlink bump is invisible to the probe
1762
+ // `.gitmodules` `ignore = all` -> the same, and it needs NO local config:
1763
+ // it is checked in, so it arrives with a
1764
+ // clone
1765
+ // `diff=<driver>` + `textconv` -> two different blobs converge to one
1766
+ // text, so the probe sees no change at
1767
+ // all; no submodule involved
1768
+ // `--ignore-submodules=dirty` rather than `=none`, because `dirty` is what
1769
+ // a partial commit of a submodule path actually means: it records the
1770
+ // GITLINK, and the gitlink moves only when the submodule's HEAD does. Under
1771
+ // `=none` a merely dirty submodule WORKTREE reports a difference the commit
1772
+ // would not record, sending an empty call back to `git commit` — the #3776
1773
+ // misreport, re-entered from the other side. `dirty` still overrides both
1774
+ // `diff.ignoreSubmodules` and a checked-in `.gitmodules` `ignore`, so the
1775
+ // gitlink vectors above stay closed (driven: rc 1 under every one of them).
1776
+ // `--no-ext-diff` is deliberately absent: `--quiet` short-circuits ahead of
1777
+ // an external diff driver, so an external `diff.<driver>.command` cannot
1778
+ // invert the probe (driven: rc 1 with and without the flag).
1779
+ // Any other non-zero exit from the probe (a genuine git error, or an unborn
1780
+ // HEAD) leaves the guard shut and falls through to the commit — failing toward
1781
+ // today's path rather than manufacturing a no-op.
1782
+ // THE ONE STATE WHERE `git diff` AND `git commit -- <paths>` GENUINELY DISAGREE.
1783
+ // `--assume-unchanged` tells git to skip the worktree stat for a path, so
1784
+ // `git add` stages nothing and BOTH diff forms report no difference — while
1785
+ // `git commit -- <path>` reads the working tree directly and records it
1786
+ // (driven: probe rc 0, commit rc 0, new content in the tree). Left
1787
+ // to the diff probe alone the guard reports `nothing_to_commit` about content
1788
+ // the caller explicitly named in `--files` and git would have written. #3776
1789
+ // is a purely diagnostic bug — nothing is corrupted and no wrong commit is
1790
+ // made — so suppressing its misreport must not be paid for by dropping named
1791
+ // content. The same rule the timeout routing already follows one block up.
1792
+ //
1793
+ // `git ls-files -v` is the discriminator for the STATE: it tags an
1794
+ // assume-unchanged path with a LOWERCASE letter (`h`), where
1795
+ // `--skip-worktree` is an uppercase `S` and never reaches THIS branch:
1796
+ // `git add` exits 1 under it, so a present-but-modified skip-worktree path
1797
+ // fails closed as `staging_failed` above the guard. (An ABSENT one is skipped
1798
+ // before `git add` runs at all per #2014, and is answered by the
1799
+ // `stagedPaths.length === 0` arm above — correctly, and exactly as it was
1800
+ // pre-fix. Both shapes are pinned.)
1801
+ //
1802
+ // Then ASK GIT, rather than reconstructing its answer. `git commit --dry-run`
1803
+ // is the same decision the real commit makes, and `--no-verify` is what keeps
1804
+ // it a DECISION rather than an execution. git 2.54 already declines to run
1805
+ // `pre-commit` on a dry run (driven: a rejecting one neither fires nor writes
1806
+ // its marker), which is the property that matters here, because a firing
1807
+ // `pre-commit` is the whole of #3776 — but that is an observed behaviour of
1808
+ // one version, and the failure it would produce on a version that differs is
1809
+ // SILENT. A `pre-commit` that fires and rejects exits 1, the same code git
1810
+ // returns for `nothing to record`, so the closure below would read it as a
1811
+ // CONFIRMED empty answer, drop the content the caller named, and report
1812
+ // `nothing_to_commit` — #3776's exact shape, in #3776's exact configuration.
1813
+ // `--no-verify` forecloses that structurally instead of resting on the
1814
+ // version, and is behaviour-neutral where the version already agrees (driven:
1815
+ // rc 0 would-record / rc 1 nothing, identical with and without it). This is
1816
+ // VERSION-SENSITIVE reasoning, hence stated at the claim per the provenance
1817
+ // note above.
1818
+ //
1819
+ // It is still NOT hook-free in general, and `--no-verify` does not widen that
1820
+ // claim: `post-index-change` fires on this call with or without the flag
1821
+ // (driven both ways), so a repo using that hook sees TWO extra invocations
1822
+ // for the probe — git fires it twice per `commit --dry-run`, and twice again
1823
+ // for the real commit (driven: 2/2/2 across flagged probe, unflagged probe
1824
+ // and real commit). Stated rather than claimed away; the narrower
1825
+ // claim is the true one. `--porcelain` keeps the output to a couple
1826
+ // of machine-readable lines instead of a full status listing — the rc is
1827
+ // identical either way (driven: 0 would-record / 1 nothing), but the plain
1828
+ // form prints every untracked path, which on a large tree is output this
1829
+ // probe has no use for and `execGit` would have to buffer. rc 0 means the
1830
+ // commit would record something, so the guard must stand aside.
1831
+ //
1832
+ // Reconstructing it was tried and is WRONG in three measured ways, all of
1833
+ // them silent drops of named content. Comparing `git hash-object` against
1834
+ // `HEAD:<path>` misses a mode-only change (`chmod +x` leaves the blob
1835
+ // identical while `git commit -- <path>` records `100755`); it cannot hash a
1836
+ // submodule path at all (`fatal: Unable to hash sub`, while the commit
1837
+ // advances the gitlink); and the path it needs must be parsed out of
1838
+ // `ls-files` output, which `core.quotePath` renders as `"caf\303\251.md"`
1839
+ // by default, so the probe reads a filename that does not exist. Asking git
1840
+ // needs no path parsed and no case enumerated.
1841
+ //
1842
+ // Scoped to this branch on purpose. The diff probe above answers the ordinary
1843
+ // case cheaply and is pinned against the configuration vectors below; the
1844
+ // dry run is the heavier, exact answer, and it runs only when an
1845
+ // assume-unchanged path is actually present.
1846
+ //
1847
+ // The `ls-files` read is an OPTIMISATION, never a gate — so an unreadable one
1848
+ // must not decide anything. It exists only to keep the dry run off the hot
1849
+ // path when no assume-unchanged entry is present; when it cannot answer, the
1850
+ // dry run simply runs, because the dry run needs nothing from it. Both
1851
+ // failing-closed (drop the content) and failing-open (re-enter #3776) are
1852
+ // wrong answers to a question we can just ask directly.
1853
+ const assumeUnchangedWouldRecord = () => {
1854
+ const listed = (0, shell_command_projection_cjs_1.execGit)(['ls-files', '-v', '--', ...stagedPaths], { cwd });
1855
+ // Only the TAG is read; the path is deliberately never parsed out — see the
1856
+ // `core.quotePath` note above, and the dry run below needs no path anyway.
1857
+ if (listed.exitCode === 0
1858
+ && !listed.stdout.split('\n').some((line) => /^[a-z] /.test(line)))
1859
+ return false;
1860
+ const dryRun = (0, shell_command_projection_cjs_1.execGit)(['commit', '--dry-run', '--porcelain', '--no-verify', '-m', sanitizedMessage, '--', ...stagedPaths], { cwd });
1861
+ // Only a CONFIRMED "nothing to record" closes the path: rc 1 from a git
1862
+ // that actually answered. This is the one probe in the guard whose rc 0
1863
+ // is the REASSURING answer, so it inverts the diff probe's safety: there
1864
+ // a timeout can only yield non-zero and reads as "not clean"; here
1865
+ // `execGit` collapses a spawn timeout (or any spawn error) to
1866
+ // `exitCode: 1` (`_spawnResult`: `result.status ?? 1`), byte-identical to
1867
+ // git's own "nothing to record" — and the guard then reports
1868
+ // `nothing_to_commit` about content it never asked git to write. Same
1869
+ // conflation the sequencer probes above defend against, same remedy: an
1870
+ // unanswered probe falls toward the commit, where git speaks for itself
1871
+ // (and a genuine error there is reported loudly, as it always was). rc 128
1872
+ // is likewise not an answer. Timeout kill of a dry run CAN leave a stale
1873
+ // `index.lock` behind (it refreshes the index); the real commit then
1874
+ // fails on it, loudly — never silently.
1875
+ if ((0, shell_command_projection_cjs_1.isSpawnTimeout)(dryRun) || dryRun.error !== null)
1876
+ return true;
1877
+ return dryRun.exitCode !== 1;
1878
+ };
1879
+ const nothingToCommit = guardApplies
1880
+ && (stagedPaths.length === 0
1881
+ || (!partialCommitRefused
1882
+ && (0, shell_command_projection_cjs_1.execGit)(['diff', '--quiet', '--ignore-submodules=dirty', '--no-textconv', 'HEAD', '--', ...stagedPaths], { cwd }).exitCode === 0
1883
+ && !assumeUnchangedWouldRecord()));
1884
+ if (nothingToCommit) {
1227
1885
  const result = { committed: false, hash: null, reason: 'nothing_to_commit' };
1228
1886
  output(result, raw, 'nothing');
1229
1887
  return;
1230
1888
  }
1231
- const isMergeInProgress = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '-q', '--verify', 'MERGE_HEAD'], { cwd }).exitCode === 0;
1232
1889
  const canScope = explicitFiles && stagedPaths.length > 0 && !amend
1233
1890
  && !isMergeInProgress;
1234
1891
  const commitArgs = amend
@@ -1239,8 +1896,61 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
1239
1896
  if (canScope) {
1240
1897
  commitArgs.push('--', ...stagedPaths);
1241
1898
  }
1242
- const commitResult = (0, shell_command_projection_cjs_1.execGit)(commitArgs, { cwd });
1899
+ // #3859 follow-up: on git 2.39.5 (confirmed on the CI Linux bench image,
1900
+ // ghcr.io/open-gsd/gsd-tester-linux:v1.8.0-node24; NOT reproducible on git
1901
+ // 2.50.1) `git commit` itself — not just `git diff` — consults
1902
+ // `diff.ignoreSubmodules` when deciding whether there is anything to
1903
+ // record. With a local `diff.ignoreSubmodules=all` and a submodule gitlink
1904
+ // genuinely bumped, that git version silently REFUSES the commit (prints a
1905
+ // `git status`-style "Changes to be committed" dump and exits 1, having
1906
+ // written nothing) even though the diff probe above (already pinned with
1907
+ // its own `--ignore-submodules=dirty`) correctly reported the change as
1908
+ // present. The result was misclassified as generic `commit_failed` because
1909
+ // git's refusal text does not contain "nothing to commit".
1910
+ // Originally scoped to `canScope` on the assumption that only a
1911
+ // PATHSPEC-LIMITED `git commit -- <paths>` exercises this git internal
1912
+ // path. That assumption was wrong: reproduced directly against the pinned
1913
+ // v1.8.0-node24 tester image, a bare WHOLE-INDEX `git commit -m ...` (no
1914
+ // pathspec at all) is refused identically when the only staged change is a
1915
+ // submodule gitlink and `diff.ignoreSubmodules=all` — git's "nothing to
1916
+ // commit" check is a real diff (HEAD vs. index) honouring
1917
+ // `diff.ignoreSubmodules` regardless of whether a pathspec narrows it.
1918
+ // `--amend` is the one shape confirmed NOT to hit this: it always
1919
+ // recreates the commit from the current index and never runs the
1920
+ // empty-diff refusal a plain `git commit` does, override or not. The
1921
+ // override is therefore applied unconditionally here (not gated on
1922
+ // `canScope`) — it is a documented no-op everywhere it is not needed
1923
+ // (dry-run, git 2.50.1, and `--amend` already behave this way with or
1924
+ // without it; see `#3859 follow-up (canScope gap)` regression tests).
1925
+ // The override rides in via `GIT_CONFIG_*` env vars rather than a `-c`
1926
+ // argv flag so `commitArgs[0]` stays `'commit'` — several #3859 regression
1927
+ // tests assert on the raw argv captured at the `execGit` seam (e.g.
1928
+ // `gitCalls.some((a) => a[0] === 'commit')`), and a leading `-c` would shift
1929
+ // every element and break that pinning. Same override the probe already
1930
+ // carries, so the two can never disagree again.
1931
+ const commitEnv = {
1932
+ GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'diff.ignoreSubmodules', GIT_CONFIG_VALUE_0: 'dirty',
1933
+ };
1934
+ // #3886: `git commit` runs pre-commit hooks (husky/lint-staged routinely
1935
+ // idles ~4s on Windows before any task) — 10s is too tight, and a timeout
1936
+ // kill is NOT an ordinary failure. Same band as the push call below.
1937
+ const commitResult = (0, shell_command_projection_cjs_1.execGit)(commitArgs, { cwd, env: commitEnv, timeout: COMMIT_TIMEOUT_MS });
1243
1938
  if (commitResult.exitCode !== 0) {
1939
+ // #3886: a SIGTERM'd git commit is a timeout, not commit_failed — the
1940
+ // partial stderr it flushed (often incidental CRLF warnings) is noise,
1941
+ // and the kill can leave a stale index.lock that blocks the next
1942
+ // attempt. Report the distinct reason and surface the lock path.
1943
+ if ((0, shell_command_projection_cjs_1.isSpawnTimeout)(commitResult)) {
1944
+ const result = {
1945
+ committed: false,
1946
+ hash: null,
1947
+ reason: 'commit_timeout',
1948
+ timed_out: true,
1949
+ error: commitTimeoutMessage(cwd, commitResult.stderr, commitResult.stdout),
1950
+ };
1951
+ output(result, raw, 'failed');
1952
+ return;
1953
+ }
1244
1954
  if (commitResult.stdout.includes('nothing to commit') || commitResult.stderr.includes('nothing to commit')) {
1245
1955
  const result = { committed: false, hash: null, reason: 'nothing_to_commit' };
1246
1956
  output(result, raw, 'nothing');
@@ -1380,8 +2090,26 @@ function cmdCommitToSubrepo(cwd, message, files, raw) {
1380
2090
  const commitArgs = canScopeSub
1381
2091
  ? ['commit', '-m', message, '--', ...stagedRelPaths]
1382
2092
  : ['commit', '-m', message];
1383
- const commitResult = (0, shell_command_projection_cjs_1.execGit)(commitArgs, { cwd: repoCwd });
2093
+ // #3859 follow-up fix as cmdCommit above (line ~2081) — git 2.39.5 needs
2094
+ // this override for pathspec-scoped AND whole-index commits alike.
2095
+ const commitEnvSub = {
2096
+ GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'diff.ignoreSubmodules', GIT_CONFIG_VALUE_0: 'dirty',
2097
+ };
2098
+ const commitResult = (0, shell_command_projection_cjs_1.execGit)(commitArgs, { cwd: repoCwd, timeout: COMMIT_TIMEOUT_MS, env: commitEnvSub });
1384
2099
  if (commitResult.exitCode !== 0) {
2100
+ if ((0, shell_command_projection_cjs_1.isSpawnTimeout)(commitResult)) {
2101
+ // #3886 (subrepo counterpart): timeout ≠ error; surface the stale-lock
2102
+ // path a killed commit can leave in the subrepo.
2103
+ repos[repo] = {
2104
+ committed: false,
2105
+ hash: null,
2106
+ files: repoFiles,
2107
+ reason: 'commit_timeout',
2108
+ timed_out: true,
2109
+ error: commitTimeoutMessage(repoCwd, commitResult.stderr, commitResult.stdout),
2110
+ };
2111
+ continue;
2112
+ }
1385
2113
  if (commitResult.stdout.includes('nothing to commit') || commitResult.stderr.includes('nothing to commit')) {
1386
2114
  repos[repo] = { committed: false, hash: null, files: repoFiles, reason: 'nothing_to_commit' };
1387
2115
  continue;
@@ -1442,7 +2170,16 @@ function cmdPrSubrepo(cwd, repo, branch, commitMessage, raw) {
1442
2170
  }
1443
2171
  // 1. Collect changed files via porcelain status — explicit, never git add -A.
1444
2172
  // ?? (untracked) lines are excluded — only stage tracked modifications.
1445
- const statusResult = (0, shell_command_projection_cjs_1.execGit)(['-c', 'core.quotePath=false', 'status', '--porcelain'], { cwd: repoCwd });
2173
+ // #3859 follow-up: `git status --porcelain` honors `diff.ignoreSubmodules`
2174
+ // the same way the empty-diff probe fixed for cmdCommit did — under a local
2175
+ // `diff.ignoreSubmodules=all`, a genuinely bumped submodule gitlink is
2176
+ // invisible here too, so `changedFiles` comes back empty and the function
2177
+ // reports `nothing_to_commit` before ever reaching the (now-fixed) commit
2178
+ // call. `--ignore-submodules=dirty` pins this the same way, reported
2179
+ // verbatim: `git -C repo status --porcelain` (no flag) shows nothing for a
2180
+ // pure gitlink bump under `diff.ignoreSubmodules=all`, while
2181
+ // `--ignore-submodules=dirty` reports ` M nested` (reproduced directly).
2182
+ const statusResult = (0, shell_command_projection_cjs_1.execGit)(['-c', 'core.quotePath=false', 'status', '--porcelain', '--ignore-submodules=dirty'], { cwd: repoCwd });
1446
2183
  if (statusResult.exitCode !== 0) {
1447
2184
  error(`git status failed in ${repo}: ${statusResult.stderr}`);
1448
2185
  }
@@ -1511,9 +2248,20 @@ function cmdPrSubrepo(cwd, repo, branch, commitMessage, raw) {
1511
2248
  const commitArgs = canScopePr
1512
2249
  ? ['commit', '-m', commitMessage, '--', ...changedFiles]
1513
2250
  : ['commit', '-m', commitMessage];
1514
- const commitResult = (0, shell_command_projection_cjs_1.execGit)(commitArgs, { cwd: repoCwd });
2251
+ // #3859 follow-up fix as cmdCommit above (line ~2081) — git 2.39.5 needs
2252
+ // this override for pathspec-scoped AND whole-index commits alike.
2253
+ const commitEnvPr = {
2254
+ GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'diff.ignoreSubmodules', GIT_CONFIG_VALUE_0: 'dirty',
2255
+ };
2256
+ const commitResult = (0, shell_command_projection_cjs_1.execGit)(commitArgs, { cwd: repoCwd, timeout: COMMIT_TIMEOUT_MS, env: commitEnvPr });
1515
2257
  if (commitResult.exitCode !== 0) {
1516
2258
  rollback();
2259
+ if ((0, shell_command_projection_cjs_1.isSpawnTimeout)(commitResult)) {
2260
+ // #3886 (PR-subrepo counterpart): name the timeout and the stale lock
2261
+ // instead of echoing the killed hook's partial stderr.
2262
+ error(`git commit timed out after ${COMMIT_TIMEOUT_MS / 1000}s in ${repo} (killed mid-hook; ` +
2263
+ `a stale lock may remain at ${resolveIndexLockPath(repoCwd)} — remove it if no git process is running)`);
2264
+ }
1517
2265
  error(`Failed to commit in ${repo}: ${commitResult.stderr}`);
1518
2266
  }
1519
2267
  // 6. Capture commit hash
@@ -1906,7 +2654,34 @@ function cmdTodoMatchPhase(cwd, phase, raw) {
1906
2654
  matches.sort((a, b) => b.score - a.score);
1907
2655
  output({ phase, matches, todo_count: todos.length }, raw, undefined);
1908
2656
  }
1909
- function cmdTodoComplete(cwd, filename, raw) {
2657
+ // #4096: upsert completion keys INSIDE the leading frontmatter block. Never a
2658
+ // bare prefix line above the opening `---` (that displaces the fence to line 2
2659
+ // and breaks every fence-locating reader). A file with no well-formed block
2660
+ // (absent, or an unterminated opening fence) gains a complete block.
2661
+ function upsertTodoCompletionFields(content, today) {
2662
+ const lines = content.split('\n');
2663
+ const fields = [`completed: ${today}`, 'status: completed'];
2664
+ const hasOpeningFence = lines[0] !== undefined && lines[0].trim() === '---';
2665
+ const closeIdx = hasOpeningFence ? lines.findIndex((l, i) => i > 0 && l.trim() === '---') : -1;
2666
+ if (!hasOpeningFence || closeIdx === -1) {
2667
+ // No parseable frontmatter: wrap the whole content in a complete block
2668
+ // rather than prefixing bare keys (#4096 fix 2).
2669
+ return `---\n${fields.join('\n')}\n---\n\n${content}`;
2670
+ }
2671
+ const block = lines.slice(1, closeIdx);
2672
+ for (const field of fields) {
2673
+ const key = `${field.slice(0, field.indexOf(':'))}:`;
2674
+ const idx = block.findIndex(l => l.startsWith(key));
2675
+ if (idx === -1) {
2676
+ block.push(field);
2677
+ }
2678
+ else {
2679
+ block[idx] = field;
2680
+ }
2681
+ }
2682
+ return [...lines.slice(0, 1), ...block, ...lines.slice(closeIdx)].join('\n');
2683
+ }
2684
+ function cmdTodoComplete(cwd, filename, options, raw) {
1910
2685
  if (!filename) {
1911
2686
  error('filename required for todo complete');
1912
2687
  }
@@ -1916,13 +2691,30 @@ function cmdTodoComplete(cwd, filename, raw) {
1916
2691
  if (!node_fs_1.default.existsSync(sourcePath)) {
1917
2692
  error(`Todo not found: ${filename}`);
1918
2693
  }
1919
- // Ensure completed directory exists
1920
- (0, shell_command_projection_cjs_1.platformEnsureDir)(completedDir);
1921
- // Read, add completion timestamp, move
1922
- let content = node_fs_1.default.readFileSync(sourcePath, 'utf-8');
2694
+ const content = node_fs_1.default.readFileSync(sourcePath, 'utf-8');
1923
2695
  const today = clock_cjs_1.realClock.localToday();
1924
- content = `completed: ${today}\n` + content;
1925
- (0, shell_command_projection_cjs_1.platformWriteSync)(node_path_1.default.join(completedDir, filename), content);
2696
+ // #4096: --dry-run mirrors `milestone complete --dry-run` (#2118) — every
2697
+ // existence check above still runs, nothing below mutates, and the payload
2698
+ // is preview-shaped (`dry_run`/`would_*`), never `completed: true`.
2699
+ if (options.dryRun) {
2700
+ output({
2701
+ dry_run: true,
2702
+ would_complete: true,
2703
+ file: filename,
2704
+ date: today,
2705
+ would_move: {
2706
+ source: node_path_1.default.relative(cwd, sourcePath).split(node_path_1.default.sep).join('/'),
2707
+ target: node_path_1.default.relative(cwd, node_path_1.default.join(completedDir, filename)).split(node_path_1.default.sep).join('/'),
2708
+ },
2709
+ would_set: { completed: today, status: 'completed' },
2710
+ }, raw);
2711
+ return;
2712
+ }
2713
+ // Ensure completed directory exists (only on the real run — a dry run
2714
+ // creates nothing).
2715
+ (0, shell_command_projection_cjs_1.platformEnsureDir)(completedDir);
2716
+ const completedContent = upsertTodoCompletionFields(content, today);
2717
+ (0, shell_command_projection_cjs_1.platformWriteSync)(node_path_1.default.join(completedDir, filename), completedContent);
1926
2718
  node_fs_1.default.unlinkSync(sourcePath);
1927
2719
  output({ completed: true, file: filename, date: today }, raw, 'completed');
1928
2720
  }
@@ -2275,6 +3067,36 @@ function buildCommitDocsGuardHookScript() {
2275
3067
  ];
2276
3068
  return lines.join('\n') + '\n';
2277
3069
  }
3070
+ /**
3071
+ * #3886: the timeout band for `git commit` — pre-commit hooks (husky +
3072
+ * lint-staged idles ~4s on Windows before any task) routinely exceed the 10s
3073
+ * plumbing default; 30s is the same band the push call uses. Shared by all
3074
+ * three commit sites AND their timeout messages, so the number and the text
3075
+ * cannot drift apart.
3076
+ */
3077
+ const COMMIT_TIMEOUT_MS = 30_000;
3078
+ /**
3079
+ * #3886: resolve where a killed `git commit` would leave its stale
3080
+ * index.lock — via `git rev-parse --git-path index.lock`, never a literal
3081
+ * `.git/index.lock` join (#3588 row 8's class: a linked worktree's `.git` is
3082
+ * a FILE pointing at `<gitdir>/worktrees/<name>/`, so the literal path
3083
+ * cannot exist there while the real lock blocks the next commit). Best
3084
+ * effort: any resolution failure falls back to the literal join, and the
3085
+ * message already hedges with "may remain".
3086
+ */
3087
+ function resolveIndexLockPath(cwd) {
3088
+ const result = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '--git-path', 'index.lock'], { cwd });
3089
+ if (result.exitCode !== 0)
3090
+ return node_path_1.default.join(cwd, '.git', 'index.lock');
3091
+ const raw = result.stdout.trim();
3092
+ return raw ? (node_path_1.default.isAbsolute(raw) ? raw : node_path_1.default.join(cwd, raw)) : node_path_1.default.join(cwd, '.git', 'index.lock');
3093
+ }
3094
+ /** #3886: shared timeout message shape for all three commit sites. */
3095
+ function commitTimeoutMessage(cwd, stderr, stdout) {
3096
+ return (`git commit timed out after ${COMMIT_TIMEOUT_MS / 1000}s (killed mid-hook; a stale lock may remain at ` +
3097
+ `${resolveIndexLockPath(cwd)} — remove it if no git process is running). ` +
3098
+ `Partial stderr: ${stderr || stdout || '(none)'}`);
3099
+ }
2278
3100
  /**
2279
3101
  * Resolve the real git hooks directory for `cwd` via `git rev-parse
2280
3102
  * --git-path hooks` — never a literal `.git/hooks` join (#3588 row 8: a
@@ -2366,6 +3188,7 @@ function cmdCommitDocsGuardDisable(cwd, raw) {
2366
3188
  output({ disabled: true, action: 'removed', path: hookPath }, raw, 'disabled');
2367
3189
  }
2368
3190
  module.exports = {
3191
+ effortSurfaceForHost,
2369
3192
  groupFilesBySubrepo,
2370
3193
  determinePhaseStatus,
2371
3194
  foldPhaseStatus,