@opengsd/gsd-core 1.11.0 → 1.12.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 (395) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-code-fixer.md +1 -1
  4. package/agents/gsd-debug-session-manager.md +1 -1
  5. package/agents/gsd-debugger.md +1 -1
  6. package/agents/gsd-dom-verifier.md +169 -0
  7. package/agents/gsd-eval-auditor.md +1 -1
  8. package/agents/gsd-executor.md +17 -9
  9. package/agents/gsd-framework-selector.md +1 -3
  10. package/agents/gsd-intel-updater.md +1 -1
  11. package/agents/gsd-mempalace-curator.md +0 -1
  12. package/agents/gsd-pattern-mapper.md +11 -0
  13. package/agents/gsd-phase-researcher.md +3 -1
  14. package/agents/gsd-plan-checker.md +15 -55
  15. package/agents/gsd-planner.md +6 -4
  16. package/agents/gsd-project-researcher.md +1 -1
  17. package/agents/gsd-research-synthesizer.md +2 -2
  18. package/agents/gsd-roadmapper.md +15 -11
  19. package/agents/gsd-ui-checker.md +63 -4
  20. package/agents/gsd-ui-researcher.md +41 -3
  21. package/agents/gsd-verifier.md +1 -1
  22. package/bin/install.js +609 -134
  23. package/commands/gsd/discuss-phase.md +1 -1
  24. package/commands/gsd/import.md +1 -1
  25. package/commands/gsd/quick.md +8 -4
  26. package/gsd-core/bin/gsd-tools.cjs +567 -51
  27. package/gsd-core/bin/lib/active-workstream-store.cjs +8 -0
  28. package/gsd-core/bin/lib/adr-parser.cjs +13 -7
  29. package/gsd-core/bin/lib/agent-install-check.cjs +162 -0
  30. package/gsd-core/bin/lib/api-coverage.cjs +30 -9
  31. package/gsd-core/bin/lib/artifacts.cjs +2 -0
  32. package/gsd-core/bin/lib/assumption-delta.cjs +30 -11
  33. package/gsd-core/bin/lib/audit.cjs +163 -41
  34. package/gsd-core/bin/lib/broken-windows.cjs +306 -28
  35. package/gsd-core/bin/lib/capability-lock.cjs +10 -4
  36. package/gsd-core/bin/lib/capability-registry.cjs +336 -95
  37. package/gsd-core/bin/lib/capability-state.cjs +18 -3
  38. package/gsd-core/bin/lib/capability-validator.cjs +205 -18
  39. package/gsd-core/bin/lib/check-command-router.cjs +145 -5
  40. package/gsd-core/bin/lib/cli-exit.cjs +496 -10
  41. package/gsd-core/bin/lib/code-review-depth.cjs +288 -0
  42. package/gsd-core/bin/lib/codex-agent-toml.cjs +410 -4
  43. package/gsd-core/bin/lib/command-arg-projection.cjs +144 -14
  44. package/gsd-core/bin/lib/command-routing-hub.cjs +31 -2
  45. package/gsd-core/bin/lib/commands.cjs +543 -44
  46. package/gsd-core/bin/lib/complexity-trigger.cjs +26 -6
  47. package/gsd-core/bin/lib/config-loader.cjs +118 -29
  48. package/gsd-core/bin/lib/config.cjs +92 -2
  49. package/gsd-core/bin/lib/configuration.cjs +129 -37
  50. package/gsd-core/bin/lib/core-utils.cjs +84 -7
  51. package/gsd-core/bin/lib/edge-probe.cjs +9 -1
  52. package/gsd-core/bin/lib/estimate-cli.cjs +55 -11
  53. package/gsd-core/bin/lib/exit-code-registry.cjs +98 -0
  54. package/gsd-core/bin/lib/frontmatter.cjs +840 -305
  55. package/gsd-core/bin/lib/gap-checker.cjs +27 -3
  56. package/gsd-core/bin/lib/git-base-branch.cjs +174 -39
  57. package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +7 -3
  58. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +6 -3
  59. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +22 -8
  60. package/gsd-core/bin/lib/health-diagnostic.cjs +23 -3
  61. package/gsd-core/bin/lib/host-integration.cjs +39 -6
  62. package/gsd-core/bin/lib/init-command-router.cjs +118 -21
  63. package/gsd-core/bin/lib/init.cjs +120 -41
  64. package/gsd-core/bin/lib/install-engine.cjs +68 -3
  65. package/gsd-core/bin/lib/install-model-override-resolver.cjs +33 -1
  66. package/gsd-core/bin/lib/install-profiles.cjs +78 -4
  67. package/gsd-core/bin/lib/installer-migration-report.cjs +3 -0
  68. package/gsd-core/bin/lib/installer-migrations/010-antigravity-retire-confighome-artifacts.cjs +169 -0
  69. package/gsd-core/bin/lib/installer-migrations.cjs +10 -7
  70. package/gsd-core/bin/lib/intel.cjs +101 -26
  71. package/gsd-core/bin/lib/io.cjs +160 -15
  72. package/gsd-core/bin/lib/learnings.cjs +85 -14
  73. package/gsd-core/bin/lib/legacy-cleanup.cjs +8 -2
  74. package/gsd-core/bin/lib/markdown-table.cjs +52 -4
  75. package/gsd-core/bin/lib/milestone.cjs +90 -5
  76. package/gsd-core/bin/lib/model-catalog.cjs +177 -19
  77. package/gsd-core/bin/lib/model-resolver.cjs +10 -28
  78. package/gsd-core/bin/lib/onboard-projection.cjs +5 -1
  79. package/gsd-core/bin/lib/phase-estimation.cjs +17 -8
  80. package/gsd-core/bin/lib/phase-id.cjs +70 -4
  81. package/gsd-core/bin/lib/phase-lifecycle.cjs +24 -16
  82. package/gsd-core/bin/lib/phase-locator.cjs +138 -17
  83. package/gsd-core/bin/lib/phase.cjs +405 -84
  84. package/gsd-core/bin/lib/plan-document.cjs +263 -0
  85. package/gsd-core/bin/lib/plan-scan.cjs +13 -2
  86. package/gsd-core/bin/lib/planning-command-router.cjs +61 -0
  87. package/gsd-core/bin/lib/planning-inspect.cjs +1168 -0
  88. package/gsd-core/bin/lib/planning-snapshot.cjs +18 -14
  89. package/gsd-core/bin/lib/planning-workspace.cjs +56 -0
  90. package/gsd-core/bin/lib/probe-core.cjs +4 -1
  91. package/gsd-core/bin/lib/profile-pipeline-command-router.cjs +50 -7
  92. package/gsd-core/bin/lib/profile-pipeline.cjs +6 -3
  93. package/gsd-core/bin/lib/real-home-guard.cjs +419 -0
  94. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +71 -45
  95. package/gsd-core/bin/lib/review-lane-descriptor.cjs +9 -9
  96. package/gsd-core/bin/lib/roadmap-command-router.cjs +45 -31
  97. package/gsd-core/bin/lib/roadmap-parser.cjs +79 -16
  98. package/gsd-core/bin/lib/roadmap.cjs +74 -19
  99. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +96 -8
  100. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +34 -1
  101. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +287 -55
  102. package/gsd-core/bin/lib/runtime-identity.cjs +234 -0
  103. package/gsd-core/bin/lib/runtime-slash.cjs +72 -2
  104. package/gsd-core/bin/lib/shell-command-projection.cjs +71 -8
  105. package/gsd-core/bin/lib/smart-entry.cjs +12 -22
  106. package/gsd-core/bin/lib/spec-section.cjs +12 -7
  107. package/gsd-core/bin/lib/state-command-router.cjs +47 -18
  108. package/gsd-core/bin/lib/state-contract.cjs +359 -0
  109. package/gsd-core/bin/lib/state-document.cjs +186 -0
  110. package/gsd-core/bin/lib/state-md-schema.cjs +221 -0
  111. package/gsd-core/bin/lib/state-transition.cjs +517 -101
  112. package/gsd-core/bin/lib/state.cjs +946 -163
  113. package/gsd-core/bin/lib/surface.cjs +10 -2
  114. package/gsd-core/bin/lib/task-command-router.cjs +111 -1
  115. package/gsd-core/bin/lib/task-content-resolution.cjs +368 -0
  116. package/gsd-core/bin/lib/teams-status.cjs +4 -1
  117. package/gsd-core/bin/lib/uat-predicate.cjs +58 -20
  118. package/gsd-core/bin/lib/uat.cjs +1376 -125
  119. package/gsd-core/bin/lib/ui-consideration-probe.cjs +9 -1
  120. package/gsd-core/bin/lib/ui-safety-gate.cjs +37 -7
  121. package/gsd-core/bin/lib/unusable-input.cjs +13 -0
  122. package/gsd-core/bin/lib/validate-command-router.cjs +2 -2
  123. package/gsd-core/bin/lib/vendor/README.md +43 -5
  124. package/gsd-core/bin/lib/vendor/js-yaml.cjs +3014 -0
  125. package/gsd-core/bin/lib/verification.cjs +14 -1
  126. package/gsd-core/bin/lib/verify-command-grounding.cjs +846 -0
  127. package/gsd-core/bin/lib/verify.cjs +95 -40
  128. package/gsd-core/bin/lib/workstream-name-policy.cjs +25 -4
  129. package/gsd-core/bin/lib/worktree-base-ref.cjs +66 -12
  130. package/gsd-core/bin/lib/worktree-safety.cjs +177 -21
  131. package/gsd-core/bin/shared/config-defaults.manifest.json +7 -1
  132. package/gsd-core/bin/shared/config-schema.manifest.json +5 -0
  133. package/gsd-core/bin/shared/exit-codes.json +8 -0
  134. package/gsd-core/bin/shared/exit-codes.sh +20 -0
  135. package/gsd-core/bin/shared/model-catalog.json +8 -1
  136. package/gsd-core/references/agent-contracts.md +3 -2
  137. package/gsd-core/references/api-coverage.md +24 -2
  138. package/gsd-core/references/autonomous-smart-discuss.md +3 -3
  139. package/gsd-core/references/checkpoints.md +37 -19
  140. package/gsd-core/references/decimal-phase-calculation.md +5 -5
  141. package/gsd-core/references/edge-probe.md +8 -0
  142. package/gsd-core/references/execute-mvp-tdd.md +1 -3
  143. package/gsd-core/references/execute-phase-between-wave-reset.md +9 -12
  144. package/gsd-core/references/execute-phase-wave-guard.md +11 -9
  145. package/gsd-core/references/failing-direction.md +78 -0
  146. package/gsd-core/references/gate-prompts.md +1 -1
  147. package/gsd-core/references/git-integration.md +5 -5
  148. package/gsd-core/references/git-planning-commit.md +3 -3
  149. package/gsd-core/references/gsd-run-resolver.md +1 -1
  150. package/gsd-core/references/loop-hook-dispatch.md +22 -0
  151. package/gsd-core/references/model-profiles.md +1 -1
  152. package/gsd-core/references/nyquist-compliance.md +74 -0
  153. package/gsd-core/references/offer-next.md +3 -5
  154. package/gsd-core/references/phase-argument-parsing.md +3 -3
  155. package/gsd-core/references/planner-failing-direction.md +53 -0
  156. package/gsd-core/references/planner-human-verify-mode.md +15 -1
  157. package/gsd-core/references/planner-revision.md +1 -1
  158. package/gsd-core/references/planner-verify-command-grounding.md +17 -0
  159. package/gsd-core/references/planning-config.md +37 -8
  160. package/gsd-core/references/reviewer-instances.md +31 -0
  161. package/gsd-core/references/runtime-aware-dispatch.md +1 -1
  162. package/gsd-core/references/tdd.md +1 -3
  163. package/gsd-core/references/ui-brand.md +65 -21
  164. package/gsd-core/references/ui-consideration-probe.md +1 -1
  165. package/gsd-core/references/universal-anti-patterns.md +2 -2
  166. package/gsd-core/references/verify-command-path-resolvability.md +42 -0
  167. package/gsd-core/references/verify-mvp-mode.md +1 -1
  168. package/gsd-core/references/workstream-flag.md +11 -11
  169. package/gsd-core/templates/README.md +1 -1
  170. package/gsd-core/templates/SECURITY.md +3 -3
  171. package/gsd-core/templates/UI-SPEC.md +25 -3
  172. package/gsd-core/templates/VALIDATION.md +3 -3
  173. package/gsd-core/templates/phase-prompt.md +3 -0
  174. package/gsd-core/templates/state.md +7 -0
  175. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
  176. package/gsd-core/workflows/add-backlog.md +1 -1
  177. package/gsd-core/workflows/add-phase.md +3 -3
  178. package/gsd-core/workflows/add-tests.md +3 -8
  179. package/gsd-core/workflows/add-todo.md +1 -1
  180. package/gsd-core/workflows/ai-integration-phase.md +4 -9
  181. package/gsd-core/workflows/audit-fix.md +12 -3
  182. package/gsd-core/workflows/audit-milestone.md +9 -9
  183. package/gsd-core/workflows/audit-uat.md +17 -2
  184. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +2 -2
  185. package/gsd-core/workflows/autonomous.md +10 -26
  186. package/gsd-core/workflows/check-todos.md +1 -1
  187. package/gsd-core/workflows/cleanup.md +2 -2
  188. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +1 -1
  189. package/gsd-core/workflows/code-review-fix.md +1 -1
  190. package/gsd-core/workflows/code-review.md +121 -40
  191. package/gsd-core/workflows/complete-milestone.md +15 -10
  192. package/gsd-core/workflows/debug.md +5 -3
  193. package/gsd-core/workflows/diagnose-issues.md +12 -6
  194. package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
  195. package/gsd-core/workflows/discuss-phase/modes/chain.md +3 -7
  196. package/gsd-core/workflows/discuss-phase/modes/text.md +1 -1
  197. package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +1 -3
  198. package/gsd-core/workflows/discuss-phase-assumptions.md +2 -2
  199. package/gsd-core/workflows/discuss-phase.md +1 -1
  200. package/gsd-core/workflows/do.md +3 -6
  201. package/gsd-core/workflows/docs-update.md +5 -4
  202. package/gsd-core/workflows/edit-phase.md +1 -1
  203. package/gsd-core/workflows/eval-review.md +4 -9
  204. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
  205. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +113 -11
  206. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +1 -1
  207. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +1 -1
  208. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +1 -1
  209. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +22 -4
  210. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +2 -2
  211. package/gsd-core/workflows/execute-phase/steps/protected-branch.md +21 -0
  212. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -2
  213. package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +39 -0
  214. package/gsd-core/workflows/execute-phase.md +38 -54
  215. package/gsd-core/workflows/execute-plan.md +17 -12
  216. package/gsd-core/workflows/explore.md +1 -1
  217. package/gsd-core/workflows/extract-learnings.md +1 -1
  218. package/gsd-core/workflows/fast.md +2 -2
  219. package/gsd-core/workflows/forensics.md +1 -1
  220. package/gsd-core/workflows/graduation.md +5 -5
  221. package/gsd-core/workflows/health.md +3 -6
  222. package/gsd-core/workflows/import.md +14 -11
  223. package/gsd-core/workflows/inbox.md +4 -5
  224. package/gsd-core/workflows/ingest-docs.md +44 -11
  225. package/gsd-core/workflows/insert-phase.md +5 -5
  226. package/gsd-core/workflows/list-seeds.md +5 -3
  227. package/gsd-core/workflows/list-workspaces.md +1 -1
  228. package/gsd-core/workflows/manager.md +12 -23
  229. package/gsd-core/workflows/map-codebase.md +1 -1
  230. package/gsd-core/workflows/milestone-summary.md +1 -1
  231. package/gsd-core/workflows/mvp-phase.md +2 -2
  232. package/gsd-core/workflows/new-milestone.md +9 -21
  233. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +1 -1
  234. package/gsd-core/workflows/new-project.md +12 -26
  235. package/gsd-core/workflows/new-workspace.md +1 -1
  236. package/gsd-core/workflows/next.md +2 -2
  237. package/gsd-core/workflows/pause-work.md +1 -1
  238. package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +1 -1
  239. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +1 -1
  240. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +2 -4
  241. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +3 -3
  242. package/gsd-core/workflows/plan-phase.md +121 -42
  243. package/gsd-core/workflows/plan-review-convergence.md +46 -9
  244. package/gsd-core/workflows/plant-seed.md +2 -2
  245. package/gsd-core/workflows/pr-branch.md +187 -51
  246. package/gsd-core/workflows/profile-user.md +16 -14
  247. package/gsd-core/workflows/progress.md +27 -12
  248. package/gsd-core/workflows/quick/steps/discussion-phase.md +1 -3
  249. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +1 -3
  250. package/gsd-core/workflows/quick/steps/quick-verification.md +2 -4
  251. package/gsd-core/workflows/quick/steps/research-phase.md +2 -4
  252. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +3 -3
  253. package/gsd-core/workflows/quick.md +20 -29
  254. package/gsd-core/workflows/remove-phase.md +4 -4
  255. package/gsd-core/workflows/remove-workspace.md +2 -2
  256. package/gsd-core/workflows/resume-project.md +8 -12
  257. package/gsd-core/workflows/review.md +193 -15
  258. package/gsd-core/workflows/scan.md +1 -1
  259. package/gsd-core/workflows/secure-phase.md +2 -2
  260. package/gsd-core/workflows/settings-advanced.md +7 -9
  261. package/gsd-core/workflows/settings-integrations.md +64 -31
  262. package/gsd-core/workflows/settings.md +3 -5
  263. package/gsd-core/workflows/ship.md +12 -6
  264. package/gsd-core/workflows/sketch-wrap-up.md +11 -17
  265. package/gsd-core/workflows/sketch.md +12 -18
  266. package/gsd-core/workflows/smart-entry.md +3 -5
  267. package/gsd-core/workflows/spec-phase.md +23 -1
  268. package/gsd-core/workflows/spike-wrap-up.md +7 -11
  269. package/gsd-core/workflows/spike.md +20 -31
  270. package/gsd-core/workflows/stats.md +2 -2
  271. package/gsd-core/workflows/sync-skills.md +1 -1
  272. package/gsd-core/workflows/thread.md +11 -7
  273. package/gsd-core/workflows/transition.md +5 -5
  274. package/gsd-core/workflows/ui-phase.md +10 -16
  275. package/gsd-core/workflows/ui-review.md +6 -10
  276. package/gsd-core/workflows/ultraplan-phase.md +5 -13
  277. package/gsd-core/workflows/undo.md +8 -16
  278. package/gsd-core/workflows/update.md +6 -10
  279. package/gsd-core/workflows/validate-phase.md +2 -2
  280. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +25 -1
  281. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +1 -1
  282. package/gsd-core/workflows/verify-work.md +57 -18
  283. package/hooks/dist/gsd-agent-isolation-guard.js +77 -38
  284. package/hooks/dist/gsd-config-reload.js +18 -12
  285. package/hooks/dist/gsd-context-monitor.js +19 -10
  286. package/hooks/dist/gsd-cursor-post-tool.js +3 -1
  287. package/hooks/dist/gsd-cursor-pre-tool.js +3 -1
  288. package/hooks/dist/gsd-cursor-session-start.js +2 -1
  289. package/hooks/dist/gsd-cursor-stop.js +2 -1
  290. package/hooks/dist/gsd-cursor-subagent-start.js +28 -23
  291. package/hooks/dist/gsd-cursor-subagent-stop.js +3 -1
  292. package/hooks/dist/gsd-ensure-canonical-path.js +2 -1
  293. package/hooks/dist/gsd-graphify-update.sh +22 -18
  294. package/hooks/dist/gsd-node-runner.sh +76 -0
  295. package/hooks/dist/gsd-phase-boundary.sh +1 -0
  296. package/hooks/dist/gsd-prompt-guard.js +16 -7
  297. package/hooks/dist/gsd-read-guard.js +16 -7
  298. package/hooks/dist/gsd-read-injection-scanner.js +17 -8
  299. package/hooks/dist/gsd-session-state.sh +1 -0
  300. package/hooks/dist/gsd-statusline.js +215 -26
  301. package/hooks/dist/gsd-validate-commit.sh +80 -6
  302. package/hooks/dist/gsd-windsurf-pre-command.js +16 -11
  303. package/hooks/dist/gsd-windsurf-pre-write.js +22 -13
  304. package/hooks/dist/gsd-workflow-guard.js +34 -16
  305. package/hooks/dist/gsd-worktree-path-guard.js +36 -21
  306. package/hooks/dist/gsd-write-guard.js +35 -25
  307. package/hooks/dist/lib/cli-exit.js +560 -0
  308. package/hooks/dist/lib/exit-code-registry.js +98 -0
  309. package/hooks/dist/lib/git-probe.js +84 -0
  310. package/hooks/dist/lib/hook-exit.js +81 -0
  311. package/hooks/dist/managed-hooks-registry.cjs +3 -0
  312. package/hooks/gsd-agent-isolation-guard.js +77 -38
  313. package/hooks/gsd-config-reload.js +18 -12
  314. package/hooks/gsd-context-monitor.js +19 -10
  315. package/hooks/gsd-cursor-post-tool.js +3 -1
  316. package/hooks/gsd-cursor-pre-tool.js +3 -1
  317. package/hooks/gsd-cursor-session-start.js +2 -1
  318. package/hooks/gsd-cursor-stop.js +2 -1
  319. package/hooks/gsd-cursor-subagent-start.js +28 -23
  320. package/hooks/gsd-cursor-subagent-stop.js +3 -1
  321. package/hooks/gsd-ensure-canonical-path.js +2 -1
  322. package/hooks/gsd-graphify-update.sh +22 -18
  323. package/hooks/gsd-node-runner.sh +76 -0
  324. package/hooks/gsd-phase-boundary.sh +1 -0
  325. package/hooks/gsd-prompt-guard.js +16 -7
  326. package/hooks/gsd-read-guard.js +16 -7
  327. package/hooks/gsd-read-injection-scanner.js +17 -8
  328. package/hooks/gsd-session-state.sh +1 -0
  329. package/hooks/gsd-statusline.js +215 -26
  330. package/hooks/gsd-validate-commit.sh +80 -6
  331. package/hooks/gsd-windsurf-pre-command.js +16 -11
  332. package/hooks/gsd-windsurf-pre-write.js +22 -13
  333. package/hooks/gsd-workflow-guard.js +34 -16
  334. package/hooks/gsd-worktree-path-guard.js +36 -21
  335. package/hooks/gsd-write-guard.js +35 -25
  336. package/hooks/lib/cli-exit.js +560 -0
  337. package/hooks/lib/exit-code-registry.js +98 -0
  338. package/hooks/lib/git-probe.js +84 -0
  339. package/hooks/lib/hook-exit.js +81 -0
  340. package/hooks/managed-hooks-registry.cjs +3 -0
  341. package/package.json +12 -7
  342. package/scripts/base64-scan.sh +74 -12
  343. package/scripts/build-hooks.js +5 -0
  344. package/scripts/check-glossary-refs.cjs +77 -15
  345. package/scripts/check-mutation-score-ratchet.cjs +156 -0
  346. package/scripts/ci-check-job-near-cap.cjs +49 -0
  347. package/scripts/ci-pr-mergeability.cjs +262 -0
  348. package/scripts/ci-test-scope.cjs +45 -12
  349. package/scripts/ci-timeout-report.cjs +230 -0
  350. package/scripts/docs-guard-registry.cjs +396 -0
  351. package/scripts/gen-capability-registry.cjs +8 -6
  352. package/scripts/gen-exit-code-docs.cjs +318 -0
  353. package/scripts/gen-exit-code-registry.cjs +891 -0
  354. package/scripts/gen-features.cjs +836 -0
  355. package/scripts/gen-hooks-cli-exit.cjs +239 -0
  356. package/scripts/gen-install-tree-fixtures.cjs +2 -2
  357. package/scripts/gen-loop-host-contract.cjs +134 -1
  358. package/scripts/gen-scripts-cli-exit.cjs +185 -0
  359. package/scripts/gen-state-md-docs.cjs +727 -0
  360. package/scripts/{test-failure-reasons.cjs → gsd-test-gate-reasons.cjs} +6 -0
  361. package/scripts/lib/ci-job-timing.cjs +72 -0
  362. package/scripts/lib/cli-exit.cjs +546 -44
  363. package/scripts/lib/drift-scan.cjs +32 -2
  364. package/scripts/lib/exit-code-registry.cjs +98 -0
  365. package/scripts/lib/ndjson-reporter.cjs +119 -0
  366. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +1 -1
  367. package/scripts/lint-docs-guard-registration.cjs +495 -0
  368. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +193 -0
  369. package/scripts/lint-eslint-glob-coverage.allowlist.json +4 -0
  370. package/scripts/{lint-fix-has-regression-test.cjs → lint-fix-has-regression-tests.cjs} +12 -6
  371. package/scripts/lint-health-diagnostic-rule-table.cjs +65 -8
  372. package/scripts/lint-mutation-test-derivation-drift.cjs +86 -0
  373. package/scripts/lint-phase-enumeration-drift.cjs +21 -8
  374. package/scripts/lint-planning-prompt-drift.cjs +38 -1
  375. package/scripts/lint-removed-but-needed.cjs +184 -16
  376. package/scripts/lint-seam-enforcement.cjs +182 -0
  377. package/scripts/lint-slug-derivation-drift.cjs +921 -0
  378. package/scripts/lint-source-test-name-collision.cjs +241 -0
  379. package/scripts/lint-state-write-path-drift.cjs +337 -432
  380. package/scripts/lint-test-file-count.allowlist.json +122 -4
  381. package/scripts/lint-test-file-count.cjs +25 -3
  382. package/scripts/lint-unreachable-guard-drift.cjs +51 -64
  383. package/scripts/lint-vendored-deps.cjs +208 -35
  384. package/scripts/mutation-matrix.cjs +599 -50
  385. package/scripts/prompt-injection-scan.sh +75 -14
  386. package/scripts/secret-scan.sh +75 -13
  387. package/scripts/select-docs-guards.cjs +56 -0
  388. package/scripts/sync-runtime-launcher.cjs +22 -3
  389. package/skills/gsd-discuss-phase/SKILL.md +1 -1
  390. package/skills/gsd-import/SKILL.md +1 -1
  391. package/skills/gsd-quick/SKILL.md +8 -4
  392. package/vscode/package.json +1 -1
  393. package/bin/lib/ui-safety-gate.cjs +0 -109
  394. package/scripts/lint-emitted-drift-ack.cjs +0 -344
  395. 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,
