session-orchestrator 5.4.0 → 5.5.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 (150) 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/rules/060-evolve.mdc +5 -5
  5. package/.cursor-plugin/plugin.json +1 -1
  6. package/CHANGELOG.md +95 -3
  7. package/README.md +11 -11
  8. package/docs/ci-setup.md +120 -23
  9. package/docs/components.md +7 -7
  10. package/docs/events-schema.md +17 -14
  11. package/docs/install.md +14 -2
  12. package/docs/migration-v4.md +24 -17
  13. package/docs/plugin-architecture-v3.md +1 -1
  14. package/docs/scope-collision-guard.md +15 -13
  15. package/docs/session-config-reference.md +7 -7
  16. package/docs/vault-docs-architecture.md +8 -4
  17. package/hooks/_lib/hook-import-set.json +17 -8
  18. package/hooks/_lib/lock-bootstrap.mjs +118 -26
  19. package/hooks/config-protection.mjs +1 -1
  20. package/hooks/enforce-commands.mjs +28 -13
  21. package/hooks/enforce-scope.mjs +201 -23
  22. package/hooks/hooks-codex.json +1 -1
  23. package/hooks/hooks.json +1 -1
  24. package/hooks/on-session-start.mjs +4 -2
  25. package/hooks/on-stop.mjs +70 -170
  26. package/hooks/post-bash-write-verify.mjs +98 -17
  27. package/hooks/post-edit-validate.mjs +15 -5
  28. package/hooks/post-tool-batch-wave-signal.mjs +32 -154
  29. package/hooks/pre-bash-memory-propose-audit.mjs +19 -7
  30. package/hooks/pre-bash-staging-fence.mjs +29 -8
  31. package/hooks/pre-task-scope-disjoint.mjs +584 -207
  32. package/hooks/wave-scope-commit-guard.mjs +168 -72
  33. package/package.json +2 -6
  34. package/scripts/apply-session-learnings.mjs +35 -14
  35. package/scripts/archive-closed-prds.mjs +38 -11
  36. package/scripts/autopilot.mjs +8 -1
  37. package/scripts/backfill-abandoned-sessions.mjs +80 -5
  38. package/scripts/backfill-learnings-from-vault.mjs +49 -22
  39. package/scripts/codex-install.mjs +3 -0
  40. package/scripts/cursor-install.mjs +46 -2
  41. package/scripts/emit-session.mjs +5 -3
  42. package/scripts/export-hw-learnings.mjs +18 -7
  43. package/scripts/fleet-checkin.mjs +26 -3
  44. package/scripts/lib/autopilot/loop.mjs +25 -10
  45. package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
  46. package/scripts/lib/blocked-commands-policy.mjs +40 -0
  47. package/scripts/lib/common.mjs +41 -0
  48. package/scripts/lib/config/vault-integration.mjs +151 -16
  49. package/scripts/lib/config.mjs +5 -1
  50. package/scripts/lib/eval/engine.mjs +13 -0
  51. package/scripts/lib/events-retention-banner.mjs +393 -0
  52. package/scripts/lib/events-rotation.mjs +21 -6
  53. package/scripts/lib/events.mjs +51 -3
  54. package/scripts/lib/fleet-protocol.mjs +20 -2
  55. package/scripts/lib/hardening.mjs +1 -0
  56. package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
  57. package/scripts/lib/instruction-budget-guard.mjs +8 -0
  58. package/scripts/lib/learnings/expiry-sweep.mjs +387 -20
  59. package/scripts/lib/learnings/io.mjs +125 -16
  60. package/scripts/lib/lock-reaper.mjs +38 -14
  61. package/scripts/lib/maintenance-due-banner.mjs +23 -8
  62. package/scripts/lib/named-vault-resolver.mjs +48 -5
  63. package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
  64. package/scripts/lib/orphan-reaper/reaper-audit.mjs +101 -69
  65. package/scripts/lib/orphan-reaper/scan-throttle.mjs +79 -27
  66. package/scripts/lib/orphan-reaper/scan.mjs +4 -0
  67. package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
  68. package/scripts/lib/orphan-reaper.mjs +5 -1
  69. package/scripts/lib/platform.mjs +206 -2
  70. package/scripts/lib/plugin-update-banner.mjs +3 -1
  71. package/scripts/lib/process-group.mjs +83 -22
  72. package/scripts/lib/scope-baseline.mjs +116 -25
  73. package/scripts/lib/scope-echo.mjs +7 -0
  74. package/scripts/lib/scope-gate.mjs +194 -27
  75. package/scripts/lib/session-close-backfill.mjs +129 -4
  76. package/scripts/lib/session-end/tail-runner.mjs +10 -5
  77. package/scripts/lib/session-lock-shape.mjs +6 -3
  78. package/scripts/lib/session-lock.mjs +320 -67
  79. package/scripts/lib/session-registry.mjs +41 -16
  80. package/scripts/lib/session-start-probes.mjs +242 -68
  81. package/scripts/lib/session-token-rollup.mjs +39 -34
  82. package/scripts/lib/sessions-staleness-banner.mjs +3 -1
  83. package/scripts/lib/tail-window.mjs +27 -3
  84. package/scripts/lib/telemetry/pricing.mjs +5 -2
  85. package/scripts/lib/telemetry/sync.mjs +17 -0
  86. package/scripts/lib/tests-src-ratio.mjs +77 -2
  87. package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
  88. package/scripts/lib/validate/check-test-value-bans.mjs +320 -32
  89. package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
  90. package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
  91. package/scripts/lib/vault-mirror/namespace.mjs +47 -26
  92. package/scripts/lib/vault-mirror/process.mjs +350 -62
  93. package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
  94. package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
  95. package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
  96. package/scripts/lib/vault-mirror/telemetry.mjs +18 -11
  97. package/scripts/lib/vault-status/board-writer.mjs +5 -1
  98. package/scripts/lib/vcs-repo-spec.mjs +8 -0
  99. package/scripts/materialize-wave-scope.mjs +7 -4
  100. package/scripts/release.mjs +1 -0
  101. package/scripts/run-migrate-v2-cross-repo.mjs +14 -2
  102. package/scripts/run-quality-gate.mjs +8 -5
  103. package/scripts/self-update.mjs +229 -0
  104. package/scripts/sweep-expired-learnings.mjs +560 -28
  105. package/scripts/validate-wave-scope.mjs +21 -2
  106. package/scripts/vault-backfill.mjs +50 -37
  107. package/scripts/vault-mirror.mjs +170 -33
  108. package/skills/_shared/fleet-protocol.md +74 -71
  109. package/skills/_shared/state-ownership.md +38 -14
  110. package/skills/autopilot/SKILL.md +1 -1
  111. package/skills/bootstrap/SKILL.md +5 -3
  112. package/skills/bootstrap/_shared-template.md +96 -1
  113. package/skills/bootstrap/fast-template.md +3 -0
  114. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
  115. package/skills/bootstrap/standard-template.md +2 -0
  116. package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
  117. package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
  118. package/skills/discovery/probes/vault-staleness.mjs +9 -3
  119. package/skills/docs-orchestrator/SKILL.md +12 -5
  120. package/skills/docs-orchestrator/audience-mapping.md +5 -3
  121. package/skills/evolve/SKILL.md +11 -2
  122. package/skills/evolve/references/evolve-analyze-mode.md +37 -17
  123. package/skills/session-end/learning-patterns.md +3 -3
  124. package/skills/session-end/metrics-collection.md +1 -1
  125. package/skills/session-end/phase-3-2-docs-verification.md +7 -6
  126. package/skills/session-end/plan-verification.md +25 -9
  127. package/skills/session-end/references/phase-3-documentation-updates.md +11 -1
  128. package/skills/session-end/session-metrics-write.md +45 -10
  129. package/skills/session-start/SKILL.md +3 -3
  130. package/skills/session-start/references/operations-contract.md +5 -3
  131. package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
  132. package/skills/session-start/references/phase-4-ssot-environment-check.md +8 -1
  133. package/skills/vault-mirror/SKILL.md +21 -11
  134. package/skills/wave-executor/SKILL.md +1 -1
  135. package/skills/wave-executor/circuit-breaker.md +1 -1
  136. package/skills/wave-executor/references/wave-executor-state-init.md +1 -1
  137. package/skills/wave-executor/references/wave-loop-dispatch.md +8 -0
  138. package/skills/wave-executor/references/wave-loop-review.md +2 -2
  139. package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
  140. package/.agents/skills/navigator/SKILL.md +0 -22
  141. package/.agents/skills/navigator/agents/openai.yaml +0 -5
  142. package/.codex-plugin/skills/navigator/SKILL.md +0 -22
  143. package/.codex-plugin/skills/navigator/agents/openai.yaml +0 -5
  144. package/.cursor/commands/navigator.md +0 -15
  145. package/.cursor/skills/navigator/SKILL.md +0 -13
  146. package/pi/prompts/navigator.md +0 -12
  147. package/skills/navigator/SKILL.md +0 -193
  148. package/skills/navigator/references/account-check.sh +0 -87
  149. package/skills/navigator/references/fleet-decisions.mjs +0 -231
  150. package/skills/navigator/references/slot-check.sh +0 -114
