@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
@@ -73,6 +73,18 @@ Verify the work is ready to ship:
73
73
  ```
74
74
  Present `$NEXT_ACTION` to the user and, when `$NEXT_COMMAND` is non-empty, show it as the command to run next. These two are message text only — the block/allow decision has already been made from `$STATUS`, so a concurrent write between the reads cannot change the gate's verdict. The query already covers every status in its closed set, so no per-status arm is needed.
75
75
 
76
+ 1a. **Phase marked complete?** (#5221) Verified is not complete: `/gsd:verify-work` completes the phase.
77
+ ```bash
78
+ ANALYZE=$(gsd_run query roadmap.analyze ${GSD_WS:+--ws=${GSD_WS##* }}) || { echo "roadmap.analyze failed — see the error above" >&2; exit 1; }
79
+ if [[ "$ANALYZE" == @file:* ]]; then ANALYZE=$(cat "${ANALYZE#@file:}"); fi
80
+ # roadmap_record: phase -> every ROADMAP record of it reads complete. Fail closed: only true ships.
81
+ VERDICT=$(jq -er --arg p "$PHASE_NUMBER" 'def n:split(".")|map(sub("^0+(?=[0-9])";""))|join(".");if .error=="ROADMAP.md not found" then "skip" elif has("roadmap_record")|not then error("no roadmap_record") else .roadmap_record as $r|($p|n) as $k|if $r==null then "unreadable" elif ($r|type)!="object" then error("bad roadmap_record") elif ($r|has($k)|not) then "unrecorded" elif $r[$k] then "complete" else "incomplete" end end' <<<"$ANALYZE") || { echo "roadmap.analyze output unparseable" >&2; exit 1; }
82
+ [ "$VERDICT" != unreadable ] || { echo "ROADMAP_TABLE_UNREADABLE: the ROADMAP.md progress table does not parse (e.g. a row with the wrong cell count); fix it, then re-run /gsd:ship" >&2; exit 1; }
83
+ [ "$VERDICT" != unrecorded ] || { echo "PHASE_NOT_RECORDED: ROADMAP.md has no checklist entry or progress-table row for phase ${PHASE_NUMBER}; add one, run /gsd:verify-work ${PHASE_NUMBER}, then re-run /gsd:ship" >&2; exit 1; }
84
+ [ "$VERDICT" != incomplete ] || { echo "PHASE_NOT_COMPLETE: Run /gsd:verify-work ${PHASE_NUMBER} (completes the phase), then re-run /gsd:ship" >&2; [[ $PHASE_NUMBER =~ ^[A-Za-z]?[0-9] ]] || echo "Custom-id phase: the phase tools cannot find its directory yet, so after verifying, tick its ROADMAP.md checklist entry by hand" >&2; exit 1; }
85
+ ```
86
+ Only a phase whose every ROADMAP record (checklist entry, progress-table Status) reads complete ships; a phase with no record blocks. A missing ROADMAP.md skips the check; an analyze failure stops. This reads the record to catch a missed write-back — it is not a completion verdict (ADR-3180 §7.4, Amendment 10; step 1 already holds the disk verdict). `roadmap_record` keys use `foldPhaseId`; `def n` above must fold the same way.
87
+
76
88
  2. **Clean working tree?**
77
89
  ```bash
78
90
  git status --short
@@ -160,7 +172,7 @@ Verify the work is ready to ship:
160
172
 
161
173
  - **Every other `capId`** — run the gate's own declared check through the generic evaluator. This arm is what makes a third-party capability's declared gate enforceable at all (#3559); before it existed, a gate whose `capId` was not named above was resolved and then silently dropped.
162
174
 
163
- ⚠ **Validate `check` before shell use** (third-party manifest input) — `loop-hook-dispatch.md` § `gate`.
175
+ ⚠ **Validate `check` before shell use** (third-party manifest input; wherever `${hook.check.query}` appears, write the dot-normalized query, each `.` as `-`, and validate and pass that same value) — `loop-hook-dispatch.md` § `gate`.
164
176
 
165
177
  For a named-query gate (only a value that has passed validation is run):
166
178
  ```bash
@@ -477,11 +489,10 @@ Report the PR URL and suggest: "Review the diff at {url}/files"
477
489
  </step>
478
490
 
479
491
  <step name="track_shipping">
480
- Update STATE.md to reflect the shipping action:
492
+ Update STATE.md to reflect the shipping action. Write the activity line only, with its description; do not write `Status`. At ship time the Current Position block already describes the next phase, and `Status` feeds the frontmatter `status` field, so a free-text value such as "Phase N shipped" does not belong there (#5165).
481
493
 
482
494
  ```bash
483
- gsd_run query state.update "Last Activity" "$(date +%Y-%m-%d)"
484
- gsd_run query state.update "Status" "Phase ${PHASE_NUMBER} shipped — PR #${PR_NUMBER}"
495
+ gsd_run query state.update "Last Activity" "$(date +%Y-%m-%d) -- Phase ${PHASE_NUMBER} shipped as PR #${PR_NUMBER}"
485
496
  ```
486
497
 
487
498
  If `commit_docs` is true, commit the ship-note AND push it onto the PR branch so
@@ -61,7 +61,7 @@ INIT=$(gsd_run init phase-op "${PHASE}")
61
61
  if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
62
62
  ```
63
63
 
64
- Parse JSON for: `phase_found`, `phase_dir`, `phase_number`, `phase_name`, `phase_slug`, `padded_phase`, `state_path`, `requirements_path`, `roadmap_path`, `planning_path`, `response_language`, `commit_docs`.
64
+ Parse JSON for: `phase_found`, `phase_dir`, `phase_number`, `phase_name`, `phase_slug`, `padded_phase`, `state_path`, `requirements_path`, `roadmap_path`, `planning_path`, `has_spec`, `spec_path`, `response_language`, `commit_docs`.
65
65
 
66
66
  **If `response_language` is set:** All user-facing text in this workflow — narration between tool calls, status updates, progress notes, findings, questions, and report prose — MUST be in `{response_language}`. Technical terms, code, and file paths stay in English.
67
67
 
@@ -72,12 +72,9 @@ Use /gsd:progress to see available phases.
72
72
  ```
73
73
  Exit.
74
74
 
75
- **Check for existing SPEC.md:**
76
- ```bash
77
- ls ${phase_dir}/*-SPEC.md 2>/dev/null | grep -v AI-SPEC | head -1 || true
78
- ```
75
+ **Check for existing SPEC.md:** use `has_spec` and `spec_path` from init (one shared definition of the phase SPEC: a plain `*-SPEC.md`, never `*-AI-SPEC.md` or `*-UI-SPEC.md`). Do not glob for it.
79
76
 
80
- If SPEC.md already exists:
77
+ If `has_spec` is true:
81
78
 
82
79
  **If `--auto`:** Auto-select "Skip" — leave the existing SPEC.md unchanged and exit with the same
83
80
  message the interactive "Skip" prints. Log: `[auto] SPEC.md exists — reusing as-is.` An unattended
@@ -75,6 +75,7 @@ registry, not hardcoded here.
75
75
  ```bash
76
76
  VERIFY_PRE_HOOKS_JSON=$(gsd_run loop render-hooks verify:pre --raw)
77
77
  PHASE_DIR=$(printf '%s' "$INIT" | jq -r '.phase_dir // empty')
78
+ PHASE_NUMBER=$(printf '%s' "$INIT" | jq -r '.phase_number // empty')
78
79
  ```
79
80
 
80
81
  Read the `activeHooks` array from `VERIFY_PRE_HOOKS_JSON` in-context (do NOT pipe through a shell parser).
@@ -87,12 +88,12 @@ Read the `activeHooks` array from `VERIFY_PRE_HOOKS_JSON` in-context (do NOT pip
87
88
 
88
89
  Record the union of `produces` artefact names declared by the active step entries as `VERIFY_PRE_PRODUCED` — `extract_tests` consumes it below. An entry declaring `produces: []` contributes nothing, which is the normal case.
89
90
 
90
- ⚠ **Validate `check` before shell use** (third-party manifest input) — `loop-hook-dispatch.md` § `gate`.
91
+ ⚠ **Validate `check` before shell use** (third-party manifest input; wherever `${hook.check.query}` appears, write the dot-normalized query, each `.` as `-`, and validate and pass that same value) — `loop-hook-dispatch.md` § `gate`.
91
92
 
92
93
  Resolve active gate hooks from `VERIFY_PRE_HOOKS_JSON` where `kind == "gate"`.
93
94
  For each active gate hook, run its declared check (a `check.query` gate runs
94
95
  `gsd_run check ${hook.check.query} "${PHASE_DIR}" --raw`; a `predicate` gate
95
- runs `gsd_run check predicate --predicate '<hook.check.predicate as JSON>' --phase-dir "${PHASE_DIR}" --raw`):
96
+ runs `gsd_run check predicate --predicate '<hook.check.predicate as JSON>' --phase-dir "${PHASE_DIR}" --phase-number "${PHASE_NUMBER}" --raw`):
96
97
 
97
98
  ```bash
98
99
  GATE_RESULT=$(gsd_run check "${hook_check_query}" "${PHASE_DIR}" --raw) && CHECK_EXIT=0 || CHECK_EXIT=$?
@@ -799,7 +800,7 @@ Spawn gsd-plan-checker against the fix plans (iteration_count starts at 1), mode
799
800
  > **ORCHESTRATOR RULE — CODEX RUNTIME**: After calling Agent() above, stop working on this task immediately. Do not read more files, edit code, or run tests related to this task while the subagent is active. Wait for the subagent to return its result. This prevents duplicate work, conflicting edits, and wasted context. Only resume when the subagent result is available.
800
801
 
801
802
  On return:
802
- - **VERIFICATION PASSED:** Proceed to `present_ready`
803
+ - **VERIFICATION PASSED:** If the return lists an entry whose severity is BLOCKER or WARNING, in the YAML issues block or listed in prose (a mention such as "no warnings" or a zero count is not an entry), handle it as ISSUES FOUND below, counting the prose entries as BLOCKER/WARNING entries; the sentinel never decides alone, and an entry with a missing or unrecognized severity counts as a BLOCKER (#5578). Otherwise proceed to `present_ready`
803
804
  - **ISSUES FOUND:** Count BLOCKER + WARNING entries in the YAML issues block; an entry whose severity is missing or unrecognized counts as a BLOCKER (fail closed). If zero — every entry is explicitly INFO — display `ℹ advisory — {dimension}: {description}` per entry and proceed to `present_ready`; INFO is advisory and never enters the loop (#3724). Otherwise proceed to `revision_loop`
804
805
 
805
806
  Exact Agent() prompt fields: `gsd-core/workflows/verify-work/detail/elaboration.md` § 2.
@@ -71,7 +71,7 @@ if (require.main === module && !process.env.NODE_V8_COVERAGE) {
71
71
  const fs = require('fs');
72
72
  const path = require('path');
73
73
  const os = require('os');
74
- const { readSentinel, VALID_ISOLATION, extractDispatchIdentifiers, sentinelAppliesToDispatch, buildSentinelDiscard } = require('./lib/isolation-sentinel.js');
74
+ const { readSentinel, VALID_ISOLATION, extractDispatchIdentifiers, sentinelAppliesToDispatch, agedNoneGovernsDispatch, buildSentinelDiscard } = require('./lib/isolation-sentinel.js');
75
75
  const { REASON_CODE, describeSentinelDiscard } = require('./lib/isolation-deny-reason.js');
76
76
  const { HOOK_ON_CRASH, allow, deny, crash } = require('./lib/hook-exit.js');
77
77
 
@@ -465,6 +465,16 @@ function resolveIsolationState(cwd, { clock = Date, dispatchIds = null } = {}) {
465
465
  };
466
466
  }
467
467
 
468
+ // #5479: a recorded sequential degrade (`none`) for THIS dispatch's phase keeps governing it after the
469
+ // trust window. Without this, an executor that ran longer than SENTINEL_STALE_MS made the next
470
+ // flag-less dispatch of the same phase fall back to the registry capability ("this host CAN
471
+ // isolate") and be denied for a degrade the workflow legitimately recorded. The predicate only
472
+ // loosens toward `none`, only for the phase the dispatch itself names, never for a future-dated
473
+ // record (see agedNoneGovernsDispatch).
474
+ if (sentinel.present && sentinel.stale && agedNoneGovernsDispatch(sentinel, dispatchIds, { clock })) {
475
+ return { gsdProject: true, isolation: 'none', harnessFlag: null, error: null, sentinelDiscarded: null };
476
+ }
477
+
468
478
  // Sentinel absent, stale, or does not apply to this dispatch (F2 mismatch):
469
479
  // conservative fallback (#3045 finding — must still cover fail-closed case
470
480
  // (a): a project that opted out of worktrees entirely via
@@ -70,7 +70,7 @@ const { allow } = require('./lib/hook-exit.js');
70
70
  // hooks/lib/cursor-workspace.js. Staged next to these scripts by
71
71
  // writeCursorHooksJson so the require always resolves post-install.
72
72
  const { resolveStatePath } = require('./lib/cursor-workspace.js');
73
- const { readSentinel, VALID_ISOLATION, extractDispatchIdentifiers, sentinelAppliesToDispatch, buildSentinelDiscard } = require('./lib/isolation-sentinel.js');
73
+ const { readSentinel, VALID_ISOLATION, extractDispatchIdentifiers, sentinelAppliesToDispatch, agedNoneGovernsDispatch, buildSentinelDiscard } = require('./lib/isolation-sentinel.js');
74
74
  const { REASON_CODE, describeSentinelDiscard } = require('./lib/isolation-deny-reason.js');
75
75
  // #3582: gsd-core/bin/lib/*.cjs (runtime-homes.cjs, worktree-safety.cjs,
76
76
  // runtime-name-policy.cjs, capability-registry.cjs — required below, inside
@@ -518,9 +518,13 @@ function evaluateRootIsolation(root, subagentType, { clock = Date, dispatchIds =
518
518
 
519
519
  let declaredIsolation;
520
520
  try {
521
+ // #5479: an aged recorded `none` for this dispatch's phase keeps governing it — the same predicate,
522
+ // and so the same verdicts, as the Claude guard (hooks/gsd-agent-isolation-guard.js).
521
523
  declaredIsolation = (sentinel.present && !sentinel.stale && applies)
522
524
  ? sentinel.isolation
523
- : resolveFallbackIsolation(root, configPath);
525
+ : (sentinel.present && sentinel.stale && agedNoneGovernsDispatch(sentinel, dispatchIds, { clock }))
526
+ ? 'none'
527
+ : resolveFallbackIsolation(root, configPath);
524
528
  } catch {
525
529
  return {
526
530
  action: 'deny',
@@ -81,6 +81,12 @@ case "$COMMAND" in
81
81
  *) exit 0 ;;
82
82
  esac
83
83
 
84
+ # #5548: inherited repo-location vars (GIT_DIR, GIT_INDEX_FILE, ...) would override
85
+ # cwd for every git call below. Drop git's own list minus the config-injection vars.
86
+ for v in $(git rev-parse --local-env-vars 2>/dev/null); do
87
+ case "$v" in GIT_CONFIG|GIT_CONFIG_PARAMETERS|GIT_CONFIG_COUNT) ;; *) unset "$v" ;; esac
88
+ done
89
+
84
90
  # Gate 3 — inside git repo
85
91
  git rev-parse --git-dir >/dev/null 2>&1 || exit 0
86
92
 
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env node
2
+ // gsd-hook-version: {{GSD_VERSION}}
3
+ // SessionStart hook: put the installed gsd-core `bin/` directory (which holds `gsd_run`) on the
4
+ // PATH of every later Bash tool call in this Claude Code session (#5465).
5
+ //
6
+ // Why a hook: the resolver snippet (gsd-core/workflows/_runtime-launcher.snippet.sh) persists its
7
+ // PATH export through $CLAUDE_ENV_FILE, but Claude Code provides that variable only to SessionStart,
8
+ // Setup, CwdChanged and FileChanged hooks, never to a workflow Bash block. So the write never fired
9
+ // and a later block that called `gsd_run` without re-including the resolver got "command not found"
10
+ // (hidden by `2>/dev/null || echo <fallback>`). This hook writes the same line from the one place
11
+ // the variable exists. The snippet's in-block write stays for users who export the variable
12
+ // themselves.
13
+ //
14
+ // Fail-open and side-effect-minimal by design: it only ever appends one line to the file Claude Code
15
+ // hands to SessionStart hooks, only for a directory that contains an installed `gsd_run`, and any
16
+ // problem (no variable, no gsd_run, unwritable file) exits 0 without output.
17
+
18
+ 'use strict';
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+ const { allow } = require('./lib/hook-exit.js');
23
+
24
+ /**
25
+ * The `bin/` directory next to this hook's install root: hooks live in `<root>/hooks/` and the
26
+ * runtime in `<root>/gsd-core/` for global, project-local and plugin layouts alike. Returns the
27
+ * real path, or null when `gsd_run` is not there.
28
+ */
29
+ function resolveBinDir(hookDir) {
30
+ try {
31
+ const bin = fs.realpathSync(path.join(hookDir, '..', 'gsd-core', 'bin'));
32
+ return fs.statSync(path.join(bin, 'gsd_run')).isFile() ? bin : null;
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * The directory as the bash that sources $CLAUDE_ENV_FILE spells it. On Windows the env file is
40
+ * sourced by Git Bash, where `:` separates PATH entries and a drive letter is `/c/...`, which is
41
+ * also what the resolver snippet writes (it derives the directory from bash's `$GSD_TOOLS`). A
42
+ * native `C:\Users\x\...` would split at the colon and never resolve.
43
+ */
44
+ function shellPath(dir, platform = process.platform) {
45
+ if (platform !== 'win32') return dir;
46
+ const m = /^([A-Za-z]):[\\/](.*)$/.exec(dir);
47
+ const rest = (m ? m[2] : dir.replace(/^[\\/]+/, '')).replace(/\\/g, '/');
48
+ return m ? `/${m[1].toLowerCase()}/${rest}` : dir.replace(/\\/g, '/');
49
+ }
50
+
51
+ /** The exact line the resolver snippet writes: `export PATH='<dir>':"$PATH"` with `'` escaped. */
52
+ function exportLine(binDir, platform = process.platform) {
53
+ return `export PATH='${shellPath(binDir, platform).replace(/'/g, "'\\''")}':"$PATH"`;
54
+ }
55
+
56
+ /**
57
+ * Append the export to `envFile` unless it is already there. Returns true when it wrote.
58
+ * Never throws.
59
+ */
60
+ function persistPath(envFile, binDir) {
61
+ try {
62
+ const line = exportLine(binDir);
63
+ let existing = '';
64
+ try {
65
+ existing = fs.readFileSync(envFile, 'utf8');
66
+ } catch (e) {
67
+ if (e && e.code !== 'ENOENT') return false;
68
+ }
69
+ if (existing.split(/\r?\n/).includes(line)) return false;
70
+ const prefix = existing === '' || existing.endsWith('\n') ? '' : '\n';
71
+ fs.appendFileSync(envFile, `${prefix}${line}\n`, 'utf8');
72
+ return true;
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
77
+
78
+ function main() {
79
+ const envFile = process.env.CLAUDE_ENV_FILE;
80
+ if (typeof envFile !== 'string' || envFile.trim() === '') return;
81
+ const binDir = resolveBinDir(__dirname);
82
+ if (binDir === null) return;
83
+ persistPath(envFile, binDir);
84
+ }
85
+
86
+ if (require.main === module) {
87
+ try {
88
+ main();
89
+ } catch {
90
+ // A PATH convenience must never fail session start.
91
+ }
92
+ allow();
93
+ }
94
+
95
+ module.exports = { resolveBinDir, shellPath, exportLine, persistPath };
@@ -56,7 +56,9 @@
56
56
  // EOF)"` — is never operand-checked, so prose mentioning `.env` is safe.
57
57
  // pass 2 groups tokens by segment and evaluates each on its own:
58
58
  // input redirect targets (`<`, `N<`) are always checked; the command word is
59
- // located past `sudo`/`env VAR=x`/`nohup`-style prefixes; a closed set of
59
+ // located past wrapper prefixes AND their own options (`sudo -u U`,
60
+ // `env -i VAR=x`, `timeout -s KILL 5`, `nice -n 5`, `stdbuf -oL`, `exec`;
61
+ // see WRAPPERS, #5627); a closed set of
60
62
  // NON-READING commands (test/[/ls/stat/rm/touch/echo/…) exempts that
61
63
  // segment's operands — `[ -f .env ]` and `ls .env*` are existence checks
62
64
  // GSD's own agents run — while `cp`/`mv`/`ln`/`git` are deliberately NOT
@@ -90,7 +92,9 @@
90
92
  // (`cat .e*`), interpreter one-liners (`python -c "open('.env')"`), a piped
91
93
  // script from a non-echo source (`cat gen.sh | bash`, `curl … | sh`), reads
92
94
  // inside scripts the agent executes, and `glob: '*'` reaching a
93
- // NON-gitignored `.env`. The promise is "no looser than the retired rules
95
+ // NON-gitignored `.env`, and a wrapper (or wrapper option taking a
96
+ // separate value) the WRAPPERS table does not list, or `env -S 'script'` (the split string is skipped as
97
+ // the option's value). The promise is "no looser than the retired rules
94
98
  // on plain commands, without arming the compound-`cd` prompt". Writes to
95
99
  // secret files are out of scope (Write/Edit were never gated). Commands
96
100
  // over 1 MiB are denied outright (`command-too-large`) rather than
@@ -148,8 +152,29 @@ const MAX_GLOB_ALTERNATIVES = 64;
148
152
  const NON_SECRET_ENV_SUFFIXES = new Set(['example', 'sample', 'template', 'dist']);
149
153
 
150
154
  // Command-prefix wrappers to look through when locating the command word at
151
- // the head of a segment (same set as hooks/gsd-windsurf-pre-command.js).
152
- const CMD_PREFIXES = new Set(['sudo', 'env', 'command', 'nice', 'nohup', 'time', 'doas']);
155
+ // the head of a segment (#5627). Each wrapper runs the words that follow its
156
+ // own options, so the table records what resolveCommand must step over:
157
+ // short letters of short options that take a value (`-n 5`, `-n5`)
158
+ // long long options that take a SEPARATE value word (`--adjustment 5`);
159
+ // `--opt=value` is one word and needs no entry
160
+ // assigns leading `VAR=value` words belong to the wrapper (env)
161
+ // operands positional words after the options (timeout's DURATION)
162
+ // Taken from sudo(8), doas(1), env(1), nice(1), time(1), timeout(1),
163
+ // stdbuf(1), setsid(1) and bash's `command`/`exec`. A wrong entry fails open
164
+ // (the option's value becomes the command word), so keep it to the man pages.
165
+ const WRAPPERS = new Map([
166
+ ['sudo', { short: 'CDghpRrTtUu', long: ['--close-from', '--chdir', '--group', '--host', '--prompt', '--chroot', '--role', '--command-timeout', '--type', '--other-user', '--user'] }],
167
+ ['doas', { short: 'Cu' }],
168
+ ['env', { short: 'uCSaP', long: ['--unset', '--chdir', '--split-string', '--argv0'], assigns: true }],
169
+ ['nice', { short: 'n', long: ['--adjustment'] }],
170
+ ['nohup', {}],
171
+ ['command', {}],
172
+ ['time', { short: 'fo', long: ['--format', '--output'] }],
173
+ ['timeout', { short: 'sk', long: ['--signal', '--kill-after'], operands: 1 }],
174
+ ['stdbuf', { short: 'ioe', long: ['--input', '--output', '--error'] }],
175
+ ['setsid', {}],
176
+ ['exec', { short: 'a' }],
177
+ ]);
153
178
 
154
179
  // Commands whose ordinary operands are file NAMES, never file CONTENTS. A
155
180
  // closed set on purpose: anything not listed is assumed to read.
@@ -405,6 +430,20 @@ function classifyGrepGlob(glob) {
405
430
  // Bash command scan — pass 1: tokenizer
406
431
  // ---------------------------------------------------------------------------
407
432
 
433
+ // Which reading of a heredoc the scan is making (#5513). The guard scans a command that
434
+ // opens a heredoc under every reading below and denies if any of them finds a secret read
435
+ // (see findSecretReadUnderOtherReadings), so a change of reading can add a denial but
436
+ // never remove one.
437
+ // - heredocBodiesSkippedInSpans: bash 5 skips the bodies of heredocs opened inside a
438
+ // `$( )` span when it looks for the closing `)`; bash 3.2.57 (still `/bin/bash` on
439
+ // macOS) does not, so a `)` on a body line closes the substitution and the rest of
440
+ // that line runs as the OUTER command.
441
+ // - heredocTagsAsWords: an unquoted tag is a bash word (a CR and a substitution belong
442
+ // to it). false is the earlier reading, where the tag stopped at any whitespace and at
443
+ // `(`: kept as a reading so nothing the guard denied before is allowed now.
444
+ let heredocBodiesSkippedInSpans = true;
445
+ let heredocTagsAsWords = true;
446
+
408
447
  // Index of the `)` closing a `$(` / `<(` / `>(` opened just before `i`, or
409
448
  // str.length when unterminated. Quote- and heredoc-aware so a `)` inside a
410
449
  // quoted string or a heredoc body never closes the span early.
@@ -439,7 +478,7 @@ function findParenClose(str, i) {
439
478
  i = j === -1 ? str.length : j + 1;
440
479
  continue;
441
480
  }
442
- if (ch === '<' && str[i + 1] === '<' && str[i + 2] !== '<') {
481
+ if (heredocBodiesSkippedInSpans && ch === '<' && str[i + 1] === '<' && str[i + 2] !== '<') {
443
482
  const tag = readHeredocTag(str, i + 2);
444
483
  heredocTags.push(tag);
445
484
  i = tag.end;
@@ -475,7 +514,34 @@ function readHeredocTag(str, i) {
475
514
  i = j === -1 ? str.length : j + 1;
476
515
  } else {
477
516
  if (str[i] === '\\') { quoted = true; i++; }
478
- while (i < str.length && !/[\s;&|<>()]/.test(str[i])) tag += str[i++];
517
+ // #5513: an unquoted tag is a bash WORD. It ends only at a metacharacter (space, tab,
518
+ // newline, `;&|<>()`): a carriage return is not one, so `EOF` + CR is the tag and the
519
+ // CRLF terminator line matches it; and a substitution inside the word belongs to it,
520
+ // taken literally (`E$(x)F` waits for the line `E$(x)F`).
521
+ while (i < str.length && !(heredocTagsAsWords ? /[ \t\n;&|<>()]/ : /[\s;&|<>()]/).test(str[i])) {
522
+ if (!heredocTagsAsWords) { tag += str[i++]; continue; }
523
+ if (str[i] === '$' && str[i + 1] === '(') {
524
+ const e = findParenClose(str, i + 2);
525
+ tag += str.slice(i, e + 1);
526
+ i = e + 1;
527
+ continue;
528
+ }
529
+ if (str[i] === '$' && str[i + 1] === '{') {
530
+ const j = str.indexOf('}', i);
531
+ const e = j === -1 ? str.length - 1 : j;
532
+ tag += str.slice(i, e + 1);
533
+ i = e + 1;
534
+ continue;
535
+ }
536
+ if (str[i] === '`') {
537
+ const j = str.indexOf('`', i + 1);
538
+ const e = j === -1 ? str.length - 1 : j;
539
+ tag += str.slice(i, e + 1);
540
+ i = e + 1;
541
+ continue;
542
+ }
543
+ tag += str[i++];
544
+ }
479
545
  }
480
546
  return { tag, quoted, stripTabs, end: i };
481
547
  }
@@ -621,6 +687,7 @@ function tokenize(str) {
621
687
  hasWord = true;
622
688
  const e = findParenClose(str, i + 2);
623
689
  nested.push(str.slice(i + 2, e));
690
+ if (expectTag && heredocTagsAsWords) buf += str.slice(i, e + 1); // #5513: the tag is taken literally
624
691
  i = e + 1;
625
692
  continue;
626
693
  }
@@ -639,6 +706,7 @@ function tokenize(str) {
639
706
  const j = str.indexOf('`', i + 1);
640
707
  const e = j === -1 ? str.length : j;
641
708
  nested.push(str.slice(i + 1, e));
709
+ if (expectTag && heredocTagsAsWords) buf += str.slice(i, e + 1); // #5513: the tag is taken literally
642
710
  i = e + 1;
643
711
  continue;
644
712
  }
@@ -673,6 +741,14 @@ function tokenize(str) {
673
741
  continue;
674
742
  }
675
743
 
744
+ // #5513: a CR is not a bash metacharacter, so inside a heredoc tag it is part of the word
745
+ // (a CRLF command's terminator line is `EOF` + CR).
746
+ if (ch === '\r' && expectTag && hasWord && heredocTagsAsWords) {
747
+ buf += ch;
748
+ i++;
749
+ continue;
750
+ }
751
+
676
752
  if (ch === ' ' || ch === '\t' || ch === '\r') {
677
753
  flush();
678
754
  i++;
@@ -769,17 +845,33 @@ function tokenize(str) {
769
845
  // Bash command scan — pass 2: per-segment evaluation
770
846
  // ---------------------------------------------------------------------------
771
847
 
772
- // `@file` (curl -d), `--flag=value`, `-Xvalue` → the operand that names the file.
773
- function normalizeOperand(text) {
848
+ // Option characters of a bundled short cluster (`-2nif`).
849
+ const CLUSTER_FLAG_CHAR_RE = /[A-Za-z0-9]/;
850
+
851
+ // True when an operand word names a secret file. `@file` (curl -d) and
852
+ // `--flag=value` are stripped to the value. In a single-dash word
853
+ // (`/^-[^-]./`, so `-2if.env` counts too) the value of a bundled cluster
854
+ // starts right after its run of option characters (#5046): `-if.env` holds
855
+ // `.env`, `-nf=.env` holds `.env` after the `=`. That value is checked as
856
+ // well as the historical two-character strip, so the verdict is never looser
857
+ // than base and costs a fixed number of `namesSecret` calls (at most three,
858
+ // whatever the word's length). The option run also swallows a value's leading
859
+ // letters and digits, so `-fapp.env` is denied although `cat app.env` is not:
860
+ // a fail-safe false positive, pinned in tests.
861
+ function operandNamesSecret(text) {
774
862
  let v = text;
775
863
  if (v.startsWith('@')) v = v.slice(1);
776
864
  if (v.startsWith('--')) {
777
865
  const eq = v.indexOf('=');
778
866
  if (eq !== -1) v = v.slice(eq + 1);
779
- } else if (/^-[A-Za-z]./.test(v)) {
780
- v = v.slice(2);
867
+ return namesSecret(v);
781
868
  }
782
- return v;
869
+ if (!/^-[^-]./.test(v)) return namesSecret(v);
870
+ if (namesSecret(v.slice(2))) return true;
871
+ let end = 1;
872
+ while (end < v.length && CLUSTER_FLAG_CHAR_RE.test(v[end])) end++;
873
+ if (end > 2 && namesSecret(v.slice(end))) return true;
874
+ return v[end] === '=' && namesSecret(v.slice(end + 1));
783
875
  }
784
876
 
785
877
  const ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;
@@ -789,18 +881,55 @@ const DASH_C_RE = /^-[A-Za-z]*c$/;
789
881
 
790
882
  const GROUPING_SEPS = new Set(['(', ')', '{', '}']);
791
883
 
884
+ // How many words the option `word` occupies for wrapper `spec`: 2 when it
885
+ // takes a separate value word, else 1.
886
+ function wrapperOptionWidth(word, spec) {
887
+ if (word.startsWith('--')) {
888
+ if (word.includes('=')) return 1;
889
+ // getopt_long accepts any unambiguous prefix (`--adj 5`).
890
+ return word.length > 2 && (spec.long || []).some((l) => l.startsWith(word)) ? 2 : 1;
891
+ }
892
+ const valued = spec.short || '';
893
+ for (let i = 1; i < word.length; i++) {
894
+ if (valued.includes(word[i])) return i === word.length - 1 ? 2 : 1; // `-n5` carries its value
895
+ }
896
+ return 1;
897
+ }
898
+
899
+ // Index of the first word after a wrapper's own options, assignments and
900
+ // positional operands (`-n 5`, `-i A=1`, `-s KILL 5`, `--`).
901
+ function skipWrapperArgs(words, start, spec) {
902
+ let idx = start;
903
+ let optionsEnded = false;
904
+ while (idx < words.length) {
905
+ const text = words[idx].text;
906
+ if (spec.assigns && ASSIGNMENT_RE.test(text)) {
907
+ idx++;
908
+ } else if (!optionsEnded && text === '--') {
909
+ optionsEnded = true;
910
+ idx++;
911
+ } else if (!optionsEnded && text.length > 1 && text.startsWith('-')) {
912
+ idx += wrapperOptionWidth(text, spec);
913
+ } else {
914
+ break;
915
+ }
916
+ }
917
+ if (!spec.operands) return idx;
918
+ idx = Math.min(idx + spec.operands, words.length);
919
+ // `timeout 5 -- cmd`: some getopt builds still accept `--` after DURATION.
920
+ return idx < words.length && words[idx].text === '--' ? idx + 1 : idx;
921
+ }
922
+
792
923
  // Command base + operands after leading `VAR=val` assignments and prefix
793
- // wrappers (`sudo`, `env VAR=x`, …), or null when nothing but prefixes remain.
924
+ // wrappers with their own options (`sudo -u root`, `env -i VAR=x`,
925
+ // `timeout -s KILL 5`, `nice -n 5`, …), or null when nothing but prefixes remain.
794
926
  function resolveCommand(words) {
795
927
  let idx = 0;
796
928
  while (idx < words.length && ASSIGNMENT_RE.test(words[idx].text)) idx++;
797
929
  while (idx < words.length) {
798
- const base = lastSegment(words[idx].text).toLowerCase();
799
- if (!CMD_PREFIXES.has(base)) break;
800
- idx++;
801
- if (base === 'env') {
802
- while (idx < words.length && ASSIGNMENT_RE.test(words[idx].text)) idx++;
803
- }
930
+ const spec = WRAPPERS.get(lastSegment(words[idx].text).toLowerCase());
931
+ if (!spec) break;
932
+ idx = skipWrapperArgs(words, idx + 1, spec);
804
933
  }
805
934
  if (idx >= words.length) return null;
806
935
  return { base: lastSegment(words[idx].text).toLowerCase(), operands: words.slice(idx + 1) };
@@ -962,6 +1091,26 @@ function precedingOp(s, bySeg, sepAfter) {
962
1091
  return { op, prevSeg: p };
963
1092
  }
964
1093
 
1094
+ // #5513: the same scan under the other readings of a heredoc: bash 3.2.57's `$( )` span
1095
+ // (bodies not skipped) and the earlier tag reading (tag ends at any whitespace). Only a
1096
+ // command that opens a heredoc can read differently, so the extra passes are skipped for
1097
+ // every other command.
1098
+ function findSecretReadUnderOtherReadings(command) {
1099
+ if (!command.includes('<<')) return null;
1100
+ for (const [bodiesSkipped, tagsAsWords] of [[false, true], [true, false]]) {
1101
+ heredocBodiesSkippedInSpans = bodiesSkipped;
1102
+ heredocTagsAsWords = tagsAsWords;
1103
+ try {
1104
+ const hit = findSecretRead(command, 0);
1105
+ if (hit) return hit;
1106
+ } finally {
1107
+ heredocBodiesSkippedInSpans = true;
1108
+ heredocTagsAsWords = true;
1109
+ }
1110
+ }
1111
+ return null;
1112
+ }
1113
+
965
1114
  // Returns the offending token text, or null.
966
1115
  function findSecretRead(command, depth) {
967
1116
  const { tokens, nested } = tokenize(command);
@@ -1040,7 +1189,7 @@ function findSecretRead(command, depth) {
1040
1189
  const hit = findSecretRead(src, depth + 1);
1041
1190
  if (hit) return hit;
1042
1191
  }
1043
- } else if (namesSecret(normalizeOperand(w.text))) return w.text;
1192
+ } else if (operandNamesSecret(w.text)) return w.text;
1044
1193
  }
1045
1194
  continue;
1046
1195
  }
@@ -1070,7 +1219,7 @@ function findSecretRead(command, depth) {
1070
1219
  const nameOnly = nameOnlyOperandIndices(base, operands);
1071
1220
  for (let k = 0; k < operands.length; k++) {
1072
1221
  if (nameOnly.has(k)) continue;
1073
- if (namesSecret(normalizeOperand(operands[k].text))) return operands[k].text;
1222
+ if (operandNamesSecret(operands[k].text)) return operands[k].text;
1074
1223
  }
1075
1224
  }
1076
1225
  return null;
@@ -1088,21 +1237,6 @@ function isDebuggerAgent(value) {
1088
1237
  return value.slice(value.lastIndexOf(':') + 1) === DEBUGGER_AGENT_NAME;
1089
1238
  }
1090
1239
 
1091
- // `timeout [flags] DURATION cmd …` is not in CMD_PREFIXES because its first
1092
- // operand is a duration, not the command. Returns the command that follows it.
1093
- function unwrapTimeout(cmd) {
1094
- let { base, operands } = cmd;
1095
- while (base === 'timeout') {
1096
- let k = 0;
1097
- while (k < operands.length && operands[k].text.startsWith('-')) k++;
1098
- k++; // the DURATION operand
1099
- const rest = resolveCommand(operands.slice(k));
1100
- if (!rest) return null;
1101
- ({ base, operands } = rest);
1102
- }
1103
- return { base, operands };
1104
- }
1105
-
1106
1240
  // Returns the `--agent` value text of a debugger launch, or null.
1107
1241
  function findDebuggerCliSpawn(command, depth) {
1108
1242
  const { tokens, nested } = tokenize(command);
@@ -1134,8 +1268,7 @@ function findDebuggerCliSpawn(command, depth) {
1134
1268
  }
1135
1269
  if (!words.length) continue;
1136
1270
 
1137
- const first = resolveCommand(words);
1138
- const cmd = first && unwrapTimeout(first);
1271
+ const cmd = resolveCommand(words);
1139
1272
  if (!cmd) continue;
1140
1273
  const { base, operands } = cmd;
1141
1274
 
@@ -1248,7 +1381,7 @@ function scanXargsPipe(operands, s, bySeg, sepAfter, depth) {
1248
1381
  const prevCmd = resolveCommand((bySeg.get(prevSeg) || []).filter((t) => t.kind === 'word'));
1249
1382
  if (!prevCmd) return null;
1250
1383
  for (const w of prevCmd.operands) {
1251
- if (namesSecret(normalizeOperand(w.text))) return w.text;
1384
+ if (operandNamesSecret(w.text)) return w.text;
1252
1385
  }
1253
1386
  return null;
1254
1387
  }
@@ -1423,7 +1556,7 @@ process.stdin.on('end', () => {
1423
1556
  const command = typeof data.tool_input.command === 'string' ? data.tool_input.command : '';
1424
1557
  if (command === '') allow(undefined);
1425
1558
  if (command.length > MAX_COMMAND_LENGTH) emitBlock('command-too-large', tool, '');
1426
- const hit = findSecretRead(command, 0);
1559
+ const hit = findSecretRead(command, 0) || findSecretReadUnderOtherReadings(command);
1427
1560
  if (hit !== null) emitBlock('secret-read', tool, hit);
1428
1561
  const spawn = findDebuggerCliSpawn(command, 0);
1429
1562
  if (spawn !== null) emitBlock('debugger-cli-spawn', tool, spawn);