session-orchestrator 5.5.0 → 5.7.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 (51) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor-plugin/plugin.json +1 -1
  5. package/CHANGELOG.md +46 -0
  6. package/README.md +27 -13
  7. package/docs/USER-GUIDE.md +1 -1
  8. package/docs/rule-authoring.md +10 -0
  9. package/docs/scope-collision-guard.md +5 -3
  10. package/docs/session-config-reference.md +1 -1
  11. package/docs/session-config-template.md +1 -1
  12. package/hooks/hooks-codex.json +1 -1
  13. package/hooks/hooks.json +1 -1
  14. package/hooks/on-stop.mjs +8 -2
  15. package/hooks/post-bash-write-verify.mjs +121 -7
  16. package/hooks/post-tool-batch-wave-signal.mjs +11 -4
  17. package/hooks/pre-bash-memory-propose-audit.mjs +13 -8
  18. package/hooks/pre-task-scope-disjoint.mjs +20 -35
  19. package/hooks/wave-scope-commit-guard.mjs +117 -20
  20. package/package.json +8 -3
  21. package/scripts/backfill-abandoned-sessions.mjs +5 -7
  22. package/scripts/backfill-learnings-from-vault.mjs +17 -6
  23. package/scripts/lib/dispatcher/rank.mjs +5 -1
  24. package/scripts/lib/events.mjs +62 -49
  25. package/scripts/lib/io.mjs +9 -4
  26. package/scripts/lib/learnings/candidates.mjs +7 -16
  27. package/scripts/lib/memory-proposals/store.mjs +9 -2
  28. package/scripts/lib/reconcile/rule-expiry-sweep.mjs +52 -0
  29. package/scripts/lib/scope-gate.mjs +8 -1
  30. package/scripts/lib/session-close-backfill.mjs +104 -13
  31. package/scripts/lib/validate/check-test-value-bans.mjs +9 -0
  32. package/scripts/lib/vault-mirror/process.mjs +388 -56
  33. package/scripts/lib/vault-mirror/prune.mjs +413 -0
  34. package/scripts/lib/vault-mirror/render-sessions.mjs +13 -5
  35. package/scripts/lib/vault-mirror/retention.mjs +388 -0
  36. package/scripts/lib/vault-status/board-writer.mjs +6 -6
  37. package/scripts/lib/wave-transcript-tail.mjs +11 -2
  38. package/scripts/print-applicable-rules.mjs +1 -25
  39. package/scripts/release.mjs +4 -0
  40. package/scripts/validate-wave-scope.mjs +39 -4
  41. package/scripts/vault-mirror-prune.mjs +145 -0
  42. package/scripts/vault-mirror.mjs +77 -12
  43. package/skills/_shared/parallel-aware-auq.md +1 -1
  44. package/skills/claude-md-drift-check/checker.mjs +6 -16
  45. package/skills/vault-mirror/SKILL.md +27 -2
  46. package/skills/wave-executor/references/wave-loop-scope-manifest.md +3 -1
  47. package/hooks/agent-teams-h3-test.sh +0 -362
  48. package/scripts/spikes/h3-agent-teams/preflight.sh +0 -53
  49. package/scripts/spikes/h3-agent-teams/run-h3.sh +0 -112
  50. package/scripts/spikes/h3-agent-teams/setup.sh +0 -137
  51. package/scripts/spikes/h3-agent-teams/toggle.sh +0 -38
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "A repeatable Plan, Go, Close workflow for AI coding sessions: /session reads your repo and agrees the scope, /go runs the work in waves with a quality gate between each, /close verifies and commits. Runs on Claude Code, Codex CLI, Cursor and Pi.",
10
- "version": "5.5.0"
10
+ "version": "5.7.0"
11
11
  },
