session-orchestrator 5.12.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 (385) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  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 +162 -4
  11. package/README.md +126 -127
  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 +1 -1
  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 +90 -17
  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 +49 -15
  47. package/docs/session-config-template.md +30 -22
  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 +139 -49
  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 -2
  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 +39 -27
  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/section-extractor.mjs +65 -11
  143. package/scripts/lib/config/template-keys.mjs +153 -0
  144. package/scripts/lib/config/vault-integration.mjs +1 -0
  145. package/scripts/lib/config.mjs +2 -2
  146. package/scripts/lib/coordinator-snapshot.mjs +1 -1
  147. package/scripts/lib/discovery/triage-state.mjs +2 -1
  148. package/scripts/lib/dispatcher/rank.mjs +5 -7
  149. package/scripts/lib/ecosystem-health.mjs +2 -2
  150. package/scripts/lib/eval/engine.mjs +3 -2
  151. package/scripts/lib/eval/session-resolve.mjs +6 -2
  152. package/scripts/lib/eval/sink.mjs +4 -3
  153. package/scripts/lib/events-retention-banner.mjs +3 -2
  154. package/scripts/lib/events-schema.mjs +10 -0
  155. package/scripts/lib/events.mjs +23 -12
  156. package/scripts/lib/evolve/autonomy-verdict.mjs +4 -9
  157. package/scripts/lib/evolve/autopilot-effectiveness.mjs +8 -18
  158. package/scripts/lib/fleet-protocol.mjs +4 -0
  159. package/scripts/lib/gates/gate-full.mjs +172 -11
  160. package/scripts/lib/gates/gate-helpers.mjs +236 -23
  161. package/scripts/lib/git-config-drift.mjs +141 -7
  162. package/scripts/lib/github-export.mjs +447 -0
  163. package/scripts/lib/gitlab-portfolio/cli.mjs +16 -5
  164. package/scripts/lib/handover-queue-marks.mjs +159 -0
  165. package/scripts/lib/hardware-pattern-detector.mjs +103 -13
  166. package/scripts/lib/harness-audit/categories/category1.mjs +16 -4
  167. package/scripts/lib/harness-audit/categories/category4.mjs +11 -8
  168. package/scripts/lib/harness-audit/categories/category6.mjs +25 -5
  169. package/scripts/lib/harness-audit/categories/category9.mjs +10 -4
  170. package/scripts/lib/host-identity.mjs +9 -5
  171. package/scripts/lib/http-request.mjs +64 -0
  172. package/scripts/lib/instruction-budget-guard.mjs +30 -22
  173. package/scripts/lib/io.mjs +6 -3
  174. package/scripts/lib/issue-budget-reconcile.mjs +3 -2
  175. package/scripts/lib/issue-budget.mjs +36 -7
  176. package/scripts/lib/learnings/evidence-candidates.mjs +154 -0
  177. package/scripts/lib/learnings/expiry-sweep.mjs +99 -7
  178. package/scripts/lib/learnings/io.mjs +289 -15
  179. package/scripts/lib/learnings/select.mjs +53 -4
  180. package/scripts/lib/learnings/store-role.mjs +209 -0
  181. package/scripts/lib/learnings/surface.mjs +46 -7
  182. package/scripts/lib/ledger-path.mjs +113 -0
  183. package/scripts/lib/maintenance-due-banner.mjs +63 -12
  184. package/scripts/lib/memory-banner.mjs +5 -6
  185. package/scripts/lib/memory-proposals/collector.mjs +5 -2
  186. package/scripts/lib/memory-proposals/sink.mjs +7 -4
  187. package/scripts/lib/memory-proposals/store.mjs +22 -5
  188. package/scripts/lib/mode-selector-accuracy.mjs +2 -1
  189. package/scripts/lib/no-follow-open.mjs +57 -0
  190. package/scripts/lib/orphan-reaper/reaper-audit.mjs +4 -2
  191. package/scripts/lib/own-lock-root.mjs +95 -0
  192. package/scripts/lib/owner-interview.mjs +2 -1
  193. package/scripts/lib/owner-yaml.mjs +70 -7
  194. package/scripts/lib/platform.mjs +161 -9
  195. package/scripts/lib/plugin-update-banner.mjs +15 -11
  196. package/scripts/lib/pre-push-local-partial.mjs +117 -0
  197. package/scripts/lib/pre-push-remote-gate.mjs +151 -25
  198. package/scripts/lib/process-group.mjs +270 -34
  199. package/scripts/lib/project-hygiene.mjs +28 -0
  200. package/scripts/lib/quality-gate.mjs +33 -9
  201. package/scripts/lib/reconcile/backlog.mjs +29 -15
  202. package/scripts/lib/reconcile/engine.mjs +26 -2
  203. package/scripts/lib/reconcile/idempotency.mjs +65 -3
  204. package/scripts/lib/reconcile/rule-expiry-sweep.mjs +53 -12
  205. package/scripts/lib/reconcile/verdict-ledger.mjs +224 -0
  206. package/scripts/lib/reconcile-nudge-banner.mjs +40 -6
  207. package/scripts/lib/resource-probe/evaluate.mjs +32 -9
  208. package/scripts/lib/resource-probe/probe-platform.mjs +3 -1
  209. package/scripts/lib/rule-loader.mjs +20 -10
  210. package/scripts/lib/scope-baseline.mjs +123 -8
  211. package/scripts/lib/scope-echo.mjs +5 -2
  212. package/scripts/lib/scope-gate.mjs +55 -20
  213. package/scripts/lib/session-close-backfill.mjs +75 -7
  214. package/scripts/lib/session-end/close-precheck.mjs +151 -0
  215. package/scripts/lib/session-end/phase-skip.mjs +8 -7
  216. package/scripts/lib/session-end/tail-runner.mjs +7 -2
  217. package/scripts/lib/session-id.mjs +9 -1
  218. package/scripts/lib/session-identity/own-session.mjs +63 -0
  219. package/scripts/lib/session-lock.mjs +114 -0
  220. package/scripts/lib/session-record-repair.mjs +245 -35
  221. package/scripts/lib/session-registry.mjs +72 -9
  222. package/scripts/lib/session-schema/constants.mjs +9 -0
  223. package/scripts/lib/session-schema/filters.mjs +56 -1
  224. package/scripts/lib/session-schema/validator.mjs +15 -5
  225. package/scripts/lib/session-start-probes.mjs +149 -22
  226. package/scripts/lib/session-token-rollup.mjs +2 -1
  227. package/scripts/lib/session-work-trees.mjs +166 -0
  228. package/scripts/lib/sessions-canonical.mjs +61 -3
  229. package/scripts/lib/sessions-integrity-banner.mjs +17 -39
  230. package/scripts/lib/sessions-staleness-banner.mjs +8 -5
  231. package/scripts/lib/sh-quote.mjs +1 -0
  232. package/scripts/lib/skill-evidence-window.mjs +5 -1
  233. package/scripts/lib/skill-health/join.mjs +10 -9
  234. package/scripts/lib/skill-invocations-schema.mjs +13 -0
  235. package/scripts/lib/skill-judgments-schema.mjs +6 -2
  236. package/scripts/lib/snapshot-probe.mjs +414 -0
  237. package/scripts/lib/so-hud/collect.mjs +49 -5
  238. package/scripts/lib/so-hud/runtime.mjs +15 -4
  239. package/scripts/lib/spiral-carryover.mjs +8 -1
  240. package/scripts/lib/state-md/frontmatter-mutators.mjs +266 -8
  241. package/scripts/lib/state-md/idle-reset.mjs +14 -5
  242. package/scripts/lib/state-md/mission-status.mjs +5 -1
  243. package/scripts/lib/state-md-peer-guard.mjs +2 -0
  244. package/scripts/lib/state-md.mjs +31 -8
  245. package/scripts/lib/sunset/walker.mjs +4 -3
  246. package/scripts/lib/telemetry/consent.mjs +4 -2
  247. package/scripts/lib/telemetry/queue.mjs +3 -3
  248. package/scripts/lib/telemetry/sync.mjs +45 -9
  249. package/scripts/lib/test-runner/artifact-paths.mjs +10 -2
  250. package/scripts/lib/tmux-layout/layouts.mjs +20 -3
  251. package/scripts/lib/ux-grill/paths.mjs +5 -1
  252. package/scripts/lib/validate/check-agents.mjs +37 -6
  253. package/scripts/lib/validate/check-component-paths.mjs +25 -1
  254. package/scripts/lib/validate/check-guard-requires-parity.mjs +3 -3
  255. package/scripts/lib/validate/check-hooks-symmetry.mjs +53 -32
  256. package/scripts/lib/validate/check-json-files.mjs +3 -1
  257. package/scripts/lib/validate/check-learning-provenance.mjs +3 -2
  258. package/scripts/lib/validate/check-learnings-shell-writes.mjs +10 -4
  259. package/scripts/lib/validate/check-metrics-ledger-root.mjs +234 -0
  260. package/scripts/lib/validate/check-owner-leakage.mjs +159 -11
  261. package/scripts/lib/validate/check-test-posix-shell.mjs +246 -0
  262. package/scripts/lib/validate/check-unwired-features.mjs +4 -2
  263. package/scripts/lib/validate/check-vcs-repo-flag-positional.mjs +58 -0
  264. package/scripts/lib/validate/check-vcs-repo-flag.mjs +259 -73
  265. package/scripts/lib/validate/check-workflow-continue-on-error.mjs +63 -0
  266. package/scripts/lib/validate/confidential-names.mjs +3 -1
  267. package/scripts/lib/vault-mirror/migrate-ids.mjs +321 -0
  268. package/scripts/lib/vault-mirror/namespace.mjs +19 -7
  269. package/scripts/lib/vault-mirror/process.mjs +56 -27
  270. package/scripts/lib/vault-mirror/prune.mjs +5 -2
  271. package/scripts/lib/vault-mirror/render-learnings.mjs +3 -3
  272. package/scripts/lib/vault-mirror/render-sessions.mjs +36 -53
  273. package/scripts/lib/vault-mirror/retention.mjs +2 -2
  274. package/scripts/lib/vault-mirror/utils.mjs +79 -0
  275. package/scripts/lib/vault-staleness-banner.mjs +4 -7
  276. package/scripts/lib/vault-status/board-writer.mjs +2 -1
  277. package/scripts/lib/vault-status/narrative-mirror.mjs +66 -7
  278. package/scripts/lib/wave-executor/foreign-dispatch.mjs +20 -0
  279. package/scripts/lib/win-process-tree.mjs +137 -0
  280. package/scripts/lib/workspace.mjs +1 -1
  281. package/scripts/lib/worktree/constants.mjs +1 -1
  282. package/scripts/lib/worktree/lifecycle.mjs +1 -1
  283. package/scripts/lib/worktree/listing.mjs +1 -1
  284. package/scripts/lib/zx.mjs +50 -0
  285. package/scripts/mcp-server.mjs +16 -7
  286. package/scripts/mcp-server.sh +13 -83
  287. package/scripts/measure-policy-cache-effectiveness.mjs +7 -5
  288. package/scripts/memory-propose.mjs +10 -3
  289. package/scripts/migrate-cold-start-seed.mjs +7 -0
  290. package/scripts/migrate-legacy-learnings.sh +22 -0
  291. package/scripts/migrate-sessions-jsonl.mjs +16 -2
  292. package/scripts/migrate-vault-paths.mjs +29 -13
  293. package/scripts/parse-config.mjs +71 -14
  294. package/scripts/print-learnings-index.mjs +25 -2
  295. package/scripts/release-session-lock.mjs +94 -7
  296. package/scripts/release.mjs +451 -24
  297. package/scripts/relocate-vault-corpus.mjs +11 -7
  298. package/scripts/repair-invalid-sessions.mjs +68 -3
  299. package/scripts/resolve-session-invocation.mjs +10 -17
  300. package/scripts/run-migrate-v2-cross-repo.mjs +56 -1
  301. package/scripts/run-quality-gate.mjs +33 -7
  302. package/scripts/self-update.mjs +56 -7
  303. package/scripts/session-commit-files.mjs +133 -0
  304. package/scripts/site-numbers.mjs +52 -9
  305. package/scripts/snapshot-probe.mjs +156 -0
  306. package/scripts/so-hud.mjs +29 -6
  307. package/scripts/state-md-idle-reset.mjs +2 -1
  308. package/scripts/sweep-expired-learnings.mjs +54 -6
  309. package/scripts/sweep-expired-rules.mjs +4 -1
  310. package/scripts/validate-plugin.mjs +22 -0
  311. package/scripts/vault-integration-watcher.mjs +54 -6
  312. package/scripts/vault-mirror.mjs +9 -0
  313. package/skills/_shared/fleet-protocol.md +142 -1
  314. package/skills/_shared/instruction-file-resolution.md +1 -1
  315. package/skills/_shared/monitor-patterns.md +44 -25
  316. package/skills/_shared/platform-tools.md +7 -0
  317. package/skills/_shared/state-ownership.md +3 -2
  318. package/skills/architecture/references/domain-model.md +1 -1
  319. package/skills/bootstrap/SKILL.md +3 -1
  320. package/skills/bootstrap/_shared-template.md +4 -4
  321. package/skills/bootstrap/fast-template.md +1 -1
  322. package/skills/bootstrap/private-contract.md +1 -1
  323. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +17 -9
  324. package/skills/bootstrap/templates/agents/project-code-review.md +5 -1
  325. package/skills/bootstrap/templates/agents/project-discovery.md +5 -1
  326. package/skills/bootstrap/templates/agents/project-quality-gate.md +5 -1
  327. package/skills/claude-md-drift-check/SKILL.md +3 -3
  328. package/skills/claude-md-drift-check/checker.mjs +61 -101
  329. package/skills/close/SKILL.md +20 -7
  330. package/skills/discovery/probes/docs-staleness.mjs +7 -5
  331. package/skills/discovery/probes/frontend-slop.mjs +7 -3
  332. package/skills/discovery/probes/ssot-code-diff.mjs +5 -3
  333. package/skills/discovery/probes/supply-chain-slopcheck.mjs +10 -8
  334. package/skills/discovery/probes/vault-narrative-staleness.mjs +30 -10
  335. package/skills/discovery/probes/vault-staleness.mjs +5 -3
  336. package/skills/discovery/probes-session.md +3 -0
  337. package/skills/discovery/probes-vault.md +2 -0
  338. package/skills/dispatcher/SKILL.md +1 -1
  339. package/skills/eval/SKILL.md +3 -1
  340. package/skills/evolve/SKILL.md +2 -0
  341. package/skills/evolve/references/evolve-analyze-mode.md +3 -3
  342. package/skills/evolve/references/evolve-dialectic-mode.md +1 -1
  343. package/skills/gitlab-ops/SKILL.md +7 -6
  344. package/skills/gitlab-portfolio/SKILL.md +2 -2
  345. package/skills/go/SKILL.md +13 -1
  346. package/skills/npm-publish/SKILL.md +5 -2
  347. package/skills/plan/SKILL.md +23 -10
  348. package/skills/plan/mode-feature.md +13 -8
  349. package/skills/plan/mode-new.md +6 -14
  350. package/skills/plan/prd-feature-template.md +19 -0
  351. package/skills/plan/prd-full-template.md +12 -5
  352. package/skills/plan/prd-premortem-panel.md +48 -0
  353. package/skills/plan/prd-reviewer-prompt.md +11 -3
  354. package/skills/plan/soul.md +1 -1
  355. package/skills/portfolio/SKILL.md +5 -6
  356. package/skills/quality-gates/SKILL.md +2 -4
  357. package/skills/reconcile/SKILL.md +27 -4
  358. package/skills/release/SKILL.md +16 -8
  359. package/skills/session-end/SKILL.md +94 -11
  360. package/skills/session-end/phase-3-2-docs-verification.md +1 -0
  361. package/skills/session-end/phase-3-6-tail.md +11 -7
  362. package/skills/session-end/phase-3-7a-recommendations.md +9 -2
  363. package/skills/session-end/plan-verification.md +16 -1
  364. package/skills/session-end/references/phase-3-documentation-updates.md +14 -12
  365. package/skills/session-end/references/phase-5-issue-cleanup.md +1 -1
  366. package/skills/session-end/references/session-summary-template.md +4 -0
  367. package/skills/session-end/session-metrics-write.md +7 -4
  368. package/skills/session-plan/SKILL.md +5 -3
  369. package/skills/session-start/SKILL.md +9 -3
  370. package/skills/session-start/phase-4-5-resource-health.md +11 -5
  371. package/skills/session-start/phase-8-5-express-path.md +4 -3
  372. package/skills/session-start/references/phase-1-2-session-lock.md +7 -5
  373. package/skills/session-start/references/phase-1-5-session-continuity.md +2 -2
  374. package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
  375. package/skills/session-start/references/phase-4-ssot-environment-check.md +3 -2
  376. package/skills/tmux-layout/SKILL.md +2 -2
  377. package/skills/vault-sync/validator.mjs +3 -2
  378. package/skills/wave-executor/SKILL.md +5 -4
  379. package/skills/wave-executor/references/wave-executor-state-init.md +3 -3
  380. package/skills/wave-executor/references/wave-loop-dispatch.md +11 -5
  381. package/skills/wave-executor/references/wave-loop-review.md +13 -5
  382. package/skills/wave-executor/references/wave-loop-scope-manifest.md +1 -1
  383. package/assets/og-card.png +0 -0
  384. package/assets/og-card.svg +0 -47
  385. package/assets/wave-lifecycle.svg +0 -98