@@ -638,46 +647,109 @@ function effortSurfaceForHost(cwd, host) {
638
647
  }
639
648
  }
640
649
  /**
641
- * #488 — Replace or inject the `effort:` value in YAML frontmatter.
650
+ * #488 — Replace or inject the `<key>:` value in YAML frontmatter.
642
651
  * Unlike injectEffortFrontmatter (install.js), this overwrites an existing value.
652
+ * #3706: key-parameterised so the same line-editor serves both claude's
653
+ * `effort:` and OpenCode's `variant:`. #3706: all offsets (eol, openLen,
654
+ * closingStart) are derived from the MATCHED BLOCK, not the start of the
655
+ * file, and the existing-key replace is scoped to the frontmatter span only.
643
656
  */
644
- function setEffortFrontmatter(content, effortValue) {
645
- const eol = /^---\r\n/.test(content) ? '\r\n' : '\n';
657
+ function setFrontmatterKeyLine(content, key, value) {
646
658
  const fmRe = /^---\r?\n([\s\S]*?)^---\r?$/m;
647
659
  const match = fmRe.exec(content);
648
660
  if (!match)
649
661
  return content;
650
662
  const fmBody = match[1];
651
- if (/^effort:/m.test(fmBody)) {
652
- return content.replace(/^(effort:)[ \t]*.*$/m, `$1 ${effortValue}`);
653
- }
663
+ // Both writers of these frontmatter keys — this sync path and the
664
+ // install-side `frontmatterScalar` in runtime-artifact-conversion.cts —
665
+ // now share one escaping rule: quote via `agentScalarNeedsDoubleQuoting` +
666
+ // `escapeDoubleQuotedScalar` (both from frontmatter.cts) rather than each
667
+ // interpolating `value` raw/differently.
668
+ const renderedValue = agentScalarNeedsDoubleQuoting(value) ? `"${escapeDoubleQuotedScalar(value)}"` : value;
669
+ // EOL comes from the MATCHED BLOCK, not the start of the file. With a
670
+ // preamble the two can disagree, and on a CRLF document that misaligns every
671
+ // offset below by one byte and mangles the opening fence.
672
+ const eol = /^---\r\n/.test(match[0]) ? '\r\n' : '\n';
654
673
  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);
674
+ const bodyStart = match.index + openLen;
675
+ const closingStart = bodyStart + fmBody.length;
676
+ // #3706: key is now generic (not just the literal 'effort'/'variant'
677
+ // callers happen to pass today) — escape it before interpolating into the
678
+ // RegExp so a future caller can't have its key metacharacters reinterpreted.
679
+ const keyLineRe = new RegExp(`^(${(0, pattern_cjs_1.escapeRegex)(key)}:)[ \\t]*.*$`, 'm');
680
+ if (keyLineRe.test(fmBody)) {
681
+ // #3706: a duplicated `<key>:` line is already invalid YAML, but a
682
+ // non-first-wins reader (last-wins) would otherwise honour a stale
683
+ // second occurrence left behind by a naive single-hit replace, while
684
+ // this function's own single-hit read reports "in sync" — a
685
+ // permanently non-converging state. Use a GLOBAL replace with a
686
+ // first-hit flag so every occurrence collapses to exactly one, IN THE
687
+ // POSITION of the first occurrence (never delete-then-append, which
688
+ // would move the key to the end of the frontmatter and churn every
689
+ // already-generated single-occurrence file).
690
+ const escaped = (0, pattern_cjs_1.escapeRegex)(key);
691
+ let seen = false;
692
+ const newBody = fmBody.replace(new RegExp(`^${escaped}:[ \\t]*.*(\\r?\\n?)`, 'gm'), (_m, nl) => {
693
+ if (!seen) {
694
+ seen = true;
695
+ return `${key}: ${renderedValue}${nl}`;
696
+ }
697
+ return '';
698
+ });
699
+ // Replace INSIDE the frontmatter span only: a whole-file /m replace would
700
+ // rewrite an earlier preamble line that happens to start with this key.
701
+ return content.slice(0, bodyStart) + newBody + content.slice(closingStart);
702
+ }
703
+ return content.slice(0, closingStart) + `${key}: ${renderedValue}${eol}` + content.slice(closingStart);
657
704
  }
