session-orchestrator 5.4.0 → 5.6.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 (151) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/rules/060-evolve.mdc +5 -5
  5. package/.cursor-plugin/plugin.json +1 -1
  6. package/CHANGELOG.md +115 -3
  7. package/README.md +30 -15
  8. package/docs/ci-setup.md +120 -23
  9. package/docs/components.md +7 -7
  10. package/docs/events-schema.md +17 -14
  11. package/docs/install.md +14 -2
  12. package/docs/migration-v4.md +24 -17
  13. package/docs/plugin-architecture-v3.md +1 -1
  14. package/docs/scope-collision-guard.md +19 -15
  15. package/docs/session-config-reference.md +7 -7
  16. package/docs/vault-docs-architecture.md +8 -4
  17. package/hooks/_lib/hook-import-set.json +17 -8
  18. package/hooks/_lib/lock-bootstrap.mjs +118 -26
  19. package/hooks/config-protection.mjs +1 -1
  20. package/hooks/enforce-commands.mjs +28 -13
  21. package/hooks/enforce-scope.mjs +201 -23
  22. package/hooks/hooks-codex.json +1 -1
  23. package/hooks/hooks.json +1 -1
  24. package/hooks/on-session-start.mjs +4 -2
  25. package/hooks/on-stop.mjs +77 -171
  26. package/hooks/post-bash-write-verify.mjs +219 -24
  27. package/hooks/post-edit-validate.mjs +15 -5
  28. package/hooks/post-tool-batch-wave-signal.mjs +41 -156
  29. package/hooks/pre-bash-memory-propose-audit.mjs +28 -11
  30. package/hooks/pre-bash-staging-fence.mjs +29 -8
  31. package/hooks/pre-task-scope-disjoint.mjs +603 -241
  32. package/hooks/wave-scope-commit-guard.mjs +235 -73
  33. package/package.json +9 -8
  34. package/scripts/apply-session-learnings.mjs +35 -14
  35. package/scripts/archive-closed-prds.mjs +38 -11
  36. package/scripts/autopilot.mjs +8 -1
  37. package/scripts/backfill-abandoned-sessions.mjs +80 -5
  38. package/scripts/backfill-learnings-from-vault.mjs +49 -22
  39. package/scripts/codex-install.mjs +3 -0
  40. package/scripts/cursor-install.mjs +46 -2
  41. package/scripts/emit-session.mjs +5 -3
  42. package/scripts/export-hw-learnings.mjs +18 -7
  43. package/scripts/fleet-checkin.mjs +26 -3
  44. package/scripts/lib/autopilot/loop.mjs +25 -10
  45. package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
  46. package/scripts/lib/blocked-commands-policy.mjs +40 -0
  47. package/scripts/lib/common.mjs +41 -0
  48. package/scripts/lib/config/vault-integration.mjs +151 -16
  49. package/scripts/lib/config.mjs +5 -1
  50. package/scripts/lib/eval/engine.mjs +13 -0
  51. package/scripts/lib/events-retention-banner.mjs +393 -0
  52. package/scripts/lib/events-rotation.mjs +21 -6
  53. package/scripts/lib/events.mjs +51 -3
  54. package/scripts/lib/fleet-protocol.mjs +20 -2
  55. package/scripts/lib/hardening.mjs +1 -0
  56. package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
  57. package/scripts/lib/instruction-budget-guard.mjs +8 -0
  58. package/scripts/lib/learnings/expiry-sweep.mjs +387 -20
  59. package/scripts/lib/learnings/io.mjs +125 -16
  60. package/scripts/lib/lock-reaper.mjs +38 -14
  61. package/scripts/lib/maintenance-due-banner.mjs +23 -8
  62. package/scripts/lib/named-vault-resolver.mjs +48 -5
  63. package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
  64. package/scripts/lib/orphan-reaper/reaper-audit.mjs +101 -69
  65. package/scripts/lib/orphan-reaper/scan-throttle.mjs +79 -27
  66. package/scripts/lib/orphan-reaper/scan.mjs +4 -0
  67. package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
  68. package/scripts/lib/orphan-reaper.mjs +5 -1
  69. package/scripts/lib/platform.mjs +206 -2
  70. package/scripts/lib/plugin-update-banner.mjs +3 -1
  71. package/scripts/lib/process-group.mjs +83 -22
  72. package/scripts/lib/scope-baseline.mjs +116 -25
  73. package/scripts/lib/scope-echo.mjs +7 -0
  74. package/scripts/lib/scope-gate.mjs +201 -27
  75. package/scripts/lib/session-close-backfill.mjs +202 -4
  76. package/scripts/lib/session-end/tail-runner.mjs +10 -5
  77. package/scripts/lib/session-lock-shape.mjs +6 -3
  78. package/scripts/lib/session-lock.mjs +320 -67
  79. package/scripts/lib/session-registry.mjs +41 -16
  80. package/scripts/lib/session-start-probes.mjs +242 -68
  81. package/scripts/lib/session-token-rollup.mjs +39 -34
  82. package/scripts/lib/sessions-staleness-banner.mjs +3 -1
  83. package/scripts/lib/tail-window.mjs +27 -3
  84. package/scripts/lib/telemetry/pricing.mjs +5 -2
  85. package/scripts/lib/telemetry/sync.mjs +17 -0
  86. package/scripts/lib/tests-src-ratio.mjs +77 -2
  87. package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
  88. package/scripts/lib/validate/check-test-value-bans.mjs +320 -32
  89. package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
  90. package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
  91. package/scripts/lib/vault-mirror/namespace.mjs +47 -26
  92. package/scripts/lib/vault-mirror/process.mjs +350 -62
  93. package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
  94. package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
  95. package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
  96. package/scripts/lib/vault-mirror/telemetry.mjs +18 -11
  97. package/scripts/lib/vault-status/board-writer.mjs +5 -1
  98. package/scripts/lib/vcs-repo-spec.mjs +8 -0
  99. package/scripts/materialize-wave-scope.mjs +7 -4
  100. package/scripts/release.mjs +1 -0
  101. package/scripts/run-migrate-v2-cross-repo.mjs +14 -2
  102. package/scripts/run-quality-gate.mjs +8 -5
  103. package/scripts/self-update.mjs +229 -0
  104. package/scripts/sweep-expired-learnings.mjs +560 -28
  105. package/scripts/validate-wave-scope.mjs +60 -6
  106. package/scripts/vault-backfill.mjs +50 -37
  107. package/scripts/vault-mirror.mjs +170 -33
  108. package/skills/_shared/fleet-protocol.md +74 -71
  109. package/skills/_shared/parallel-aware-auq.md +1 -1
  110. package/skills/_shared/state-ownership.md +38 -14
  111. package/skills/autopilot/SKILL.md +1 -1
  112. package/skills/bootstrap/SKILL.md +5 -3
  113. package/skills/bootstrap/_shared-template.md +96 -1
  114. package/skills/bootstrap/fast-template.md +3 -0
  115. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
  116. package/skills/bootstrap/standard-template.md +2 -0
  117. package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
  118. package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
  119. package/skills/discovery/probes/vault-staleness.mjs +9 -3
  120. package/skills/docs-orchestrator/SKILL.md +12 -5
  121. package/skills/docs-orchestrator/audience-mapping.md +5 -3
  122. package/skills/evolve/SKILL.md +11 -2
  123. package/skills/evolve/references/evolve-analyze-mode.md +37 -17
  124. package/skills/session-end/learning-patterns.md +3 -3
  125. package/skills/session-end/metrics-collection.md +1 -1
  126. package/skills/session-end/phase-3-2-docs-verification.md +7 -6
  127. package/skills/session-end/plan-verification.md +25 -9
  128. package/skills/session-end/references/phase-3-documentation-updates.md +11 -1
  129. package/skills/session-end/session-metrics-write.md +45 -10
  130. package/skills/session-start/SKILL.md +3 -3
  131. package/skills/session-start/references/operations-contract.md +5 -3
  132. package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
  133. package/skills/session-start/references/phase-4-ssot-environment-check.md +8 -1
  134. package/skills/vault-mirror/SKILL.md +21 -11
  135. package/skills/wave-executor/SKILL.md +1 -1
  136. package/skills/wave-executor/circuit-breaker.md +1 -1
  137. package/skills/wave-executor/references/wave-executor-state-init.md +1 -1
  138. package/skills/wave-executor/references/wave-loop-dispatch.md +8 -0
  139. package/skills/wave-executor/references/wave-loop-review.md +2 -2
  140. package/skills/wave-executor/references/wave-loop-scope-manifest.md +14 -6
  141. package/.agents/skills/navigator/SKILL.md +0 -22
  142. package/.agents/skills/navigator/agents/openai.yaml +0 -5
  143. package/.codex-plugin/skills/navigator/SKILL.md +0 -22
  144. package/.codex-plugin/skills/navigator/agents/openai.yaml +0 -5
  145. package/.cursor/commands/navigator.md +0 -15
  146. package/.cursor/skills/navigator/SKILL.md +0 -13
  147. package/pi/prompts/navigator.md +0 -12
  148. package/skills/navigator/SKILL.md +0 -193
  149. package/skills/navigator/references/account-check.sh +0 -87
  150. package/skills/navigator/references/fleet-decisions.mjs +0 -231
  151. package/skills/navigator/references/slot-check.sh +0 -114
