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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/060-evolve.mdc +5 -5
- package/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +115 -3
- package/README.md +30 -15
- package/docs/ci-setup.md +120 -23
- package/docs/components.md +7 -7
- package/docs/events-schema.md +17 -14
- package/docs/install.md +14 -2
- package/docs/migration-v4.md +24 -17
- package/docs/plugin-architecture-v3.md +1 -1
- package/docs/scope-collision-guard.md +19 -15
- package/docs/session-config-reference.md +7 -7
- package/docs/vault-docs-architecture.md +8 -4
- package/hooks/_lib/hook-import-set.json +17 -8
- package/hooks/_lib/lock-bootstrap.mjs +118 -26
- package/hooks/config-protection.mjs +1 -1
- package/hooks/enforce-commands.mjs +28 -13
- package/hooks/enforce-scope.mjs +201 -23
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-session-start.mjs +4 -2
- package/hooks/on-stop.mjs +77 -171
- package/hooks/post-bash-write-verify.mjs +219 -24
- package/hooks/post-edit-validate.mjs +15 -5
- package/hooks/post-tool-batch-wave-signal.mjs +41 -156
- package/hooks/pre-bash-memory-propose-audit.mjs +28 -11
- package/hooks/pre-bash-staging-fence.mjs +29 -8
- package/hooks/pre-task-scope-disjoint.mjs +603 -241
- package/hooks/wave-scope-commit-guard.mjs +235 -73
- package/package.json +9 -8
- package/scripts/apply-session-learnings.mjs +35 -14
- package/scripts/archive-closed-prds.mjs +38 -11
- package/scripts/autopilot.mjs +8 -1
- package/scripts/backfill-abandoned-sessions.mjs +80 -5
- package/scripts/backfill-learnings-from-vault.mjs +49 -22
- package/scripts/codex-install.mjs +3 -0
- package/scripts/cursor-install.mjs +46 -2
- package/scripts/emit-session.mjs +5 -3
- package/scripts/export-hw-learnings.mjs +18 -7
- package/scripts/fleet-checkin.mjs +26 -3
- package/scripts/lib/autopilot/loop.mjs +25 -10
- package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
- package/scripts/lib/blocked-commands-policy.mjs +40 -0
- package/scripts/lib/common.mjs +41 -0
- package/scripts/lib/config/vault-integration.mjs +151 -16
- package/scripts/lib/config.mjs +5 -1
- package/scripts/lib/eval/engine.mjs +13 -0
- package/scripts/lib/events-retention-banner.mjs +393 -0
- package/scripts/lib/events-rotation.mjs +21 -6
- package/scripts/lib/events.mjs +51 -3
- package/scripts/lib/fleet-protocol.mjs +20 -2
- package/scripts/lib/hardening.mjs +1 -0
- package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
- package/scripts/lib/instruction-budget-guard.mjs +8 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +387 -20
- package/scripts/lib/learnings/io.mjs +125 -16
- package/scripts/lib/lock-reaper.mjs +38 -14
- package/scripts/lib/maintenance-due-banner.mjs +23 -8
- package/scripts/lib/named-vault-resolver.mjs +48 -5
- package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
- package/scripts/lib/orphan-reaper/reaper-audit.mjs +101 -69
- package/scripts/lib/orphan-reaper/scan-throttle.mjs +79 -27
- package/scripts/lib/orphan-reaper/scan.mjs +4 -0
- package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
- package/scripts/lib/orphan-reaper.mjs +5 -1
- package/scripts/lib/platform.mjs +206 -2
- package/scripts/lib/plugin-update-banner.mjs +3 -1
- package/scripts/lib/process-group.mjs +83 -22
- package/scripts/lib/scope-baseline.mjs +116 -25
- package/scripts/lib/scope-echo.mjs +7 -0
- package/scripts/lib/scope-gate.mjs +201 -27
- package/scripts/lib/session-close-backfill.mjs +202 -4
- package/scripts/lib/session-end/tail-runner.mjs +10 -5
- package/scripts/lib/session-lock-shape.mjs +6 -3
- package/scripts/lib/session-lock.mjs +320 -67
- package/scripts/lib/session-registry.mjs +41 -16
- package/scripts/lib/session-start-probes.mjs +242 -68
- package/scripts/lib/session-token-rollup.mjs +39 -34
- package/scripts/lib/sessions-staleness-banner.mjs +3 -1
- package/scripts/lib/tail-window.mjs +27 -3
- package/scripts/lib/telemetry/pricing.mjs +5 -2
- package/scripts/lib/telemetry/sync.mjs +17 -0
- package/scripts/lib/tests-src-ratio.mjs +77 -2
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +320 -32
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
- package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
- package/scripts/lib/vault-mirror/namespace.mjs +47 -26
- package/scripts/lib/vault-mirror/process.mjs +350 -62
- package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
- package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
- package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
- package/scripts/lib/vault-mirror/telemetry.mjs +18 -11
- package/scripts/lib/vault-status/board-writer.mjs +5 -1
- package/scripts/lib/vcs-repo-spec.mjs +8 -0
- package/scripts/materialize-wave-scope.mjs +7 -4
- package/scripts/release.mjs +1 -0
- package/scripts/run-migrate-v2-cross-repo.mjs +14 -2
- package/scripts/run-quality-gate.mjs +8 -5
- package/scripts/self-update.mjs +229 -0
- package/scripts/sweep-expired-learnings.mjs +560 -28
- package/scripts/validate-wave-scope.mjs +60 -6
- package/scripts/vault-backfill.mjs +50 -37
- package/scripts/vault-mirror.mjs +170 -33
- package/skills/_shared/fleet-protocol.md +74 -71
- package/skills/_shared/parallel-aware-auq.md +1 -1
- package/skills/_shared/state-ownership.md +38 -14
- package/skills/autopilot/SKILL.md +1 -1
- package/skills/bootstrap/SKILL.md +5 -3
- package/skills/bootstrap/_shared-template.md +96 -1
- package/skills/bootstrap/fast-template.md +3 -0
- package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
- package/skills/bootstrap/standard-template.md +2 -0
- package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
- package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
- package/skills/discovery/probes/vault-staleness.mjs +9 -3
- package/skills/docs-orchestrator/SKILL.md +12 -5
- package/skills/docs-orchestrator/audience-mapping.md +5 -3
- package/skills/evolve/SKILL.md +11 -2
- package/skills/evolve/references/evolve-analyze-mode.md +37 -17
- package/skills/session-end/learning-patterns.md +3 -3
- package/skills/session-end/metrics-collection.md +1 -1
- package/skills/session-end/phase-3-2-docs-verification.md +7 -6
- package/skills/session-end/plan-verification.md +25 -9
- package/skills/session-end/references/phase-3-documentation-updates.md +11 -1
- package/skills/session-end/session-metrics-write.md +45 -10
- package/skills/session-start/SKILL.md +3 -3
- package/skills/session-start/references/operations-contract.md +5 -3
- package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
- package/skills/session-start/references/phase-4-ssot-environment-check.md +8 -1
- package/skills/vault-mirror/SKILL.md +21 -11
- package/skills/wave-executor/SKILL.md +1 -1
- package/skills/wave-executor/circuit-breaker.md +1 -1
- package/skills/wave-executor/references/wave-executor-state-init.md +1 -1
- package/skills/wave-executor/references/wave-loop-dispatch.md +8 -0
- package/skills/wave-executor/references/wave-loop-review.md +2 -2
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +14 -6
- package/.agents/skills/navigator/SKILL.md +0 -22
- package/.agents/skills/navigator/agents/openai.yaml +0 -5
- package/.codex-plugin/skills/navigator/SKILL.md +0 -22
- package/.codex-plugin/skills/navigator/agents/openai.yaml +0 -5
- package/.cursor/commands/navigator.md +0 -15
- package/.cursor/skills/navigator/SKILL.md +0 -13
- package/pi/prompts/navigator.md +0 -12
- package/skills/navigator/SKILL.md +0 -193
- package/skills/navigator/references/account-check.sh +0 -87
- package/skills/navigator/references/fleet-decisions.mjs +0 -231
- 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
|
|
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`
|
|
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
|
|
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` (
|
|
111
|
-
- **Lock:** `.orchestrator/wave-dispatch-scopes
|
|
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()`. `
|
|
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
|
|
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
|
|
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. **
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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`,
|
|
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`.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
175
|
-
|
|
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-
|
|
3
|
-
"head": "
|
|
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
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
-
|
|
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.
|
|
240
|
-
//
|
|
241
|
-
//
|
|
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.
|
|
305
|
-
|
|
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 =
|
|
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
|
|
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
|
|
98
|
+
let resolveSessionRoot;
|
|
99
99
|
let readJson;
|
|
100
|
-
|
|
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
|
-
* `
|
|
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
|
-
({
|
|
211
|
+
({ resolveSessionRoot } = modules.platform);
|
|
207
212
|
({ readJson } = modules.common);
|
|
208
|
-
({
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
//
|
|
281
|
-
const
|
|
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
|
-
// `
|
|
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
|