@@ -74,6 +74,12 @@
74
74
  * hook's protection scope is shell-redirect writes that BYPASS validation,
75
75
  * and this command never does that.
76
76
  *
77
+ * #1577/#1656 widen this by a CLOSED grammar, not a pattern: the same command
78
+ * with exactly one explicit `--repo-root <ABS>` (either side of `--apply`),
79
+ * and the same two shapes with this plugin's own absolute script path. The
80
+ * root is judged on its own (Git top-level, ledger present, not the Vault) —
81
+ * see `allowsRepairApply`. Every other deviation still falls through to DENY.
82
+ *
77
83
  * ## Path scope
78
84
  *
79
85
  * A write target counts as the ledger when its BASENAME is `sessions.jsonl`,
@@ -232,13 +238,15 @@
232
238
  * denied (`"$PWD"/…/sessions.jsonl`, `$HOME/…/sessions.jsonl`,
233
239
  * `"$(pwd)/…/sessions.jsonl"` and `exec 3> …/sessions.jsonl` all deny;
234
240
  * verified, not assumed).
235
- * - A redirect nested inside a DOUBLE-QUOTED command substitution or a
236
- * backtick run: `echo "$(echo x >> …/sessions.jsonl)"` allows, while the
237
- * unquoted `echo $(echo x >> …/sessions.jsonl)` denies. The scanner
238
- * consumes a quoted run whole and does not recurse into it. Verified, and
239
- * left uncaught deliberately: recursing costs lexer surface, and a redirect
240
- * buried in a quoted substitution is not the accident shape this guard is
241
- * for — it is the "determined circumvention" line below.
241
+ * - Since #1665 a `$(…)` / backtick substitution — quoted or not, nested
242
+ * double quotes included — is parsed as its own command line, so
243
+ * `echo "$(echo x >> …/sessions.jsonl)"` and
244
+ * `echo "$(node -e "…appendFileSync('…/sessions.jsonl',…)")"` deny. Three
245
+ * substitution shapes stay out: a `case` pattern's `)` inside a body ends
246
+ * it early; a body nested past MAX_PAYLOAD_DEPTH is marked, not judged;
247
+ * and a double-quoted body this lexer cannot parse is allowed with an
248
+ * `unparsed-substitution` notice instead of failing closed (a real
249
+ * `printf '… "x" …'` description denied before that rule).
242
250
  * - In-place editors: `sed -i`, `perl -i`, `ed`, an interactive editor.
