session-orchestrator 5.9.0 → 5.11.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 (78) 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/AGENTS.md +2 -0
  6. package/CHANGELOG.md +65 -2
  7. package/README.md +11 -9
  8. package/SECURITY.md +1 -1
  9. package/agents/code-implementer.md +7 -1
  10. package/docs/USER-GUIDE.md +2 -0
  11. package/docs/agent-authoring.md +2 -0
  12. package/docs/ci-setup.md +46 -1
  13. package/docs/events-schema.md +37 -6
  14. package/docs/fleet-checkin-audit.md +172 -0
  15. package/docs/owner-config-schema.md +18 -1
  16. package/docs/owner-privacy-patterns.md +85 -0
  17. package/docs/role-profile-contract.md +139 -0
  18. package/docs/scope-collision-guard.md +15 -2
  19. package/docs/session-config-reference.md +1 -1
  20. package/evals/instruction-ablation/run.mjs +11 -6
  21. package/hooks/_lib/hook-import-set.json +27 -2
  22. package/hooks/_lib/orchestrator-ignore.mjs +112 -0
  23. package/hooks/enforce-commands.mjs +9 -19
  24. package/hooks/enforce-scope.mjs +23 -42
  25. package/hooks/hooks-codex.json +1 -1
  26. package/hooks/hooks.json +1 -1
  27. package/hooks/on-session-end.mjs +8 -1
  28. package/hooks/on-session-start.mjs +7 -0
  29. package/hooks/post-bash-write-verify.mjs +54 -24
  30. package/hooks/pre-task-scope-disjoint.mjs +12 -0
  31. package/hooks/subagent-telemetry.mjs +8 -3
  32. package/hooks/wave-scope-commit-guard.mjs +4 -1
  33. package/package.json +2 -2
  34. package/rules/opt-in-stack/security-web.md +1 -1
  35. package/scripts/emit-session.mjs +28 -0
  36. package/scripts/fleet-checkin-audit.mjs +138 -0
  37. package/scripts/lib/common.mjs +2 -4
  38. package/scripts/lib/context-overhead-format.mjs +57 -0
  39. package/scripts/lib/fleet-checkin-audit.mjs +244 -0
  40. package/scripts/lib/gates/gate-full.mjs +6 -0
  41. package/scripts/lib/gates/gate-helpers.mjs +2 -0
  42. package/scripts/lib/memory-proposals/sink.mjs +79 -17
  43. package/scripts/lib/memory-proposals/store.mjs +245 -9
  44. package/scripts/lib/named-vault-resolver.mjs +1 -1
  45. package/scripts/lib/owner-yaml.mjs +33 -3
  46. package/scripts/lib/platform.mjs +70 -1
  47. package/scripts/lib/pre-push-remote-gate.mjs +107 -0
  48. package/scripts/lib/project-hygiene.mjs +37 -0
  49. package/scripts/lib/quality-gate.mjs +6 -1
  50. package/scripts/lib/redact-spans.mjs +8 -17
  51. package/scripts/lib/role-profile/schema.mjs +219 -0
  52. package/scripts/lib/scope-gate.mjs +68 -5
  53. package/scripts/lib/session-lock.mjs +57 -10
  54. package/scripts/lib/session-record-repair.mjs +63 -30
  55. package/scripts/lib/session-schema/constants.mjs +8 -0
  56. package/scripts/lib/session-schema/validator.mjs +33 -0
  57. package/scripts/lib/subagents-schema.mjs +9 -0
  58. package/scripts/lib/telemetry/pricing.mjs +11 -0
  59. package/scripts/lib/validate/check-owner-leakage.mjs +198 -1015
  60. package/scripts/lib/vault-backfill/template.mjs +1 -1
  61. package/scripts/lib/vault-mirror/render-sessions.mjs +15 -3
  62. package/scripts/lib/vault-status/board-writer.mjs +15 -7
  63. package/scripts/measure-context-overhead.sh +4 -21
  64. package/scripts/migrate-vault-paths.mjs +57 -60
  65. package/scripts/release.mjs +463 -48
  66. package/scripts/run-quality-gate.mjs +47 -0
  67. package/scripts/validate-wave-scope.mjs +59 -6
  68. package/scripts/vault-consolidate.mjs +19 -4
  69. package/skills/claude-md-drift-check/SKILL.md +1 -1
  70. package/skills/claude-md-drift-check/checker.mjs +22 -9
  71. package/skills/npm-publish/SKILL.md +3 -3
  72. package/skills/release/SKILL.md +2 -2
  73. package/skills/repo-audit/SKILL.md +1 -1
  74. package/skills/session-end/metrics-collection.md +28 -4
  75. package/skills/session-end/session-metrics-write.md +1 -1
  76. package/skills/session-start/references/phase-1-2-session-lock.md +1 -1
  77. package/skills/wave-executor/circuit-breaker.md +1 -0
  78. package/skills/wave-executor/references/wave-loop-review.md +26 -2
