@opengsd/gsd-core 1.18.0 → 1.20.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 (308) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +2 -1
  3. package/.opencode/plugins/gsd-core.js +2 -3
  4. package/agents/gsd-code-fixer.compact.md +7 -7
  5. package/agents/gsd-code-fixer.md +13 -7
  6. package/agents/gsd-code-reviewer.compact.md +49 -8
  7. package/agents/gsd-code-reviewer.md +53 -14
  8. package/agents/gsd-executor.md +8 -4
  9. package/agents/gsd-verifier.md +1 -1
  10. package/bin/install.js +422 -103
  11. package/commands/gsd/update.md +5 -2
  12. package/gsd-core/bin/gsd-tools.cjs +179 -30
  13. package/gsd-core/bin/lib/agent-install-check.cjs +29 -3
  14. package/gsd-core/bin/lib/args-terminator.cjs +114 -0
  15. package/gsd-core/bin/lib/broken-windows.cjs +59 -1
  16. package/gsd-core/bin/lib/capability-lifecycle.cjs +1 -12
  17. package/gsd-core/bin/lib/capability-lock.cjs +13 -2
  18. package/gsd-core/bin/lib/capability-registry.cjs +376 -103
  19. package/gsd-core/bin/lib/capability-state.cjs +14 -8
  20. package/gsd-core/bin/lib/capability-validator.cjs +32 -0
  21. package/gsd-core/bin/lib/commands.cjs +27 -17
  22. package/gsd-core/bin/lib/config-loader.cjs +13 -3
  23. package/gsd-core/bin/lib/config.cjs +256 -58
  24. package/gsd-core/bin/lib/core-utils.cjs +45 -0
  25. package/gsd-core/bin/lib/decision-coverage-support.cjs +74 -0
  26. package/gsd-core/bin/lib/frontmatter.cjs +21 -3
  27. package/gsd-core/bin/lib/gate-api-coverage-verify-pre.cjs +2 -2
  28. package/gsd-core/bin/lib/gate-context-drift.cjs +9 -4
  29. package/gsd-core/bin/lib/gate-decision-coverage-plan.cjs +6 -1
  30. package/gsd-core/bin/lib/gate-decision-coverage-verify.cjs +6 -1
  31. package/gsd-core/bin/lib/gate-evaluation-scope.cjs +134 -13
  32. package/gsd-core/bin/lib/gate-phase-context.cjs +7 -8
  33. package/gsd-core/bin/lib/gate-schema-drift.cjs +1 -1
  34. package/gsd-core/bin/lib/gate-tdd-review-checkpoint.cjs +63 -22
  35. package/gsd-core/bin/lib/gate-ui-safety.cjs +23 -5
  36. package/gsd-core/bin/lib/git-info-exclude.cjs +52 -0
  37. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +31 -12
  38. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +9 -7
  39. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +3 -3
  40. package/gsd-core/bin/lib/health-diagnostic-types.cjs +4 -0
  41. package/gsd-core/bin/lib/health-diagnostic.cjs +52 -1
  42. package/gsd-core/bin/lib/host-integration.cjs +13 -6
  43. package/gsd-core/bin/lib/init.cjs +245 -68
  44. package/gsd-core/bin/lib/install-engine.cjs +246 -32
  45. package/gsd-core/bin/lib/install-profiles.cjs +9 -4
  46. package/gsd-core/bin/lib/installer-migration-report.cjs +2 -0
  47. package/gsd-core/bin/lib/io.cjs +6 -0
  48. package/gsd-core/bin/lib/markdown-line-diff.cjs +147 -0
  49. package/gsd-core/bin/lib/markdown-sectionizer.cjs +147 -1
  50. package/gsd-core/bin/lib/markdown-table.cjs +23 -0
  51. package/gsd-core/bin/lib/mcp-server.cjs +8 -5
  52. package/gsd-core/bin/lib/milestone-lock.cjs +8 -1
  53. package/gsd-core/bin/lib/milestone-release-map.cjs +244 -0
  54. package/gsd-core/bin/lib/milestone-release-tags.cjs +440 -0
  55. package/gsd-core/bin/lib/milestone.cjs +61 -51
  56. package/gsd-core/bin/lib/model-catalog.cjs +8 -0
  57. package/gsd-core/bin/lib/normalize-test-command.cjs +67 -0
  58. package/gsd-core/bin/lib/phase-entry-grammar.cjs +119 -0
  59. package/gsd-core/bin/lib/phase-id-display.cjs +2 -0
  60. package/gsd-core/bin/lib/phase-id.cjs +629 -38
  61. package/gsd-core/bin/lib/phase-lifecycle.cjs +12 -44
  62. package/gsd-core/bin/lib/phase-locator.cjs +328 -19
  63. package/gsd-core/bin/lib/phase.cjs +3066 -653
  64. package/gsd-core/bin/lib/planning-document.cjs +26 -5
  65. package/gsd-core/bin/lib/planning-inspect.cjs +27 -35
  66. package/gsd-core/bin/lib/planning-snapshot.cjs +15 -8
  67. package/gsd-core/bin/lib/planning-workspace.cjs +21 -3
  68. package/gsd-core/bin/lib/pristine-baseline.cjs +11 -3
  69. package/gsd-core/bin/lib/read-only-git-env.cjs +79 -0
  70. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +2 -2
  71. package/gsd-core/bin/lib/review-lane-descriptor.cjs +57 -2
  72. package/gsd-core/bin/lib/review-lane-invocation.cjs +125 -18
  73. package/gsd-core/bin/lib/review-lane-runner.cjs +33 -4
  74. package/gsd-core/bin/lib/reviewer-step-dispatch.cjs +11 -9
  75. package/gsd-core/bin/lib/roadmap-parser.cjs +735 -137
  76. package/gsd-core/bin/lib/roadmap-progress.cjs +136 -0
  77. package/gsd-core/bin/lib/roadmap-upgrade.cjs +47 -7
  78. package/gsd-core/bin/lib/roadmap.cjs +383 -123
  79. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +381 -14
  80. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +8 -4
  81. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +60 -16
  82. package/gsd-core/bin/lib/shell-command-projection.cjs +151 -19
  83. package/gsd-core/bin/lib/smart-entry.cjs +35 -11
  84. package/gsd-core/bin/lib/state-contract.cjs +39 -15
  85. package/gsd-core/bin/lib/state-document.cjs +344 -27
  86. package/gsd-core/bin/lib/state-transition.cjs +156 -32
  87. package/gsd-core/bin/lib/state.cjs +527 -186
  88. package/gsd-core/bin/lib/surface.cjs +9 -0
  89. package/gsd-core/bin/lib/task-command-router.cjs +5 -25
  90. package/gsd-core/bin/lib/task-content-resolution.cjs +46 -1
  91. package/gsd-core/bin/lib/template.cjs +2 -2
  92. package/gsd-core/bin/lib/validate.cjs +3 -2
  93. package/gsd-core/bin/lib/vendor/re2js.cjs +863 -577
  94. package/gsd-core/bin/lib/vendor/re2js.d.cts +284 -219
  95. package/gsd-core/bin/lib/verification.cjs +96 -9
  96. package/gsd-core/bin/lib/verify.cjs +4 -2
  97. package/gsd-core/bin/lib/workstream-inventory.cjs +26 -20
  98. package/gsd-core/bin/lib/worktree-safety.cjs +22 -1
  99. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  100. package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
  101. package/gsd-core/bin/verify-reapply-patches.cjs +411 -10
  102. package/gsd-core/references/cicd-review-patterns.md +45 -0
  103. package/gsd-core/references/git-integration.md +11 -0
  104. package/gsd-core/references/loop-hook-dispatch.md +13 -6
  105. package/gsd-core/references/necessity-review.md +48 -0
  106. package/gsd-core/references/planning-config.md +2 -0
  107. package/gsd-core/references/tdd.md +2 -2
  108. package/gsd-core/templates/DEBUG.md +16 -0
  109. package/gsd-core/templates/config.json +2 -1
  110. package/gsd-core/templates/verification-report.md +1 -1
  111. package/gsd-core/workflows/audit-fix.md +2 -1
  112. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +10 -1
  113. package/gsd-core/workflows/code-review-fix.md +46 -5
  114. package/gsd-core/workflows/code-review.md +64 -7
  115. package/gsd-core/workflows/complete-milestone/steps/project-md-evolution-review.md +145 -0
  116. package/gsd-core/workflows/complete-milestone/steps/release-map.md +80 -0
  117. package/gsd-core/workflows/complete-milestone.md +37 -150
  118. package/gsd-core/workflows/diagnose-issues.md +29 -5
  119. package/gsd-core/workflows/discuss-phase/modes/power.md +1 -1
  120. package/gsd-core/workflows/discuss-phase-assumptions.md +33 -2
  121. package/gsd-core/workflows/discuss-phase-power.md +19 -2
  122. package/gsd-core/workflows/discuss-phase.md +6 -10
  123. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +13 -7
  124. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +7 -2
  125. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -1
  126. package/gsd-core/workflows/execute-phase/steps/verify-phase-goal.md +2 -2
  127. package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +11 -2
  128. package/gsd-core/workflows/execute-phase.md +10 -6
  129. package/gsd-core/workflows/execute-plan.md +1 -1
  130. package/gsd-core/workflows/health.md +2 -1
  131. package/gsd-core/workflows/import.md +1 -1
  132. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +2 -2
  133. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +13 -9
  134. package/gsd-core/workflows/plan-phase.md +14 -14
  135. package/gsd-core/workflows/pr-branch.md +2 -0
  136. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +1 -1
  137. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +8 -1
  138. package/gsd-core/workflows/quick-batch/steps/plan-checker-loop.md +2 -1
  139. package/gsd-core/workflows/quick.md +1 -1
  140. package/gsd-core/workflows/reapply-patches.md +104 -0
  141. package/gsd-core/workflows/review.md +70 -154
  142. package/gsd-core/workflows/section-manifest.json +5 -0
  143. package/gsd-core/workflows/secure-phase.md +1 -1
  144. package/gsd-core/workflows/settings-advanced.md +57 -9
  145. package/gsd-core/workflows/settings.md +1 -0
  146. package/gsd-core/workflows/ship.md +15 -4
  147. package/gsd-core/workflows/spec-phase.md +3 -6
  148. package/gsd-core/workflows/verify-work.md +4 -3
  149. package/hooks/dist/gsd-agent-isolation-guard.js +11 -1
  150. package/hooks/dist/gsd-cursor-subagent-start.js +6 -2
  151. package/hooks/dist/gsd-graphify-update.sh +6 -0
  152. package/hooks/dist/gsd-path-export.js +95 -0
  153. package/hooks/dist/gsd-secret-read-guard.js +172 -39
  154. package/hooks/dist/gsd-statusline.js +37 -3
  155. package/hooks/dist/gsd-windsurf-pre-write.js +29 -2
  156. package/hooks/dist/gsd-workflow-guard.js +3 -2
  157. package/hooks/dist/gsd-worktree-path-guard.js +29 -2
  158. package/hooks/dist/lib/git-probe.js +16 -0
  159. package/hooks/dist/lib/isolation-sentinel.js +44 -0
  160. package/hooks/dist/managed-hooks-registry.cjs +1 -0
  161. package/hooks/gsd-agent-isolation-guard.js +11 -1
  162. package/hooks/gsd-cursor-subagent-start.js +6 -2
  163. package/hooks/gsd-graphify-update.sh +6 -0
  164. package/hooks/gsd-path-export.js +95 -0
  165. package/hooks/gsd-secret-read-guard.js +172 -39
  166. package/hooks/gsd-statusline.js +37 -3
  167. package/hooks/gsd-windsurf-pre-write.js +29 -2
  168. package/hooks/gsd-workflow-guard.js +3 -2
  169. package/hooks/gsd-worktree-path-guard.js +29 -2
  170. package/hooks/hooks.json +1 -0
  171. package/hooks/lib/git-probe.js +16 -0
  172. package/hooks/lib/isolation-sentinel.js +44 -0
  173. package/hooks/managed-hooks-registry.cjs +1 -0
  174. package/package.json +15 -8
  175. package/pi/gsd.cjs +1047 -10
  176. package/pi/package-agents/gsd-advisor-researcher.md +113 -0
  177. package/pi/package-agents/gsd-ai-researcher.md +111 -0
  178. package/pi/package-agents/gsd-assumptions-analyzer.md +110 -0
  179. package/pi/package-agents/gsd-code-fixer.md +774 -0
  180. package/pi/package-agents/gsd-code-reviewer.md +441 -0
  181. package/pi/package-agents/gsd-codebase-mapper.md +850 -0
  182. package/pi/package-agents/gsd-debug-session-manager.md +416 -0
  183. package/pi/package-agents/gsd-debugger.md +1274 -0
  184. package/pi/package-agents/gsd-doc-classifier.md +269 -0
  185. package/pi/package-agents/gsd-doc-synthesizer.md +259 -0
  186. package/pi/package-agents/gsd-doc-verifier.md +212 -0
  187. package/pi/package-agents/gsd-doc-writer.md +626 -0
  188. package/pi/package-agents/gsd-dom-verifier.md +164 -0
  189. package/pi/package-agents/gsd-domain-researcher.md +144 -0
  190. package/pi/package-agents/gsd-eval-auditor.md +185 -0
  191. package/pi/package-agents/gsd-eval-planner.md +149 -0
  192. package/pi/package-agents/gsd-executor.md +898 -0
  193. package/pi/package-agents/gsd-framework-selector.md +159 -0
  194. package/pi/package-agents/gsd-integration-checker.md +476 -0
  195. package/pi/package-agents/gsd-intel-updater.md +338 -0
  196. package/pi/package-agents/gsd-mempalace-curator.md +49 -0
  197. package/pi/package-agents/gsd-nyquist-auditor.md +200 -0
  198. package/pi/package-agents/gsd-pattern-mapper.md +340 -0
  199. package/pi/package-agents/gsd-phase-researcher.md +897 -0
  200. package/pi/package-agents/gsd-plan-checker.md +1077 -0
  201. package/pi/package-agents/gsd-planner.md +1003 -0
  202. package/pi/package-agents/gsd-project-researcher.md +611 -0
  203. package/pi/package-agents/gsd-research-synthesizer.md +259 -0
  204. package/pi/package-agents/gsd-roadmapper.md +779 -0
  205. package/pi/package-agents/gsd-security-auditor.md +171 -0
  206. package/pi/package-agents/gsd-ui-auditor.md +581 -0
  207. package/pi/package-agents/gsd-ui-checker.md +417 -0
  208. package/pi/package-agents/gsd-ui-researcher.md +469 -0
  209. package/pi/package-agents/gsd-user-profiler.md +174 -0
  210. package/pi/package-agents/gsd-verifier.md +963 -0
  211. package/pi/package-agents/manifest.json +39 -0
  212. package/pi/package-skills/gsd-add-tests/SKILL.md +39 -0
  213. package/pi/package-skills/gsd-ai-integration-phase/SKILL.md +36 -0
  214. package/pi/package-skills/gsd-audit-fix/SKILL.md +34 -0
  215. package/pi/package-skills/gsd-audit-milestone/SKILL.md +40 -0
  216. package/pi/package-skills/gsd-audit-uat/SKILL.md +26 -0
  217. package/pi/package-skills/gsd-autonomous/SKILL.md +52 -0
  218. package/pi/package-skills/gsd-capture/SKILL.md +68 -0
  219. package/pi/package-skills/gsd-cleanup/SKILL.md +25 -0
  220. package/pi/package-skills/gsd-code-review/SKILL.md +71 -0
  221. package/pi/package-skills/gsd-complete-milestone/SKILL.md +147 -0
  222. package/pi/package-skills/gsd-config/SKILL.md +60 -0
  223. package/pi/package-skills/gsd-debug/SKILL.md +64 -0
  224. package/pi/package-skills/gsd-discuss-phase/SKILL.md +74 -0
  225. package/pi/package-skills/gsd-docs-update/SKILL.md +50 -0
  226. package/pi/package-skills/gsd-eval-review/SKILL.md +35 -0
  227. package/pi/package-skills/gsd-execute-phase/SKILL.md +62 -0
  228. package/pi/package-skills/gsd-explore/SKILL.md +26 -0
  229. package/pi/package-skills/gsd-extract-learnings/SKILL.md +25 -0
  230. package/pi/package-skills/gsd-fast/SKILL.md +33 -0
  231. package/pi/package-skills/gsd-forensics/SKILL.md +59 -0
  232. package/pi/package-skills/gsd-graphify/SKILL.md +210 -0
  233. package/pi/package-skills/gsd-health/SKILL.md +36 -0
  234. package/pi/package-skills/gsd-help/SKILL.md +37 -0
  235. package/pi/package-skills/gsd-import/SKILL.md +46 -0
  236. package/pi/package-skills/gsd-inbox/SKILL.md +42 -0
  237. package/pi/package-skills/gsd-ingest-docs/SKILL.md +43 -0
  238. package/pi/package-skills/gsd-manager/SKILL.md +46 -0
  239. package/pi/package-skills/gsd-map-codebase/SKILL.md +85 -0
  240. package/pi/package-skills/gsd-mempalace-capture/SKILL.md +111 -0
  241. package/pi/package-skills/gsd-mempalace-recall/SKILL.md +111 -0
  242. package/pi/package-skills/gsd-milestone-summary/SKILL.md +54 -0
  243. package/pi/package-skills/gsd-mvp-phase/SKILL.md +45 -0
  244. package/pi/package-skills/gsd-new-milestone/SKILL.md +49 -0
  245. package/pi/package-skills/gsd-new-project/SKILL.md +49 -0
  246. package/pi/package-skills/gsd-next/SKILL.md +34 -0
  247. package/pi/package-skills/gsd-ns-context/SKILL.md +31 -0
  248. package/pi/package-skills/gsd-ns-ideate/SKILL.md +30 -0
  249. package/pi/package-skills/gsd-ns-manage/SKILL.md +42 -0
  250. package/pi/package-skills/gsd-ns-project/SKILL.md +34 -0
  251. package/pi/package-skills/gsd-ns-review/SKILL.md +35 -0
  252. package/pi/package-skills/gsd-ns-workflow/SKILL.md +44 -0
  253. package/pi/package-skills/gsd-onboard/SKILL.md +46 -0
  254. package/pi/package-skills/gsd-pause-work/SKILL.md +48 -0
  255. package/pi/package-skills/gsd-phase/SKILL.md +61 -0
  256. package/pi/package-skills/gsd-plan-phase/SKILL.md +62 -0
  257. package/pi/package-skills/gsd-plan-review-convergence/SKILL.md +63 -0
  258. package/pi/package-skills/gsd-pr-branch/SKILL.md +31 -0
  259. package/pi/package-skills/gsd-profile-user/SKILL.md +48 -0
  260. package/pi/package-skills/gsd-progress/SKILL.md +51 -0
  261. package/pi/package-skills/gsd-quick/SKILL.md +179 -0
  262. package/pi/package-skills/gsd-quick-batch/SKILL.md +114 -0
  263. package/pi/package-skills/gsd-resume-work/SKILL.md +31 -0
  264. package/pi/package-skills/gsd-review/SKILL.md +50 -0
  265. package/pi/package-skills/gsd-review-backlog/SKILL.md +65 -0
  266. package/pi/package-skills/gsd-secure-phase/SKILL.md +37 -0
  267. package/pi/package-skills/gsd-settings/SKILL.md +30 -0
  268. package/pi/package-skills/gsd-ship/SKILL.md +27 -0
  269. package/pi/package-skills/gsd-sketch/SKILL.md +56 -0
  270. package/pi/package-skills/gsd-spec-phase/SKILL.md +63 -0
  271. package/pi/package-skills/gsd-spike/SKILL.md +53 -0
  272. package/pi/package-skills/gsd-stats/SKILL.md +22 -0
  273. package/pi/package-skills/gsd-surface/SKILL.md +177 -0
  274. package/pi/package-skills/gsd-thread/SKILL.md +29 -0
  275. package/pi/package-skills/gsd-ui-phase/SKILL.md +35 -0
  276. package/pi/package-skills/gsd-ui-review/SKILL.md +35 -0
  277. package/pi/package-skills/gsd-ultraplan-phase/SKILL.md +38 -0
  278. package/pi/package-skills/gsd-undo/SKILL.md +38 -0
  279. package/pi/package-skills/gsd-update/SKILL.md +54 -0
  280. package/pi/package-skills/gsd-validate-phase/SKILL.md +37 -0
  281. package/pi/package-skills/gsd-verify-work/SKILL.md +41 -0
  282. package/pi/package-skills/gsd-workspace/SKILL.md +57 -0
  283. package/pi/package-skills/gsd-workstreams/SKILL.md +73 -0
  284. package/scripts/build-hooks.js +4 -0
  285. package/scripts/ci-next-health.cjs +246 -23
  286. package/scripts/command-contract-helpers.cjs +1 -1
  287. package/scripts/docs-guard-registry.cjs +22 -7
  288. package/scripts/gen-features.cjs +14 -2
  289. package/scripts/gen-health-docs.cjs +13 -1
  290. package/scripts/gen-pi-package-skills.cjs +217 -0
  291. package/scripts/lib/drift-scan.cjs +27 -0
  292. package/scripts/lib/macos-conformance-tier.generated.cjs +17 -1
  293. package/scripts/lib/platform-conformance-tier.generated.cjs +37 -1
  294. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +3 -3
  295. package/scripts/lint-phase-enumeration-drift.cjs +8 -4
  296. package/scripts/lint-retired-runtime-name.cjs +5 -0
  297. package/scripts/lint-roadmap-progress-drift.cjs +421 -0
  298. package/scripts/mutation-matrix.cjs +23 -1
  299. package/scripts/npm-audit-baseline.cjs +41 -7
  300. package/scripts/reviewed-or-maintainer-policy.cjs +181 -0
  301. package/scripts/select-docs-guards.cjs +1 -1
  302. package/scripts/self-test-report-policy.cjs +350 -0
  303. package/scripts/sync-next-version.cjs +38 -6
  304. package/skills/gsd-update/SKILL.md +5 -2
  305. package/vscode/package.json +1 -1
  306. package/scripts/diagnose-validate-commit-in-tier.cjs +0 -270
  307. package/scripts/diagnose-validate-commit-phases.cjs +0 -348
  308. package/scripts/xtrace-phase-timing.cjs +0 -205
