session-orchestrator 5.10.0 → 5.12.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 (88) hide show
  1. package/.claude-plugin/marketplace.json +12 -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 +51 -0
  6. package/README.md +22 -11
  7. package/SECURITY.md +1 -1
  8. package/agents/code-implementer.md +7 -1
  9. package/commands/session.md +3 -1
  10. package/docs/agent-authoring.md +2 -0
  11. package/docs/events-schema.md +2 -1
  12. package/docs/fleet-checkin-audit.md +172 -0
  13. package/docs/install.md +12 -8
  14. package/docs/role-profile-contract.md +139 -0
  15. package/docs/scope-collision-guard.md +3 -0
  16. package/docs/session-config-reference.md +7 -7
  17. package/docs/session-config-template.md +2 -2
  18. package/evals/instruction-ablation/run.mjs +11 -6
  19. package/hooks/_lib/hook-import-set.json +27 -2
  20. package/hooks/_lib/orchestrator-ignore.mjs +112 -0
  21. package/hooks/hooks-codex.json +1 -1
  22. package/hooks/hooks.json +1 -1
  23. package/hooks/on-session-end.mjs +8 -1
  24. package/hooks/on-session-start.mjs +26 -10
  25. package/hooks/post-bash-write-verify.mjs +16 -3
  26. package/hooks/pre-task-scope-disjoint.mjs +12 -0
  27. package/hooks/subagent-telemetry.mjs +8 -3
  28. package/hooks/wave-scope-commit-guard.mjs +4 -1
  29. package/package.json +1 -2
  30. package/rules/opt-in-stack/security-web.md +1 -1
  31. package/scripts/emit-session.mjs +13 -0
  32. package/scripts/fleet-checkin-audit.mjs +138 -0
  33. package/scripts/fleet-checkin.mjs +61 -3
  34. package/scripts/lib/common.mjs +2 -4
  35. package/scripts/lib/config/remote-hosts.mjs +2 -2
  36. package/scripts/lib/config.mjs +1 -1
  37. package/scripts/lib/context-overhead-format.mjs +57 -0
  38. package/scripts/lib/fleet-checkin-audit.mjs +244 -0
  39. package/scripts/lib/fleet-protocol.mjs +17 -0
  40. package/scripts/lib/memory-proposals/sink.mjs +79 -17
  41. package/scripts/lib/memory-proposals/store.mjs +245 -9
  42. package/scripts/lib/project-hygiene.mjs +37 -0
  43. package/scripts/lib/role-profile/schema.mjs +219 -0
  44. package/scripts/lib/session-end/vault-orphans.mjs +205 -0
  45. package/scripts/lib/session-invocation.mjs +14 -3
  46. package/scripts/lib/session-lock.mjs +57 -10
  47. package/scripts/lib/session-record-repair.mjs +63 -30
  48. package/scripts/lib/session-registry.mjs +14 -4
  49. package/scripts/lib/session-schema/constants.mjs +8 -0
  50. package/scripts/lib/session-schema/validator.mjs +33 -0
  51. package/scripts/lib/so-hud/collect.mjs +130 -0
  52. package/scripts/lib/so-hud/runtime.mjs +76 -0
  53. package/scripts/lib/state-md/idle-reset.mjs +239 -0
  54. package/scripts/lib/state-md/recommendations.mjs +26 -4
  55. package/scripts/lib/subagents-schema.mjs +9 -0
  56. package/scripts/lib/telemetry/pricing.mjs +11 -0
  57. package/scripts/lib/vault-mirror/namespace.mjs +3 -2
  58. package/scripts/lib/vault-mirror/process.mjs +143 -40
  59. package/scripts/lib/vault-mirror/render-learnings.mjs +6 -5
  60. package/scripts/lib/vault-mirror/utils.mjs +27 -13
  61. package/scripts/lib/vault-status/board-writer.mjs +32 -4
  62. package/scripts/measure-context-overhead.sh +4 -21
  63. package/scripts/release.mjs +359 -11
  64. package/scripts/resolve-session-invocation.mjs +64 -8
  65. package/scripts/run-quality-gate.mjs +47 -0
  66. package/scripts/so-hud.mjs +45 -0
  67. package/scripts/state-md-idle-reset.mjs +81 -0
  68. package/scripts/validate-wave-scope.mjs +59 -6
  69. package/scripts/vault-orphans.mjs +171 -0
  70. package/skills/_shared/fleet-protocol.md +24 -2
  71. package/skills/_shared/state-ownership.md +3 -3
  72. package/skills/autopilot/SKILL.md +3 -2
  73. package/skills/claude-md-drift-check/checker.mjs +8 -6
  74. package/skills/npm-publish/SKILL.md +3 -3
  75. package/skills/release/SKILL.md +2 -2
  76. package/skills/repo-audit/SKILL.md +1 -1
  77. package/skills/session-end/SKILL.md +3 -2
  78. package/skills/session-end/metrics-collection.md +2 -2
  79. package/skills/session-end/references/phase-3-7e-vault-orphans.md +63 -0
  80. package/skills/session-end/references/phase-3-documentation-updates.md +11 -1
  81. package/skills/session-end/references/session-summary-template.md +4 -0
  82. package/skills/session-end/session-metrics-write.md +1 -1
  83. package/skills/session-start/SKILL.md +1 -1
  84. package/skills/session-start/phase-7-5-mode-selector.md +3 -1
  85. package/skills/session-start/references/phase-1-2-session-lock.md +1 -1
  86. package/skills/session-start/references/phase-1-5-session-continuity.md +12 -4
  87. package/skills/wave-executor/circuit-breaker.md +1 -0
  88. package/AGENTS.md +0 -205
