session-orchestrator 5.2.0 → 5.3.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 (295) hide show
  1. package/.agents/skills/architecture/SKILL.md +3 -1
  2. package/.agents/skills/autopilot/SKILL.md +5 -1
  3. package/.agents/skills/autopilot/agents/openai.yaml +5 -0
  4. package/.agents/skills/bootstrap/SKILL.md +5 -1
  5. package/.agents/skills/bootstrap/agents/openai.yaml +5 -0
  6. package/.agents/skills/brainstorm/SKILL.md +5 -1
  7. package/.agents/skills/brainstorm/agents/openai.yaml +5 -0
  8. package/.agents/skills/claude-md-drift-check/SKILL.md +3 -1
  9. package/.agents/skills/close/SKILL.md +5 -1
  10. package/.agents/skills/close/agents/openai.yaml +5 -0
  11. package/.agents/skills/convergence-monitoring/SKILL.md +4 -2
  12. package/.agents/skills/debug/SKILL.md +5 -1
  13. package/.agents/skills/debug/agents/openai.yaml +5 -0
  14. package/.agents/skills/discovery/SKILL.md +5 -1
  15. package/.agents/skills/discovery/agents/openai.yaml +5 -0
  16. package/.agents/skills/dispatcher/SKILL.md +5 -1
  17. package/.agents/skills/dispatcher/agents/openai.yaml +5 -0
  18. package/.agents/skills/docs-orchestrator/SKILL.md +3 -1
  19. package/.agents/skills/ecosystem-health/SKILL.md +3 -1
  20. package/.agents/skills/eli5/SKILL.md +5 -1
  21. package/.agents/skills/eli5/agents/openai.yaml +5 -0
  22. package/.agents/skills/eval/SKILL.md +6 -2
  23. package/.agents/skills/eval/agents/openai.yaml +5 -0
  24. package/.agents/skills/evolve/SKILL.md +6 -2
  25. package/.agents/skills/evolve/agents/openai.yaml +5 -0
  26. package/.agents/skills/frontmatter-guard/SKILL.md +3 -1
  27. package/.agents/skills/gitlab-ops/SKILL.md +3 -1
  28. package/.agents/skills/gitlab-portfolio/SKILL.md +3 -1
  29. package/.agents/skills/go/SKILL.md +5 -1
  30. package/.agents/skills/go/agents/openai.yaml +5 -0
  31. package/.agents/skills/grill/SKILL.md +5 -1
  32. package/.agents/skills/grill/agents/openai.yaml +5 -0
  33. package/.agents/skills/harness-audit/SKILL.md +5 -1
  34. package/.agents/skills/harness-audit/agents/openai.yaml +5 -0
  35. package/.agents/skills/hook-development/SKILL.md +3 -1
  36. package/.agents/skills/mcp-builder/SKILL.md +3 -1
  37. package/.agents/skills/memory-cleanup/SKILL.md +5 -1
  38. package/.agents/skills/memory-cleanup/agents/openai.yaml +5 -0
  39. package/.agents/skills/mode-selector/SKILL.md +3 -1
  40. package/.agents/skills/npm-publish/SKILL.md +4 -2
  41. package/.agents/skills/peekaboo-driver/SKILL.md +3 -1
  42. package/.agents/skills/persona-panel/SKILL.md +5 -1
  43. package/.agents/skills/persona-panel/agents/openai.yaml +5 -0
  44. package/.agents/skills/plan/SKILL.md +5 -1
  45. package/.agents/skills/plan/agents/openai.yaml +5 -0
  46. package/.agents/skills/playwright-driver/SKILL.md +3 -1
  47. package/.agents/skills/portfolio/SKILL.md +5 -1
  48. package/.agents/skills/portfolio/agents/openai.yaml +5 -0
  49. package/.agents/skills/quality-gates/SKILL.md +3 -1
  50. package/.agents/skills/reconcile/SKILL.md +5 -1
  51. package/.agents/skills/reconcile/agents/openai.yaml +5 -0
  52. package/.agents/skills/release/SKILL.md +5 -1
  53. package/.agents/skills/release/agents/openai.yaml +5 -0
  54. package/.agents/skills/remote-offload/SKILL.md +3 -1
  55. package/.agents/skills/repo-audit/SKILL.md +5 -1
  56. package/.agents/skills/repo-audit/agents/openai.yaml +5 -0
  57. package/.agents/skills/session/SKILL.md +21 -0
  58. package/.agents/skills/session/agents/openai.yaml +5 -0
  59. package/.agents/skills/session-end/SKILL.md +3 -1
  60. package/.agents/skills/session-plan/SKILL.md +3 -1
  61. package/.agents/skills/session-start/SKILL.md +3 -1
  62. package/.agents/skills/spinout/SKILL.md +5 -1
  63. package/.agents/skills/spinout/agents/openai.yaml +5 -0
  64. package/.agents/skills/sunset-review/SKILL.md +5 -1
  65. package/.agents/skills/sunset-review/agents/openai.yaml +5 -0
  66. package/.agents/skills/templates-ack/SKILL.md +21 -0
  67. package/.agents/skills/templates-ack/agents/openai.yaml +5 -0
  68. package/.agents/skills/test/SKILL.md +5 -1
  69. package/.agents/skills/test/agents/openai.yaml +5 -0
  70. package/.agents/skills/test-runner/SKILL.md +3 -1
  71. package/.agents/skills/tmux-layout/SKILL.md +3 -1
  72. package/.agents/skills/using-orchestrator/SKILL.md +3 -1
  73. package/.agents/skills/ux-grill/SKILL.md +5 -1
  74. package/.agents/skills/ux-grill/agents/openai.yaml +5 -0
  75. package/.agents/skills/vault-mirror/SKILL.md +3 -1
  76. package/.agents/skills/vault-sync/SKILL.md +3 -1
  77. package/.agents/skills/wave-executor/SKILL.md +3 -1
  78. package/.agents/skills/write-executable-plan/SKILL.md +3 -1
  79. package/.claude-plugin/marketplace.json +1 -1
  80. package/.claude-plugin/plugin.json +1 -1
  81. package/.codex-plugin/plugin.json +4 -4
  82. package/.codex-plugin/skills/convergence-monitoring/SKILL.md +1 -3
  83. package/.codex-plugin/skills/eval/SKILL.md +1 -1
  84. package/.codex-plugin/skills/evolve/SKILL.md +1 -1
  85. package/.codex-plugin/skills/npm-publish/SKILL.md +1 -3
  86. package/.codex-plugin/skills/session/SKILL.md +1 -1
  87. package/.cursor/commands/eval.md +1 -1
  88. package/.cursor/commands/session.md +1 -1
  89. package/.cursor/rules/000-session-orchestrator.mdc +0 -2
  90. package/.cursor/rules/050-plan.mdc +1 -1
  91. package/.cursor/skills/convergence-monitoring/SKILL.md +1 -0
  92. package/.cursor/skills/eval/SKILL.md +1 -1
  93. package/.cursor/skills/npm-publish/SKILL.md +1 -0
  94. package/.cursor-plugin/plugin.json +1 -1
  95. package/.orchestrator/policy/blocked-commands.json +12 -3
  96. package/AGENTS.md +3 -2
  97. package/CHANGELOG.md +136 -0
  98. package/README.md +9 -9
  99. package/SECURITY.md +12 -0
  100. package/agents/dialectic-deriver.md +13 -10
  101. package/agents/eval-judge.md +67 -45
  102. package/agents/skill-applied-judge.md +34 -19
  103. package/commands/session.md +7 -3
  104. package/docs/baseline.md +12 -6
  105. package/docs/codex-setup.md +14 -2
  106. package/docs/components.md +7 -5
  107. package/docs/events-schema.md +56 -9
  108. package/docs/rule-authoring.md +58 -6
  109. package/docs/session-config-reference.md +100 -7
  110. package/docs/session-config-template.md +31 -2
  111. package/docs/telemetry.md +2 -0
  112. package/hooks/_lib/hook-import-set.json +85 -8
  113. package/hooks/_lib/subagent-transcript.mjs +582 -31
  114. package/hooks/config-protection.mjs +11 -3
  115. package/hooks/cwd-change-restore.mjs +11 -3
  116. package/hooks/enforce-commands.mjs +70 -23
  117. package/hooks/enforce-scope.mjs +143 -33
  118. package/hooks/hooks-codex.json +1 -1
  119. package/hooks/hooks.json +1 -1
  120. package/hooks/loop-guard.mjs +11 -3
  121. package/hooks/on-session-end.mjs +58 -23
  122. package/hooks/on-session-start.mjs +48 -11
  123. package/hooks/on-stop.mjs +168 -22
  124. package/hooks/operator-steer.mjs +11 -3
  125. package/hooks/post-bash-issue-budget-refund.mjs +18 -8
  126. package/hooks/post-bash-write-verify.mjs +3 -2
  127. package/hooks/post-edit-import-probe.mjs +17 -9
  128. package/hooks/post-edit-validate.mjs +13 -5
  129. package/hooks/post-subagent-discovery-validator.mjs +98 -13
  130. package/hooks/post-tool-batch-wave-signal.mjs +200 -38
  131. package/hooks/post-tool-failure-corrective-context.mjs +11 -5
  132. package/hooks/post-tooluse-frontend-slop.mjs +10 -4
  133. package/hooks/pre-auq-clarity.mjs +15 -2
  134. package/hooks/pre-bash-destructive-guard.mjs +80 -9
  135. package/hooks/pre-bash-issue-budget.mjs +16 -11
  136. package/hooks/pre-bash-memory-propose-audit.mjs +86 -54
  137. package/hooks/pre-bash-sessions-ledger-guard.mjs +391 -20
  138. package/hooks/pre-bash-staging-fence.mjs +335 -31
  139. package/hooks/pre-bash-templates-first.mjs +19 -14
  140. package/hooks/pre-task-scope-disjoint.mjs +233 -2
  141. package/hooks/subagent-telemetry.mjs +15 -19
  142. package/hooks/wave-scope-commit-guard.mjs +197 -100
  143. package/monitors/monitors.json +1 -1
  144. package/output-styles/wave-summary.md +1 -1
  145. package/package.json +1 -1
  146. package/pi/prompts/eval.md +1 -1
  147. package/pi/prompts/session.md +1 -1
  148. package/rules/README.md +1 -1
  149. package/rules/opt-in-domain/prompt-caching.md +1 -1
  150. package/rules/opt-in-stack/backend-data.md +1 -1
  151. package/rules/opt-in-stack/backend.md +3 -3
  152. package/rules/opt-in-stack/frontend.md +1 -1
  153. package/rules/opt-in-stack/security-web.md +3 -3
  154. package/rules/opt-in-stack/swift.md +1 -1
  155. package/scripts/autopilot.mjs +23 -2
  156. package/scripts/backfill-abandoned-sessions.mjs +117 -15
  157. package/scripts/check-sessions-integrity.mjs +300 -0
  158. package/scripts/dialectic-deriver.mjs +50 -13
  159. package/scripts/emit-session.mjs +75 -29
  160. package/scripts/eval-session.mjs +65 -3
  161. package/scripts/generate-agents-skills.mjs +102 -29
  162. package/scripts/generate-cursor-adapter.mjs +61 -16
  163. package/scripts/lib/agent-status.mjs +2 -31
  164. package/scripts/lib/auq/clarity.mjs +10 -2
  165. package/scripts/lib/auq/parse.mjs +12 -31
  166. package/scripts/lib/auq/schema.mjs +56 -41
  167. package/scripts/lib/auto-dialectic.mjs +304 -15
  168. package/scripts/lib/autopilot/flags.mjs +12 -1
  169. package/scripts/lib/autopilot/kill-switches.mjs +6 -3
  170. package/scripts/lib/autopilot/loop.mjs +14 -1
  171. package/scripts/lib/autopilot/stall-sampler.mjs +80 -23
  172. package/scripts/lib/ci-status-banner.mjs +376 -16
  173. package/scripts/lib/command-blocker.mjs +275 -28
  174. package/scripts/lib/config/dialectic.mjs +12 -3
  175. package/scripts/lib/config/gate.mjs +74 -0
  176. package/scripts/lib/config/reaper.mjs +162 -0
  177. package/scripts/lib/config.mjs +14 -0
  178. package/scripts/lib/convergence-monitor.mjs +74 -11
  179. package/scripts/lib/ecosystem-health.mjs +11 -0
  180. package/scripts/lib/eval/engine.mjs +421 -53
  181. package/scripts/lib/eval/judge.mjs +463 -40
  182. package/scripts/lib/eval/schema.mjs +10 -1
  183. package/scripts/lib/events-rotation.mjs +221 -25
  184. package/scripts/lib/events-schema.mjs +114 -0
  185. package/scripts/lib/events.mjs +524 -5
  186. package/scripts/lib/frontmatter-guard.mjs +21 -10
  187. package/scripts/lib/gates/gate-baseline.mjs +27 -2
  188. package/scripts/lib/gates/gate-full.mjs +28 -3
  189. package/scripts/lib/gates/gate-helpers.mjs +243 -21
  190. package/scripts/lib/gates/gate-incremental.mjs +28 -3
  191. package/scripts/lib/gates/gate-per-file.mjs +27 -2
  192. package/scripts/lib/gitlab-portfolio/markdown-writer.mjs +6 -1
  193. package/scripts/lib/instruction-budget-guard.mjs +146 -4
  194. package/scripts/lib/io.mjs +42 -8
  195. package/scripts/lib/issue-close-strip-labels.mjs +207 -49
  196. package/scripts/lib/js-mask.mjs +197 -0
  197. package/scripts/lib/learnings/evolve-telemetry.mjs +11 -7
  198. package/scripts/lib/maintenance-due-banner.mjs +53 -88
  199. package/scripts/lib/orphan-reaper.mjs +1588 -0
  200. package/scripts/lib/peer-cards/merger.mjs +48 -10
  201. package/scripts/lib/peer-cards/reader.mjs +78 -2
  202. package/scripts/lib/process-group.mjs +899 -0
  203. package/scripts/lib/quality-gate.mjs +107 -28
  204. package/scripts/lib/reconcile/backlog.mjs +368 -0
  205. package/scripts/lib/reconcile/engine.mjs +55 -188
  206. package/scripts/lib/reconcile/rule-expiry-sweep.mjs +302 -60
  207. package/scripts/lib/reconcile/sanitize.mjs +69 -3
  208. package/scripts/lib/reconcile-nudge-banner.mjs +138 -45
  209. package/scripts/lib/resource-probe/parsers.mjs +31 -0
  210. package/scripts/lib/rule-loader.mjs +41 -12
  211. package/scripts/lib/scope-echo.mjs +39 -2
  212. package/scripts/lib/scope-gate.mjs +605 -1
  213. package/scripts/lib/session-close-backfill.mjs +33 -6
  214. package/scripts/lib/session-id.mjs +9 -20
  215. package/scripts/lib/session-invocation.mjs +20 -0
  216. package/scripts/lib/session-schema/constants.mjs +30 -2
  217. package/scripts/lib/session-schema/normalizer.mjs +56 -4
  218. package/scripts/lib/session-schema.mjs +8 -3
  219. package/scripts/lib/session-start-probes.mjs +95 -10
  220. package/scripts/lib/sessions-canonical.mjs +23 -0
  221. package/scripts/lib/sessions-integrity-banner.mjs +7 -1
  222. package/scripts/lib/sessions-staleness-banner.mjs +193 -51
  223. package/scripts/lib/skill-evidence-window.mjs +891 -0
  224. package/scripts/lib/skill-evolution/candidate-intake.mjs +133 -12
  225. package/scripts/lib/skill-evolution/engine.mjs +18 -9
  226. package/scripts/lib/skill-judge.mjs +45 -3
  227. package/scripts/lib/tail-window.mjs +56 -0
  228. package/scripts/lib/telemetry/schema.mjs +30 -0
  229. package/scripts/lib/telemetry/sync.mjs +61 -6
  230. package/scripts/lib/telemetry-flush-health-banner.mjs +4 -22
  231. package/scripts/lib/test-runner/issue-reconcile.mjs +48 -16
  232. package/scripts/lib/tmux-layout/telemetry-stats.mjs +72 -13
  233. package/scripts/lib/user-invocable-skills.mjs +23 -3
  234. package/scripts/lib/ux-grill/reconcile.mjs +48 -22
  235. package/scripts/lib/validate/check-agents-skills.mjs +26 -15
  236. package/scripts/lib/validate/check-cursor-adapter.mjs +1 -0
  237. package/scripts/lib/validate/check-entry-guard.mjs +13 -50
  238. package/scripts/lib/validate/check-hook-entry-guards.mjs +636 -0
  239. package/scripts/lib/validate/check-pi-prompts.mjs +1 -0
  240. package/scripts/lib/validate/check-rules.mjs +7 -5
  241. package/scripts/lib/validate/check-skill-links.mjs +9 -1
  242. package/scripts/lib/validate/check-skill-script-paths.mjs +239 -27
  243. package/scripts/lib/validate/check-test-git-config-target.mjs +24 -34
  244. package/scripts/lib/validate/check-untracked-test-deps.mjs +7 -102
  245. package/scripts/lib/validate/check-unwired-features.mjs +130 -27
  246. package/scripts/lib/validate/check-validator-registration.mjs +34 -10
  247. package/scripts/lib/validate/confidential-names.mjs +10 -0
  248. package/scripts/lib/validate-vendored-rules.mjs +4 -3
  249. package/scripts/lib/vault-mirror/namespace.mjs +46 -8
  250. package/scripts/lib/vault-mirror/process.mjs +10 -3
  251. package/scripts/lib/vault-mirror/render-sessions.mjs +12 -2
  252. package/scripts/lib/vault-status/narrative-mirror.mjs +31 -7
  253. package/scripts/lib/vault-yaml.mjs +118 -0
  254. package/scripts/lib/worktree/lifecycle.mjs +153 -1
  255. package/scripts/release-session-lock.mjs +305 -0
  256. package/scripts/release.mjs +30 -5
  257. package/scripts/resolve-session-invocation.mjs +59 -0
  258. package/scripts/run-quality-gate.mjs +156 -17
  259. package/scripts/sweep-expired-rules.mjs +14 -3
  260. package/scripts/validate-plugin.mjs +12 -0
  261. package/scripts/validate-wave-scope.mjs +32 -105
  262. package/scripts/vault-mirror.mjs +9 -1
  263. package/skills/_shared/platform-tools.md +23 -11
  264. package/skills/autopilot/SKILL.md +22 -7
  265. package/skills/claude-md-drift-check/SKILL.md +1 -1
  266. package/skills/convergence-monitoring/README.md +8 -1
  267. package/skills/convergence-monitoring/SIGNALS.md +50 -6
  268. package/skills/convergence-monitoring/SKILL.md +15 -6
  269. package/skills/eval/SKILL.md +39 -24
  270. package/skills/eval/rubric-v1.md +1 -0
  271. package/skills/eval/rubric-v2.md +457 -0
  272. package/skills/evolve/SKILL.md +1 -1
  273. package/skills/evolve/references/evolve-dialectic-mode.md +42 -25
  274. package/skills/gitlab-ops/SKILL.md +3 -2
  275. package/skills/npm-publish/SKILL.md +1 -1
  276. package/skills/reconcile/SKILL.md +11 -0
  277. package/skills/session-end/SKILL.md +13 -16
  278. package/skills/session-end/discovery-scan.md +1 -1
  279. package/skills/session-end/phase-3-6-tail.md +55 -9
  280. package/skills/session-end/references/phase-5-issue-cleanup.md +9 -14
  281. package/skills/session-end/session-metrics-write.md +10 -0
  282. package/skills/session-plan/SKILL.md +17 -5
  283. package/skills/session-plan/references/session-plan-task-classification.md +2 -2
  284. package/skills/session-start/references/phase-4-ssot-environment-check.md +2 -1
  285. package/skills/ux-grill/SKILL.md +1 -1
  286. package/skills/wave-executor/SKILL.md +8 -4
  287. package/skills/wave-executor/circuit-breaker.md +2 -0
  288. package/skills/wave-executor/references/wave-executor-state-init.md +5 -3
  289. package/skills/wave-executor/references/wave-loop-dispatch.md +2 -1
  290. package/.codex-plugin/skills/convergence-monitoring/agents/openai.yaml +0 -5
  291. package/.codex-plugin/skills/npm-publish/agents/openai.yaml +0 -5
  292. package/.cursor/commands/convergence-monitoring.md +0 -13
  293. package/.cursor/commands/npm-publish.md +0 -13
  294. package/pi/prompts/convergence-monitoring.md +0 -11
  295. package/pi/prompts/npm-publish.md +0 -11