658
705
  /**
659
- * #3533 (10d) — remove exactly the frontmatter `effort:` line (and its line
706
+ * #3533 (10d) — remove exactly the frontmatter `<key>:` line (and its line
660
707
  * ending) so an agent configured for `inherit` carries NO key. Mirrors the
661
708
  * codex-agent-toml strip discipline: targeted line removal, EOL-aware, every
662
709
  * other byte (comments, sibling keys, the body) untouched.
710
+ * #3706: key-parameterised so the same line-editor serves both claude's
711
+ * `effort:` and OpenCode's `variant:`. #3706: openLen is derived from the
712
+ * MATCHED BLOCK, not the start of the file — a preamble on a CRLF document
713
+ * would otherwise misalign every offset below.
663
714
  */
664
- function removeEffortFrontmatter(content) {
715
+ function removeFrontmatterKeyLine(content, key) {
665
716
  // 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,
717
+ // preamble or body line starting with `<key>:` (a fenced config example,
667
718
  // a thematic-break flanked fragment) must never be the line removed.
668
719
  const fmRe = /^---\r?\n([\s\S]*?)^---\r?$/m;
669
720
  const match = fmRe.exec(content);
670
721
  if (!match)
671
722
  return content;
672
723
  const fmBody = match[1];
673
- const lineRe = /^effort:[ \t]*.*\r?\n?/m;
724
+ // #3706: same generic-key escape as setFrontmatterKeyLine above.
725
+ const lineRe = new RegExp(`^${(0, pattern_cjs_1.escapeRegex)(key)}:[ \\t]*.*\\r?\\n?`, 'm');
674
726
  if (!lineRe.test(fmBody))
675
727
  return content;
676
- const strippedFm = fmBody.replace(lineRe, '');
677
- const openLen = 3 + (/^---\r\n/.test(content) ? 2 : 1);
728
+ // A duplicate `<key>:` mapping key is already invalid YAML (a document with
729
+ // two `effort:`/`variant:` lines does not parse), so this is robustness
730
+ // against a malformed document, not a live corruption path. Still, "a null
731
+ // target means the key must not exist" is an invariant this function must
732
+ // leave true on disk — a non-global replace here would strip only the
733
+ // FIRST occurrence and require a second run to converge. Use a fresh
734
+ // global RegExp for the strip so every occurrence in the frontmatter body
735
+ // is removed in one pass.
736
+ const stripAllRe = new RegExp(`^${(0, pattern_cjs_1.escapeRegex)(key)}:[ \\t]*.*\\r?\\n?`, 'gm');
737
+ const strippedFm = fmBody.replace(stripAllRe, '');
738
+ // Same rule as setFrontmatterKeyLine: the EOL must come from the matched
739
+ // block, not the start of the file, or a preambled CRLF document misaligns.
740
+ const eol = /^---\r\n/.test(match[0]) ? '\r\n' : '\n';
741
+ const openLen = 3 + eol.length;
678
742
  const closingStart = match.index + openLen + fmBody.length;
679
743
  return content.slice(0, match.index + openLen) + strippedFm + content.slice(closingStart);
680
744
  }
745
+ /** #488 — Replace or inject the `effort:` value in YAML frontmatter. */
746
+ function setEffortFrontmatter(content, effortValue) {
747
+ return setFrontmatterKeyLine(content, 'effort', effortValue);
748
+ }
749
+ /** #3533 (10d) — remove exactly the frontmatter `effort:` line (and its line ending). */
750
+ function removeEffortFrontmatter(content) {
751
+ return removeFrontmatterKeyLine(content, 'effort');
752
+ }
681
753
  /**
682
754
  * #488 — Re-sync effort: frontmatter in all installed gsd-*.md agent files to
683
755
  * match the current effort config, without requiring a full reinstall.
@@ -701,6 +773,14 @@ function cmdEffortSync(cwd, raw, opts) {
701
773
  cmdEffortSyncCodex(raw, dryRun, opts.configDir);
702
774
  return;
703
775
  }
776
+ // #3706: install now bakes OpenCode's resolved effort into agent
777
+ // frontmatter under the `variant:` key (not `effort:`), so OpenCode gets
778
+ // its own sync path — mirroring the codex branch above — rather than
779
+ // falling into the generic "does not use effort: frontmatter" skip.
780
+ if (runtime === 'opencode') {
781
+ cmdEffortSyncOpencode(cwd, raw, dryRun, opts.configDir);
782
+ return;
783
+ }
704
784
  if (runtime !== 'claude') {
705
785
  output({ synced: 0, skipped: 0, changes: [], dry_run: dryRun, reason: `runtime '${runtime}' does not use effort: frontmatter` }, raw, '');
706
786
  return;
@@ -729,14 +809,46 @@ function cmdEffortSync(cwd, raw, opts) {
729
809
  catch {
730
810
  return false;
731
811
  }
732
- });
812
+ }).sort(); // #3706: sorted like the codex and
813
+ // opencode branches — readdir order is platform-dependent, so leaving it unsorted makes the
814
+ // reported `changes` ordering differ across machines for identical inputs.
733
815
  const changes = [];
734
816
  let synced = 0;
735
817
  let skipped = 0;
818
+ // Local-only counter: reads AND writes are both guarded in this loop (an
819
+ // unreadable or unwritable agent file must not abort the whole sweep), but
820
+ // this result shape (`{synced, skipped, changes, dry_run, agents_dir}`) is
821
+ // long-standing and widely consumed, so it deliberately gains NO new key
822
+ // (no `read_failures`/`write_failures`, unlike the codex/opencode branches
823
+ // below). Instead every per-file failure — read or write — is folded into
824
+ // `skipped` and rides the raw-mode summary token below — `output()`'s
825
+ // third argument is never merged into the emitted JSON object (see io.cts
826
+ // `output()`: it is only read when `raw === true`, entirely replacing the
827
+ // JSON payload), so flipping it to `'failed'` costs nothing in the wire
828
+ // shape while still surfacing the failure to a raw-mode caller. The three
829
+ // branches differ on that reporting shape, but are now also consistent in
830
+ // HOW they publish: every write below goes through the same tmp-file +
831
+ // chmod + retryRenameSync atomic-publish sequence used by
832
+ // cmdEffortSyncCodex and cmdEffortSyncOpencode, so a fault mid-write can
833
+ // never leave an agent file truncated or empty.
834
+ let fileFailureCount = 0;
736
835
  for (const file of files) {
737
836
  const agentName = file.replace(/\.md$/, '');
738
837
  const filePath = node_path_1.default.join(agentsDir, file);
739
- const content = node_fs_1.default.readFileSync(filePath, 'utf8');
838
+ let content;
839
+ try {
840
+ content = node_fs_1.default.readFileSync(filePath, 'utf8');
841
+ }
842
+ catch {
843
+ // An unreadable agent file must not abort the whole sweep. Deliberately
844
+ // NOT adding a new field here: this result shape (`{synced, skipped,
845
+ // changes, dry_run, agents_dir}`) is long-standing and widely consumed,
846
+ // so the failure is folded into `skipped` only, with no
847
+ // read_failures/write_failures list — see `fileFailureCount` above.
848
+ skipped++;
849
+ fileFailureCount++;
850
+ continue;
851
+ }
740
852
  // Resolve using install-time logic: home defaults merged with project config.
741
853
  const universalEffort = resolveInstallTimeEffort(effortCfg, agentName);
742
854
  // #3533 (10d): 'inherit' means the key must NOT exist. An absent key is
@@ -744,45 +856,154 @@ function cmdEffortSync(cwd, raw, opts) {
744
856
  // drift and the sync re-added a hand-stripped key on every apply. A
745
857
  // present key under inherit is stripped, reported as {from, to: null}.
746
858
  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
859
  const fmMatchInherit = /^---\r?\n([\s\S]*?)^---\r?$/m.exec(content);
749
860
  if (!fmMatchInherit) {
750
861
  skipped++;
751
862
  continue;
752
863
  }
753
- const effortMatchInherit = /^effort:[ \t]*(.+?)[ \t]*$/m.exec(fmMatchInherit[1]);
754
- if (!effortMatchInherit) {
864
+ // Presence and value are distinct questions: `effort:` with an EMPTY
865
+ // value is a key that IS present but whose captured value is null (the
866
+ // `(.+?)` group requires at least one char). Deciding "already correct"
867
+ // from a null value alone is wrong here — it would leave an
868
+ // unresolvable `effort: null` key on disk forever. Test presence with
869
+ // its own regex, and only compare values once presence is known.
870
+ const effortPresentInherit = /^effort:/m.test(fmMatchInherit[1]);
871
+ if (!effortPresentInherit) {
755
872
  skipped++;
756
873
  continue;
757
874
  }
758
- changes.push({ agent: agentName, from: effortMatchInherit[1], to: null });
759
- synced++;
875
+ const effortMatchInherit = /^effort:[ \t]*(.+?)[ \t]*$/m.exec(fmMatchInherit[1]);
876
+ // `effortPresentInherit` is guaranteed true here (checked above), so a
877
+ // failed value match means the key is present with an EMPTY value —
878
+ // report `''`, not `null`, so "present-but-empty" is never conflated
879
+ // with "absent" in the sync output.
760
880
  if (!dryRun) {
761
- node_fs_1.default.writeFileSync(filePath, removeEffortFrontmatter(content));
881
+ // Atomic publish AND mode preservation, same discipline as
882
+ // cmdEffortSyncCodex/cmdEffortSyncOpencode: write to a sibling tmp
883
+ // file, chmod it to match filePath's existing (masked) mode, then
884
+ // retryRenameSync it over the target so filePath is either the old
885
+ // bytes or the new ones, never half-written and never dropped to a
886
+ // default mode. On any failure the tmp file is unlinked (best-effort)
887
+ // and the write is reported (folded into `skipped`/`fileFailureCount`,
888
+ // no new field), not thrown, so the remaining agents still get
889
+ // processed. ONE failure path for this site — no nested try/catch.
890
+ const tmpPathInherit = `${filePath}.tmp.${process.pid}`;
891
+ // Stat filePath BEFORE the write so its mode can be passed at
892
+ // CREATION time — a plain `writeFileSync(tmpPath, data)` creates the
893
+ // tmp file at the default `0666 & ~umask` even when filePath is more
894
+ // restrictive. Best-effort only: a stat failure must not abort the
895
+ // sync, since the content write is what matters, not the mode.
896
+ let originalModeInherit;
897
+ try {
898
+ originalModeInherit = node_fs_1.default.statSync(filePath).mode & 0o7777;
899
+ }
900
+ catch { /* non-fatal: fall back to writing without an explicit mode */ }
901
+ try {
902
+ node_fs_1.default.writeFileSync(tmpPathInherit, removeEffortFrontmatter(content), originalModeInherit !== undefined ? { mode: originalModeInherit } : undefined);
903
+ // Not redundant with the `mode` option above: `mode` only applies
904
+ // when the file is actually created (O_CREAT). A leftover tmp file
905
+ // from an earlier crashed run would be reused (truncated) at its
906
+ // OLD mode instead, and this chmod is what corrects that case.
907
+ // Best-effort only: a chmod failure must not abort the sync, since
908
+ // the content write is what matters, not the mode.
909
+ try {
910
+ if (originalModeInherit !== undefined)
911
+ node_fs_1.default.chmodSync(tmpPathInherit, originalModeInherit);
912
+ }
913
+ catch { /* non-fatal: proceed with default tmp-file mode */ }
914
+ (0, shell_command_projection_cjs_1.retryRenameSync)(tmpPathInherit, filePath);
915
+ }
916
+ catch {
917
+ try {
918
+ node_fs_1.default.unlinkSync(tmpPathInherit);
919
+ }
920
+ catch { /* already gone or never created */ }
921
+ skipped++;
922
+ fileFailureCount++;
923
+ continue;
924
+ }
762
925
  }