@@ -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.
@@ -0,0 +1,139 @@
1
+ # Role-profile evidence contract v1
2
+
3
+ Refs #1469. This additive offline contract records role-selection evidence; it does not
4
+ select a model, dispatch an agent, load a registry, start an adapter, or change a pin.
5
+ The test-profile registry under `scripts/lib/profiles/` is a separate contract.
6
+
7
+ ## API and meaning
8
+
9
+ `scripts/lib/role-profile/schema.mjs` exports `ROLE_PROFILE_CONTRACT_VERSION` (1)
10
+ and `roleProfileEvidenceSchema.safeParse(value)`:
11
+
12
+ - Success: `{ success: true, data, diagnostics }`. `data` is the original input,
13
+ unchanged, without defaults, normalization or alias resolution.
14
+ - Failure: `{ success: false, error: { issues } }`. Each issue has a fixed `code`,
15
+ field-addressing `path` array and `message`. Messages never interpolate raw input.
16
+ - Codes: `INVALID_TYPE`, `MISSING_FIELD`, `UNKNOWN_FIELD`, `INVALID_VALUE`,
17
+ `INVALID_RELATION`, `UNSUPPORTED_VERSION`. Diagnostics can contain `UNSUPPORTED_EFFORT`.
18
+
19
+ Success means internal structural consistency only. A source reference is a claim
20
+ by the recorder: the validator does not read it, authenticate it, check revocation,
21
+ verify permissions, or confirm that the harness actually used the recorded model.
22
+ No result asserts host availability, account access, quota, cost, quality, or model
23
+ acceptance. An `authorized` record is not an execution authorization issued by this API.
24
+ No I/O, environment lookup, current-clock read, provider call or input mutation occurs.
25
+
26
+ ## Fields
27
+
28
+ All fields below are required, including null-valued fields. Objects must have only
29
+ the specified own data properties; arrays, accessors and class instances are rejected.
30
+ Strings must contain a non-whitespace character but are preserved byte for byte.
31
+ IDs and version strings are opaque; unlisted model IDs are accepted without substitution.
32
+
33
+ | Field | Shape and relationship |
34
+ |---|---|
35
+ | `contractVersion` | Integer literal `1`; separate from harness/profile/registry versions |
36
+ | `role`, `taskClass` | Nonempty role name and task classification; no role registry lookup |
37
+ | `provider`, `transport` | Separate nonempty IDs; never inferred from a model name |
38
+ | `binding` | `{status, sourceRef, reason}`; known bindings in v1 are `openai`/`codex-cli` and `anthropic`/`claude-code` |
39
+ | `model` | `{requested, observed, resolution}`; details below |
40
+ | `effort` | `{requested, effective, support}`; details below |
41
+ | `harness` | `{name, version}`; name is a string, version is evidence with `value` |
42
+ | `profile` | `{id, version}`; id is a string, version is evidence with `value` |
43
+ | `registryVersion` | Evidence with `value`; records provenance without creating a registry |
44
+ | `selection` | `{mode, escalationReason}`; mode `profile`, `owner-override`, or `escalation` |
45
+ | `authorization` | `{status, authority, sourceRef, scope, reason}`; details below |
46
+ | `evidence` | Evidence with `timestamp`; known timestamps are real calendar dates in UTC, exactly `YYYY-MM-DDTHH:mm:ss.sssZ` |
47
+
48
+ An evidence object has `{status, <field>, sourceRef, reason}`. With `known`, the
49
+ value and source reference are nonempty strings and reason is null. With `unknown`,
50
+ value and source reference are null and reason is nonempty. Missing evidence is not
51
+ an unknown state. Binding uses the same source/reason convention without a value.
52
+ Unrecognized provider/transport pairs require binding `unknown`, retaining the raw IDs;
53
+ known contradictory pairs are rejected. Binding validity is not transport availability.
54
+
55
+ ## Model request, observation and resolution
56
+
57
+ `requested` is `{kind, value}`: `id` or `alias` requires the original string;
58
+ `inherit` requires null. `observed` is evidence with `id`, independently collected.
59
+ Unknown observation requires resolution `unknown`; no requested ID is copied into it.
60
+ A known observation may still have unknown resolution when the relationship is unproven.
61
+
62
+ `resolution` has `{status, baselineId, parent, sourceRef, reason}`:
63
+
64
+ | Status | Required relationship |
65
+ |---|---|
66
+ | `unknown` | baselineId, parent and sourceRef null; reason nonempty |
67
+ | `exact` | Requested kind id, requested value equals observed ID |
68
+ | `alias-resolved` | Requested kind alias, known observation; no drift assertion |
69
+ | `alias-drift` | Requested kind alias, known observation, nonempty baselineId different from observed ID; sourceRef must identify the resolution/baseline evidence |
70
+ | `parent-inherit` | Requested kind inherit; parent `{id, sourceRef}` names the observed ID with separate parent evidence |
71
+
72
+ All non-unknown resolutions require known observation, nonempty sourceRef and null
73
+ reason. baselineId is null outside alias-drift; parent is null outside parent-inherit.
74
+ A changed alias without a documented baseline is not proof of drift. Inheritance
75
+ without parent evidence remains unknown. The validator checks equality and structure,
76
+ not whether any source genuinely proves the relationship.
77
+
78
+ ## Effort and capability evidence
79
+
80
+ `requested` is `{provider, dialect, value}`; value is a nonempty string or null
81
+ (no explicit effort requested). `effective` is `{provider, dialect, status, value,
82
+ sourceRef, reason}` with the known/unknown evidence rules above. Both providers must
83
+ match the record provider. Recognized dialects are `openai-reasoning` for openai and
84
+ `anthropic-effort` for anthropic. Unrecognized dialects require both support and
85
+ effective evidence to be unknown, preserving requested values.
86
+
87
+ `support` is `{status, sourceRef, reason}` with `supported`, `unsupported`, or
88
+ `unknown`. The first two require a source and null reason; unknown requires null
89
+ source and a reason. This describes support for the requested effort, as asserted
90
+ by the source, not a live capability table maintained by this validator. Values
91
+ are never translated or ranked numerically across vendors. A familiar label such
92
+ as medium alone proves no support on a particular harness/version/model.
93
+
94
+ Unsupported requests emit `UNSUPPORTED_EFFORT`. An identical value cannot also be
95
+ claimed effective; explicitly unknown effective effort or a different documented
96
+ value can be recorded. Unknown support does not become supported merely because
97
+ a syntactically valid record contains a known effective value.
98
+
99
+ ## Escalation and owner authorization
100
+
101
+ Escalation requires a nonempty escalationReason; other selection modes require null.
102
+ Owner-override and escalation require authorization status `authorized`, authority
103
+ `owner`, nonempty sourceRef, null reason and explicit scope:
104
+
105
+ ```json
106
+ {
107
+ "role": "worker", "provider": "openai", "transport": "codex-cli",
108
+ "model": {"kind": "id", "value": "unlisted-model/2030"},
109
+ "effort": {"provider": "openai", "dialect": "openai-reasoning", "value": "medium"}
110
+ }
111
+ ```
112
+
113
+ Every scope value must match the record's role, provider, transport and original
114
+ model/effort requests. This is an internal coverage comparison, not external policy
115
+ verification. Profile selection can record `unknown` or `not-required`: authority,
116
+ sourceRef and scope are null and reason is nonempty. Neither status grants permission.
117
+ Any authorized record, including profile mode, must carry the full matching scope.
118
+ Historical narratives, cost labels or model preferences never supply missing evidence.
119
+ References must not include tokens, account names, mail addresses or other secrets.
120
+
121
+ ## Unknown example and versioning
122
+
123
+ The following fragments belong to an otherwise complete v1 record:
124
+
125
+ ```json
126
+ {
127
+ "requested": {"kind": "id", "value": "unlisted-model/2030"},
128
+ "observed": {"status": "unknown", "id": null, "sourceRef": null, "reason": "Not measured"},
129
+ "resolution": {"status": "unknown", "baselineId": null, "parent": null, "sourceRef": null, "reason": "Not resolved"}
130
+ }
131
+ ```
132
+
133
+ This can be contract-valid; it never proves successful resolution or model acceptance.
134
+ Inline executable complete records live in `tests/lib/role-profile/schema.test.mjs`.
135
+ Unknown contract versions are rejected before interpreting nested evidence. Unknown
136
+ field names are rejected; unknown opaque IDs are retained. New fields or changed
137
+ semantics require a new contract version, explicit handling and migration policy;
138
+ there is no automatic upgrade. This offline slice leaves runtime smokes, dispatch
139
+ integration, precedence measurement, billing, access and rollout acceptance open.
@@ -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
 