@@ -21,9 +21,9 @@ Two things follow, and both are the point of #1020:
21
21
  | 2 | Assert disjointness | the materialized sidecar array `[{id, files}, …]` | `validate-wave-scope.mjs --assert-disjoint` → `findScopeCollisions()` |
22
22
  | 3 | Compute the union | stdout of `--union` → `allowedPaths` | `expandTestSiblings(unionFileScopes(scopes), { role })` |
23
23
  | 4 | Inject | `FILE-SCOPE — exactly these:` + a fenced block in each agent prompt | the per-agent file from step 1 |
24
- | 5 | Dispatch | `.orchestrator/wave-dispatch-scopes.json` (ledger) | `hooks/pre-task-scope-disjoint.mjs`, `PreToolUse` matcher `Agent` |
24
+ | 5 | Dispatch | `.orchestrator/wave-dispatch-scopes.<session>.json` (ledger, one per session) | `hooks/pre-task-scope-disjoint.mjs`, `PreToolUse` matcher `Agent` |
25
25
 
26
- `<state-dir>` is the first of `.pi` / `.cursor` / `.codex` / `.claude` that carries a `wave-scope.json` — the same precedence `findScopeFile()` and the hook's `waveKeyOf()` use.
26
+ `<state-dir>` is the first of `.pi` / `.cursor` / `.codex` / `.claude` that carries a `wave-scope.json` not provably owned by a peer session — `findOwnScopeFile()` resolves it using process-local session ids, including for the hook's `waveKeyOf()`. Unbound or corrupt manifests still decide under the ENFORCE contract; when only peer manifests exist, the wave key is `<session>|w?|?`.
27
27
 