@@ -1264,10 +1264,10 @@ function configuredEntrypointsForHook(configDir, hookName, opts) {
1264
1264
  const bash = resolveBashExecutable(opts);
1265
1265
  if (isShellHook) {
1266
1266
  // An unresolved bash must still surface as an interpreterCandidates entry
1267
- // (the literal token, same as the portableHooks runner below) so
1267
+ // (the sentinel token, same as the portableHooks runner below) so
1268
1268
  // validateConfiguredEntrypoints reports 'unresolved-interpreter' instead
1269
1269
  // of silently skipping the check because the field is absent.
1270
- return [{ ...target, interpreterCandidates: [bash === null ? 'bash' : bash] }];
1270
+ return [{ ...target, interpreterCandidates: [bash === null ? UNRESOLVED_BASH_SENTINEL : bash] }];
1271
1271
  }
1272
1272
  // #4249: check the SAME stable alias buildNodeRunnerChainToken bakes as its
1273
1273
  // first candidate (normalizeNodePath rewrites a version-manager shim like
@@ -1287,7 +1287,7 @@ function configuredEntrypointsForHook(configDir, hookName, opts) {
1287
1287
  runtime,
1288
1288
  configPath,
1289
1289
  scriptPath: node_path_1.default.join(configDir, 'hooks', NODE_RUNNER_RESOLVER_HOOK),
1290
- interpreterCandidates: bash === null ? ['bash'] : [bash],
1290
+ interpreterCandidates: bash === null ? [UNRESOLVED_BASH_SENTINEL] : [bash],
1291
1291
  platform,
1292
1292
  };
1293
1293
  return [runner, { ...target, interpreterCandidates: nodeCandidates }];
@@ -1298,6 +1298,15 @@ function recordConfiguredHookCommand(command, configDir, hookName, opts) {
1298
1298
  }
1299
1299
  return command;
1300
1300
  }