@@ -201,6 +211,7 @@ Complete list of what this guard does **not** see, or sees only approximately:
201
211
  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.
202
212
  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.
203
213
  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.
214
+ 12. **The commit guard's identity is whatever the committing shell exports (#1504 point 7b).** `wave-scope-commit-guard` runs inside `git commit` and gets no hook payload, so `readProcessLocalSessionIds()` reads the env only. `CLAUDE_CODE_SESSION_ID=<foreign id> git commit` therefore makes this session's own manifest read as `foreign` and the guard stands down. Named, not fixed (owner decision 2026-10-09): it is the same power `git commit --no-verify` already grants to the same shell, so reading identity from `session.lock` instead would add a failure mode (lock absent or reaped) without closing the bypass. The guard protects against accidents, not against intent. Revisit if `--no-verify` is ever blocked for coordinator sessions.
204
215
 
205
216
  ## 7. Debugging
206
217
 
@@ -218,6 +229,8 @@ node scripts/validate-wave-scope.mjs --assert-disjoint "$WAVE_SCOPES_SIDECAR" <
218
229
 
219
230
  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).
220
231
 
232
+ **`--assert-disjoint` reports a collision with an agent that has already finished (#1026 point 1).** Expected: the CLI checks the declared plan and has no notion of liveness. That keeps its verdict reproducible. The hook (§ 5) is the gate that knows a partner finished. When the coordinator (or a fix agent) takes over a finished agent's file, move the file from the finished agent's record to the new owner's record in the canonical `{id, files}` array, pipe that array into `materialize-wave-scope.mjs --state-dir <state-dir> --wave <N>` again (it rewrites both shapes), then re-assert. Do not mark records as done or bypass the check: an ownership change is a plan change, and the plan is what the CLI certifies (owner decision 2026-10-09).
233
+
221
234
  **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.
222
235
 
223
236
  **Disable.** The hook honours the repo's profile gate (`shouldRunHook('pre-task-scope-disjoint')`, row 1) — a silent exit 0, no decision at all.
@@ -915,7 +915,7 @@ node scripts/memory-propose.mjs \
915
915
 
916
916
  A successful dry-run exits `0` with stdout status `dry-run-ok` (see updated exit-codes table below) and never appends to `proposals.jsonl`.
917
917
 
918
- `--type` accepts one of the `PROPOSAL_TYPES` enum values (the agent-writable subset of the learnings schema): `mode-selector-accuracy`, `hardware-pattern`, `fragile-file`, `effective-sizing`, `recurring-issue`, `workflow-pattern`, `proven-pattern`, `anti-pattern`, `autopilot-effectiveness`, `domain-regression`, `convention`, `architecture-pattern`, `design-pattern`. Analyzer-only learning types such as `autonomy-verdict` are intentionally excluded because their evidence gates are enforced by `/evolve` analyzers, not by agent proposals. Strings with embedded quotes must be shell-escaped per usual conventions. The CLI appends one JSONL line to `.orchestrator/metrics/proposals.jsonl` (atomic via O_APPEND under the `.orchestrator/metrics/proposals-write.lock` mutex) and updates a per-wave summary at `.orchestrator/metrics/proposals-summary-<wave-id>.json` (counters: queued / dropped / below_floor / fs_error). The coordinator surfaces both files at session-end Phase 3.6.3 to render the AUQ multiSelect; approved entries promote into `.orchestrator/metrics/learnings.jsonl` with `_provenance: agent-proposed@<wave-id>`; rejected entries archive to `.orchestrator/proposals.rejected.log`. Privacy: `proposed_by_agent` is captured in the audit hook (`events.jsonl`) only and is stripped before promotion to learnings.jsonl.
918
+ `--type` accepts one of the `PROPOSAL_TYPES` enum values (the agent-writable subset of the learnings schema): `mode-selector-accuracy`, `hardware-pattern`, `fragile-file`, `effective-sizing`, `recurring-issue`, `workflow-pattern`, `proven-pattern`, `anti-pattern`, `autopilot-effectiveness`, `domain-regression`, `convention`, `architecture-pattern`, `design-pattern`. Analyzer-only learning types such as `autonomy-verdict` are intentionally excluded because their evidence gates are enforced by `/evolve` analyzers, not by agent proposals. Strings with embedded quotes must be shell-escaped per usual conventions. The CLI appends one JSONL line to `.orchestrator/metrics/proposals.jsonl` (atomic via O_APPEND under the `.orchestrator/metrics/proposals-write.lock` mutex) and updates a per-wave summary at `.orchestrator/metrics/proposals-summary-<wave-id>.json` (counters: queued / dropped / below_floor / fs_error). A proposal the quota rejects (`dropped`) is not discarded: its full record is appended to `.orchestrator/metrics/proposals-overflow.jsonl` under the same lock — never counted against the quota, not read by the collector, not cleared at wave end (#1027); if that append fails the CLI exits `4` instead of `1`. The coordinator surfaces both files at session-end Phase 3.6.3 to render the AUQ multiSelect; approved entries promote into `.orchestrator/metrics/learnings.jsonl` with `_provenance: agent-proposed@<wave-id>`; rejected entries archive to `.orchestrator/proposals.rejected.log`. Privacy: `proposed_by_agent` is captured in the audit hook (`events.jsonl`) only and is stripped before promotion to learnings.jsonl.
919
919
 
920
920
  ### Exit codes
921
921
 
@@ -243,7 +243,7 @@ function runCell(variant, kase, run) {
243
243
  if (DRY) return { variant: variant.id, case: kase.id, run, dry: true, workdir: wd };
244
244
 
245
245
  let usage = {};
246
- let cost = 0;
246
+ let cost = null;
247
247
  let text = '';
248
248
  let error = null;
249
249
  try {
@@ -255,7 +255,9 @@ function runCell(variant, kase, run) {
255
255
  });
256
256
  const j = JSON.parse(out);
257
257
  usage = j.usage || {};
258
- cost = j.total_cost_usd || 0;
258
+ // Missing or non-numeric cost stays unknown; a reported 0 stays 0 (#1547).
259
+ const c = j.total_cost_usd;
260
+ cost = typeof c === 'number' && Number.isFinite(c) && c >= 0 ? c : null;
259
261
  text = typeof j.result === 'string' ? j.result : '';
260
262
  } catch (e) {
261
263
  error = String(e.message || e).slice(0, 300);
@@ -275,7 +277,7 @@ function runCell(variant, kase, run) {
275
277
  error,
276
278
  context_tokens: ctx,
277
279
  output_tokens: usage.output_tokens || 0,
278
- cost_usd: Number(cost.toFixed(4)),
280
+ cost_usd: cost === null ? null : Number(cost.toFixed(4)),
279
281
  oracles,
280
282
  reply_excerpt: text.slice(0, 300),
281
283
  workdir: wd,
@@ -300,7 +302,7 @@ for (const kase of cases) {
300
302
  const rec = runCell(variant, kase, run);
301
303
  records.push(rec);
302
304
  if (!DRY) appendFileSync(outFile, JSON.stringify(rec) + '\n');
303
- process.stderr.write(rec.dry ? 'built\n' : `${rec.pass ? 'PASS' : 'FAIL'} (${rec.cost_usd ?? 0} USD)\n`);
305
+ process.stderr.write(rec.dry ? 'built\n' : `${rec.pass ? 'PASS' : 'FAIL'} (${rec.cost_usd ?? 'unknown'} USD)\n`);
304
306
  }
305
307
  }
306
308
  }
@@ -324,13 +326,16 @@ for (const kase of cases) {
324
326
  console.log(row.join('\t'));
325
327
  }
326
328
 
327
- const spend = records.reduce((a, r) => a + (r.cost_usd || 0), 0);
329
+ // One unknown cell makes the total unknown; a partial sum would read as complete.
330
+ const spend = records.some((r) => r.cost_usd === null)
331
+ ? 'unknown'
332
+ : records.reduce((a, r) => a + r.cost_usd, 0).toFixed(2);
328
333
  const ctxAvg = (id) => {
329
334
  const c = records.filter((r) => r.variant === id && r.context_tokens);
330
335
  return c.length ? Math.round(c.reduce((a, r) => a + r.context_tokens, 0) / c.length) : 0;
331
336
  };
332
337
  console.log('\n=== mean context tokens by variant ===');
333
338
  for (const v of variants) console.log(`${v.id}\t${ctxAvg(v.id)}`);
334
- console.log(`\ntotal spend: USD ${spend.toFixed(2)}`);
339
+ console.log(`\ntotal spend: USD ${spend}`);
335
340
  console.log(`results: ${outFile}`);
336
341
  console.log(`workdirs: ${ROOT} (throwaway; inspect failures there, remove when done)`);
@@ -1,6 +1,6 @@
1
1
  {
2
- "generated_at": "2026-10-05T10:45:41.038Z",
3
- "head": "94dc4a0cf182a06492feb217162d290e9fd0e921",
2
+ "generated_at": "2026-10-10T12:04:56.821Z",
3
+ "head": "f4f6200c067d28025bf70e34296cd5ebabcfc7f8",
4
4
  "entries": [
5
5
  {
6
6
  "file": "hooks/_lib/atomic-json.mjs",
@@ -34,6 +34,12 @@
34
34
  "on-session-end.mjs"
35
35
  ]
36
36
  },
37
+ {
38
+ "file": "hooks/_lib/orchestrator-ignore.mjs",
39
+ "reachable_from": [
40
+ "on-session-start.mjs"
41
+ ]
42
+ },
37
43
  {
38
44
  "file": "hooks/_lib/profile-gate.mjs",
39
45
  "reachable_from": [
@@ -1158,6 +1164,19 @@
1158
1164
  "post-edit-validate.mjs"
1159
1165
  ]
1160
1166
  },
1167
+ {
1168
+ "file": "scripts/lib/native-command.mjs",
1169
+ "reachable_from": [
1170
+ "on-session-end.mjs",
1171
+ "on-session-start.mjs",
1172
+ "on-stop.mjs",
1173
+ "post-bash-issue-budget-refund.mjs",
1174
+ "post-edit-validate.mjs",
1175
+ "post-subagent-discovery-validator.mjs",
1176
+ "pre-bash-issue-budget.mjs",
1177
+ "pre-bash-templates-first.mjs"
1178
+ ]
1179
+ },
1161
1180
  {
1162
1181
  "file": "scripts/lib/orphan-reaper/defaults.mjs",
1163
1182
  "reachable_from": [
@@ -1633,6 +1652,12 @@
1633
1652
  "skill-invocation-telemetry.mjs"
1634
1653
  ]
1635
1654
  },
1655
+ {
1656
+ "file": "scripts/lib/telemetry/pricing.mjs",
1657
+ "reachable_from": [
1658
+ "subagent-telemetry.mjs"
1659
+ ]
1660
+ },
1636
1661
  {
1637
1662
  "file": "scripts/lib/telemetry/queue.mjs",
1638
1663
  "reachable_from": [
@@ -0,0 +1,112 @@
1
+ /**
2
+ * orchestrator-ignore.mjs — keep the plugin's runtime state out of `git status`
3
+ * in a repo that has never been bootstrapped (#1515 Pkt 5).
4
+ *
5
+ * Measured 2026-10-08 @ 644743cc: one SessionStart in a fresh `git init` repo
6
+ * left five untracked files under `.orchestrator/` (session.lock,
7
+ * current-session.json, host.json, runtime/lock-owner-proof.json,
8
+ * metrics/events.jsonl) and nothing ignored them. Bootstrapped repos get their
9
+ * runtime ignore lines from `skills/bootstrap/_shared-template.md`
10
+ * § store-lock-ignore; an un-bootstrapped one gets nothing.
11
+ *
12
+ * Root, not per writer (BV-003): 23 hook-reachable modules call mkdir, and
13
+ * `.orchestrator/` is created by whichever runs first. SessionStart is the first
14
+ * plugin hook of every session (hooks/hooks.json), so the one call in
15
+ * on-session-start.mjs covers the session. Because SessionStart runs async, a
16
+ * PreToolUse hook MAY have created the directory a moment earlier — which is
17
+ * why the decision keys on "nothing under .orchestrator/ is tracked" (one
18
+ * `git ls-files`), not on "the directory was just created".
19
+ *
20
+ * Invariants:
21
+ * - A repo WITH `.orchestrator/bootstrap.lock` is never given the file. If it
22
+ * carries a file this helper wrote (byte-identical), the file is removed:
23
+ * after bootstrap, steering/, peers/, policy/ and the ledgers are durable
24
+ * project data and must not stay ignored.
25
+ * - An existing `.orchestrator/.gitignore` is never overwritten.
26
+ * - Any tracked path under `.orchestrator/`, or git failing (no repo, no git
27
+ * binary), means: write nothing.
28
+ * - Never throws; returns a reason string for tests and diagnostics.
29
+ *
30
+ * The negations are the exact `.orchestrator/` paths a bootstrap run stages
31
+ * with `git add -- <file>` (BOOTSTRAP_FILES in skills/bootstrap/*-template.md,
32
+ * measured 2026-10-08). The Bootstrap Gate runs bootstrap in the SAME session
33
+ * this file was written, before the next SessionStart can remove it, and git
34
+ * refuses `git add` on an explicitly named ignored path. NAMED CEILING
35
+ * (BV-004): the list is hand-copied; revisit when a template adds a new
36
+ * `BOOTSTRAP_FILES+=(.orchestrator/...)` entry.
37
+ */
38
+
39
+ import { readFile, writeFile, link, unlink, mkdir } from 'node:fs/promises';
40
+ import { existsSync, lstatSync } from 'node:fs';
41
+ import { execFile } from 'node:child_process';
42
+ import path from 'node:path';
43
+
44
+ /** Directories that hold durable project data, never plugin runtime output. */
45
+ const PROJECT_DATA_DIRS = ['steering', 'peers', 'policy'];
46
+
47
+ export const GENERATED_IGNORE = [
48
+ '# Written by session-orchestrator (#1515): this repo has no .orchestrator/bootstrap.lock,',
49
+ '# so the plugin runtime state below stays out of git status. Removed automatically',
50
+ '# at the first session start after /bootstrap.',
51
+ '*',
52
+ '!*/',
53
+ '!/bootstrap.lock',
54
+ '!/metrics/learnings.jsonl',
55
+ '!/metrics/sessions.jsonl',
56
+ '!/metrics/README.md',
57
+ '!/policy/quality-gates.json',
58
+ '',
59
+ ].join('\n');
60
+
61
+ function trackedUnderOrchestrator(repoRoot) {
62
+ return new Promise((resolve) => {
63
+ execFile('git', ['ls-files', '-z', '--', '.orchestrator'], { cwd: repoRoot, timeout: 2000 }, (err, stdout) => {
64
+ if (err) return resolve(null); // unknown → caller writes nothing
65
+ resolve(stdout.length > 0);
66
+ });
67
+ });
68
+ }
69
+
70
+ /**
71
+ * @param {string} repoRoot
72
+ * @returns {Promise<'written'|'removed'|'bootstrapped'|'present'|'project-data'|'symlink'|'tracked'|'no-git'|'error'>}
73
+ */
74
+ export async function ensureOrchestratorIgnore(repoRoot) {
75
+ try {
76
+ const dir = path.join(repoRoot, '.orchestrator');
77
+ const target = path.join(dir, '.gitignore');
78
+ if (existsSync(path.join(dir, 'bootstrap.lock'))) {
79
+ if (existsSync(target)) {
80
+ const current = await readFile(target, 'utf8').catch(() => null);
81
+ if (current === GENERATED_IGNORE) {
82
+ await unlink(target);
83
+ return 'removed';
84
+ }
85
+ }
86
+ return 'bootstrapped';
87
+ }
88
+ if (existsSync(target)) return 'present';
89
+ // Durable project data (bootstrap/_shared-template.md #store-lock-ignore)
90
+ // the user may hold untracked on purpose: ignoring it would hide it from
91
+ // git status without a word. Only plugin runtime output may be swept.
92
+ if (PROJECT_DATA_DIRS.some((d) => existsSync(path.join(dir, d)))) return 'project-data';
93
+ // A symlinked .orchestrator would put the file outside the repo.
94
+ if (existsSync(dir) && lstatSync(dir).isSymbolicLink()) return 'symlink';
95
+ const tracked = await trackedUnderOrchestrator(repoRoot);
96
+ if (tracked === null) return 'no-git';
97
+ if (tracked) return 'tracked';
98
+ await mkdir(dir, { recursive: true });
99
+ // tmp + link: atomic, and link() fails with EEXIST instead of replacing a
100
+ // .gitignore another process created in between (rename would overwrite).
101
+ const tmp = `${target}.tmp-${process.pid}`;
102
+ await writeFile(tmp, GENERATED_IGNORE, 'utf8');
103
+ try {
104
+ await link(tmp, target);
105
+ } finally {
106
+ await unlink(tmp).catch(() => {});
107
+ }
108
+ return 'written';
109
+ } catch (err) {
110
+ return err && err.code === 'EEXIST' ? 'present' : 'error';
111
+ }
112
+ }
@@ -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);