@@ -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.
@@ -211,6 +211,7 @@ Complete list of what this guard does **not** see, or sees only approximately:
211
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.
212
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.
213
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.
214
215
 
215
216
  ## 7. Debugging
216
217
 
@@ -228,6 +229,8 @@ node scripts/validate-wave-scope.mjs --assert-disjoint "$WAVE_SCOPES_SIDECAR" <
228
229
 
229
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).
230
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
+
231
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.
232
235
 
233
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
 
@@ -1357,7 +1357,7 @@ Opt-in declaration of ssh-reachable hosts a heavy wave role may be OFFLOADED to
1357
1357
 
1358
1358
  ```yaml
1359
1359
  remote-hosts:
1360
- - alias: m5 # required, SAFE slug; reaches argv as `-H <alias>`
1360
+ - alias: build-host # required, SAFE slug; reaches argv as `-H <alias>`
1361
1361
  roles-allowed: [test, ui, perf] # subset of test|ui|perf (default: all three)
1362
1362
  repo-path: ~/Projects/Alice # optional; SAFE path; default null
1363
1363
  claude-path: ~/.local/bin/claude # optional; SAFE path; default null
@@ -1373,7 +1373,7 @@ remote-hosts:
1373
1373
 
1374
1374
  **Two enums, never conflated.** `roles-allowed` holds `agent-mapping` roles (`test`, `ui`, `perf`) — NOT wave roles (`Impl-Core`, `Quality`, …). The wave→role translation is `OFFLOADABLE_WAVE_ROLES` in `scripts/lib/wave-resource-gate.mjs`; a wave role absent from that map is local-only by default.
1375
1375
 
1376
- **Placement contract.** The gate applies its offload arm only after the HR-004 heavy-repo cap, and only when the resource verdict was `reduce` or `coordinator-direct` — or, with `offload-first: true`, on `proceed` as well (see § `offload-first` below). It does NOT probe the network: the coordinator supplies a readiness witness (`remoteReady: { m5: true }`, or an async `probeFn`). With no witness, no host counts as ready and the decision stays local — the gate fails toward local, never toward an unverified host. A role in `NEVER_FOREIGN_ROLES` (`scripts/lib/wave-executor/dispatch-common.mjs`) is never offloaded regardless.
1376
+ **Placement contract.** The gate applies its offload arm only after the HR-004 heavy-repo cap, and only when the resource verdict was `reduce` or `coordinator-direct` — or, with `offload-first: true`, on `proceed` as well (see § `offload-first` below). It does NOT probe the network: the coordinator supplies a readiness witness (`remoteReady: { 'build-host': true }`, or an async `probeFn`). With no witness, no host counts as ready and the decision stays local — the gate fails toward local, never toward an unverified host. A role in `NEVER_FOREIGN_ROLES` (`scripts/lib/wave-executor/dispatch-common.mjs`) is never offloaded regardless.
1377
1377
 
1378
1378
  **agent-mapping interaction.** A declared alias is what an `agent-mapping` value of the form `<role>: ssh:<alias>` validates against; naming an undeclared host throws at parse time, naming the `ssh` channel with no target throws as for any other channel.
1379
1379
 
@@ -1396,7 +1396,7 @@ offload-first: false # true = offloadable roles go to a witnessed-ready remote
1396
1396
  **Behaviour with `offload-first: true`.**
1397
1397
 
1398
1398
  - Only roles in `OFFLOADABLE_WAVE_ROLES` (`scripts/lib/wave-resource-gate.mjs`) move: wave roles `quality` / `test` → `test`, `ui` → `ui`, `perf` → `perf`. The table is NOT extended by this switch — build, lint and audit work is covered by the Quality wave's `test` role. `impl-core`, `security-review`, `migration`, `release`, `secrets`, `incident` and `refactor-crosscut` never leave the local host.
1399
- - The target is the FIRST declared `remote-hosts` entry whose `roles-allowed` carries the mapped role AND that is witnessed ready (`remoteReady: { m5: true }` or an async `probeFn`). The gate still never probes the network itself, so the witness is mandatory for the switch to have any effect: without one, nothing offloads even with the switch on.
1399
+ - The target is the FIRST declared `remote-hosts` entry whose `roles-allowed` carries the mapped role AND that is witnessed ready (`remoteReady: { 'build-host': true }` or an async `probeFn`). The gate still never probes the network itself, so the witness is mandatory for the switch to have any effect: without one, nothing offloads even with the switch on.
1400
1400
  - The HR-004 heavy-repo cap still binds the offloaded agent count.
1401
1401
  - No ready host for the role → the verdict is unchanged (`proceed`, or the `reduce`/`coordinator-direct` the resource rules computed) and `reasons` carries `offload-first: no ready host for '<role>' — staying local`.
1402
1402
  - Switch on but no `remote-hosts` declared → the verdict is unchanged and `reasons` carries `offload-first: true but no remote-hosts declared — staying local`. `validateSessionConfig()` (`scripts/lib/config-schema.mjs`) additionally emits a warn-level cross-field finding `offload-first-cross-field` for that combination; it never flips `ok`.
@@ -1531,7 +1531,7 @@ Implementation: `scripts/lib/recommendations-v0.mjs` → `computeV0Recommendatio
1531
1531
 