926
+ changes.push({ agent: agentName, from: effortMatchInherit ? effortMatchInherit[1] : '', to: null });
927
+ synced++;
763
928
  continue;
764
929
  }
930
+ // `runtime` is guaranteed 'claude' by the guard above (#3007: only
931
+ // codex's 'ultra' rejection can produce a null value).
765
932
  const rendered = (0, model_catalog_cjs_1.renderEffortForRuntime)(runtime, universalEffort);
766
933
  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
934
  const fmMatch = /^---\r?\n([\s\S]*?)^---\r?$/m.exec(content);
769
935
  if (!fmMatch) {
770
936
  skipped++;
771
937
  continue;
772
938
  }
939
+ // Presence and value are distinct questions here too: `currentEffort`
940
+ // reads null both when the key is ABSENT and when it is present with an
941
+ // EMPTY value. `effortPresent` disambiguates those two for the reported
942
+ // `from` below (never `null` when the key is present but empty) — but it
943
+ // has no bearing on the skip check that follows: `newEffortValue` is
944
+ // never null on this path (guarded above), so an absent key already
945
+ // yields `currentEffort === null !== newEffortValue` without consulting
946
+ // presence separately.
947
+ const effortPresent = /^effort:/m.test(fmMatch[1]);
773
948
  const effortMatch = /^effort:[ \t]*(.+?)[ \t]*$/m.exec(fmMatch[1]);