1301
+ // #5100: the two null-runner branches of buildHookCommand share this. No
1302
+ // command was ever built, so track() (which needs one) cannot record the
1303
+ // hook — push the entry directly instead, so the gate sees it rather than
1304
+ // the hook going silently unregistered.
1305
+ function recordUnresolvedBashEntrypoint(configDir, hookName, opts) {
1306
+ if (opts.configuredEntrypoints) {
1307
+ opts.configuredEntrypoints.push(...configuredEntrypointsForHook(configDir, hookName, opts));
1308
+ }
1309
+ }
1301
1310
  function buildHookCommand(configDir, hookName, opts) {
1302
1311
  if (!opts)
1303
1312
  opts = {};
@@ -1323,15 +1332,9 @@ function buildHookCommand(configDir, hookName, opts) {
1323
1332
  const runner = resolveBashRunner(opts);
1324
1333
  if (runner === null) {
1325
1334
  // #4249 (antigravity review): this early return skips `track()` below,
1326
- // so an unresolved bash on win32 (no Git Bash found) previously left
1327
- // this hook silently unregistered with nothing for
1328
- // validateConfiguredEntrypoints to reject — configuredEntrypointsForHook's
1329
- // own 'unresolved bash must still surface' comment describes intent this
1330
- // return never reached. Push the entry directly (no `command`, since
1331
- // none was ever built) so the gate actually sees it.
1332
- if (opts.configuredEntrypoints) {
1333
- opts.configuredEntrypoints.push(...configuredEntrypointsForHook(configDir, hookName, opts));
1334
- }
1335
+ // which needs a command that was never built — record the entry
1336
+ // directly so the gate can reject it.
1337
+ recordUnresolvedBashEntrypoint(configDir, hookName, opts);
1335
1338
  return null;
1336
1339
  }
1337
1340
  if (opts.portableHooks) {
@@ -1374,9 +1377,13 @@ function buildHookCommand(configDir, hookName, opts) {
1374
1377
  configDir,
1375
1378
  homeDir: node_os_1.default.homedir(),
1376
1379
  });
1377
- // Absolute Git-Bash discovery on win32 when available (#580); `bash` on
1378
- // PATH otherwise — the same assumption .sh hooks already make.
1379
- const resolverRunner = resolveBashRunner(opts) || 'bash';
1380
+ // #5100: same contract as the .sh early return. A bare `bash` is how
1381
+ // win32 reaches WSL's System32 launcher, and the gate used to accept it.
1382
+ const resolverRunner = resolveBashRunner(opts);
1383
+ if (resolverRunner === null) {
1384
+ recordUnresolvedBashEntrypoint(configDir, hookName, opts);
1385
+ return null;
1386
+ }
1380
1387
  return track(shellCmdProjection.projectShellCommandText({
1381
1388
  runnerToken: resolverRunner,
1382
1389
  argTokens: [
@@ -2158,6 +2165,9 @@ const SETTINGS_JSON_HOOK_ROWS = deepFreeze([
2158
2165
  // Guard: only register if the hook file was actually installed (#1754, #1817).
2159
2166
  { file: 'gsd-check-update.js', event: 'SessionStart', command: { opts: 'updateCheckCommand' },
2160
2167
  configuredMessage: 'Configured update check hook', skipLabel: 'update check hook' },
2168
+ // #5465: persists the gsd-core bin directory on PATH through $CLAUDE_ENV_FILE (SessionStart-only).
2169
+ { file: 'gsd-path-export.js', event: 'SessionStart', timeout: 5, command: { build: 'js' },
2170
+ configuredMessage: 'Configured PATH persistence hook', skipLabel: 'PATH persistence hook' },
2161
2171
  { file: 'gsd-context-monitor.js', event: 'post', matcher: 'Bash|Edit|Write|MultiEdit|Agent|Task', timeout: 10,
2162
2172
  command: { opts: 'contextMonitorCommand' },
2163
2173
  configuredMessage: 'Configured context window monitor hook', skipLabel: 'context monitor hook',
@@ -2710,6 +2720,30 @@ function referencesHook(h, hookName) {
2710
2720
  (Array.isArray(args) && args.some(a => typeof a === 'string' && a.includes(hookName))) ||
2711
2721
  (typeof url === 'string' && url.includes(hookName));
2712
2722
  }
2723
+ // #5100: the writer emits the bare `bash` token when the Git Bash policy
2724
+ // finds nothing, and the gate resolves that token through the same policy —
2725
+ // never a PATH scan. One shared definition so the two sides cannot drift.
2726
+ const UNRESOLVED_BASH_SENTINEL = 'bash';
2727
+ const WIN32_BARE_BASH_TOKENS = [UNRESOLVED_BASH_SENTINEL, 'bash.exe'];
2728
+ function isWin32BareBashToken(candidate, platform) {
2729
+ if ((platform || process.platform) !== 'win32')
2730
+ return false;
2731
+ // Equality already rejects any path: a string holding a separator can never
2732
+ // equal a bare token, so no explicit separator guard is needed here.
2733
+ return WIN32_BARE_BASH_TOKENS.includes(candidate.trim().toLowerCase());
2734
+ }
2735
+ // #5100: the literal `bash` sentinel means "Git Bash policy found nothing"
2736
+ // (#4249). Resolve that token through resolveBashExecutable, never a PATH
2737
+ // scan — WSL's System32\bash.exe is on PATH and cannot see Windows paths.
2738
+ function win32BareBashResolves(candidate, platform, deps) {
2739
+ if (!isWin32BareBashToken(candidate, platform))
2740
+ return null;
2741
+ return resolveBashExecutable({
2742
+ platform: 'win32',
2743
+ env: deps.env,
2744
+ existsSync: deps.existsSync,
2745
+ }) !== null;
2746
+ }
2713
2747
  function validateConfiguredEntrypoints(entries, deps = {}) {
2714
2748
  const statSync = deps.statSync ?? node_fs_1.default.statSync;
2715
2749
  const accessSync = deps.accessSync ?? node_fs_1.default.accessSync;
@@ -2766,7 +2800,12 @@ function validateConfiguredEntrypoints(entries, deps = {}) {
2766
2800
  invalid.push({ runtime: entry.runtime, configPath: entry.configPath, role: 'script', path: entry.scriptPath, reason: 'not-executable' });
2767
2801
  }
2768
2802
  }
2769
- if (entry.interpreterCandidates && !entry.interpreterCandidates.some(candidate => resolve(candidate, { platform: entry.platform, requireExecutable: true }) !== null)) {
2803
+ if (entry.interpreterCandidates && !entry.interpreterCandidates.some(candidate => {
2804
+ const bareBash = win32BareBashResolves(candidate, entry.platform, deps);
2805
+ if (bareBash !== null)
2806
+ return bareBash;
2807
+ return resolve(candidate, { platform: entry.platform, requireExecutable: true }) !== null;
2808
+ })) {
2770
2809
  invalid.push({ runtime: entry.runtime, configPath: entry.configPath, role: 'interpreter', path: entry.interpreterCandidates.join(' | '), reason: 'unresolved-interpreter' });
2771
2810
  }
2772
2811
  }
@@ -2856,6 +2895,11 @@ module.exports = {
2856
2895
  resolveBashExecutable,
2857
2896
  resolveBashRunner,
2858
2897
  NODE_RUNNER_RESOLVER_HOOK,
2898
+ // #5100: the literal the writer records for an unresolved Git Bash and the
2899
+ // gate resolves through the Git Bash policy. Exported so a consumer that
2900
+ // needs to recognise that entry imports it rather than respelling it;
2901
+ // bin/install.js keys on the entry having no command instead.
2902
+ UNRESOLVED_BASH_SENTINEL,
2859
2903
  // Atomic write seam (shared with bin/install.js so all writes participate
2860
2904
  // in install.js's _cleanTmpFiles() scoped temp-cleanup).
2861
2905
  atomicWriteFileSync,
@@ -15,7 +15,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
15
15
  return (mod && mod.__esModule) ? mod : { "default": mod };
16
16
  };
17
17
  Object.defineProperty(exports, "__esModule", { value: true });
18
- exports.PATH_ACTION_REASON = void 0;
18
+ exports.scrubGitLocationEnv = exports.GIT_LOCATION_ENV_VARS = exports.PATH_ACTION_REASON = void 0;
19
19
  exports.toPosixPath = toPosixPath;
20
20
  exports.toNativePath = toNativePath;
21
21
  exports.posixNormalize = posixNormalize;
@@ -37,6 +37,7 @@ exports.projectCodexHookTomlCommand = projectCodexHookTomlCommand;
37
37
  exports.escapePowerShellSingleQuoted = escapePowerShellSingleQuoted;
38
38
  exports.escapePosixDoubleQuoted = escapePosixDoubleQuoted;
39
39
  exports.escapeSingleQuotedShellLiteral = escapeSingleQuotedShellLiteral;
40
+ exports.shellSingleQuote = shellSingleQuote;
40
41
  exports.projectPathExportLine = projectPathExportLine;
41
42
  exports.renderShellActionLines = renderShellActionLines;
42
43
  exports.projectPathActionProjection = projectPathActionProjection;
@@ -64,8 +65,11 @@ const node_fs_1 = __importDefault(require("node:fs"));
64
65
  // at load time and become un-mockable.
65
66
  const node_child_process_1 = __importDefault(require("node:child_process"));
66
67
  const pattern_cjs_1 = require("./pattern.cjs");
68
+ const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
67
69
  const frontmatter_fence_cjs_1 = require("./frontmatter-fence.cjs");
70
+ const markdown_line_diff_cjs_1 = require("./markdown-line-diff.cjs");
68
71
  const runtime_name_policy_cjs_1 = require("./runtime-name-policy.cjs");
72
+ const read_only_git_env_cjs_1 = require("./read-only-git-env.cjs");
69
73
  /**
70
74
  * Convert a filesystem path to POSIX form (forward slashes) by translating the
71
75
  * platform-native separator. Single seam for native→POSIX conversion.
@@ -249,6 +253,8 @@ const MANAGED_HOOK_BASENAMES_BY_SURFACE = {
249
253
  'gsd-worktree-path-guard.js',
250
254
  // #4221: secret-file read guard (Read|Grep|Bash).
251
255
  'gsd-secret-read-guard.js',
256
+ // #5465: SessionStart PATH persistence through $CLAUDE_ENV_FILE.
257
+ 'gsd-path-export.js',
252
258
  ]),
253
259
  'codex-toml': new Set([
254
260
  'gsd-check-update.js',
@@ -279,6 +285,8 @@ const MANAGED_HOOK_COMMAND_BASENAMES_BY_SURFACE = {
279
285
  'gsd-worktree-path-guard.js',
280
286
  // #4221: secret-file read guard (Read|Grep|Bash).
281
287
  'gsd-secret-read-guard.js',
288
+ // #5465: SessionStart PATH persistence through $CLAUDE_ENV_FILE.
289
+ 'gsd-path-export.js',
282
290
  ]),
283
291
  'codex-toml': new Set([
284
292
  'gsd-check-update.js',
@@ -452,6 +460,18 @@ function escapePosixDoubleQuoted(value) {
452
460
  function escapeSingleQuotedShellLiteral(value) {
453
461
  return String(value).replace(/'/g, "'\\''");
454
462
  }
463
+ /**
464
+ * #1460 / #4039: POSIX single-quote an arbitrary string as ONE shell token. A path such as a
465
+ * hook script's absolute install location commonly sits under a home directory containing
466
+ * spaces or other metacharacters (e.g. "/Users/Bob Smith/.claude/..."); written unquoted it
467
+ * would word-split and, with a hostile prefix, could inject. Wrapping in single quotes — with
468
+ * each embedded `'` escaped as `'\''` by escapeSingleQuotedShellLiteral — makes the whole value a
469
+ * single token no metacharacter inside it can break. This is the one implementation of that rule;
470
+ * do not re-implement it in a caller.
471
+ */
472
+ function shellSingleQuote(value) {
473
+ return "'" + escapeSingleQuotedShellLiteral(value) + "'";
474
+ }
455
475
  /**
456
476
  * The `export PATH="<dir>:$PATH"` line every persistence lane appends, plus the escaped directory
457
477
  * token it embeds. One builder because three lanes emit this line: a lane that re-escapes it
@@ -609,12 +629,28 @@ function _spawnResult(result, program) {
609
629
  timedOut: isSpawnTimeout({ error }),
610
630
  };
611
631
  }
632
+ // #5548: owned by read-only-git-env.cts (its default base is scrubbed); re-exported for callers.
633
+ var read_only_git_env_cjs_2 = require("./read-only-git-env.cjs");
634
+ Object.defineProperty(exports, "GIT_LOCATION_ENV_VARS", { enumerable: true, get: function () { return read_only_git_env_cjs_2.GIT_LOCATION_ENV_VARS; } });
635
+ Object.defineProperty(exports, "scrubGitLocationEnv", { enumerable: true, get: function () { return read_only_git_env_cjs_2.scrubGitLocationEnv; } });
612
636
  function execGit(args, opts = {}) {
613
637
  // Non-interactive defaults: a hung credential prompt or terminal-input
614
638
  // probe must surface as a timeout, not block the tool forever. Callers
615
639
  // can override via opts.env.
640
+ //
641
+ // #5048: readOnlyGitEnv() as well. execGit is the shared seam behind every
642
+ // git call the tool makes, including the read-only ones (`status`,
643
+ // `diff --cached`, `rev-parse`, `log`, …). By default git refreshes the index
644
+ // and takes an *optional* `.git/index.lock` to write the refreshed copy back,
645
+ // so a read can lose a race against a real `git add` / `git commit` and fail
646
+ // it with `Unable to create '.git/index.lock': File exists`. The owner is
647
+ // src/read-only-git-env.cts, shared with every other read-only seam, so this
648
+ // call site cannot drift from them. The variable disables only those optional
649
+ // index operations; a caller that genuinely needs one (a writing command, or a
650
+ // status whose freshness it wants persisted) can still set it back through
651
+ // opts.env, which is spread last on purpose.
616
652
  const env = {
617
- ...process.env,
653
+ ...(0, read_only_git_env_cjs_1.readOnlyGitEnv)(),
618
654
  GIT_TERMINAL_PROMPT: '0',
619
655
  GCM_INTERACTIVE: 'never',
620
656
  ...(opts.env || {}),
@@ -1037,7 +1073,6 @@ function probeTty(opts = {}) {
1037
1073
  return null;
1038
1074
  }
1039
1075
  }
1040
- // ─── Platform file I/O ────────────────────────────────────────────────────────
1041
1076
  /**
1042
1077
  * How many leading lines of LF-only `text` are its closed YAML frontmatter block (opening
1043
1078
  * fence through closing fence), or 0 when there is none (an unterminated block is not
@@ -1053,18 +1088,43 @@ function leadingFrontmatterLineCount(text) {
1053
1088
  // The lines before the closing fence, plus the closing fence line itself.
1054
1089
  return text.slice(0, fence.closingStart).split('\n').length;
1055
1090
  }
1056
- function _normalizeMd(content) {
1091
+ /** A leading BOM is not part of the first line's content when aligning two documents. */
1092
+ function withoutBom(line) {
1093
+ return line !== undefined && line.charCodeAt(0) === 0xFEFF ? line.slice(1) : (line ?? '');
1094
+ }
1095
+ function _normalizeMd(content, prior, preserveFencedStructure = false) {
1057
1096
  if (!content || typeof content !== 'string')
1058
1097
  return content;
1059
1098
  let text = content.replace(/\r\n/g, '\n');
1060
1099
  const lines = text.split('\n');
1100
+ // #5165: when the document being replaced is known, only the junctions the
1101
+ // write touched are normalized; every other region stays byte-identical.
1102
+ let scope = null;
1103
+ let priorLines = [];
1104
+ if (typeof prior === 'string' && prior !== '') {
1105
+ priorLines = prior.replace(/\r\n/g, '\n').split('\n');
1106
+ // A BOM on either first line must not make an unchanged line look rewritten.
1107
+ const alignPrior = [withoutBom(priorLines[0]), ...priorLines.slice(1)];
1108
+ const alignNext = [withoutBom(lines[0]), ...lines.slice(1)];
1109
+ const match = (0, markdown_line_diff_cjs_1.matchLines)(alignPrior, alignNext);
1110
+ if (match)
1111
+ scope = (0, markdown_line_diff_cjs_1.writeScope)(match, priorLines.length);
1112
+ }
1061
1113
  // The frontmatter block is YAML, not markdown: its lines are published exactly as written.
1062
1114
  // A `# comment` is not a heading, and a blank line inserted into a multi-line quoted scalar
1063
1115
  // or removed from a block scalar changes the value (found while implementing #5105).
1064
1116
  const frontmatterLines = leadingFrontmatterLineCount(text);
1065
1117
  const result = [];
1118
+ // A separating blank the rules add. In scoped mode (#5165) two rules that fire on
1119
+ // the same junction add ONE blank: the whole-document pass collapses them in its
1120
+ // final blank-run regex, which scoped mode must not apply to runs the write kept.
1121
+ const blank = () => {
1122
+ if (scope === null || result[result.length - 1] !== '')
1123
+ result.push('');
1124
+ };
1066
1125
  const fenceRegex = /^```/;
1067
1126
  const insideFence = new Array(lines.length);
1127
+ const insideSharedFence = new Array(lines.length).fill(false);
1068
1128
  let fenceOpen = false;
1069
1129
  for (let i = 0; i < lines.length; i++) {
1070
1130
  if (i < frontmatterLines) {
@@ -1085,6 +1145,13 @@ function _normalizeMd(content) {
1085
1145
  insideFence[i] = fenceOpen;
1086
1146
  }
1087
1147
  }
1148
+ if (preserveFencedStructure) {
1149
+ for (const block of (0, markdown_sectionizer_cjs_1.scanFencedBlocks)(lines)) {
1150
+ const end = block.closeLineIdx === -1 ? lines.length : block.closeLineIdx;
1151
+ for (let i = block.openLineIdx + 1; i < end; i++)
1152
+ insideSharedFence[i] = true;
1153
+ }
1154
+ }
1088
1155
  for (let i = 0; i < lines.length; i++) {
1089
1156
  const line = lines[i];
1090
1157
  if (i < frontmatterLines) {
@@ -1095,11 +1162,25 @@ function _normalizeMd(content) {
1095
1162
  const prevTrimmed = prev.trimEnd();
1096
1163
  const trimmed = line.trimEnd();
1097
1164
  const isFenceLine = fenceRegex.test(trimmed);
1098
- if (/^#{1,6}\s/.test(trimmed) && i > 0 && prevTrimmed !== '' && prevTrimmed !== '---')
1099
- result.push('');
1100
- if (isFenceLine && i > 0 && prevTrimmed !== '' && !insideFence[i] && (i === 0 || !insideFence[i - 1] || isFenceLine)) {
1165
+ // Bracket writers preserve literal headings and lists inside fenced examples.
1166
+ const normalizeStructuralLine = !preserveFencedStructure || !insideSharedFence[i];
1167
+ // A heading or fence line the write authored brings its own separation, even
1168
+ // where it replaced a prose line in place (#5165). One that replaced a heading
1169
+ // or fence (a rename, a renumber) inherits the spacing around it instead.
1170
+ const isStructural = (text) => /^#{1,6}\s/.test(text.trimEnd()) || fenceRegex.test(text.trimEnd());
1171
+ const replacedPrior = scope !== null ? scope.replacedPriorIndex(i) : -1;
1172
+ const authoredStructure = scope !== null && scope.lineWritten(i) && isStructural(line)
1173
+ && !(replacedPrior >= 0 && isStructural(priorLines[replacedPrior] ?? ''));
1174
+ const keptBefore = scope !== null && !authoredStructure && i > 0 && scope.junctionKept(i - 1);
1175
+ const keptAfter = scope !== null && !authoredStructure && i < lines.length - 1 && scope.junctionKept(i);
1176
+ // A blank-line run is collapsed only where the write made it (#5165).
1177
+ if (scope !== null && line === '' && result.length > 0 && result[result.length - 1] === '' && !keptBefore)
1178
+ continue;
1179
+ if (normalizeStructuralLine && /^#{1,6}\s/.test(trimmed) && i > 0 && prevTrimmed !== '' && prevTrimmed !== '---' && !keptBefore)
1180
+ blank();
1181
+ if (isFenceLine && i > 0 && prevTrimmed !== '' && !insideFence[i] && (i === 0 || !insideFence[i - 1] || isFenceLine) && !keptBefore) {
1101
1182
  if (i === 0 || !insideFence[i - 1])
1102
- result.push('');
1183
+ blank();
1103
1184
  }
1104
1185
  // No "separate a list from a preceding paragraph" rule (#3854, #4725).
1105
1186
  // This pass re-normalizes the ENTIRE document on every .md write, so an
@@ -1111,21 +1192,21 @@ function _normalizeMd(content) {
1111
1192
  // from the after-heading rule below. The after-a-bullet rule at the end
1112
1193
  // of this loop is a different transition (list→prose) and is unaffected.
1113
1194
  result.push(line);
1114
- if (/^#{1,6}\s/.test(trimmed) && i < lines.length - 1 && (lines[i + 1] ?? '').trimEnd() !== '')
1115
- result.push('');
1116
- if (/^```\s*$/.test(trimmed) && i > 0 && insideFence[i - 1] && i < lines.length - 1 && (lines[i + 1] ?? '').trimEnd() !== '')
1117
- result.push('');
1118
- if (/^(\s*[-*+]\s|\s*\d+\.\s)/.test(line) && i < lines.length - 1) {
1195
+ if (normalizeStructuralLine && /^#{1,6}\s/.test(trimmed) && i < lines.length - 1 && (lines[i + 1] ?? '').trimEnd() !== '' && !keptAfter)
1196
+ blank();
1197
+ if (/^```\s*$/.test(trimmed) && i > 0 && insideFence[i - 1] && i < lines.length - 1 && (lines[i + 1] ?? '').trimEnd() !== '' && !keptAfter)
1198
+ blank();
1199
+ if (normalizeStructuralLine && /^(\s*[-*+]\s|\s*\d+\.\s)/.test(line) && i < lines.length - 1 && !keptAfter) {
1119
1200
  const next = lines[i + 1];
1120
1201
  if (next !== undefined && next.trimEnd() !== '' && !/^(\s*[-*+]\s|\s*\d+\.\s)/.test(next) && !/^\s/.test(next))
1121
- result.push('');
1202
+ blank();
1122
1203
  }
1123
1204
  }
1124
1205
  // The frontmatter lines are `result`'s first entries, unchanged; the blank-run collapse
1125
1206
  // applies from the line ending that closes the block onward.
1126
1207
  const head = result.slice(0, frontmatterLines).join('\n');
1127
1208
  text = result.join('\n');
1128
- text = head + text.slice(head.length).replace(/\n{3,}/g, '\n\n');
1209
+ text = scope === null ? head + text.slice(head.length).replace(/\n{3,}/g, '\n\n') : text;
1129
1210
  text = text.replace(/\n*$/, '\n');
1130
1211
  return text;
1131
1212
  }
@@ -1136,7 +1217,7 @@ function normalizeContent(filePath, content, opts = {}) {
1136
1217
  const isMd = node_path_1.default.extname(filePath).toLowerCase() === '.md';
1137
1218
  let normalized;
1138
1219
  if (isMd) {
1139
- normalized = _normalizeMd(content);
1220
+ normalized = _normalizeMd(content, opts.prior, opts.preserveFencedMarkdownStructure === true);
1140
1221
  }
1141
1222
  else {
1142
1223
  normalized = (content ?? '').replace(/\r\n/g, '\n').replace(/\n*$/, '\n');
@@ -1157,8 +1238,8 @@ function normalizeContent(filePath, content, opts = {}) {
1157
1238
  * post-write re-read of the actual on-disk bytes) instead of a raw `!==` —
1158
1239
  * do not simplify this back to a direct string comparison.
1159
1240
  */
1160
- function contentChangedAfterNormalize(filePath, before, after) {
1161
- return normalizeContent(filePath, after).content !== normalizeContent(filePath, before).content;
1241
+ function contentChangedAfterNormalize(filePath, before, after, opts = {}) {
1242
+ return normalizeContent(filePath, after, { ...opts, prior: before }).content !== normalizeContent(filePath, before, { ...opts, prior: before }).content;
1162
1243
  }
1163
1244
  // Rename errnos that are transient on Windows: a concurrent reader (or an AV
1164
1245
  // scanner / indexer) holding the target open makes renameSync fail briefly.
@@ -1210,8 +1291,59 @@ function retryRenameSync(fromPath, toPath) {
1210
1291
  if (err !== null)
1211
1292
  throw err;
1212
1293
  }
1294
+ /**
1295
+ * Largest on-disk Markdown document read back as the `prior` of a scoped write.
1296
+ * A larger file is normalized whole (the pre-#5165 behavior) rather than read and
1297
+ * diffed on every write.
1298
+ */
1299
+ const MAX_PRIOR_BYTES = 2 * 1024 * 1024;
1300
+ /**
1301
+ * The Markdown document a write replaces, or undefined when there is none worth
1302
+ * diffing against: no file, not a regular file (a FIFO or device reports size 0
1303
+ * and would block or never end), unreadable, or larger than MAX_PRIOR_BYTES. The
1304
+ * bound is enforced on the bytes actually read from one open descriptor, so a
1305
+ * file that grows after the check cannot exceed it.
1306
+ */
1307
+ function readPriorMarkdown(filePath, encoding) {
1308
+ let fd;
1309
+ try {
1310
+ // O_NONBLOCK keeps opening a FIFO from waiting for a writer; it is absent on Windows.
1311
+ fd = node_fs_1.default.openSync(filePath, node_fs_1.default.constants.O_RDONLY | (node_fs_1.default.constants.O_NONBLOCK ?? 0));
1312
+ const stat = node_fs_1.default.fstatSync(fd);
1313
+ if (!stat.isFile() || stat.size > MAX_PRIOR_BYTES)
1314
+ return undefined;
1315
+ // One byte of headroom: reading it means the file grew past what was measured.
1316
+ const buffer = Buffer.alloc(stat.size + 1);
1317
+ let length = 0;
1318
+ while (length < buffer.length) {
1319
+ const read = node_fs_1.default.readSync(fd, buffer, length, buffer.length - length, null);
1320
+ if (read === 0)
1321
+ break;
1322
+ length += read;
1323
+ }
1324
+ if (length > stat.size)
1325
+ return undefined;
1326
+ return buffer.toString(encoding, 0, length);
1327
+ }
1328
+ catch {
1329
+ return undefined; // no prior document (new file or unreadable): normalize it whole
1330
+ }
1331
+ finally {
1332
+ if (fd !== undefined) {
1333
+ try {
1334
+ node_fs_1.default.closeSync(fd);
1335
+ }
1336
+ catch { /* already closed */ }
1337
+ }
1338
+ }
1339
+ }
1213
1340
  function platformWriteSync(filePath, content, opts = {}) {
1214
- const { content: normalized, encoding } = normalizeContent(filePath, content, opts);
1341
+ // #5165: a Markdown write is scoped to what it changed relative to the file it replaces.
1342
+ let prior = opts.prior;
1343
+ if (prior === undefined && opts.verbatim !== true && node_path_1.default.extname(filePath).toLowerCase() === '.md') {
1344
+ prior = readPriorMarkdown(filePath, opts.encoding ?? 'utf-8');
1345
+ }
1346
+ const { content: normalized, encoding } = normalizeContent(filePath, content, { ...opts, prior });
1215
1347
  node_fs_1.default.mkdirSync(node_path_1.default.dirname(filePath), { recursive: true });
1216
1348
  const tmpPath = filePath + '.tmp.' + process.pid;
1217
1349
  // Step 1: write the sibling tmp file. If THIS fails, nothing was published, so a
@@ -43,6 +43,7 @@ const node_fs_1 = __importDefault(require("node:fs"));
43
43
  const node_path_1 = __importDefault(require("node:path"));
44
44
  const node_child_process_1 = require("node:child_process");
45
45
  const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
46
+ const read_only_git_env_cjs_1 = require("./read-only-git-env.cjs");
46
47
  const runtime_slash_cjs_1 = require("./runtime-slash.cjs");
47
48
  // #3696: the calendar-validity predicate moved to the STATE.md document module
48
49
  // so `state validate` can assert the same `last_activity` invariant this reader
@@ -68,11 +69,14 @@ const stateDocument = require("./state-document.cjs");
68
69
  const { stateFieldValue } = stateDocument;
69
70
  // eslint-disable-next-line @typescript-eslint/no-require-imports
70
71
  const phaseId = require("./phase-id.cjs");
71
- const { comparePhaseNum, extractPhaseToken, matchPhaseDirs, normalizePhaseName, parsePhaseFromProse, stripProjectCodePrefix } = phaseId;
72
+ const { comparePhaseNum, extractPhaseToken, customPhaseQuery, customDirectoryOwner, parsePhaseFromProse, stripProjectCodePrefix } = phaseId;
72
73
  // eslint-disable-next-line @typescript-eslint/no-require-imports
73
74
  const stateMod = require("./state.cjs");
74
75
  const { readStateHeadFreshness } = stateMod;
75
76
  // eslint-disable-next-line @typescript-eslint/no-require-imports
77
+ const phaseLocatorMod = require("./phase-locator.cjs");
78
+ const { phaseDirectoryOptions, resolvePhaseDirectoryLookup, matchPhaseDirsForLookup } = phaseLocatorMod;
79
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
76
80
  const unusableInput = require("./unusable-input.cjs");
77
81
  const { warnUnusableInput, UNUSABLE_REASON } = unusableInput;
78
82
  // #5118: the verification-status owner's frontmatter-only report check.
@@ -119,8 +123,8 @@ function parseIntOrNull(s) {
119
123
  const n = Number.parseInt(cleaned, 10);
120
124
  return Number.isFinite(n) ? n : null;
121
125
  }
122
- function phaseTokenFromDirName(name) {
123
- const token = extractPhaseToken(name);
126
+ function phaseTokenFromDirName(name, convention) {
127
+ const token = extractPhaseToken(name, convention);
124
128
  // #2528: the shape probe runs on the PROJECT-CODE-STRIPPED token. A prefixed
125
129
  // directory tokenizes to `MEM-05-80-20`, which does not start with a digit,
126
130
  // so the unstripped probe rejected it and the entry was dropped before any
@@ -222,6 +226,18 @@ function readGitSignals(cwd) {
222
226
  windowsHide: true,
223
227
  stdio: ['pipe', 'pipe', 'pipe'],
224
228
  timeout: 10_000,
229
+ // #5048: readGitSignals runs `git status`, which by default refreshes
230
+ // the index and takes an *optional* `.git/index.lock` to write the
231
+ // refreshed copy back. That lock is the one a real `git add` / `git
232
+ // commit` needs, so a routine "which phase am I in" probe can fail
233
+ // someone else's commit with `Unable to create '.git/index.lock': File
234
+ // exists`. GIT_OPTIONAL_LOCKS=0 turns off exactly that optional write.
235
+ // The `rev-parse` / `log` calls sharing this helper never open the
236
+ // index, so the variable is inert for them. The block itself is owned by
237
+ // src/read-only-git-env.cts and shared with every other read-only seam;
238
+ // parity across every read-only surface is asserted in
239
+ // tests/git-optional-locks-parity.test.cjs.
240
+ env: (0, read_only_git_env_cjs_1.readOnlyGitEnv)(),
225
241
  });
226
242
  }
227
243
  catch {
@@ -239,9 +255,9 @@ function readGitSignals(cwd) {
239
255
  const unpushed = run(['log', '@{u}..HEAD', '--oneline']).trim().length > 0;
240
256
  return { has_git: true, dirty, unpushed };
241
257
  }
242
- /** Leading numeric phase token from a STATE.md scalar or body `Phase:` value. */
243
- function phaseTokenFromState(raw) {
244
- return parsePhaseFromProse(raw).phase;
258
+ /** Phase identity from a STATE.md scalar or body `Phase:` value, honoring custom naming when a context is supplied. */
259
+ function phaseTokenFromState(raw, context) {
260
+ return parsePhaseFromProse(raw, undefined, context).phase;
245
261
  }
246
262
  /**
247
263
  * Detect whether the current phase's verify report indicates failure. The
@@ -251,12 +267,20 @@ function phaseTokenFromState(raw) {
251
267
  */
252
268
  function detectVerifyFailed(cwd, currentPhaseRaw) {
253
269
  const phasesDir = node_path_1.default.join(planningPaths(cwd).phases);
270
+ // #4304: both the directory admission scan and the later selector must read
271
+ // the same current-checkout grammar. Bracket is the only opt-in widening.
272
+ const convention = resolvePhaseIdConvention(cwd) === 'bracket' ? 'bracket' : undefined;
273
+ const directoryContext = phaseDirectoryOptions(cwd);
254
274
  let entries = [];
255
275
  try {
256
276
  entries = node_fs_1.default.readdirSync(phasesDir)
257
- .map((name) => ({ name, phaseToken: phaseTokenFromDirName(name) }))
277
+ .map((name) => {
278
+ const custom = customPhaseQuery(name, directoryContext);
279
+ const parts = custom && customDirectoryOwner(name, custom);
280
+ return { name, phaseToken: parts ? parts.id : phaseTokenFromDirName(name, convention) };
281
+ })
258
282
  .filter((entry) => entry.phaseToken !== null)
259
- .sort((a, b) => comparePhaseNum(a.phaseToken, b.phaseToken) || a.name.localeCompare(b.name))
283
+ .sort((a, b) => comparePhaseNum(a.phaseToken, b.phaseToken, directoryContext) || a.name.localeCompare(b.name))
260
284
  .map((entry) => entry.name);
261
285
  }
262
286
  catch {
@@ -264,10 +288,10 @@ function detectVerifyFailed(cwd, currentPhaseRaw) {
264
288
  }
265
289
  if (entries.length === 0)
266
290
  return false;
267
- const phaseToken = phaseTokenFromState(currentPhaseRaw);
291
+ const phaseToken = phaseTokenFromState(currentPhaseRaw, directoryContext);
268
292
  let targetDir;
269
293
  if (phaseToken) {
270
- const normalized = normalizePhaseName(phaseToken);
294
+ const lookup = resolvePhaseDirectoryLookup(cwd, phaseToken, undefined, directoryContext);
271
295
  // #2528: the fourth directory-resolution site, and the one where a miss is
272
296
  // silent — a phase whose directory cannot be found reports "not failed",
273
297
  // which reads identically to a healthy phase. It must therefore apply the
@@ -276,7 +300,7 @@ function detectVerifyFailed(cwd, currentPhaseRaw) {
276
300
  // surfaces its own failed verification. `entries` is already sorted, and
277
301
  // `matchPhaseDirs` filters without reordering, so taking the first match
278
302
  // preserves the previous `.find()` selection exactly.
279
- const { matches } = matchPhaseDirs(entries, normalized);
303
+ const { matches } = matchPhaseDirsForLookup(entries, lookup, undefined, directoryContext);
280
304
  targetDir = matches[0];
281
305
  if (!targetDir)
282
306
  return false;