28
28
  Step 1 also offers an opt-in path diagnostic: run `materialize-wave-scope.mjs` from the project root with `--warn-missing` to name absent concrete paths and their agents on stderr (#1235). Relative paths resolve from the working directory, not `<state-dir>`. A warning does not fail the command or change its stdout or either declaration shape. For a file the wave deliberately creates, repeat `--new-file PATH` as needed; every exception must exactly match a declared path and pass scope validation before any writes begin. Grants containing `*` or ending in `/` are skipped under the existing glob/prefix grammar; `?` and braces are literal. The canonical invocation and handling steps are in `skills/wave-executor/references/wave-loop-scope-manifest.md` § 3.1.
29
29
 
@@ -35,7 +35,9 @@ The same ordering argument applies one level up: step 2 runs on the **declared**
35
35
 
36
36
  ### 2.2 Why the scope files are not temp files
37
37
 
38
- Steps 1, 2, 3, 4 and the `--assert-subset` assertion all read the *same* file, addressed by wave and agent id. A `$TMPDIR` copy is the one failure in this chain that **costs no error**: the injector finds nothing, no `FILE-SCOPE` block reaches the prompt, `extractScopeFromPrompt()` returns `[]`, and the hook allows the dispatch exactly as it did before #1020 — signal-free (matrix rows 5/6 below). The scope files are control state like `wave-scope.json` itself, never a wave territory; writing them legitimately trips `bash-write-verify` once per wave rollover, and widening `allowedPaths` to silence that would grant agents write access to the deconfliction record.
38
+ Steps 1, 2, 3, 4 and the `--assert-subset` assertion all read the *same* file, addressed by wave and agent id. A `$TMPDIR` copy is the one failure in this chain that **costs no error**: the injector finds nothing, no `FILE-SCOPE` block reaches the prompt, `extractScopeFromPrompt()` returns `[]`, and the hook allows the dispatch exactly as it did before #1020 — signal-free (matrix rows 5/6 below). The scope files are control state like `wave-scope.json` itself, never a wave territory, so they never enter `allowedPaths` — that would grant agents write access to the deconfliction record. Until #1027 Pkt 5 writing them tripped `bash-write-verify` once per wave rollover; its `computeReport` now drops `<state-dir>/filescopes/**` from the violation set beside the manifest itself (`isScopeDeclarationPath()` in `scope-gate.mjs`, keyed on the ACTIVE manifest's state dir, so another harness dir's `filescopes/` still reports). The cost, named: a Bash rewrite of a scope file after dispatch is no longer reported there. For the per-agent files `filescopes/wave-<N>/<agent-id>.json` what it can change is bounded — the dispatch already read the file, `wave-scope.json` (and with it `allowedPaths`) keeps its content-hash control notice, and `scope-echo --verify` turns a rewritten file into `digest-unknown`. The aggregate sidecar `filescopes/wave-<N>.scopes.json` is re-read on every call (`readPeerScopeRecords()`), and a `peer-session-*` record there downgrades the matching violations to a "peer write" notice — so since #1504 point 5 its hash is BOUND to the manifest in the hook's trusted snapshot (`bindPeerSidecar()`): bound whenever the `wave-scope.json` hash changes, which fits the materialize → assert → union → write-manifest order. A sidecar that changes while the same manifest stands is untrusted: no peer record is honoured (those writes report as violations) until the manifest is rewritten, and a ⚠ notice names the file once per mismatching sidecar hash (HR-101 — not on every call). Named ceilings: a sidecar changed before the hook first observes the manifest is bound as found; one rewritten together with the manifest is covered only by the control-file notice; re-materializing the SAME wave without rewriting the manifest raises the notice once and leaves every peer record disabled until the manifest is rewritten — and because `--union` excludes `peer-session-*` records, a mid-wave Peer-Scope-Union changes the sidecar but not `allowedPaths`, so only the § 3.3 skeleton→final manifest rewrite changes the manifest hash and re-binds (the re-binding DEPENDS on that rewrite; `skills/_shared/parallel-aware-auq.md` § Peer-Scope-Union step 2 requires it); a forged complete snapshot record is believed (`trustedScopeState()`'s residual). No digest is written into any file the coordinator controls — a digest beside the sidecar sits where the same Bash line can rewrite it.
39
+
40
+ **A peer session's real manifest is reported once (#1511 e, named ceiling).** `.codex/wave-scope.json` or `<other-harness>/filescopes/**` written by a live peer appears once in this session's out-of-scope report (the report-once snapshot then holds it). The obvious exemption — "the manifest's binding names another session" — was shipped and withdrawn (e52bd786): the binding is what the file says about itself, so one Bash line could write a foreign-looking manifest and silence everything under that `filescopes/`. The hook cannot attribute a write to a caller either: as a `PostToolUse` hook it sees only the tree after the call and the PREVIOUS call's snapshot, so a peer's write between two calls and this call's own write land in the same window. Every other candidate signal is forgeable from inside this session as well: mtime (`touch -d`), a registry-live peer id (readable, so writable into the file), the harness directory. Until a signal exists that this session cannot produce, the one-time report stands.
39
41
 
40
42
  The coordinator's **own** planned direct edits belong in `coordinator.json` in the identical form. They are not dispatches, so the hook can never see them (§ 6); the CLI check is the only gate that covers them.
41
43
 
@@ -54,6 +56,8 @@ The reader is `hooks/enforce-scope.mjs` **Gate 3b**, between the manifest parse
54
56
  | ids present, none matching, own identity resolvable | `foreign` | **ALLOW** + one `orchestrator.scope.foreign_session_ignored` event |
55
57
  | ids present, own identity unresolvable (empty id set) | `unknown` | ENFORCE |
56
58
 
59
+ **Which manifest Gate 3 reads (#1504 point 6).** Up to four candidates can exist (`.pi` > `.cursor` > `.codex` > `.claude`). Gate 3 takes the first one whose verdict is NOT `foreign` (`findOwnScopeFile()`, `scripts/lib/scope-gate.mjs`; identity checked per candidate inside the loop) — `unknown` included, so an unbound, legacy or corrupt manifest still decides exactly as before. Only when EVERY candidate is foreign does the first of them reach Gate 3b and its ALLOW + event above. Before, the first EXISTING file decided: a peer's `.pi`/`.cursor` manifest made Gate 3b stand down and this session's own `.claude/wave-scope.json` was never read. `enforce-commands`, `post-bash-write-verify` and `wave-scope-commit-guard` resolve the same way.
60
+
57
61
  Five properties are choices, not omissions — and every one of them points the fail-**closed** way, the deliberate inverse of § 4.1's posture for the dispatch hook:
58
62
 
59
63
  - **Only what is PROVABLY foreign is foreign.** `readProcessLocalSessionIds()` returns the ids that are PROCESS-LOCAL — hook input (`session_id`/`sessionId`/`parent_session_id`) and `CLAUDE_CODE_SESSION_ID` — and an EMPTY set when neither yields an id, which can only produce `unknown`. The repo-global `session.lock` is deliberately NOT a tier here (#1194): it is ONE file shared by every session in the checkout, so unioning it let a peer's manifest match a peer-written lock id, classify `own`, and have Gate 7 deny the second session's legitimate writes — the exact lockout G3b exists to end. A better signal REPLACES a worse one (`.claude/rules/host-resources.md` § HR-102). A gate that guessed would turn "cannot tell" into a silent enforcement-off on every harness exporting no session id. Every value is trimmed on the way in, so a whitespace-only env var cannot enter as a phantom id that matches nothing (`.claude/rules/development.md` § env-var whitespace trap).
@@ -107,10 +111,10 @@ Both globs match `scripts/lib/x.mjs`, yet the direct comparison says `false`. Fo
107
111
 
108
112
  It cannot compare a batch of siblings directly — the header records why, measured against 12 archived transcripts of this repo (147 dispatch `tool_use` blocks, 51 batches, measured 2026-08-14): the dispatch tool is named `Agent` and not `Task`; the payload carries **no** structured file scope (`files` / `file_scope` / `scope` → 441 probes, zero hits), so the scope exists only as prose inside `prompt`; and the not-yet-dispatched siblings of a batch are not visible in the transcript at dispatch time. What remains is a **ledger**: each dispatch records its scope under a wave key, and the next dispatch is checked against everything already recorded.
109
113
 
110
- - **Ledger:** `.orchestrator/wave-dispatch-scopes.json` (gitignored), keyed `<session-id>|w<wave>|<role>` from `<state-dir>/wave-scope.json`.
111
- - **Lock:** `.orchestrator/wave-dispatch-scopes.lock` — the read-modify-write cycle runs under `withFileLock()` (the primitive behind the PSA-005 STATE.md lock). `writeJsonAtomicSync` makes the write atomic, never the *cycle*.
112
- - **Scope extraction:** `extractScopeFromPrompt()` finds a scope marker line and takes the FIRST fenced block after it, accepting only lines that survive a deliberately strict `looksLikeRepoPath()`. `normalizeScopeEntry()` then folds `./`, `//` and `/./` spellings together, and `promoteDirEntries()` rewrites `scripts/lib` → `scripts/lib/` **on evidence** (not a tracked file itself, at least one tracked file beneath it) — both because two spellings of one path previously compared as disjoint.
113
- - **DENY** fires on exactly one condition: a collision involving THIS dispatch with a prior agent of the same wave that is **still in flight** (§ 5). The reason names the agent pair, the collision kind and the evidence paths; the suggestion is "give the file exactly ONE owner, or wait for the named agent(s) and re-dispatch", plus the ledger path to delete if the state is stale. The ledger is deliberately **not** persisted on a deny — the dispatch did not happen, so recording it would make the retry-after-fix look like a duplicate.
114
+ - **Ledger:** `.orchestrator/wave-dispatch-scopes.<session>.json` — ONE FILE PER SESSION since #1493, `<session>` being the payload's `session_id` reduced to `[A-Za-z0-9._-]` (`ledgerPathsFor()`). Before, every session of the working copy shared one file holding one `waveKey`: claims of two sessions were never compared (the key's first segment is the session id), but a peer's dispatch reset the file and erased this session's live claims. The pre-split `.orchestrator/wave-dispatch-scopes.json` is read once for migration — adopted only while this session has no file yet and its `waveKey` names this session — and never written or deleted, since it may be a peer's live state. (Gitignored in this repo; consumer repos get the ignore line from bootstrap's `store-lock-ignore` step since #1495 — a repo bootstrapped earlier gets it on its next `--upgrade` or `--refresh-lock`), keyed `<session-id>|w<wave>|<role>` from `<state-dir>/wave-scope.json`.
115
+ - **Lock:** `.orchestrator/wave-dispatch-scopes.<session>.lock`, one per ledger file — the read-modify-write cycle runs under `withFileLock()` (the primitive behind the PSA-005 STATE.md lock). `writeJsonAtomicSync` makes the write atomic, never the *cycle*.
116
+ - **Scope extraction:** `extractScopeFromPrompt()` finds a scope marker line and takes the FIRST fenced block after it, accepting only lines that survive a deliberately strict `looksLikeRepoPath()`. `decide()` then folds `./`, `//` and `/./` spellings together with `scopeEntrySpelling()` — the same rule `findScopeCollisions()` and `unionFileScopes()` apply, exported from `scope-gate.mjs` so no second copy can drift (#1510 g), and `promoteDirEntries()` rewrites `scripts/lib` → `scripts/lib/` **on evidence** (not a tracked file itself, at least one tracked file beneath it) — both because two spellings of one path previously compared as disjoint.
117
+ - **DENY** fires on exactly one condition: a collision involving THIS dispatch with a prior agent of the same wave that is **still in flight** (§ 5). The reason names the agent pair, the collision kind and the evidence paths; the suggestion is "give the file exactly ONE owner, or wait for the named agent(s) and re-dispatch", plus this session's ledger path to delete if the state is stale (never a peer's). The ledger is deliberately **not** persisted on a deny — the dispatch did not happen, so recording it would make the retry-after-fix look like a duplicate.
114
118
 
115
119
  ### 4.1 Error-class matrix — deliberately fail-**open**
116
120
 
@@ -157,7 +161,7 @@ Why it exists: the in-ledger `scopeSignals` counter (§ 4.1, rows 5/6) is a **wa
157
161
 
158
162
  A cryptographic proof still needs that platform boundary: no mechanism inside this repo can distinguish a copied digest from a read one.
159
163
 
160
- **The digest now JOINS the two halves (#1092, 2026-09-16).** Everything above stays true — what changed is that the halves became joinable at all. `scope_checked` carries `scope_digest` (`scopeDigest()` over the paths extracted from the prompt, omitted for an empty scope rather than defaulted to the empty-string digest), `echo_instruction_present`, `instructed_digest` and `digest_consistent`; `scope_echo_checked` already carried `expected_digest` / `actual_digest`; and `materialize-wave-scope` writes one `scope_materialized` record per wave carrying `transport_observable`. `node scripts/lib/scope-echo.mjs --verify --wave <N> --state-dir <dir>` joins all three **on the digest, never on `agent_id`** — measured 2026-09-16 over this host's ledger, 609 send-side against 51 receive-side records whose agent-id sets overlap in ZERO elements (send records the dispatch `description` + `subagent_type`, receive the coordinator's short handle), so before the digest the two halves could not be paired even in principle. Its closed verdict enum — `matched` · `injected-not-echoed` · `echoed-not-injected` · `injection-missing` · `digest-unknown` · `duplicate-claim`, all degrading to `echo-only` when the transport is unobservable — is what makes the issue's AC-2 (an omitted injection turning an end-to-end probe red) a measurable state rather than an unfalsifiable absence. `digest_consistent: false` additionally catches agent A's fenced block beside agent B's echo line **at dispatch time**, from the prompt alone. None of this raises the ceiling above: a joined digest still proves transport of a LINE, never obedience.
164
+ **The digest now JOINS the two halves (#1092, 2026-09-16).** Everything above stays true — what changed is that the halves became joinable at all. `scope_checked` carries `scope_digest` (`scopeDigest()` over the paths extracted from the prompt, omitted for an empty scope rather than defaulted to the empty-string digest), `echo_instruction_present`, `instructed_digest` and `digest_consistent`; `scope_echo_checked` already carried `expected_digest` / `actual_digest`; and `materialize-wave-scope` writes one `scope_materialized` record per wave carrying `transport_observable`. `node scripts/lib/scope-echo.mjs --verify --wave <N> --state-dir <dir>` joins all three **on the digest, never on `agent_id`** — measured 2026-09-16 over this host's ledger, 609 send-side against 51 receive-side records whose agent-id sets overlap in ZERO elements (send records the dispatch `description` + `subagent_type`, receive the coordinator's short handle), so before the digest the two halves could not be paired even in principle. Its closed verdict enum — `matched` · `injected-not-echoed` · `echoed-not-injected` · `injection-missing` · `digest-unknown` · `duplicate-claim`, all degrading to `echo-only` when the transport is unobservable — is what makes the issue's AC-2 (an omitted injection turning an end-to-end probe red) a measurable state rather than an unfalsifiable absence. `digest_consistent: false` additionally catches agent A's fenced block beside agent B's echo line **at dispatch time**, from the prompt alone. The coordinator's own `coordinator.json` takes no part in the join, and `scope_materialized` counts no agent for it (`isCoordinatorRecordId()`, #1493.2): it is a declaration, never a dispatch, so nothing can claim its digest and it used to read `injection-missing` on every wave where the coordinator planned an edit. None of this raises the ceiling above: a joined digest still proves transport of a LINE, never obedience.
161
165
 
162
166
  **Revisit-Trigger** (verbatim from issue #1092, for the transport half this section deliberately does NOT close):
163
167
 
@@ -186,7 +190,7 @@ The discriminator is therefore neither time nor the agent's name, but whether th
186
190
 
187
191
  Complete list of what this guard does **not** see, or sees only approximately:
188
192
 
189
- 1. **The blind TTL window.** Without transcript evidence the only liveness signal is the 30-minute TTL above. Inside that window a finished agent still blocks (false deny, recoverable by deleting the ledger); outside it a running agent no longer blocks (false allow). Revisit trigger as stated in § 5.
193
+ 1. **The blind TTL window.** Without transcript evidence the only liveness signal is the 30-minute TTL above. Inside that window a finished agent still blocks (false deny, recoverable by deleting this session's ledger); outside it a running agent no longer blocks (false allow). Revisit trigger as stated in § 5.
190
194
  2. **Test-sibling collisions.** Disjointness is asserted on the **declared** scopes (step 2), before `expandTestSiblings()` runs (step 3). Two agents whose production files share a basename receive the *same* emitted sibling glob (`tests/**/{basename}*.test.mjs`), which a declared-scope check cannot see. Revisit if a wave is ever scoped by basename family instead of by directory.
191
195
  3. **Prose extraction fails only toward ALLOW.** The hook's only channel is the `FILE-SCOPE` prose block. A missing marker, a missing fence, a decorated path that fails `looksLikeRepoPath()` — all resolve to allow (rows 5/6). Measured: 42 of 147 archived prompts (28.6 %) carried a scope marker at all, so denying the non-extractable case would have denied ~7 dispatches in 10. The CLI check (step 2) is the gate that does not depend on prose.
192
196
  4. **Coordinator-direct edits are invisible to the hook.** They are not dispatches, so no ledger entry exists for them. They participate in the CLI check via `coordinator.json` only — and 2 of the 5 divergences that motivated #1020 were coordinator-direct edits.
@@ -195,16 +199,16 @@ Complete list of what this guard does **not** see, or sees only approximately:
195
199
  7. **Only collisions involving the current dispatch are actionable.** A pair among already-dispatched agents was either denied at its own dispatch or predates the guard; re-denying it would block an innocent third agent.
196
200
  8. **Lock loss reopens the race.** On lock timeout the cycle runs unlocked (row 14) — two dispatches starting together can then read the same ledger state and one record is lost. That is the pre-lock behaviour, chosen over denying on a lock-file problem.
197
201
  9. **The session binding is self-declared** (§ 2.3). `session` is a plain field in a file any process in this working copy can write, so writing a foreign id into it turns the write gate off for that manifest. Named rather than hidden: it is the SAME power `enforcement: "off"` already grants in the same file, so Gate 3b adds no new authority — the manifest is the coordinator's own artefact either way.
198
- 10. **Only the WRITE gate is session-bound.** The dispatch ledger of § 4 takes its session component from the harness's own `input.session_id` (`waveKeyOf(projectDir, sessionId, …)`), and reads only `wave` and `role` out of `wave-scope.json` — the `session` field is not consulted there at all. So a peer session's manifest cannot bind this session's writes since #1123, but the two hooks reach that property by different routes, and a change to one does not carry to the other.
202
+ 10. **The manifest binding is read by the WRITE gates, not by the dispatch hook.** `enforce-scope`, `enforce-commands`, `post-bash-write-verify` and — since #1493.1 — the git pre-commit `wave-scope-commit-guard` (env-tier identity only, it has no hook payload) classify the manifest via `classifyManifestSession()` and stand down on a foreign one. The dispatch ledger of § 4 takes its session from the harness's own `input.session_id` instead — in its file name since #1493 and in `waveKeyOf(projectDir, sessionId, …)` — and reads only `wave` and `role` out of `wave-scope.json`; the `session` field is not consulted there at all. So a peer session's manifest cannot bind this session's writes or commits, but the hooks reach that property by different routes, and a change to one does not carry to the other.
199
203
  11. **A session that published a peer's id reads its OWN manifest as `foreign` (#1194) — the writer's defense is now mechanical (#1207).** Dropping the lock tier (§ 2.3) moved the cost to the other side of the trade: with a raw `sessionAttribution()` read, a session that lost the `bootstrapLock()` race could get the peer's id and write it into its own manifest, and Gate 3b would then classify the manifest `foreign`, standing its own write guard down. Since #1207 the writer calls `attributionForRecord(repoRoot)` instead — `skills/wave-executor/wave-loop.md` § Scope Manifest 1 — which confirms the lock's raw `session_id` against `readProcessLocalSessionIds()` before returning anything and yields `{}` on any mismatch, so the coordinator performs no manual comparison and STATE.md plays no part in it (a shared working-copy artefact written by whichever session holds the lock, not a process-local witness). A filled binding is therefore provably this session's own; unbound (`{}`) = ENFORCE. CEILING (BV-004): on a harness that exports no session env var and puts no `session_id` in the hook payload (Codex CLI, Cursor today) both tiers are empty, so G3b is permanently `unknown` = enforce = pre-#1123 behaviour there. Revisit when Codex/Cursor hook payloads carry a session id.
200
204
 
201
205
  ## 7. Debugging
202
206
 
203
- **A dispatch was denied and you do not believe it.** Read `.orchestrator/wave-dispatch-scopes.json`: it carries `waveKey`, `updated`, and one `{id, desc, useId, files, at}` record per already-dispatched agent (`useId` absent when the harness sent no `tool_use_id`). The deny reason names the other agent — find its record and compare its `files` to the ones in your prompt's `FILE-SCOPE` block. Three outcomes:
207
+ **A dispatch was denied and you do not believe it.** Read this session's `.orchestrator/wave-dispatch-scopes.<session>.json` (the deny suggestion names the exact file): it carries `waveKey`, `updated`, and one `{id, desc, useId, files, at}` record per already-dispatched agent (`useId` absent when the harness sent no `tool_use_id`). The deny reason names the other agent — find its record and compare its `files` to the ones in your prompt's `FILE-SCOPE` block. Three outcomes:
204
208
 
205
209
  - The other agent is genuinely running and the overlap is real → fix the ownership in the session plan (one file, one agent), rewrite the affected `filescopes/wave-<N>/*.json`, re-assert, re-dispatch.
206
- - The other agent has finished, but the ledger still binds it → the transcript carried no evidence (§ 5) and you are inside the TTL window. Delete `.orchestrator/wave-dispatch-scopes.json`; the next dispatch rebuilds it.
207
- - The `waveKey` names an older wave → the `<session>|w?|?` fallback (limit 5). Check that `<state-dir>/wave-scope.json` exists and is readable, then delete the ledger.
210
+ - The other agent has finished, but the ledger still binds it → the transcript carried no evidence (§ 5) and you are inside the TTL window. Delete this session's `.orchestrator/wave-dispatch-scopes.<session>.json`; the next dispatch rebuilds it. Never delete another session's file beside it — that erases its live claims.
211
+ - The `waveKey` names an older wave → the `<session>|w?|?` fallback (limit 5). Check that `<state-dir>/wave-scope.json` exists and is readable, then delete this session's ledger file.
208
212
 
209
213
  **A dispatch was NOT denied and should have been.** Start at the ledger line, which now says which row fired without re-running anything: `jq -c 'select(.event=="orchestrator.wave_dispatch.scope_checked")' .orchestrator/metrics/events.jsonl | tail` — `injected:false` with `signal:"marker-absent"` is row 5 (no declaration in the prompt at all, i.e. the injection step, not the guard), `signal:"unparseable"` is row 6 (a declaration the parser could not use), `ledger_result:"allow-finished"` is row 10a (the collision was real but its partner had finished), and NO record at all for a dispatch you watched happen means the hook never ran or crashed before deciding (§ 4.2). Then work down the allow rows: is `FILE-SCOPE` present in the prompt with a fenced block right after it (rows 5/6)? Is the hook armed at all (`GUARD INACTIVE` on stderr = row 2)? Did a `systemMessage` warning appear (rows 7/9)? Cross-check the same scopes through the CLI, which does not depend on prose:
210
214
 
@@ -212,9 +216,9 @@ Complete list of what this guard does **not** see, or sees only approximately:
212
216
  node scripts/validate-wave-scope.mjs --assert-disjoint "$WAVE_SCOPES_SIDECAR" < <state-dir>/wave-scope.json
213
217
  ```
214
218
 
215
- Exit 1 prints one `ERROR:` line per collision (`agents "A" and "B" both claim [...]`) and one per duplicate id. Exit 0 — nothing on stderr, the manifest echoed back on stdout — means the declared scopes really are disjoint and the hook was right to allow; the divergence is then in the prompt, not in the plan.
219
+ Exit 1 prints one `ERROR:` line per collision (`agents "A" and "B" both claim [...]`) and one per duplicate id. Exit 0 — nothing on stderr, the manifest echoed back on stdout — means the declared scopes really are disjoint and the hook was right to allow; the divergence is then in the prompt, not in the plan. No manifest yet (e.g. `/plan`, before any `wave-scope.json` exists)? Add `--no-manifest` and drop the stdin redirect: the sidecar is checked alone and stdout stays empty — without the flag an empty stdin is still an error, and the flag refuses `--union` / `--assert-subset`, which need the manifest (#1510 f).
216
220
 
217
- **Reset.** Delete `.orchestrator/wave-dispatch-scopes.json` (and `.orchestrator/wave-dispatch-scopes.lock` if a dead holder is suspected). Both are gitignored, both are rebuilt on the next dispatch, and neither is shared with any other mechanism. After the final wave, `<state-dir>/filescopes/` is deleted along with `wave-scope.json` — a stale `wave-<N>/` directory left behind is a scope claim nobody re-verified.
221
+ **Reset.** Delete this session's `.orchestrator/wave-dispatch-scopes.<session>.json` (and its `.lock` if a dead holder is suspected) — never a peer's. Both are gitignored here (in a consumer repo only once bootstrap's `store-lock-ignore` step has run, #1495), both are rebuilt on the next dispatch, and neither is shared with any other mechanism or session. After the final wave, `<state-dir>/filescopes/` is deleted along with `wave-scope.json` and this session's ledger file (`skills/wave-executor/references/wave-loop-scope-manifest.md` step 7, session-end 3.0); a crashed session's file stays behind, inert — no other session reads it — a stale `wave-<N>/` directory left behind is a scope claim nobody re-verified.
218
222
 
219
223
  **Disable.** The hook honours the repo's profile gate (`shouldRunHook('pre-task-scope-disjoint')`, row 1) — a silent exit 0, no decision at all.
220
224
 
@@ -793,7 +793,7 @@ vault-integration:
793
793
  >
794
794
  > A leading `~` is expanded to the home directory: `path-prefix` when matching, and the `path` / `paths.vault-dir` value the two owner.yaml tiers return (`match`, `owner`). The `env` and `committed` tiers pass through unexpanded; `scripts/vault-mirror.mjs` expands its `--vault-dir` at entry, so a `~/…` from any tier reaches the vault rather than `<cwd>/~/…`.
795
795
  >
796
- > The parsed config reports which tier won as `vault-integration.vault-dir-source` (`env`, `match`, `owner`, `committed`). This keeps maintainer-specific absolute paths out of version control. Resolvers: `scripts/lib/config.mjs` (`resolveVaultDir`, module-private), `scripts/lib/config/host-paths.mjs` (`resolveHostPath`, used for `plan-baseline-path`) and `scripts/lib/named-baseline-resolver.mjs` (the `baselines:` / `vault-dirs:` match tier).
796
+ > The parsed config reports which tier won as `vault-integration.vault-dir-source` (`env`, `match`, `owner`, `committed`). This keeps maintainer-specific absolute paths out of version control. Resolvers: `scripts/lib/config.mjs` (`resolveVaultDir`, exported; `scripts/vault-backfill.mjs` resolves its stub root through it, #1094), `scripts/lib/config/host-paths.mjs` (`resolveHostPath`, used for `plan-baseline-path`) and `scripts/lib/named-baseline-resolver.mjs` (the `baselines:` / `vault-dirs:` match tier).
797
797
  >
798
798
  > **Fake-repoRoot trap (moved from CLAUDE.md Critical Gotchas, 2026-09-25).** A throwaway invocation of a vault writer (`mirrorNarrative()`, `vault-mirror.mjs`, the board writer) with a synthetic `repoRoot` does **not** land in a sandbox — the fake repo path only fakes the SOURCE, never the DESTINATION. A reviewer in this repo walked into exactly that.
799
799
  > Export `SO_VAULT_DIR=<tmpdir>` before ANY manual invocation of vault-writing code. <!-- consistency:exempt:runtime-only -->
@@ -810,7 +810,7 @@ vault-integration:
810
810
  | `vault-integration.vault-dir` | string or null | `null` | Absolute path to the vault repository. Host-local tiers override it (see the note above). If it is still empty after them, only the bash snippet in session-end and evolve falls back to the `$VAULT_DIR` env variable. Required when `enabled` is true. |
811
811
  | `vault-integration.mode` | string | `warn` | Mirror error handling. `strict` blocks session close if the mirror exits non-zero. `warn` reports errors but does not block. `off` bypasses mirror invocation entirely (useful when transitioning). |
812
812
  | `vault-integration.vault-name` | string or null | `null` | Optional override for the per-project vault namespace segment (#660). When set (or via CLI `--vault-name`), vault writes go to `40-learnings/<vault-name>/` and `50-sessions/<vault-name>/`, sanitised to a kebab slug. When null/absent, the namespace is derived from the git origin via `deriveRepo()`. Owner-privacy leaks (personal home path / private slug / personal name) are redacted to `redacted-repo`. NOT a filesystem path → NOT host-path-resolved. **Coverage gap:** portfolio board rows are NOT yet namespace-aware — the override only reaches `40-learnings/`, `50-sessions/`, and the per-repo narrative mirror (`narrative-mirror.mjs`, #832 item 2); tracked separately in #832. Resolver: `scripts/lib/vault-mirror/namespace.mjs` (`resolveRepoNamespace`). |
813
- | `vault-integration.gitlab-groups` | string[] or null | `null` | List of GitLab group paths to scan for repos missing `.vault.yaml`. Consumed by `scripts/vault-backfill.mjs` (via `readVaultIntegrationConfig()`) and the `/plan retro` vault-backfill sub-mode (`skills/plan/mode-retro.md` Phase 1.6 Step 1). When null/unset, the backfill CLI exits with a "no groups configured" notice. |
813
+ | `vault-integration.gitlab-groups` | string[] or null | `null` | List of GitLab group paths to scan for repos missing `.vault.yaml`. Parsed by `_parseVaultIntegration` (`scripts/lib/config/vault-integration.mjs`) and consumed by `scripts/vault-backfill.mjs`. Accepts a comma string, `[a, b]`, or a YAML block list; in the inline-object form more than one group needs `[a, b]`; an empty list is `null`. YAML semantics: `null`, `~` and an empty value mean unset (as a list element too, where they are dropped), and each element loses one pair of matching quotes — `["a"]` is the group `a`, and only a quoted `"null"` element is a group named `null`. Also consumed by the `/plan retro` vault-backfill sub-mode (`skills/plan/mode-retro.md` Phase 1.6 Step 1). When null/unset and neither `--groups` nor `--yes` is passed, the backfill CLI exits 1 with `no GitLab groups specified — pass --groups <CSV> or set vault-integration.gitlab-groups in CLAUDE.md`. |
814
814
 
815
815
  ### Environment override: `VAULT_MIRROR_CANONICAL_SUFFIX`
816
816
 
@@ -866,7 +866,7 @@ Opt-out configuration for the agent-writable memory tool. During a wave, an agen
866
866
 
867
867
  This is a Hermes-style memory-write API **without** Hermes' overwrites-manual-edits critique: the operator confirmation is mandatory and there is no silent overwrite path. Three safety layers keep the surface conservative:
868
868
 
869
- 1. **Quota per wave** — `quota-per-wave` (default `5`) caps how many proposals any single agent may queue within one wave. Excess proposals exit `1` and the call-site logs the rejection.
869
+ 1. **Quota per wave** — `quota-per-wave` (default `5`) caps how many proposals ONE WAVE may queue, all of its agents together — not each agent: the store counts a wave's records by `wave_id` only (`scripts/lib/memory-proposals/store.mjs`). In a wave of N agents the 6th proposal of the wave exits `1` regardless of which agent sends it, so one agent that proposes five times uses up the quota for every sibling. Excess proposals exit `1` and the call-site logs the rejection.
870
870
  2. **Confidence floor** — `confidence-floor` (default `0.5`) rejects low-confidence proposals before they reach the operator (exit `2`). Tuned so a learning is only proposed when the agent is at least 50% sure of the insight.
871
871
  3. **AUQ confirm-or-discard** — the session-end phase never auto-persists. Every proposal is `AskUserQuestion`-gated; the operator can accept, reject, or edit before commit.
872
872
 
@@ -876,14 +876,14 @@ All fields live under the top-level `memory` object in your Session Config host
876
876
  memory:
877
877
  proposals:
878
878
  enabled: true # default true; opt-out master toggle for the memory.propose feature
879
- quota-per-wave: 5 # max proposals an agent may queue per wave (exit 1 on overflow)
879
+ quota-per-wave: 5 # max proposals per wave, all agents together (exit 1 on overflow)
880
880
  confidence-floor: 0.5 # proposals below this confidence are rejected (exit 2)
881
881
  ```
882
882
 
883
883
  | Field | Type | Default | Description |
884
884
  |-------|------|---------|-------------|
885
885
  | `memory.proposals.enabled` | boolean | `true` | Master toggle for the entire memory-proposals feature. When `false`, the `memory.propose` CLI exits `3` (rejected-wrong-context) for every call and session-end Phase 3.6.3 is skipped. When `true`, agents may queue proposals during waves and the coordinator surfaces them at session-end. PRD F2.1 / issue #501. |
886
- | `memory.proposals.quota-per-wave` | integer | `5` | Maximum number of proposals one wave-executor agent can queue per wave. The 6th proposal from the same agent in the same wave exits `1` (quota-exceeded). Bounds: integer ≥ 0. Set to `0` to disable proposals from agents without disabling the feature entirely (operator can still propose). |
886
+ | `memory.proposals.quota-per-wave` | integer | `5` | Maximum number of proposals a wave can queue, counted across ALL of its agents (the store counts records by `wave_id` only). The 6th proposal of a wave exits `1` (quota-exceeded) whichever agent sends it — in a wave of N agents, the first five proposals of the wave win. Bounds: integer ≥ 0. Set to `0` to disable proposals from agents without disabling the feature entirely (operator can still propose). |
887
887
  | `memory.proposals.confidence-floor` | float | `0.5` | Minimum confidence (0.0..1.0) required for a proposal to be queued. Proposals with `--confidence < confidence-floor` exit `2` (rejected-low-confidence) before reaching the operator. Bounds: `0.0 ≤ value ≤ 1.0`. Set to `0.0` to accept any confidence (operator filters at AUQ time). |
888
888
 
889
889
  ### Agent CLI invocation
@@ -923,7 +923,7 @@ A successful dry-run exits `0` with stdout status `dry-run-ok` (see updated exit
923
923
  |-----------|-----------------|---------|--------------|
924
924
  | `0` | `queued` | Queued | Proposal accepted into the per-wave staging directory; awaits operator confirmation at session-end Phase 3.6.3. |
925
925
  | `0` | `dry-run-ok` | Validated, not written | `--dry-run` was passed and the proposal (argv + schema) validated successfully. No write to `proposals.jsonl` occurs; the wrong-context gates are bypassed under this flag (#741.3). |
926
- | `1` | `quota-exceeded` | Rejected — quota exceeded | This agent has already queued `quota-per-wave` proposals in this wave. Subsequent calls from the same agent fail until the next wave. Not applicable under `--dry-run` (gate bypassed). |
926
+ | `1` | `quota-exceeded` | Rejected — quota exceeded | This wave has already queued `quota-per-wave` proposals, from any of its agents. Every further call in this wave fails, whichever agent makes it, until the next wave. Not applicable under `--dry-run` (gate bypassed). |
927
927
  | `2` | `rejected-low-confidence` | Rejected — low confidence | `--confidence` argument is below `confidence-floor`. Tighten the insight or raise the confidence (operator can still tune the floor). |
928
928
  | `3` | `rejected-wrong-context` | Rejected — wrong context | Feature disabled (`enabled: false`), STATE.md not active, or `SO_WAVE_AGENT != "1"` (call originated outside a wave-executor agent context). Not applicable under `--dry-run` (gate bypassed). |
929
929
  | `4` | `error` | Arg error | Missing or malformed flag — invalid `--type`, empty `--subject`, non-numeric `--confidence`. The CLI prints a one-line usage message on stderr. |
@@ -1626,7 +1626,7 @@ reaper:
1626
1626
  | `reaper.max-hook-latency-ms` | integer (≥ 1) | `50` | Ceiling the TRIGGER may delay a hook by — the config read, the marker `stat` and the detached spawn, measured with `performance.now()` in `maybeTriggerOrphanScan()`. Overrunning it writes ONE stderr WARN line and nothing else: no event (a `PostToolBatch`-class hook emitting per fire is the always-on signal HR-101 calls a broken instrument) and no abort (the child is already detached by then). The scan itself is not covered — that is the point of spawning detached. |
1627
1627
  | `reaper.false-alarm-window` | integer (≥ 1) | `50` | Rolling window of audit decisions the HR-101 10 % false-alarm rate is judged over — a window in DECISIONS, not calendar time, so the rate has a population on quiet hosts too. Reaches the scan as `--false-alarm-window` on the detached child's argv and becomes `runOrphanScan`'s `falseAlarmWindow`; the CLI refuses `0`, which `falseAlarmRate()` would read as “every firing ever recorded” rather than as a window. |
1628
1628
 
1629
- **Used by:** `hooks/post-tool-batch-wave-signal.mjs`, `hooks/on-stop.mjs` (both via `maybeTriggerOrphanScan()`), `scripts/lib/orphan-reaper.mjs`, `scripts/lib/config/reaper.mjs` (`_parseReaper`). PRD `docs/prd/2026-09-20-prozessgruppen-kill-und-waisen-waechter.md`, Epic #1425, issue #1432.
1629
+ **Used by:** `hooks/post-tool-batch-wave-signal.mjs`, `hooks/on-stop.mjs` (both via `maybeTriggerOrphanScan()` from `scripts/lib/orphan-reaper/trigger.mjs`), `scripts/lib/orphan-reaper.mjs`, `scripts/lib/config/reaper.mjs` (`_parseReaper`). PRD `docs/prd/2026-09-20-prozessgruppen-kill-und-waisen-waechter.md`, Epic #1425, issue #1432.
1630
1630
 
1631
1631
  ## Gate Timeout — Path B (#1425 A3)
1632
1632
 
@@ -169,10 +169,14 @@ entirely. Source: `docs/session-config-reference.md` § Docs Orchestrator.
169
169
  The `docs-writer` agent operates under a **hallucination ban**: every
170
170
  substantive paragraph must trace to one of the canonical four sources, or
171
171
  carry an inline `<!-- REVIEW: source needed -->` marker. The four sources are
172
- defined once and reused everywhere:
173
-
174
- 1. **diff** — `git diff $SESSION_START_REF..HEAD`
175
- 2. **git-log** — `git log $SESSION_START_REF..HEAD --format="%H %s%n%b"`
172
+ defined once — in `skills/docs-orchestrator/SKILL.md` — and reused everywhere.
173
+ Diffs take `"$SESSION_RANGE"`, ONE complete range (the pinned SHA's
174
+ `<sha>..HEAD`, else the resolved `<base>...HEAD` from the accessor in
175
+ `skills/session-end/plan-verification.md`); only the log keeps the two-dot
176
+ `$SESSION_START_REF..HEAD`. An empty range or ref is a skip, never "no changes":
177
+
178
+ 1. **diff** — `git diff "$SESSION_RANGE"`
179
+ 2. **git-log** — `git log "$SESSION_START_REF..HEAD" --format="%H %s%n%b"`
176
180
  3. **session-memory** — `~/.claude/projects/<project>/memory/session-*.md` and
177
181
  `.orchestrator/` outputs
178
182
  4. **affected-files** — files in the wave-scope `allowedPaths` block
@@ -1,6 +1,6 @@
1
1
  {
2
- "generated_at": "2026-10-01T17:18:47.118Z",
3
- "head": "ffc5929f12df0b5fd7c75d9708a3aa1a5dc6fa45",
2
+ "generated_at": "2026-10-03T07:30:53.188Z",
3
+ "head": "0cfbaa2c4fad16d825b94b8000ca8aea3dc9af68",
4
4
  "entries": [
5
5
  {
6
6
  "file": "hooks/_lib/atomic-json.mjs",
@@ -308,6 +308,7 @@
308
308
  {
309
309
  "file": "scripts/lib/command-blocker.mjs",
310
310
  "reachable_from": [
311
+ "on-stop.mjs",
311
312
  "post-bash-issue-budget-refund.mjs",
312
313
  "post-bash-write-verify.mjs",
313
314
  "post-edit-validate.mjs",
@@ -315,7 +316,8 @@
315
316
  "pre-bash-issue-budget.mjs",
316
317
  "pre-bash-memory-propose-audit.mjs",
317
318
  "pre-bash-staging-fence.mjs",
318
- "pre-bash-templates-first.mjs"
319
+ "pre-bash-templates-first.mjs",
320
+ "pre-task-scope-disjoint.mjs"
319
321
  ]
320
322
  },
321
323
  {
@@ -841,8 +843,7 @@
841
843
  "file": "scripts/lib/env-check.mjs",
842
844
  "reachable_from": [
843
845
  "post-bash-write-verify.mjs",
844
- "post-edit-validate.mjs",
845
- "pre-bash-memory-propose-audit.mjs"
846
+ "post-edit-validate.mjs"
846
847
  ]
847
848
  },
848
849
  {
@@ -975,8 +976,7 @@
975
976
  "file": "scripts/lib/hardening.mjs",
976
977
  "reachable_from": [
977
978
  "post-bash-write-verify.mjs",
978
- "post-edit-validate.mjs",
979
- "pre-bash-memory-propose-audit.mjs"
979
+ "post-edit-validate.mjs"
980
980
  ]
981
981
  },
982
982
  {
@@ -1172,6 +1172,13 @@
1172
1172
  "post-tool-batch-wave-signal.mjs"
1173
1173
  ]
1174
1174
  },
1175
+ {
1176
+ "file": "scripts/lib/orphan-reaper/trigger.mjs",
1177
+ "reachable_from": [
1178
+ "on-stop.mjs",
1179
+ "post-tool-batch-wave-signal.mjs"
1180
+ ]
1181
+ },
1175
1182
  {
1176
1183
  "file": "scripts/lib/owner-yaml.mjs",
1177
1184
  "reachable_from": [
@@ -1296,10 +1303,12 @@
1296
1303
  {
1297
1304
  "file": "scripts/lib/scope-gate.mjs",
1298
1305
  "reachable_from": [
1306
+ "on-stop.mjs",
1299
1307
  "post-bash-write-verify.mjs",
1300
1308
  "post-edit-validate.mjs",
1301
1309
  "post-tool-batch-wave-signal.mjs",
1302
- "pre-bash-memory-propose-audit.mjs"
1310
+ "pre-bash-memory-propose-audit.mjs",
1311
+ "pre-task-scope-disjoint.mjs"
1303
1312
  ]
1304
1313
  },
1305
1314
  {
@@ -25,11 +25,11 @@
25
25
  * ttl_hours: number,
26
26
  * }
27
27
  *
28
- * The current scripts/lib/session-lock.mjs (pre-I3) writes the v1 shape
29
- * (no last_heartbeat, no semantic_session_id). This helper layers v2 fields
30
- * on top via an atomic tmp+rename overwrite — when I3 ships its v2 schema,
31
- * this helper's overlay becomes a no-op (the field is already there) and
32
- * everything continues to work.
28
+ * scripts/lib/session-lock.mjs `buildLock()` has written `last_heartbeat`
29
+ * itself since Epic #583 W2-I3 (`last_heartbeat` = `started_at` at genesis).
30
+ * This helper's tmp+rename overlay therefore re-writes an identical
31
+ * `last_heartbeat`, and its remaining job is `semantic_session_id`, which
32
+ * `acquire()` / `forceAcquire()` are not given here.
33
33
  *
34
34
  * @module hooks/_lib/lock-bootstrap
35
35
  */
@@ -55,6 +55,10 @@ import { writeJsonAtomicSync } from '../../scripts/lib/io.mjs';
55
55
  */
56
56
  export const SAME_LOGICAL_SESSION_SOURCES = new Set(['resume', 'clear', 'compact']);
57
57
 
58
+ /** Re-reads of a just-acquired lock that answers ENOENT, and the pause between them (#1494). */
59
+ const ENRICH_READ_RETRIES = 5;
60
+ const ENRICH_READ_RETRY_MS = 10;
61
+
58
62
  /**
59
63
  * Bootstrap the session.lock for this hook invocation.
60
64
  *
@@ -189,6 +193,13 @@ export async function bootstrapLock({
189
193
  // session-lock.mjs; matching only the new one keeps this force-branch
190
194
  // reachable.
191
195
  acquireResult.reason === 'stale-heartbeat' ||
196
+ // A lock file that is not a lock record (invalid JSON or not the lock
197
+ // shape) can never be a live lock — every writer writes atomically — so
198
+ // it is reclaimed like a stale one, with one stderr WARN below. Before
199
+ // acquire() reported 'corrupt' it answered 'active' / existingLock:null,
200
+ // which this predicate could never force: one damaged file wedged every
201
+ // new session in the repo.
202
+ acquireResult.reason === 'corrupt' ||
192
203
  (acquireResult.reason === 'active' &&
193
204
  acquireResult.existingLock &&
194
205
  (acquireResult.existingLock.session_id === sessionId ||
@@ -201,12 +212,41 @@ export async function bootstrapLock({
201
212
  acquireResult.existingLock.session_id === predecessorSessionId)))
202
213
  );
203
214
 
215
+ // Recorded on the acquired event below, so a takeover of a stale or corrupt
216
+ // lock is countable — its stderr WARN is discarded by the harness (#1401).
217
+ let reclaimed = null;
204
218
  if (!acquireResult.ok && shouldForce) {
219
+ const reclaimingCorrupt = acquireResult.reason === 'corrupt';
220
+ const forcedFrom = acquireResult.reason;
221
+ // A session resuming its OWN stale lock (same raw id after > ttl idle) is
222
+ // a refresh, not a takeover — counting it would inflate the very figure
223
+ // `reclaimed` exists to make countable.
224
+ const priorOwner = acquireResult.existingLock?.session_id;
205
225
  try {
206
- acquireResult = forceAcquireFn({ sessionId, mode, ttlHours, repoRoot });
226
+ // A stale or corrupt lock is reclaimed single-winner (#1494): two starts
227
+ // that both saw it used to force it, both got ok:true, and the later
228
+ // rename won. With onlyIfNotLive the loser gets the winner's live lock
229
+ // back as 'active' and bails into the conflict signal below. The same-
230
+ // session refresh of a LIVE lock keeps the unconditional overwrite.
231
+ const reclaim = forcedFrom === 'stale-heartbeat' || forcedFrom === 'corrupt';
232
+ acquireResult = forceAcquireFn({ sessionId, mode, ttlHours, repoRoot, ...(reclaim ? { onlyIfNotLive: true } : {}) });
207
233
  } catch {
208
234
  return null;
209
235
  }
236
+ if (
237
+ acquireResult?.ok === true &&
238
+ (forcedFrom === 'corrupt' || (forcedFrom === 'stale-heartbeat' && priorOwner !== sessionId))
239
+ ) {
240
+ reclaimed = forcedFrom;
241
+ }
242
+ if (reclaimingCorrupt) {
243
+ // The file's content is not echoed: it is unbounded and may be anything.
244
+ process.stderr.write(
245
+ acquireResult && acquireResult.ok === true
246
+ ? '⚠ lock-bootstrap: .orchestrator/session.lock was not a valid lock record — reclaimed for this session\n'
247
+ : `⚠ lock-bootstrap: .orchestrator/session.lock is not a valid lock record and could not be reclaimed (${acquireResult?.reason ?? 'unknown'}) — this session runs without a lock\n`,
248
+ );
249
+ }
210
250
  }
211
251
 
212
252
  // Any other non-ok reason (parallel-conflict, fs-error, other-session-active)
@@ -221,6 +261,20 @@ export async function bootstrapLock({
221
261
  // failure is swallowed and the bail proceeds. The return contract is unchanged —
222
262
  // bootstrapLock STILL returns null on this path.
223
263
  if (!acquireResult || acquireResult.ok !== true) {
264
+ // #1494 / #599: a lock path that exists but cannot be read (EACCES, or a
265
+ // directory) is never reclaimed — but it is no longer silent either. The
266
+ // WARN is discarded by the harness, so the read_anomaly event (the one
267
+ // SessionEnd already emits for the same state) is the countable trace.
268
+ if (acquireResult && acquireResult.reason === 'unreadable') {
269
+ process.stderr.write(
270
+ `⚠ lock-bootstrap: .orchestrator/session.lock exists but cannot be read (${acquireResult.error ?? 'unknown'}) — this session runs without a lock\n`,
271
+ );
272
+ await emitBestEffort(_emitEventImpl, 'orchestrator.session.lock.read_anomaly', {
273
+ session_id: sessionId,
274
+ status: 'unreadable',
275
+ ...(typeof acquireResult.error === 'string' ? { error: acquireResult.error } : {}),
276
+ });
277
+ }
224
278
  if (
225
279
  acquireResult &&
226
280
  acquireResult.reason === 'active' &&
@@ -236,19 +290,46 @@ export async function bootstrapLock({
236
290
 
237
291
  // Step 2: enrich the lock with v2 fields (last_heartbeat + semantic_session_id).
238
292
  // We re-read the file fresh (acquire() just wrote it) and overlay the new
239
- // fields, then atomically tmp+rename. When I3 lands and acquire() writes the
240
- // v2 shape natively, this overlay becomes idempotent (already-present fields
241
- // get overwritten with identical values).
293
+ // fields, then atomically tmp+rename. acquire() already writes
294
+ // last_heartbeat (Epic #583 W2-I3), so that half of the overlay re-writes an
295
+ // identical value; semantic_session_id is the half that adds information.
242
296
  const lockFile = path.join(repoRoot, '.orchestrator', 'session.lock');
297
+ let raw;
298
+ // ENOENT right after ok:true is most likely a racer holding OUR fresh lock in
299
+ // its tombstone: a second start that saw the same stale lock renames it
300
+ // aside, finds it live and links it back within microseconds
301
+ // (session-lock.mjs reclaimIfNotLive). Reading once and giving up left the
302
+ // lock ours but without semantic_session_id, owner proof and lock.acquired
303
+ // (#1494 review: two racers are enough). Bounded: 5 retries × 10 ms, so a lock
304
+ // that is really gone costs this hook at most ~50 ms. Measured 2026-10-02
305
+ // (2000 live-restore reclaims, load average ~9): the racer's rename→link-back
306
+ // window was p50 0.34 ms, p99 2.4 ms, max 31.7 ms. Ceiling (BV-004): a window
307
+ // longer than ~50 ms still ends in the lockless-enrichment null above;
308
+ // revisit if lock.acquired goes missing for sessions that own the lock.
309
+ for (let attempt = 0; raw === undefined; attempt += 1) {
310
+ try {
311
+ raw = fs.readFileSync(lockFile, 'utf8');
312
+ } catch (err) {
313
+ if (err?.code !== 'ENOENT' || attempt >= ENRICH_READ_RETRIES) return null;
314
+ await new Promise((resolve) => setTimeout(resolve, ENRICH_READ_RETRY_MS));
315
+ }
316
+ }
243
317
  let baseLock;
244
318
  try {
245
- const raw = fs.readFileSync(lockFile, 'utf8');
246
319
  baseLock = JSON.parse(raw);
247
320
  if (typeof baseLock !== 'object' || baseLock === null) return null;
248
321
  } catch {
249
- // Lock vanished between write and read — best-effort, return null.
250
322
  return null;
251
323
  }
324
+ // The file can belong to another session by now. The reclaim above is
325
+ // single-winner since #1494 (tombstone rename + link-create in
326
+ // forceAcquire's onlyIfNotLive), but a plain forceAcquire — the same-session
327
+ // refresh, an operator's Phase-1.2 force-take — still overwrites
328
+ // unconditionally, and a link-back that loses to a third writer drops the
329
+ // lock it was restoring (session-lock.mjs reclaimIfNotLive, ceiling 2).
330
+ // Enriching a foreign lock would stamp OUR label onto ITS record and write
331
+ // an owner proof that verifies against it, so it is left as is.
332
+ if (baseLock.session_id !== sessionId) return null;
252
333
 
253
334
  const startedAt = typeof baseLock.started_at === 'string'
254
335
  ? baseLock.started_at
@@ -301,27 +382,38 @@ export async function bootstrapLock({
301
382
  }
302
383
  } catch { /* best-effort — a missing proof never breaks session-start */ }
303
384
 
304
- // Step 3: best-effort observability breadcrumb. Failures are swallowed
305
- // so a missing events module never breaks the hook.
385
+ // Step 3: best-effort observability breadcrumb.
386
+ await emitBestEffort(_emitEventImpl, 'orchestrator.session.lock.acquired', {
387
+ session_id: enriched.session_id,
388
+ semantic_session_id: enriched.semantic_session_id,
389
+ mode: enriched.mode,
390
+ pid: enriched.pid,
391
+ host: enriched.host,
392
+ ttl_hours: enriched.ttl_hours,
393
+ ...(reclaimed ? { reclaimed } : {}),
394
+ });
395
+
396
+ return enriched;
397
+ }
398
+
399
+ /**
400
+ * Emit one event; every failure is swallowed so a missing events module never
401
+ * breaks the hook (observability is best-effort).
402
+ *
403
+ * @param {Function|undefined} emitImpl — the `_emitEventImpl` test seam.
404
+ * @param {string} name
405
+ * @param {object} payload
406
+ * @returns {Promise<void>}
407
+ */
408
+ async function emitBestEffort(emitImpl, name, payload) {
306
409
  try {
307
- let emitFn = _emitEventImpl;
410
+ let emitFn = emitImpl;
308
411
  if (!emitFn) {
309
412
  const eventsMod = await import('../../scripts/lib/events.mjs');
310
413
  emitFn = eventsMod.emitEvent;
311
414
  }
312
- if (typeof emitFn === 'function') {
313
- await emitFn('orchestrator.session.lock.acquired', {
314
- session_id: enriched.session_id,
315
- semantic_session_id: enriched.semantic_session_id,
316
- mode: enriched.mode,
317
- pid: enriched.pid,
318
- host: enriched.host,
319
- ttl_hours: enriched.ttl_hours,
320
- });
321
- }
415
+ if (typeof emitFn === 'function') await emitFn(name, payload);
322
416
  } catch { /* observability is best-effort */ }
323
-
324
- return enriched;
325
417
  }
326
418
 
327
419
  /**
@@ -81,7 +81,7 @@ const PROTECTED_PATTERNS = [
81
81
  /^tsconfig.*\.json$/, // tsconfig.json, tsconfig.base.json, tsconfig.build.json, …
82
82
  /^\.prettierrc(?:\..+)?$/, // .prettierrc, .prettierrc.json, .prettierrc.js, …
83
83
  /^prettier\.config\.(?:js|cjs|mjs)$/,
84
- /^commitlint\.config\.(?:js|cjs|mjs|ts)$/,
84
+ /^commitlint(?:\.ci)?\.config\.(?:js|cjs|mjs|ts)$/, // + the strict CI config (commitlint.ci.config.mjs)
85
85
  ];
86
86
 
87
87
  /**
@@ -95,9 +95,10 @@ import { pathToFileURL } from 'node:url';
95
95
  /** @type {typeof import('../scripts/lib/io.mjs').emitAllow} */ let emitAllow;
96
96
  /** @type {typeof import('../scripts/lib/io.mjs').emitDeny} */ let emitDeny;
97
97
  /** @type {typeof import('../scripts/lib/io.mjs').emitWarn} */ let emitWarn;
98
- let resolveProjectDir;
98
+ let resolveSessionRoot;
99
99
  let readJson;
100
- let findScopeFile;
100
+ // #1504 point 6 — bound from scope-gate below.
101
+ let findOwnScopeFile;
101
102
  let extractBashWriteTargets;
102
103
  let pathMatchesPattern;
103
104
  /** @type {typeof import('../scripts/lib/session-identity/own-session.mjs').readProcessLocalSessionIds} */
@@ -165,7 +166,8 @@ function bannerProjectDir() {
165
166
  * the entry-point catch banners.
166
167
  *
167
168
  * `hardening.mjs` is bound (no headFallback — it carries relative imports) for
168
- * `findScopeFile` / `extractBashWriteTargets` / `pathMatchesPattern`; the two
169
+ * `extractBashWriteTargets` / `pathMatchesPattern` (and `scope-gate.mjs` for
170
+ * `findOwnScopeFile`, #1504 point 6); the two
169
171
  * command-matching primitives come from the direct `blocker` namespace, which is
170
172
  * the only entry that opts into the `git show HEAD:` recovery. Because hardening
171
173
  * transitively imports command-blocker (via scope-gate.mjs), a broken command-blocker
@@ -184,6 +186,9 @@ async function bootstrap() {
184
186
  platform: { specifier: lib('platform.mjs') },
185
187
  common: { specifier: lib('common.mjs') },
186
188
  hardening: { specifier: lib('hardening.mjs') },
189
+ // #1504 point 6 — `findOwnScopeFile`. Already reachable through the
190
+ // hardening barrel, so no new module edge.
191
+ scopeGate: { specifier: lib('scope-gate.mjs') },
187
192
  blocker: {
188
193
  specifier: lib('command-blocker.mjs'),
189
194
  headFallback: true,
@@ -203,9 +208,10 @@ async function bootstrap() {
203
208
  );
204
209
 
205
210
  ({ readStdin, emitAllow, emitDeny, emitWarn } = modules.io);
206
- ({ resolveProjectDir } = modules.platform);
211
+ ({ resolveSessionRoot } = modules.platform);
207
212
  ({ readJson } = modules.common);
208
- ({ findScopeFile, extractBashWriteTargets, pathMatchesPattern } = modules.hardening);
213
+ ({ extractBashWriteTargets, pathMatchesPattern } = modules.hardening);
214
+ ({ findOwnScopeFile } = modules.scopeGate);
209
215
  ({ readProcessLocalSessionIds, classifyManifestSession } = modules.sessionIdentity);
210
216
  blocker = modules.blocker;
211
217
  degradedLabels = degraded;
@@ -275,10 +281,22 @@ async function main() {
275
281
  );
276
282
  }
277
283
 
278
- const projectRoot = resolveProjectDir();
279
-
280
- // G3 — no scope file → allow
281
- const scopePath = findScopeFile(projectRoot);
284
+ // #1492 — the SESSION's working copy, not the launch dir `$CLAUDE_PROJECT_DIR`
285
+ // keeps after `EnterWorktree` (the manifest lives in the worktree). See
286
+ // `resolveSessionRoot` in scripts/lib/platform.mjs.
287
+ const projectRoot = resolveSessionRoot(input.cwd);
288
+
289
+ // `new Set(...)` is load-bearing: `readProcessLocalSessionIds` returns a
290
+ // string[], and `classifyManifestSession` does `ownIds instanceof Set ?
291
+ // ownIds : new Set()` — a bare array would silently become EMPTY.
292
+ const ownIds = new Set(readProcessLocalSessionIds({ hookInput: input }));
293
+
294
+ // G3 — no scope file → allow. #1504 point 6: the first manifest that is not
295
+ // provably a peer's, so a peer's higher-precedence `.pi`/`.cursor`/`.codex`
296
+ // manifest no longer hides this session's own one; only when every candidate
297
+ // is foreign does the first come back, and G3b below stands down as before.
298
+ const located = findOwnScopeFile(projectRoot, ownIds, classifyManifestSession);
299
+ const scopePath = located.path ?? located.foreignPath;
282
300
  if (!scopePath) return flushNotices(notices);
283
301
 
284
302
  // SECURITY-REQ-08: read scope file exactly once; use the parsed object
@@ -313,10 +331,7 @@ async function main() {
313
331
  // empty, so this gate is permanently `'unknown'` = enforce = pre-#1153
314
332
  // behaviour there. Revisit when those hook payloads carry a session id.
315
333
  {
316
- // `new Set(...)` is load-bearing: `readProcessLocalSessionIds` returns a
317
- // string[], and `classifyManifestSession` does `ownIds instanceof Set ?
318
- // ownIds : new Set()` — a bare array would silently become EMPTY.
319
- const ownIds = new Set(readProcessLocalSessionIds({ hookInput: input }));
334
+ // `ownIds` is the process-local set built above G3.
320
335
  const { verdict, manifestIds } = classifyManifestSession(scope, ownIds);
321
336
  if (verdict === 'foreign') {
322
337
  // Observability only, and deliberately NOT emitWarn: this branch is hit on