774
- const currentEffort = effortMatch ? effortMatch[1] : null;
949
+ // `null` (key absent) and `''` (key present, value empty) are distinct
950
+ // states `effortPresent` deliberately disambiguates — collapsing both to
951
+ // `null` here would make the reported `from` lie about which case fired.
952
+ const currentEffort = effortPresent ? (effortMatch ? effortMatch[1] : '') : null;
775
953
  if (currentEffort === newEffortValue) {
776
954
  skipped++;
777
955
  continue;
778
956
  }
779
- changes.push({ agent: agentName, from: currentEffort, to: newEffortValue });
780
- synced++;
781
957
  if (!dryRun) {
782
- node_fs_1.default.writeFileSync(filePath, setEffortFrontmatter(content, newEffortValue));
958
+ // Atomic publish AND mode preservation, same discipline as
959
+ // cmdEffortSyncCodex/cmdEffortSyncOpencode: write to a sibling tmp
960
+ // file, chmod it to match filePath's existing (masked) mode, then
961
+ // retryRenameSync it over the target so filePath is either the old
962
+ // bytes or the new ones, never half-written and never dropped to a
963
+ // default mode. On any failure the tmp file is unlinked (best-effort)
964
+ // and the write is reported (folded into `skipped`/`fileFailureCount`,
965
+ // no new field), not thrown, so the remaining agents still get
966
+ // processed. ONE failure path for this site — no nested try/catch.
967
+ const tmpPathSet = `${filePath}.tmp.${process.pid}`;
968
+ // Stat filePath BEFORE the write so its mode can be passed at CREATION
969
+ // time — a plain `writeFileSync(tmpPath, data)` creates the tmp file
970
+ // at the default `0666 & ~umask` even when filePath is more
971
+ // restrictive. Best-effort only: a stat failure must not abort the
972
+ // sync, since the content write is what matters, not the mode.
973
+ let originalModeSet;
974
+ try {
975
+ originalModeSet = node_fs_1.default.statSync(filePath).mode & 0o7777;
976
+ }
977
+ catch { /* non-fatal: fall back to writing without an explicit mode */ }
978
+ try {
979
+ node_fs_1.default.writeFileSync(tmpPathSet, setEffortFrontmatter(content, newEffortValue), originalModeSet !== undefined ? { mode: originalModeSet } : undefined);
980
+ // Not redundant with the `mode` option above: `mode` only applies
981
+ // when the file is actually created (O_CREAT). A leftover tmp file
982
+ // from an earlier crashed run would be reused (truncated) at its OLD
983
+ // mode instead, and this chmod is what corrects that case.
984
+ // Best-effort only: a chmod failure must not abort the sync, since
985
+ // the content write is what matters, not the mode.
986
+ try {
987
+ if (originalModeSet !== undefined)
988
+ node_fs_1.default.chmodSync(tmpPathSet, originalModeSet);
989
+ }
990
+ catch { /* non-fatal: proceed with default tmp-file mode */ }
991
+ (0, shell_command_projection_cjs_1.retryRenameSync)(tmpPathSet, filePath);
992
+ }
993
+ catch {
994
+ try {
995
+ node_fs_1.default.unlinkSync(tmpPathSet);
996
+ }
997
+ catch { /* already gone or never created */ }
998
+ skipped++;
999
+ fileFailureCount++;
1000
+ continue;
1001
+ }
783
1002
  }