243
251
  * - A write performed by a SCRIPT FILE the command invokes
244
252
  * (`node tools/append.mjs`) — the ledger name never appears in the command
@@ -272,10 +280,11 @@
272
280
  * guarded by `hooks/pre-edit-sessions-ledger-guard.mjs` (#1443), not this
273
281
  * hook's surface.
274
282
  * - The #385 repair-apply subshell/backtick deny (`unwrapSubshellLayers`)
275
- * only unwraps a command that IS, in its entirety, one wrapped run —
276
- * `x=$(node scripts/repair-invalid-sessions.mjs --apply)` (the wrapper is
277
- * part of a larger assignment, not the whole command) still allows. Same
278
- * class as the line above: a determined rewrite, not the accident shape.
283
+ * only unwraps a command that IS, in its entirety, one wrapped run. A
284
+ * wrapper inside a larger command —
285
+ * `x=$(node scripts/repair-invalid-sessions.mjs --apply)` — is no longer a
286
+ * gap since #1665: the substitution body reaches the general matcher and
287
+ * denies there.
279
288
  *
280
289
  * This is a guard against the accident that actually happened, not a
281
290
  * containment boundary. Determined circumvention is out of scope by design.
@@ -310,6 +319,7 @@
310
319
  */
311
320
 
312
321
  import { spawnSync } from 'node:child_process';
322
+ import { lstatSync, realpathSync } from 'node:fs';
313
323
  import path from 'node:path';
314
324
  import { pathToFileURL } from 'node:url';
315
325
 
@@ -451,6 +461,17 @@ async function bootstrap() {
451
461
  /** The ledger's filename. A write target matches on basename equality. */
452
462
  const LEDGER_BASENAME = 'sessions.jsonl';
453
463
 
464
+ /**
465
+ * What a `$( … )` / backtick span becomes in `sanitized` (#1665) and in a
466
+ * redirect word's `skeleton` (#1671): a visible `_` plus an invisible U+2063,
467
+ * so a literal `_` in a file name is never mistaken for a substitution and
468
+ * {@link mayNameLedgerDynamically} can treat exactly the spans as wildcards.
469
+ */
470
+ const SUBST_PLACEHOLDER = '_\u2063';
471
+
472
+ /** The ledger's directory, repo-relative (#1671 dynamic-name rule). */
473
+ const LEDGER_DIR = '.orchestrator/metrics';
474
+
454
475
  /** The repair CLI whose effective apply mode mutates the sessions ledger internally. */
455
476
  const REPAIR_SCRIPT_BASENAME = 'repair-invalid-sessions.mjs';
456
477
  const REPAIR_APPLY_MARKER = `${REPAIR_SCRIPT_BASENAME} --apply`;
@@ -478,6 +499,211 @@ const REPAIR_APPLY_MARKER = `${REPAIR_SCRIPT_BASENAME} --apply`;
478
499
  */
479
500
  const REPAIR_APPLY_CANONICAL_COMMAND = `node scripts/${REPAIR_SCRIPT_BASENAME} --apply`;
480
501
 
502
+ /**
503
+ * Every verb that IS the Node runtime (#1665). Debian/Ubuntu ship the binary as
504
+ * `nodejs`, and a guard that recognised only `node` let
505
+ * `nodejs scripts/repair-invalid-sessions.mjs --apply …` through. One set, read
506
+ * by every place that asks "is this verb Node?" — the eval-payload family table
507
+ * (`INTERPRETER_FAMILY`) carries the same two names.
508
+ */
509
+ const NODE_VERBS = new Set(['node', 'nodejs']);
510
+ // Verbs that only print or search their operands — they never execute a script
511
+ // they name, so the non-node repair-runner rule (#1670) does not apply to them.
512
+ const TEXT_ONLY_VERBS = new Set(['echo', 'printf', 'grep', 'egrep', 'fgrep', 'rg', 'cat', 'head', 'tail', 'less', 'wc']);
513
+
514
+ /** This plugin's own repair script — the only sanctioned absolute operand (#1577). */
515
+ const REPAIR_ABSOLUTE_SCRIPT = path.join(PLUGIN_ROOT, 'scripts', REPAIR_SCRIPT_BASENAME);
516
+
517
+ /** Deny-message hint: every form the closed allowlist accepts (#385, #1577, #1656). */
518
+ const REPAIR_SANCTIONED_FORMS = [
519
+ ` ${REPAIR_APPLY_CANONICAL_COMMAND}`,
520
+ ` ${REPAIR_APPLY_CANONICAL_COMMAND} --repo-root <absolute-git-toplevel>`,
521
+ ` node ${REPAIR_ABSOLUTE_SCRIPT} --apply --repo-root <absolute-git-toplevel>`,
522
+ ` (--repo-root <path> may also precede --apply)`,
523
+ ].join('\n');
524
+
525
+ /** A canonical, existing path with no symlink component (including parents). */
526
+ function isSymlinkFreeAbsolute(candidate) {
527
+ if (typeof candidate !== 'string' || !path.isAbsolute(candidate) || path.resolve(candidate) !== candidate) return false;
528
+ try {
529
+ for (let current = candidate; ; current = path.dirname(current)) {
530
+ if (lstatSync(current).isSymbolicLink()) return false;
531
+ if (path.dirname(current) === current) break;
532
+ }
533
+ return realpathSync.native(candidate) === candidate;
534
+ } catch {
535
+ return false;
536
+ }
537
+ }
538
+
539
+ /** Measurement failure never authorizes the new repair exception. */
540
+ function measuredGitRoot(cwd) {
541
+ if (!isSymlinkFreeAbsolute(cwd)) return null;
542
+ if (Object.keys(process.env).some((name) =>
543
+ /^(?:GIT_DIR|GIT_WORK_TREE|GIT_COMMON_DIR|GIT_CONFIG.*|GIT_CEILING_DIRECTORIES|GIT_DISCOVERY_ACROSS_FILESYSTEM|GIT_INDEX_FILE|GIT_OBJECT_DIRECTORY|GIT_ALTERNATE_OBJECT_DIRECTORIES)$/.test(name))) return null;
544
+ try {
545
+ const result = spawnSync('git', ['-C', cwd, 'rev-parse', '--show-toplevel'], {
546
+ encoding: 'utf8', timeout: 2000, maxBuffer: 64 * 1024,
547
+ });
548
+ if (result.error || result.status !== 0) return null;
549
+ const root = result.stdout?.trim();
550
+ if (typeof root !== 'string' || !isSymlinkFreeAbsolute(root)) return null;
551
+ // Local core.worktree can redirect Git's reported top-level away from cwd.
552
+ // A measured root must physically contain this invocation, not merely be
553
+ // the same redirected answer for both process and payload probes (#1577).
554
+ const relative = path.relative(root, cwd);
555
+ if (relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative)) return null;
556
+ const marker = lstatSync(path.join(root, '.git'));
557
+ return !marker.isSymbolicLink() && (marker.isDirectory() || marker.isFile()) ? root : null;
558
+ } catch {
559
+ return null;
560
+ }
561
+ }
562
+
563
+ /** Conservative charset for an explicit `--repo-root` path (#1577/#1656). */
564
+ const REPAIR_ROOT_CHARSET = /^[A-Za-z0-9._/@+-]+$/;
565
+
566
+ /**
567
+ * Parse the closed repair-apply grammar (#1577/#1656):
568
+ *
569
+ * node <script> --apply
570
+ * node <script> --apply --repo-root <ABS>
571
+ * node <script> --repo-root <ABS> --apply
572
+ *
573
+ * Operands may be wrapped in one pair of simple quotes (a plugin root with a
574
+ * space needs it); shell evaluation characters anywhere in the command fail
575
+ * the parse, so double quotes cannot expand anything. Anything else — an
576
+ * extra argument, `--repo-root=X`, a second `--repo-root`, chaining, a
577
+ * wrapper, a trailing newline — is not this grammar.
578
+ *
579
+ * @param {string} command - the raw, untrimmed command
580
+ * @returns {{ script: string, rawScript: string, root: string|null }|null}
581
+ */
582
+ function parseRepairApply(command) {
583
+ if (/[$`\\;|&<>()*?[\]{}~!#\r\n]/.test(command)) return null;
584
+ const op = `("[^"]+"|'[^']+'|[^\\s'"]+)`;
585
+ const sep = '[ \\t]+';
586
+ const unquote = (value) => /^["']/.test(value) ? value.slice(1, -1) : value;
587
+ const shapes = [
588
+ { re: new RegExp(`^node${sep}${op}${sep}--apply$`), root: null },
589
+ { re: new RegExp(`^node${sep}${op}${sep}--apply${sep}--repo-root${sep}${op}$`), root: 2 },
590
+ { re: new RegExp(`^node${sep}${op}${sep}--repo-root${sep}${op}${sep}--apply$`), root: 2 },
591
+ ];
592
+ for (const shape of shapes) {
593
+ const match = shape.re.exec(command);
594
+ if (match) {
595
+ return {
596
+ rawScript: match[1],
597
+ script: unquote(match[1]),
598
+ root: shape.root === null ? null : unquote(match[shape.root]),
599
+ };
600
+ }
601
+ }
602
+ return null;
603
+ }
604
+
605
+ /**
606
+ * Closed allowlist for the repair CLI beyond the bare #385 exact string
607
+ * (#1577/#1656). The script is either the literal relative
608
+ * `scripts/repair-invalid-sessions.mjs` or this hook's OWN plugin copy
609
+ * (`PLUGIN_ROOT` comes from `import.meta.dirname`, which Node has already
610
+ * realpath'd — env vars are never consulted for it); any other absolute path
611
+ * to a file of that name, including a symlink to the real one, is DENY.
612
+ *
613
+ * Exactly one explicit `--repo-root <ABS>` is required here, and it is the
614
+ * whole target identity: the CLI repairs that root and nothing else, so the
615
+ * hook judges the root itself instead of demanding that hook cwd, payload cwd
616
+ * and the CLI's default project dir agree. That agreement is what made the
617
+ * repair unreachable from a consumer repo or a worktree (`getProjectDir()`
618
+ * names the main checkout there, possibly on a foreign branch — #1656).
619
+ * ABS must be in the conservative charset, absolute, canonical (no `..`, no
620
+ * trailing slash, no symlink component), an existing directory that Git
621
+ * itself reports as its own top-level, hold a regular-file
622
+ * `.orchestrator/metrics/sessions.jsonl`, and lie outside the Vault.
623
+ *
624
+ * Repo modules load only inside this caught path: a broken optional resolver
625
+ * must DENY this exception, not disarm the rest of the ledger guard (#993).
626
+ *
627
+ * @param {string} command - the raw, untrimmed command
628
+ * @returns {Promise<boolean>}
629
+ */
630
+ async function allowsRepairApply(command) {
631
+ try {
632
+ const parsed = parseRepairApply(command);
633
+ if (!parsed || parsed.root === null) return false;
634
+ const relative = parsed.rawScript === `scripts/${REPAIR_SCRIPT_BASENAME}`;
635
+ if (!relative) {
636
+ const script = parsed.script;
637
+ if (script !== REPAIR_ABSOLUTE_SCRIPT ||
638
+ !isSymlinkFreeAbsolute(script) || !lstatSync(script).isFile()) return false;
639
+ }
640
+ const root = parsed.root;
641
+ if (!REPAIR_ROOT_CHARSET.test(root) || root.split('/').includes('..')) return false;
642
+ if (!isSymlinkFreeAbsolute(root) || !lstatSync(root).isDirectory()) return false;
643
+ if (measuredGitRoot(root) !== root) return false;
644
+ // The target must already carry a regular-file ledger (#1577): a repo
645
+ // without one is no repair target. EVERY component of the ledger path must
646
+ // be symlink-free, not only the root: a scratch repo whose `.orchestrator/`
647
+ // or `metrics/` links into the Vault would otherwise pass the Vault check
648
+ // on the plain root while the script (realpathSync) repairs the Vault.
649
+ const ledger = path.join(root, '.orchestrator', 'metrics', LEDGER_BASENAME);
650
+ if (!isSymlinkFreeAbsolute(ledger) || !lstatSync(ledger).isFile()) return false;
651
+ return !(await isVaultRepairRoot(root));
652
+ } catch {
653
+ return false;
654
+ }
655
+ }
656
+
657
+ /**
658
+ * Vault resolution failure has the Lead-authorized narrow fallback, rather
659
+ * than disabling all absolute repairs. Keep optional config imports here so
660
+ * they cannot disarm the ledger matcher or the relative exact exception.
661
+ */
662
+ async function isVaultRepairRoot(root) {
663
+ let vault;
664
+ try {
665
+ const { resolveVaultDir, readConfigFile } = await import('../scripts/lib/config.mjs');
666
+ const { loadHostPaths } = await import('../scripts/lib/config/host-paths.mjs');
667
+ const { _parseVaultIntegration } = await import('../scripts/lib/config/vault-integration.mjs');
668
+ const { expandTilde } = await import('../scripts/lib/common.mjs');
669
+ let resolved;
670
+ // An authoritative environment override does not need owner/committed IO.
671
+ if (typeof process.env.SO_VAULT_DIR === 'string' && process.env.SO_VAULT_DIR.trim()) {
672
+ resolved = resolveVaultDir(null);
673
+ } else {
674
+ const { loadOwnerConfig, validateOwnerSections } = await import('../scripts/lib/owner-yaml.mjs');
675
+ const owner = loadOwnerConfig();
676
+ const host = loadHostPaths({ownerLoader: () => owner});
677
+ if (!host.source || host.reason || !validateOwnerSections(host.ownerConfig).sections['vault-dirs'].valid) {
678
+ throw new Error('Vault owner resolution unavailable');
679
+ }
680
+ resolved = resolveVaultDir(null, host);
681
+ if (resolved.source !== 'match' && host.droppedSections?.some(({ section }) => section === 'paths')) {
682
+ throw new Error('Vault owner paths unavailable');
683
+ }
684
+ if (resolved.source === 'committed') {
685
+ if (host.source === 'defaults' && owner.errors?.length) throw new Error('Vault owner defaults are not measurable');
686
+ const committed = _parseVaultIntegration(await readConfigFile(root))['vault-dir'];
687
+ resolved = resolveVaultDir(committed, host);
688
+ }
689
+ }
690
+ if (typeof resolved.value !== 'string' || !resolved.value.trim()) throw new Error('Vault path unavailable');
691
+ vault = realpathSync.native(expandTilde(resolved.value));
692
+ } catch {
693
+ try {
694
+ // Only HOME/Projects/vault is the fallback; similarly named or nested
695
+ // vault segments elsewhere under Projects are ordinary repair targets.
696
+ if (typeof process.env.HOME !== 'string' || !process.env.HOME.trim()) return false;
697
+ const home = realpathSync.native(process.env.HOME);
698
+ vault = realpathSync.native(path.join(home, 'Projects', 'vault'));
699
+ } catch {
700
+ return false;
701
+ }
702
+ }
703
+ const relative = path.relative(vault, root);
704
+ return relative === '' || (relative !== '..' && !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative));
705
+ }
706
+
481
707
  /**
482
708
  * How much of the offending target may appear in the deny reason.
483
709
  *
@@ -502,6 +728,14 @@ const DEST_LAST_VERBS = new Set(['cp', 'mv']);
502
728
  */
503
729
  const MAX_PAYLOAD_DEPTH = 2;
504
730
 
731
+ /**
732
+ * How deep `scanCommand` follows nested `$(`, backticks and `(` subshells.
733
+ * Stack safety, fail-CLOSED (#1665): unbounded recursion overflowed the stack,
734
+ * and the fail-open crash handler turned that into an ALLOW. Past this cap the
735
+ * scan counts as unparseable, which denies a ledger-naming command.
736
+ */
737
+ const MAX_SCAN_NESTING = 256;
738
+
505
739
  /** Unquoted characters that end a shell WORD. */
506
740
  const WORD_END = new Set([';', '|', '&', '<', '>', '(', ')', '\n']);
507
741
 
@@ -531,6 +765,58 @@ function refersToLedger(target) {
531
765
  return path.posix.basename(normalized) === LEDGER_BASENAME;
532
766
  }
533
767
 
768
+ /**
769
+ * Could a write target whose basename a command substitution (partly) decides
770
+ * be the ledger (#1671)? `target` carries {@link SUBST_PLACEHOLDER} where each
771
+ * substitution stood. True when the target's directory is the ledger directory
772
+ * and the basename skeleton — each placeholder read as "any text" — can match
773
+ * `sessions.jsonl`: `$(echo sessions).jsonl`, `sess$(echo ions).jsonl`, a bare
774
+ * `$(…)`. `$(date).log` or `events-$(date).jsonl` cannot, and stay allowed.
775
+ *
776
+ * Ceiling: only the literal `.orchestrator/metrics/` spelling is known. A name
777
+ * decided by a VARIABLE (`$F`), or a dynamic name after `cd` into the metrics
778
+ * dir, is not judged. Measured still-ALLOW pre-existing forms (security review):
779
+ * - parameter expansion: `${x}.jsonl`, `$x.jsonl` (no substitution placeholder)
780
+ * - brace expansion: `{sessions,x}.jsonl`
781
+ * - globs: `session?.jsonl`, `sessions.json[l]`
782
+ * - ANSI-C hex escapes: `$'\x73essions'` (escape kept literal, not decoded)
783
+ * - the `install` / `rsync` verbs (no write-verb branch)
784
+ * - the prefilter needs the literal `.orchestrator/metrics/` spelling, so
785
+ * `.orchestrator/x/../metrics/$(…)` is never normalized or judged
786
+ * Revisit trigger: a measured ledger write of any of these shapes, or a
787
+ * sessions.jsonl corruption whose writer used one.
788
+ *
789
+ * @param {string} target
790
+ * @returns {boolean}
791
+ */
792
+ function mayNameLedgerDynamically(target) {
793
+ if (typeof target !== 'string' || !target.includes(SUBST_PLACEHOLDER)) return false;
794
+ const normalized = target.replace(/\\/g, '/');
795
+ const dir = path.posix.normalize(path.posix.dirname(normalized));
796
+ if (dir !== LEDGER_DIR && !dir.endsWith(`/${LEDGER_DIR}`)) return false;
797
+ const skeleton = path.posix.basename(normalized)
798
+ .split(SUBST_PLACEHOLDER)
799
+ .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
800
+ .join('.*');
801
+ return new RegExp(`^${skeleton}$`).test(LEDGER_BASENAME);
802
+ }
803
+
804
+ /**
805
+ * The write-target verdict every write-verb branch shares: the target itself
806
+ * when it names the ledger, a fail-closed label when only a substitution could
807
+ * decide that (#1671), else null.
808
+ *
809
+ * @param {string} target
810
+ * @returns {string|null}
811
+ */
812
+ function ledgerWriteHit(target) {
813
+ if (refersToLedger(target)) return target;
814
+ if (mayNameLedgerDynamically(target)) {
815
+ return `${target.split(SUBST_PLACEHOLDER).join('$(…)')} (command substitution decides the target — not statically resolvable, denied fail-closed)`;
816
+ }
817
+ return null;
818
+ }
819
+
534
820
  /**
535
821
  * Does this token name the repair CLI by its immediate script operand basename?
536
822
  *
@@ -553,11 +839,13 @@ function refersToRepairScript(target) {
553
839
  * Deliberately narrow: this is NOT a general shell parser. It exists only to
554
840
  * answer "is this string exactly the canonical repair-apply command sitting
555
841
  * inside one layer of command-substitution/subshell syntax" (see
556
- * {@link unwrapSubshellLayers} and GitLab #385 test (c)) — the general
557
- * `blocker.tokenizeCommand` path glues a leading `$(`/`` ` ``/`(` onto the
558
- * next word (#385 measured: `$(node …)` tokenizes as a single `$(node`
559
- * token), which is why the existing verb-based matcher never resolves `node`
560
- * as the verb for a wrapped command and silently ALLOWS it today.
842
+ * {@link unwrapSubshellLayers} and GitLab #385 test (c)). History: the shared
843
+ * `blocker.tokenizeCommand` once glued a leading `$(`/`` ` ``/`(` onto the next
844
+ * word (#385 measured `$(node` as one token), so the verb matcher never saw
845
+ * `node` in a wrapped command. That is no longer true — `scanCommand` (#1665)
846
+ * and the shared tokenizer (#1667, e6adbcc7) both split those spans. The unwrapping stays
847
+ * because two checks in `main()` still need the wrapped command's interior as
848
+ * a STRING: the absolute-operand regex and the dedicated "wrapped" deny text.
561
849
  *
562
850
  * @param {string} s - already-trimmed candidate
563
851
  * @returns {string|null} the unwrapped, trimmed interior, or null
@@ -761,35 +1049,67 @@ function skipQuotedRun(command, open, escapes) {
761
1049
  * `.orchestrator/metrics/"sessions.jsonl"`, `My\ Repo/…/sessions.jsonl` and
762
1050
  * `"."/…/sessions.jsonl` all resolve to a value whose basename is the ledger.
763
1051
  *
1052
+ * #1670 — with `onSubstitution`, an unquoted OR double-quoted `$(…)` or backtick span is part of
1053
+ * the word instead of ending it at its `(`: `>> $(pwd)/…/sessions.jsonl` used to
1054
+ * read as the target `$`, after which the scanner took `(pwd)` for a subshell and
1055
+ * never judged the write. The span's raw text stands in the value, so the
1056
+ * basename is still the static last component (a span's `)` / backtick can never
1057
+ * end in `sessions.jsonl`). `dynamic` reports whether a span sits in that LAST
1058
+ * component, i.e. whether the basename is not statically known.
1059
+ *
764
1060
  * @param {string} command
765
1061
  * @param {number} i
766
- * @returns {{ value: string, end: number, balanced: boolean }}
1062
+ * @param {(bodyStart: number, stop: string) => number} [onSubstitution] - scans
1063
+ * the body and returns the index just past its closing character, or -1
1064
+ * @returns {{ value: string, end: number, balanced: boolean, dynamic: boolean }}
767
1065
  */
768
- function readWord(command, i) {
1066
+ function readWord(command, i, onSubstitution) {
769
1067
  let value = '';
1068
+ // `value` with every substitution replaced by SUBST_PLACEHOLDER (#1671).
1069
+ let skeleton = '';
770
1070
  let state = 'normal';
1071
+ let dynamic = false;
1072
+ const add = (text) => {
1073
+ value += text;
1074
+ skeleton += text;
1075
+ if (text.includes('/')) dynamic = false;
1076
+ };
771
1077
  while (i < command.length) {
772
1078
  const ch = command[i];
773
1079
 
774
1080
  if (state === 'single') {
775
1081
  if (ch === "'") { state = 'normal'; i++; continue; }
776
- value += ch; i++; continue;
1082
+ add(ch); i++; continue;
777
1083
  }
778
1084
  if (state === 'double' || state === 'ansi') {
779
1085
  if (ch === (state === 'double' ? '"' : "'")) { state = 'normal'; i++; continue; }
780
- if (ch === '\\' && i + 1 < command.length) { value += command[i + 1]; i += 2; continue; }
781
- value += ch; i++; continue;
1086
+ if (ch === '\\' && i + 1 < command.length) { add(command[i + 1]); i += 2; continue; }
1087
+ // Bash expands `$(…)` and backticks inside "…" (never inside '…' or $'…'),
1088
+ // so a double-quoted span decides the name exactly like an unquoted one.
1089
+ if (!(state === 'double' && onSubstitution && ((ch === '$' && command[i + 1] === '(') || ch === '`'))) {
1090
+ add(ch); i++; continue;
1091
+ }
782
1092
  }
783
1093
 
1094
+ if (onSubstitution && ((ch === '$' && command[i + 1] === '(') || ch === '`')) {
1095
+ const isDollarParen = ch === '$';
1096
+ const end = onSubstitution(isDollarParen ? i + 2 : i + 1, isDollarParen ? ')' : '`');
1097
+ if (end === -1) return { value, skeleton, end: i, balanced: false, dynamic: true };
1098
+ value += command.slice(i, end);
1099
+ skeleton += SUBST_PLACEHOLDER;
1100
+ dynamic = true;
1101
+ i = end;
1102
+ continue;
1103
+ }
784
1104
  if (/\s/.test(ch) || WORD_END.has(ch)) break;
785
- if (ch === '\\' && i + 1 < command.length) { value += command[i + 1]; i += 2; continue; }
1105
+ if (ch === '\\' && i + 1 < command.length) { add(command[i + 1]); i += 2; continue; }
786
1106
  if (ch === "'") { state = 'single'; i++; continue; }
787
1107
  if (ch === '"') { state = 'double'; i++; continue; }
788
1108
  if (ch === '$' && command[i + 1] === "'") { state = 'ansi'; i += 2; continue; }
789
1109
  if (ch === '$' && command[i + 1] === '"') { state = 'double'; i += 2; continue; }
790
- value += ch; i++;
1110
+ add(ch); i++;
791
1111
  }
792
- return { value, end: i, balanced: state === 'normal' };
1112
+ return { value, skeleton, end: i, balanced: state === 'normal', dynamic };
793
1113
  }
794
1114
 
795
1115
  /**
@@ -821,107 +1141,268 @@ function skipHeredocBody(command, from, delim, stripTabs) {
821
1141
  * Returns every RESOLVED redirect target, plus a sanitized command for the
822
1142
  * write-verb matcher: comments and here-doc bodies removed, `$'…'` folded to a
823
1143
  * plain single-quoted literal, redirect operators and their targets elided.
824
- * `balanced` is false when the scan ended inside an unterminated quote — a
825
- * command bash would reject, and the fail-closed trigger (see docblock).
1144
+ * `balanced` is false when the scan ended inside an unterminated quote or
1145
+ * substitution — a command bash would reject, and the fail-closed trigger
1146
+ * (see docblock).
1147
+ *
1148
+ * #1665 — subshells, command substitutions and brace groups. The shared
1149
+ * tokenizer treats `(`, `$(`, `` ` ``, `{` as ordinary word characters, so
1150
+ * `true && (node -e …)` lexed `(node` as one word and every verb-keyed check
1151
+ * went blind inside ANY wrapper. This pass therefore parses them as spans:
1152
+ *
1153
+ * - `$(…)` and `` `…` `` — quoted or not — are scanned recursively to their
1154
+ * quote-aware closing `)` / backtick (a `"` inside the body opens a NEW
1155
+ * quoting context, as in bash, and no longer ends the outer `"…"`). The
1156
+ * body is returned in `substitutions` for the caller to judge as its own
1157
+ * command line, and the span becomes SUBST_PLACEHOLDER (a `_`) in `sanitized`, so
1158
+ * the surrounding WORD stays whole: `tee -a $(pwd)/…/sessions.jsonl` reads
1159
+ * as `tee -a _/…/sessions.jsonl`, not as three statements.
1160
+ * - a bare `( … )` subshell and a `{ … ; }` group (reserved words only —
1161
+ * never `{}` or `${…}`/`{a,b}`) become `;` separators, which puts the inner
1162
+ * command at the head of its own segment.
1163
+ * - redirect targets inside any span land in the one shared `targets` list.
1164
+ *
1165
+ * Only DIRECT substitution bodies are returned; a body nested in a body is
1166
+ * found when the caller re-scans the outer one (bounded by MAX_PAYLOAD_DEPTH).
1167
+ * When a substitution inside double quotes cannot be parsed, the run falls back
1168
+ * to the plain quote skip and `unparsedSubstitution` is set: the body is NOT
1169
+ * judged and the command is NOT failed closed — the top-level command was
1170
+ * balanced, so bash may well run it (a mark, never a deny).
1171
+ *
1172
+ * NAMED CEILING (BV-004): a `case` pattern's `)` inside a substitution ends the
1173
+ * body early (the bash 3.2 reading). Revisit if a measured transcript command
1174
+ * carries a `case` inside `$(…)` next to the ledger.
826
1175
  *
827
1176
  * @param {string} command
828
- * @returns {{ targets: string[], sanitized: string, balanced: boolean }}
1177
+ * @returns {{ targets: string[], unresolvedTargets: string[], sanitized: string,
1178
+ * balanced: boolean, substitutions: string[], unparsedSubstitution: boolean }}
829
1179
  */
830
1180
  function scanCommand(command) {
831
1181
  const targets = [];
1182
+ // #1670 — redirect targets whose basename a substitution supplies (see readWord).
1183
+ const unresolvedTargets = [];
1184
+ const substitutions = [];
832
1185
  const heredocs = [];
833
- let out = '';
834
- let i = 0;
1186
+ let unparsedSubstitution = false;
1187
+ // MAX_SCAN_NESTING bookkeeping: current span depth, and whether any span hit
1188
+ // the cap. `tooDeep` overrides the quoted-run fallback in scanDouble, which
1189
+ // would otherwise downgrade the overflow to a mere `unparsed-substitution` mark.
1190
+ let nesting = 0;
1191
+ let tooDeep = false;
835
1192
 
836
1193
  const atWordStart = (idx) => idx === 0 || /[\s;|&()<>]/.test(command[idx - 1]);
837
1194
 
838
- while (i < command.length) {
839
- const ch = command[i];
840
-
841
- // A `#` in word position comments out the rest of the LINE. Without this,
842
- // one apostrophe in English prose desynced the whole scan (MED-1).
843
- if (ch === '#' && atWordStart(i)) {
844
- while (i < command.length && command[i] !== '\n') i++;
845
- continue;
846
- }
1195
+ /**
1196
+ * Scan a substitution body from `bodyStart` to its `stop` character and record
1197
+ * the body in `subs`. Nested bodies go to a throwaway list (see above).
1198
+ *
1199
+ * @returns {number} index just past the closing character, or -1 when unbalanced
1200
+ */
1201
+ function substitution(bodyStart, stop, subs) {
1202
+ const inner = scanSpan(bodyStart, stop, []);
1203
+ if (!inner) return -1;
1204
+ subs.push(command.slice(bodyStart, inner.end - 1));
1205
+ return inner.end;
1206
+ }
847
1207
 
848
- if (ch === '\n') {
849
- out += '\n';
850
- i++;
851
- while (heredocs.length) {
852
- const { delim, stripTabs } = heredocs.shift();
853
- i = skipHeredocBody(command, i, delim, stripTabs);
1208
+ /**
1209
+ * A double-quoted run opening at `open`. Text is copied verbatim except that
1210
+ * each substitution becomes `_`.
1211
+ *
1212
+ * @returns {{ out: string, end: number } | null} null when never closed
1213
+ */
1214
+ function scanDouble(open, subs) {
1215
+ const saved = {
1216
+ targets: targets.length, unresolved: unresolvedTargets.length, heredocs: heredocs.slice(), subs: subs.length,
1217
+ };
1218
+ let out = '"';
1219
+ let i = open + 1;
1220
+ while (i < command.length) {
1221
+ const ch = command[i];
1222
+ if (ch === '"') return { out: out + '"', end: i + 1 };
1223
+ if (ch === '\\' && i + 1 < command.length) { out += ch + command[i + 1]; i += 2; continue; }
1224
+ const isDollarParen = ch === '$' && command[i + 1] === '(';
1225
+ if (isDollarParen || ch === '`') {
1226
+ const end = substitution(isDollarParen ? i + 2 : i + 1, isDollarParen ? ')' : '`', subs);
1227
+ if (end === -1) {
1228
+ // Unparseable body inside a quoted run: undo what the failed attempt
1229
+ // recorded, skip the run the plain way, and mark it (#1665).
1230
+ targets.length = saved.targets;
1231
+ unresolvedTargets.length = saved.unresolved;
1232
+ heredocs.splice(0, heredocs.length, ...saved.heredocs);
1233
+ subs.length = saved.subs;
1234
+ const plainEnd = skipQuotedRun(command, open, true);
1235
+ if (plainEnd === -1) return null;
1236
+ unparsedSubstitution = true;
1237
+ return { out: command.slice(open, plainEnd), end: plainEnd };
1238
+ }
1239
+ out += SUBST_PLACEHOLDER;
1240
+ i = end;
1241
+ continue;
854
1242
  }
855
- continue;
1243
+ out += ch;
1244
+ i++;
856
1245
  }
1246
+ return null;
1247
+ }
857
1248
 
858
- if (ch === '\\' && i + 1 < command.length) { out += ch + command[i + 1]; i += 2; continue; }
859
-
860
- // ANSI-C `$'…'`: a backslash escapes the closing quote, which the old mask
861
- // did not know (MED-1). Folded to a plain literal for the write-verb lexer.
862
- if (ch === '$' && command[i + 1] === "'") {
863
- const end = skipQuotedRun(command, i + 1, true);
864
- if (end === -1) return { targets, sanitized: out, balanced: false };
865
- const literal = command.slice(i + 2, end - 1).replace(/\\(.)/g, '$1').replace(/'/g, '');
866
- out += `'${literal}'`;
867
- i = end;
868
- continue;
1249
+ /**
1250
+ * Scan from `i` up to `stop` (`)` or a backtick), or to the end of input when
1251
+ * `stop` is null.
1252
+ *
1253
+ * @returns {{ out: string, end: number } | null} `end` is just past `stop`;
1254
+ * null when the input ends first (or inside an unterminated quote)
1255
+ */
1256
+ function scanSpan(i, stop, subs) {
1257
+ if (nesting >= MAX_SCAN_NESTING) {
1258
+ tooDeep = true;
1259
+ return null;
869
1260
  }
870
- if (ch === '$' && command[i + 1] === '"') {
871
- const end = skipQuotedRun(command, i + 1, true);
872
- if (end === -1) return { targets, sanitized: out, balanced: false };
873
- out += command.slice(i + 1, end);
874
- i = end;
875
- continue;
876
- }
877
- if (ch === "'" || ch === '"') {
878
- const end = skipQuotedRun(command, i, ch === '"');
879
- if (end === -1) return { targets, sanitized: out, balanced: false };
880
- out += command.slice(i, end);
881
- i = end;
882
- continue;
1261
+ nesting++;
1262
+ try {
1263
+ return scanSpanBody(i, stop, subs);
1264
+ } finally {
1265
+ nesting--;
883
1266
  }
1267
+ }
884
1268
 
885
- // Here-doc: remember the delimiter now, skip the body at the next newline.
886
- // `<<<` is a here-STRING, not a here-doc, and needs no body skipping.
887
- if (ch === '<' && command[i + 1] === '<' && command[i + 2] !== '<') {
888
- let j = i + 2;
889
- let stripTabs = false;
890
- if (command[j] === '-') { stripTabs = true; j++; }
891
- while (command[j] === ' ' || command[j] === '\t') j++;
892
- const w = readWord(command, j);
893
- if (!w.balanced) return { targets, sanitized: out, balanced: false };
894
- if (w.value) heredocs.push({ delim: w.value, stripTabs });
895
- out += ' ';
896
- i = Math.max(w.end, i + 2);
897
- continue;
898
- }
1269
+ function scanSpanBody(i, stop, subs) {
1270
+ let out = '';
1271
+ while (i < command.length) {
1272
+ const ch = command[i];
899
1273
 
900
- // Redirection: `>` `>>` `>|` `N>` `&>` `&>>`. The optional leading fd digits
901
- // are ordinary characters we already copied; only the operator matters.
902
- if (ch === '>' || (ch === '&' && command[i + 1] === '>')) {
903
- let j = ch === '&' ? i + 2 : i + 1;
904
- if (command[j] === '>') j++;
905
- if (command[j] === '|') j++;
906
- if (command[j] === '&') {
907
- const dup = readWord(command, j + 1);
908
- if (FD_DUP_RE.test(dup.value)) { out += ' '; i = Math.max(dup.end, j + 1); continue; }
909
- j += 1;
1274
+ if (stop !== null && ch === stop) return { out, end: i + 1 };
1275
+
1276
+ // A `#` in word position comments out the rest of the LINE. Without this,
1277
+ // one apostrophe in English prose desynced the whole scan (MED-1).
1278
+ if (ch === '#' && atWordStart(i)) {
1279
+ while (i < command.length && command[i] !== '\n') i++;
1280
+ continue;
910
1281
  }
911
- while (command[j] === ' ' || command[j] === '\t') j++;
912
- const w = readWord(command, j);
913
- if (!w.balanced) return { targets, sanitized: out, balanced: false };
914
- if (w.value) targets.push(w.value);
915
- out += ' ';
916
- i = Math.max(w.end, j, i + 1);
917
- continue;
918
- }
919
1282
 
920
- out += ch;
921
- i++;
1283
+ if (ch === '\n') {
1284
+ out += '\n';
1285
+ i++;
1286
+ while (heredocs.length) {
1287
+ const { delim, stripTabs } = heredocs.shift();
1288
+ i = skipHeredocBody(command, i, delim, stripTabs);
1289
+ }
1290
+ continue;
1291
+ }
1292
+
1293
+ if (ch === '\\' && i + 1 < command.length) { out += ch + command[i + 1]; i += 2; continue; }
1294
+
1295
+ // ANSI-C `$'…'`: a backslash escapes the closing quote, which the old mask
1296
+ // did not know (MED-1). Folded to a plain literal for the write-verb lexer.
1297
+ if (ch === '$' && command[i + 1] === "'") {
1298
+ const end = skipQuotedRun(command, i + 1, true);
1299
+ if (end === -1) return null;
1300
+ const literal = command.slice(i + 2, end - 1).replace(/\\(.)/g, '$1').replace(/'/g, '');
1301
+ out += `'${literal}'`;
1302
+ i = end;
1303
+ continue;
1304
+ }
1305
+ // `$"…"` (locale string) is a double-quoted run without its `$`.
1306
+ if (ch === '"' || (ch === '$' && command[i + 1] === '"')) {
1307
+ const run = scanDouble(ch === '"' ? i : i + 1, subs);
1308
+ if (!run) return null;
1309
+ out += run.out;
1310
+ i = run.end;
1311
+ continue;
1312
+ }
1313
+ if (ch === "'") {
1314
+ const end = skipQuotedRun(command, i, false);
1315
+ if (end === -1) return null;
1316
+ out += command.slice(i, end);
1317
+ i = end;
1318
+ continue;
1319
+ }
1320
+
1321
+ // Here-doc: remember the delimiter now, skip the body at the next newline.
1322
+ // `<<<` is a here-STRING, not a here-doc, and needs no body skipping.
1323
+ if (ch === '<' && command[i + 1] === '<' && command[i + 2] !== '<') {
1324
+ let j = i + 2;
1325
+ let stripTabs = false;
1326
+ if (command[j] === '-') { stripTabs = true; j++; }
1327
+ while (command[j] === ' ' || command[j] === '\t') j++;
1328
+ const w = readWord(command, j);
1329
+ if (!w.balanced) return null;
1330
+ if (w.value) heredocs.push({ delim: w.value, stripTabs });
1331
+ out += ' ';
1332
+ i = Math.max(w.end, i + 2);
1333
+ continue;
1334
+ }
1335
+
1336
+ // Redirection: `>` `>>` `>|` `N>` `&>` `&>>`. The optional leading fd digits
1337
+ // are ordinary characters we already copied; only the operator matters.
1338
+ if (ch === '>' || (ch === '&' && command[i + 1] === '>')) {
1339
+ let j = ch === '&' ? i + 2 : i + 1;
1340
+ if (command[j] === '>') j++;
1341
+ if (command[j] === '|') j++;
1342
+ if (command[j] === '&') {
1343
+ const dup = readWord(command, j + 1);
1344
+ if (FD_DUP_RE.test(dup.value)) { out += ' '; i = Math.max(dup.end, j + 1); continue; }
1345
+ j += 1;
1346
+ }
1347
+ while (command[j] === ' ' || command[j] === '\t') j++;
1348
+ // #1670 — a substitution inside the target belongs to the word; its body
1349
+ // is still recorded and judged as its own command line.
1350
+ const w = readWord(command, j, (bodyStart, stop) => substitution(bodyStart, stop, subs));
1351
+ if (!w.balanced) return null;
1352
+ if (w.value) targets.push(w.value);
1353
+ // The basename comes from a substitution and the word mentions the
1354
+ // ledger — or (#1671) the substitution could spell part of its name in
1355
+ // the ledger directory: not statically resolvable, judged fail-closed.
1356
+ if (w.dynamic && !refersToLedger(w.value) &&
1357
+ (w.value.includes(LEDGER_BASENAME) || mayNameLedgerDynamically(w.skeleton))) {
1358
+ unresolvedTargets.push(w.value);
1359
+ }
1360
+ out += ' ';
1361
+ i = Math.max(w.end, j, i + 1);
1362
+ continue;
1363
+ }
1364
+
1365
+ // #1665 — substitutions keep their word whole (placeholder), subshells and
1366
+ // brace groups open a new command position (separator). See the docblock.
1367
+ const isDollarParen = ch === '$' && command[i + 1] === '(';
1368
+ if (isDollarParen || ch === '`') {
1369
+ const end = substitution(isDollarParen ? i + 2 : i + 1, isDollarParen ? ')' : '`', subs);
1370
+ if (end === -1) return null;
1371
+ out += SUBST_PLACEHOLDER;
1372
+ i = end;
1373
+ continue;
1374
+ }
1375
+ if (ch === '(') {
1376
+ const inner = scanSpan(i + 1, ')', subs);
1377
+ if (!inner) return null;
1378
+ out += ` ; ${inner.out} ; `;
1379
+ i = inner.end;
1380
+ continue;
1381
+ }
1382
+ // A `)` nothing here is waiting for (a `case` pattern, a stray close).
1383
+ if (ch === ')') { out += ' ; '; i++; continue; }
1384
+ if (ch === '{' && atWordStart(i) && /\s/.test(command[i + 1] ?? '')) { out += ' ; '; i++; continue; }
1385
+ if (ch === '}' && atWordStart(i) && (i + 1 === command.length || /[\s;|&)`]/.test(command[i + 1]))) {
1386
+ out += ' ; ';
1387
+ i++;
1388
+ continue;
1389
+ }
1390
+
1391
+ out += ch;
1392
+ i++;
1393
+ }
1394
+ return stop === null ? { out, end: i } : null;
922
1395
  }
923
1396
 
924
- return { targets, sanitized: out, balanced: true };
1397
+ const top = scanSpan(0, null, substitutions);
1398
+ return {
1399
+ targets,
1400
+ unresolvedTargets,
1401
+ sanitized: top ? top.out : '',
1402
+ balanced: top !== null && !tooDeep,
1403
+ substitutions,
1404
+ unparsedSubstitution,
1405
+ };
925
1406
  }
926
1407
 
927
1408
  // ---------------------------------------------------------------------------
@@ -1271,7 +1752,8 @@ function findWriteVerbTarget(command, depth = 0, marks = []) {
1271
1752
  // lives beside that table (command-blocker.mjs, the WRAPPER_UNWRAP docblock).
1272
1753
  for (const wa of wrapperArgs) {
1273
1754
  if (wa.writesFile !== true) continue;
1274
- if (typeof wa.value === 'string' && refersToLedger(wa.value)) return wa.value;
1755
+ const hit = typeof wa.value === 'string' ? ledgerWriteHit(wa.value) : null;
1756
+ if (hit) return hit;
1275
1757
  }
1276
1758
  }
1277
1759
 
@@ -1321,7 +1803,17 @@ function findWriteVerbTarget(command, depth = 0, marks = []) {
1321
1803
  // actual script operand after Node runtime options, a later exact
1322
1804
  // `--apply`, and no later exact `--dry-run`. The CLI remains the owner of
1323
1805
  // target semantics.
1324
- if (verb === 'node') {
1806
+ //
1807
+ // NAMED CEILING (BV-004, #1671 item 4): the rule keys on the LITERAL
1808
+ // script basename in argv. Not recognised, and therefore allowed: a glob
1809
+ // as the script name (`repair-invalid-session?.mjs` — the shell expands
1810
+ // it), a symlink or copy under another name, the script run via `xargs`,
1811
+ // loaded as a module (`bun -e 'import(…)'`, `node --import <script>`), or
1812
+ // piped into node on stdin. Each needs either filesystem resolution or
1813
+ // payload evaluation this lexical guard does not do; the CLI itself stays
1814
+ // the owner of its apply semantics. REVISIT TRIGGER: a repair --apply
1815
+ // reaching the ledger through one of these shapes is observed.
1816
+ if (NODE_VERBS.has(verb)) {
1325
1817
  const script = resolveNodeScriptOperand(args);
1326
1818
  const hasRepairMention = args.some((arg) => refersToRepairScript(arg.text));
1327
1819
  const hasApply = args.some((arg) => arg.text === '--apply');
@@ -1346,12 +1838,31 @@ function findWriteVerbTarget(command, depth = 0, marks = []) {
1346
1838
  return REPAIR_APPLY_MARKER;
1347
1839
  }
1348
1840
  }
1841
+ } else if (!TEXT_ONLY_VERBS.has(verb)) {
1842
+ // #1670 — the allowance is for `node` only (the closed grammar in
1843
+ // parseRepairApply). Any OTHER runtime or launcher that runs the repair
1844
+ // CLI — `bun`, `bun run`, `deno run -A`, `npx node`, `npm exec -- node`,
1845
+ // a node resolved via `$(which node)` (verb `_`) — mutates the ledger
1846
+ // just the same, and used to ALLOW because this branch only knew Node's
1847
+ // verb names: a broader bypass than `node` itself had. Rule: such a
1848
+ // segment is judged like a node repair-apply WITHOUT the allowance, i.e.
1849
+ // denied whenever the script operand is followed by `--apply` and no
1850
+ // `--dry-run`. Keyed on the operand rather than a runner list, because a
1851
+ // runner list cannot enumerate the next launcher. Verbs that only print
1852
+ // or search text (TEXT_ONLY_VERBS: `echo …`, `grep … --apply`) never run
1853
+ // the script and are exempt — they were false positives of this rule.
1854
+ const at = args.findIndex((arg) => refersToRepairScript(arg.text));
1855
+ const laterArgs = at === -1 ? [] : args.slice(at + 1);
1856
+ if (laterArgs.some((arg) => arg.text === '--apply') && !laterArgs.some((arg) => arg.text === '--dry-run')) {
1857
+ return `${REPAIR_APPLY_MARKER} (run by '${verb}', not node — only the node forms are sanctioned)`;
1858
+ }
1349
1859
  }
1350
1860
 
1351
1861
  if (verb === 'tee') {
1352
1862
  for (const arg of args) {
1353
1863
  if (!arg.quoted && arg.text.startsWith('-')) continue;
1354
- if (refersToLedger(arg.text)) return arg.text;
1864
+ const hit = ledgerWriteHit(arg.text);
1865
+ if (hit) return hit;
1355
1866
  }
1356
1867
  continue;
1357
1868
  }
@@ -1359,7 +1870,8 @@ function findWriteVerbTarget(command, depth = 0, marks = []) {
1359
1870
  if (verb === 'dd') {
1360
1871
  for (const arg of args) {
1361
1872
  const m = /^of=(.*)$/.exec(arg.text);
1362
- if (m && refersToLedger(m[1])) return m[1];
1873
+ const hit = m ? ledgerWriteHit(m[1]) : null;
1874
+ if (hit) return hit;
1363
1875
  }
1364
1876
  continue;
1365
1877
  }
@@ -1368,7 +1880,8 @@ function findWriteVerbTarget(command, depth = 0, marks = []) {
1368
1880
  const operands = args.filter((a) => a.quoted || !a.text.startsWith('-'));
1369
1881
  // `cp a b` writes b; `cp ledger backup` READS the ledger and must pass.
1370
1882
  const dest = operands.length >= 2 ? operands[operands.length - 1] : null;
1371
- if (dest && refersToLedger(dest.text)) return dest.text;
1883
+ const hit = dest ? ledgerWriteHit(dest.text) : null;
1884
+ if (hit) return hit;
1372
1885
  }
1373
1886
  }
1374
1887
  }
@@ -1393,8 +1906,15 @@ function findWriteVerbTarget(command, depth = 0, marks = []) {
1393
1906
  function findLedgerWrite(command, depth = 0, marks = []) {
1394
1907
  if (typeof command !== 'string' || command.length === 0) return null;
1395
1908
  // Cheap pre-filter: commands naming neither the ledger nor the internal repair
1396
- // CLI cannot reach either deny path.
1397
- if (!command.includes(LEDGER_BASENAME) && !command.includes(REPAIR_SCRIPT_BASENAME)) return null;
1909
+ // CLI cannot reach either deny path. #1671: judged with quotes and backslashes
1910
+ // stripped (`"sessions".jsonl`, `sessions\.jsonl` name the ledger to bash),
1911
+ // and a substitution in the ledger directory passes too — it may spell the name.
1912
+ const unquoted = command.replace(/["'\\]/g, '');
1913
+ if (
1914
+ !unquoted.includes(LEDGER_BASENAME) &&
1915
+ !unquoted.includes(REPAIR_SCRIPT_BASENAME) &&
1916
+ !(unquoted.includes(`${LEDGER_DIR}/`) && /\$\(|`/.test(command))
1917
+ ) return null;
1398
1918
 
1399
1919
  const scan = scanCommand(command);
1400
1920
  for (const target of scan.targets) {
@@ -1407,7 +1927,27 @@ function findLedgerWrite(command, depth = 0, marks = []) {
1407
1927
  if (!scan.balanced) {
1408
1928
  return `${LEDGER_BASENAME} (unbalanced quote — command not parseable, denied fail-closed)`;
1409
1929
  }
1410
- return findWriteVerbTarget(scan.sanitized, depth, marks);
1930
+ // #1670 — same direction for a redirect target whose basename only a command
1931
+ // substitution decides (`> $(echo …/sessions.jsonl)`): the guard cannot know
1932
+ // what it names, and the word mentions the ledger.
1933
+ if (scan.unresolvedTargets.length > 0) {
1934
+ return `${scan.unresolvedTargets[0]} (command substitution decides the target — not statically resolvable, denied fail-closed)`;
1935
+ }
1936
+ if (scan.unparsedSubstitution) marks.push('unparsed-substitution');
1937
+ const direct = findWriteVerbTarget(scan.sanitized, depth, marks);
1938
+ if (direct) return direct;
1939
+ // #1665 — every `$(…)` / backtick body, quoted or not, runs as its own
1940
+ // command line; judged by the SAME matcher, bounded like every other payload
1941
+ // recursion.
1942
+ for (const body of scan.substitutions) {
1943
+ if (depth >= MAX_PAYLOAD_DEPTH) {
1944
+ if (body.includes(LEDGER_BASENAME) || body.includes(REPAIR_SCRIPT_BASENAME)) marks.push('depth-exceeded');
1945
+ continue;
1946
+ }
1947
+ const hit = findLedgerWrite(body, depth + 1, marks);
1948
+ if (hit) return hit;
1949
+ }
1950
+ return null;
1411
1951
  }
1412
1952
 
1413
1953
  /**
@@ -1474,20 +2014,48 @@ async function main() {
1474
2014
  if (trimmedCommand === REPAIR_APPLY_CANONICAL_COMMAND) {
1475
2015
  return flushNotices(notices);
1476
2016
  }
2017
+ // #1577/#1656 — the closed explicit-root grammar (relative script or this
2018
+ // plugin's own absolute copy). Judged on the RAW command: trailing
2019
+ // newlines/CR and every other addition fall outside it.
2020
+ if (await allowsRepairApply(command)) return flushNotices(notices);
2021
+ // Absolute repair candidates get an explicit DENY even when shell wrapping
2022
+ // would hide `node` from the general matcher. Never trim the candidate here:
2023
+ // newlines/CR and all trailing syntax are outside this closed exception.
2024
+ const unwrapped = unwrapSubshellLayers(trimmedCommand);
2025
+ const absoluteOperand = /^(?:node|nodejs)\s+("\/[^"]+"|'\/[^']+'|\/[^\s]+)/.exec(unwrapped)?.[1];
2026
+ const absoluteScript = absoluteOperand?.replace(/^["']|["']$/g, '');
2027
+ if (refersToRepairScript(absoluteScript) && /--apply(?:[\s;|&<>)]|$)/.test(unwrapped)) {
2028
+ emitDeny(
2029
+ `Direct write to the sessions ledger blocked: '${REPAIR_APPLY_MARKER}'\n` +
2030
+ `Absolute repair requires this plugin's own script (${REPAIR_ABSOLUTE_SCRIPT}), --apply and ` +
2031
+ `exactly one --repo-root <ABS>: an absolute Git top-level without symlinks, whitespace or ` +
2032
+ `shell characters that holds .orchestrator/metrics/sessions.jsonl. ` +
2033
+ `Vault roots and descendants are forbidden repair targets. ` +
2034
+ `Extra syntax or an unmeasurable Git context is denied (#1577, #1656).\n` +
2035
+ `Sanctioned forms:\n${REPAIR_SANCTIONED_FORMS}`
2036
+ );
2037
+ return;
2038
+ }
1477
2039
  // The SAME canonical command wrapped in one layer of command substitution /
1478
2040
  // backticks / a bare subshell is a DIFFERENT command — bash still executes
1479
2041
  // the repair mutation for the substitution's side effect, but it no longer
1480
- // matches the exact sanctioned string, so `sonst weiter DENY` applies. This
1481
- // also closes a real gap the exact-match alone would not: `blocker`'s
1482
- // tokenizer glues a leading `$(`/`` ` ``/`(` onto `node`, so the EXISTING
1483
- // verb-based matcher below never resolves verb `node` for a wrapped command
1484
- // and silently allowed it before this hook had any opinion on the subject.
1485
- if (unwrapSubshellLayers(trimmedCommand) === REPAIR_APPLY_CANONICAL_COMMAND) {
2042
+ // matches the exact sanctioned string, so `sonst weiter DENY` applies. When
2043
+ // this was written (#385) it also closed a real gap: the tokenizer then glued
2044
+ // a leading `$(`/`` ` ``/`(` onto `node`, so the verb matcher below missed a
2045
+ // wrapped command. Since #1665/#1667 the scanner and the shared tokenizer
2046
+ // split those spans; this branch keeps the wrapped form's own deny text.
2047
+ // #1656: not only the canonical string — ANY repair-apply invocation the
2048
+ // matcher recognizes once unwrapped (`(node scripts/… --apply --repo-root X)`,
2049
+ // `$(… --apply --json)`) was a silent ALLOW at HEAD for the same reason.
2050
+ if (
2051
+ unwrapped === REPAIR_APPLY_CANONICAL_COMMAND ||
2052
+ (unwrapped !== trimmedCommand && findLedgerWrite(unwrapped)?.startsWith(REPAIR_APPLY_MARKER))
2053
+ ) {
1486
2054
  emitDeny(
1487
2055
  [
1488
2056
  `Direct write to the sessions ledger blocked: '${REPAIR_APPLY_MARKER} (wrapped in a subshell/backticks — denied)'`,
1489
- `The sanctioned repair invocation is allowed ONLY as the exact bare command:`,
1490
- ` ${REPAIR_APPLY_CANONICAL_COMMAND}`,
2057
+ `The sanctioned repair invocation is allowed ONLY as one of these bare commands:`,
2058
+ REPAIR_SANCTIONED_FORMS,
1491
2059
  `Wrapping it in $(...), backticks, or a subshell is denied even though the`,
1492
2060
  `command is otherwise identical — see hooks/pre-bash-sessions-ledger-guard.mjs #385.`,
1493
2061
  `Override (intentional maintenance only): run the session with`,
@@ -1532,9 +2100,9 @@ async function main() {
1532
2100
  emitDeny(
1533
2101
  [
1534
2102
  `Direct write to the sessions ledger blocked: '${shown}'`,
1535
- `The sanctioned repair invocation is allowed ONLY as the exact bare command:`,
1536
- ` ${REPAIR_APPLY_CANONICAL_COMMAND}`,
1537
- `Any variant — an extra flag, a wrapper, an env prefix — is denied.`,
2103
+ `The sanctioned repair invocation is allowed ONLY as one of these bare commands:`,
2104
+ REPAIR_SANCTIONED_FORMS,
2105
+ `Any other variant — an extra flag, a wrapper, an env prefix — is denied.`,
1538
2106
  `A SO_DISABLED_HOOKS=… prefix INSIDE the command has no effect: this hook reads`,
1539
2107
  `it from the environment of the whole session, never from the command text.`,
1540
2108
  `Override (intentional maintenance only, operator-side — e.g. started with \`!\`):`,