session-orchestrator 5.11.0 → 5.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (394) hide show
  1. package/.claude-plugin/marketplace.json +12 -1
  2. package/.claude-plugin/plugin.json +4 -1
  3. package/.codex-plugin/agents/explorer.toml +1 -1
  4. package/.codex-plugin/agents/session-reviewer.toml +2 -2
  5. package/.codex-plugin/agents/wave-worker.toml +1 -1
  6. package/.codex-plugin/plugin.json +1 -1
  7. package/.cursor-plugin/plugin.json +1 -1
  8. package/.orchestrator/policy/blocked-commands.json +8 -0
  9. package/.orchestrator/policy/public-export.json +30 -0
  10. package/CHANGELOG.md +180 -3
  11. package/README.md +126 -117
  12. package/agents/analyst.md +7 -2
  13. package/agents/architect-reviewer.md +7 -2
  14. package/agents/code-implementer.md +4 -2
  15. package/agents/db-specialist.md +9 -2
  16. package/agents/dialectic-deriver.md +9 -3
  17. package/agents/docs-writer.md +7 -2
  18. package/agents/eval-judge.md +7 -1
  19. package/agents/qa-strategist.md +7 -2
  20. package/agents/security-reviewer.md +9 -2
  21. package/agents/session-reviewer.md +18 -2
  22. package/agents/skill-applied-judge.md +7 -1
  23. package/agents/test-writer.md +9 -2
  24. package/agents/ui-developer.md +9 -2
  25. package/agents/ux-evaluator.md +6 -1
  26. package/commands/session.md +4 -2
  27. package/commands/templates-ack.md +89 -18
  28. package/docs/agent-authoring.md +30 -11
  29. package/docs/ci-setup.md +38 -2
  30. package/docs/codex-setup.md +33 -9
  31. package/docs/components.md +13 -2
  32. package/docs/cursor-setup.md +3 -3
  33. package/docs/events-schema.md +5 -4
  34. package/docs/evidence-candidates.md +63 -0
  35. package/docs/fleet-checkin-audit.md +7 -4
  36. package/docs/github-mirror-protection.md +150 -3
  37. package/docs/install.md +102 -25
  38. package/docs/instruction-delivery.md +1 -1
  39. package/docs/migration-v4.md +2 -2
  40. package/docs/mods.md +118 -0
  41. package/docs/owner-config-schema.md +1 -0
  42. package/docs/plugin-architecture-v3.md +1 -1
  43. package/docs/pm-skills-marketplace.md +4 -3
  44. package/docs/rule-authoring.md +24 -13
  45. package/docs/scope-collision-guard.md +2 -1
  46. package/docs/session-config-reference.md +55 -21
  47. package/docs/session-config-template.md +32 -24
  48. package/docs/sessions-ledger-repair.md +137 -0
  49. package/docs/vault-docs-architecture.md +1 -1
  50. package/hooks/_lib/hook-import-set.json +406 -73
  51. package/hooks/_lib/hook-root.mjs +301 -0
  52. package/hooks/_lib/lock-bootstrap.mjs +434 -4
  53. package/hooks/_lib/orchestrator-ignore.mjs +3 -0
  54. package/hooks/_lib/subagent-transcript.mjs +2 -2
  55. package/hooks/_lib/vcs-create-matcher.mjs +13 -17
  56. package/hooks/cwd-change-restore.mjs +5 -1
  57. package/hooks/enforce-commands.mjs +4 -3
  58. package/hooks/enforce-scope.mjs +11 -1
  59. package/hooks/hooks-codex.json +48 -2
  60. package/hooks/hooks.json +31 -31
  61. package/hooks/on-session-end.mjs +38 -15
  62. package/hooks/on-session-start.mjs +158 -59
  63. package/hooks/on-stop.mjs +11 -6
  64. package/hooks/post-bash-issue-budget-refund.mjs +6 -1
  65. package/hooks/post-bash-write-verify.mjs +8 -4
  66. package/hooks/post-edit-validate.mjs +45 -52
  67. package/hooks/post-subagent-discovery-validator.mjs +2 -2
  68. package/hooks/post-tool-batch-wave-signal.mjs +8 -4
  69. package/hooks/post-tool-failure-corrective-context.mjs +5 -1
  70. package/hooks/pre-bash-destructive-guard.mjs +220 -8
  71. package/hooks/pre-bash-issue-budget.mjs +6 -1
  72. package/hooks/pre-bash-sessions-ledger-guard.mjs +691 -123
  73. package/hooks/pre-bash-staging-fence.mjs +258 -14
  74. package/hooks/pre-bash-templates-first.mjs +28 -27
  75. package/hooks/run-node.sh +1 -1
  76. package/hooks/skill-invocation-telemetry.mjs +15 -8
  77. package/hooks/subagent-telemetry.mjs +77 -14
  78. package/hooks/wave-scope-commit-guard.mjs +143 -14
  79. package/mods/so-hud/.claude-plugin/plugin.json +11 -0
  80. package/mods/so-hud/README.md +57 -0
  81. package/mods/so-hud/hooks/beacon-io.mjs +101 -0
  82. package/mods/so-hud/hooks/heredoc-scan.ts +394 -0
  83. package/mods/so-hud/hooks/hooks.json +1 -0
  84. package/mods/so-hud/hooks/logic.ts +182 -0
  85. package/mods/so-hud/hooks/register.tsx +670 -0
  86. package/mods/so-hud/hooks/rules.ts +2998 -0
  87. package/mods/so-hud/tsconfig.json +8 -0
  88. package/mods/so-hud/types/index.d.ts +36 -0
  89. package/package.json +4 -3
  90. package/rules/_index.md +1 -1
  91. package/rules/always-on/ask-via-tool.md +1 -1
  92. package/rules/always-on/loop-and-monitor.md +17 -194
  93. package/rules/always-on/parallel-sessions.md +3 -1
  94. package/rules/opt-in-stack/backend-data.md +1 -1
  95. package/rules/opt-in-stack/backend.md +1 -1
  96. package/rules/opt-in-stack/security-web.md +5 -1
  97. package/scripts/agent-usage-census.mjs +79 -0
  98. package/scripts/apply-session-learnings.mjs +34 -6
  99. package/scripts/archive-closed-prds.mjs +21 -5
  100. package/scripts/autopilot.mjs +11 -5
  101. package/scripts/backfill-abandoned-sessions.mjs +13 -3
  102. package/scripts/backfill-evidence-digest.mjs +2 -1
  103. package/scripts/backfill-learnings-from-vault.mjs +61 -6
  104. package/scripts/backfill-sessions.mjs +16 -2
  105. package/scripts/check-package-manager.mjs +26 -8
  106. package/scripts/check-sessions-integrity.mjs +4 -1
  107. package/scripts/ci/assert-vitest-green.mjs +3 -0
  108. package/scripts/ci/windows-smoke.mjs +201 -0
  109. package/scripts/close-precheck.mjs +55 -0
  110. package/scripts/dialectic-deriver.mjs +20 -12
  111. package/scripts/emit-event.mjs +2 -2
  112. package/scripts/emit-session.mjs +239 -20
  113. package/scripts/eval-session.mjs +17 -3
  114. package/scripts/export-hw-learnings.mjs +29 -2
  115. package/scripts/fleet-checkin-audit.mjs +8 -4
  116. package/scripts/fleet-checkin.mjs +73 -3
  117. package/scripts/gc-stale-worktrees.mjs +6 -1
  118. package/scripts/github-export.mjs +128 -0
  119. package/scripts/github-protection-audit.mjs +3 -1
  120. package/scripts/handover-queue-check.mjs +209 -0
  121. package/scripts/harness-audit.mjs +6 -4
  122. package/scripts/lib/agent-frontmatter.mjs +57 -10
  123. package/scripts/lib/agent-status.mjs +125 -7
  124. package/scripts/lib/agent-usage-census.mjs +394 -0
  125. package/scripts/lib/auto-dialectic.mjs +17 -10
  126. package/scripts/lib/auto-dream.mjs +22 -14
  127. package/scripts/lib/autopilot/loop.mjs +3 -1
  128. package/scripts/lib/autopilot/recent-runs.mjs +4 -2
  129. package/scripts/lib/autopilot/stall-sampler.mjs +2 -1
  130. package/scripts/lib/autopilot/telemetry.mjs +5 -1
  131. package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
  132. package/scripts/lib/baseline-archetypes.mjs +10 -2
  133. package/scripts/lib/blocked-commands-policy.mjs +4 -3
  134. package/scripts/lib/build-live-signals.mjs +13 -9
  135. package/scripts/lib/ci-status-banner.mjs +329 -24
  136. package/scripts/lib/codex/plugin-contract.mjs +8 -0
  137. package/scripts/lib/cold-start-detector.mjs +5 -2
  138. package/scripts/lib/command-blocker.mjs +603 -43
  139. package/scripts/lib/common.mjs +3 -2
  140. package/scripts/lib/config/cross-repo.mjs +74 -7
  141. package/scripts/lib/config/host-paths.mjs +6 -2
  142. package/scripts/lib/config/remote-hosts.mjs +2 -2
  143. package/scripts/lib/config/section-extractor.mjs +65 -11
  144. package/scripts/lib/config/template-keys.mjs +153 -0
  145. package/scripts/lib/config/vault-integration.mjs +1 -0
  146. package/scripts/lib/config.mjs +3 -3
  147. package/scripts/lib/coordinator-snapshot.mjs +1 -1
  148. package/scripts/lib/discovery/triage-state.mjs +2 -1
  149. package/scripts/lib/dispatcher/rank.mjs +5 -7
  150. package/scripts/lib/ecosystem-health.mjs +2 -2
  151. package/scripts/lib/eval/engine.mjs +3 -2
  152. package/scripts/lib/eval/session-resolve.mjs +6 -2
  153. package/scripts/lib/eval/sink.mjs +4 -3
  154. package/scripts/lib/events-retention-banner.mjs +3 -2
  155. package/scripts/lib/events-schema.mjs +10 -0
  156. package/scripts/lib/events.mjs +23 -12
  157. package/scripts/lib/evolve/autonomy-verdict.mjs +4 -9
  158. package/scripts/lib/evolve/autopilot-effectiveness.mjs +8 -18
  159. package/scripts/lib/fleet-protocol.mjs +21 -0
  160. package/scripts/lib/gates/gate-full.mjs +172 -11
  161. package/scripts/lib/gates/gate-helpers.mjs +236 -23
  162. package/scripts/lib/git-config-drift.mjs +141 -7
  163. package/scripts/lib/github-export.mjs +447 -0
  164. package/scripts/lib/gitlab-portfolio/cli.mjs +16 -5
  165. package/scripts/lib/handover-queue-marks.mjs +159 -0
  166. package/scripts/lib/hardware-pattern-detector.mjs +103 -13
  167. package/scripts/lib/harness-audit/categories/category1.mjs +16 -4
  168. package/scripts/lib/harness-audit/categories/category4.mjs +11 -8
  169. package/scripts/lib/harness-audit/categories/category6.mjs +25 -5
  170. package/scripts/lib/harness-audit/categories/category9.mjs +10 -4
  171. package/scripts/lib/host-identity.mjs +9 -5
  172. package/scripts/lib/http-request.mjs +64 -0
  173. package/scripts/lib/instruction-budget-guard.mjs +30 -22
  174. package/scripts/lib/io.mjs +6 -3
  175. package/scripts/lib/issue-budget-reconcile.mjs +3 -2
  176. package/scripts/lib/issue-budget.mjs +36 -7
  177. package/scripts/lib/learnings/evidence-candidates.mjs +154 -0
  178. package/scripts/lib/learnings/expiry-sweep.mjs +99 -7
  179. package/scripts/lib/learnings/io.mjs +289 -15
  180. package/scripts/lib/learnings/select.mjs +53 -4
  181. package/scripts/lib/learnings/store-role.mjs +209 -0
  182. package/scripts/lib/learnings/surface.mjs +46 -7
  183. package/scripts/lib/ledger-path.mjs +113 -0
  184. package/scripts/lib/maintenance-due-banner.mjs +63 -12
  185. package/scripts/lib/memory-banner.mjs +5 -6
  186. package/scripts/lib/memory-proposals/collector.mjs +5 -2
  187. package/scripts/lib/memory-proposals/sink.mjs +7 -4
  188. package/scripts/lib/memory-proposals/store.mjs +22 -5
  189. package/scripts/lib/mode-selector-accuracy.mjs +2 -1
  190. package/scripts/lib/no-follow-open.mjs +57 -0
  191. package/scripts/lib/orphan-reaper/reaper-audit.mjs +4 -2
  192. package/scripts/lib/own-lock-root.mjs +95 -0
  193. package/scripts/lib/owner-interview.mjs +2 -1
  194. package/scripts/lib/owner-yaml.mjs +70 -7
  195. package/scripts/lib/platform.mjs +161 -9
  196. package/scripts/lib/plugin-update-banner.mjs +15 -11
  197. package/scripts/lib/pre-push-local-partial.mjs +117 -0
  198. package/scripts/lib/pre-push-remote-gate.mjs +151 -25
  199. package/scripts/lib/process-group.mjs +270 -34
  200. package/scripts/lib/project-hygiene.mjs +28 -0
  201. package/scripts/lib/quality-gate.mjs +33 -9
  202. package/scripts/lib/reconcile/backlog.mjs +29 -15
  203. package/scripts/lib/reconcile/engine.mjs +26 -2
  204. package/scripts/lib/reconcile/idempotency.mjs +65 -3
  205. package/scripts/lib/reconcile/rule-expiry-sweep.mjs +53 -12
  206. package/scripts/lib/reconcile/verdict-ledger.mjs +224 -0
  207. package/scripts/lib/reconcile-nudge-banner.mjs +40 -6
  208. package/scripts/lib/resource-probe/evaluate.mjs +32 -9
  209. package/scripts/lib/resource-probe/probe-platform.mjs +3 -1
  210. package/scripts/lib/rule-loader.mjs +20 -10
  211. package/scripts/lib/scope-baseline.mjs +123 -8
  212. package/scripts/lib/scope-echo.mjs +5 -2
  213. package/scripts/lib/scope-gate.mjs +55 -20
  214. package/scripts/lib/session-close-backfill.mjs +75 -7
  215. package/scripts/lib/session-end/close-precheck.mjs +151 -0
  216. package/scripts/lib/session-end/phase-skip.mjs +8 -7
  217. package/scripts/lib/session-end/tail-runner.mjs +7 -2
  218. package/scripts/lib/session-end/vault-orphans.mjs +205 -0
  219. package/scripts/lib/session-id.mjs +9 -1
  220. package/scripts/lib/session-identity/own-session.mjs +63 -0
  221. package/scripts/lib/session-invocation.mjs +14 -3
  222. package/scripts/lib/session-lock.mjs +114 -0
  223. package/scripts/lib/session-record-repair.mjs +245 -35
  224. package/scripts/lib/session-registry.mjs +85 -12
  225. package/scripts/lib/session-schema/constants.mjs +9 -0
  226. package/scripts/lib/session-schema/filters.mjs +56 -1
  227. package/scripts/lib/session-schema/validator.mjs +15 -5
  228. package/scripts/lib/session-start-probes.mjs +149 -22
  229. package/scripts/lib/session-token-rollup.mjs +2 -1
  230. package/scripts/lib/session-work-trees.mjs +166 -0
  231. package/scripts/lib/sessions-canonical.mjs +61 -3
  232. package/scripts/lib/sessions-integrity-banner.mjs +17 -39
  233. package/scripts/lib/sessions-staleness-banner.mjs +8 -5
  234. package/scripts/lib/sh-quote.mjs +1 -0
  235. package/scripts/lib/skill-evidence-window.mjs +5 -1
  236. package/scripts/lib/skill-health/join.mjs +10 -9
  237. package/scripts/lib/skill-invocations-schema.mjs +13 -0
  238. package/scripts/lib/skill-judgments-schema.mjs +6 -2
  239. package/scripts/lib/snapshot-probe.mjs +414 -0
  240. package/scripts/lib/so-hud/collect.mjs +174 -0
  241. package/scripts/lib/so-hud/runtime.mjs +87 -0
  242. package/scripts/lib/spiral-carryover.mjs +8 -1
  243. package/scripts/lib/state-md/frontmatter-mutators.mjs +266 -8
  244. package/scripts/lib/state-md/idle-reset.mjs +248 -0
  245. package/scripts/lib/state-md/mission-status.mjs +5 -1
  246. package/scripts/lib/state-md/recommendations.mjs +26 -4
  247. package/scripts/lib/state-md-peer-guard.mjs +2 -0
  248. package/scripts/lib/state-md.mjs +31 -8
  249. package/scripts/lib/sunset/walker.mjs +4 -3
  250. package/scripts/lib/telemetry/consent.mjs +4 -2
  251. package/scripts/lib/telemetry/queue.mjs +3 -3
  252. package/scripts/lib/telemetry/sync.mjs +45 -9
  253. package/scripts/lib/test-runner/artifact-paths.mjs +10 -2
  254. package/scripts/lib/tmux-layout/layouts.mjs +20 -3
  255. package/scripts/lib/ux-grill/paths.mjs +5 -1
  256. package/scripts/lib/validate/check-agents.mjs +37 -6
  257. package/scripts/lib/validate/check-component-paths.mjs +25 -1
  258. package/scripts/lib/validate/check-guard-requires-parity.mjs +3 -3
  259. package/scripts/lib/validate/check-hooks-symmetry.mjs +53 -32
  260. package/scripts/lib/validate/check-json-files.mjs +3 -1
  261. package/scripts/lib/validate/check-learning-provenance.mjs +3 -2
  262. package/scripts/lib/validate/check-learnings-shell-writes.mjs +10 -4
  263. package/scripts/lib/validate/check-metrics-ledger-root.mjs +234 -0
  264. package/scripts/lib/validate/check-owner-leakage.mjs +159 -11
  265. package/scripts/lib/validate/check-test-posix-shell.mjs +246 -0
  266. package/scripts/lib/validate/check-unwired-features.mjs +4 -2
  267. package/scripts/lib/validate/check-vcs-repo-flag-positional.mjs +58 -0
  268. package/scripts/lib/validate/check-vcs-repo-flag.mjs +259 -73
  269. package/scripts/lib/validate/check-workflow-continue-on-error.mjs +63 -0
  270. package/scripts/lib/validate/confidential-names.mjs +3 -1
  271. package/scripts/lib/vault-mirror/migrate-ids.mjs +321 -0
  272. package/scripts/lib/vault-mirror/namespace.mjs +22 -9
  273. package/scripts/lib/vault-mirror/process.mjs +197 -65
  274. package/scripts/lib/vault-mirror/prune.mjs +5 -2
  275. package/scripts/lib/vault-mirror/render-learnings.mjs +8 -7
  276. package/scripts/lib/vault-mirror/render-sessions.mjs +36 -53
  277. package/scripts/lib/vault-mirror/retention.mjs +2 -2
  278. package/scripts/lib/vault-mirror/utils.mjs +106 -13
  279. package/scripts/lib/vault-staleness-banner.mjs +4 -7
  280. package/scripts/lib/vault-status/board-writer.mjs +34 -5
  281. package/scripts/lib/vault-status/narrative-mirror.mjs +66 -7
  282. package/scripts/lib/wave-executor/foreign-dispatch.mjs +20 -0
  283. package/scripts/lib/win-process-tree.mjs +137 -0
  284. package/scripts/lib/workspace.mjs +1 -1
  285. package/scripts/lib/worktree/constants.mjs +1 -1
  286. package/scripts/lib/worktree/lifecycle.mjs +1 -1
  287. package/scripts/lib/worktree/listing.mjs +1 -1
  288. package/scripts/lib/zx.mjs +50 -0
  289. package/scripts/mcp-server.mjs +16 -7
  290. package/scripts/mcp-server.sh +13 -83
  291. package/scripts/measure-policy-cache-effectiveness.mjs +7 -5
  292. package/scripts/memory-propose.mjs +10 -3
  293. package/scripts/migrate-cold-start-seed.mjs +7 -0
  294. package/scripts/migrate-legacy-learnings.sh +22 -0
  295. package/scripts/migrate-sessions-jsonl.mjs +16 -2
  296. package/scripts/migrate-vault-paths.mjs +29 -13
  297. package/scripts/parse-config.mjs +71 -14
  298. package/scripts/print-learnings-index.mjs +25 -2
  299. package/scripts/release-session-lock.mjs +94 -7
  300. package/scripts/release.mjs +451 -24
  301. package/scripts/relocate-vault-corpus.mjs +11 -7
  302. package/scripts/repair-invalid-sessions.mjs +68 -3
  303. package/scripts/resolve-session-invocation.mjs +57 -8
  304. package/scripts/run-migrate-v2-cross-repo.mjs +56 -1
  305. package/scripts/run-quality-gate.mjs +33 -7
  306. package/scripts/self-update.mjs +56 -7
  307. package/scripts/session-commit-files.mjs +133 -0
  308. package/scripts/site-numbers.mjs +52 -9
  309. package/scripts/snapshot-probe.mjs +156 -0
  310. package/scripts/so-hud.mjs +68 -0
  311. package/scripts/state-md-idle-reset.mjs +82 -0
  312. package/scripts/sweep-expired-learnings.mjs +54 -6
  313. package/scripts/sweep-expired-rules.mjs +4 -1
  314. package/scripts/validate-plugin.mjs +22 -0
  315. package/scripts/vault-integration-watcher.mjs +54 -6
  316. package/scripts/vault-mirror.mjs +9 -0
  317. package/scripts/vault-orphans.mjs +171 -0
  318. package/skills/_shared/fleet-protocol.md +166 -3
  319. package/skills/_shared/instruction-file-resolution.md +1 -1
  320. package/skills/_shared/monitor-patterns.md +44 -25
  321. package/skills/_shared/platform-tools.md +7 -0
  322. package/skills/_shared/state-ownership.md +5 -4
  323. package/skills/architecture/references/domain-model.md +1 -1
  324. package/skills/autopilot/SKILL.md +3 -2
  325. package/skills/bootstrap/SKILL.md +3 -1
  326. package/skills/bootstrap/_shared-template.md +4 -4
  327. package/skills/bootstrap/fast-template.md +1 -1
  328. package/skills/bootstrap/private-contract.md +1 -1
  329. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +17 -9
  330. package/skills/bootstrap/templates/agents/project-code-review.md +5 -1
  331. package/skills/bootstrap/templates/agents/project-discovery.md +5 -1
  332. package/skills/bootstrap/templates/agents/project-quality-gate.md +5 -1
  333. package/skills/claude-md-drift-check/SKILL.md +3 -3
  334. package/skills/claude-md-drift-check/checker.mjs +61 -101
  335. package/skills/close/SKILL.md +20 -7
  336. package/skills/discovery/probes/docs-staleness.mjs +7 -5
  337. package/skills/discovery/probes/frontend-slop.mjs +7 -3
  338. package/skills/discovery/probes/ssot-code-diff.mjs +5 -3
  339. package/skills/discovery/probes/supply-chain-slopcheck.mjs +10 -8
  340. package/skills/discovery/probes/vault-narrative-staleness.mjs +30 -10
  341. package/skills/discovery/probes/vault-staleness.mjs +5 -3
  342. package/skills/discovery/probes-session.md +3 -0
  343. package/skills/discovery/probes-vault.md +2 -0
  344. package/skills/dispatcher/SKILL.md +1 -1
  345. package/skills/eval/SKILL.md +3 -1
  346. package/skills/evolve/SKILL.md +2 -0
  347. package/skills/evolve/references/evolve-analyze-mode.md +3 -3
  348. package/skills/evolve/references/evolve-dialectic-mode.md +1 -1
  349. package/skills/gitlab-ops/SKILL.md +7 -6
  350. package/skills/gitlab-portfolio/SKILL.md +2 -2
  351. package/skills/go/SKILL.md +13 -1
  352. package/skills/npm-publish/SKILL.md +5 -2
  353. package/skills/plan/SKILL.md +23 -10
  354. package/skills/plan/mode-feature.md +13 -8
  355. package/skills/plan/mode-new.md +6 -14
  356. package/skills/plan/prd-feature-template.md +19 -0
  357. package/skills/plan/prd-full-template.md +12 -5
  358. package/skills/plan/prd-premortem-panel.md +48 -0
  359. package/skills/plan/prd-reviewer-prompt.md +11 -3
  360. package/skills/plan/soul.md +1 -1
  361. package/skills/portfolio/SKILL.md +5 -6
  362. package/skills/quality-gates/SKILL.md +2 -4
  363. package/skills/reconcile/SKILL.md +27 -4
  364. package/skills/release/SKILL.md +16 -8
  365. package/skills/session-end/SKILL.md +97 -13
  366. package/skills/session-end/phase-3-2-docs-verification.md +1 -0
  367. package/skills/session-end/phase-3-6-tail.md +11 -7
  368. package/skills/session-end/phase-3-7a-recommendations.md +9 -2
  369. package/skills/session-end/plan-verification.md +16 -1
  370. package/skills/session-end/references/phase-3-7e-vault-orphans.md +63 -0
  371. package/skills/session-end/references/phase-3-documentation-updates.md +25 -13
  372. package/skills/session-end/references/phase-5-issue-cleanup.md +1 -1
  373. package/skills/session-end/references/session-summary-template.md +8 -0
  374. package/skills/session-end/session-metrics-write.md +7 -4
  375. package/skills/session-plan/SKILL.md +5 -3
  376. package/skills/session-start/SKILL.md +10 -4
  377. package/skills/session-start/phase-4-5-resource-health.md +11 -5
  378. package/skills/session-start/phase-7-5-mode-selector.md +3 -1
  379. package/skills/session-start/phase-8-5-express-path.md +4 -3
  380. package/skills/session-start/references/phase-1-2-session-lock.md +7 -5
  381. package/skills/session-start/references/phase-1-5-session-continuity.md +13 -5
  382. package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
  383. package/skills/session-start/references/phase-4-ssot-environment-check.md +3 -2
  384. package/skills/tmux-layout/SKILL.md +2 -2
  385. package/skills/vault-sync/validator.mjs +3 -2
  386. package/skills/wave-executor/SKILL.md +5 -4
  387. package/skills/wave-executor/references/wave-executor-state-init.md +3 -3
  388. package/skills/wave-executor/references/wave-loop-dispatch.md +11 -5
  389. package/skills/wave-executor/references/wave-loop-review.md +13 -5
  390. package/skills/wave-executor/references/wave-loop-scope-manifest.md +1 -1
  391. package/AGENTS.md +0 -207
  392. package/assets/og-card.png +0 -0
  393. package/assets/og-card.svg +0 -47
  394. package/assets/wave-lifecycle.svg +0 -98