@@ -10,7 +10,7 @@
10
10
 
11
11
  Acquire a distributed session-lock to detect parallel sessions in the same repo before initializing STATE.md. This prevents two concurrent Claude/Codex sessions from stomping each other's wave state and metrics writes.
12
12
 
13
- **Mechanical wiring (Epic #583, 2026-05-27):** The SessionStart hook (`hooks/on-session-start.mjs` → `hooks/_lib/lock-bootstrap.mjs`) now writes `.orchestrator/session.lock` mechanically BEFORE this skill's prose runs. The prose Phase 1.2 becomes confirmatory — it verifies the lock exists with the expected shape via `readLock({ repoRoot: process.cwd() })`. Re-call `acquire()` only if `readLock()` returns `null` (mechanical hook failed) OR the existing lock's raw `session_id` does not exactly match the current session's raw id (a rare divergence — surface via AUQ before overwriting). A matching `semantic_session_id`, STATE.md `session`, or owner proof cannot repair that mismatch. The decision flow below still applies to all three outcomes (active / stale / fs-error) when the prose path needs to acquire.
13
+ **Mechanical wiring (Epic #583, 2026-05-27):** The SessionStart hook (`hooks/on-session-start.mjs` → `hooks/_lib/lock-bootstrap.mjs`) now writes `.orchestrator/session.lock` mechanically BEFORE this skill's prose runs. The prose Phase 1.2 becomes confirmatory — it verifies the lock exists with the expected shape via `readLock({ repoRoot: process.cwd() })`. Re-call `acquire()` only if `readLock()` returns `null` (mechanical hook failed) OR the existing lock's raw `session_id` does not exactly match the current session's raw id (a rare divergence — surface via AUQ before overwriting). A matching `semantic_session_id`, STATE.md `session`, or owner proof cannot repair that mismatch. The decision flow below still applies to all five outcomes (active / stale / corrupt / unreadable / fs-error) when the prose path needs to acquire.
14
14
 
15
15
  ```javascript
16
16
  import { acquire, forceAcquire } from 'scripts/lib/session-lock.mjs';
@@ -25,11 +25,15 @@ Where `sessionId` is the physical raw identity for this invocation: the native h
25
25
 
26
26
  2. **`result.ok === false`** with `reason === 'active'**:
27
27
  - Another Claude/Codex session holds an active lock in this repo.
28
+ - `existingLock` is `null` only when a lost create race found the winner's lock already gone — call `acquire()` once more instead of prompting.
28
29
  - Present a choice via `AskUserQuestion`:
29
30
  ```js
31
+ // `ageHours` comes off the acquire() result (age from started_at, #1494) and can be
32
+ // null when started_at is unparseable — render "start time unreadable" then.
33
+ const started = ageHours === null ? 'start time unreadable' : `started ${ageHours.toFixed(1)}h ago`;
30
34
  AskUserQuestion({
31
35
  questions: [{
32
- question: `Another session holds the lock here — started ${ageHours}h ago, mode=${existingLock.mode}, host=${existingLock.host}, pid=${existingLock.pid}. Wait, or take the lock?`,
36
+ question: `Another session holds the lock here — ${started}, mode=${existingLock.mode}, host=${existingLock.host}, pid=${existingLock.pid}. Wait, or take the lock?`,
33
37
  header: "Session lock",
34
38
  multiSelect: false,
35
39
  options: [
@@ -63,13 +67,25 @@ Where `sessionId` is the physical raw identity for this invocation: the native h
63
67
  // hostnamesMatch('', …) is false and this machine reads its own lock as
64
68
  // cross-host. Production uses `lockHostCandidate()` from host-identity.mjs.
65
69
  const sameHost = hostnamesMatch(existingLock.host_id || existingLock.host, os.hostname());
70
+ // Both figures can be `null`, and a template literal renders null as "null" while
71
+ // Math.round(null) is 0 — "0 minutes ago" for a lock that NEVER heartbeat. A lock
72
+ // without `last_heartbeat` is stale because nothing heartbeats it (#595 sunset), not
73
+ // because its ttl ran out, so the ttl wording branches with it.
74
+ const started = ageHours === null ? 'start time unreadable' : `started ${ageHours.toFixed(1)}h ago`;
75
+ // A NEGATIVE heartbeatAgeMinutes is a heartbeat stamped more than 5 minutes in the
76
+ // future (hand edit, clock stepped back) — not live since #1494, and not "-N minutes ago".
77
+ const heartbeat = heartbeatAgeMinutes === null
78
+ ? 'last heartbeat: never (lock has no last_heartbeat), so nothing has kept it alive'
79
+ : heartbeatAgeMinutes < 0
80
+ ? `its last heartbeat is stamped ${Math.round(-heartbeatAgeMinutes)} minutes in the future, so nothing proves it alive`
81
+ : `its ttl=${existingLock.ttl_hours}h has expired, and its last heartbeat was ${Math.round(heartbeatAgeMinutes)} minutes ago`;
66
82
  AskUserQuestion({
67
83
  questions: [{
68
- question: `A stale session lock is in the way — started ${ageHours}h ago on host=${existingLock.host}${sameHost ? '' : ' (another machine)'}, its ttl=${existingLock.ttl_hours}h has expired, and its last heartbeat was ${Math.round(heartbeatAgeMinutes)} minutes ago. Reclaim it?`,
84
+ question: `A stale session lock is in the way — ${started} on host=${existingLock.host}${sameHost ? '' : ' (another machine)'}, ${heartbeat}. Reclaim it?`,
69
85
  header: "Stale lock",
70
86
  multiSelect: false,
71
87
  options: [
72
- { label: "Reclaim (Recommended)", description: "Overwrites the stale lock and continues, because its time-to-live has run out. When that process is really dead, nothing of the old session is lost." },
88
+ { label: "Reclaim (Recommended)", description: "Overwrites the stale lock and continues, because nothing has refreshed its heartbeat within its time-to-live. When that process is really dead, nothing of the old session is lost." },
73
89
  { label: "Abort — investigate manually", description: "Stops here and writes nothing. The lock file `.orchestrator/session.lock` (it names the process that wrote it) tells you whether that session is still alive." },
74
90
  ],
75
91
  }],
@@ -78,15 +94,24 @@ Where `sessionId` is the physical raw identity for this invocation: the native h
78
94
  - **Codex CLI / Cursor IDE fallback (numbered Markdown list):**
79
95
  ```
80
96
  A stale session lock is in the way — started <ageHours>h ago on <host>, ttl=<ttlHours>h expired, last heartbeat <heartbeatAgeMinutes> minutes ago. Reclaim it?
81
- 1. Reclaim (Recommended) — overwrites the stale lock and continues, because its time-to-live has run out and that process is no longer holding anything.
97
+ (heartbeatAgeMinutes null → replace the ttl/heartbeat clause with "last heartbeat: never (lock has no last_heartbeat)"; negative → "last heartbeat stamped <N> minutes in the future"; ageHours null → "start time unreadable")
98
+ 1. Reclaim (Recommended) — overwrites the stale lock and continues, because nothing has refreshed its heartbeat within its time-to-live.
82
99
  2. Abort — stops here and writes nothing. The lock file `.orchestrator/session.lock` (it names the process that wrote it) tells you whether that session is still alive.
83
100
  Reply with the number of your choice.
84
101
  ```
85
- - On **Reclaim**: call `forceAcquire({ sessionId, mode: sessionType, ttlHours: 4, repoRoot: process.cwd() })`. After Phase 1.5 initializes STATE.md, append a deviation:
102
+ - On **Reclaim**: call `forceAcquire({ sessionId, mode: sessionType, ttlHours: 4, repoRoot: process.cwd(), onlyIfNotLive: true })` — the single-winner takeover (#1494): if another session reclaimed the lock in the meantime, it returns that session's `active` result instead of overwriting it; follow the decision flow for the returned result. After Phase 1.5 initializes STATE.md, append a deviation:
86
103
  `Stale-lock reclaim: replaced lock from session_id=<existingLock.session_id>, age=<ageHours>h, pid=<existingLock.pid>`. Continue.
87
104
  - On **Abort**: exit cleanly.
88
105
 
89
- 4. **`result.ok === false`** with `reason === 'fs-error'**:
106
+ 4. **`result.ok === false`** with `reason === 'corrupt'`:
107
+ - `.orchestrator/session.lock` exists but is not a lock record (invalid JSON, or JSON without the lock shape). It can never be a live lock: every writer writes it atomically (tmp+link / tmp+rename), so no reader sees a half-written file. The SessionStart hook already reclaims this case mechanically and prints one stderr WARN; reaching it here means the hook did not run or its reclaim failed.
108
+ - No AUQ — the answer is derivable (AUQ-001.2). Log `⚠ session-lock: .orchestrator/session.lock was not a valid lock record — reclaiming.`, call `forceAcquire({ sessionId, mode: sessionType, ttlHours: 4, repoRoot: process.cwd(), onlyIfNotLive: true })` (a non-ok result means another session got there first — follow the decision flow for it), and after Phase 1.5 append a deviation: `Corrupt-lock reclaim: replaced an unparseable .orchestrator/session.lock`. Continue.
109
+
110
+ 5. **`result.ok === false`** with `reason === 'unreadable'`:
111
+ - `.orchestrator/session.lock` exists but cannot be read (`result.error`, e.g. `EACCES`, or `EISDIR` for a directory at the path). Nothing was written, and it is never reclaimed: no lock writer produces such a path, and a directory may hold content nobody here created (PSA-003). The SessionStart hook already printed a stderr WARN and emitted `orchestrator.session.lock.read_anomaly` (`status: 'unreadable'`).
112
+ - No AUQ — the operator has to inspect the path; nothing the session could pick fixes it (AUQ-001.2). Log `⚠ session-lock: .orchestrator/session.lock exists but cannot be read (<error>). Continuing without lock (degraded mode) — inspect or remove it by hand.`, proceed without a lock, and after Phase 1.5 append a deviation: `Session lock unreadable (<error>) — ran without a lock`.
113
+
114
+ 6. **`result.ok === false`** with `reason === 'fs-error'**:
90
115
  - Filesystem error when writing the lock file. Log `⚠ session-lock: acquire failed — <error>. Continuing without lock (degraded mode).` and proceed without a lock. Do NOT block the session for a transient FS error.
91
116
 
92
117
  > **New reasons from P1.2 #570:** When called with the optional `activeSessions` argument, `acquire()` can also return `active-incompatible-exclusive`, `active-compatible-parallel`, or `active-readonly-bypass`. Session-start invokes `acquire()` WITHOUT `activeSessions` (the preamble in Phase 0.5 already handled cross-worktree detection); these new reasons surface only in callers that bypass the preamble. Other entry-points (autopilot, session-plan, wave-executor, session-end) follow the same pattern.
@@ -13,7 +13,7 @@
13
13
  3. **Pencil design status**: if `pencil` is configured, verify the `.pen` file exists at the configured path. Report: "Pencil design configured at [path] — design-code alignment reviews will run after Impl-Core and Impl-Polish waves." If file not found, warn: "Pencil path configured but file not found at [path]."
14
14
  4. **Plugin freshness**: Determine the session-orchestrator plugin directory (navigate up from this skill's base directory to the plugin root). Run `git -C <plugin-dir> log -1 --format="%ci"` to get the last commit date. If older than `plugin-freshness-days` (default: 30) days, flag a warning in the Session Overview: `"⚠ Session Orchestrator plugin last updated [N] days ago — consider pulling the latest version."` Non-blocking — present in overview, don't halt.
15
15
 
16
- **The 19-probe banner family runs ONCE, mechanically — never invoke a probe below by hand (#1351).** `hooks/on-session-start.mjs` (the `SO_DISABLE_STARTUP_PROBES` block) imports `runSessionStartProbes()` from `scripts/lib/session-start-probes.mjs` on every SessionStart, and the `PROBES` registry in that module is the single declaration site for each probe's module path, entry function, arguments, precondition and network flag. The paragraphs below are the RENDERING reference — return contract and exact banner wording per probe — not call instructions: a hand-invocation runs the same measurement a second time at full cost (`project-hygiene` alone is a `git status --ignored` plus a batched `du` over every ignored path — seconds, not milliseconds, under load; see its own paragraph below for the measurement and the host conditions it was taken under). The runner returns `bannerLines`; the hook pushes them into the ONE `systemMessage` envelope (HR-106). Read those lines and present them in the Session Overview.
16
+ **The 20-probe banner family runs ONCE, mechanically — never invoke a probe below by hand (#1351).** `hooks/on-session-start.mjs` (the `SO_DISABLE_STARTUP_PROBES` block) imports `runSessionStartProbes()` from `scripts/lib/session-start-probes.mjs` on every SessionStart, and the `PROBES` registry in that module is the single declaration site for each probe's module path, entry function, arguments, precondition and network flag. The paragraphs below are the RENDERING reference — return contract and exact banner wording per probe — not call instructions: a hand-invocation runs the same measurement a second time at full cost (`project-hygiene` alone is a `git status --ignored` plus a batched `du` over every ignored path — seconds, not milliseconds, under load; see its own paragraph below for the measurement and the host conditions it was taken under). The runner returns `bannerLines`; the hook pushes them into the ONE `systemMessage` envelope (HR-106). Read those lines and present them in the Session Overview.
17
17
 
18
18
  The bootstrap-lock-freshness probe (`scripts/lib/bootstrap-lock-freshness.mjs`, `checkBootstrapLockFreshness`) runs in the Phase 4 banner family as `id: 'bootstrap-lock-freshness'` in `scripts/lib/session-start-probes.mjs` `PROBES`, preconditioned on `.orchestrator/bootstrap.lock` existing and called with `currentPluginVersion` read from the plugin's `package.json` so version comparison is live. It checks lock age and plugin-version drift. When severity is `warn` or `alert`, render an additional banner alongside the plugin-freshness warning. The remediation is **reason-aware** (`result.details.reason`, #57) — a present-but-stale lock is never told to re-run `--retroactive` (idempotent no-op once `version`/`tier` already parse; see the Retroactive Flow's idempotency guard in `skills/bootstrap/SKILL.md`):
19
19
  - **warn, `reason` = `stale-age` or `unparseable-timestamp`** (age 30–89d, or timestamp missing/unparseable but not yet ≥90d): `"⚠ bootstrap.lock: age=<N>d, plugin-version=<lock-ver> (current=<plugin-ver>) — run /bootstrap --refresh-lock to acknowledge and reset the freshness clock."`
@@ -176,5 +176,12 @@
176
176
 
177
177
  Non-blocking. Cross-reference: `scripts/lib/vcs-repo-spec.mjs` (`isQueryFailure` — the same absence-vs-query-failure split this probe's `degraded` state implements).
178
178
 
179
+ The events-retention probe (`scripts/lib/events-retention-banner.mjs`, `checkEventsRetention`) runs in the Phase 4 banner family as `id: 'events-retention'` in `scripts/lib/session-start-probes.mjs` `PROBES` (#1401). `events-rotation.max-backups` bounds how MANY archives `_archive/` keeps; the readers of the rotated ledger need a TIME window, declared as `REQUIRED_EVENTS_WINDOW_DAYS` beside every reader call — `readEventsWithRotations(`, `scanEventsBackwards(` and `listEventSourcesNewestFirst(`, seven readers censused from source by `tests/lib/events-retention-banner.test.mjs` (`telemetry/sync.mjs` 1 day — a chosen ceiling, the assumed maximum age of the latest `orchestrator.session.started` when a flush reads it; `eval/engine.mjs`, `tmux-layout/telemetry-stats.mjs`, `maintenance-due-banner.mjs`, `instruction-budget-guard.mjs`, `backfill-abandoned-sessions.mjs` and `session-close-backfill.mjs` `null` — no fixed window: their questions span the whole history, and the backward scan reports a pruned history as `gaps`, so maintenance-due answers `undeterminable` instead of "never", #1489). The probe compares the span from the oldest retained archive's first timestamp to now against the largest finite declaration, and only once the archive ring is FULL (`archives >= max-backups`) — below that the rotator prunes nothing under the current config, so the ledger only grows and the probe stays silent. On a full ring the archives alone are judged: the rotator never prunes a legacy `events.jsonl.N`, so its older stamp would span the hole a pruned archive left (#1489 review). "Not full" is not "never pruned": after `max-backups` is raised, archives pruned under the old ring stay gone — the oldest retained archive's rotation record names a predecessor (`archived_as`) that no longer exists — and the silent coverage figure then starts at the oldest archive as on a full ring. The hole goes unseen only when the oldest archive's head holds no rotation record — the first archive a ledger ever wrote, before which no archive can have been pruned, or a later one whose tombstone was never written (`recordWritten: false`); measured 2026-10-02 over the 5 fleet `_archive/` dirs: 4 of 4 first archives without one, 2 of 2 later archives with it on line 0 (#1498). No Session Config key of its own. **Three states:** no live ledger and no archive in `_archive/` (absent, empty, or holding only names outside `ARCHIVE_NAME_RE`) → registry precondition, recorded `skipped: no-events-ledger` (never `ran-clean`); `_archive/` unlistable, a reader's declaration unreadable, the oldest retained stamp dated after now (a future-dated archive or a clock set back — the span would be negative), or no stamp at all (no archive and no parseable `timestamp` in the head of `events.jsonl` or a legacy `events.jsonl.N`, #1498) → `{ severity: 'warn', degraded: true }`, never rendered as covered; measured → `{ severity: 'ok', kind: 'ring-not-full' | 'no-finite-window' | 'covered' }` or a warn, and every measured answer returns `coverageDays`, `oldestEventAt`, `requiredDays` / `requiredBy`, `archives` and `maxBackups` to its caller even when silent. Of these, the registry entry's `telemetry` persists `coverage_days`, `archives`, `max_backups` and `required_days` as `measure` on this probe's element of `orchestrator.probes.completed` — on the silent answers too, which the runner records as `ran-clean` — so whether the warning could have fired is answerable from the ledger (HR-105); `oldestEventAt` and `requiredBy` are not persisted. A degraded answer persists no `measure`. Render `result.message`:
180
+ - **warn** (full ring covers less than the required window): `"⚠ events-retention: the rotated events ledger covers <X>d (<N> archives at events-rotation.max-backups: <M>), less than the <D>d window <reader> declares as REQUIRED_EVENTS_WINDOW_DAYS — events older than <X>d are not retained; raise events-rotation.max-backups or max-size-mb."`
181
+ - **degraded, `_archive/` unlistable**: `"⚠ events-retention: could not list _archive/ (<code>) — retention span not measured, which is not the same as covered."`
182
+ - **degraded, a reader's declaration unreadable** (on every measured start, full ring or not — the reader modules are imported BEFORE the ring check, because `requiredDays` is part of the silent measurement; `<error>` is the import or validation error, cut to 120 characters): `"⚠ events-retention: required window not readable (<error>) — retention span not judged, which is not the same as covered."`
183
+ - **degraded, oldest retained stamp after now**: `"⚠ events-retention: the oldest retained event is dated <ISO timestamp>, after now — retention span not measured, which is not the same as covered."`
184
+ - **degraded, no stamp in any source** (no archive, and no parseable `timestamp` in the first 64 KiB of `events.jsonl` or a legacy `events.jsonl.N`, #1498): `"⚠ events-retention: no parseable timestamp in the first 64 KiB of events.jsonl (or of a legacy events.jsonl.N) and no _archive/ archive — retention span not measured, which is not the same as covered."`
185
+
179
186
  All banners are non-blocking — display in the Session Overview, do not halt the session. If `bootstrap-lock-freshness.mjs` is absent (pre-#186 plugin install) or `peer-cards/staleness-banner.mjs` is absent (pre-#503 plugin install) or `loop-readiness-banner.mjs` is absent (pre-#633 plugin install) or `instruction-budget-guard.mjs` is absent (pre-#687 plugin install) or `maintenance-due-banner.mjs` / `reconcile-nudge-banner.mjs` are absent (pre-#723 plugin install) or `sessions-staleness-banner.mjs` is absent (pre-#724 plugin install) or `sessions-integrity-banner.mjs` is absent (pre-#958 plugin install) or `owner-config-banner.mjs` is absent (pre-#820 plugin install) or `moc-staleness-banner.mjs` / `context-coverage-banner.mjs` are absent (pre-#831 plugin install) or `claude-md-budget-lint.mjs` is absent (pre-#878 plugin install) or `mirror-issues-banner.mjs` is absent (pre-#1022 plugin install), skip silently.
180
187
 
@@ -32,20 +32,21 @@ Both call sites are conditional: vault-mirror runs only when `vault-integration.
32
32
 
33
33
  ## Inputs
34
34
 
35
- `scripts/vault-mirror.mjs` is the implementation. All arguments are required except `--dry-run`.
35
+ `scripts/vault-mirror.mjs` is the implementation (call it by its plugin path, `"$PLUGIN_ROOT/scripts/vault-mirror.mjs"` — never `cd` into the plugin checkout to run it). `--vault-dir`, `--source` and `--kind` are required; `node "$PLUGIN_ROOT/scripts/vault-mirror.mjs" --help` lists every flag.
36
36
 
37
37
  | Flag | Type | Required | Description |
38
38
  |---|---|---|---|
39
39
  | `--vault-dir` | path | yes | Absolute path to the Meta-Vault root directory. Must exist. |
40
- | `--source` | path | yes | Path to the JSONL file to read (one JSON object per line). Must exist. |
40
+ | `--source` | path | yes | Path to the JSONL file to read (one JSON object per line). Must exist. Pass it absolute — a relative path resolves against the cwd. |
41
41
  | `--kind` | `session` or `learning` | yes | Determines which generator and target path are used. |
42
+ | `--repo-root` | path | no | The repo whose ledger `--source` is (#1503). Its `.vault.yaml` slug / git remote decides the vault namespace. Default: the project holding `--source` (`<root>/.orchestrator/metrics/<file>` → `<root>`, so a monorepo package with its own `.orchestrator/` keeps its own namespace), else the git repo containing it, else the cwd (only for a loose source outside any git repo, with a stderr `WARN`). Callers pass `--repo-root "$PWD"` from the repo root. |
42
43
  | `--dry-run` | flag | no | Parse and resolve paths but do not write any files. Emits action lines as normal. |
43
44
 
44
45
  Empty lines in the JSONL source are silently skipped.
45
46
 
46
47
  ## Outputs
47
48
 
48
- One JSON line is written to stdout for each non-empty JSONL entry processed. Exit code reflects the run outcome.
49
+ One JSON line is written to stdout for each non-empty JSONL entry processed. Exit code reflects the run outcome. Diagnostics (`SKIP …`, `WARN …`, refusals) go to stderr and are NOT JSON — a caller that captures `2>&1` must parse line by line (`jq -R -r 'fromjson? | objects | …'`), or one `SKIP` line ends the parse and drops every action after it.
49
50
 
50
51
  ### Action values
51
52
 
@@ -56,6 +57,7 @@ One JSON line is written to stdout for each non-empty JSONL entry processed. Exi
56
57
  | `skipped-noop` | Entry existed, same id, `updated` date not advanced; file unchanged. |
57
58
  | `skipped-handwritten` | A file at the target path has no `_generator` marker (or an unknown generator); left untouched. |
58
59
  | `skipped-collision-resolved` | A file at the target path has the generator marker but a different `id`; a disambiguated slug was used instead. |
60
+ | `skipped-foreign-owner` | A generator note at the target path was rendered from another record (#1503), and this write must not replace it. **Sessions:** its `source-record` differs from this write and occurs nowhere in this `--source` ledger (`reason: "source-record mismatch"`), or its `id` names a different session (`reason: "id mismatch"`). **Learnings:** only for the disambiguated `<slug>-<uuid8>.md` file — a mismatch on the main `<slug>.md` is routed to disambiguation instead (`skipped-collision-resolved`, see Idempotency rule 6). One `SKIP foreign owner` line goes to stderr; `--force` does not bypass it. A leak in the foreign note is masked in place — owner, content and `source-record` kept — and the line carries `healed_leak: true`. A note without `source-record` (written before #1503) stays writable. |
59
61
  | `skipped-invalid` | Entry is missing one or more required fields; entry skipped, processing continues. |
60
62
  | `skipped-quality-low` | Entry failed the quality gate (PRD F1.2): learning `confidence` below `vault-mirror.quality.min-confidence` (CLI: `--quality-min-confidence`, default `0.5`), or session rendered-narrative length below `vault-mirror.quality.min-narrative-chars` (CLI: `--quality-min-narrative-chars`, default `400`). The emitted JSON line includes a `reason` field describing the violated threshold and `path: null` (no file was created). The quality gate runs **before** `--force`; `--force` does not bypass it. |
61
63
 
@@ -73,7 +75,7 @@ One JSON line is written to stdout for each non-empty JSONL entry processed. Exi
73
75
  |---|---|
74
76
  | `0` | Success (including idempotent no-ops and per-entry skips). |
75
77
  | `1` | Malformed JSON on a JSONL line — fatal, processing stops. Also returned when required CLI args are missing. |
76
- | `2` | Filesystem error: `--vault-dir` not found, `--source` not found, or an unexpected write error. |
78
+ | `2` | Filesystem error or pre-loop refusal: `--vault-dir` not found, vault not canonical, `--source` not found, `--source` in a different repo than `--repo-root` (`source-repo-mismatch`, #1503), or an unexpected write error. |
77
79
 
78
80
  ## Target Paths
79
81
 
@@ -86,7 +88,7 @@ Subdirectories are created automatically with `mkdirSync({ recursive: true })` w
86
88
 
87
89
  The numeric prefix (`50-sessions/`, `40-learnings/`) follows the vault folder ordering convention so that sessions and learnings appear in the correct position in the vault tree relative to other note types.
88
90
 
89
- **Per-project namespacing (#660).** New writes are namespaced under a per-repo subdirectory `<repo>/`, so a single shared vault can hold notes from multiple projects without cross-repo slug/id collisions. `<repo>` is resolved by `resolveRepoNamespace()` (`scripts/lib/vault-mirror/namespace.mjs`): the optional `vault-integration.vault-name` Session Config key (CLI: `--vault-name`) when set, else the git-origin repo slug via `deriveRepo()`, sanitised to a single kebab segment. Owner-privacy leaks (personal home path / private project slug / personal name) are redacted to `redacted-repo` before any write. The legacy **flat** layout (`40-learnings/<slug>.md`) is still read — the writer dual-probes the flat path so a pre-existing flat note is never duplicated; a one-time relocation of the historical flat corpus is tracked as a follow-up.
91
+ **Per-project namespacing (#660).** New writes are namespaced under a per-repo subdirectory `<repo>/`, so a single shared vault can hold notes from multiple projects without cross-repo slug/id collisions. `<repo>` is resolved by `resolveRepoNamespace()` (`scripts/lib/vault-mirror/namespace.mjs`): the optional `vault-integration.vault-name` Session Config key (CLI: `--vault-name`) when set, else the repo root's `.vault.yaml` slug, else its preferred git remote via `deriveRepo()`, sanitised to a single kebab segment. The repo root is `--repo-root`, else the project holding `--source` (`<root>/.orchestrator/metrics/`), else the git repo containing it, else the cwd — never the cwd when the source belongs to a project (#1503: running from another repo's checkout once wrote one project's ledger over another project's notes). Owner-privacy leaks (personal home path / private project slug / personal name) are redacted to `redacted-repo` before any write. The legacy **flat** layout (`40-learnings/<slug>.md`) is still read — the writer dual-probes the flat path so a pre-existing flat note is never duplicated; a one-time relocation of the historical flat corpus is tracked as a follow-up.
90
92
 
91
93
  **Path contract note:** Issue #187 shipped the flat numeric-prefix layout (deferring the per-`<repo>` subfolder it sketched). Issue #660 adds that per-repo subfolder for write-isolation (above) while keeping the numeric-prefix ordering. If you need a different layout, file a new issue — do NOT silently change the script.
92
94
 
@@ -102,7 +104,8 @@ vault-mirror is safe to run multiple times against the same JSONL source:
102
104
  2. **File exists, `_generator` marker present, same id, `updated` date not advanced** → `skipped-noop`, file unchanged.
103
105
  3. **File exists, `_generator` marker present, same id, `updated` date advanced** → `updated`, file overwritten.
104
106
  4. **File exists, no `_generator` marker** → `skipped-handwritten`, file untouched. The absence of the marker means the file was written by a human and must not be overwritten automatically.
105
- 5. **File exists, `_generator` marker present, different id** → slug collision. A disambiguated slug is derived by appending `-<first-8-chars-of-entry-uuid>` (hyphens stripped from the UUID before taking the prefix). The original file is left unchanged; the new note is written at the disambiguated path with action `skipped-collision-resolved`.
107
+ 5. **File exists, `_generator` marker present, different id** → slug collision. A disambiguated slug is derived by appending `-<first-8-chars-of-entry-uuid>` (hyphens stripped from the UUID before taking the prefix). The original file is left unchanged; the new note is written at the disambiguated path with action `skipped-collision-resolved`. Sessions have no disambiguation: a different-id session note is `skipped-foreign-owner`.
108
+ 6. **File exists, `_generator` marker present, different record** (#1503). Every note records the record it was rendered from in `source-record` (learnings: the record `id`; sessions: `raw_session_id`, else `started_at`). Checked before rules 2–3, because an id match proves nothing — subjects and semantic session ids recur across repos, and an expired learning re-learned in the same repo gets a new id. **Learnings:** handled as the slug collision of rule 5 — the new record lands at `<slug>-<uuid8>.md` (`skipped-collision-resolved` with `reason: "source-record mismatch"`), the existing note is not overwritten. **Sessions:** `skipped-foreign-owner`, unless the note's key occurs on any line of this `--source` ledger (superseded duplicates included — a backfill stub replaced by its real record stays this repo's note). `source-repo` is deliberately not an owner signal: a note at the target path already sits in the writer's namespace folder, so a different `source-repo` only means the note was moved or the repo relabelled. **A leak in a foreign note is masked in place, never rewritten from this record:** the note keeps its owner, content and `source-record`, only the values the current masker knows become `[REDACTED]`, and this record is routed exactly as for any foreign note (the action carries `healed_leak: true`). If the masked frontmatter would no longer parse as YAML (a value that starts with the secret), the note is left unchanged and one stderr `WARN` names it. Rewriting it from this record would be the #1503 overwrite, triggered by a secret. Limitation: this cannot protect a note written before `source-record` existed; the repo-root resolution and the `source-repo-mismatch` refusal are what prevent the incident class.
106
109
 
107
110
  The generator marker value is `session-orchestrator-vault-mirror@1` and appears in the YAML frontmatter as `_generator: session-orchestrator-vault-mirror@1`.
108
111
 
@@ -127,31 +130,38 @@ Per `.claude/rules/development.md` Git Safety Protocol — *"Never skip hooks (`
127
130
  | Malformed JSON on a JSONL line | Error written to stderr, exit 1 (processing stops). |
128
131
  | `--vault-dir` not found | Error written to stderr, exit 2. |
129
132
  | `--source` file not found | Error written to stderr, exit 2. |
133
+ | `--source` lies in a different repo than `--repo-root` | Refusal written to stderr (both namespaces named), exit 2, nothing written; run event `aborted: "source-repo-mismatch"`. |
134
+ | Session note rendered from a record outside this ledger | `skipped-foreign-owner` emitted on stdout with a `reason`, `SKIP foreign owner` line on stderr, file left unchanged. |
135
+ | `--source` outside any git repo | One `WARN` line on stderr naming the namespace used (it comes from the cwd or an unverifiable `--repo-root`); the run proceeds. |
130
136
  | Hand-written file at target path | `skipped-handwritten` emitted on stdout, note written to stderr, file left unchanged. |
131
137
  | Unknown `_generator` value in existing file | Treated as hand-written: `skipped-handwritten`, file left unchanged. |
132
138
  | Unexpected filesystem write error | Error written to stderr, exit 2. |
133
139
 
134
140
  ## Examples
135
141
 
136
- Mirror the sessions.jsonl for the current project into a vault:
142
+ Mirror the sessions.jsonl for the current project into a vault — run from the project's repo root, call the script by its plugin path:
137
143
 
138
144
  ```bash
139
- node scripts/vault-mirror.mjs \
145
+ node "$PLUGIN_ROOT/scripts/vault-mirror.mjs" \
140
146
  --vault-dir ~/Projects/vault \
141
- --source .orchestrator/metrics/sessions.jsonl \
147
+ --repo-root "$PWD" \
148
+ --source "$PWD/.orchestrator/metrics/sessions.jsonl" \
142
149
  --kind session
143
150
  ```
144
151
 
145
152
  Dry-run a learnings mirror to preview actions without writing:
146
153
 
147
154
  ```bash
148
- node scripts/vault-mirror.mjs \
155
+ node "$PLUGIN_ROOT/scripts/vault-mirror.mjs" \
149
156
  --vault-dir ~/Projects/vault \
150
- --source .orchestrator/metrics/learnings.jsonl \
157
+ --repo-root "$PWD" \
158
+ --source "$PWD/.orchestrator/metrics/learnings.jsonl" \
151
159
  --kind learning \
152
160
  --dry-run
153
161
  ```
154
162
 
163
+ Never `cd` into the plugin checkout to run it: the namespace comes from the repo the ledger belongs to, and an explicit `--repo-root` that disagrees with `--source` is refused with exit 2 (`source-repo-mismatch`).
164
+
155
165
  ## Live State
156
166
 
157
167
  **Phase 1 shipped and actively running.** As of 2026-05-09, the skill has produced:
@@ -230,7 +230,7 @@ MUST prefix with `SO_WAVE_AGENT=1` — without it the CLI returns exit 3 `reject
230
230
 
231
231
  Exit code 0 = queued (the coordinator will present at session-end via AskUserQuestion); 1 = quota-exceeded; 2 = rejected-low-confidence (below floor 0.5); 3 = rejected-wrong-context (STATE.md not active OR SO_WAVE_AGENT != "1"); 4 = error (arg validation or internal).
232
232
 
233
- Use ONLY when you find a recurring pattern, anti-pattern, or constraint worth carrying into future sessions. The coordinator confirms each proposal before it lands in learnings.jsonl. Do NOT over-propose — quota is bounded per wave.
233
+ Use ONLY when you find a recurring pattern, anti-pattern, or constraint worth carrying into future sessions. The coordinator confirms each proposal before it lands in learnings.jsonl. Do NOT over-propose — the quota is shared by every agent of the wave: once the wave holds `quota-per-wave` proposals (default 5), the next one exits 1 whichever agent sends it.
234
234
 
235
235
  Analyzer-only learning types, including `autonomy-verdict`, are intentionally not valid here; those are emitted by `/evolve` after their analyzer-specific evidence gates pass.
236
236
  ```
@@ -97,7 +97,7 @@ The function never throws — it always returns a result object. Treat `skipped:
97
97
 
98
98
  Rationale: the verified learning `coordinator-over-worktree-on-shared-files` (confidence 0.75) shows that small waves on partitioned scopes merge cleaner when run in-place. Two consecutive deep-session regressions (2026-04-20 07:30, 09:00) were worktree base-ref staleness on ≤2-agent waves editing the same SKILL.md. Graduated default makes worktree the tool for parallelism, not the default tax on every wave.
99
99
 
100
- 1a. **After ANY commit made during this session, run the wave IN-PLACE** (omit `isolation`) regardless of what the agent-count table above resolves — the harness bases a new agent worktree on the SESSION-START commit and exposes no base-ref field, so a worktree dispatched after a mid-session commit hands the agent the OLD code and its test run is structurally red on top. The measurement, the fix-agent case and the base-verification one-liner are in `.claude/rules/review-and-adapter-contracts.md` § "Ein Review-Panel im frischen Worktree prueft den ALTEN Code"; `hooks/pre-task-scope-disjoint.mjs` warns on stderr when it sees this combination. In-place then auto-promotes `warn` → `strict` via step 2, which is the intended trade: the scope hook becomes the barrier the worktree no longer is.
100
+ 1a. **After ANY commit made during this session, run the wave IN-PLACE** (omit `isolation`) regardless of what the agent-count table above resolves. The harness picks the agent worktree's base — the `Agent` payload has no base-ref field: `origin/HEAD` (origin's default branch) unless `worktree.baseRef: "head"` is set, then the local HEAD; this repo sets `"head"` in `.claude/settings.json`. Under the default, a dispatch from a HEAD that origin's default branch lacks hands the agent the OLD code and its test run is structurally red on top; under either base, uncommitted work reaches no worktree. The measurement, the fix-agent case and the base-verification one-liner are in `.claude/rules/review-and-adapter-contracts.md` § "Ein Review-Panel im frischen Worktree prueft den ALTEN Code"; `hooks/pre-task-scope-disjoint.mjs` DENIES a worktree dispatch whose base it measures as missing commits of HEAD, naming the remedies, and allows + records one it cannot determine (#1485). In-place then auto-promotes `warn` → `strict` via step 2, which is the intended trade: the scope hook becomes the barrier the worktree no longer is.
101
101
 
102
102
  2. **Enforcement auto-promote (#194)**: Call `resolveEnforcement({ isolation, configEnforcement })` from the same module. When isolation resolves to `none` and the user has not explicitly set `configEnforcement: 'off'`, enforcement auto-promotes from `warn` → `strict`. Worktrees provide filesystem-level isolation; in-place dispatch relies on the scope hook as the only barrier — it must be hard, not informational. Write the resolved value into `wave-scope.json` `enforcement`.
103
103
 
@@ -52,7 +52,7 @@ node -e "import('./scripts/lib/state-md.mjs').then(m => console.log(JSON.stringi
52
52
 
53
53
  `session-id` is the raw/native harness id (`resolveSessionIds().session_id`), `session` the semantic label (`.semantic_session_id`). Both are OPTIONAL (`schema-version` stays `1`): when the lock yields `null`, OMIT the key entirely — never write a placeholder. Neither field grants lock or registry ownership; see `skills/_shared/state-ownership.md`.
54
54
 
55
- `session-start-ref` is the commit the session started on (`git rev-parse HEAD`, full SHA); `scripts/emit-session.mjs` derives the ledger's `session_start_ref` from it and, when the key is absent, falls back to the `head_sha` of this session's own `orchestrator.session.started` event (#1443). `hooks/pre-task-scope-disjoint.mjs` reads it too, for its warn-only #1413 stale-worktree check on `isolation: "worktree"` dispatches — and because this template takes HEAD at wave-executor init while the harness bases agent worktrees on the commit at session start, a commit landed in between makes that warning compare against the wrong ref. The third reader is `scripts/lib/scope-baseline.mjs` (`readBaseline()` reads the key into `sessionStartRef`, `computeDrift()` consumes it): with the key present it diffs `<ref>..HEAD` in the coordinator's own checkout; absent, it resolves a base itself (the preferred remote's default branch, else a local `main`/`master`; nothing resolvable skips with `no-baseline-ref`); a present-but-dead ref skips with `unresolvable-ref`. The worktree-base edge above does NOT apply there: the drift diff never touches an agent worktree (`execFileSync('git', ['diff', '--name-only', diffRange], { cwd })` with `cwd` = repoRoot), but the init-time edge does, in the mild direction: files changed by a commit landed between session start and wave-executor init are missing from the drift numerator, so the ratio under-reports rather than warns falsely.
55
+ `session-start-ref` is the commit the session started on (`git rev-parse HEAD`, full SHA); `scripts/emit-session.mjs` derives the ledger's `session_start_ref` from it and, when the key is absent, falls back to the `head_sha` of this session's own `orchestrator.session.started` event (#1443). `hooks/pre-task-scope-disjoint.mjs` no longer reads it: since #1485 its stale-worktree check on `isolation: "worktree"` dispatches compares HEAD against the base the harness actually branches from (`origin/HEAD` unless `worktree.baseRef: "head"`, then HEAD), and DENIES a measured mismatch. The other reader is `scripts/lib/scope-baseline.mjs` (`readBaseline()` reads the key into `sessionStartRef`, `computeDrift()` consumes it): with the key present its numerator is the union of the committed `<ref>..HEAD` diff, the working tree vs HEAD (staged + unstaged) and the untracked files (#1027 Pkt 2), all in the coordinator's own checkout; absent, it resolves a base itself (the preferred remote's default branch, else a local `main`/`master`; nothing resolvable skips with `no-baseline-ref`); a present-but-dead ref skips with `unresolvable-ref`, and so does any failed, oversized or timed-out numerator query. All three git queries run with `cwd` = repoRoot, so they never read an agent worktree's contents (the worktree directory itself is excluded from the untracked list). The ref bounds only the committed source, which gives the ratio two edges in opposite directions. Under-report: this template takes HEAD at wave-executor init rather than at session start, so files changed by a commit landed between session start and wave-executor init are missing from the committed source. Over-report: the working-tree and untracked sources are not tied to the ref at all, so files already dirty when the session started and a same-checkout peer session's files count as drift and can raise a false WARN. That second edge is a named ceiling in `computeDrift()`'s JSDoc; removing it needs a start-dirty snapshot persisted at freeze time, a storage change outside this template.
56
56
 
57
57
  Create the `<state-dir>` directory if needed (`mkdir -p <state-dir>`) before writing. This file is the persistent state record — other skills and resumed sessions read it.
58
58
 
@@ -427,6 +427,14 @@ The helper is stdlib-only and cross-platform. `mode=block` is recommended when t
427
427
 
428
428
  This is a downstream backstop: the underlying worktree merge-back strategy lives in the Claude Code harness and is outside this plugin's control. The correct fix (preserve untracked coordinator files during merge-back) must come upstream. Until then, this check is the only defense.
429
429
 
430
+ #### Worktree Base Check (#1485)
431
+
432
+ The harness, not this plugin, picks the commit an `isolation: "worktree"` agent branches from. Per the Claude Code docs (code.claude.com/docs/en/worktrees § "Choose the base branch", read 2026-10-02), subagent worktrees follow `worktree.baseRef`: `"fresh"` (the default) branches from `origin/HEAD`, `"head"` from the current local HEAD. On a feature branch with commits the default branch on origin lacks, `"fresh"` hands the agent OLD code — #1485 measured worktrees on `main`, six commits behind the branch HEAD.
433
+
434
+ - **Root cause, removed here:** this repo's `.claude/settings.json` sets `"worktree": {"baseRef": "head"}`. Uncommitted coordinator work still reaches no worktree under either base — a wave that builds on uncommitted predecessor results runs in-place.
435
+ - **Mechanical guard:** `hooks/pre-task-scope-disjoint.mjs` checks every `isolation: "worktree"` dispatch before it runs. It reads `worktree.baseRef` in the documented precedence order — `.claude/settings.local.json`, then `.claude/settings.json`, then user settings; the first file that sets it decides — at the settings root (`$CLAUDE_PROJECT_DIR`, else the git toplevel), not at a `cwd` the session has `cd`-ed into. When that selects `"fresh"`, it runs `git merge-base --is-ancestor HEAD origin/HEAD` in `cwd`'s repo; exit 1 (the worktree would miss commits) DENIES the dispatch, naming the deciding settings file and the remedies: re-dispatch in-place (omit `isolation`), set `baseRef: "head"`, or get HEAD into origin's default branch. A denied dispatch is not written to the scope ledger, so the in-place re-dispatch does not collide with it. Anything it cannot measure (no cached `origin/HEAD`, a shallow clone, a git error, or a would-be deny while `FETCH_HEAD` is older than 24 h or absent — the harness then fetches the default branch before branching, so the cached ref is not the real base: `skipped: 'stale-remote-ref'`) is allowed. Every worktree dispatch leaves one `orchestrator.wave_dispatch.worktree_base_checked` record carrying `decision` and `base_ref_source` (`docs/events-schema.md`).
436
+ - **Blind spots:** managed settings and the `--settings` flag outrank the files above and are not read — a `"head"` set only there makes the hook see `"fresh"` and can DENY wrongly (that deny names "the default" as its source, `base_ref_source: "default"`); re-dispatch in-place or mirror the value into `.claude/settings.local.json`. Two rarer divergences remain: a repo without an `origin/HEAD` symref (the harness falls back to `origin/main|master`, the hook skips), and a stale fetch whose harness refetch then fails offline.
437
+
430
438
  #### Pre-Dispatch Coordinator Snapshot (#196)
431
439
 
432
440
  Before dispatching agents for this wave, checkpoint any uncommitted coordinator work as a git stash snapshot. This is a backup — it does NOT touch the working tree and does NOT block dispatch on failure.
@@ -389,7 +389,7 @@ If the commit itself fails (e.g., nothing to commit, pre-commit hook rejects), d
389
389
  > **What is NOT on the event, and stays hand-written:** `suite_platform` — the payload has no platform field, so keep writing it from the § 3a header as before. Likewise, the auto-fix-loop producer (`scripts/lib/quality-gate.mjs`, active only under `verification-auto-fix.enabled: true`) emits `counts` WITHOUT `wave_number`, so its retry records correctly never match the selector above; they are mid-wave attempts, not the wave's verdict. If the wave's gate ran outside `run-quality-gate.mjs` entirely, no event exists — fall back to the gate output you read, and say so in the progress update. The reader side (`skills/session-end/metrics-collection.md` § 1.7) reads the event first and this hand-written trio second, so keep writing the trio: it is the compatibility path for those two cases and for sessions already in flight.
390
390
  Append this wave record to the session metrics `waves` array.
391
391
 
392
- 7a. **Scope drift tripwire (S2 — #896, warn-only)**: session-cumulative (since `session-start-ref`) and filtered through `DRIFT_EXCLUDE_PATTERNS`, so its `filesRatio` is NOT expected to agree with this wave's unfiltered `files_changed` from step 7. Call `computeDrift()` from the same `scripts/lib/scope-baseline.mjs` module as `wave-loop-dispatch.md` § 0a Scope Baseline Freeze. Never blocks — exit code stays 0 and the next wave is dispatched regardless of the result.
392
+ 7a. **Scope drift tripwire (S2 — #896, warn-only)**: session-cumulative and filtered through `DRIFT_EXCLUDE_PATTERNS`, so its `filesRatio` is NOT expected to agree with this wave's unfiltered `files_changed` from step 7. Only the committed part of its numerator starts at `session-start-ref`; the uncommitted and untracked parts read the checkout as it is now, so files already dirty at session start and a same-checkout peer's files count too (an over-report, named as a ceiling in `computeDrift()`'s JSDoc). Call `computeDrift()` from the same `scripts/lib/scope-baseline.mjs` module as `wave-loop-dispatch.md` § 0a Scope Baseline Freeze. Never blocks — exit code stays 0 and the next wave is dispatched regardless of the result.
393
393
 
394
394
  ```js
395
395
  import { computeDrift } from '$PLUGIN_ROOT/scripts/lib/scope-baseline.mjs';
@@ -403,7 +403,7 @@ If the commit itself fails (e.g., nothing to commit, pre-commit hook rejects), d
403
403
  }
404
404
  ```
405
405
 
406
- Include the WARN line verbatim in the wave progress update when `breached` is true — name `filesRatio`, `plannedFiles`, `actualFiles`, and the configured `threshold`, not merely the word "drift". `drift.skipped === true` (`no-state-md`, `unreadable-state-md`, `no-baseline`, `stale-baseline`, or `unresolvable-ref` — see `computeDrift()`'s JSDoc for the precedence order) is silent: no WARN, no progress-update line. `persistence: false` implies `no-state-md`, so this step degrades to a silent no-op in that mode without a separate gate check.
406
+ Include the WARN line verbatim in the wave progress update when `breached` is true — name `filesRatio`, `plannedFiles`, `actualFiles`, and the configured `threshold`, not merely the word "drift". `drift.skipped === true` (`no-state-md`, `unreadable-state-md`, `no-baseline`, `stale-baseline`, `no-baseline-ref`, or `unresolvable-ref` — see `computeDrift()`'s JSDoc for the precedence order) is silent: no WARN, no progress-update line. `persistence: false` implies `no-state-md`, so this step degrades to a silent no-op in that mode without a separate gate check.
407
407
 
408
408
  ### 3. Adapt Plan (if needed)
409
409
 
@@ -16,12 +16,12 @@ Before each wave dispatch:
16
16
  **Deriving `blockedCommands` (effective floor∪overlay policy, #155/#972):** Before writing `wave-scope.json`, derive the blocked patterns from the EFFECTIVE policy via the shared merge module — the plugin's floor policy united with the repo's overlay policy. (A bare `jq` over the repo-local policy file alone under-counts the merged result since #972.)
17
17
  ```bash
18
18
  BLOCKED=$(node --input-type=module -e "
19
- import { loadEffectivePolicy } from '$PLUGIN_ROOT/scripts/lib/blocked-commands-policy.mjs';
19
+ import { loadEffectivePolicy, blockedCommandPatterns } from '$PLUGIN_ROOT/scripts/lib/blocked-commands-policy.mjs';
20
20
  const { rules } = await loadEffectivePolicy({ cwd: process.cwd(), projectDir: process.env.CLAUDE_PROJECT_DIR ?? null, pluginRoot: '$PLUGIN_ROOT' });
21
- console.log(JSON.stringify((rules ?? []).filter(r => r.severity === 'block').map(r => r.pattern)));
21
+ console.log(JSON.stringify(blockedCommandPatterns(rules)));
22
22
  ")
23
23
  ```
24
- Use `$BLOCKED` as the `blockedCommands` value in `wave-scope.json`. Since #972 this is the effective floor∪overlay policy — identical to what the destructive-guard hook enforces.
24
+ Use `$BLOCKED` as the `blockedCommands` value in `wave-scope.json`. Since #972 the rules come from the effective floor∪overlay policy the destructive-guard hook enforces. `blockedCommandPatterns()` keeps only the UNTYPED `block` rules (#1509): a typed rule (`redirect-truncate`, `path-delete`) carries a target-decided `pattern` (`">"`, a free-text label) that `hooks/enforce-commands.mjs` would misread as a command pattern. The destructive guard still enforces the typed rules on every Bash call — do not re-inline the filter here.
25
25
 
26
26
  **Fallback:** If the command fails or prints `[]` (neither the plugin's floor policy nor a repo policy resolvable — pre-#155 setup), use the legacy hardcoded array and log a warning in the wave progress update:
27
27
  ```bash
@@ -49,7 +49,7 @@ Before each wave dispatch:
49
49
  "session_id": "<raw session id from attributionForRecord(); OMIT the key if unavailable>",
50
50
  "semantic_session_id": "<semantic_session_id from the same call; OMIT if unavailable>",
51
51
  "allowedPaths": ["<from agent specs in session plan>"],
52
- "blockedCommands": "<derived dynamically from the effective floor∪overlay policy via loadEffectivePolicy (severity: block rules, #972); falls back to legacy 5-element array if no policy resolves>",
52
+ "blockedCommands": "<derived dynamically from the effective floor∪overlay policy via loadEffectivePolicy + blockedCommandPatterns (untyped severity: block rules, #972/#1509); falls back to legacy 5-element array if no policy resolves>",
53
53
  "gates": "<copy of enforcement-gates from Session Config, or omit if unset>"
54
54
  }
55
55
  ```
@@ -163,4 +163,12 @@ Before each wave dispatch:
163
163
  5. Delete `<state-dir>/wave-scope.json`
164
164
  6. Write Phase 2 wave-scope.json with test file allowedPaths (`**/*.test.*`, `**/*.spec.*`, `**/__tests__/**`)
165
165
  7. Dispatch test/review agents
166
- 7. After the final wave completes, delete `<state-dir>/wave-scope.json` (cleanup). Delete `<state-dir>/filescopes/` in the same step — the per-agent scope files (§ 3.1) are wave-local working state, and a stale `wave-<N>/` directory left behind is a scope claim nobody re-verified.
166
+ 7. After the final wave completes, delete `<state-dir>/wave-scope.json` (cleanup). Delete `<state-dir>/filescopes/` in the same step — the per-agent scope files (§ 3.1) are wave-local working state, and a stale `wave-<N>/` directory left behind is a scope claim nobody re-verified. Delete THIS session's dispatch ledger in the same step too — `hooks/pre-task-scope-disjoint.mjs` keeps one file per session since #1493, `.orchestrator/wave-dispatch-scopes.<session-id>.json` plus its `.lock`, under the session root (the repo root, or the worktree the session entered). Only your own: a file beside it named after another id holds a peer session's live claims. Delete only an id that is already path-safe (the hook names the file after it unchanged); anything else is left behind, inert, because no other session reads it:
167
+
168
+ ```bash
169
+ SID="${CLAUDE_CODE_SESSION_ID:-}"
170
+ case "$SID" in
171
+ ''|*[!A-Za-z0-9._-]*) ;; # no id on this harness, or not path-safe: leave the file
172
+ *) rm -f ".orchestrator/wave-dispatch-scopes.$SID.json" ".orchestrator/wave-dispatch-scopes.$SID.lock" ;;
173
+ esac
174
+ ```
@@ -1,22 +0,0 @@
1
- ---
2
- name: navigator
3
- description: "Fleet coordinator persona for sessions running side by side on one host: it measures resources, pipelines and merge readiness itself, writes conditions (\"Auflagen\") as files, starts headless runs behind slot and busy gates, and grants no approvals. Operator-only, invoke as /session-orchestrator:navigator ticker|status|start|fenster|handover."
4
- metadata:
5
- user-invocable: "true"
6
- disable-model-invocation: "true"
7
- argument-hint: "[ticker|status|start|fenster|handover]"
8
- model: inherit
9
- ---
10
-
11
- # navigator
12
-
13
- > **Portable mirror — generated, do not edit.** The canonical workflow lives at
14
- > [`skills/navigator/SKILL.md`](../../../skills/navigator/SKILL.md); read that file for the full instructions.
15
- > This mirror carries only agentskills.io-spec-legal frontmatter so harnesses that
16
- > discover skills under `.agents/skills/` can find and route to the skill.
17
- >
18
- > Regenerate with `node scripts/generate-agents-skills.mjs`.
19
-
20
- Read the linked document in full and follow its complete workflow, including prechecks and stop conditions. Resolve its link relative to this SKILL.md, not the project working directory. The plugin root is three directories above this file. Resolve package paths such as `skills/` and `scripts/` from that root and relative links in the canonical document from its own directory. Keep the user’s project as the target of project operations.
21
-
22
- `$ARGUMENTS` means the trailing user input after the selected command skill, or an empty string when absent. Preserve flags, quoted text, and Unicode as data. Do not perform global substitution in the command document, shell expansion on the argument string, or execution of that string as shell code. Pass command arguments through structured tool parameters or safely quoted individual arguments.
@@ -1,5 +0,0 @@
1
- # Generated by scripts/generate-agents-skills.mjs; do not edit.
2
- interface:
3
- display_name: navigator
4
- policy:
5
- allow_implicit_invocation: false
@@ -1,22 +0,0 @@
1
- ---
2
- name: navigator
3
- description: 'Fleet coordinator persona for sessions running side by side on one host: it measures resources, pipelines and merge readiness itself, writes conditions ("Auflagen") as files, starts headless runs behind slot and busy gates, and grants no approvals. Operator-only, invoke as /session-orchestrator:navigator ticker|status|start|fenster|handover.'
4
- metadata:
5
- user-invocable: 'true'
6
- disable-model-invocation: 'true'
7
- argument-hint: '[ticker|status|start|fenster|handover]'
8
- model: inherit
9
- ---
10
-
11
- <!-- Generated by scripts/generate-codex-skills.mjs; do not edit. -->
12
-
13
- # navigator
14
-
15
- Read [`skills/navigator/SKILL.md`](../../../skills/navigator/SKILL.md) in full before taking any action, then follow its complete workflow.
16
- The linked document is authoritative, including its prechecks, argument rules, and stop conditions.
17
-
18
- Resolve this link relative to this SKILL.md, not the project working directory. The plugin root is three directories above this file. Resolve package paths such as `skills/` and `scripts/` from that root; resolve relative links inside the canonical document from its own directory. Keep the user’s project as the target of project operations.
19
-
20
- `$ARGUMENTS` means the trailing user input after the selected command skill, or an empty string when absent. Preserve flags, quoted text, and Unicode as data. Do not perform global substitution in the command document, shell expansion on the argument string, or execution of that string as shell code. When the workflow needs a command, pass its arguments through structured tool parameters or safely quoted individual arguments.
21
-
22
- Regenerate with `node scripts/generate-codex-skills.mjs`.
@@ -1,5 +0,0 @@
1
- # Generated by scripts/generate-codex-skills.mjs; do not edit.
2
- interface:
3
- display_name: navigator
4
- policy:
5
- allow_implicit_invocation: false
@@ -1,15 +0,0 @@
1
- ---
2
- description: "Fleet coordinator persona for sessions running side by side on one host: it measures resources, pipelines and merge readiness itself, writes conditions (\"Auflagen\") as files, starts headless runs behind slot and busy gates, and grants no approvals. Operator-only, invoke as /session-orchestrator:navigator ticker|status|start|fenster|handover."
3
- argument-hint: "[ticker|status|start|fenster|handover]"
4
- disable-model-invocation: true
5
- ---
6
-
7
- # /navigator
8
-
9
- Use the Session Orchestrator skill definition at `skills/navigator/SKILL.md`.
10
-
11
- Arguments: $ARGUMENTS
12
-
13
- Read that skill file and follow it exactly. When it references `$ARGUMENTS`, substitute the arguments above. Keep all Session Orchestrator platform fallbacks intact.
14
-
15
- Cursor has no Skill tool. When the skill says to invoke another skill, Read `skills/<skill-name>/SKILL.md` and follow it. Supporting files (`soul.md`, phase docs) live in that same `skills/<skill-name>/` directory.
@@ -1,13 +0,0 @@
1
- ---
2
- name: navigator
3
- description: "Fleet coordinator persona for sessions running side by side on one host: it measures resources, pipelines and merge readiness itself, writes conditions (\"Auflagen\") as files, starts headless runs behind slot and busy gates, and grants no approvals. Operator-only, invoke as /session-orchestrator:navigator ticker|status|start|fenster|handover."
4
- disable-model-invocation: true
5
- ---
6
-
7
- # navigator
8
-
9
- Canonical skill: `skills/navigator/SKILL.md`
10
-
11
- Read that file and follow it exactly. Resolve relative links against `skills/navigator/`, not this wrapper.
12
-
13
- Cursor has no Skill tool. Treat "invoke the navigator skill" as: Read `skills/navigator/SKILL.md`.
@@ -1,12 +0,0 @@
1
- ---
2
- description: "Fleet coordinator persona for sessions running side by side on one host: it measures resources, pipelines and merge readiness itself, writes conditions (\"Auflagen\") as files, starts headless runs behind slot and busy gates, and grants no approvals. Operator-only, invoke as /session-orchestrator:navigator ticker|status|start|fenster|handover."
3
- argument-hint: "[ticker|status|start|fenster|handover]"
4
- ---
5
-
6
- # /navigator
7
-
8
- Use the Session Orchestrator skill definition at `skills/navigator/SKILL.md`.
9
-
10
- Arguments: $@
11
-
12
- Read that skill file and follow it exactly. When it references `$ARGUMENTS`, substitute the arguments above. Keep all Session Orchestrator platform fallbacks intact.