session-orchestrator 5.8.0 → 5.10.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 (52) 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-plugin/plugin.json +1 -1
  5. package/CHANGELOG.md +52 -2
  6. package/README.md +14 -10
  7. package/docs/USER-GUIDE.md +2 -0
  8. package/docs/ci-setup.md +46 -1
  9. package/docs/events-schema.md +71 -1
  10. package/docs/owner-config-schema.md +18 -1
  11. package/docs/owner-privacy-patterns.md +85 -0
  12. package/docs/scope-collision-guard.md +12 -2
  13. package/hooks/enforce-commands.mjs +9 -19
  14. package/hooks/enforce-scope.mjs +23 -42
  15. package/hooks/hooks-codex.json +1 -1
  16. package/hooks/hooks.json +1 -1
  17. package/hooks/post-bash-write-verify.mjs +38 -21
  18. package/package.json +2 -2
  19. package/scripts/emit-session.mjs +15 -0
  20. package/scripts/lib/ci-status-banner.mjs +38 -5
  21. package/scripts/lib/events-schema.mjs +8 -0
  22. package/scripts/lib/gates/gate-full.mjs +6 -0
  23. package/scripts/lib/gates/gate-helpers.mjs +2 -0
  24. package/scripts/lib/named-vault-resolver.mjs +1 -1
  25. package/scripts/lib/owner-yaml.mjs +33 -3
  26. package/scripts/lib/platform.mjs +70 -1
  27. package/scripts/lib/pre-push-remote-gate.mjs +107 -0
  28. package/scripts/lib/quality-gate-wave.mjs +40 -0
  29. package/scripts/lib/quality-gate.mjs +32 -26
  30. package/scripts/lib/redact-spans.mjs +8 -17
  31. package/scripts/lib/scope-gate.mjs +68 -5
  32. package/scripts/lib/session-record-repair.mjs +19 -8
  33. package/scripts/lib/session-schema/validator.mjs +12 -0
  34. package/scripts/lib/session-start-probes.mjs +20 -5
  35. package/scripts/lib/validate/check-owner-leakage.mjs +198 -1015
  36. package/scripts/lib/vault-backfill/template.mjs +1 -1
  37. package/scripts/lib/vault-mirror/render-sessions.mjs +15 -3
  38. package/scripts/lib/vault-status/board-writer.mjs +15 -7
  39. package/scripts/lib/wave-executor/remote-dispatch.mjs +27 -9
  40. package/scripts/lib/wave-resource-gate.mjs +2 -1
  41. package/scripts/migrate-vault-paths.mjs +57 -60
  42. package/scripts/release.mjs +107 -37
  43. package/scripts/run-quality-gate.mjs +9 -39
  44. package/scripts/site-numbers.mjs +4 -0
  45. package/scripts/vault-consolidate.mjs +19 -4
  46. package/skills/_shared/fleet-protocol.md +2 -0
  47. package/skills/claude-md-drift-check/SKILL.md +1 -1
  48. package/skills/claude-md-drift-check/checker.mjs +14 -3
  49. package/skills/remote-offload/SKILL.md +1 -1
  50. package/skills/session-end/metrics-collection.md +39 -9
  51. package/skills/wave-executor/references/wave-loop-dispatch.md +1 -1
  52. package/skills/wave-executor/references/wave-loop-review.md +37 -7
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "A repeatable Plan, Go, Close workflow for AI coding sessions: /session reads your repo and agrees the scope, /go runs the work in waves with a quality gate between each, /close verifies and commits. Runs on Claude Code, Codex CLI, Cursor and Pi.",
10
- "version": "5.8.0"
10
+ "version": "5.10.0"
11
11
  },