1532
1532
  - Pre-v1.1 STATE.md files (produced by sessions before Epic #271 shipped) simply do not contain these fields. The parser returns `null` from `parseRecommendations`; the banner silently no-ops.
1533
1533
  - `schema-version` is NOT bumped to `1.1` in the frontmatter — the existing `schema-version: 1` remains canonical. The v1.1 label is documentation-only, describing the additive surface.
1534
- - Idle Reset (session-start Phase 1.5) archives these fields into the `## Previous Session` body block of STATE.md as a human-readable block, then removes them from the frontmatter — so a fresh session never inherits stale recommendations in its live frontmatter.
1534
+ - Idle Reset (session-start Phase 1.5, `scripts/state-md-idle-reset.mjs`) copies these fields into the `## Previous Session` body block of STATE.md as a human-readable block and KEEPS them in the frontmatter: they are the handoff the Phase 7.5 Mode-Selector reads after the reset (#1560). Deleting them, as the reset did before #1560, left the selector at confidence 0 after every clean `/close`. The next session-end Phase 3.7a overwrites them.
1535
1535
 
1536
1536
  ### Reader behavior
1537
1537
 
@@ -1552,8 +1552,8 @@ The banner renders with one of the following shapes:
1552
1552
 
1553
1553
  - **Writer:** `skills/session-end/SKILL.md` § Phase 3.7a "Compute and Write Recommendations" (runs after Phase 3.7 sessions.jsonl write, before Phase 3.4 `status: completed` setting).
1554
1554
  - **Reader:** `skills/session-start/SKILL.md` § Phase 1.5 "Recommendations Banner" (renders on `status: completed` branch only).
1555
- - **Archival:** `skills/session-start/SKILL.md` § Idle Reset rule 6 (removes fields from frontmatter, prepends readable block to `## Previous Session`).
1556
- - **Future consumer:** Phase B Mode-Selector skill (planned in Epic #271) will read these fields as the primary input for autonomous mode selection.
1555
+ - **Archival:** `skills/session-start/references/phase-1-5-session-continuity.md` § Idle Reset rule 6 (keeps the fields in the frontmatter, prepends a readable block to `## Previous Session`).
1556
+ - **Mode-Selector:** `skills/session-start/phase-7-5-mode-selector.md` reads these fields through `buildLiveSignals()` (`scripts/lib/build-live-signals.mjs`) as its primary input.
1557
1557
 
1558
1558
  ## Frontend-Slop Hook (#684) — deprecated
1559
1559
 
@@ -393,7 +393,7 @@ Opt-in declaration of ssh-reachable hosts that heavy wave roles may be OFFLOADED
393
393
 
394
394
  ```yaml
395
395
  remote-hosts:
396
- - alias: m5 # required, SAFE slug ([A-Za-z0-9._-]); reaches argv as `-H <alias>`
396
+ - alias: build-host # required, SAFE slug ([A-Za-z0-9._-]); reaches argv as `-H <alias>`
397
397
  roles-allowed: [test, ui, perf] # subset of test|ui|perf (default: all three)
398
398
  repo-path: ~/Projects/Alice # optional SAFE path (default: null)
399
399
  claude-path: ~/.local/bin/claude # optional SAFE path (default: null)
@@ -890,7 +890,7 @@ custom-phases:
890
890
 
891
891
  # Remote hosts — ssh-reachable offload targets for heavy roles (#1160)
892
892
  remote-hosts:
893
- - alias: m5 # required, SAFE slug; reaches argv as `-H <alias>`
893
+ - alias: build-host # required, SAFE slug; reaches argv as `-H <alias>`
894
894
  roles-allowed: [test, ui, perf] # subset of test|ui|perf (default: all three)
895
895
  repo-path: ~/Projects/Alice # optional SAFE path (default: null)
896
896
  claude-path: ~/.local/bin/claude # optional SAFE path (default: null)
@@ -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
+ }
@@ -7,7 +7,7 @@
7
7
  "hooks": [
8
8
  {
9
9
  "type": "command",
10
- "command": "echo '🎯 Session Orchestrator v5.10.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
10
+ "command": "echo '🎯 Session Orchestrator v5.12.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
11
11
  "async": false
12
12
  },
13
13
  {
package/hooks/hooks.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "hooks": [
7
7
  {
8
8
  "type": "command",
9
- "command": "echo '🎯 Session Orchestrator v5.10.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
9
+ "command": "echo '🎯 Session Orchestrator v5.12.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
10
10
  "async": false
11
11
  },
12
12
  {
@@ -43,6 +43,7 @@ import {
43
43
  release,
44
44
  loadOwnerProof,
45
45
  OWNER_PROOF_RELPATH,
46
+ isLockOwnedByProof,
46
47
  } from '../scripts/lib/session-lock.mjs';
47
48
  import { parseSessionId } from '../scripts/lib/session-id.mjs';
48
49
  import { isMainModule } from '../scripts/lib/is-main-module.mjs';
@@ -789,8 +790,14 @@ async function main() {
789
790
  // millisecond started_at cannot match any FUTURE lock, so it
790
791
  // self-invalidates), this just avoids the stale artifact. ENOENT
791
792
  // (no proof was ever written) lands in the same swallow.
793
+ // Only when the proof still describes the lock we released: since
794
+ // #1541 every acquire() rewrites it, so a successor that acquired
795
+ // during the awaited emit above owns a fresh proof we must not
796
+ // remove. A narrow window between this read and the unlink remains.
792
797
  try {
793
- await fs.unlink(path.join(projectRoot, OWNER_PROOF_RELPATH));
798
+ if (isLockOwnedByProof(lock, loadOwnerProof({ repoRoot: projectRoot }))) {
799
+ await fs.unlink(path.join(projectRoot, OWNER_PROOF_RELPATH));
800
+ }
794
801
  } catch { /* best-effort — a leftover proof is self-invalidating */ }
795
802
  }
796
803
  } else {
@@ -29,16 +29,17 @@
29
29
  */
30
30
 
31
31
  import { readFile, writeFile, mkdir } from 'node:fs/promises';
32
- import { writeSync } from 'node:fs';
32
+ import { existsSync, writeSync } from 'node:fs';
33
33
  import { execFile } from 'node:child_process';
34
34
  import { promisify } from 'node:util';
35
35
  import { randomUUID } from 'node:crypto';
36
36
  import path from 'node:path';
37
37
 
38
38
  import { shouldRunHook } from './_lib/profile-gate.mjs';
39
+ import { ensureOrchestratorIgnore } from './_lib/orchestrator-ignore.mjs';
39
40
 
40
41
  import { emitEvent, eventsFilePath } from '../scripts/lib/events.mjs';
41
- import { readNavigatorLease } from '../scripts/lib/fleet-protocol.mjs';
42
+ import { readNavigatorLease, fleetIntegrationEnabled } from '../scripts/lib/fleet-protocol.mjs';
42
43
  import { maybeRotate } from '../scripts/lib/events-rotation.mjs';
43
44
  import { readConfigFile, parseSessionConfig } from '../scripts/lib/config.mjs';
44
45
  import { getPlatform, resolveProjectDir } from '../scripts/lib/platform.mjs';
@@ -781,6 +782,12 @@ function maybeQueueConsentNudge() {
781
782
  async function main() {
782
783
  const projectRoot = resolveProjectDir();
783
784
 
785
+ // #1515 Pkt 5 — before this hook's own writers (host.json, session.lock,
786
+ // events.jsonl) run: an un-bootstrapped repo gets `.orchestrator/.gitignore`
787
+ // so the runtime state never shows up in `git status`. Bootstrapped repos
788
+ // pay two existsSync and nothing else. Never throws.
789
+ await ensureOrchestratorIgnore(projectRoot);
790
+
784
791
  // Read optional stdin payload in parallel with git info so we don't stall
785
792
  // the hook. Both are best-effort and the promise races against a 500 ms cap
786
793
  // inside readStdinJson itself.
@@ -810,15 +817,23 @@ async function main() {
810
817
  // null on its own error paths, which would silently re-open the nudge.
811
818
  const hostBannerEnabled = await isHostBannerEnabled(projectRoot);
812
819
  let bannerData = null;
820
+ // #1559: a git repository with no bootstrap.lock — neither at its toplevel
821
+ // nor under projectRoot (a bootstrapped sub-project) — has one first step,
822
+ // /bootstrap; /go and /close do not work before it. Judged at the toplevel
823
+ // too, so a session started in a subdirectory of a bootstrapped repo is not
824
+ // told to bootstrap again; outside a git repository there is nothing to set up.
825
+ const needsBootstrap = Boolean(topLevel)
826
+ && !existsSync(path.join(topLevel, '.orchestrator', 'bootstrap.lock'))
827
+ && !existsSync(path.join(projectRoot, '.orchestrator', 'bootstrap.lock'));
813
828
  if (hostBannerEnabled) {
814
829
  bannerData = await emitHostBanner(projectRoot);
815
- // Always-on nudge: a user decision has three legitimate forms and AUQ-001
816
- // routes between them in order — operator verb first (nothing is blocked
817
- // while the operator picks his moment), then derive-and-report from Session
818
- // Config / STATE.md / git, and only then the tool. The banner carries the
819
- // ORDER, not an absolute; full routing + exceptions in
820
- // .claude/rules/ask-via-tool.md.
821
- pushBanner('🎯 Decide: operator verb (/go) > derive+report > AUQ if blocking (.claude/rules/ask-via-tool.md).');
830
+ // AUQ-001 in plain words (#1559): name the command when nothing is
831
+ // blocked, read available answers from config / STATE.md / git, and ask
832
+ // only when blocked — then with the question tool, never as a prose list.
833
+ // Before bootstrap, the setup step replaces it as the first action.
834
+ pushBanner(needsBootstrap
835
+ ? 'ℹ New here? Start with /bootstrap — it sets up this repo for session-orchestrator.'
836
+ : '🎯 Next step: name the command (/go, /close) when nothing is blocked; read answers from config, STATE.md or git; when blocked, ask with the question tool, never as a prose list.');
822
837
  }
823
838
 
824
839
  // F1.3 cold-start abandonment fix (PRD 2026-05-21). Emit a one-shot
@@ -1033,7 +1048,8 @@ async function main() {
1033
1048
  // Gated like the peer lines above: no host banner → no navigator line. A read
1034
1049
  // failure must never block the hook — the helper is fail-closed itself, the
1035
1050
  // try/catch only guards against the unexpected.
1036
- if (bannerData) {
1051
+ // #1559: solo users without fleet integration see no fleet line.
1052
+ if (bannerData && fleetIntegrationEnabled()) {
1037
1053
  try {
1038
1054
  pushBanner(navigatorBannerLine(await readNavigatorLease()));
1039
1055
  } catch { /* swallow — hook must remain non-blocking */ }
@@ -1299,10 +1299,23 @@ async function main() {
1299
1299
  ? []
1300
1300
  : readPeerScopeRecords(path.dirname(scopePath), scope.wave, sidecarRaw);
1301
1301
  if (sidecarBinding.notify) {
1302
- messages.push(formatSidecarMismatchNotice(
1303
- path.relative(repoRoot, path.join(path.dirname(scopePath), 'filescopes', `wave-${scope.wave}.scopes.json`)),
1304
- ));
1302
+ const relSidecar = path.relative(
1303
+ repoRoot,
1304
+ path.join(path.dirname(scopePath), 'filescopes', `wave-${scope.wave}.scopes.json`),
1305
+ );
1306
+ messages.push(formatSidecarMismatchNotice(relSidecar));
1305
1307
  warn = true;
1308
+ // #1514 point 6 (HR-105) — the notice alone left its firing rate unmeasurable.
1309
+ // Same once-per-new-hash dedupe as the notice (`bindPeerSidecar().notify`);
1310
+ // awaited so the append cannot be lost to the process exiting.
1311
+ try {
1312
+ const { emitEvent } = await import('../scripts/lib/events.mjs');
1313
+ await emitEvent(
1314
+ 'orchestrator.scope.sidecar_mismatch',
1315
+ { hook: 'post-bash-write-verify', sidecar: relSidecar.split(path.sep).join('/'), wave: scope.wave },
1316
+ { repoRoot: context.manifestRoot },
1317
+ );
1318
+ } catch { /* observability is best-effort — never blocks the notice */ }
1306
1319
  }
1307
1320
  const violations = [];
1308
1321
  for (const relPath of report) {