1003
+ changes.push({ agent: agentName, from: currentEffort, to: newEffortValue });
1004
+ synced++;
784
1005
  }
785
- output({ synced, skipped, changes, dry_run: dryRun, agents_dir: agentsDir }, raw, synced > 0 ? 'changed' : 'ok');
1006
+ output({ synced, skipped, changes, dry_run: dryRun, agents_dir: agentsDir }, raw, fileFailureCount > 0 ? 'failed' : synced > 0 ? 'changed' : 'ok');
786
1007
  }
787
1008
  /**
788
1009
  * ADR-2313 D7 (#3243) — the Codex branch of `cmdEffortSync`. Strips a stale
@@ -793,8 +1014,9 @@ function cmdEffortSync(cwd, raw, opts) {
793
1014
  * document is refused and reported, never partially rewritten (40-design.md
794
1015
  * "Reconciliation" — parseCodexAgentToml is the STRICT half of the reader/
795
1016
  * 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.
1017
+ * `{synced, skipped, changes, dry_run, agents_dir}` — `refused`,
1018
+ * `write_failures`, and `read_failures` are new fields, never a reshape of
1019
+ * the existing ones.
798
1020
  */
799
1021
  function cmdEffortSyncCodex(raw, dryRun, configDir) {
800
1022
  // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
@@ -822,12 +1044,25 @@ function cmdEffortSyncCodex(raw, dryRun, configDir) {
822
1044
  const changes = [];
823
1045
  const refused = [];
824
1046
  const writeFailures = [];
1047
+ const readFailures = [];
825
1048
  let synced = 0;
826
1049
  let skipped = 0;
827
1050
  for (const file of files) {
828
1051
  const agentName = file.replace(/\.toml$/, '');
829
1052
  const filePath = node_path_1.default.join(agentsDir, file);
830
- const content = node_fs_1.default.readFileSync(filePath, 'utf8');
1053
+ let content;
1054
+ try {
1055
+ content = node_fs_1.default.readFileSync(filePath, 'utf8');
1056
+ }
1057
+ catch (err) {
1058
+ // An unreadable agent file must not abort the whole sweep — mirrors the
1059
+ // opencode branch's own read guard, reported under its own
1060
+ // `read_failures` key so a caller can tell "never read" apart from
1061
+ // "read but write failed".
1062
+ skipped++;
1063
+ readFailures.push({ agent: agentName, file: filePath, error: err instanceof Error ? err.message : String(err) });
1064
+ continue;
1065
+ }
831
1066
  const parsed = (0, codex_agent_toml_cjs_1.parseCodexAgentToml)(content);
832
1067
  if (!parsed.ok) {
833
1068
  // Never partially rewritten (40-design.md, ADR-2313 reader/writer
@@ -869,8 +1104,32 @@ function cmdEffortSyncCodex(raw, dryRun, configDir) {
869
1104
  // bare fs.renameSync) carries the transient-Windows-lock retry per
870
1105
  // DEFECT.WINDOWS-FS-OPS.
871
1106
  const tmpPath = `${filePath}.tmp.${process.pid}`;
1107
+ // Stat filePath BEFORE the write so the original mode is available to
1108
+ // pass at creation time, not just at chmod time afterward — otherwise
1109
+ // the tmp file is briefly created at the default `0666 & ~umask`
1110
+ // (world-readable under a typical 022 umask) even when filePath is
1111
+ // e.g. 0600, exposing its contents for the window between creation and
1112
+ // chmod. Best-effort: a stat failure must not abort the sync, since the
1113
+ // content write is what matters, not the mode.
1114
+ let originalMode;
872
1115
  try {
873
- node_fs_1.default.writeFileSync(tmpPath, (0, codex_agent_toml_cjs_1.renderCodexAgentToml)(doc));
1116
+ originalMode = node_fs_1.default.statSync(filePath).mode & 0o7777;
1117
+ }
1118
+ catch { /* non-fatal: fall back to writing without an explicit mode */ }
1119
+ try {
1120
+ node_fs_1.default.writeFileSync(tmpPath, (0, codex_agent_toml_cjs_1.renderCodexAgentToml)(doc), originalMode !== undefined ? { mode: originalMode } : undefined);
1121
+ // Not redundant with the `mode` option above: `mode` only applies
1122
+ // when the file is actually created (O_CREAT). A leftover tmp file
1123
+ // from an earlier crashed run would be reused (truncated) at its OLD
1124
+ // mode instead, and this chmod is what corrects that case. Mask off
1125
+ // the file-type bits fs.statSync().mode carries (POSIX leaves
1126
+ // chmod's handling of those unspecified); best-effort only, since
1127
+ // the content write is what matters, not the mode.
1128
+ try {
1129
+ if (originalMode !== undefined)
1130
+ node_fs_1.default.chmodSync(tmpPath, originalMode);
1131
+ }
1132
+ catch { /* non-fatal: proceed with default tmp-file mode */ }
874
1133
  (0, shell_command_projection_cjs_1.retryRenameSync)(tmpPath, filePath);
875
1134
  }
876
1135
  catch (err) {
@@ -888,7 +1147,177 @@ function cmdEffortSyncCodex(raw, dryRun, configDir) {
888
1147
  changes.push(...pendingChanges);
889
1148
  synced++;
890
1149
  }
891
- output({ synced, skipped, changes, dry_run: dryRun, agents_dir: agentsDir, refused, write_failures: writeFailures }, raw, synced > 0 ? 'changed' : 'ok');
1150
+ 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');
1151
+ }
1152
+ /**
1153
+ * #3706 — the OpenCode branch of `cmdEffortSync`. Maintains the `variant:`
1154
+ * frontmatter key install now bakes into every `~/.config/opencode/agents/
1155
+ * gsd-*.md` (or configDir-relative equivalent), mirroring exactly what
1156
+ * install writes: a resolved universal effort clamped through
1157
+ * `clampEffortForHost('opencode', ...)`. Null means the key must be ABSENT —
1158
+ * #3533 (10d): an absent key is the correct state under `inherit`, and a
1159
+ * level OpenCode does not accept must never be written, so both collapse to
1160
+ * the same `target: null` and the same removal path. Result shape is
1161
+ * additive over the claude branch, matching the CODEX branch's
1162
+ * `{synced, skipped, changes, dry_run, agents_dir, write_failures}`.
1163
+ */
1164
+ function cmdEffortSyncOpencode(cwd, raw, dryRun, configDir) {
1165
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
1166
+ const { getGlobalConfigDir } = require('./runtime-homes.cjs');
1167
+ const agentsDir = node_path_1.default.join(configDir || getGlobalConfigDir('opencode'), 'agents');
1168
+ if (!node_fs_1.default.existsSync(agentsDir)) {
1169
+ output({ synced: 0, skipped: 0, changes: [], dry_run: dryRun, agents_dir: agentsDir, reason: 'agents directory not found' }, raw, '');
1170
+ return;
1171
+ }
1172
+ // Skip symlinks — matches the claude branch's existing guard (only write
1173
+ // regular files, never follow a symlink into clobbering its target).
1174
+ const files = node_fs_1.default
1175
+ .readdirSync(agentsDir)
1176
+ .filter(f => {
1177
+ if (!f.startsWith('gsd-') || !f.endsWith('.md'))
1178
+ return false;
1179
+ try {
1180
+ return node_fs_1.default.lstatSync(node_path_1.default.join(agentsDir, f)).isFile();
1181
+ }
1182
+ catch {
1183
+ return false;
1184
+ }
1185
+ })
1186
+ .sort();
1187
+ // Use install-time resolvers: they merge ~/.gsd/defaults.json with project
1188
+ // config, matching the exact logic used when agents were originally
1189
+ // installed. Resolved once, outside the loop, like the claude branch.
1190
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
1191
+ const { readGsdEffectiveEffortConfig, resolveInstallTimeEffort } = require('./install-effort-resolver.cjs');
1192
+ const effortCfg = readGsdEffectiveEffortConfig(cwd);
1193
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
1194
+ const { clampEffortForHost } = require('./model-catalog.cjs');
1195
+ const changes = [];
1196
+ const writeFailures = [];
1197
+ const readFailures = [];
1198
+ let synced = 0;
1199
+ let skipped = 0;
1200
+ for (const file of files) {
1201
+ const agentName = file.replace(/\.md$/, '');
1202
+ const filePath = node_path_1.default.join(agentsDir, file);
1203
+ let content;
1204
+ try {
1205
+ content = node_fs_1.default.readFileSync(filePath, 'utf8');
1206
+ }
1207
+ catch (err) {
1208
+ // An unreadable agent file must not abort the whole sweep — degrade
1209
+ // like the write path below does, and report it under its own
1210
+ // `read_failures` key so a caller can tell "never read" apart from
1211
+ // "read but write failed".
1212
+ skipped++;
1213
+ readFailures.push({ agent: agentName, file: filePath, error: err instanceof Error ? err.message : String(err) });
1214
+ continue;
1215
+ }
1216
+ // `target === null` covers both "no effort configured" (inherit) and "a
1217
+ // level OpenCode does not accept" — both must produce NO `variant:` key,
1218
+ // exactly what install writes.
1219
+ const universal = effortCfg ? resolveInstallTimeEffort(effortCfg, agentName) : null;
1220
+ const target = universal ? clampEffortForHost('opencode', universal) : null;
1221
+ const fmMatch = /^---\r?\n([\s\S]*?)^---\r?$/m.exec(content);
1222
+ if (!fmMatch) {
1223
+ skipped++;
1224
+ continue;
1225
+ }
1226
+ // Presence and value are distinct questions: `variant:` with an EMPTY
1227
+ // value is a key that IS present but whose captured value is null (the
1228
+ // `(.+?)` group requires at least one char). Deciding "already correct"
1229
+ // from a null-vs-null comparison alone is wrong when target is also
1230
+ // null — it would leave an unresolvable `variant: null` key on disk
1231
+ // forever. Test presence with its own regex, and only compare values
1232
+ // once presence is known.
1233
+ const variantPresent = /^variant:/m.test(fmMatch[1]);
1234
+ const variantMatch = /^variant:[ \t]*(.+?)[ \t]*$/m.exec(fmMatch[1]);
1235
+ // `null` (key absent) and `''` (key present, value empty) are distinct
1236
+ // states this code deliberately tracks via `variantPresent` above — a
1237
+ // reported `from` that collapses both to `null` would make "no key" and
1238
+ // "empty key" indistinguishable in the sync output, even though only one
1239
+ // of them actually has a `variant:` line to remove.
1240
+ const currentVariant = variantPresent ? (variantMatch ? variantMatch[1] : '') : null;
1241
+ if (target === null) {
1242
+ if (!variantPresent) {
1243
+ skipped++;
1244
+ continue;
1245
+ }
1246
+ }
1247
+ else if (variantPresent && currentVariant === target) {
1248
+ skipped++;
1249
+ continue;
1250
+ }
1251
+ changes.push({ agent: agentName, from: currentVariant, to: target });
1252
+ synced++;
1253
+ if (!dryRun) {
1254
+ // Atomic publish AND mode preservation, same discipline as
1255
+ // cmdEffortSyncCodex above: write to a sibling tmp file, chmod it to
1256
+ // match filePath's existing (masked) mode, then retryRenameSync it over
1257
+ // the target so filePath is either the old bytes or the new ones, never
1258
+ // half-written and never dropped to a default mode. On failure the
1259
+ // write is reported, not thrown, so the remaining agents still get
1260
+ // processed.
1261
+ const tmpPath = `${filePath}.tmp.${process.pid}`;
1262
+ // Stat filePath BEFORE the write so its mode can be passed at CREATION
1263
+ // time — a plain `writeFileSync(tmpPath, data)` creates the tmp file at
1264
+ // the default `0666 & ~umask` (world-readable under a typical 022
1265
+ // umask) even when filePath is e.g. 0600, exposing its contents for
1266
+ // the window between creation and the chmod below. Mask off the
1267
+ // file-type bits (e.g. S_IFREG 0o100000) that fs.statSync().mode
1268
+ // carries alongside the permission bits — POSIX leaves chmod's
1269
+ // handling of those bits unspecified, and the remote matrix runs Linux
1270
+ // only (Darwin tolerating the full mode is not evidence it is safe
1271
+ // there). Best-effort only: a stat failure must not abort the sync,
1272
+ // since the content write is what matters, not the mode.
1273
+ let originalMode;
1274
+ try {
1275
+ originalMode = node_fs_1.default.statSync(filePath).mode & 0o7777;
1276
+ }
1277
+ catch { /* non-fatal: fall back to writing without an explicit mode */ }
1278
+ try {
1279
+ node_fs_1.default.writeFileSync(tmpPath, target === null ? removeFrontmatterKeyLine(content, 'variant') : setFrontmatterKeyLine(content, 'variant', target), originalMode !== undefined ? { mode: originalMode } : undefined);
1280
+ // Not redundant with the `mode` option above: `mode` only applies
1281
+ // when the file is actually created (O_CREAT). A leftover tmp file
1282
+ // from an earlier crashed run would be reused (truncated) at its OLD
1283
+ // mode instead, and this chmod is what corrects that case.
1284
+ // Best-effort only: a chmod failure must not abort the sync, since
1285
+ // the content write is what matters, not the mode.
1286
+ try {
1287
+ if (originalMode !== undefined)
1288
+ node_fs_1.default.chmodSync(tmpPath, originalMode);
1289
+ }
1290
+ catch { /* non-fatal: proceed with default tmp-file mode */ }
1291
+ (0, shell_command_projection_cjs_1.retryRenameSync)(tmpPath, filePath);
1292
+ }
1293
+ catch (err) {
1294
+ try {
1295
+ node_fs_1.default.unlinkSync(tmpPath);
1296
+ }
1297
+ catch { /* already gone or never created */ }
1298
+ changes.pop();
1299
+ synced--;
1300
+ skipped++;
1301
+ writeFailures.push({ agent: agentName, file: filePath, error: err instanceof Error ? err.message : String(err) });
1302
+ continue;
1303
+ }
1304
+ }
1305
+ }
1306
+ // Any failure — a write OR a read — must not report 'ok' or 'changed':
1307
+ // either would hide that at least one agent's on-disk state is now unknown
1308
+ // (unread) or unchanged despite being reported as a pending change (write
1309
+ // failed after being pushed onto `changes`/`synced`). `write_failures` and
1310
+ // `read_failures` take priority over the synced-count-derived summary below,
1311
+ // even when other agents in the same run succeeded.
1312
+ //
1313
+ // Known limitation, deliberately not fixed here: `output()` only honors its
1314
+ // third argument when `raw === true`, and this command's process always
1315
+ // exits 0 regardless of the summary string — so `if gsd-tools effort sync;
1316
+ // then` reads success in a shell even on a run where every write failed.
1317
+ // Making the exit code reflect failure would be a CLI-contract change
1318
+ // affecting all three cmdEffortSync* branches (claude, codex, opencode) and
1319
+ // is out of scope for this fix.
1320
+ 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
1321
  }
893
1322
  /**
894
1323
  * Detect the phase number for a commit from its `--files` path list.
@@ -1059,7 +1488,10 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
1059
1488
  // — see #2528 for the parallel drift problem in phase-locator/phase),
1060
1489
  // so this is the canonical path-segment-bound read, not a fourth copy.
1061
1490
  const phaseNum = detectPhaseNumberFromFiles(files);
1062
- if (phaseNum) {
1491
+ // #3734: a 999.x/0.x backlog sentinel is a parking-lot entry, not a real
1492
+ // phase — the phase arm must never branch-mutate for it (isSentinelPhaseId
1493
+ // is the invariant's single owner, src/phase-id.cts).
1494
+ if (phaseNum && !isSentinelPhaseId(phaseNum)) {
1063
1495
  const phaseInfo = findPhaseInternal(cwd, phaseNum);
1064
1496
  if (phaseInfo) {
1065
1497
  branchName = config['phase_branch_template']
@@ -1239,8 +1671,26 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
1239
1671
  if (canScope) {
1240
1672
  commitArgs.push('--', ...stagedPaths);
1241
1673
  }
1242
- const commitResult = (0, shell_command_projection_cjs_1.execGit)(commitArgs, { cwd });
1674
+ // #3886: `git commit` runs pre-commit hooks (husky/lint-staged routinely
1675
+ // idles ~4s on Windows before any task) — 10s is too tight, and a timeout
1676
+ // kill is NOT an ordinary failure. Same band as the push call below.
1677
+ const commitResult = (0, shell_command_projection_cjs_1.execGit)(commitArgs, { cwd, timeout: COMMIT_TIMEOUT_MS });
1243
1678
  if (commitResult.exitCode !== 0) {
1679
+ // #3886: a SIGTERM'd git commit is a timeout, not commit_failed — the
1680
+ // partial stderr it flushed (often incidental CRLF warnings) is noise,
1681
+ // and the kill can leave a stale index.lock that blocks the next
1682
+ // attempt. Report the distinct reason and surface the lock path.
1683
+ if ((0, shell_command_projection_cjs_1.isSpawnTimeout)(commitResult)) {
1684
+ const result = {
1685
+ committed: false,
1686
+ hash: null,
1687
+ reason: 'commit_timeout',
1688
+ timed_out: true,
1689
+ error: commitTimeoutMessage(cwd, commitResult.stderr, commitResult.stdout),
1690
+ };
1691
+ output(result, raw, 'failed');
1692
+ return;
1693
+ }
1244
1694
  if (commitResult.stdout.includes('nothing to commit') || commitResult.stderr.includes('nothing to commit')) {
1245
1695
  const result = { committed: false, hash: null, reason: 'nothing_to_commit' };
1246
1696
  output(result, raw, 'nothing');
@@ -1380,8 +1830,21 @@ function cmdCommitToSubrepo(cwd, message, files, raw) {
1380
1830
  const commitArgs = canScopeSub
1381
1831
  ? ['commit', '-m', message, '--', ...stagedRelPaths]
1382
1832
  : ['commit', '-m', message];
1383
- const commitResult = (0, shell_command_projection_cjs_1.execGit)(commitArgs, { cwd: repoCwd });
1833
+ const commitResult = (0, shell_command_projection_cjs_1.execGit)(commitArgs, { cwd: repoCwd, timeout: COMMIT_TIMEOUT_MS });
1384
1834
  if (commitResult.exitCode !== 0) {
1835
+ if ((0, shell_command_projection_cjs_1.isSpawnTimeout)(commitResult)) {
1836
+ // #3886 (subrepo counterpart): timeout ≠ error; surface the stale-lock
1837
+ // path a killed commit can leave in the subrepo.
1838
+ repos[repo] = {
1839
+ committed: false,
1840
+ hash: null,
1841
+ files: repoFiles,
1842
+ reason: 'commit_timeout',
1843
+ timed_out: true,
1844
+ error: commitTimeoutMessage(repoCwd, commitResult.stderr, commitResult.stdout),
1845
+ };
1846
+ continue;
1847
+ }
1385
1848
  if (commitResult.stdout.includes('nothing to commit') || commitResult.stderr.includes('nothing to commit')) {
1386
1849
  repos[repo] = { committed: false, hash: null, files: repoFiles, reason: 'nothing_to_commit' };
1387
1850
  continue;
@@ -1511,9 +1974,15 @@ function cmdPrSubrepo(cwd, repo, branch, commitMessage, raw) {
1511
1974
  const commitArgs = canScopePr
1512
1975
  ? ['commit', '-m', commitMessage, '--', ...changedFiles]
1513
1976
  : ['commit', '-m', commitMessage];
1514
- const commitResult = (0, shell_command_projection_cjs_1.execGit)(commitArgs, { cwd: repoCwd });
1977
+ const commitResult = (0, shell_command_projection_cjs_1.execGit)(commitArgs, { cwd: repoCwd, timeout: COMMIT_TIMEOUT_MS });
1515
1978
  if (commitResult.exitCode !== 0) {
1516
1979
  rollback();
1980
+ if ((0, shell_command_projection_cjs_1.isSpawnTimeout)(commitResult)) {
1981
+ // #3886 (PR-subrepo counterpart): name the timeout and the stale lock
1982
+ // instead of echoing the killed hook's partial stderr.
1983
+ error(`git commit timed out after ${COMMIT_TIMEOUT_MS / 1000}s in ${repo} (killed mid-hook; ` +
1984
+ `a stale lock may remain at ${resolveIndexLockPath(repoCwd)} — remove it if no git process is running)`);
1985
+ }
1517
1986
  error(`Failed to commit in ${repo}: ${commitResult.stderr}`);
1518
1987
  }
1519
1988
  // 6. Capture commit hash
@@ -2275,6 +2744,36 @@ function buildCommitDocsGuardHookScript() {
2275
2744
  ];
2276
2745
  return lines.join('\n') + '\n';
2277
2746
  }
2747
+ /**
2748
+ * #3886: the timeout band for `git commit` — pre-commit hooks (husky +
2749
+ * lint-staged idles ~4s on Windows before any task) routinely exceed the 10s
2750
+ * plumbing default; 30s is the same band the push call uses. Shared by all
2751
+ * three commit sites AND their timeout messages, so the number and the text
2752
+ * cannot drift apart.
2753
+ */
2754
+ const COMMIT_TIMEOUT_MS = 30_000;
2755
+ /**
2756
+ * #3886: resolve where a killed `git commit` would leave its stale
2757
+ * index.lock — via `git rev-parse --git-path index.lock`, never a literal
2758
+ * `.git/index.lock` join (#3588 row 8's class: a linked worktree's `.git` is
2759
+ * a FILE pointing at `<gitdir>/worktrees/<name>/`, so the literal path
2760
+ * cannot exist there while the real lock blocks the next commit). Best
2761
+ * effort: any resolution failure falls back to the literal join, and the
2762
+ * message already hedges with "may remain".
2763
+ */
2764
+ function resolveIndexLockPath(cwd) {
2765
+ const result = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '--git-path', 'index.lock'], { cwd });
2766
+ if (result.exitCode !== 0)
2767
+ return node_path_1.default.join(cwd, '.git', 'index.lock');
2768
+ const raw = result.stdout.trim();
2769
+ 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');
2770
+ }
2771
+ /** #3886: shared timeout message shape for all three commit sites. */
2772
+ function commitTimeoutMessage(cwd, stderr, stdout) {
2773
+ return (`git commit timed out after ${COMMIT_TIMEOUT_MS / 1000}s (killed mid-hook; a stale lock may remain at ` +
2774
+ `${resolveIndexLockPath(cwd)} — remove it if no git process is running). ` +
2775
+ `Partial stderr: ${stderr || stdout || '(none)'}`);
2776
+ }
2278
2777
  /**
2279
2778
  * Resolve the real git hooks directory for `cwd` via `git rev-parse
2280
2779
  * --git-path hooks` — never a literal `.git/hooks` join (#3588 row 8: a