@@ -288,6 +288,29 @@ const SHAPE_NONE = 'none';
288
288
  /** The per-dispatch observability record (#1092) — see § Observability. */
289
289
  const SCOPE_EVENT = 'orchestrator.wave_dispatch.scope_checked';
290
290
 
291
+ /**
292
+ * The worktree-base observability record (#1413).
293
+ *
294
+ * Same `wave_dispatch` domain and same `_checked` verb as `SCOPE_EVENT`, and
295
+ * written for EVERY `isolation: "worktree"` dispatch rather than only the
296
+ * alarming one. That is HR-105 applied at the source: a numerator-only stream
297
+ * cannot tell "genuinely rare" from "silently broken", and the firing rate this
298
+ * warning must stay under is then unfalsifiable. With every outcome in one
299
+ * stream the rate is `stale:true / all records of this name`.
300
+ *
301
+ * #1424 widened "every outcome" from two to three: the `stale: true|false`
302
+ * split was already covered, but each NON-measurement (peer STATE.md, no
303
+ * STATE.md, no `session-start-ref`, a git failure) returned early and emitted
304
+ * nothing — so the gate in front of the split reproduced exactly the
305
+ * zero-records ambiguity the split had removed. Those now carry
306
+ * `stale: null` plus a `skipped` reason, and only `isolation !== "worktree"`
307
+ * stays silent (HR-101: it is ~90% of all dispatches).
308
+ */
309
+ const WORKTREE_BASE_EVENT = 'orchestrator.wave_dispatch.worktree_base_checked';
310
+
311
+ /** State-dir candidates, in the same order `waveKeyOf()` probes them. */
312
+ const STATE_DIR_CANDIDATES = ['.pi', '.cursor', '.codex', '.claude'];
313
+
291
314
  /**
292
315
  * The receive-side instruction line `renderScopeEchoInstruction()` renders and
293
316
  * the coordinator appends immediately after the fenced block
@@ -981,6 +1004,163 @@ export function listTrackedFiles(cwd) {
981
1004
  }
982
1005
  }
983
1006
 
1007
+ // ---------------------------------------------------------------------------
1008
+ // Stale worktree base (#1413) — a WARNING, never a decision
1009
+ //
1010
+ // `isolation: "worktree"` makes the HARNESS create `<repo>/.claude/worktrees/
1011
+ // agent-<hex>`; no code in this repo creates it and the `Agent` payload carries
1012
+ // no base-ref field (the 147-dispatch key census in the module docblock). The
1013
+ // base is the SESSION-START commit, and it does not follow a mid-session commit:
1014
+ // measured 2026-09-19 (session s18), worktrees created 25 minutes AFTER commit
1015
+ // `240efda6` still stood on its parent `8f15f77b`. A fix agent then silently
1016
+ // edits the OLD code, and its test run is structurally red on top — `validate-
1017
+ // plugin` is vitest's globalSetup and `check-guard-requires-parity.mjs` compares
1018
+ // against `git show HEAD:` inside the stale worktree.
1019
+ //
1020
+ // We have no lever on the base, so the only available fix is to be LOUD. This
1021
+ // never touches the verdict: the two functions below are TOTAL (every failure
1022
+ // resolves to silence), and the caller ignores their result for the decision.
1023
+ // ---------------------------------------------------------------------------
1024
+
1025
+ /**
1026
+ * Facts for the worktree-base check, as a DISCRIMINATED record with three
1027
+ * outcomes rather than two (#1424):
1028
+ *
1029
+ * - `{stale: true|false, head, session_start_ref, subagent_type?}` — measured.
1030
+ * - `{stale: null, skipped: <reason>}` — the dispatch WAS on the `worktree`
1031
+ * branch, but the question could not be answered honestly. Still emitted,
1032
+ * because HR-105 needs the denominator: while every such case returned bare
1033
+ * `null` and the caller emitted nothing, "the check never had anything to
1034
+ * say" and "the check was silently disarmed" both read as zero records —
1035
+ * precisely the shape this event exists to close.
1036
+ * - `null` — `isolation !== 'worktree'`. SILENT by construction, and the one
1037
+ * outcome that must stay so: `isolation` appeared in 14 of 147 measured
1038
+ * dispatches, so recording the other ~90% would put the denominator on the
1039
+ * whole hot dispatch path (HR-101).
1040
+ *
1041
+ * IDENTITY GATE (`.claude/rules/identity-and-locks.md`): `session-start-ref`
1042
+ * lives in STATE.md, a SHARED working-copy artefact that routinely belongs to a
1043
+ * peer session. It is read only when STATE.md's own `session-id` equals this
1044
+ * process's raw id (hook payload `session_id`, else `CLAUDE_CODE_SESSION_ID`) —
1045
+ * a process-local witness REPLACES the shared one, never unions with it. On
1046
+ * mismatch, absence or any read failure this reports `stale: null`: a confident
1047
+ * warning about somebody else's session is worse than no warning at all. The
1048
+ * test runs PER CANDIDATE (`continue`, not `break`) — see the loop below.
1049
+ *
1050
+ * `parseStateMd` is imported DYNAMICALLY and only on the rare `worktree` branch
1051
+ * (14 of 147 measured dispatches carried `isolation` at all): it is a leaf module
1052
+ * with zero imports and already inside the committed hook-import closure, so
1053
+ * reuse costs no new edge, and binding it late keeps a load failure catchable
1054
+ * instead of disarming the guard at ESM link time (§ #993 late-bound deps).
1055
+ *
1056
+ * @param {{tool_input?: unknown}} input — the raw hook payload
1057
+ * @param {string} projectDir
1058
+ * @param {string} sessionId — `input.session_id`, or `'no-session'`
1059
+ * @returns {Promise<{head: string, session_start_ref: string, stale: boolean,
1060
+ * subagent_type?: string}|{stale: null, skipped: string}|null>}
1061
+ */
1062
+ async function worktreeBaseFacts(input, projectDir, sessionId) {
1063
+ // The applicability gate sits OUTSIDE the try on purpose: everything below it
1064
+ // resolves to a `skipped` RECORD, so a throw here must not be able to mint one
1065
+ // for a dispatch that was never on the `worktree` branch at all.
1066
+ const toolInput = input?.tool_input;
1067
+ if (toolInput === null || typeof toolInput !== 'object') return null;
1068
+ if (toolInput.isolation !== 'worktree') return null;
1069
+
1070
+ try {
1071
+ const ownId = sessionId && sessionId !== 'no-session'
1072
+ ? sessionId
1073
+ : (process.env.CLAUDE_CODE_SESSION_ID || '');
1074
+ if (!ownId) return { stale: null, skipped: 'no-own-id' };
1075
+
1076
+ const { parseStateMd } = await import(
1077
+ pathToFileURL(path.join(PLUGIN_ROOT, 'scripts', 'lib', 'state-md', 'yaml-parser.mjs')).href
1078
+ );
1079
+
1080
+ // #1424: the identity test lives INSIDE the loop. Breaking at the first
1081
+ // PARSEABLE STATE.md and testing `session-id` afterwards meant a single
1082
+ // left-over `.pi/STATE.md` — `.pi` sorts before `.claude` in
1083
+ // STATE_DIR_CANDIDATES — disarmed the check for a session whose own
1084
+ // `.claude/STATE.md` sat right there, readable and matching. `sawStateMd`
1085
+ // keeps the two silences apart afterwards: "nothing to read" is a different
1086
+ // fact from "read, and none of it was mine".
1087
+ let frontmatter = null;
1088
+ let sawStateMd = false;
1089
+ for (const dir of STATE_DIR_CANDIDATES) {
1090
+ try {
1091
+ const parsed = parseStateMd(readFileSync(path.join(projectDir, dir, 'STATE.md'), 'utf8'));
1092
+ if (!parsed?.frontmatter) continue;
1093
+ sawStateMd = true;
1094
+ if (parsed.frontmatter['session-id'] !== ownId) continue;
1095
+ frontmatter = parsed.frontmatter;
1096
+ break;
1097
+ } catch { /* try the next state dir */ }
1098
+ }
1099
+ if (frontmatter === null) {
1100
+ return { stale: null, skipped: sawStateMd ? 'identity-mismatch' : 'no-state-md' };
1101
+ }
1102
+
1103
+ const startRef = typeof frontmatter['session-start-ref'] === 'string'
1104
+ ? frontmatter['session-start-ref'].trim()
1105
+ : '';
1106
+ if (startRef === '') return { stale: null, skipped: 'no-start-ref' };
1107
+
1108
+ let head = '';
1109
+ try {
1110
+ head = execFileSync('git', ['rev-parse', 'HEAD'], {
1111
+ cwd: projectDir,
1112
+ encoding: 'utf8',
1113
+ stdio: ['ignore', 'pipe', 'ignore'],
1114
+ }).trim();
1115
+ } catch {
1116
+ return { stale: null, skipped: 'git-error' };
1117
+ }
1118
+ if (head === '') return { stale: null, skipped: 'git-error' };
1119
+
1120
+ const facts = { head, session_start_ref: startRef, stale: head !== startRef };
1121
+ if (typeof toolInput.subagent_type === 'string' && toolInput.subagent_type !== '') {
1122
+ facts.subagent_type = toolInput.subagent_type;
1123
+ }
1124
+ return facts;
1125
+ } catch {
1126
+ // Total by construction: no parser, an unreadable state dir, anything else.
1127
+ // Still a RECORD rather than silence — the dispatch WAS on the `worktree`
1128
+ // branch, and a broken probe that emits nothing is indistinguishable from a
1129
+ // probe that had nothing to report (#1424 / HR-105).
1130
+ return { stale: null, skipped: 'probe-error' };
1131
+ }
1132
+ }
1133
+
1134
+ /**
1135
+ * The operator-facing warning. It names the ACTION, not just the condition —
1136
+ * a warning the coordinator cannot act on is noise (HR-106).
1137
+ *
1138
+ * NO `⚠ ` PREFIX HERE: this text is handed to `emitWarn`, which prefixes it on
1139
+ * both channels it writes (stderr and the `systemMessage` payload). Prefixing
1140
+ * here too produced `⚠ ⚠ …`.
1141
+ *
1142
+ * @param {{head: string, session_start_ref: string}} facts
1143
+ * @returns {string}
1144
+ */
1145
+ function staleWorktreeWarning(facts) {
1146
+ const head = facts.head.slice(0, 12);
1147
+ const base = facts.session_start_ref.slice(0, 12);
1148
+ return [
1149
+ `${HOOK_NAME}: STALE WORKTREE BASE (#1413) — this dispatch uses `
1150
+ + `isolation: "worktree", but HEAD (${head}) has moved past this session's `
1151
+ + `session-start-ref (${base}).`,
1152
+ ' The harness bases a new agent worktree on the SESSION-START commit and offers',
1153
+ ' no base-ref field, so the agent will silently get the OLDER code — and its test',
1154
+ ' run is structurally red on top (check-guard-requires-parity compares against',
1155
+ ' `git show HEAD:`, and validate-plugin is vitest\'s globalSetup).',
1156
+ ' DO ONE OF: dispatch this wave IN-PLACE (omit `isolation`) — or verify the base',
1157
+ ' before trusting the agent\'s result:',
1158
+ ' git worktree list --porcelain | awk -v h="$(git rev-parse HEAD)" '
1159
+ + '\'/^worktree /{w=$2} /^HEAD /{if (w ~ /\\.claude\\/worktrees\\/agent-/ && $2 != h) '
1160
+ + 'print "STALE " substr($2,1,12) " " w}\'',
1161
+ ].join('\n');
1162
+ }
1163
+
984
1164
  /**
985
1165
  * Clip a path for the deny reason without losing the discriminating tail.
986
1166
  *
@@ -1440,8 +1620,59 @@ async function main() {
1440
1620
  } catch { /* observability is best-effort — it never blocks the decision */ }