@@ -373,9 +373,94 @@ function readHeredocBody(command, from, delim, stripTabs) {
373
373
  * with it `rm -rf /tmp/x &> log` staying ALLOWED); the `;`/`|`/`&` branch runs
374
374
  * first and is never shadowed.
375
375
  *
376
+ * ## Subshells and command substitutions (#1667)
377
+ *
378
+ * Before #1667 `(`, `$(`, `)` and backticks were word characters, so
379
+ * `true && (rm -rf X)` resolved the verb `(rm` and every verb- or target-based
380
+ * check (path-delete denylist, rm allowlist, issue budget, staging fence)
381
+ * missed X. The ledger guard had fixed this in its own scanner (#1665); the
382
+ * shared lexer now carries it for every consumer:
383
+ * - An unquoted `(` at WORD START opens a subshell: it and its `)` become
384
+ * `{ text: ';', operator: 'subshell' }` separators, the body stays in place.
385
+ * - `$( … )`, backticks (unquoted AND inside double quotes) and process
386
+ * substitution `<( … )` / `>( … )` are WORDS whose body runs as a command:
387
+ * the outer word keeps the RAW source text (`$(pwd)/x` stays one operand,
388
+ * so no outer word boundary moves) and the body's tokens are HOISTED to the
389
+ * end of the stream behind a `{ text: ';', operator: 'substitution' }`.
390
+ * - Unchanged on purpose: `$((`/`((` arithmetic, a mid-word `(` (`f()`,
391
+ * `arr=(…)`, whose `)` stays word text), an unmatched `)`, the parens of a
392
+ * case HEAD (`(a)` patterns), quoted/escaped parens, `{`/`}` (brace groups
393
+ * were already split by COMMAND_POSITION_KEYWORDS), here-doc bodies.
394
+ * - A case pattern's closing `)` is followed by a `{ text: ';', operator:
395
+ * 'case-pattern' }` separator, so an arm's first command (`a) rm x;;`)
396
+ * heads its own segment; in an arm body `(` opens a subshell again.
397
+ * - Fail direction: an unclosed substitution is closed at end of input and
398
+ * STILL hoisted (verb visible). Past MAX_TOKENIZER_NESTING an unquoted
399
+ * opener degrades to a plain separator (verb visible); a double-quoted one
400
+ * stays literal text there.
401
+ *
376
402
  * @param {string} command
377
403
  * @returns {Array<{ text: string, quoted: boolean, redirect?: { fd: number|null, mode: string } }>}
378
404
  */
405
+ /** Unquoted chain-operator texts — a word after one of them is in command position. */
406
+ const CHAIN_OPERATOR_TEXTS = new Set([';', '&&', '||', '|', '&']);
407
+
408
+ /**
409
+ * Reserved words that are followed by a COMMAND (#1671): a word after one of
410
+ * them — itself in command position — is in command position too. The opener
411
+ * half of COMMAND_POSITION_KEYWORDS (closers like `fi`/`done` end a command, so
412
+ * `fi case` is no case) plus `time`, which bash parses as a pipeline prefix.
413
+ * `(` is absent on purpose: since #1667 the lexer emits it as a separator.
414
+ */
415
+ const COMMAND_POSITION_OPENERS = new Set([
416
+ '{', '!', 'if', 'elif', 'then', 'else', 'while', 'until', 'do', 'time', 'coproc',
417
+ ]);
418
+
419
+ /** A function-definition head `name()` — its body is a compound command. */
420
+ const FUNCTION_HEAD_RE = /^[^\s()$`'"]+\(\)$/;
421
+
422
+ /**
423
+ * Is the word at index `end` of `sink` (default: the NEXT word pushed) in
424
+ * command position (#1671)? Walks back over
425
+ * a run of unquoted opener keywords (`if ! case …`, `then { case …`) to the
426
+ * statement start: an operator/separator or the start of the sink. An opener
427
+ * that is itself an argument (`echo then case`) does not count. Also openers:
428
+ * `coproc`, a function head `name()` / `function name`, and `time -p`.
429
+ * A case pattern is followed by a `case-pattern` separator token (see
430
+ * {@link tokenizeCommand}), so the first word of a case ARM is in command
431
+ * position through the operator branch.
432
+ *
433
+ * @param {Array<{ text: string, quoted: boolean, operator?: string }>} sink
434
+ * @param {number} [end=sink.length]
435
+ * @returns {boolean}
436
+ */
437
+ function nextWordInCommandPosition(sink, end = sink.length) {
438
+ for (let i = end - 1; i >= 0; i--) {
439
+ const tok = sink[i];
440
+ if (tok.quoted) return false;
441
+ if (tok.operator !== undefined || CHAIN_OPERATOR_TEXTS.has(tok.text)) return true;
442
+ const prev = sink[i - 1];
443
+ const prevIs = (word) => prev !== undefined && !prev.quoted && prev.text === word;
444
+ // `time -p cmd`: the POSIX-format flag is part of the `time` prefix.
445
+ if (tok.text === '-p' && prevIs('time')) continue;
446
+ // `function name { … }` / `function name() …`: skip the keyword+name pair.
447
+ if (prevIs('function')) { i--; continue; }
448
+ if (FUNCTION_HEAD_RE.test(tok.text)) continue;
449
+ if (!COMMAND_POSITION_OPENERS.has(tok.text)) return false;
450
+ }
451
+ return true;
452
+ }
453
+
454
+ /**
455
+ * How many `$( … )` / backtick / `( … )` levels {@link tokenizeCommand} tracks
456
+ * (#1667, mirroring the ledger guard's MAX_SCAN_NESTING). The lexer is a loop
457
+ * with an explicit frame stack, so this is a work bound, not a stack-safety
458
+ * bound. Past it an unquoted opener is read as a plain separator: the verb
459
+ * behind it stays visible (fail-closed for verb checks), only the word-boundary
460
+ * fidelity of that pathological level is given up.
461
+ */
462
+ const MAX_TOKENIZER_NESTING = 256;
463
+
379
464
  export function tokenizeCommand(command) {
380
465
  const tokens = [];
381
466
  if (typeof command !== 'string' || command.length === 0) return tokens;
@@ -384,19 +469,103 @@ export function tokenizeCommand(command) {
384
469
  let text = '';
385
470
  let started = false; // a token is in progress
386
471
  let sawQuote = false; // any char of the current token came from inside quotes
472
+ // Leading UNQUOTED `{` characters of the current token (#1707 point 2). For a
473
+ // token that is otherwise quoted (`{"unlink"`) the `quoted` flag cannot say
474
+ // whether its first `{` was bare, so the count rides on the token.
475
+ let braceLead = 0;
476
+ // Index of the last BARE `(` appended to a word (`f(`, `arr=(`). An escaped
477
+ // `\(` or a quoted one never sets it, so `$(echo \()` still closes there.
478
+ let bareParenAt = -1;
387
479
  let state = 'normal'; // 'normal' | 'single' | 'double' | 'ansi'
388
480
  let arithDepth = 0; // open `$((` / `((` levels — inside them `<<` is a shift
481
+ // One entry per open `case … esac` level, innermost last. Each names what the
482
+ // NEXT word at that level is: 'subject' (`case WORD`), 'in' (the `in`),
483
+ // 'pattern' (a clause pattern, or `esac`) or 'body' (commands of an arm).
484
+ // In the three head states `(`/`)` are pattern syntax; an unquoted `)` in
485
+ // 'pattern' ENDS the pattern and emits a `case-pattern` separator, so the
486
+ // arm's first word (`a) rm -f x`, `a)rm -f x`) heads its own segment.
487
+ let caseStates = [];
488
+ const caseTop = () => (caseStates.length > 0 ? caseStates[caseStates.length - 1] : null);
489
+ const setCaseTop = (s) => { caseStates[caseStates.length - 1] = s; };
389
490
  const pendingHeredocs = [];
390
491
 
492
+ // #1667 nesting: `sink` receives the tokens of the CURRENT command context.
493
+ // A `$( … )` / backtick / process-substitution body gets its own sink and is
494
+ // HOISTED (appended after the outer stream, behind a separator) on close; a
495
+ // `( … )` subshell stays in place between two separators. `stack` holds one
496
+ // frame per open level — see the tokenizeCommand docblock.
497
+ let sink = tokens;
498
+ const hoisted = [];
499
+ const stack = [];
500
+
391
501
  const flush = () => {
392
502
  if (started) {
393
- tokens.push({ text, quoted: sawQuote });
503
+ // #1671: BOTH words count only where bash reads them as reserved words.
504
+ // An argument `esac` (`echo esac`) used to close a case early, and a
505
+ // `case` behind `then`/`do`/`else`/`{`/`!` was never opened — either way
506
+ // the next pattern `)` closed the enclosing `$( … )` and moved the rest
507
+ // of the body (an `rm` operand) into the outer statement.
508
+ const top = caseTop();
509
+ if (top === 'subject') setCaseTop('in');
510
+ else if (top === 'in') { if (!sawQuote && text === 'in') setCaseTop('pattern'); }
511
+ else if (top === 'pattern') {
512
+ // `case w in esac` / `;; esac` — `esac` where a pattern may start.
513
+ if (!sawQuote && text === 'esac') caseStates.pop();
514
+ } else if (!sawQuote && text === 'case' && nextWordInCommandPosition(sink)) {
515
+ caseStates.push('subject');
516
+ } else if (top === 'body' && !sawQuote && text === 'esac' && nextWordInCommandPosition(sink)) {
517
+ // `a) esac` (last clause without `;;`) is covered too: the pattern's
518
+ // `case-pattern` separator puts `esac` in command position.
519
+ caseStates.pop();
520
+ }
521
+ const tok = { text, quoted: sawQuote };
522
+ // Only a QUOTED token carries the count: for an unquoted one every `{` of
523
+ // its text is bare, so the text already says it (and the token shape of
524
+ // every unquoted word stays as it was).
525
+ if (sawQuote && braceLead > 0) tok.braceLead = braceLead;
526
+ sink.push(tok);
394
527
  text = '';
395
528
  started = false;
396
529
  sawQuote = false;
530
+ braceLead = 0;
397
531
  }
398
532
  };
399
533
 
534
+ // Open a substitution level whose BODY is lexed into a fresh sink. The outer
535
+ // word in progress (`x=` in `x=$(…)`, or the double-quoted run around
536
+ // `"$(…)"`) is parked in the frame and resumed on close.
537
+ const openSubstitution = (kind, start) => {
538
+ stack.push({ kind, start, text, started, sawQuote, braceLead, state, sink, arithDepth, caseStates });
539
+ text = ''; started = false; sawQuote = false; braceLead = 0; state = 'normal'; arithDepth = 0; caseStates = [];
540
+ sink = [];
541
+ };
542
+
543
+ // Close the innermost substitution level. `end` is the index just past its
544
+ // closing character (or the input length when it never closed). The body is
545
+ // hoisted; the outer word gets the RAW source text of the whole substitution
546
+ // appended, so the outer token stream is byte-identical to the pre-#1667
547
+ // lexer's word boundaries (`rm -rf $(pwd)/x` keeps ONE operand).
548
+ const closeSubstitution = (end) => {
549
+ if (state !== 'normal') sawQuote = true;
550
+ flush();
551
+ const frame = stack.pop();
552
+ // Loops, never `push(...arr)`: a spread passes every element as a call
553
+ // argument, and a body of ~125k tokens throws RangeError — which the
554
+ // destructive guard's top-level catch turns into ALLOW (fail-open).
555
+ hoisted.push({ text: ';', quoted: false, operator: 'substitution' });
556
+ for (const t of sink) hoisted.push(t);
557
+ ({ text, started, sawQuote, braceLead, state, sink, arithDepth, caseStates } = frame);
558
+ const raw = command.slice(frame.start, end);
559
+ text += raw; started = true;
560
+ if (/["']/.test(raw)) sawQuote = true;
561
+ };
562
+
563
+ const topKind = () => (stack.length > 0 ? stack[stack.length - 1].kind : null);
564
+ const pushSubshellSeparator = () => {
565
+ flush();
566
+ sink.push({ text: ';', quoted: false, operator: 'subshell' });
567
+ };
568
+
400
569
  // A leading all-digit word is the fd of a redirect (`2> log`), not an argument.
401
570
  // Returns the digits to prefix onto the operator token; flushes otherwise.
402
571
  const takeFdPrefix = () => {
@@ -429,6 +598,20 @@ export function tokenizeCommand(command) {
429
598
  text += next; started = true; sawQuote = true; i++;
430
599
  continue;
431
600
  }
601
+ // Double quotes do NOT stop command substitution (#1667): `"$(rm x)"` runs
602
+ // `rm`. The quoted run is parked and the body is lexed as a command.
603
+ // `$((` stays literal arithmetic text, exactly as before.
604
+ if (state === 'double' && stack.length < MAX_TOKENIZER_NESTING) {
605
+ if (ch === '`') {
606
+ if (topKind() === 'backtick') { closeSubstitution(i + 1); continue; }
607
+ openSubstitution('backtick', i);
608
+ continue;
609
+ }
610
+ if (ch === '$' && command[i + 1] === '(' && command[i + 2] !== '(') {
611
+ openSubstitution('subst', i); i++;
612
+ continue;
613
+ }
614
+ }
432
615
  text += ch; started = true; sawQuote = true;
433
616
  continue;
434
617
  }
@@ -460,7 +643,7 @@ export function tokenizeCommand(command) {
460
643
  // real newline (#999).
461
644
  if (!terminated) { lastTerm = false; pendingHeredocs.length = 0; break; }
462
645
  lastTerm = true;
463
- if (body.length > 0) tokens.push({ text: body, quoted: true });
646
+ if (body.length > 0) sink.push({ text: body, quoted: true });
464
647
  j = end;
465
648
  }
466
649
  // `readHeredocBody` returns `end` pointing PAST the newline that closes the
@@ -514,6 +697,86 @@ export function tokenizeCommand(command) {
514
697
  continue;
515
698
  }
516
699
 
700
+ // Nesting (#1667) — subshells, command substitutions, backticks and process
701
+ // substitutions. Arithmetic (`$((`/`((`, taken above) keeps every paren as
702
+ // text; inside `case … esac` a paren is pattern syntax (`(a)` / `a)`) and
703
+ // also stays text, the pre-#1667 behaviour.
704
+ if (arithDepth === 0) {
705
+ const atCap = stack.length >= MAX_TOKENIZER_NESTING;
706
+ if (ch === '`') {
707
+ if (topKind() === 'backtick') { closeSubstitution(i + 1); continue; }
708
+ if (atCap) { pushSubshellSeparator(); continue; }
709
+ openSubstitution('backtick', i);
710
+ continue;
711
+ }
712
+ if (ch === '$' && command[i + 1] === '(') {
713
+ if (atCap) { pushSubshellSeparator(); i++; continue; }
714
+ openSubstitution('subst', i); i++;
715
+ continue;
716
+ }
717
+ // Inside a case HEAD (`subject`/`in`/`pattern`) a word-start `(` is the
718
+ // optional leading paren of a pattern (`(a)`); in an arm BODY it is a
719
+ // subshell like anywhere else (`a) (rm x);;`).
720
+ const top = caseTop();
721
+ if (ch === '(' && !started && (top === null || top === 'body')) {
722
+ if (atCap) { pushSubshellSeparator(); continue; }
723
+ // `<(cmd)` / `>(cmd)`: a process substitution is a WORD (a filename),
724
+ // so it is hoisted like `$(…)` and the redirect keeps its operand.
725
+ const prev = sink[sink.length - 1];
726
+ if (i > 0 && (command[i - 1] === '<' || command[i - 1] === '>') && prev && prev.redirect) {
727
+ openSubstitution('subst', i);
728
+ continue;
729
+ }
730
+ // `caseLen`: a subshell opened in an arm body closes only at that
731
+ // same case level, never through a pattern `)` of a nested case.
732
+ stack.push({ kind: 'paren', caseLen: caseStates.length });
733
+ pushSubshellSeparator();
734
+ continue;
735
+ }
736
+ if (ch === ')') {
737
+ // `esac)` must close its case before the paren is judged.
738
+ if (started && !sawQuote && text === 'esac') flush();
739
+ // End of a case PATTERN (`a)`, `(a)`, `"a")`, `a)rm …`): keep the `)`
740
+ // on the pattern word and start the arm as a new statement, so its
741
+ // first word is a segment head (#1671 follow-up F1) and a `case`
742
+ // there opens (F2).
743
+ if (caseTop() === 'pattern') {
744
+ text += ch; started = true;
745
+ flush();
746
+ sink.push({ text: ';', quoted: false, operator: 'case-pattern' });
747
+ setCaseTop('body');
748
+ continue;
749
+ }
750
+ // `f()` / `arr=()` — the `)` right after a MID-WORD `(` is that word's
751
+ // own text, never the close of an enclosing `$( … )` / subshell.
752
+ // Keyed on WHERE that `(` came from, not on the raw previous char: an
753
+ // escaped `\(` would otherwise keep a `"$(echo \()"` open and swallow
754
+ // every following command (refute review of 8f323ad8, HIGH).
755
+ if (started && bareParenAt === i - 1 && text.endsWith('(')) {
756
+ text += ch;
757
+ // #1679: an adjacent compound body must start a fresh token too:
758
+ // `f(){unlink X;}` / `f()(unlink X)`. Only a bare, unquoted head in
759
+ // command position qualifies; argument text must stay an argument.
760
+ const bodyOpener = command[i + 1];
761
+ if (!sawQuote && FUNCTION_HEAD_RE.test(text) && nextWordInCommandPosition(sink) &&
762
+ (bodyOpener === '{' || bodyOpener === '(')) {
763
+ flush();
764
+ if (bodyOpener === '{') {
765
+ sink.push({ text: '{', quoted: false });
766
+ i++;
767
+ }
768
+ }
769
+ continue;
770
+ }
771
+ const frame = stack[stack.length - 1];
772
+ if (topKind() === 'paren' && caseStates.length === frame.caseLen &&
773
+ (caseTop() === null || caseTop() === 'body')) {
774
+ stack.pop(); pushSubshellSeparator(); continue;
775
+ }
776
+ if (caseStates.length === 0 && topKind() === 'subst') { closeSubstitution(i + 1); continue; }
777
+ }
778
+ }
779
+
517
780
  // A REAL (non-continued) newline is a command separator, exactly like `;`
518
781
  // in the POSIX grammar (#981). MUST stay below the pending-here-doc branch
519
782
  // above: a newline that opens or ends a here-doc body is consumed there and
@@ -528,7 +791,7 @@ export function tokenizeCommand(command) {
528
791
  // consumers that care, mirroring how `redirect` marks redirect tokens.
529
792
  if (ch === '\n') {
530
793
  flush();
531
- tokens.push({ text: ';', quoted: false, operator: 'newline' });
794
+ sink.push({ text: ';', quoted: false, operator: 'newline' });
532
795
  continue;
533
796
  }
534
797
 
@@ -544,10 +807,10 @@ export function tokenizeCommand(command) {
544
807
  if (ch === '&' && command[i + 1] === '>') {
545
808
  flush();
546
809
  if (command[i + 2] === '>') {
547
- tokens.push({ text: '&>>', quoted: false, redirect: { fd: null, mode: 'append' } });
810
+ sink.push({ text: '&>>', quoted: false, redirect: { fd: null, mode: 'append' } });
548
811
  i += 2;
549
812
  } else {
550
- tokens.push({ text: '&>', quoted: false, redirect: { fd: null, mode: 'truncate' } });
813
+ sink.push({ text: '&>', quoted: false, redirect: { fd: null, mode: 'truncate' } });
551
814
  i += 1;
552
815
  }
553
816
  continue;
@@ -555,7 +818,7 @@ export function tokenizeCommand(command) {
555
818
  if (ch === '<') {
556
819
  if (command[i + 1] === '<' && command[i + 2] === '<') {
557
820
  flush();
558
- tokens.push({ text: '<<<', quoted: false, redirect: { fd: null, mode: 'herestring' } });
821
+ sink.push({ text: '<<<', quoted: false, redirect: { fd: null, mode: 'herestring' } });
559
822
  i += 2;
560
823
  continue;
561
824
  }
@@ -576,7 +839,7 @@ export function tokenizeCommand(command) {
576
839
  const fdDigits = takeFdPrefix();
577
840
  const fdNum = fdDigits === '' ? null : Number.parseInt(fdDigits, 10);
578
841
  const opText = `${fdDigits}<<${stripTabs ? '-' : ''}`;
579
- tokens.push({ text: opText, quoted: false, redirect: { fd: fdNum, mode: 'heredoc' } });
842
+ sink.push({ text: opText, quoted: false, redirect: { fd: fdNum, mode: 'heredoc' } });
580
843
  if (value && command[end] !== ')') {
581
844
  pendingHeredocs.push({ delim: value, stripTabs });
582
845
  i = end - 1;
@@ -587,7 +850,7 @@ export function tokenizeCommand(command) {
587
850
  }
588
851
  const fdDigits = takeFdPrefix();
589
852
  const fdNum = fdDigits === '' ? null : Number.parseInt(fdDigits, 10);
590
- tokens.push({ text: `${fdDigits}<`, quoted: false, redirect: { fd: fdNum, mode: 'read' } });
853
+ sink.push({ text: `${fdDigits}<`, quoted: false, redirect: { fd: fdNum, mode: 'read' } });
591
854
  continue;
592
855
  }
593
856
  if (ch === '>') {
@@ -605,20 +868,20 @@ export function tokenizeCommand(command) {
605
868
  }
606
869
  const fdText = fd === null ? '' : String(fd);
607
870
  if (command[i + 1] === '>') {
608
- tokens.push({ text: `${fdText}>>`, quoted: false, redirect: { fd, mode: 'append' } });
871
+ sink.push({ text: `${fdText}>>`, quoted: false, redirect: { fd, mode: 'append' } });
609
872
  i += 1;
610
873
  } else if (command[i + 1] === '&' && /[0-9]/.test(command[i + 2] ?? '')) {
611
874
  // N>&M / >&M — fd duplication; the target fd is inline, no operand word.
612
875
  let j = i + 2;
613
876
  let dupTarget = '';
614
877
  while (j < command.length && /[0-9]/.test(command[j])) { dupTarget += command[j]; j++; }
615
- tokens.push({ text: `${fdText}>&${dupTarget}`, quoted: false, redirect: { fd, mode: 'dup' } });
878
+ sink.push({ text: `${fdText}>&${dupTarget}`, quoted: false, redirect: { fd, mode: 'dup' } });
616
879
  i = j - 1;
617
880
  } else if (command[i + 1] === '|') {
618
- tokens.push({ text: `${fdText}>|`, quoted: false, redirect: { fd, mode: 'truncate' } });
881
+ sink.push({ text: `${fdText}>|`, quoted: false, redirect: { fd, mode: 'truncate' } });
619
882
  i += 1;
620
883
  } else {
621
- tokens.push({ text: `${fdText}>`, quoted: false, redirect: { fd, mode: 'truncate' } });
884
+ sink.push({ text: `${fdText}>`, quoted: false, redirect: { fd, mode: 'truncate' } });
622
885
  }
623
886
  continue;
624
887
  }
@@ -630,12 +893,20 @@ export function tokenizeCommand(command) {
630
893
  // #983 redirect token class), so this branch only ever sees a bare chain `&`.
631
894
  if (ch === ';' || ch === '|' || ch === '&') {
632
895
  flush();
896
+ // `;;` / `;&` / `;;&` end an arm: the next word at this level is a pattern.
897
+ if (ch === ';' && (command[i + 1] === ';' || command[i + 1] === '&') && caseTop() === 'body') {
898
+ setCaseTop('pattern');
899
+ }
633
900
  let op = ch;
634
901
  if ((ch === '&' || ch === '|') && command[i + 1] === ch) { op = ch + ch; i++; }
635
- tokens.push({ text: op, quoted: false });
902
+ sink.push({ text: op, quoted: false });
636
903
  continue;
637
904
  }
638
905
 
906
+ if (ch === '(') bareParenAt = i;
907
+ // A bare `{` extends the run of leading braces only while nothing else — not
908
+ // even an empty quote pair (`""{x` is a literal word) — came before it.
909
+ if (ch === '{' && text.length === braceLead && (braceLead > 0 || !started)) braceLead++;
639
910
  text += ch; started = true;
640
911
  }
641
912
 
@@ -645,6 +916,18 @@ export function tokenizeCommand(command) {
645
916
  if (state !== 'normal') sawQuote = true;
646
917
  flush();
647
918
 
919
+ // Unclosed nesting levels (#1667): an open `( …` just ends; an open
920
+ // substitution is closed at end of input, so its body is STILL hoisted — the
921
+ // conservative direction (the verb inside stays visible) — and the outer word
922
+ // keeps the raw remainder.
923
+ while (stack.length > 0) {
924
+ if (topKind() === 'paren') { stack.pop(); continue; }
925
+ closeSubstitution(command.length);
926
+ if (state !== 'normal') sawQuote = true;
927
+ flush();
928
+ }
929
+ for (const t of hoisted) tokens.push(t); // no spread — see closeSubstitution
930
+
648
931
  return tokens;
649
932
  }
650
933
 
@@ -656,7 +939,10 @@ export function tokenizeCommand(command) {
656
939
  *
657
940
  * Two classes, both dropped, for the same reason:
658
941
  * - OPENERS (`{`, `(`, `!`, `if`, `elif`, `then`, `else`, `while`, `until`,
659
- * `do`) are followed by a COMMAND. Dropping one moves verb resolution
942
+ * `do`, `coproc`) are followed by a COMMAND. `coproc` runs it as a
943
+ * coprocess in bash and zsh (#1707 point 3). NAMED CEILING (BV-004): bash's
944
+ * named form `coproc NAME { …; }` still reads NAME as the verb; revisit if
945
+ * that spelling shows up in a transcript census. Dropping one moves verb resolution
660
946
  * towards the real command — the identical safety argument WRAPPER_UNWRAP
661
947
  * makes for a transparent wrapper: a dropped token is a reserved word, never
662
948
  * an interpreter, so this cannot turn a match into a miss.
@@ -672,19 +958,120 @@ export function tokenizeCommand(command) {
672
958
  * one table: a second entry here would fork the wrapper-vs-keyword decision
673
959
  * the way `VERB_PREFIXES` once forked WRAPPER_UNWRAP (#991).
674
960
  *
675
- * NAMED CEILING (BV-004): only a STANDALONE token is dropped. bash requires
676
- * whitespace around `{` / `}`, so brace groups are always covered; a subshell
677
- * written WITHOUT the space (`(rm -rf /)`) lexes as the glued word `(rm` and is
678
- * not reached — the pre-#1145 behaviour, unchanged. Peeling the paren off the
679
- * token TEXT would mutate a token five consumers read positionally, which is a
680
- * larger contract change than this defect justifies. Revisit if a glued-paren
681
- * form shows up in a real overflow triage or guard-bypass measurement.
961
+ * Only a STANDALONE token is dropped. bash requires whitespace around `{` /
962
+ * `}`, so brace groups are always covered; a subshell written WITHOUT the space
963
+ * (`(rm -rf /)`) no longer reaches this table at all — since #1667 the lexer
964
+ * emits its parens as separators (see {@link tokenizeCommand}).
682
965
  */
683
966
  const COMMAND_POSITION_KEYWORDS = new Set([
684
- '{', '(', '!', 'if', 'elif', 'then', 'else', 'while', 'until', 'do',
967
+ '{', '(', '!', 'if', 'elif', 'then', 'else', 'while', 'until', 'do', 'coproc',
685
968
  '}', ')', 'fi', 'done', 'esac',
686
969
  ]);
687
970
 
971
+ /**
972
+ * Is `segment` exactly a function-definition head: `name()`, `function name`
973
+ * or `function name()`, all unquoted?
974
+ *
975
+ * @param {Array<{ text: string, quoted: boolean }>} segment
976
+ * @returns {boolean}
977
+ */
978
+ function isFunctionHead(segment) {
979
+ // Length first: splitSegments asks this per token, so a scan of a long
980
+ // segment here would make the split quadratic.
981
+ if (segment.length === 0 || segment.length > 2 || segment.some((t) => t.quoted)) return false;
982
+ // A lone `function` is zsh's anonymous function (`function { …; }`), which
983
+ // zsh runs on the spot — measured: zsh 5.9 prints `hi` for
984
+ // `function { echo hi; }` (#1699 item 13).
985
+ if (segment.length === 1) return segment[0].text === 'function' || FUNCTION_HEAD_RE.test(segment[0].text);
986
+ return segment.length === 2 && segment[0].text === 'function';
987
+ }
988
+
989
+ /**
990
+ * How many brace-group openers are glued to the front of `tok`'s command word
991
+ * (`{unlink` → 1, `{{unlink` → 2, `{"unlink"` → 1)? 0 when none.
992
+ * zsh reads a leading `{` in command position as the reserved word even
993
+ * without a following blank — measured with zsh 5.9: `{echo hi;}`,
994
+ * `f() {echo hi;}; f` and `function f() {echo hi;}; f` all print `hi`, and
995
+ * `{echo,hi}` fails as `command not found: echo,hi` (#1699 item 13). bash
996
+ * rejects or brace-expands these forms; splitting the `{` off never hides a
997
+ * verb there either, since the remainder was no verb before. A token that
998
+ * also ENDS in `}` (`{}`, `{fd}` of a named-fd redirection `{fd}>file`) is
999
+ * left alone: it closes itself, so no command word sits behind its `{`.
1000
+ *
1001
+ * #1707 point 2: the decision rests on the RAW leading characters, not on the
1002
+ * token's `quoted` flag — zsh runs `{"echo" L;}`, `{'echo' L;}` and
1003
+ * `{{echo L;}}`, where the `{` is bare although the rest of the word is quoted,
1004
+ * or is itself followed by another opener. The lexer records the bare run as
1005
+ * `braceLead` on a quoted token (see {@link tokenizeCommand}); a token that is
1006
+ * quoted from its first character (`"{unlink"`) carries none and stays a word.
1007
+ *
1008
+ * @param {{ text: string, quoted: boolean, braceLead?: number, redirect?: object, operator?: string }} tok
1009
+ * @returns {number}
1010
+ */
1011
+ function gluedBraceOpenerCount(tok) {
1012
+ if (tok.redirect || tok.operator !== undefined) return 0;
1013
+ let n = 0;
1014
+ if (tok.quoted) n = tok.braceLead ?? 0;
1015
+ else while (n < tok.text.length && tok.text[n] === '{') n++;
1016
+ if (n === 0 || n >= tok.text.length || tok.text.endsWith('}')) return 0;
1017
+ return n;
1018
+ }
1019
+
1020
+ /**
1021
+ * The extra reading of a word that ends in a `}` glued to it inside an open
1022
+ * brace group (#1707 point 1): zsh 5.9 prints `-r X` for `{echo -r X}` — the
1023
+ * glued `}` closes the group and is not part of the word — while bash keeps it
1024
+ * as text. An option table cannot enumerate unknown forms, so both readings
1025
+ * are judged and never one picked: the word itself stays, and the word with
1026
+ * ONE trailing `}` stripped is the second reading.
1027
+ *
1028
+ * zsh closes exactly ONE group per glued `}` word, however many braces trail
1029
+ * it (zsh 5.9, measured 2026-10-22): `{echo X}}` prints `X}`, `{{echo X}}` is a
1030
+ * parse error, `{ echo X }}}` prints `X }}`, `{ { echo a}}; echo X}` prints
1031
+ * `a}` then `X` (the outer group stays open until `X}`), and
1032
+ * `{ { { echo a}}}; echo X}; echo Y}` prints `a}}`, `X`, `Y`. Counting several
1033
+ * closes for one word left a later glued `}` with no reading:
1034
+ * `{ { echo a}}; echo x >CLAUDE.md}` truncates `CLAUDE.md` in zsh. One reading
1035
+ * per word also keeps the work linear on a `{`×N `X` `}`×N flood.
1036
+ *
1037
+ * A glued `}` word never lowers `openGroups` (security re-review of f99e1352):
1038
+ * the reader cannot tell a real closer from a word zsh keeps as text —
1039
+ * `${HOME}`, `{a,b}`, `"a}"`, `a\}` — and counting one of those as a close left
1040
+ * the REAL closer behind it with no reading: `{ echo ${HOME}; echo x >CLAUDE.md}`
1041
+ * truncates `CLAUDE.md` in zsh 5.9. Only a `}` keyword in command position
1042
+ * closes a group (see {@link splitSegments}).
1043
+ *
1044
+ * NAMED CEILING (BV-004): this over-counts open groups, deliberately fail-closed
1045
+ * — an extra open group only ADDS a reading, never drops one. What it
1046
+ * over-blocks: once a glued group has really closed, a later `x}` word outside
1047
+ * it still yields `x` too (`{ echo a}; rm x}` is judged as `x}` and `x`).
1048
+ * Revisit if a false DENY on a real transcript command traces to this reading.
1049
+ *
1050
+ * @param {string} text
1051
+ * @param {number} openGroups
1052
+ * @returns {string[]} the one stripped reading, or [] when the word has no
1053
+ * glued `}`, no group is open, or the word is only `}`.
1054
+ */
1055
+ function gluedCloseReadings(text, openGroups) {
1056
+ if (openGroups === 0 || text.length < 2 || !text.endsWith('}')) return [];
1057
+ return [text.slice(0, -1)];
1058
+ }
1059
+
1060
+ /**
1061
+ * Is `segment` exactly a prefix that puts the next word — a brace group or any
1062
+ * other compound command — in command position? A function head (see
1063
+ * {@link isFunctionHead}) or `time` / `time -p`, which bash and zsh both parse
1064
+ * as a pipeline prefix (#1707 point 3: zsh runs `time {unlink P;}`).
1065
+ *
1066
+ * @param {Array<{ text: string, quoted: boolean }>} segment
1067
+ * @returns {boolean}
1068
+ */
1069
+ function isCompoundPrefix(segment) {
1070
+ if (isFunctionHead(segment)) return true;
1071
+ if (segment.length === 0 || segment.length > 2 || segment.some((t) => t.quoted)) return false;
1072
+ return segment[0].text === 'time' && (segment.length === 1 || segment[1].text === '-p');
1073
+ }
1074
+
688
1075
  /**
689
1076
  * Split a tokenized command into chained segments on shell control operators
690
1077
  * (`;`, `&&`, `||`, `|`, `&`) and on newline separators (#981). Only UNQUOTED
@@ -699,6 +1086,16 @@ const COMMAND_POSITION_KEYWORDS = new Set([
699
1086
  * `for t in a b c; do glab issue create …; done` and `{ glab issue create …; }`
700
1087
  * were allowed with NO accounting at all, while the plain form denied at 1/1.
701
1088
  *
1089
+ * zsh keyword bodies (#1707 point 3), each judged in command position:
1090
+ * `repeat N cmd` / `repeat N { …; }` (the count word is dropped with the
1091
+ * keyword), `{ …; } always { …; }` (an `always` right after a group's closing
1092
+ * `}` starts a new statement), `time { …; }` (see {@link isCompoundPrefix}) and
1093
+ * zsh's short function body `f() cmd`. Inside an open brace group a word with a
1094
+ * glued `}` carries `closeReadings` (see {@link gluedCloseReadings}) for the
1095
+ * delete and redirect target surfaces. Only a command-position `}` keyword
1096
+ * lowers the open-group count; a glued `}` word never does, so the count may
1097
+ * over-state open groups — a fail-closed ceiling named in gluedCloseReadings.
1098
+ *
702
1099
  * The newline separator is checked by its `operator` field as well as its text,
703
1100
  * so the split survives a future change to that token's spelling. Because a
704
1101
  * separator token is CONSUMED here, it can never reach a per-segment operand
@@ -710,30 +1107,181 @@ const COMMAND_POSITION_KEYWORDS = new Set([
710
1107
  * parsing (#982/#983). The hook previously kept a drift-prone local mirror
711
1108
  * of this splitter (regex-based operator set vs. this Set) — W4 B1.
712
1109
  *
1110
+ * An `always` after a GLUED or one-word close (`x}`, `{true}`) has two readings
1111
+ * and both are judged (review of !233): zsh may read the word as closing the
1112
+ * group, so `always` starts a new statement; bash knows no `always` and passes
1113
+ * it and every word behind it to the command — `bash -c '{ rm -f x} always
1114
+ * CLAUDE.md; }'` deletes CLAUDE.md. The bash reading keeps every statement; from
1115
+ * the first soft cut on, each zsh statement is added unless the bash reading has
1116
+ * the same words, in which case its glued-close readings are merged into that
1117
+ * statement instead — `{true} always {echo a; rm CLAUDE.md}` keeps `CLAUDE.md`
1118
+ * from zsh's open `always` group, and no statement is counted twice (the
1119
+ * issue-budget guard counts per segment). A command-position `}` is no such
1120
+ * case: `} always` is a bash syntax error, the cut is hard.
1121
+ *
713
1122
  * @param {Array<{ text: string, quoted: boolean }>} tokens
714
- * @returns {Array<Array<{ text: string, quoted: boolean }>>}
1123
+ * @returns {Array<Array<{ text: string, quoted: boolean, closeReadings?: string[] }>>}
715
1124
  */
716
1125
  function splitSegments(tokens) {
1126
+ const zsh = splitSegmentsReading(tokens, true);
1127
+ if (!zsh.softCut) return zsh.segments;
1128
+ const segments = splitSegmentsReading(tokens, false).segments;
1129
+ const byWords = new Map(segments.map((segment, i) => [segmentKey(segment), i]));
1130
+ for (const segment of zsh.segments) {
1131
+ if (!segment.afterSoftCut) continue;
1132
+ const key = segmentKey(segment);
1133
+ const at = byWords.get(key);
1134
+ if (at === undefined) {
1135
+ byWords.set(key, segments.length);
1136
+ segments.push(segment);
1137
+ continue;
1138
+ }
1139
+ segments[at] = mergeCloseReadings(segments[at], segment);
1140
+ }
1141
+ return segments;
1142
+ }
1143
+
1144
+ /**
1145
+ * The bash statement with the zsh statement's glued-close readings added per
1146
+ * word (both have the same words). New token objects: the tokens are shared
1147
+ * between the two readings.
1148
+ *
1149
+ * @param {Array<object>} target
1150
+ * @param {Array<object>} source
1151
+ * @returns {Array<object>}
1152
+ */
1153
+ function mergeCloseReadings(target, source) {
1154
+ return target.map((tok, i) => {
1155
+ const extra = source[i]?.closeReadings;
1156
+ if (!extra) return tok;
1157
+ const merged = [...new Set([...(tok.closeReadings ?? []), ...extra])];
1158
+ return { ...tok, closeReadings: merged };
1159
+ });
1160
+ }
1161
+
1162
+ /** @param {Array<{ text: string, quoted: boolean }>} segment */
1163
+ function segmentKey(segment) {
1164
+ return JSON.stringify(segment.map((t) => [t.text, t.quoted]));
1165
+ }
1166
+
1167
+ /**
1168
+ * One reading of {@link splitSegments}. `cutSoftAlways`: whether an `always`
1169
+ * after a glued or one-word close starts a new statement (zsh) or stays a word
1170
+ * (bash). Every statement written after the first such cut is marked
1171
+ * `afterSoftCut`.
1172
+ *
1173
+ * @param {Array<{ text: string, quoted: boolean }>} tokens
1174
+ * @param {boolean} cutSoftAlways
1175
+ * @returns {{ segments: Array<Array<object>>, softCut: boolean }}
1176
+ */
1177
+ function splitSegmentsReading(tokens, cutSoftAlways) {
1178
+ let softCut = false;
1179
+ // Marked when a statement is WRITTEN, not when `current` starts: keyword
1180
+ // peeling (`always { rm …`) replaces `current` in between.
717
1181
  const segments = [];
1182
+ const pushSegment = (segment) => {
1183
+ if (segment.length > 0 && softCut) segment.afterSoftCut = true;
1184
+ segments.push(segment);
1185
+ };
718
1186
  let current = [];
719
1187
  const operators = new Set([';', '&&', '||', '|', '&']);
720
- for (const tok of tokens) {
1188
+ // Open brace groups (#1707 point 1) — only a group opened in command
1189
+ // position counts, so `echo {a,b}` never arms the glued-`}` readings.
1190
+ let openGroups = 0;
1191
+ // The previous token closed a brace group — zsh's `always` may follow it:
1192
+ // 'hard' after a command-position `}`, 'soft' after a glued or one-word close.
1193
+ let closedGroup = null;
1194
+ // The next token is the count word of a command-position `repeat`.
1195
+ let repeatCount = false;
1196
+ for (let i = 0; i < tokens.length; i++) {
1197
+ const tok = tokens[i];
1198
+ const afterClose = closedGroup;
1199
+ closedGroup = null;
721
1200
  if (!tok.quoted && (tok.operator === 'newline' || operators.has(tok.text))) {
722
- segments.push(current);
1201
+ pushSegment(current);
1202
+ current = [];
1203
+ repeatCount = false;
1204
+ continue;
1205
+ }
1206
+ if (repeatCount) { repeatCount = false; continue; }
1207
+ // zsh `repeat N body` (#1707 point 3): the keyword and its count are no
1208
+ // command; the body behind them runs N times. Only a count WORD counts: in
1209
+ // bash `repeat` is an ordinary command name and `repeat >CLAUDE.md`
1210
+ // truncates before the lookup fails, so a redirect or an operator behind it
1211
+ // keeps `repeat` the segment head and the redirect collected. A quoted count
1212
+ // is still one word — zsh 5.9 runs `repeat '2' cmd` and `repeat "$N" cmd`.
1213
+ const count = tokens[i + 1];
1214
+ if (current.length === 0 && !tok.quoted && tok.text === 'repeat' && count !== undefined &&
1215
+ !count.redirect && count.operator === undefined &&
1216
+ !operators.has(count.text)) {
1217
+ repeatCount = true;
1218
+ continue;
1219
+ }
1220
+ // zsh `{ try; } always { finally; }` (#1707 point 3): the `always` body runs
1221
+ // like any group, so `always` ends the statement before it.
1222
+ if (afterClose !== null && !tok.quoted && tok.text === 'always' &&
1223
+ (afterClose === 'hard' || cutSoftAlways)) {
1224
+ if (current.length > 0) pushSegment(current);
723
1225
  current = [];
1226
+ if (afterClose === 'soft') softCut = true;
724
1227
  continue;
725
1228
  }
726
1229
  // Reserved word in COMMAND POSITION (`current` still empty) → drop it, so
727
1230
  // the NEXT token becomes the segment head and is itself judged in command
728
1231
  // position (`do { rm …` peels both). A keyword anywhere else is an ordinary
729
1232
  // argument and is kept verbatim (#1145).
730
- if (current.length === 0 && !tok.quoted && COMMAND_POSITION_KEYWORDS.has(tok.text)) {
1233
+ //
1234
+ // A function head (`f()`, `function f`, `function f()`) before its compound
1235
+ // body (#1679 point 1): the head is no command, and the body runs on every
1236
+ // call, so drop the head and judge the body in command position like a bare
1237
+ // brace group. Before, `f() { unlink <ledger>; }; f` resolved to verb `f()`.
1238
+ // `time` / `time -p` before a compound command is dropped the same way.
1239
+ if (!tok.quoted && COMMAND_POSITION_KEYWORDS.has(tok.text) &&
1240
+ (current.length === 0 || isCompoundPrefix(current))) {
1241
+ current = [];
1242
+ if (tok.text === '{') openGroups++;
1243
+ if (tok.text === '}') { openGroups = Math.max(0, openGroups - 1); closedGroup = 'hard'; }
1244
+ continue;
1245
+ }
1246
+ // A `{` glued to the first word of a brace group (zsh, #1699 item 13 —
1247
+ // repeatedly and on the raw first character since #1707 point 2): drop the
1248
+ // openers, so the word behind them is the segment head.
1249
+ const glued = gluedBraceOpenerCount(tok);
1250
+ if (glued > 0 && (current.length === 0 || isCompoundPrefix(current))) {
1251
+ const { braceLead: _consumed, ...rest } = tok;
1252
+ current = [{ ...rest, text: tok.text.slice(glued) }];
1253
+ openGroups += glued;
1254
+ continue;
1255
+ }
1256
+ // zsh's short function body `f() cmd` (#1707 point 3): zsh 5.9 runs
1257
+ // `f() unlink P; f`, so the head is dropped and `cmd` heads the segment.
1258
+ if (current.length === 1 && !current[0].quoted && FUNCTION_HEAD_RE.test(current[0].text) &&
1259
+ !tok.redirect) {
1260
+ current = [];
1261
+ }
1262
+ // Outside a group `gluedCloseReadings` is []; inside one, a glued `}` word
1263
+ // gets its stripped reading but does NOT lower `openGroups`: `${HOME}`,
1264
+ // `{a,b}`, `"a}"` look the same and close nothing in zsh, so every later
1265
+ // glued `}` keeps its reading (fail-closed, see gluedCloseReadings). It may
1266
+ // still close a group, so an `always` may follow it.
1267
+ const readings = !tok.redirect && tok.operator === undefined
1268
+ ? gluedCloseReadings(tok.text, openGroups)
1269
+ : [];
1270
+ if (readings.length > 0) {
1271
+ closedGroup = 'soft';
1272
+ current.push({ ...tok, closeReadings: readings });
731
1273
  continue;
732
1274
  }
1275
+ // A one-word group heading its statement (`{true} always {…}`) closes
1276
+ // itself, so an `always` may follow it as well.
1277
+ if (current.length === 0 && !tok.quoted && tok.text.length > 2 && tok.text[0] === '{' &&
1278
+ tok.text.endsWith('}')) {
1279
+ closedGroup = 'soft';
1280
+ }
733
1281
  current.push(tok);
734
1282
  }
735
- segments.push(current);
736
- return segments.filter((s) => s.length > 0);
1283
+ pushSegment(current);
1284
+ return { segments: segments.filter((s) => s.length > 0), softCut };
737
1285
  }
738
1286
 
739
1287
  // Public alias — the single source of truth for chain-segment splitting shared
@@ -1394,8 +1942,12 @@ function collectRedirectTargets(segments, out, depth, budget) {
1394
1942
  // Variable indirection (`> "$X"`), command substitution (`> $(cmd)` /
1395
1943
  // backticks), or a missing operand: fail-visible, never guess (#983).
1396
1944
  out.push({ target: null, mode, fd, unresolved: true });
1397
- } else {
1398
- out.push({ target: operand.text, mode, fd });
1945
+ continue;
1946
+ }
1947
+ // A glued group-closing `}` (`{echo x >CLAUDE.md}`) — both readings are
1948
+ // judged (#1707 point 1, see gluedCloseReadings).
1949
+ for (const text of [operand.text, ...(operand.closeReadings ?? [])]) {
1950
+ out.push({ target: text, mode, fd });
1399
1951
  }
1400
1952
  }
1401
1953
 
@@ -1975,7 +2527,11 @@ function collectDeleteTargets(segments, out, depth, budget) {
1975
2527
  // this segment — same rule commandHasRecursiveForceRm applies. An
1976
2528
  // interpreter's quoted payload is reached through the recursion below
1977
2529
  // instead, where it is re-tokenized as the command it really is.
1978
- if (segment[reading.index].quoted) continue;
2530
+ // A quoted SINGLE word is no such literal (#1707 point 2): `"unlink" P`
2531
+ // and `{"unlink" P;}` run unlink, the quotes only vanish. Only a quoted
2532
+ // token with whitespace in it — a payload, never one argv word that
2533
+ // could still resolve to the verb — stays inert.
2534
+ if (segment[reading.index].quoted && /\s/.test(segment[reading.index].text)) continue;
1979
2535
 
1980
2536
  let seenDashDash = false;
1981
2537
  for (let i = reading.index + 1; i < segment.length; i++) {
@@ -1998,19 +2554,23 @@ function collectDeleteTargets(segments, out, depth, budget) {
1998
2554
  // where a value-taking reading would have swallowed it silently.
1999
2555
  if (!seenDashDash && !tok.quoted && tok.text.startsWith('-') && tok.text !== '-') continue;
2000
2556
 
2001
- const key = `${reading.verb}\u0000${tok.text}`;
2002
- if (seen.has(key)) continue;
2003
- seen.add(key);
2004
-
2005
- if (/[$`]/.test(tok.text)) {
2006
- // Variable indirection (`rm -f "$LEDGER"`) or command substitution:
2007
- // fail-visible, never guess (#983 rule, same direction).
2008
- out.push({
2009
- target: null, verb: reading.verb, unresolved: true, reason: 'variable/substitution',
2010
- });
2011
- continue;
2557
+ // A word with a glued group-closing `}` is judged in BOTH readings
2558
+ // (#1707 point 1, see gluedCloseReadings): `{rm -r .orchestrator}`.
2559
+ for (const text of tok.closeReadings ? [tok.text, ...tok.closeReadings] : [tok.text]) {
2560
+ const key = `${reading.verb}\u0000${text}`;
2561
+ if (seen.has(key)) continue;
2562
+ seen.add(key);
2563
+
2564
+ if (/[$`]/.test(text)) {
2565
+ // Variable indirection (`rm -f "$LEDGER"`) or command substitution:
2566
+ // fail-visible, never guess (#983 rule, same direction).
2567
+ out.push({
2568
+ target: null, verb: reading.verb, unresolved: true, reason: 'variable/substitution',
2569
+ });
2570
+ continue;
2571
+ }
2572
+ out.push({ target: text, verb: reading.verb });
2012
2573
  }
2013
- out.push({ target: tok.text, verb: reading.verb });
2014
2574
  }
2015
2575
  }
2016
2576