12
12
  "plugins": [
13
13
  {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "session-orchestrator",
4
- "version": "5.8.0",
4
+ "version": "5.10.0",
5
5
  "description": "A repeatable Plan, Go, Close workflow for AI coding sessions: /session reads your repo and agrees the scope, /go runs the work in waves with a quality gate between each, /close verifies and commits. Runs on Claude Code, Codex CLI, Cursor and Pi.",
6
6
  "author": {
7
7
  "name": "Bernhard Goetzendorfer",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "session-orchestrator",
3
- "version": "5.8.0+codex.20261005120103",
3
+ "version": "5.10.0+codex.20261007193643",
4
4
  "description": "A repeatable Plan, Go, Close workflow for AI coding sessions: /session reads your repo and agrees the scope, /go runs the work in waves with a quality gate between each, /close verifies and commits. Runs on Claude Code, Codex CLI, Cursor and Pi.",
5
5
  "keywords": [
6
6
  "session",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "session-orchestrator",
3
- "version": "5.8.0",
3
+ "version": "5.10.0",
4
4
  "description": "A repeatable Plan, Go, Close workflow for AI coding sessions: /session reads your repo and agrees the scope, /go runs the work in waves with a quality gate between each, /close verifies and commits. Runs on Claude Code, Codex CLI, Cursor and Pi.",
5
5
  "author": {
6
6
  "name": "Bernhard Goetzendorfer"
package/CHANGELOG.md CHANGED
@@ -7,6 +7,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [5.10.0] - 2026-10-07
11
+
12
+ ### Added
13
+
14
+ - **Foreign-host path exceptions** (#937): optional `owner.yaml` `drift-check.foreign-host-prefixes` downgrades matching missing paths to warnings. Directory boundaries are respected; broad, malformed or unsafe prefixes cannot weaken other checks.
15
+ - **Session host attribution** (#1054): new owned sessions record their stable originating host. Mirrored notes prefer `host_id`, then `host`, then `host_class`; the board appends a `Host` column while supporting legacy rows. Explicit origins and historical records remain unchanged.
16
+
17
+ ### Fixed
18
+
19
+ - **Annotated release tags** (#1536): pre-push verification resolves tag objects to their target commit before remote execution. Tags targeting older commits verify that target, and non-commit tags remain blocked.
20
+ - **Isolated agent scope enforcement** (#1504 items 1–2): hooks resolve coordinator authority separately from the agent checkout, apply grants to that checkout and deny ambiguous ownership, unsafe manifests and coordinator-only carveouts. Native Codex pre-tool hook wiring remains outside this change.
21
+ - **Published-package privacy** (#1530): private matching rules live in a protected host-local file. Release checks scan the retained archive's contents and publish those checked bytes; diagnostics omit sensitive content. Vault tools use configured destinations.
22
+ - **Gate diagnostics and counts** (#1534, #1535): colored Vitest summaries retain correct case and file counts, and large piped failures drain their complete assertion output before exit.
23
+ - **Linked-worktree ledger tests** (#1527): probes use temporary fixtures instead of writing to the real checkout's event ledger.
24
+ - **Watcher regression tests** (#1529): monotonic lifetime measurement and explicit spawn/close handling remove timing-related false failures while retaining early-exit detection.
25
+ - **Public presentation**: correct the README privacy-policy link, remove private changelog examples and add SoftwareApplication structured data to the lead page.
26
+
27
+ ### Changed
28
+
29
+ - **Remote pre-push verification** (#1532): configured test-role hosts can run the full gate against the exact pushed SHA. Transport or readiness failures permit fallback; failed gates, mismatched receipts and unconfirmed results block the push. Verdicts enter the originating repository's ledger.
30
+ - **Gate-result documentation** (#1528): clarify corrupt-line handling, per-wave count fallbacks, diagnostic queries and the known split-line append limitation.
31
+ - Update `source-map-js` from 1.2.1 to 1.2.2 in the root and Remotion lockfiles.
32
+
33
+ ### Security
34
+
35
+ - Upgrade `lint-staged` to 16.4.0, removing the `micromatch` → `braces` dependency chain affected by GHSA-vfj7-8cjw-p6xm (#1533).
36
+
37
+ ### Upgrade notes
38
+
39
+ - Hosts relying on embedded owner-specific privacy rules must migrate them to a protected version 1 `owner-patterns.json` before strict release checks. See [Host-local owner privacy patterns](docs/owner-privacy-patterns.md). This is release-host configuration; ordinary consumer scans retain their documented warning behavior when no policy is configured.
40
+
41
+ ## [5.9.0] - 2026-10-06
42
+
43
+ ### Added
44
+
45
+ - **Lead page on the website**: `site/lead/` introduces the sibling project lead-session-orchestrator in the site design, linked from the main, German and guide pages, the sitemap and llms.txt; the README and the fleet contract point to it.
46
+
47
+ ### Changed
48
+
49
+ - **CI status banner, GitHub path**: unfinished check runs get their own reasons `check-runs-in-progress`, `check-runs-queued` and `check-runs-cancelled` (several kinds at once: `check-runs-mixed`) instead of `check-runs-not-complete`, and `details.checkRunCounts` carries additive counts per kind, shown by the session-start banner for `unknown`/`red`; the gh hint applies to every GitHub reason. Status vocabulary and severity are unchanged (#856).
50
+ - **CI status banner** names GitHub check runs in the valid states `skipped`, `neutral` and `waiting`/`pending`/`requested` with their own reasons (`check-runs-skipped`, `-neutral`, `-waiting`) and banner labels instead of `check-runs-not-complete`; the status vocabulary `green | red | unknown` is unchanged (#1526).
51
+ - **Session-record validator** rejects millisecond-precise but calendar-invalid timestamps (round-trip check); this deliberately also rejects the ISO end-of-day form `T24:00:00Z`, which no producer writes (#1526).
52
+ - **Prose readers of the gate result** (`skills/wave-executor/references/wave-loop-review.md`, `skills/session-end/metrics-collection.md`) take `suite_passed`/`suite_failed` from the `orchestrator.quality_gate.*` event selected by `semantic_session_id` + `wave_number` (last record of the wave) with one identical `jq` selector that skips unreadable ledger lines instead of silently returning nothing; the counts fallback applies only to pre-#954 sessions, a gate run outside the wrapper (including an event without `semantic_session_id`) and a missing wave, and reads this wave's own gate output in wave-loop step 7 (which runs before the header is written) and the STATE.md header at session end, the same sources as `suite_platform`. `docs/events-schema.md` and the emitter docblocks describe that contract (#966 steps 2–3).
53
+ - **Quality-gate events** carry an optional `wave_number` (positive safe integer from the session's own wave scope, omitted when unknown) on `orchestrator.quality_gate.*` from both the CLI and the library path; the auto-fix-loop record therefore now matches the per-wave selector (#966 step 1).
54
+
55
+ ### Fixed
56
+
57
+ - **Remote-offload readiness** (#1524): Claude dispatch authorizes on the new `headless-ready=yes` marker; the legacy `ready=yes` is accepted only when no new readiness marker (`gate-ready`, `headless-ready`) is on the line, and `gate-ready` alone no longer authorizes Claude dispatch. Conflicting or unknown headless values fail closed.
58
+ - **`session-record-repair`** (#1525) also normalizes microsecond-precise timestamps with a `+00:00` offset to milliseconds, after a calendar round-trip check; the original value is preserved, and an invalid `ended_at` produces no substitute.
59
+
10
60
  ## [5.8.0] - 2026-10-05
11
61
 
12
62
  ### Added
@@ -3452,8 +3502,8 @@ existing sessions are unaffected. CI green across the full suite on every commit
3452
3502
  ### Fixed
3453
3503
 
3454
3504
  - **Owner-leakage scanner: trailing-slash blindspot (P1) + dash-encoded form (P9) + dotfile-allowlist reachability** — closes #631 (priority:high), #634 (priority:high, W4 panel finding).
3455
- - **#631** — P1 regex `/\/Users\/bernhard[a-z.]*\//` required a slash AFTER the username, so bare `/Users/<owner>.` strings (end-of-line, before `&&`, before newline) passed undetected. Replaced with Candidate F `/\/Users\/bernhardg[a-z.]*(\/|\b)/` (tightest false-positive profile: rejects `bernhardo`/`bernhardt`/`bernhardg9`/`bernhardg_x` lookalikes — all pinned by tests); neutralized the 2 genuinely-leaking scanned lines (`memory-paths.mjs` docstring, `memory-paths.test.mjs` literal → dynamic-construct); 5 issue-form regression tests + 7 W3 edge-case tests + word-boundary intent pins. Fleet re-run with the fixed scanner across 43 repos posted to #632 (19 repos with P1 hits / ~233 occurrences quantified).
3456
- - **#634** — W4 security-review found a committed dash-encoded home path (`-Users-<owner>--…`, the Claude Code projects-dir encoding) in `docs/migrations/2026-05-18-461-persona-reviewers-rename.md` shipping to the public mirror, structurally invisible to P1–P8. Scrubbed the line, added **P9** `/-Users-bernhardg[a-z.]*-/`, added the encoding-contract fixture file to SELF_EXCLUSIONS, + positive/negative regression tests. Residual: git history retains the old line (same accepted-residual class as #632 point 2).
3505
+ - **#631** — P1 matched `/Users/<owner>/` only with a slash AFTER the username, so bare `/Users/<owner>.` strings (end-of-line, before `&&`, before newline) passed undetected. Replaced with Candidate F: owner-specific matching with either a trailing slash or a word boundary (tightest false-positive profile: rejects similar usernames and digit/underscore suffixes — all pinned by tests); neutralized the 2 genuinely-leaking scanned lines (`memory-paths.mjs` docstring, `memory-paths.test.mjs` literal → dynamic-construct); 5 issue-form regression tests + 7 W3 edge-case tests + word-boundary intent pins. Fleet re-run with the fixed scanner across 43 repos posted to #632 (19 repos with P1 hits / ~233 occurrences quantified).
3506
+ - **#634** — W4 security-review found a committed dash-encoded home path (`-Users-<owner>--…`, the Claude Code projects-dir encoding) in `docs/migrations/2026-05-18-461-persona-reviewers-rename.md` shipping to the public mirror, structurally invisible to P1–P8. Scrubbed the line, added **P9** (owner-specific dash-encoded home path pattern), added the encoding-contract fixture file to SELF_EXCLUSIONS, + positive/negative regression tests. Residual: git history retains the old line (same accepted-residual class as #632 point 2).
3457
3507
  - **Dotfile-allowlist reachability (W3-P3 finding, folded in-session)** — `isTextFile()` checked `extname()` first, but `extname('.env.example')` is `'.example'` (truthy), making the `DOTFILE_ALLOWLIST` entry unreachable — `.env.example` was silently never scanned. Now basename-first + 2 reachability regression tests.
3458
3508
  - **Dead baseline-fetch rule-bridge in bootstrap** — closes #618 (priority:high). Bootstrap Step S99/D99 gated the on-demand baseline fetch on `scripts/lib/fetch-baseline.sh`, a file the plugin never ships (only the Node port `fetch-baseline.mjs` exists since #218) — so the guard was always false and the step silently never fired in real installs. Flipped all bootstrap guards/refs (`skills/bootstrap/{_shared-template,SKILL,standard-template}.md`) to drive the shipped `fetch-baseline.mjs` single-file CLI in a per-manifest-rule `while`-loop (404→skip→continue) followed by an inline `node -e` `.baseline-fetch.lock` write, and plumbed the GitLab host from the `gitlab-host` Session Config key / `GITLAB_HOST` env (the `.mjs` keeps the host **mandatory with no private-host default** — privacy-correct for the public mirror; the issue's suggestion to reintroduce the legacy `.sh`'s hardcoded private-host default was a leak trap and was avoided). Mechanically pinned by a new `scripts/lib/validate/check-baseline-fetch-bridge.mjs` validate-plugin check (asserts the guarded file exists + zero dead `.sh`/shell-function refs) and a 19-test regression guard, so the dead-bridge class cannot silently recur.
3459
3509
  - **Undocumented `--no-verify` in vault-mirror auto-commit** — closes #603. The vault-mirror auto-commit's `git commit --no-verify` (`scripts/lib/vault-mirror/auto-commit.mjs`) is now documented inline + in `skills/vault-mirror/SKILL.md` (rationale: only generator-stamped mirror artifacts ever reach the commit — already validated — and the unattended session-end close must not block on interactive/slow vault-side hooks) and pinned by a regression test asserting the flag on the real commit args.
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Session Orchestrator
2
2
 
3
3
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
4
- [![Version](https://img.shields.io/badge/version-5.8.0-blue.svg)](CHANGELOG.md)
4
+ [![Version](https://img.shields.io/badge/version-5.10.0-blue.svg)](CHANGELOG.md)
5
5
  [![npm](https://img.shields.io/npm/v/session-orchestrator.svg)](https://www.npmjs.com/package/session-orchestrator)
6
6
 
7
7
  **Give your agents a working rhythm.**
@@ -139,17 +139,17 @@ How this compares to other orchestrators, with measured results kept separate fr
139
139
 
140
140
  All four platforms share the same skills, commands and scripts; only the hooks differ, because each harness fires different events. Codex leaves its `PreToolUse` handlers empty because these guards do not yet match its tool names and edit payloads ([why](docs/codex-setup.md#why-our-pretooluse-guards-stay-unwired--the-reason-corrected)). Cursor and Pi have known event-coverage limits — see [`docs/cursor-setup.md`](docs/cursor-setup.md) and [`docs/pi-setup.md`](docs/pi-setup.md).
141
141
 
142
- ## Recent highlights (v5.8.0)
142
+ ## Recent highlights (v5.10.0)
143
143
 
144
- Highlights of the v5.8.0 line:
144
+ Highlights of the v5.10.0 line:
145
145
 
146
- - **Your secrets stay out of the plugin cache.** Claude and Codex installs now come from the packed package (`npm pack`), so `.env.local` and session state no longer land in `~/.claude` or `~/.codex` plugin caches (#1515, #1518).
147
- - **`mirror: none` means none.** `/close` no longer pushes to a GitHub remote when the repo opted out, and a fresh shell without `PLUGIN_ROOT` no longer breaks the mirror push (#1034).
148
- - **Honest gate scores.** Eval rubric v3 counts a gate run that was killed but exited 0 as failed; older records keep their own rubric version and are never re-scored silently (#1487).
149
- - **Rules live through their expiry day.** A rule's `expires-at` date is inclusive in the loader, the budget probe, the drift check and the expiry sweep alike (#1521).
150
- - **vault-sync needs no second install.** The validator resolves `zod`/`yaml` from the plugin's own dependencies and, if they are missing, says exactly which command fixes it (#1070).
146
+ - **Scope checks follow the agent checkout.** Isolated agents use verified coordinator manifests, with grants applied to their own checkout (#1504).
147
+ - **Release privacy checks inspect the published archive.** Host-specific rules stay in protected local configuration, and the release publishes the bytes it checked (#1530).
148
+ - **Remote push gates verify the pushed commit.** A matching receipt is required; unavailable hosts permit fallback (#1532).
149
+ - **Sessions retain their host.** Mirrored notes and the board show the originating machine, while legacy records remain supported (#1054).
150
+ - **Clearer gate results and host-aware drift checks.** Colored test counts and complete failure output survive capture; narrow foreign-host prefixes can be configured explicitly (#937, #1534, #1535).
151
151
 
152
- Full changes and verification: [CHANGELOG.md](CHANGELOG.md).
152
+ Full changes and upgrade notes: [CHANGELOG.md](CHANGELOG.md).
153
153
 
154
154
  ## Documentation
155
155
 
@@ -159,6 +159,10 @@ Full changes and verification: [CHANGELOG.md](CHANGELOG.md).
159
159
  - [Telemetry](docs/telemetry.md) — what stays on your machine, what the optional anonymous telemetry would send, and every switch that turns it off
160
160
  - [Contributing](https://github.com/Kanevry/session-orchestrator/blob/main/CONTRIBUTING.md) · [Plugin architecture](docs/plugin-architecture-v3.md) · [docs/ router](docs/README.md)
161
161
 
162
+ ## Running many sessions at once
163
+
164
+ session-orchestrator makes one session reliable. When several sessions run in parallel across repositories, a lead session can coordinate them: who runs next, what each may do, one queue for your decisions, one stop for everything. That is the sibling project [lead-session-orchestrator](https://session-orchestrator.com/lead) ([GitHub](https://github.com/Kanevry/lead-session-orchestrator), npm `lead-session-orchestrator`). Each works alone; together they use the shared fleet contract in `skills/_shared/fleet-protocol.md`.
165
+
162
166
  ## Scope and support
163
167
 
164
168
  Provided **as-is**: a community project, best-effort maintenance, no SLA. Questions and ideas go to [Discussions](https://github.com/Kanevry/session-orchestrator/discussions), bugs to [Issues](https://github.com/Kanevry/session-orchestrator/issues).
@@ -169,4 +173,4 @@ The reasoning behind the method is taught at [agenticbuilders.at](https://agenti
169
173
 
170
174
  ## License
171
175
 
172
- [MIT](LICENSE) · [Privacy policy](https://gotzendorfer.at/en/session-orchestrator/privacy) · [npm](https://www.npmjs.com/package/session-orchestrator)
176
+ [MIT](LICENSE) · [Privacy policy](https://session-orchestrator.com/datenschutz) · [npm](https://www.npmjs.com/package/session-orchestrator)
@@ -1030,6 +1030,8 @@ Set `persistence: false` in your Session Config to disable STATE.md writing and
1030
1030
 
1031
1031
  ## 15. Safety Features
1032
1032
 
1033
+ Configure host-specific leakage protection with the [owner privacy pattern guide](owner-privacy-patterns.md), including protected local policy files, vault-path migration and strict packed-release checks.
1034
+
1033
1035
  ### Scope Enforcement
1034
1036
 
1035
1037
  Before each wave, the wave-executor writes `.claude/wave-scope.json` defining the allowed file paths and blocked commands for that wave's agents. PreToolUse hooks validate Edit/Write operations against `allowedPaths` and Bash commands against `blockedCommands`.
package/docs/ci-setup.md CHANGED
@@ -598,7 +598,7 @@ green. Revisit trigger: a `main` coverage failure whose MR pipeline was green,
598
598
  or a switch to measuring coverage inside the three `test` shards with
599
599
  `vitest --merge-reports`.
600
600
 
601
- ## Local pre-push gate
601
+ ## Pre-push full gate
602
602
 
603
603
  `.husky/pre-push` runs `npm run quality-gate`
604
604
  (`scripts/run-quality-gate.mjs --variant full-gate`: typecheck + full vitest
@@ -611,3 +611,48 @@ suite + lint) and blocks the push on any non-zero exit.
611
611
  - `SKIP_QUALITY_GATE=1 git push` is the named bypass. Prefer it over
612
612
  `git push --no-verify`, which disables every hook silently and leaves no
613
613
  record of what was skipped.
614
+
615
+ The hook materialises the first content-bearing ref's exact pushed SHA in an
616
+ isolated tracked checkout, so uncommitted, untracked and gitignored source files
617
+ cannot make the pushed tree pass. A multi-ref push warns that only the first
618
+ content-bearing ref is gated. Failure to materialise that commit blocks the push.
619
+
620
+ ### Remote routing (#1532)
621
+
622
+ Remote execution requires `offload` on the local PATH and a reachable host listed
623
+ in the pushed tree's Session Config `remote-hosts` block with `test` in
624
+ `roles-allowed`. For example, using an SSH alias configured on your machine:
625
+
626
+ ```yaml
627
+ remote-hosts:
628
+ - alias: gate-host
629
+ roles-allowed: [test]
630
+ ```
631
+
632
+ The router tries eligible aliases in configuration order, checking each with
633
+ `offload doctor -H <alias> --brief --no-probe`. The alias selects a gate execution
634
+ host; it is independent of the Git remote being pushed to. With no eligible
635
+ host, no `offload` executable, or unavailable remote readiness/transport, the
636
+ hook runs the same local full gate. A classified transport failure can first
637
+ try the next configured host.
638
+
639
+ On the remote checkout, the command checks out the pushed SHA again, verifies
640
+ `HEAD` and a clean source tree, and runs `npm run --silent quality-gate`.
641
+ Acceptance requires a nonce-bound receipt for that SHA, successful command
642
+ exits, and the canonical `full-gate` JSON report with no stubbed checks: tests
643
+ must pass without failures, suite death or timeout; typecheck and lint must
644
+ report `pass` or an explicitly configured `skip` without a timeout. A gate failure or an unconfirmed result
645
+ blocks the push; it does not trigger a local retry that could hide the failure.
646
+
647
+ Linked worktrees retain their main repository's basename and original Git
648
+ remote URL in the isolated checkout. Offload 1.5.1 resolves an external temp
649
+ checkout by basename; it has no explicit repository-mapping option. Nested
650
+ `Ventures/org` layouts therefore do not retain their full host path mapping,
651
+ and repositories with the same basename can collide. The gate still verifies
652
+ the exact pushed commit; remote setup failures use the fallback above.
653
+
654
+ The local router records the remote verdict in the originating repository's
655
+ `orchestrator.quality_gate.passed` or `.failed` event, with `remote_host`,
656
+ `pushed_sha` and available canonical suite counts. Logs identify the chosen
657
+ route and fallback reason. This verifies the pushed commit before publication;
658
+ CI status remains a separate check on the published SHA.
@@ -66,6 +66,76 @@ instead of `jq >> events.jsonl`. For callers that manage their own stream path,
66
66
  `emitEvent(type, payload, { filePath })` accepts an additive optional path override
67
67
  (default = the resolved `eventsFilePath()`); existing two-argument callers are unchanged.
68
68
 
69
+ ## Quality-gate wave metrics consumers
70
+
71
+ The readers are two skill readers,
72
+ [`wave-loop-review.md` step 7](../skills/wave-executor/references/wave-loop-review.md)
73
+ and [`metrics-collection.md` § 1.7](../skills/session-end/metrics-collection.md),
74
+ and the convergence monitor (`scripts/lib/convergence-monitor.mjs`), which folds
75
+ `counts` only from gate records that also carry `wave_number`.
76
+ The two skill readers read `orchestrator.quality_gate.{passed,failed}` as flat
77
+ records, filtering by the existing own `semantic_session_id` and `wave_number`, then taking the
78
+ last matching record with `counts`. `counts.passed` supplies `suite_passed`;
79
+ `counts.failed` supplies `suite_failed`. Their identical jq selectors exclude
80
+ other sessions, other waves, and unrelated events without a time-window join.
81
+ They read the ledger raw (`jq -R` with `fromjson? | objects`), so an unreadable
82
+ line such as a torn tail is skipped instead of aborting `jq`. The old parsed-input
83
+ pipeline could emit an earlier matching value before a parse error; `tail -1`
84
+ then retained that stale value, and its success could hide the nonzero jq exit.
85
+ Known limit: if `appendFile` is interrupted mid-record, `fromjson?` discards the
86
+ whole truncated line, so `tail -1` can still return an older matching run. That
87
+ value alone does not establish the current gate result.
88
+ An event without `semantic_session_id` (emitters set it only from the session lock)
89
+ never matches and is treated like a gate outside the event wrapper.
90
+
91
+ Both emitters supply the same optional counts and wave keys: the CLI emits once
92
+ per CLI run; the library emits once per `runQualityGateWithRetry` call, never
93
+ per retry (`attempts` describes retries). Missing `counts` or `wave_number` stays
94
+ absent, never zero-filled. An empty selector or an event without counts, such
95
+ as fail-fast before the test suite, does not alone permit count fallback.
96
+ Telemetry remains best-effort; emission does not change gate exits or verdicts.
97
+ The event-name and reporting-variant contracts in the catalogue below still apply.
98
+
99
+ An empty counts selector cannot distinguish a missing event, missing wave attribution,
100
+ or an attributed event without counts after fail-fast. Inspect the own session's
101
+ records without the counts filter before deciding whether a documented count
102
+ fallback case applies:
103
+
104
+ ```bash
105
+ jq -cR --argjson w "$WAVE" --arg s "$SEMANTIC_SESSION_ID" '
106
+ fromjson? | objects
107
+ | select((.event | type) == "string" and (.event | startswith("orchestrator.quality_gate.")))
108
+ | select(.semantic_session_id == $s)
109
+ | select(.wave_number == $w or .wave_number == null)
110
+ | {event, semantic_session_id, wave_number, counts}
111
+ ' .orchestrator/metrics/events.jsonl
112
+ ```
113
+
114
+ This is a diagnostic, not a replacement counts source. No rows means no readable
115
+ matching event; a row without `wave_number` is not proof of this wave's run;
116
+ a row for this wave without `counts` supplies no measurement. Keep the session
117
+ filter and never copy counts from another session or wave, or from a row without
118
+ wave attribution. An event without `semantic_session_id` is not attributable by
119
+ this query either; diagnose missing session attribution from independent evidence
120
+ of the own gate run, not by adopting an unrelated unattributed event. Missing
121
+ attribution or an empty result alone does not prove a fallback source exists.
122
+
123
+ Count fallback is limited to three cases: pre-#954 sessions, a gate outside the
124
+ event wrapper, or missing wave attribution. Its source differs by reader: step 7
125
+ runs before § 3a writes this wave's header and falls back to the output of this
126
+ wave's own gate run; session end falls back to the STATE.md Wave History header
127
+ `— suite <passed>/<failed> on <platform>`.
128
+ An auto-fix call with a known wave uses the event selector. When neither an event
129
+ measurement nor an applicable count fallback supplies counts, omit the two
130
+ count fields: absence means unmeasured; a present zero means measured zero.
131
+
132
+ `suite_platform` comes separately from the platform of this wave's gate run
133
+ (step 7) or the header that run wrote (session end); there is **no platform field
134
+ in the event payload**. Omit it without such a measurement, and never infer it
135
+ from `variant`. Event-derived
136
+ counts may still be mirrored into the existing header as compatibility output,
137
+ not a second measurement source. Header and sessions.jsonl contracts are unchanged.
138
+
69
139
  ## Naming convention
70
140
 
71
141
  Orchestrator-owned events use a dotted namespace:
@@ -105,7 +175,7 @@ namespace we own. The validator + regex live in `scripts/lib/events-schema.mjs`
105
175
  | `orchestrator.memory.propose_invoked` | `hooks/pre-bash-memory-propose-audit.mjs` | PreToolUse(Bash), once per `node … memory-propose.mjs …` invocation the G3 regex matches — observe-only, the hook never denies. **Payload:** `session_id` (or `null`), `wave`, `cwd` (the working directory RELATIVE to the resolved project dir — `.` when equal, `scripts/lib` from a subdirectory; a directory OUTSIDE the project is written as `sha256:<12 hex>` of its resolved path, and so is EVERY cwd when the project dir is the filesystem root, the home dir or an ancestor of it (`/Users`), where the relative form would read `Users/<name>/…`; that 12-hex sha256 prefix is PSEUDONYMISATION, not anonymisation — a guessable path (a home dir, a usual checkout location) is recovered offline by hashing candidates; **never an absolute path** — until 2026-09-25 (#1439) it was `process.cwd()` verbatim, i.e. the operator's home path in an `events.jsonl` line; zero production readers), `exit_code` (always `null` — a PreToolUse hook runs BEFORE the command), plus the #1415 argv summary: `command_hash` (**sha256 of the RAW command, truncated to 16 hex — same recipe as `enforce-commands` after `8f15f77b`; never the command text**), `flags_present` (string[], the subset of `memory-propose.mjs`'s OWN flag NAMES — `type`, `subject`, `insight`, `evidence`, `confidence`, `dry-run`, `file-paths`, `help` — present in the command, in that fixed order; built by intersecting a CLOSED list, never by scraping `--\S+`, so a flag-shaped VALUE can never be reported as a flag) and `argv_length` (number, the raw command's character count). Until 2026-09-21 this row carried `argv_truncated`: 512 characters of command TEXT, redacted only per-VALUE for five flags, whose own caveat named its gaps (`--insight=$VAR`, `--insight=$(cat secret)`, a secret written anywhere else on the line). It went into an `events.jsonl` line (gitignored here via `.gitignore:57`; consumer repos may track it) and, with the Clank webhook configured, onto the network. **Measured 2026-09-21** (`rg argv_truncated` over `scripts/ hooks/ skills/ docs/`): **zero production readers** — the field's only consumers were this hook's own tests. Pinned in `tests/hooks/pre-bash-memory-propose-audit.test.mjs` in BOTH directions (summary fields present AND `argv_truncated`/`command` undefined), plus a negative test that string-searches the serialized record for every sentinel value — so nobody puts the raw field back beside the hash |
106
176
  | `orchestrator.wave.completed` | `hooks/post-tool-batch-wave-signal.mjs` · `hooks/on-session-end.mjs` (`emitFinalWaveCompleted`) | PostToolBatch — fires live via `.claude/wave-scope.json` `.wave` increase (mechanical fallback, #612), closing wave N at the N→N+1 transition; an explicit injected `wave_signal: 'wave-complete'` still takes precedence. **The former `started` sibling was REMOVED 2026-09-19** (operator decision, #1202 §11): it carried no measurement, its only reader was `scripts/lib/convergence-monitor.mjs`, and there it cost the one signal it could not feed — the batch hook wrote it in the same millisecond as completed{N}, so both shared a tail tick and it masked the (N-1, N) `shrinking_diff` pair. An injected `wave_signal: 'wave-start'` is still accepted and emits nothing; historical records stay in the ledgers and nothing reads them. **Re-emission ceiling (open):** a session that does not own `.orchestrator/current-session.json` never advances the marks below, so each of its batches re-emits completed{lastWave} until its own SessionStart takes the file over — measured 2026-09-19 as up to 579 emissions for one (session_id, wave_number) group. **Second `.completed` emitter (#1193):** the batch hook closes wave N-1 only at an N-1→N transition, so the LAST wave of every session never received a completion — measured fleet-wide 2026-09-02 as **296 gaps over 296 wave runs** (1018 started vs 722 completed), exactly one missing final completion per run. SessionEnd now emits it. **Payload of that record:** `wave_number` (the `current-session.json` `last_wave`), `reason: 'session-end'`, `emitted_by: 'on-session-end'`, plus `session_id` / `semantic_session_id` — **omitted when unattested**, never fabricated. **Idempotent** via the `last_wave_completed` high-water mark in `.orchestrator/current-session.json`, written by BOTH emitters and preserved across `/clear`+compact by `on-session-start.mjs`; `last_wave` absent or `0` emits nothing (an Express-Path or coordinator-direct session never batched, and zero waves is the correct reading, not a gap). **Two gates on the SessionEnd emitter, both load-bearing:** (a) OWNERSHIP — `current-session.json` is a single repo-global file describing whichever session most recently ran SessionStart, routinely a different still-live session in a shared working copy, so the emit reuses `resolveSession()`'s `isRecordedSession` predicate; when false it emits nothing AND writes nothing, since writing the marker into a peer's file would silence the peer's own SessionEnd and preserve this very gap on the wrong session. (b) REASON — `reason === 'clear'` **and `reason === 'resume'`** are SKIPPED: the SessionEnd matcher is empty, so `/clear` fires the hook mid-wave while the LOGICAL session continues, and `on-session-start.mjs` preserves `last_wave` / `last_wave_completed` across a resume of the SAME logical session exactly as it does across a clear — so both end the HARNESS session, not the logical one. Closing the live wave on either is premature, and the preserved marker would then suppress the real completion later. Resume is the MORE common of the two (fleet n = 1498 `session.ended`, 2026-09-02: 12 `resume` vs 9 `clear`). Deliberately SessionEnd-only (`on-stop.mjs` is not mirrored) so the closed-vs-abandoned split stays measurable. **Diff-size keys on the BATCH-HOOK emitter only (#980):** `files_changed` (integer) + `files_changed_source: 'worktree-vs-wave-start-sha'` (the only value emitted today; present iff `files_changed` is). Measurement: the DEDUPED union of `git diff --name-only <wave_start_sha>` and `git ls-files --others --exclude-standard`, run in the project dir at the N→N+1 transition, where `wave_start_sha` is the `git rev-parse HEAD` the batch hook persisted into `.orchestrator/current-session.json` when wave N was OPENED (same ownership gate as the wave keys; written as `null` when git is unreadable, so a previous wave's sha can never inflate the next count). Worktree-vs-sha rather than `<sha>..HEAD` because the coordinator commits at session close, not per wave — a commit-only diff reads 0 for every wave of a normal session. **Both keys are OPTIONAL and absent-is-not-zero:** any git failure, a 1.5 s timeout, or a missing `wave_start_sha` omits them, and `scripts/lib/convergence-monitor.mjs` reads an absent key as `null`, so the `shrinking_diff` signal simply does not fire (it never reads a fabricated 0). The `on-session-end.mjs` final-wave `.completed` carries NEITHER key by design — no wave-open transition runs there, so it has no start sha to measure against |
107
177
  | `orchestrator.wave.final_refused` | `hooks/on-session-end.mjs` (`emitFinalWaveCompleted` → `emitFinalRefused`, via `emitEvent(..., {repoRoot})` + `sessionAttribution(repoRoot)`, wrapped in its OWN try/catch — independent of the caller's outer catch — so a telemetry failure on one refusal can never surface as a teardown failure) | the SIBLING event to `orchestrator.wave.completed` above (#1201 Part B / Discovery D8) — fires exactly once per SessionEnd in which the final `.completed` was NOT emitted, one row per refusal. Deliberately a SEPARATE event name rather than `.completed` carrying `emitted:false`: existing consumers of `.completed` treat every row as a finished wave, and overloading it would silently corrupt that count. **Payload:** `reason` (always present, closed enum — verified against `emitFinalWaveCompleted`'s own call sites) `not-recorded` \| `clear` \| `resume` \| `unreadable` \| `session-id-mismatch` \| `no-wave` \| `already-completed` \| `exception`, `emitted_by: 'on-session-end'` (always), plus `session_id` / `semantic_session_id` — omitted when `null`, never fabricated. **Optional, absent-is-not-zero:** `wave_number` — present ONLY on the `already-completed` reason, the sole call site that passes a resolved `last_wave` through to `emitFinalRefused`; every other reason, INCLUDING `no-wave`, omits it (that path never resolved a wave number at all — the function's own JSDoc pins this: "only when `last_wave` was resolved to a positive number before the refusal (currently only `already-completed`)"). Six of the eight reasons were previously SILENT refusal paths with no trace anywhere (`.claude/rules/host-resources.md` § HR-105: "a refusal that writes nothing is unfalsifiable") |
108
- | `orchestrator.quality_gate.passed` / `.failed` | `scripts/run-quality-gate.mjs` (the gate CLI — live between waves) · `scripts/lib/quality-gate.mjs` (`emitGateEvent` inside `runQualityGateWithRetry`, reached ONLY under `verification-auto-fix.enabled: true` — default `false`, and `false` in this repo) | CLI: once per gate-CLI run. Library: once per `runQualityGateWithRetry` **call**, never per retry attempt (`attempts` carries that detail). The two paths never nest, so one run passes through exactly one emitter. **Name (CLI, #1459, 2026-10-01):** `.failed` when `exit_code` ≠ 0 OR the joined kill fields say `timed_out: true`, in every variant. The reporting variants (`baseline` \| `incremental` \| `per-file`) exit 0 even after a failing check, so for them `.passed` means "not killed", not "checks green" — the check verdict lives in the gate output, not in the event name. **Payload (both):** `variant`, `exit_code`, plus `session_id` / `semantic_session_id` when `sessionAttribution()` finds a session lock (both omitted when it does not). CLI `variant` is the `--variant` value (`baseline`\|`incremental`\|`full-gate`\|`per-file`); the library pins `variant: 'auto-fix-loop'`. **CLI only:** `wave_number`, and `failed_files` (string[] — the failing test FILES `gate-full.mjs` names when the runner printed a file-level summary; **omitted, never `[]`**, when nothing named a file — an empty array would read as "no file failed"). **Library only:** `attempts` (1…`maxRetries+1`) and `gate` (`lint`\|`typecheck`\|`test` — the fail-fast gate of the last attempt; omitted on the passing path). **Kill-ladder fields (#1439, #1457, 2026-09-25):** `timed_out` (boolean — the run hit its wall-clock ceiling and was killed), `survivors` (integer — pids still alive after the SIGKILL step, `0` when none; the pids themselves stay in the gate output / diagnostics bundle) and `kill_signals` (string[] — the signals actually sent, in order, e.g. `["SIGTERM","SIGKILL"]`; `[]` when none). Both emitters start from the ONE shared `gateKillFields()` (`scripts/lib/quality-gate.mjs`); from the library emitter `false` / `0` / `[]` are therefore measurements ("not killed"), not absence — the CLI emitter additionally passes them through `joinGateKillFields()` and may omit the keys (see below, #1457). The library always carries all three. The CLI JOINS two ladders with `joinGateKillFields()` (`scripts/lib/gates/gate-helpers.mjs`): the OUTER one over the gate sub-script as a whole (that run exits 124), and the INNER per-command ones the gate's JSON envelope publishes — every variant (`gate-full.mjs` on `typecheck`/`test`/`lint`, `gate-{baseline,incremental,per-file}.mjs` on `typecheck`/`test` since #1457 pkt 12) puts `timed_out` / `kill_signals` / `survivors` (pids) on each command object it actually spawned, so a single command killed by the per-command ceiling (exit 2) now reads `timed_out: true`. Join: `timed_out` = outer OR any inner, `kill_signals` = outer followed by each command's in execution order, `survivors` = outer count plus every inner pid count. **The CLI omits all three keys** when the inner side was not measured — an envelope without the per-command ladder (all four variants publish it since #1457 pkt 12, so today this means an older gate script) or an unparseable envelope — and the outer ladder did not fire; a fired outer ladder is reported alone. The library reports the failing gate command of the last attempt, and `false` / `0` / `[]` on the passing path (every gate exited 0, so none was killed). **Optional on both:** `counts: {passed, failed, total}`. **Absent is not zero — for both optional fields.** `counts` is admitted by the ONE shared policy `admitSuiteCounts()` (`scripts/lib/gates/gate-helpers.mjs`, #967 item 2), which returns `null` — never a zero triple — for an unmeasured or inconsistent input (test gate skipped/stubbed, fail-fast on lint or typecheck before the test step, no parseable `<N> passed` marker, or `passed + failed !== total`); both callers spread `...(counts ? { counts } : {})`, so the KEY is missing in those runs. A present `counts.failed: 0` therefore means "measured, zero failures", while an absent `counts` means "not measured" — reading a missing field as `0` mis-analyses the ledger in both directions. Same contract for `wave_number` (CLI, #966 step 1): resolved from the `.{pi,cursor,codex,claude}/wave-scope.json` sidecar, **omitted** — never `0` — when there is no sidecar or its `wave` is non-numeric/non-positive; a human running `npm run quality-gate` from a `git push` has no wave at all, so an invented wave 0 would have to be special-cased by every consumer. Note `total` is `passed + failed` and EXCLUDES skipped/todo (see `extractTestCounts`) |
178
+ | `orchestrator.quality_gate.passed` / `.failed` | `scripts/run-quality-gate.mjs` (the gate CLI — live between waves) · `scripts/lib/quality-gate.mjs` (`emitGateEvent` inside `runQualityGateWithRetry`, reached ONLY under `verification-auto-fix.enabled: true` — default `false`, and `false` in this repo) | CLI: once per gate-CLI run. Library: once per `runQualityGateWithRetry` **call**, never per retry attempt (`attempts` carries that detail). The two paths never nest, so one run passes through exactly one emitter. **Name (CLI, #1459, 2026-10-01):** `.failed` when `exit_code` ≠ 0 OR the joined kill fields say `timed_out: true`, in every variant. The reporting variants (`baseline` \| `incremental` \| `per-file`) exit 0 even after a failing check, so for them `.passed` means "not killed", not "checks green" — the check verdict lives in the gate output, not in the event name. **Payload (both):** `variant`, `exit_code`, plus `session_id` / `semantic_session_id` when `sessionAttribution()` finds a session lock (both omitted when it does not). CLI `variant` is the `--variant` value (`baseline`\|`incremental`\|`full-gate`\|`per-file`); the library pins `variant: 'auto-fix-loop'`. **Both emitters:** optional `wave_number` (positive safe integer from the running own-session manifest at the event root). **CLI only:** `failed_files` (string[] — the failing test FILES `gate-full.mjs` names when the runner printed a file-level summary; **omitted, never `[]`**, when nothing named a file — an empty array would read as "no file failed"). **Library only:** `attempts` (1…`maxRetries+1`) and `gate` (`lint`\|`typecheck`\|`test` — the fail-fast gate of the last attempt; omitted on the passing path). **Kill-ladder fields (#1439, #1457, 2026-09-25):** `timed_out` (boolean — the run hit its wall-clock ceiling and was killed), `survivors` (integer — pids still alive after the SIGKILL step, `0` when none; the pids themselves stay in the gate output / diagnostics bundle) and `kill_signals` (string[] — the signals actually sent, in order, e.g. `["SIGTERM","SIGKILL"]`; `[]` when none). Both emitters start from the ONE shared `gateKillFields()` (`scripts/lib/quality-gate.mjs`); from the library emitter `false` / `0` / `[]` are therefore measurements ("not killed"), not absence — the CLI emitter additionally passes them through `joinGateKillFields()` and may omit the keys (see below, #1457). The library always carries all three. The CLI JOINS two ladders with `joinGateKillFields()` (`scripts/lib/gates/gate-helpers.mjs`): the OUTER one over the gate sub-script as a whole (that run exits 124), and the INNER per-command ones the gate's JSON envelope publishes — every variant (`gate-full.mjs` on `typecheck`/`test`/`lint`, `gate-{baseline,incremental,per-file}.mjs` on `typecheck`/`test` since #1457 pkt 12) puts `timed_out` / `kill_signals` / `survivors` (pids) on each command object it actually spawned, so a single command killed by the per-command ceiling (exit 2) now reads `timed_out: true`. Join: `timed_out` = outer OR any inner, `kill_signals` = outer followed by each command's in execution order, `survivors` = outer count plus every inner pid count. **The CLI omits all three keys** when the inner side was not measured — an envelope without the per-command ladder (all four variants publish it since #1457 pkt 12, so today this means an older gate script) or an unparseable envelope — and the outer ladder did not fire; a fired outer ladder is reported alone. The library reports the failing gate command of the last attempt, and `false` / `0` / `[]` on the passing path (every gate exited 0, so none was killed). **Optional on both:** `counts: {passed, failed, total}`. **Absent is not zero — for both optional fields.** `counts` is admitted by the ONE shared policy `admitSuiteCounts()` (`scripts/lib/gates/gate-helpers.mjs`, #967 item 2), which returns `null` — never a zero triple — for an unmeasured or inconsistent input (test gate skipped/stubbed, fail-fast on lint or typecheck before the test step, no parseable `<N> passed` marker, or `passed + failed !== total`); both callers spread `...(counts ? { counts } : {})`, so the KEY is missing in those runs. A present `counts.failed: 0` therefore means "measured, zero failures", while an absent `counts` means "not measured" — reading a missing field as `0` mis-analyses the ledger in both directions. Same contract for `wave_number` (both emitters, #966 step 1): resolved from the `.{pi,cursor,codex,claude}/wave-scope.json` sidecar, **omitted** — never `0` — when there is no sidecar or its `wave` is not a positive safe integer; a human running `npm run quality-gate` from a `git push` has no wave at all, so an invented wave 0 would have to be special-cased by every consumer. Note `total` is `passed + failed` and EXCLUDES skipped/todo (see `extractTestCounts`) |
109
179
  | `orchestrator.scope.coordinator_carveout_allowed` | `hooks/enforce-scope.mjs` (coordinator carveout, #245 / #1361) | exactly once per ALLOWED write BY THE COORDINATOR into one of the harness-owned in-repo files the carveout covers — `.claude/STATE.md` and its `.codex/` / `.cursor/` / `.pi/` siblings, plus the exact relative path of the live `wave-scope.json` the hook itself just read. Since #1361 a payload carrying `agent_id` is a dispatched subagent: it gets no carveout and falls through to Gate 7, where a manifest that does not grant the path is a DENY (a subagent permitted to write `wave-scope.json` could rewrite its own file scope and disarm every later gate of the wave; STATE.md is coordinator-owned per `skills/_shared/state-ownership.md`). **Payload:** `hook`, `manifest` (the manifest path), `wave`, `file_path` (the project-RELATIVE, forward-slash-normalized path — the carveout set is in-repo by construction, so no host-local absolute path enters the ledger) and `discriminator` (`'coordinator' \| 'malformed' \| 'absent'` — same enum and same meanings as `orchestrator.scope.memory_dir_allowed`: `'coordinator'` = `agent_type` present without `agent_id`; `'malformed'` = an `agent_id` key present but unusable (number, object, array, blank string), a fail-open that must stay rare; `'absent'` = no `agent_id` key at all, the harness's documented main-thread shape. `'subagent'` never appears here BECAUSE a subagent gets no carveout and so emits no allow) — exactly the five keys the `emitEvent` call passes, with `{ repoRoot: projectRoot }` as options. **Fail-safe:** awaited BEFORE `emitAllow()` (which calls `process.exit()` and would discard a pending append) and wrapped in its own `try {} catch {}`, so a telemetry failure can never flip the decision. Before #1361 this branch was a bare `emitAllow()` with no log and no event, so whether it ever fired was unfalsifiable after the fact (HR-105) | <!-- path-check: example -->
110
180
  | `orchestrator.scope.foreign_session_ignored` | `hooks/enforce-scope.mjs` (Gate 3b, #1123) · `hooks/enforce-commands.mjs` · `hooks/post-bash-write-verify.mjs` (both Gate 3b, #1153 P1) | exactly once per gated tool call while a FOREIGN-session `wave-scope.json` is live: the manifest's `session_id`/`semantic_session_id` provably name another session (legacy `session`/`semantic_session` still read, #1153 P2), so the hook stands down instead of enforcing. Since #1504 point 6 only when EVERY existing candidate (`.pi` > `.cursor` > `.codex` > `.claude`) is foreign — a peer's higher-precedence manifest no longer hides this session's own one (`findOwnScopeFile()`, `scripts/lib/scope-gate.mjs`); `manifest` then names the first foreign candidate. **Payload:** `hook`, `manifest` (path), `manifest_session` (string[]), `own_session` (string[]), `wave`; additionally `file_path` from `enforce-scope` (PreToolUse Edit/Write) and `command_hash` from `enforce-commands` (PreToolUse Bash) — **sha256 auf 16 Hex gekuerzt, NIE das rohe Kommando.** Bis 2026-09-19 stand hier `command` im Klartext, und zwar als dokumentierter Vertrag, nicht als Versehen. Gemessen in EventDrop.at (#1140): **3.816 Zeilen** der GETRACKTEN `.orchestrator/metrics/events.jsonl` trugen ein rohes Kommando, darin **24 distinkte echte Produktions-Share-Codes** aus **17 fremden Kundenkonten** plus ein protokollierter `select access_pin_hash` — bei 23 dieser Events ist der Share-Code die VOLLSTAENDIGE Capability (`/event/<code>` oeffnet das Album ohne Anmeldung), und das Journal geht bei jedem Klon mit. Der Geschwisterhook `pre-bash-destructive-guard.mjs` fuehrte von Anfang an `command_hash` und sagt den Grund im eigenen Kopf; diese Zeile war die Ausnahme. Der Hash haelt das Ereignis zaehlbar und gruppierbar — genau die Eigenschaft, fuer die es laut `docs/scope-collision-guard.md` existiert; das Rohfeld hatte keinen Leser. Gepinnt in `tests/hooks/enforce-commands.test.mjs` in BEIDE Richtungen (Hash vorhanden UND `command` undefined), damit niemand das Rohfeld neben den Hash zuruecklegt. **Der zweite rohe Schreiber derselben Klasse — `hooks/pre-bash-staging-fence.mjs` (512 Zeichen rohes Kommando je Eintrag) — ist seit #1404 (2026-09-20) geschlossen:** der Fence speichert jetzt die Pfad-Operanden plus `command_hash`, nie den Kommandotext. Belegt mit einem Test-Secret im Kommando: `grep -c` auf die Fence-Datei findet es 0-mal, ebenso 0 Vorkommen eines `command`-Feldes. Der einzige Leser (`hooks/wave-scope-commit-guard.mjs`) vergleicht seitdem Pfadlisten statt den Rohtext per Regex zu durchsuchen — was nebenbei `git add -A` abdeckt, das der Regex-Leser nie erkannte. **Die DRITTE und letzte Stelle derselben Klasse — `hooks/pre-bash-memory-propose-audit.mjs` (`argv_truncated`: 512 Zeichen nur flag-redigiertes Kommando in `orchestrator.memory.propose_invoked`, also in die GETRACKTE `events.jsonl` und optional an den Webhook) — ist seit #1415 (2026-09-21) geschlossen, analog `8f15f77b` und #1404:** der Hook schreibt jetzt `command_hash` + `flags_present` + `argv_length`, nie den Kommandotext. Gemessen vor dem Fix: 0 Produktionsleser des Rohfelds. Damit ist die Klasse zu — kein Hook dieses Repos legt mehr rohen Kommandotext in ein Event oder eine Kontrolldatei. `post-bash-write-verify` (PostToolUse Bash) carries neither — it reports on the working tree, not on one tool input. Legacy manifests without a session-binding field never emit this — they stay enforced |
111
181
  | `orchestrator.scope.memory_dir_allowed` | `hooks/enforce-scope.mjs` (Gate 5c, #1295) | exactly once per ALLOWED write BY THE COORDINATOR (#1352 — a payload carrying `agent_id` is a dispatched subagent, gets no carve-out and falls through to the normal gates) into THIS repo's harness auto-memory directory `~/.claude/projects/<encodeProjectDir(repoRoot)>/memory/` — the single out-of-repo carveout, evaluated only on the out-of-root branch (Gate 6) and only when a `wave-scope.json` manifest is live, so the in-repo gates are untouched. **Payload:** `hook`, `manifest` (the manifest path), `wave`, `file_path` (the REALPATH-resolved candidate) and, since #1352, `discriminator` (`'coordinator' \| 'malformed' \| 'absent'` — `'coordinator'` = `agent_type` present without `agent_id`; `'malformed'` = an `agent_id` key present but unusable (number, object, array, blank string), a fail-open that must stay rare; `'absent'` = no `agent_id` key at all, the harness's documented main-thread shape. `'subagent'` never appears here BECAUSE a subagent gets no carve-out and so emits no allow) — exactly the five keys the `emitEvent` call passes, with `{ repoRoot: projectRoot }` as options. **Fail-safe:** awaited BEFORE `emitAllow()` (which calls `process.exit()` and would discard a pending append) and wrapped in its own `try {} catch {}`, so a telemetry failure can never flip the decision. This row is the audit trail for the carveout: it is the only place an out-of-repo ALLOW becomes measurable after the fact |
@@ -126,7 +126,7 @@ hardware-sharing: { enabled: true }
126
126
 
127
127
  # ACCEPTED — all four required sections present and valid
128
128
  owner:
129
- name: Bernhard
129
+ name: Example Owner
130
130
  language: de
131
131
  tone:
132
132
  style: direct
@@ -136,3 +136,20 @@ efficiency:
136
136
  hardware-sharing:
137
137
  enabled: false
138
138
  ```
139
+
140
+ ## Owner privacy pattern source
141
+
142
+ Host-local owner leakage patterns use a separate protected version 1 JSON file, not an owner.yaml section. See [Owner privacy patterns](owner-privacy-patterns.md) for source precedence, schema, migration and strict packed-release checks. `paths.vault-dir` remains the canonical vault target used by the vault migration and consolidation tools; `SO_VAULT_DIR` overrides it.
143
+
144
+ ### Host-local drift path exceptions (#937)
145
+
146
+ Optional `drift-check.foreign-host-prefixes` is an array of absolute directory
147
+ prefixes belonging to known foreign hosts, for example
148
+ `/Users/remote-user/Projects/foreign-layout`. Missing `/Users/…` references
149
+ under a declared prefix become warnings; other missing paths remain errors.
150
+ Matching respects directory boundaries and accepts a trailing slash. Prefixes
151
+ must contain at least three components (four under `/Users` or `/home`); roots,
152
+ home directories, broad Projects roots, traversal, globs and malformed entries
153
+ are rejected. One invalid entry drops the entire optional `drift-check` section
154
+ while preserving other valid owner settings. This section does not change
155
+ `drift-check.mode` or its precedence. No default allowlist is supplied.
@@ -0,0 +1,85 @@
1
+ # Host-local owner privacy patterns
2
+
3
+ The owner-leakage scanner keeps host-specific usernames, endpoints, project names and allowed public contacts in a protected file outside every repository. The package ships generic rules and invented test fixtures. Public author and copyright attribution remain public; operational paths and private endpoints belong in local configuration.
4
+
5
+ ## Configure a host
6
+
7
+ Create `owner-patterns.json` using your own private values. Do not commit it, copy it into a release tree, or print it into CI logs. Resolution follows this order:
8
+
9
+ 1. `SO_OWNER_PATTERNS_FILE`: an explicit absolute filename.
10
+ 2. `SO_CONFIG_HOME/owner-patterns.json`.
11
+ 3. `XDG_CONFIG_HOME/session-orchestrator/owner-patterns.json`.
12
+ 4. `~/.config/session-orchestrator/owner-patterns.json`.
13
+
14
+ Empty or whitespace-only environment values are unset. An explicit relative filename is unsafe. The file must be regular, owned by the current user, have exactly mode `0600` and a single hard link, and be outside any Git repository, including ignored directories. Its parent and user-owned ancestors must not be group/world writable. User-controlled symlink ancestors and a symlink file are rejected; root-owned operating-system aliases such as macOS `/tmp` are allowed above the protected parent.
15
+
16
+ For a new directory, use a private umask and create the file with mode `0600`. Choose the directory according to the resolution above; this example uses the default:
17
+
18
+ ```sh
19
+ umask 077
20
+ mkdir -p "$HOME/.config/session-orchestrator"
21
+ # Create owner-patterns.json in that directory with a local editor.
22
+ chmod 600 "$HOME/.config/session-orchestrator/owner-patterns.json"
23
+ ```
24
+
25
+ Existing directory permissions may remain `0755` if they meet the ownership/writability requirements. The file is limited to 64 KiB. Use a local protected file on each host that needs these checks; it is separate from [owner.yaml](owner-config-schema.md).
26
+
27
+ ## Version 1 schema
28
+
29
+ The object has `version: 1` and these optional arrays of literal strings. Omitted arrays become empty; explicit `null`, unknown fields and invalid types fail validation. Strings are escaped as literals rather than interpreted as regular expressions. Each array permits at most 256 strings, each at most 256 UTF-16 code units, without leading/trailing whitespace or ASCII control characters below U+0020. At least one blocking array must be nonempty.
30
+
31
+ | Field | Purpose |
32
+ |---|---|
33
+ | `usernamePrefixes` | Personal home-path and bare username prefixes |
34
+ | `privateHosts` | Private service hosts |
35
+ | `eventsHosts` | Private events service hosts |
36
+ | `privateDomains` | Private domain catch-all |
37
+ | `packageScopes` | Private package scope names, **without** the leading `@` |
38
+ | `privateSlugs` | Private project/repository slugs |
39
+ | `vaultClearSlugs` | Members of `privateSlugs` allowed by the synchronous vault namespace consumer; case-insensitive subset |
40
+ | `personalNames` | Personal segments in home-relative `Projects` paths |
41
+ | `publicEmails` | Exact published email contacts permitted by occurrence |
42
+ | `publicUrls` | Exact public publication URLs permitted in designated attribution files |
43
+
44
+ This invented example demonstrates the schema; replace it locally rather than treating these values as protection for your host:
45
+
46
+ ```json
47
+ {
48
+ "version": 1,
49
+ "usernamePrefixes": ["sampleg"],
50
+ "privateHosts": ["gitlab.example.invalid"],
51
+ "eventsHosts": ["events.example.invalid"],
52
+ "privateDomains": ["example.invalid"],
53
+ "packageScopes": ["example"],
54
+ "privateSlugs": ["project-secret", "project-public"],
55
+ "vaultClearSlugs": ["project-public"],
56
+ "personalNames": ["SampleOwner"],
57
+ "publicEmails": ["office@example.invalid"],
58
+ "publicUrls": ["https://www.example.invalid", "www.example.invalid"]
59
+ }
60
+ ```
61
+
62
+ Public contacts exempt only matching occurrences. A line that combines a published contact with a private host still fails. Public URL exceptions apply to designated publication files such as the root README, plugin manifests and site attribution pages. They do not grant every file permission to contain a private domain. Clearance affects the synchronous vault namespace consumer; the full scanner still checks private slugs.
63
+
64
+ ## Scans and releases
65
+
66
+ An ordinary scan with no host file visibly warns that host-specific rules are unavailable. Generic rules and independently configured confidential-name checks continue. An unsafe or malformed source fails the scan. The synchronous namespace guard stays synchronous and conservatively redacts identities for an invalid/unsafe policy or an explicitly configured missing source.
67
+
68
+ Release checks require the source with `--require-owner-patterns`. Packed scans also read the actual contents listed in npm's JSON inventory:
69
+
70
+ ```sh
71
+ node scripts/lib/validate/check-owner-leakage.mjs /absolute/extracted/package \
72
+ --require-owner-patterns --packed-files /absolute/inventory.json
73
+ ```
74
+
75
+ Use an inventory describing the actual extracted tarball and scan that extraction, not just a list of filenames. Packed mode checks all listed regular files and rejects unsafe paths, symlinks and unreadable entries. Ordinary test/file exclusions and CP8/CP10 filename exceptions do not exempt packed contents. Violation output keeps checkpoint IDs, counts and safe file/line locations, omits offending content, and replaces private filename segments; it does not publish configured policy values. This detects the configured patterns, not every possible unknown secret.
76
+
77
+ Confidential customer-name checking (CP11) remains separately configured through `SO_CONFIDENTIAL_NAMES_FILE` or `owner.yaml` `paths.confidential-names-file`. A configured unusable CP11 source fails closed; absence of that optional source does not disable the other rules.
78
+
79
+ ## Migration and configured vault paths
80
+
81
+ Hosts upgrading from the previous embedded pattern lists must privately migrate the values they relied on to this version 1 file before strict release checks. Preserve blocking entries, narrowly justified public contacts/URLs and vault clearance; verify mode and a strict scan without printing the source. Do not substitute invented fixture values or remove protection to pass a release. New hosts opt in by creating their own protected policy.
82
+
83
+ Vault tools resolve the canonical vault from `SO_VAULT_DIR`, then `owner.yaml` `paths.vault-dir`. `vault-consolidate.mjs --canonical` overrides both; it retains the generic source default `~/Projects/vault`, and a source equal to the canonical real path is a no-op before backups or writes. Existing sources require a configured canonical destination. A missing source retains its harmless no-op behavior.
84
+
85
+ `migrate-vault-paths.mjs` replaces narrowly matched legacy `vault-dir` bases with the whole configured target, preserving suffixes, scalar quoting and comments. It skips the vault-target migration when unconfigured or invalid, without inserting a guessed personal segment; its separately configured username rewrite still applies. Historical and username-owned lines retain their existing protections. Preview either tool before applying it.
@@ -74,7 +74,17 @@ The event is what keeps the skip countable rather than silent: `orchestrator.sco
74
74
 
75
75
  The grant is exactly one directory: containment is exact-prefix on the realpath-resolved candidate (both the raw and the canonical project root are encoded, since Claude Code names the directory after the path it was launched with), so a SIBLING repo's memory dir, an adjacent-prefix sibling directory (`<memory>-evil/`), a `..` traversal out of it, and every other out-of-repo path stay denied. One honest caveat: the harness name is not unique — `encodeProjectDir()` maps both `/` and `.` to `-`, so two repo roots differing only in `.` vs `-` (`/x/a.b` and `/x/a-b`) share ONE memory dir. That collision is the harness's, inherited here and not narrowable by this gate — and in-repo enforcement, including Discovery's `allowedPaths: []` deny-all, is untouched. The encoder is bound lazily and a load failure falls through to the deny (fail-closed). **Who the grant is for (#1352): the COORDINATOR only.** The carve-out opens a directory whose content is auto-injected as trusted project context into every later session, outside `git diff`, CI review, owner-leakage and gitleaks — so a dispatched wave agent must not reach it. The discriminator is the payload's **`agent_id`**, and it is measured, not assumed (Claude Code 2.1.270, `strings` over the shipped binary, 2026-09-13): the base hook-input builder every event shares is `{session_id, transcript_path: yf(session.id), cwd, …, agent_id: toolUseContext?.agentId, agent_type}`, which PreToolUse spreads verbatim, and the harness's own schema says of `agent_id` — *"Present only when the hook fires from within a subagent… Absent for the main thread, even in `--agent` sessions. Use this field (not `agent_type`) to distinguish subagent calls from main-thread calls."* The issue's original candidate is refuted by the same source: `transcript_path` is derived from the SESSION id, so coordinator and subagent carry the identical value; so is `CLAUDE_CODE_SESSION_ID`, and `CLAUDE_CODE_CHILD_SESSION=1` was measured in a main-thread `env` dump (`isSidechain:false`).
76
76
 
77
- `classifyCaller()` therefore returns `subagent` (non-empty `agent_id` → **no carve-out**; the write falls through to the normal gates, which is a DENY under Discovery's `allowedPaths: []`), `coordinator` (no `agent_id` but a non-empty `agent_type` — per the schema that combination is the main thread of an `--agent` session), or `absent` (neither field). **`absent` keeps the pre-#1352 ALLOW, deliberately fail-OPEN:** fail-closed on ambiguity would deny the coordinator's own memory writes and break `/close`, and a harness that sends no marker is indistinguishable from a main thread. The outcome rides on the existing `orchestrator.scope.memory_dir_allowed` event as `discriminator`, so the fail-open case is countable rather than silent (HR-105) — revisit if `absent` ever dominates the records on a harness that does dispatch subagents.
77
+ `classifyCaller()` therefore returns `subagent` (non-empty `agent_id` → **no carve-out**; the write falls through to the normal gates, which is a DENY under Discovery's `allowedPaths: []`), `coordinator` (no `agent_id` but a non-empty `agent_type` — per the schema that combination is the main thread of an `--agent` session), or `absent` (neither field). **For a caller outside a verified isolated agent checkout, `absent` keeps the pre-#1352 ALLOW, deliberately fail-OPEN:** fail-closed on ambiguity would deny the coordinator's own memory writes and break `/close`, and a harness that sends no marker is indistinguishable from a main thread. The outcome rides on the existing `orchestrator.scope.memory_dir_allowed` event as `discriminator`, so the fail-open case is countable rather than silent (HR-105) — revisit if `absent` ever dominates the records on a harness that does dispatch subagents.
78
+
79
+ ### 2.5 Isolated agent worktrees (#1504 items 1–2)
80
+
81
+ `resolveScopeContext()` separates the coordinator's **manifest root** from the agent's **write root**. `enforce-scope`, `enforce-commands` and `post-bash-write-verify` use this context; `resolveSessionRoot()` keeps its existing lifecycle behavior. A relative grant such as `src/**` applies to the actual agent checkout. Writing the coordinator's `src/` requires an explicit absolute grant, and isolated agents receive neither coordinator control-file nor memory-directory carveouts, even when their hook payload omits `agent_id`.
82
+
83
+ Discovery requires the Claude harness agent-directory shape and reciprocal Git worktree metadata belonging to the launch checkout's common Git directory. A local own, unbound or corrupt manifest retains precedence. When no local authority exists, the resolver inspects only registered worktree roots, at most 256 entries, and adopts a uniquely owned manifest using process-local session identity. Multiple owned coordinators, unreadable discovery or an unsafe remote manifest deny instead of becoming unrestricted absence. Unknown or foreign remote manifests cannot supply authority. Ordinary checkout and no-match behavior remain unchanged; arbitrary subdirectories of registered worktrees are not searched.
84
+
85
+ The post-Bash hook reads control state from the coordinator but observes Git changes in the agent checkout. Its saved observations are keyed by both roots and the manifest path, preventing one agent's snapshot from hiding another's changes.
86
+
87
+ These guarantees require the named hooks to run. Native Codex `exec_command` and `apply_patch` are not wired to these Claude `PreToolUse` matchers. Codex sessions still require explicit scope manifests, disjoint assignments and diff verification; this change does not claim native pre-tool enforcement. The environment-only commit guard and dispatch-ledger retention are separate mechanisms.
78
88
 
79
89
  ## 3. The collision algorithm
80
90
 
@@ -86,7 +96,7 @@ The grant is exactly one directory: containment is exact-prefix on the realpath-
86
96
  - **3a — witness:** expand both entries against `KNOWN = knownFiles ∪ {every concrete entry of every agent}`; a non-empty intersection is `glob-expanded`. The second half of that union matters — a file the wave is about to *create* is not in `git ls-files`, but a concrete claim by one agent can still witness another's glob.
87
97
  - **3b — prefix fallback:** for the intersection that exists only in files not yet on disk. Requires literal-prefix containment in either direction, at least one **recursive** entry (`**`, or a trailing `/`, which `pathMatchesPattern` matches by `startsWith` at any depth), and compatible literal suffixes. The suffix filter is a necessary condition, so it adds no false negative while removing `scripts/**/*.ts` vs `scripts/**/*.mjs`.
88
98
 
89
- `knownFiles` is **injected, never discovered**: `scope-gate.mjs` is hook-safe (pure, sync, no I/O, no spawn) because `enforce-scope.mjs` reaches it on a hot path, and under the exit-0/stdout-JSON protocol a throw there reads as "no decision" = ALLOW. The CLI spawns `git ls-files` in `knownRepoFiles()`; the hook does the same in `listTrackedFiles()`, both resolving `git rev-parse --show-toplevel` first so a session started in a subdirectory produces repo-relative paths on both sides.
99
+ `knownFiles` is **injected, never discovered** by the collision helpers: they remain pure and synchronous, with no I/O or spawning. The separate scope-context resolver in the same module performs bounded synchronous filesystem reads (§ 2.5). Under the exit-0/stdout-JSON protocol, an uncaught throw in a hook reads as "no decision" = ALLOW. The CLI spawns `git ls-files` in `knownRepoFiles()`; the hook does the same in `listTrackedFiles()`, both resolving `git rev-parse --show-toplevel` first so a session started in a subdirectory produces repo-relative paths on both sides.
90
100
 
91
101
  Duplicate agent ids are reported separately (`duplicateIds`), not as a self-collision. A record with no usable id runs as `<unnamed#i>` rather than being dropped — an unreviewed scope is exactly the one that collides.
92
102
 
@@ -95,10 +95,9 @@ 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 resolveSessionRoot;
99
98
  let readJson;
100
99
  // #1504 point 6 — bound from scope-gate below.
101
- let findOwnScopeFile;
100
+ let resolveScopeContext;
102
101
  let extractBashWriteTargets;
103
102
  let pathMatchesPattern;
104
103
  /** @type {typeof import('../scripts/lib/session-identity/own-session.mjs').readProcessLocalSessionIds} */
@@ -208,10 +207,9 @@ async function bootstrap() {
208
207
  );
209
208
 
210
209
  ({ readStdin, emitAllow, emitDeny, emitWarn } = modules.io);
211
- ({ resolveSessionRoot } = modules.platform);
212
210
  ({ readJson } = modules.common);
213
211
  ({ extractBashWriteTargets, pathMatchesPattern } = modules.hardening);
214
- ({ findOwnScopeFile } = modules.scopeGate);
212
+ ({ resolveScopeContext } = modules.scopeGate);
215
213
  ({ readProcessLocalSessionIds, classifyManifestSession } = modules.sessionIdentity);
216
214
  blocker = modules.blocker;
217
215
  degradedLabels = degraded;
@@ -281,21 +279,13 @@ async function main() {
281
279
  );
282
280
  }
283
281
 
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.
282
+ // Coordinator control state and isolated-agent write targets may live in
283
+ // different working copies; the shared guard context resolves both.
292
284
  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);
285
+ const context = resolveScopeContext({ cwd: input.cwd, ownIds, classify: classifyManifestSession });
286
+ if (context.error) return emitDeny('Scope coordinator cannot be resolved', context.error);
287
+ const projectRoot = context.writeRoot;
288
+ const located = context;
299
289
  const scopePath = located.path ?? located.foreignPath;
300
290
  if (!scopePath) return flushNotices(notices);
301
291
 
@@ -350,7 +340,7 @@ async function main() {
350
340
  wave: scope.wave,
351
341
  command_hash: hashCommand(command),
352
342
  },
353
- { repoRoot: projectRoot },
343
+ { repoRoot: context.manifestRoot },
354
344
  );
355
345
  } catch { /* observability is best-effort — never blocks the decision */ }
356
346
  return flushNotices(notices);