1441
1621
  }
1442
1622
 
1443
- if (verdict.action === 'deny') return emitDeny(verdict.reason, verdict.suggestion);
1444
- if (verdict.action === 'warn') return emitWarn(verdict.note);
1623
+ // #1413 the stale-worktree-base warning, also awaited BEFORE the terminal
1624
+ // emit for the same process.exit() reason as the block above. It reads
1625
+ // `verdict` not at all: it can never turn an allow into a deny, and a git or
1626
+ // STATE.md failure resolves to silence rather than to a wrong accusation.
1627
+ //
1628
+ // ROUTED TO THE OPERATOR, not to stderr alone (w3-5): under the exit-0
1629
+ // PreToolUse protocol stderr goes nowhere — `io.mjs:545` calls it the "Debug
1630
+ // channel … Invisible under exit 0" — so the first cut of this check announced
1631
+ // the s18 incident class to a log and to nobody. The visible channel is the
1632
+ // top-level `systemMessage`, i.e. `emitWarn`, which carries NO
1633
+ // `permissionDecision` and therefore still means ALLOW (io.mjs § "which is
1634
+ // precisely what keeps warn non-blocking").
1635
+ //
1636
+ // The note is CARRIED to the single terminal emit below rather than emitted
1637
+ // here, because `emitWarn` calls `process.exit(0)` and never returns — warning
1638
+ // inline would terminate the process before a collision DENY could be emitted,
1639
+ // flipping a block into an allow (§ stdout discipline, the same reason
1640
+ // `decide()` is pure).
1641
+ //
1642
+ // `null` here means "not a worktree dispatch" and stays silent; ANY other
1643
+ // shape is emitted, including the `{stale: null, skipped: …}` non-measurements
1644
+ // (#1424). The warning still fires on `stale === true` alone — a skipped probe
1645
+ // accuses nobody.
1646
+ const worktreeBase = await worktreeBaseFacts(input, projectDir, sessionId);
1647
+ let staleNote = null;
1648
+ if (worktreeBase !== null) {
1649
+ if (worktreeBase.stale === true) staleNote = staleWorktreeWarning(worktreeBase);
1650
+ try {
1651
+ const { emitEvent, sessionAttribution } = await import(
1652
+ pathToFileURL(path.join(PLUGIN_ROOT, 'scripts', 'lib', 'events.mjs')).href
1653
+ );
1654
+ await emitEvent(
1655
+ WORKTREE_BASE_EVENT,
1656
+ { hook: HOOK_NAME, ...worktreeBase, ...sessionAttribution(projectDir) },
1657
+ { repoRoot: projectDir }
1658
+ );
1659
+ } catch { /* observability is best-effort — it never blocks the decision */ }
1660
+ }
1661
+
1662
+ // ONE terminal emit, with the #1413 note folded in where a visible channel is
1663
+ // free. On DENY the dispatch does not happen and `systemMessage` already
1664
+ // carries the ⛔ headline, so the note stays on the debug channel — a stale
1665
+ // base is moot for a dispatch that was just blocked.
1666
+ if (verdict.action === 'deny') {
1667
+ if (staleNote !== null) {
1668
+ try { console.error(`⚠ ${staleNote}`); } catch { /* stderr may be closed */ }
1669
+ }
1670
+ return emitDeny(verdict.reason, verdict.suggestion);
1671
+ }
1672
+ if (verdict.action === 'warn') {
1673
+ return emitWarn(staleNote === null ? verdict.note : `${verdict.note}\n\n${staleNote}`);
1674
+ }
1675
+ if (staleNote !== null) return emitWarn(staleNote);
1445
1676
  return emitAllow();