12
12
  "plugins": [
13
13
  {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "session-orchestrator",
4
- "version": "5.5.0",
4
+ "version": "5.7.0",
5
5
  "description": "A repeatable Plan, Go, Close workflow for AI coding sessions: /session reads your repo and agrees the scope, /go runs the work in waves with a quality gate between each, /close verifies and commits. Runs on Claude Code, Codex CLI, Cursor and Pi.",
6
6
  "author": {
7
7
  "name": "Bernhard Goetzendorfer",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "session-orchestrator",
3
- "version": "5.5.0+codex.20261003080809",
3
+ "version": "5.7.0+codex.20261003172657",
4
4
  "description": "A repeatable Plan, Go, Close workflow for AI coding sessions: /session reads your repo and agrees the scope, /go runs the work in waves with a quality gate between each, /close verifies and commits. Runs on Claude Code, Codex CLI, Cursor and Pi.",
5
5
  "keywords": [
6
6
  "session",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "session-orchestrator",
3
- "version": "5.5.0",
3
+ "version": "5.7.0",
4
4
  "description": "A repeatable Plan, Go, Close workflow for AI coding sessions: /session reads your repo and agrees the scope, /go runs the work in waves with a quality gate between each, /close verifies and commits. Runs on Claude Code, Codex CLI, Cursor and Pi.",
5
5
  "author": {
6
6
  "name": "Bernhard Goetzendorfer"
package/CHANGELOG.md CHANGED
@@ -7,6 +7,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [5.7.0] - 2026-10-03
11
+
12
+ ### Added
13
+
14
+ - **`scripts/vault-mirror-prune.mjs`** (#1513): applies the vault-mirror retention rules to the existing `40-learnings/` and `50-sessions/` notes. Dry run by default with a JSON manifest (`--manifest`: path, reason, action, target, kept copy, rollup); `--apply` writes the month rollups, then moves archived notes to `90-archive/mirror/<zone>/<same path>` with `status: archived` + `archived-reason`, then renames alias-folder and flat notes into their namespace. Reasons: `duplicate`, `superseded`, `expired`, `metrics-only-session`, `namespace-alias`, `flat-relocate`. Only notes with the mirror's `_generator` marker are touched; nothing is deleted; a second run plans nothing.
15
+
16
+ ### Changed
17
+
18
+ - **vault-mirror narrative gate for sessions** (#1513): `vault-mirror.quality.min-narrative-chars` now measures the record's free text (`notes`, `narrative`, `summary`) instead of the rendered body with its tables. A record below it gets no note but one row in `50-sessions/<repo>/_rollup-YYYY-MM.md` (action `skipped-metrics-only`; idempotent). `0` disables the gate.
19
+ - **vault-mirror learnings lifecycle** (#1513): the pre-#725 slug variant of a note is updated in place instead of getting a hyphenated twin; a new note whose normalised insight already exists in the namespace is refused (`skipped-duplicate-insight`); an expired learning is never created (`skipped-expired`) and its existing note becomes `status: archived` + `archived-reason: expired`; `--kind learning` also reads the sibling `learnings-archive.jsonl` and marks the notes of archived records (`expired` / `superseded`). `backfill-learnings-from-vault.mjs` falls back to `90-archive/mirror/40-learnings/`.
20
+ - **`session-close-backfill.log` carries events only** (#1513): `skipped-*` outcomes are no longer appended (86 % of the Meta-Vault's 723 KB log); they remain queryable as ledger events.
21
+ - **`sweep-expired-rules` removes expired single-entry rule files** (#1513): a generated rule with one learning and no counter sentence was skipped as `no-counter-sentence` forever; it is now deleted (pair stamped first) once the later of its frontmatter `expires-at` and the learning's store date has passed.
22
+ - **Commit guard names the manifest** (#1514): a parse error and each block header now carry the manifest path (`.claude/wave-scope.json` vs `pkg/.claude/wave-scope.json`), and the "To proceed" hint prints once when both manifests block.
23
+ - **Read failures other than a missing file are reported** (#1216): `countProposalsForWave` and the transcript tail's `readAgentType` now warn on stderr instead of silently returning `0` / `unknown`.
24
+ - **Lint rejects the deprecated locks shim and the deleted `OWNER_YAML_PATH`** (#1234): `no-restricted-imports` with the replacement named in the message.
25
+ - **ADR-0002 verdict corrected to Stay** (#1053): native session↔session and agent→coordinator messaging (Claude Code 2.1.224) removed the one capability Agent Teams added; ADR-0003/0007/0010 and the rules point at the addendum. LM-002a separates Channels from cross-session messaging.
26
+
27
+ ### Fixed
28
+
29
+ - **Drift-check Check 7 derives learning keys with the shared `learningKeyOf`** (#1021): a learning with a blank title or an untrimmed type no longer produces a key that differs from the one the rule emitter stamps.
30
+ - Shared helpers instead of private copies: one `tombstoneGap` for the three archive-gap sites (#1505), and `deriveFenceToken`, `learningKeyOf`, `kebab` imported where they were copied (#1021). Behaviour unchanged.
31
+
32
+ ### Removed
33
+
34
+ - **H3 Agent Teams test harness** (`hooks/agent-teams-h3-test.sh`, `scripts/spikes/h3-agent-teams/`) — the question it measured is closed (#1061).
35
+
36
+ ## [5.6.0] - 2026-10-03
37
+
38
+ ### Added
39
+
40
+ - **`validate-wave-scope --assert-disjoint --no-manifest`** (#1510 f): runs the disjointness check on the per-wave sidecar alone, without a manifest on stdin, so `/plan` no longer fabricates a dummy manifest. Without the flag an empty stdin stays an error (fail-closed); combining it with `--union`, `--assert-subset`, `--expand-test-siblings` or a positional manifest is rejected.
41
+
42
+ ### Changed
43
+
44
+ - **Wave git facts follow the session root** (`hooks/post-tool-batch-wave-signal.mjs`, `hooks/on-stop.mjs`, `hooks/pre-bash-memory-propose-audit.mjs`, #1511 a–c): in a session that entered a worktree, the wave's start SHA, changed-file count, HEAD, the stop record's branch/commit and the audit record's `cwd` now describe the tree the session works in. `current-session.json`, `session.lock` and the heartbeat stay at the launch dir, where `on-session-start` writes them.
45
+ - **The commit guard also judges the session-root manifest** (`hooks/wave-scope-commit-guard.mjs`, #1511 d): a session launched in a repo subdirectory has its own manifest checked in addition to the repo-root manifest, which is judged exactly as before; either one blocking blocks the commit. Staged paths outside the session root are violations without pattern matching, and the path is resolved with `realpathSync.native`.
46
+ - **One scope-entry spelling rule** (`scripts/lib/scope-gate.mjs` `scopeEntrySpelling`, now exported, #1510 g): `pre-task-scope-disjoint` drops its drifted copy (`scripts/././lib` stayed unpromoted there, so a sibling claim compared as disjoint).
47
+ - **Close-backfill reads ledger gaps inside the session's own window** (`scripts/lib/session-close-backfill.mjs`, #1512): a recoverable `unindexed-archive` gap overlapping the session defers the candidate; a pruned `missing-archive` gap writes the record and lists `session_type`/`session_profile` (and `completed_at` when the gap runs past the last event) in `_backfill_incomplete_fields`. Gaps outside the window, or without bounds, no longer matter.
48
+ - **Website, README and npm description** lead with the problem the plugin solves and group the features by benefit (site EN/DE, guide, `llms.txt`, `llms-full.txt`).
49
+
50
+ ### Fixed
51
+
52
+ - **A sidecar changed under a standing manifest no longer downgrades violations** (`hooks/post-bash-write-verify.mjs`, #1504 point 5): the sidecar hash is bound into the hook's trusted snapshot; on a mismatch `peer-session-*` records are ignored and one notice per mismatching hash is shown. A mid-wave Peer-Scope-Union must re-run the full scope sequence including the skeleton rewrite (`skills/_shared/parallel-aware-auq.md`).
53
+ - **Destructive-command guard counts on the site** read 11 blocking of 15 rules; the policy has 12 blocking and 4 warning rules of 16.
54
+ - **`.phase-result` on the site** loses its coloured side stripe (#1357, first half).
55
+
10
56
  ## [5.5.0] - 2026-10-03
11
57
 
12
58
  ### Added
package/README.md CHANGED
@@ -1,15 +1,17 @@
1
1
  # Session Orchestrator
2
2
 
3
3
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
4
- [![Version](https://img.shields.io/badge/version-5.5.0-blue.svg)](CHANGELOG.md)
4
+ [![Version](https://img.shields.io/badge/version-5.7.0-blue.svg)](CHANGELOG.md)
5
5
  [![npm](https://img.shields.io/npm/v/session-orchestrator.svg)](https://www.npmjs.com/package/session-orchestrator)
6
6
 
7
7
  **Give your agents a working rhythm.**
8
8
 
9
+ Your coding agents get an agreed plan, a check after each round of changes and a handover to the next session. On their own, coding agents drift from the plan, overwrite each other's files and call work done before it is checked; Session Orchestrator closes those gaps.
10
+
9
11
  You type three commands:
10
12
 
11
13
  - **`/session`** reads your repository, your open issues and the last session, proposes what to work on, and waits for your correction.
12
- - **`/go`** runs the agreed work in waves of parallel agents and runs your test, typecheck and lint commands between each wave. Work that fails a check goes back to be fixed before the next wave starts.
14
+ - **`/go`** runs the agreed work in waves of parallel agents. After each implementation wave your tests and typecheck run, and failures become fix tasks for the next wave. The full test, typecheck and lint gate runs before `/close` commits.
13
15
  - **`/close`** checks every planned item against what actually happened, commits, and files the rest as issues for next time.
14
16
 
15
17
  Session Orchestrator is a free, MIT-licensed workflow plugin for **Claude Code, Codex CLI, Cursor IDE, or [Pi](docs/pi-setup.md)**. It runs on your machine and writes plain text into your repository. No account, no server, nothing to sign up for.
@@ -59,7 +61,7 @@ persistence: true
59
61
  enforcement: warn
60
62
  ```
61
63
 
62
- The first three are the commands `/go` runs between waves and `/close` runs at the end — use whatever your project actually uses. Everything else is opt-in: [full template](docs/session-config-template.md) · [every key, its type and default](docs/session-config-reference.md).
64
+ The first three are the commands the quality gate runs: tests and typecheck after each implementation wave, all three after the Quality wave and before `/close` commits — use whatever your project actually uses. Everything else is opt-in: [full template](docs/session-config-template.md) · [every key, its type and default](docs/session-config-reference.md).
63
65
 
64
66
  **3. Run the loop.**
65
67
 
@@ -73,6 +75,20 @@ On Codex the same three are `$session-orchestrator:session feature`, `$session-o
73
75
 
74
76
  In headless Claude Code (`claude -p`), `/session` and `/plan` are reserved terminal-only built-in names and the bare form is refused; use `/session-orchestrator:session` and `/session-orchestrator:plan` there. Every other command keeps its bare form.
75
77
 
78
+ ## What you get
79
+
80
+ Each item: what it does for you, then how it works.
81
+
82
+ | You get | How |
83
+ |---|---|
84
+ | **Know what to work on.** Every session starts from the real state of your project, not from a prompt you rewrite. | `/session` reads git, open issues, recent commits and the last session record, recommends one scope and waits for your correction. `/plan`, `/brainstorm` and `/grill` help while an idea is still vague. |
85
+ | **Errors stop at the next check.** A broken test is found right after the work that caused it, not after more work is built on top. | `/go` checks each wave with your tests and typecheck and turns failures into fix tasks; lint and the full suite run before the commit. `/close` checks every planned item against what happened. `/debug` looks for the root cause before a fix. |
86
+ | **Agents keep to their files.** Parallel agents do not overwrite each other, and destructive commands do not slip through. | Each agent gets a declared list of files it may change; overlapping lists are rejected before dispatch. A guard blocks 12 destructive command patterns (hard reset, `rm -rf`, force-push, `DROP TABLE`, …) and warns on 4. Hooks on Claude Code, bridges on Cursor and Pi, instructions only on Codex. |
87
+ | **Pick up where you stopped.** A crash or a second session in the same folder does not cost you the work. | `STATE.md` (a progress file in your repo) records each finished wave, and the next `/session` offers to resume. A session lock with a heartbeat keeps two sessions apart in one checkout. `/close` files what is left as GitLab or GitHub issues. |
88
+ | **It improves with your sessions.** Patterns from your own work become rules, once you approve them. | Every session appends a record. `/evolve` proposes learnings with a confidence score, `/reconcile` drafts rules from them; you approve or delete each one. Optional mirror into a Markdown notes vault. |
89
+ | **Audit, test and review on demand.** Find problems you did not ask about, and show the app works. | A discovery scan at `/close` turns confirmed findings into issues. `/test` drives web and macOS flows end to end, `/ux-grill` audits a running web app, `/test-audit` removes tests that catch nothing. `/repo-audit` and `/harness-audit` score a repository against a checklist, and `/persona-panel` has several reviewer personas read the same output in parallel. |
90
+ | **Longer runs, more repositories.** | `/autopilot` chains sessions and stops on any of 10 kill-switches. `/dispatcher` recommends the next free repository and claims it once you confirm, and `/portfolio` summarises issues, MRs and CI across the repositories registered in your notes vault. |
91
+
76
92
  ## How it works
77
93
 
78
94
  When you type `/session feature`:
@@ -80,7 +96,7 @@ When you type `/session feature`:
80
96
  1. **It reads the project.** Git state, open issues, recent commits, documentation, host resources and the records of previous sessions become a Session Overview with one recommendation.
81
97
  2. **You correct the scope.** Nothing is implemented until you agree to the plan.
82
98
  3. **The work is split into waves.** The session type decides how many: housekeeping 1, feature 3, deep 5, the ultradeep profile 7. Each wave gets a purpose, a list of file paths it may write to, and a result that can be checked.
83
- 4. **`/go` runs it.** Agents whose file scopes do not overlap run at the same time on Claude Code and Codex; Cursor and Pi run them one after another. After each wave the quality gate runs, and anything it reports goes back for correction before the next wave starts.
99
+ 4. **`/go` runs it.** Agents whose file scopes do not overlap run at the same time on Claude Code and Codex; Cursor and Pi run them one after another. After each implementation wave your tests and typecheck run, and failures become fix tasks for the next wave. The full test, typecheck and lint gate runs after the Quality wave.
84
100
  5. **`/close` checks and records.** It compares the plan against what happened, runs the full quality gate, commits file by file, and opens issues for whatever was not finished.
85
101
 
86
102
  **What it writes into your repository**, and nothing else — all of it plain text, all of it local:
@@ -100,7 +116,7 @@ The plugin is **51 skills, 27 slash commands, 14 typed subagents and 29 hook fil
100
116
  ## Why it is built this way
101
117
 
102
118
  - **The wave order is deliberate.** Discovery runs first so every implementer starts from the same picture of the code. Impl-Core runs before Impl-Polish so the structure exists before anything integrates against it. The Quality wave simplifies generated code *before* tests are written — write the tests first and they assert whatever the model produced, so changing it later means rewriting the tests too.
103
- - **Checks run between waves, not only at the end.** A mistake caught after wave 2 costs one wave. The same mistake found at `/close` has already been copied into every wave after it. Findings below the configured confidence threshold are not shown to you.
119
+ - **Checks run after each implementation wave, not only at the end.** A mistake caught after wave 2 costs one wave. The same mistake found at `/close` has already been copied into every wave after it. Findings below the configured confidence threshold are not shown to you.
104
120
  - **A crash does not lose the session.** `STATE.md` records which wave finished and what deviated from the plan. The next `/session` offers to continue from the last completed wave.
105
121
  - **Two sessions in one working copy is treated as a real risk.** Two people, or two of your own sessions, in the same checkout share one git index, one filesystem and one `STATE.md`, and neither can see the other's uncommitted work. A heartbeat session lock, per-agent file-scope manifests, and the PSA rules in [`.claude/rules/parallel-sessions.md`](https://github.com/Kanevry/session-orchestrator/blob/main/.claude/rules/parallel-sessions.md) exist for exactly that case.
106
122
  - **Guards run where the harness supports them, and the table below says where it does not.** A destructive-command policy — 12 rules block, 4 warn — and file-scope enforcement run as real hooks on Claude Code, as bridges on Cursor and Pi, and as instructions only on Codex. Details: [`docs/components.md`](docs/components.md#other-surfaces).
@@ -123,16 +139,14 @@ How this compares to other orchestrators, with measured results kept separate fr
123
139
 
124
140
  All four platforms share the same skills, commands and scripts; only the hooks differ, because each harness fires different events. Codex leaves its `PreToolUse` handlers empty because these guards do not yet match its tool names and edit payloads ([why](docs/codex-setup.md#why-our-pretooluse-guards-stay-unwired--the-reason-corrected)). Cursor and Pi have known event-coverage limits — see [`docs/cursor-setup.md`](docs/cursor-setup.md) and [`docs/pi-setup.md`](docs/pi-setup.md).
125
141
 
126
- ## Recent highlights (v5.5.0)
142
+ ## Recent highlights (v5.7.0)
127
143
 
128
- Highlights of the v5.5.0 line:
144
+ Highlights of the v5.7.0 line:
129
145
 
130
- - **Scope guards hold in worktrees, subdirectory launches and beside peer sessions.** The guards read the session's own working copy, find this session's own `wave-scope.json` even when a peer session's manifest ranks higher, and the wave readers, staging fence and orphan reaper follow the same session root. `blockedCommands` carries only command patterns, so redirects inside `bash -c` payloads are no longer denied (#1492, #1493, #1504, #1509).
131
- - **Session locks are taken over by exactly one session.** A stale or corrupt lock is reclaimed through a tombstone, a future-stamped lock or registry entry never counts as young, and leftovers of a killed reclaim are swept (#1494, #1505).
132
- - **vault-mirror never overwrites a note it does not own.** The namespace follows the mirrored ledger, a note of another project or generator is skipped, and the vault writers honour the `vault-integration` off switch (#1496, #1503, #1506).
133
- - **The learnings store keeps every line.** Unparseable lines survive each rewrite, `sweep-expired-learnings --drop-malformed` removes one on purpose, and a prune snapshots first and refuses on drift (#1486, #1489, #1500).
134
- - **Conventional commits on every path to `main`.** CI lints each commit range and the squash title, and accepts merge or revert headers only from real merges and reverts (#1477, #1502, #1507).
135
- - **`npm run update:local` updates every local install**, and Claude Code installs the plugin's dependencies again (the `overrides` block it refused is gone). The `/session-orchestrator:navigator` skill left the package; the fleet file contract stays (#1491).
146
+ - **Tidy up the vault mirror in place.** `scripts/vault-mirror-prune.mjs` applies the retention rules to existing learning and session notes: dry run with a manifest first, then `--apply` archives duplicates, superseded and expired notes and writes month rollups. Nothing is deleted, and a second run plans nothing (#1513).
147
+ - **Fewer, better vault notes from now on.** A session record without enough free text gets one rollup row instead of a note, an expired learning never becomes a note, and a learning whose insight already exists is refused (#1513).
148
+ - **The commit guard says which manifest it means.** Parse errors and block messages name the manifest file, and the "To proceed" hint prints once (#1514).
149
+ - **Silent failures made visible.** Read errors other than a missing file are now reported instead of turning into `0` or `unknown` (#1216), and lint blocks imports of the deprecated locks shim (#1234).
136
150
 
137
151
  Full changes and verification: [CHANGELOG.md](CHANGELOG.md).
138
152
 
@@ -454,7 +454,7 @@ Add a `## Session Config` section to your project's Session Config host file to
454
454
  | `plan-retro-location` | string | `docs/retro/` | Directory where retrospective documents are saved (relative to project root). |
455
455
  | `memory-cleanup-threshold` | integer | `5` | Recommend `/memory-cleanup` after N accumulated session memory files. |
456
456
  | `memory-cleanup-soft-limit` | integer | `180` | Hard ceiling on accumulated memory files before the cleanup nudge escalates from soft suggestion to strong recommendation. PRD F2.2 / issue #502. |
457
- | `vault-mirror.quality.min-narrative-chars` | integer | `400` | Minimum body length (characters) before a learning or session note is mirrored to the vault. Notes shorter than this threshold are skipped. PRD F1.2 / issue #504. |
457
+ | `vault-mirror.quality.min-narrative-chars` | integer | `400` | Minimum free text (characters in the session record's `notes` / `narrative` / `summary`) before a session gets its own vault note; below it the session becomes a row in the month rollup `50-sessions/<repo>/_rollup-YYYY-MM.md`. PRD F1.2 / #504, #1513. |
458
458
  | `vault-mirror.quality.min-confidence` | float | `0.5` | Minimum learning confidence (0.0..1.0) before a learning note is mirrored. Set to `0.0` to mirror every learning regardless of confidence. PRD F1.2 / issue #504. |
459
459
  | `cold-start.enabled` | boolean | `true` | Master toggle for the cold-start detector. When `false`, no idle-time nudges fire at session-start. PRD F1.3 / issue #500. |
460
460
  | `cold-start.nudge-after-hours` | integer | `1` | Hours of wall-clock idle (since the last session-end) before the cold-start detector fires a nudge. PRD F1.3 / issue #500. |
@@ -466,6 +466,16 @@ under four contracts, all of them consequences of the four merge rules above:
466
466
  `--apply` emits `orchestrator.rules.expiry_sweep_applied` after the writes
467
467
  succeed (see `docs/events-schema.md`); a dry run emits nothing.
468
468
 
469
+ **Single-entry files (#1513).** A generated rule holding exactly ONE learning
470
+ (one provenance pair, no `###` entry, no counter sentence — the per-learning
471
+ shape a consumer's reconciliation engine writes; all 21 generated rules of the
472
+ Meta-Vault on 2026-10-03) is dated by its own frontmatter `expires-at`, the
473
+ date `rule-loader.mjs` acts on. There is no body sentence to keep in agreement,
474
+ so the `no-counter-sentence` refusal does not apply: the file is deleted
475
+ (`reason: 'single-entry-expired'`, pair stamped terminal first) once the LATER
476
+ of that date and the learning's store `expires_at` (when it resolves) has
477
+ passed. A re-stamped learning therefore keeps its rule.
478
+
469
479
  ## Authoring Examples
470
480
 
471
481
  ### (a) Hand-authored always-on rule (no frontmatter)
@@ -35,7 +35,9 @@ The same ordering argument applies one level up: step 2 runs on the **declared**
35
35
 
36
36
  ### 2.2 Why the scope files are not temp files
37
37
 
38
- Steps 1, 2, 3, 4 and the `--assert-subset` assertion all read the *same* file, addressed by wave and agent id. A `$TMPDIR` copy is the one failure in this chain that **costs no error**: the injector finds nothing, no `FILE-SCOPE` block reaches the prompt, `extractScopeFromPrompt()` returns `[]`, and the hook allows the dispatch exactly as it did before #1020 — signal-free (matrix rows 5/6 below). The scope files are control state like `wave-scope.json` itself, never a wave territory, so they never enter `allowedPaths` — that would grant agents write access to the deconfliction record. Until #1027 Pkt 5 writing them tripped `bash-write-verify` once per wave rollover; its `computeReport` now drops `<state-dir>/filescopes/**` from the violation set beside the manifest itself (`isScopeDeclarationPath()` in `scope-gate.mjs`, keyed on the ACTIVE manifest's state dir, so another harness dir's `filescopes/` still reports). The cost, named: a Bash rewrite of a scope file after dispatch is no longer reported there. For the per-agent files `filescopes/wave-<N>/<agent-id>.json` what it can change is bounded — the dispatch already read the file, `wave-scope.json` (and with it `allowedPaths`) keeps its content-hash control notice, and `scope-echo --verify` turns a rewritten file into `digest-unknown`. The aggregate sidecar `filescopes/wave-<N>.scopes.json` is NOT bounded that way: `post-bash-write-verify` re-reads it on every call (`readPeerScopeRecords()`), and a `peer-session-*` record added there downgrades the matching violations to a "peer write" notice — the control hash covers only `wave-scope.json`, and `scope-echo` digests only the `wave-<N>/` directory. Open follow-up: hash the sidecar into the control state (or report its rewrite) so a peer record added after dispatch is visible.
38
+ Steps 1, 2, 3, 4 and the `--assert-subset` assertion all read the *same* file, addressed by wave and agent id. A `$TMPDIR` copy is the one failure in this chain that **costs no error**: the injector finds nothing, no `FILE-SCOPE` block reaches the prompt, `extractScopeFromPrompt()` returns `[]`, and the hook allows the dispatch exactly as it did before #1020 — signal-free (matrix rows 5/6 below). The scope files are control state like `wave-scope.json` itself, never a wave territory, so they never enter `allowedPaths` — that would grant agents write access to the deconfliction record. Until #1027 Pkt 5 writing them tripped `bash-write-verify` once per wave rollover; its `computeReport` now drops `<state-dir>/filescopes/**` from the violation set beside the manifest itself (`isScopeDeclarationPath()` in `scope-gate.mjs`, keyed on the ACTIVE manifest's state dir, so another harness dir's `filescopes/` still reports). The cost, named: a Bash rewrite of a scope file after dispatch is no longer reported there. For the per-agent files `filescopes/wave-<N>/<agent-id>.json` what it can change is bounded — the dispatch already read the file, `wave-scope.json` (and with it `allowedPaths`) keeps its content-hash control notice, and `scope-echo --verify` turns a rewritten file into `digest-unknown`. The aggregate sidecar `filescopes/wave-<N>.scopes.json` is re-read on every call (`readPeerScopeRecords()`), and a `peer-session-*` record there downgrades the matching violations to a "peer write" notice — so since #1504 point 5 its hash is BOUND to the manifest in the hook's trusted snapshot (`bindPeerSidecar()`): bound whenever the `wave-scope.json` hash changes, which fits the materialize → assert → union → write-manifest order. A sidecar that changes while the same manifest stands is untrusted: no peer record is honoured (those writes report as violations) until the manifest is rewritten, and a ⚠ notice names the file once per mismatching sidecar hash (HR-101 — not on every call). Named ceilings: a sidecar changed before the hook first observes the manifest is bound as found; one rewritten together with the manifest is covered only by the control-file notice; re-materializing the SAME wave without rewriting the manifest raises the notice once and leaves every peer record disabled until the manifest is rewritten — and because `--union` excludes `peer-session-*` records, a mid-wave Peer-Scope-Union changes the sidecar but not `allowedPaths`, so only the § 3.3 skeleton→final manifest rewrite changes the manifest hash and re-binds (the re-binding DEPENDS on that rewrite; `skills/_shared/parallel-aware-auq.md` § Peer-Scope-Union step 2 requires it); a forged complete snapshot record is believed (`trustedScopeState()`'s residual). No digest is written into any file the coordinator controls — a digest beside the sidecar sits where the same Bash line can rewrite it.
39
+
40
+ **A peer session's real manifest is reported once (#1511 e, named ceiling).** `.codex/wave-scope.json` or `<other-harness>/filescopes/**` written by a live peer appears once in this session's out-of-scope report (the report-once snapshot then holds it). The obvious exemption — "the manifest's binding names another session" — was shipped and withdrawn (e52bd786): the binding is what the file says about itself, so one Bash line could write a foreign-looking manifest and silence everything under that `filescopes/`. The hook cannot attribute a write to a caller either: as a `PostToolUse` hook it sees only the tree after the call and the PREVIOUS call's snapshot, so a peer's write between two calls and this call's own write land in the same window. Every other candidate signal is forgeable from inside this session as well: mtime (`touch -d`), a registry-live peer id (readable, so writable into the file), the harness directory. Until a signal exists that this session cannot produce, the one-time report stands.
39
41
 
40
42
  The coordinator's **own** planned direct edits belong in `coordinator.json` in the identical form. They are not dispatches, so the hook can never see them (§ 6); the CLI check is the only gate that covers them.
41
43
 
@@ -111,7 +113,7 @@ It cannot compare a batch of siblings directly — the header records why, measu
111
113
 
112
114
  - **Ledger:** `.orchestrator/wave-dispatch-scopes.<session>.json` — ONE FILE PER SESSION since #1493, `<session>` being the payload's `session_id` reduced to `[A-Za-z0-9._-]` (`ledgerPathsFor()`). Before, every session of the working copy shared one file holding one `waveKey`: claims of two sessions were never compared (the key's first segment is the session id), but a peer's dispatch reset the file and erased this session's live claims. The pre-split `.orchestrator/wave-dispatch-scopes.json` is read once for migration — adopted only while this session has no file yet and its `waveKey` names this session — and never written or deleted, since it may be a peer's live state. (Gitignored in this repo; consumer repos get the ignore line from bootstrap's `store-lock-ignore` step since #1495 — a repo bootstrapped earlier gets it on its next `--upgrade` or `--refresh-lock`), keyed `<session-id>|w<wave>|<role>` from `<state-dir>/wave-scope.json`.
113
115
  - **Lock:** `.orchestrator/wave-dispatch-scopes.<session>.lock`, one per ledger file — the read-modify-write cycle runs under `withFileLock()` (the primitive behind the PSA-005 STATE.md lock). `writeJsonAtomicSync` makes the write atomic, never the *cycle*.
114
- - **Scope extraction:** `extractScopeFromPrompt()` finds a scope marker line and takes the FIRST fenced block after it, accepting only lines that survive a deliberately strict `looksLikeRepoPath()`. `normalizeScopeEntry()` then folds `./`, `//` and `/./` spellings together, and `promoteDirEntries()` rewrites `scripts/lib` → `scripts/lib/` **on evidence** (not a tracked file itself, at least one tracked file beneath it) — both because two spellings of one path previously compared as disjoint.
116
+ - **Scope extraction:** `extractScopeFromPrompt()` finds a scope marker line and takes the FIRST fenced block after it, accepting only lines that survive a deliberately strict `looksLikeRepoPath()`. `decide()` then folds `./`, `//` and `/./` spellings together with `scopeEntrySpelling()` — the same rule `findScopeCollisions()` and `unionFileScopes()` apply, exported from `scope-gate.mjs` so no second copy can drift (#1510 g), and `promoteDirEntries()` rewrites `scripts/lib` → `scripts/lib/` **on evidence** (not a tracked file itself, at least one tracked file beneath it) — both because two spellings of one path previously compared as disjoint.
115
117
  - **DENY** fires on exactly one condition: a collision involving THIS dispatch with a prior agent of the same wave that is **still in flight** (§ 5). The reason names the agent pair, the collision kind and the evidence paths; the suggestion is "give the file exactly ONE owner, or wait for the named agent(s) and re-dispatch", plus this session's ledger path to delete if the state is stale (never a peer's). The ledger is deliberately **not** persisted on a deny — the dispatch did not happen, so recording it would make the retry-after-fix look like a duplicate.
116
118
 
117
119
  ### 4.1 Error-class matrix — deliberately fail-**open**
@@ -214,7 +216,7 @@ Complete list of what this guard does **not** see, or sees only approximately:
214
216
  node scripts/validate-wave-scope.mjs --assert-disjoint "$WAVE_SCOPES_SIDECAR" < <state-dir>/wave-scope.json
215
217
  ```
216
218
 
217
- Exit 1 prints one `ERROR:` line per collision (`agents "A" and "B" both claim [...]`) and one per duplicate id. Exit 0 — nothing on stderr, the manifest echoed back on stdout — means the declared scopes really are disjoint and the hook was right to allow; the divergence is then in the prompt, not in the plan.
219
+ Exit 1 prints one `ERROR:` line per collision (`agents "A" and "B" both claim [...]`) and one per duplicate id. Exit 0 — nothing on stderr, the manifest echoed back on stdout — means the declared scopes really are disjoint and the hook was right to allow; the divergence is then in the prompt, not in the plan. No manifest yet (e.g. `/plan`, before any `wave-scope.json` exists)? Add `--no-manifest` and drop the stdin redirect: the sidecar is checked alone and stdout stays empty — without the flag an empty stdin is still an error, and the flag refuses `--union` / `--assert-subset`, which need the manifest (#1510 f).
218
220
 
219
221
  **Reset.** Delete this session's `.orchestrator/wave-dispatch-scopes.<session>.json` (and its `.lock` if a dead holder is suspected) — never a peer's. Both are gitignored here (in a consumer repo only once bootstrap's `store-lock-ignore` step has run, #1495), both are rebuilt on the next dispatch, and neither is shared with any other mechanism or session. After the final wave, `<state-dir>/filescopes/` is deleted along with `wave-scope.json` and this session's ledger file (`skills/wave-executor/references/wave-loop-scope-manifest.md` step 7, session-end 3.0); a crashed session's file stays behind, inert — no other session reads it — a stale `wave-<N>/` directory left behind is a scope claim nobody re-verified.
220
222
 
@@ -837,7 +837,7 @@ vault-mirror:
837
837
 
838
838
  | Field | Type | Default | Description |
839
839
  |-------|------|---------|-------------|
840
- | `vault-mirror.quality.min-narrative-chars` | integer | `400` | Minimum body length (characters) before a learning or session note is mirrored to the vault. Notes shorter than this threshold are skipped — useful to prevent low-information notes from cluttering the vault during onboarding or when a session yields only stubs. Bounds: integer ≥ 0. Out-of-range values silently fall back to the default. PRD F1.2 / issue #504. |
840
+ | `vault-mirror.quality.min-narrative-chars` | integer | `400` | Minimum free text (characters in the session record's `notes` / `narrative` / `summary`) before a session gets its own vault note (#1513). Below it the session becomes one row in `50-sessions/<repo>/_rollup-YYYY-MM.md`; `0` disables the gate. Learnings are not gated by length. Bounds: integer ≥ 0. Out-of-range values silently fall back to the default. PRD F1.2 / issue #504, narrative semantics #1513. |
841
841
  | `vault-mirror.quality.min-confidence` | float | `0.5` | Minimum learning confidence (0.0..1.0) before a learning note is mirrored. Confidence is read from the source learning record. Notes below this threshold are skipped. Set to `0.0` to mirror every learning regardless of confidence. Bounds: `0.0 ≤ value ≤ 1.0`. Out-of-range values silently fall back to the default. PRD F1.2 / issue #504. |
842
842
 
843
843
  **Used by:** `scripts/vault-mirror.mjs`.
@@ -235,7 +235,7 @@ Quality thresholds applied by `scripts/vault-mirror.mjs` before mirroring a lear
235
235
  ```yaml
236
236
  vault-mirror:
237
237
  quality:
238
- min-narrative-chars: 400 # integer ≥ 0 — minimum body length to mirror
238
+ min-narrative-chars: 400 # integer ≥ 0 — minimum session free text (notes/narrative/summary) for an own note; below → month rollup (#1513)
239
239
  min-confidence: 0.5 # float 0.0..1.0 — minimum learning confidence to mirror
240
240
  ```
241
241
 
@@ -7,7 +7,7 @@
7
7
  "hooks": [
8
8
  {
9
9
  "type": "command",
10
- "command": "echo '🎯 Session Orchestrator v5.5.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
10
+ "command": "echo '🎯 Session Orchestrator v5.7.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.5.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
9
+ "command": "echo '🎯 Session Orchestrator v5.7.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
10
10
  "async": false
11
11
  },
12
12
  {
package/hooks/on-stop.mjs CHANGED
@@ -400,10 +400,16 @@ function reaperTriggerField(trigger) {
400
400
  * @param {{reason?: string}|null} [reaperTrigger] `maybeTriggerOrphanScan()` result.
401
401
  */
402
402
  async function handleStop(input, reaperTrigger = null) {
403
+ // Two roots, by writer (#1511 point b). `current-session.json` and
404
+ // `session.lock` (session id, lock heartbeat, duration) stay at the LAUNCH
405
+ // dir: on-session-start.mjs writes both at `resolveProjectDir()`. The wave
406
+ // and the commit/branch describe the tree the session WORKS in — after
407
+ // EnterWorktree that is the session root, not the launch checkout.
403
408
  const projectRoot = getProjectDir();
409
+ const sessionRoot = resolveSessionRoot(input?.cwd);
404
410
 
405
- const wave = await readWaveNumber(resolveSessionRoot(input?.cwd), input);
406
- const { commit, branch } = await gitInfo(projectRoot);
411
+ const wave = await readWaveNumber(sessionRoot, input);
412
+ const { commit, branch } = await gitInfo(sessionRoot);
407
413
 
408
414
  const { sessionId, semanticSessionId } = await resolveSessionId(input, projectRoot);
409
415
 
@@ -408,14 +408,11 @@ export { PEER_RECORD_PREFIX };
408
408
  * @param {unknown} wave the manifest's `wave` field
409
409
  * @returns {Array<{ id: string, files: string[] }>}
410
410
  */
411
- export function readPeerScopeRecords(stateDir, wave) {
412
- if (typeof stateDir !== 'string' || stateDir === '') return [];
413
- if (typeof wave !== 'number' || !Number.isInteger(wave) || wave <= 0) return [];
411
+ export function readPeerScopeRecords(stateDir, wave, raw = readPeerSidecarRaw(stateDir, wave)) {
412
+ if (typeof raw !== 'string') return [];
414
413
  let parsed;
415
414
  try {
416
- parsed = JSON.parse(
417
- readFileSync(path.join(stateDir, 'filescopes', `wave-${wave}.scopes.json`), 'utf8'),
418
- );
415
+ parsed = JSON.parse(raw);
419
416
  } catch {
420
417
  return [];
421
418
  }
@@ -429,6 +426,100 @@ export function readPeerScopeRecords(stateDir, wave) {
429
426
  .map((r) => ({ id: r.id, files: r.files.filter((f) => typeof f === 'string') }));
430
427
  }
431
428
 
429
+ /**
430
+ * The raw bytes of a wave's aggregate scope sidecar, or null (absent,
431
+ * unreadable, or no usable state dir / wave). Read ONCE per call so the bytes
432
+ * that are hashed ({@link bindPeerSidecar}) are the bytes that are parsed.
433
+ *
434
+ * @param {string} stateDir
435
+ * @param {unknown} wave
436
+ * @returns {string|null}
437
+ */
438
+ export function readPeerSidecarRaw(stateDir, wave) {
439
+ if (typeof stateDir !== 'string' || stateDir === '') return null;
440
+ if (typeof wave !== 'number' || !Number.isInteger(wave) || wave <= 0) return null;
441
+ try {
442
+ return readFileSync(path.join(stateDir, 'filescopes', `wave-${wave}.scopes.json`), 'utf8');
443
+ } catch {
444
+ return null;
445
+ }
446
+ }
447
+
448
+ /**
449
+ * Bind the aggregate sidecar's content to the manifest it was written for
450
+ * (#1504 point 5). The sidecar carried no integrity anchor: a `peer-session-*`
451
+ * record appended AFTER dispatch downgraded matching violations to a peer-write
452
+ * notice, because the control hash covers only `wave-scope.json`.
453
+ *
454
+ * The anchor is the trusted snapshot's `scopeState` — the one record this hook
455
+ * already treats as control state. The coordinator's order is materialize →
456
+ * assert-disjoint → union → write manifest, so the sidecar is final by the time
457
+ * the manifest is; the hash is therefore (re)bound whenever the MANIFEST hash
458
+ * changes (or no binding exists yet), and must hold while the manifest stands.
459
+ * No coordinator step and no new field: a materializer-written digest would sit
460
+ * in a file the same actor can rewrite, which binds nothing.
461
+ *
462
+ * manifest changed / no prior binding → bind the current hash, honour records
463
+ * same manifest, same sidecar hash → honour records
464
+ * same manifest, sidecar now absent → nothing to honour; keep the binding so
465
+ * a re-created sidecar still mismatches
466
+ * same manifest, DIFFERENT sidecar → `tampered`: honour NO peer record
467
+ * (fail toward reporting) and keep the
468
+ * ORIGINAL binding until the manifest is
469
+ * rewritten; the notice fires ONCE per
470
+ * mismatching sidecar hash (`notify`)
471
+ *
472
+ * Once, not per call: a legitimate mid-wave Peer-Scope-Union rewrites the sidecar
473
+ * while `--union` (which excludes peer records) leaves the manifest bytes
474
+ * unchanged, so `tampered` holds for the rest of the wave. Repeating the ⚠ on
475
+ * every Bash call would be an always-on signal (HR-101). The marker
476
+ * `sidecarNotified` is persisted in the trusted snapshot by the caller only on
477
+ * the path that emits; a further change of the sidecar (a new hash) notifies again.
478
+ *
479
+ * A snapshot written before this field existed has no `sidecarHash` and binds
480
+ * silently on its first call — the same one-time adoption the control hash had.
481
+ *
482
+ * NAMED CEILING: a sidecar changed BEFORE this hook first observes the manifest
483
+ * (no trusted snapshot yet) is bound as found; so is one rewritten together with
484
+ * the manifest (that path already raises the control-file notice). A forged
485
+ * COMPLETE snapshot record is believed — `trustedScopeState`'s residual.
486
+ *
487
+ * @param {{ hash: string, sidecarHash?: unknown }|null} prevScopeState trusted prior state
488
+ * @param {string} manifestHash this call's manifest hash
489
+ * @param {string} sidecarHash this call's sidecar hash, or 'absent'
490
+ * @returns {{ sidecarHash: string, tampered: boolean, notify: boolean, sidecarNotified?: string }}
491
+ * the hash to persist, the verdict, whether to emit the mismatch notice, and
492
+ * (when tampered) the marker to persist once it has been emitted
493
+ */
494
+ export function bindPeerSidecar(prevScopeState, manifestHash, sidecarHash) {
495
+ const bound = prevScopeState?.sidecarHash;
496
+ if (!prevScopeState || prevScopeState.hash !== manifestHash || typeof bound !== 'string') {
497
+ return { sidecarHash, tampered: false, notify: false };
498
+ }
499
+ if (bound === sidecarHash || sidecarHash === 'absent') {
500
+ return { sidecarHash: bound, tampered: false, notify: false };
501
+ }
502
+ return {
503
+ sidecarHash: bound,
504
+ tampered: true,
505
+ notify: prevScopeState.sidecarNotified !== sidecarHash,
506
+ sidecarNotified: sidecarHash,
507
+ };
508
+ }
509
+
510
+ /**
511
+ * Render the sidecar-mismatch notice (#1504 point 5) — an alarm (⚠), because the
512
+ * consequence is that agreed peer writes now report as violations.
513
+ *
514
+ * @param {string} relSidecarPath
515
+ * @returns {string}
516
+ */
517
+ export function formatSidecarMismatchNotice(relSidecarPath) {
518
+ return `bash-write-verify: ${relSidecarPath} changed since wave-scope.json was written — `
519
+ + 'its peer-session-* records are NOT honoured (writes under them report as violations) '
520
+ + 'until the manifest is rewritten (#1504 point 5).';
521
+ }
522
+
432
523
  /**
433
524
  * Which peer record — if any — declared this path?
434
525
  *
@@ -960,6 +1051,16 @@ async function main() {
960
1051
  : 'unparseable',
961
1052
  gateOn: scope ? scope?.gates?.['bash-write-verify'] !== false : true,
962
1053
  };
1054
+ // #1504 point 5 — bind the aggregate sidecar to this manifest (see
1055
+ // `bindPeerSidecar`). Part of `currentScopeState` so EVERY snapshot write
1056
+ // below, stand-down paths included, carries the binding forward.
1057
+ const sidecarRaw = readPeerSidecarRaw(path.dirname(scopePath), scope?.wave);
1058
+ const sidecarBinding = bindPeerSidecar(
1059
+ prevScopeState,
1060
+ currentScopeState.hash,
1061
+ sidecarRaw === null ? 'absent' : createHash('sha1').update(sidecarRaw).digest('hex').slice(0, 16),
1062
+ );
1063
+ currentScopeState.sidecarHash = sidecarBinding.sidecarHash;
963
1064
 
964
1065
  // G3b (#1153 P1) — is this manifest even MINE?
965
1066
  //
@@ -1153,6 +1254,9 @@ async function main() {
1153
1254
  },
1154
1255
  });
1155
1256
 
1257
+ // The mismatch notice below is emitted on this path only, so its once-marker
1258
+ // is persisted here and nowhere else (see `bindPeerSidecar`).
1259
+ if (sidecarBinding.tampered) currentScopeState.sidecarNotified = sidecarBinding.sidecarNotified;
1156
1260
  writeSnapshot(snapFile, { ...nextSnapshot, scopeState: currentScopeState });
1157
1261
 
1158
1262
  const messages = [];
@@ -1172,7 +1276,17 @@ async function main() {
1172
1276
  // reachable. Such a write is agreed, not a bypass. Split it out of the violation report and
1173
1277
  // name it, so the peer's file is countable without being an alarm. Sidecar
1174
1278
  // absent ⇒ `peerRecords` is empty ⇒ every path stays a violation, unchanged.
1175
- const peerRecords = readPeerScopeRecords(path.dirname(scopePath), scope.wave);
1279
+ // #1504 point 5 — a sidecar changed under a standing manifest is untrusted:
1280
+ // no peer record is honoured, and the mismatch is named.
1281
+ const peerRecords = sidecarBinding.tampered
1282
+ ? []
1283
+ : readPeerScopeRecords(path.dirname(scopePath), scope.wave, sidecarRaw);
1284
+ if (sidecarBinding.notify) {
1285
+ messages.push(formatSidecarMismatchNotice(
1286
+ path.relative(repoRoot, path.join(path.dirname(scopePath), 'filescopes', `wave-${scope.wave}.scopes.json`)),
1287
+ ));
1288
+ warn = true;
1289
+ }
1176
1290
  const violations = [];
1177
1291
  for (const relPath of report) {
1178
1292
  const peerId = peerRecordFor(relPath, peerRecords);
@@ -354,6 +354,13 @@ async function main() {
354
354
  ...(waveSignal !== null ? { wave_signal: waveSignal } : {}),
355
355
  };
356
356
 
357
+ // Two roots, by writer (#1511 point a). `current-session.json` and
358
+ // `session.lock` stay at the LAUNCH dir: on-session-start.mjs writes both at
359
+ // `resolveProjectDir()`, and a reader elsewhere finds no record. Everything
360
+ // about the WAVE — its manifest, its start sha, the files it changed — is read
361
+ // at the SESSION root: after EnterWorktree the launch dir is a different
362
+ // checkout, whose HEAD and working tree say nothing about this wave.
363
+ const sessionRoot = resolveSessionRoot(input?.cwd);
357
364
  const sessionFile = path.join(getProjectDir(), '.orchestrator', 'current-session.json');
358
365
 
359
366
  const lastBatchResult = await atomicMutateJson(sessionFile, {}, (current) => ({
@@ -462,7 +469,7 @@ async function main() {
462
469
  // last_wave_completed === last_wave and stays silent.
463
470
  try {
464
471
  const wave = await resolveWaveNumber(
465
- resolveSessionRoot(input?.cwd),
472
+ sessionRoot,
466
473
  new Set(readProcessLocalSessionIds({ hookInput: input })),
467
474
  );
468
475
  if (wave > 0) {
@@ -495,7 +502,7 @@ async function main() {
495
502
  if (lastWave > lastWaveCompleted) {
496
503
  // #980 — measure the closing wave's diff size BEFORE the new wave's
497
504
  // start sha is persisted below. Unmeasurable → both keys omitted.
498
- const filesChanged = countFilesChangedSince(getProjectDir(), waveStartSha);
505
+ const filesChanged = countFilesChangedSince(sessionRoot, waveStartSha);
499
506
  await emitEvent('orchestrator.wave.completed', {
500
507
  wave_number: lastWave,
501
508
  ...(filesChanged !== null
@@ -537,7 +544,7 @@ async function main() {
537
544
  // #980 — the OPEN half: stamp the sha this new wave starts from.
538
545
  // Written as null (not omitted) when git is unreadable, so the
539
546
  // PREVIOUS wave's sha can never linger and inflate the next count.
540
- const startSha = readHeadSha(getProjectDir());
547
+ const startSha = readHeadSha(sessionRoot);
541
548
  const markResult = await atomicMutateJson(sessionFile, {}, (current) => ({
542
549
  ...current,
543
550
  wave_start_sha: startSha,
@@ -593,7 +600,7 @@ async function main() {
593
600
  // Rooted at the SESSION root (#1492 point 2, #1504 point 4): the quality gate
594
601
  // registers its children in the ledger of the tree it runs in, which for an
595
602
  // entered worktree is the worktree — the launch dir holds none of them.
596
- await maybeTriggerOrphanScan({ projectDir: resolveSessionRoot(input?.cwd) });
603
+ await maybeTriggerOrphanScan({ projectDir: sessionRoot });
597
604
  }
598
605
 
599
606
  // Entry guard (#1393): run only as the node script the harness execs — a bare
@@ -29,7 +29,7 @@ import { readFile } from 'node:fs/promises';
29
29
  import os from 'node:os';
30
30
  import path from 'node:path';
31
31
  import { emitEvent } from '../scripts/lib/events.mjs';
32
- import { resolveSessionRoot } from '../scripts/lib/platform.mjs';
32
+ import { getProjectDir, resolveSessionRoot } from '../scripts/lib/platform.mjs';
33
33
  import { findOwnScopeFile } from '../scripts/lib/scope-gate.mjs';
34
34
  import {
35
35
  classifyManifestSession,
@@ -246,18 +246,23 @@ async function main() {
246
246
  if (!MEMORY_PROPOSE_REGEX.test(command)) return emitAllow();
247
247
 
248
248
  // Matched — proceed with audit logging (G4-G7)
249
- const projectDir = process.env.CLAUDE_PROJECT_DIR
250
- ?? process.env.CODEX_PROJECT_DIR
251
- ?? process.cwd();
249
+ // Two roots, by writer (#1511 point c). The session-id fallback reads
250
+ // `current-session.json` where on-session-start.mjs WRITES it — the launch dir,
251
+ // through the same resolver (`resolveProjectDir`; the hand-rolled
252
+ // `CLAUDE_PROJECT_DIR ?? CODEX_PROJECT_DIR ?? process.cwd()` kept a set-but-
253
+ // empty var, skipped the Cursor/Pi vars and never walked up). The wave and the
254
+ // recorded `cwd` describe the tree the session WORKS in: the SESSION root the
255
+ // scope guards use (#1504 point 4), which after EnterWorktree is not the
256
+ // launch dir.
257
+ const projectDir = getProjectDir();
258
+ const sessionRoot = resolveSessionRoot(input.cwd);
252
259
 
253
260
  // G4 — resolve session_id
254
261
  const sessionId = await resolveSessionId(input, projectDir);
255
262
 
256
263
  // G5 — resolve wave
257
- // Read at the SESSION root the scope guards use (#1504 point 4), not at the
258
- // launch dir above, which stays put while the session works in a worktree.
259
264
  const wave = await resolveWave(
260
- resolveSessionRoot(input.cwd),
265
+ sessionRoot,
261
266
  new Set(readProcessLocalSessionIds({ hookInput: input })),
262
267
  );
263
268
 
@@ -280,7 +285,7 @@ async function main() {
280
285
  command_hash: commandHash,
281
286
  flags_present: flags,
282
287
  argv_length: command.length,
283
- cwd: relativeCwd(process.cwd(), projectDir),
288
+ cwd: relativeCwd(process.cwd(), sessionRoot),
284
289
  exit_code: null,
285
290
  });
286
291
  } catch { /* telemetry never blocks the hook (#1183) */ }