1446
1677
  }
1447
1678
 
@@ -105,14 +105,14 @@
105
105
  */
106
106
 
107
107
  import { shouldRunHook } from './_lib/profile-gate.mjs';
108
- // Exit 0 immediately when disabled via SO_HOOK_PROFILE / SO_DISABLED_HOOKS.
109
- if (!shouldRunHook('subagent-telemetry')) process.exit(0);
108
+ import { isMainModule } from '../scripts/lib/is-main-module.mjs';
110
109
 
111
110
  import fs from 'node:fs';
112
111
  import path from 'node:path';
113
112
  import { appendSubagent } from '../scripts/lib/subagents-schema.mjs';
114
113
  import { getProjectDir } from '../scripts/lib/platform.mjs';
115
114
  import { resolveSubagentSidecar } from './_lib/subagent-paths.mjs';
115
+ import { readTailWindow } from '../scripts/lib/tail-window.mjs';
116
116
 
117
117
  // ---------------------------------------------------------------------------
118
118
  // Constants
@@ -593,25 +593,18 @@ function resolveSubagentTranscriptPath(parentTranscriptPath, agentId) {
593
593
  * @returns {number|null} epoch-ms of the matching start, or null
594
594
  */
595
595
  function findStartTimestampMs(filePath, agentId) {
596
- let fd;
597
596
  try {
598
597
  if (typeof agentId !== 'string' || !agentId.trim()) return null;
599
598
  if (!fs.existsSync(filePath)) return null;
600
599
 
601
- const { size } = fs.statSync(filePath);
602
- if (size === 0) return null;
600
+ const window = readTailWindow(filePath, START_JOIN_TAIL_BYTES);
601
+ if (window.size === 0) return null;
603
602
 
604
- const readLen = Math.min(size, START_JOIN_TAIL_BYTES);
605
- const from = size - readLen;
606
- const buf = Buffer.allocUnsafe(readLen);
607
- fd = fs.openSync(filePath, 'r');
608
- fs.readSync(fd, buf, 0, readLen, from);
609
-
610
- const lines = buf.toString('utf8').split('\n');
603
+ const lines = window.text.split('\n');
611
604
  // When the window does not cover the whole file, the first element is a
612
605
  // record sliced mid-line (possibly mid-UTF-8-sequence). Drop it rather than
613
606
  // feed a corrupt fragment to JSON.parse.
614
- if (from > 0) lines.shift();
607
+ if (window.cut) lines.shift();
615
608
 
616
609
  for (let i = lines.length - 1; i >= 0; i--) {
617
610
  const line = lines[i].trim();
@@ -631,10 +624,6 @@ function findStartTimestampMs(filePath, agentId) {
631
624
  return null;
632
625
  } catch {
633
626
  return null;
634
- } finally {
635
- if (fd !== undefined) {
636
- try { fs.closeSync(fd); } catch { /* ignore */ }
637
- }
638
627
  }
639
628
  }
640
629
 
@@ -815,5 +804,12 @@ async function main() {
815
804
  await appendSubagent(jsonlPath(), record);
816
805
  }
817
806
 
818
- // Exit 0 always informational hook must never block Claude.
819
- main().catch(() => {}).finally(() => process.exit(0));
807
+ // Entry guard (#1393): run only as the node script the harness execs — a bare
808
+ // `import()` must run no handler and must not exit the importing process.
809
+ if (isMainModule(import.meta.url)) {
810
+ // Exit 0 immediately when disabled via SO_HOOK_PROFILE / SO_DISABLED_HOOKS.
811
+ if (!shouldRunHook('subagent-telemetry')) process.exit(0);
812
+
813
+ // Exit 0 always — informational hook must never block Claude.
814
+ main().catch(() => {}).finally(() => process.exit(0));
815
+ }
@@ -41,63 +41,7 @@ import { join } from 'node:path';
41
41
  // consumer repo or a test tmp-dir.
42
42
  import { pathMatchesPattern, findScopeFile } from '../scripts/lib/hardening.mjs';
43
43
  import { withStagingFenceLock } from '../scripts/lib/session-lock.mjs';
44
-
45
- const repoRoot = execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim();
46
- // #801: resolve wave-scope.json via the same precedence every other reader
47
- // uses (.pi/.cursor/.codex/.claude — scope-gate.mjs findScopeFile). The prior
48
- // hardcoded `.orchestrator/wave-scope.json` path was DEAD — the coordinator
49
- // writes wave-scope.json to the state dir (.claude/ on Claude Code; see
50
- // skills/wave-executor/wave-loop.md), so this guard never fired. findScopeFile
51
- // returns null when no scope file exists at any precedence dir, preserving
52
- // the "no active wave → exit 0" semantics below.
53
- const scopePath = findScopeFile(repoRoot);
54
- const fenceDir = join(repoRoot, '.orchestrator', 'staging-fence');
55
-
56
- // ---------------------------------------------------------------------------
57
- // Sub-mode B — allowedPaths check
58
- // ---------------------------------------------------------------------------
59
-
60
- const stagedOutput = execSync('git diff --cached --name-only', { encoding: 'utf8' });
61
- const stagedFiles = stagedOutput.split('\n').filter(Boolean);
62
-
63
- if (scopePath) {
64
- let scope;
65
- try {
66
- scope = JSON.parse(readFileSync(scopePath, 'utf8'));
67
- } catch (err) {
68
- process.stderr.write(`wave-scope-commit-guard: failed to parse wave-scope.json: ${err.message}\n`);
69
- process.exit(1);
70
- }
71
-
72
- const allowedPaths = Array.isArray(scope.allowedPaths) ? scope.allowedPaths : [];
73
- if (allowedPaths.length > 0) {
74
- const violations = stagedFiles.filter(
75
- (f) => !allowedPaths.some((pattern) => pathMatchesPattern(f, pattern)),
76
- );
77
-
78
- if (violations.length > 0) {
79
- process.stderr.write('✗ wave-scope-commit-guard: staged paths outside wave-scope.allowedPaths:\n');
80
- for (const v of violations) process.stderr.write(` - ${v}\n`);
81
- process.stderr.write('\nThese files were likely added by lint-staged eslint --fix / prettier --write.\n');
82
- process.stderr.write('To proceed:\n');
83
- process.stderr.write(' 1) git restore --staged <path> # for each foreign path\n');
84
- process.stderr.write(' 2) git commit # retry\n');
85
- process.exit(1);
86
- }
87
- }
88
- }
89
-
90
- // ---------------------------------------------------------------------------
91
- // Sub-mode C — cross-agent staging-fence reconciliation (issue #552)
92
- // ---------------------------------------------------------------------------
93
- //
94
- // Skip entirely when no fence dir exists OR no staged files. The fence dir
95
- // only appears when a wave-agent invoked `git add` while SO_WAVE_AGENT=1.
96
- // Manual / coordinator commits never write a fence file, so this branch
97
- // short-circuits to exit 0 for them (AC5 safe-default).
98
- if (!existsSync(fenceDir) || stagedFiles.length === 0) {
99
- process.exit(0);
100
- }
44
+ import { isMainModule } from '../scripts/lib/is-main-module.mjs';
101
45
 
102
46
  // Resolve the "current" agent id from SO_WAVE_AGENT_ID (when set by the
103
47
  // caller) or fall back to a PID-derived marker. The fence files themselves
@@ -105,11 +49,20 @@ if (!existsSync(fenceDir) || stagedFiles.length === 0) {
105
49
  // fence entries (we cross-check against SIBLINGS, not ourselves).
106
50
  const ownAgentId = process.env.SO_WAVE_AGENT_ID ?? null;
107
51
 
52
+ /**
53
+ * Recorded by the writer when a staging command stages a set no path operand
54
+ * names (`git add -A`, `git add .`, `git add -u`). Overlaps everything.
55
+ * Mirrors ALL_PATHS_MARKER in hooks/pre-bash-staging-fence.mjs.
56
+ */
57
+ const ALL_PATHS_MARKER = '*';
58
+
108
59
  /**
109
60
  * Build a regex that finds a staged path inside a `git add` command string.
110
61
  * Word-boundary on both sides so `src/foo.ts` does not match `src/foo.ts.bak`.
111
62
  * The path is escaped so glob metacharacters (`*`, `?`) and shell metas
112
63
  * cannot be reinterpreted.
64
+ *
65
+ * LEGACY PATH ONLY since #1404 — see the fallback branch in findOverlaps.
113
66
  */
114
67
  function pathRegex(p) {
115
68
  const escaped = p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -117,8 +70,56 @@ function pathRegex(p) {
117
70
  }
118
71
 
119
72
  /**
120
- * Walk a single sibling fence file and return the staged paths whose path
121
- * pattern matches any of our staged files.
73
+ * Normalise a path token so reader and writer compare the same spelling.
74
+ *
75
+ * DELIBERATE DUPLICATE of the identically-named function in
76
+ * hooks/pre-bash-staging-fence.mjs: this guard is a husky pre-commit hook
77
+ * outside the Claude-Code hook import set, and the two sides must agree byte
78
+ * for byte. Change one, change both.
79
+ *
80
+ * @param {string} raw
81
+ * @returns {string}
82
+ */
83
+ function normalizeStagedPath(raw) {
84
+ let p = String(raw).trim();
85
+ while (p.startsWith('./')) p = p.slice(2);
86
+ p = p.replace(/\/{2,}/g, '/');
87
+ while (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1);
88
+ return p;
89
+ }
90
+
91
+ /**
92
+ * Does a path a sibling recorded overlap one of OUR staged files? Both sides
93
+ * arrive already normalised. A DIRECTORY entry overlaps every file beneath it
94
+ * (`src` overlaps `src/foo.ts`); the marker overlaps everything.
95
+ *
96
+ * @param {string} fencePath
97
+ * @param {string} ourPath
98
+ * @returns {boolean}
99
+ */
100
+ function pathsOverlap(fencePath, ourPath) {
101
+ if (fencePath === ALL_PATHS_MARKER) return true;
102
+ if (fencePath === ourPath) return true;
103
+ return ourPath.startsWith(`${fencePath}/`);
104
+ }
105
+
106
+ /**
107
+ * Walk a single sibling fence file and return the staged paths a sibling
108
+ * agent also recorded an intent to stage.
109
+ *
110
+ * Two entry shapes are accepted:
111
+ * - #1404 shape `{ paths, command_hash, timestamp }` — path LISTS compared
112
+ * against our staged set.
113
+ * - LEGACY shape `{ command, timestamp }` — a fence file written by a
114
+ * pre-#1404 hook version. Live sessions on this host may hold such files
115
+ * RIGHT NOW, and dropping them would read as "no overlap" — silently
116
+ * weaker than before the change. So the old raw-command regex still runs
117
+ * for an entry that has `command` and no `paths`.
118
+ * NAMED CEILING (BV-004): this fallback exists only to span the sessions
119
+ * running across the upgrade. REVISIT TRIGGER — remove it one minor
120
+ * release after 5.3.0 (i.e. in 5.4.0), by which point no process started
121
+ * before the upgrade can still be writing fence files. The command text it
122
+ * reads is never PRINTED (issue #1404) — only the fact of the match is.
122
123
  */
123
124
  function findOverlaps(fenceJsonPath, ourStaged) {
124
125
  let body;
@@ -131,61 +132,157 @@ function findOverlaps(fenceJsonPath, ourStaged) {
131
132
  if (!Array.isArray(body.staged_paths)) return [];
132
133
  if (ownAgentId && body.agent_id === ownAgentId) return []; // skip self
133
134
 
135
+ const siblingAgent = body.agent_id ?? '<unknown>';
134
136
  const matches = [];
135
137
  for (const entry of body.staged_paths) {
138
+ if (Array.isArray(entry?.paths)) {
139
+ const hash = typeof entry.command_hash === 'string' ? entry.command_hash : '<no-hash>';
140
+ for (const raw of entry.paths) {
141
+ if (typeof raw !== 'string') continue;
142
+ const fencePath = normalizeStagedPath(raw);
143
+ for (const ours of ourStaged) {
144
+ if (pathsOverlap(fencePath, normalizeStagedPath(ours))) {
145
+ matches.push({ ourPath: ours, siblingAgent, fencePath, hash });
146
+ }
147
+ }
148
+ }
149
+ continue;
150
+ }
151
+
136
152
  const cmd = entry?.command;
137
153
  if (typeof cmd !== 'string') continue;
138
154
  for (const ours of ourStaged) {
139
155
  if (pathRegex(ours).test(cmd)) {
140
- matches.push({ ourPath: ours, siblingAgent: body.agent_id ?? '<unknown>', cmd });
156
+ matches.push({
157
+ ourPath: ours,
158
+ siblingAgent,
159
+ fencePath: '<legacy entry, pre-#1404>',
160
+ hash: '<legacy entry, pre-#1404>',
161
+ });
141
162
  }
142
163
  }
143
164
  }
144
165
  return matches;
145
166
  }
146
167
 
147
- let overlaps = [];
148
-
149
- try {
150
- await withStagingFenceLock(
151
- repoRoot,
152
- async () => {
153
- let entries;
154
- try {
155
- entries = readdirSync(fenceDir);
156
- } catch {
157
- return;
158
- }
159
- for (const name of entries) {
160
- if (!name.endsWith('.json')) continue;
161
- if (name.startsWith('.')) continue; // skip .commit.lock, tmp files
162
- const overlapsFromFile = findOverlaps(join(fenceDir, name), stagedFiles);
163
- overlaps = overlaps.concat(overlapsFromFile);
168
+ /**
169
+ * Both sub-modes, in the order the pre-commit hook needs them. Every statement
170
+ * here used to sit at module top level, which meant a bare `import()` of this
171
+ * file ran `git rev-parse` / `git diff --cached` and could exit the IMPORTING
172
+ * process with 1 (#1393). Moved into a function so the entry guard below can
173
+ * gate it; the logic and its exit codes are unchanged.
174
+ */
175
+ async function main() {
176
+ const repoRoot = execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim();
177
+ // #801: resolve wave-scope.json via the same precedence every other reader
178
+ // uses (.pi/.cursor/.codex/.claude — scope-gate.mjs findScopeFile). The prior
179
+ // hardcoded `.orchestrator/wave-scope.json` path was DEAD — the coordinator
180
+ // writes wave-scope.json to the state dir (.claude/ on Claude Code; see
181
+ // skills/wave-executor/wave-loop.md), so this guard never fired. findScopeFile
182
+ // returns null when no scope file exists at any precedence dir, preserving
183
+ // the "no active wave → exit 0" semantics below.
184
+ const scopePath = findScopeFile(repoRoot);
185
+ const fenceDir = join(repoRoot, '.orchestrator', 'staging-fence');
186
+
187
+ // -------------------------------------------------------------------------
188
+ // Sub-mode B — allowedPaths check
189
+ // -------------------------------------------------------------------------
190
+
191
+ const stagedOutput = execSync('git diff --cached --name-only', { encoding: 'utf8' });
192
+ const stagedFiles = stagedOutput.split('\n').filter(Boolean);
193
+
194
+ if (scopePath) {
195
+ let scope;
196
+ try {
197
+ scope = JSON.parse(readFileSync(scopePath, 'utf8'));
198
+ } catch (err) {
199
+ process.stderr.write(`wave-scope-commit-guard: failed to parse wave-scope.json: ${err.message}\n`);
200
+ process.exit(1);
201
+ }
202
+
203
+ const allowedPaths = Array.isArray(scope.allowedPaths) ? scope.allowedPaths : [];
204
+ if (allowedPaths.length > 0) {
205
+ const violations = stagedFiles.filter(
206
+ (f) => !allowedPaths.some((pattern) => pathMatchesPattern(f, pattern)),
207
+ );
208
+
209
+ if (violations.length > 0) {
210
+ process.stderr.write('✗ wave-scope-commit-guard: staged paths outside wave-scope.allowedPaths:\n');
211
+ for (const v of violations) process.stderr.write(` - ${v}\n`);
212
+ process.stderr.write('\nThese files were likely added by lint-staged eslint --fix / prettier --write.\n');
213
+ process.stderr.write('To proceed:\n');
214
+ process.stderr.write(' 1) git restore --staged <path> # for each foreign path\n');
215
+ process.stderr.write(' 2) git commit # retry\n');
216
+ process.exit(1);
164
217
  }
165
- },
166
- { timeoutMs: 5000 },
167
- );
168
- } catch (err) {
169
- // Lock acquisition failed — emit a warning but do NOT block the commit.
170
- // The race-detection layer is opportunistic; a lock-acquire timeout is
171
- // strictly less severe than blocking a legitimate commit on flaky FS.
172
- process.stderr.write(
173
- `⚠ wave-scope-commit-guard: staging-fence lock failed — ${err?.message ?? err}\n`,
174
- );
175
- process.exit(0);
176
- }
218
+ }
219
+ }
177
220
 
178
- if (overlaps.length > 0) {
179
- process.stderr.write('✗ wave-scope-commit-guard: staging-fence: cross-agent overlap detected:\n');
180
- for (const o of overlaps) {
181
- process.stderr.write(` - ${o.ourPath} (also staged by sibling agent ${o.siblingAgent})\n`);
221
+ // -------------------------------------------------------------------------
222
+ // Sub-mode C — cross-agent staging-fence reconciliation (issue #552)
223
+ // -------------------------------------------------------------------------
224
+ //
225
+ // Skip entirely when no fence dir exists OR no staged files. The fence dir
226
+ // only appears when a wave-agent invoked `git add` while SO_WAVE_AGENT=1.
227
+ // Manual / coordinator commits never write a fence file, so this branch
228
+ // short-circuits to exit 0 for them (AC5 safe-default).
229
+ if (!existsSync(fenceDir) || stagedFiles.length === 0) {
230
+ process.exit(0);
182
231
  }
183
- process.stderr.write('\nAnother wave-agent recorded a `git add` for one or more of your staged paths.\n');
184
- process.stderr.write('To proceed:\n');
185
- process.stderr.write(' 1) Coordinate with the sibling agent OR\n');
186
- process.stderr.write(' 2) git restore --staged <path> # for each conflicting path, then retry\n');
187
- process.stderr.write(' 3) git commit --no-verify # bypass (PSA-001/PSA-003 risk — operator opt-out)\n');
188
- process.exit(1);
232
+
233
+ let overlaps = [];
234
+
235
+ try {
236
+ await withStagingFenceLock(
237
+ repoRoot,
238
+ async () => {
239
+ let entries;
240
+ try {
241
+ entries = readdirSync(fenceDir);
242
+ } catch {
243
+ return;
244
+ }
245
+ for (const name of entries) {
246
+ if (!name.endsWith('.json')) continue;
247
+ if (name.startsWith('.')) continue; // skip .commit.lock, tmp files
248
+ const overlapsFromFile = findOverlaps(join(fenceDir, name), stagedFiles);
249
+ overlaps = overlaps.concat(overlapsFromFile);
250
+ }
251
+ },
252
+ { timeoutMs: 5000 },
253
+ );
254
+ } catch (err) {
255
+ // Lock acquisition failed — emit a warning but do NOT block the commit.
256
+ // The race-detection layer is opportunistic; a lock-acquire timeout is
257
+ // strictly less severe than blocking a legitimate commit on flaky FS.
258
+ process.stderr.write(
259
+ `⚠ wave-scope-commit-guard: staging-fence lock failed — ${err?.message ?? err}\n`,
260
+ );
261
+ process.exit(0);
262
+ }
263
+
264
+ if (overlaps.length > 0) {
265
+ process.stderr.write('✗ wave-scope-commit-guard: staging-fence: cross-agent overlap detected:\n');
266
+ for (const o of overlaps) {
267
+ process.stderr.write(
268
+ ` - ${o.ourPath} (sibling agent ${o.siblingAgent} recorded ${o.fencePath}, command ${o.hash})\n`,
269
+ );
270
+ }
271
+ process.stderr.write('\nAnother wave-agent recorded a `git add` for one or more of your staged paths.\n');
272
+ process.stderr.write('To proceed:\n');
273
+ process.stderr.write(' 1) Coordinate with the sibling agent OR\n');
274
+ process.stderr.write(' 2) git restore --staged <path> # for each conflicting path, then retry\n');
275
+ process.stderr.write(' 3) git commit --no-verify # bypass (PSA-001/PSA-003 risk — operator opt-out)\n');
276
+ process.exit(1);
277
+ }
278
+
279
+ process.exit(0);
189
280
  }
190
281
 
191
- process.exit(0);
282
+ // Entry guard (#1393): run only as the node script `.husky/pre-commit` execs —
283
+ // a bare `import()` must run no git command and must not exit the importing
284
+ // process. No shouldRunHook gate here: this file is NOT registered in
285
+ // hooks/hooks.json, so the profile-gate never governed it.
286
+ if (isMainModule(import.meta.url)) {
287
+ await main();
288
+ }