session-orchestrator 5.4.0 → 5.6.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 (151) 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 +115 -3
  7. package/README.md +30 -15
  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 +19 -15
  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 +77 -171
  26. package/hooks/post-bash-write-verify.mjs +219 -24
  27. package/hooks/post-edit-validate.mjs +15 -5
  28. package/hooks/post-tool-batch-wave-signal.mjs +41 -156
  29. package/hooks/pre-bash-memory-propose-audit.mjs +28 -11
  30. package/hooks/pre-bash-staging-fence.mjs +29 -8
  31. package/hooks/pre-task-scope-disjoint.mjs +603 -241
  32. package/hooks/wave-scope-commit-guard.mjs +235 -73
  33. package/package.json +9 -8
  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 +201 -27
  75. package/scripts/lib/session-close-backfill.mjs +202 -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 +60 -6
  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/parallel-aware-auq.md +1 -1
  110. package/skills/_shared/state-ownership.md +38 -14
  111. package/skills/autopilot/SKILL.md +1 -1
  112. package/skills/bootstrap/SKILL.md +5 -3
  113. package/skills/bootstrap/_shared-template.md +96 -1
  114. package/skills/bootstrap/fast-template.md +3 -0
  115. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
  116. package/skills/bootstrap/standard-template.md +2 -0
  117. package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
  118. package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
  119. package/skills/discovery/probes/vault-staleness.mjs +9 -3
  120. package/skills/docs-orchestrator/SKILL.md +12 -5
  121. package/skills/docs-orchestrator/audience-mapping.md +5 -3
  122. package/skills/evolve/SKILL.md +11 -2
  123. package/skills/evolve/references/evolve-analyze-mode.md +37 -17
  124. package/skills/session-end/learning-patterns.md +3 -3
  125. package/skills/session-end/metrics-collection.md +1 -1
  126. package/skills/session-end/phase-3-2-docs-verification.md +7 -6
  127. package/skills/session-end/plan-verification.md +25 -9
  128. package/skills/session-end/references/phase-3-documentation-updates.md +11 -1
  129. package/skills/session-end/session-metrics-write.md +45 -10
  130. package/skills/session-start/SKILL.md +3 -3
  131. package/skills/session-start/references/operations-contract.md +5 -3
  132. package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
  133. package/skills/session-start/references/phase-4-ssot-environment-check.md +8 -1
  134. package/skills/vault-mirror/SKILL.md +21 -11
  135. package/skills/wave-executor/SKILL.md +1 -1
  136. package/skills/wave-executor/circuit-breaker.md +1 -1
  137. package/skills/wave-executor/references/wave-executor-state-init.md +1 -1
  138. package/skills/wave-executor/references/wave-loop-dispatch.md +8 -0
  139. package/skills/wave-executor/references/wave-loop-review.md +2 -2
  140. package/skills/wave-executor/references/wave-loop-scope-manifest.md +14 -6
  141. package/.agents/skills/navigator/SKILL.md +0 -22
  142. package/.agents/skills/navigator/agents/openai.yaml +0 -5
  143. package/.codex-plugin/skills/navigator/SKILL.md +0 -22
  144. package/.codex-plugin/skills/navigator/agents/openai.yaml +0 -5
  145. package/.cursor/commands/navigator.md +0 -15
  146. package/.cursor/skills/navigator/SKILL.md +0 -13
  147. package/pi/prompts/navigator.md +0 -12
  148. package/skills/navigator/SKILL.md +0 -193
  149. package/skills/navigator/references/account-check.sh +0 -87
  150. package/skills/navigator/references/fleet-decisions.mjs +0 -231
  151. package/skills/navigator/references/slot-check.sh +0 -114
@@ -24,6 +24,7 @@
24
24
 
25
25
  import { existsSync, readFileSync, readdirSync, mkdirSync, appendFileSync } from 'node:fs';
26
26
  import { join } from 'node:path';
27
+ import { expandTilde } from '../../../scripts/lib/common.mjs';
27
28
 
28
29
  // ---------------------------------------------------------------------------
29
30
  // Constants
@@ -73,9 +74,12 @@ function parseFrontmatter(text) {
73
74
  // Helpers
74
75
  // ---------------------------------------------------------------------------
75
76
 
77
+ // #1506 pt 6: the committed value is `~/Projects/vault`; unexpanded it resolved
78
+ // against the cwd (`<cwd>/~/Projects/vault`), where a stray `~` directory
79
+ // produced a false all-clear (0 stale narratives instead of 33).
76
80
  function resolveVaultDir(projectRoot, config) {
77
- if (config?.['vault-integration']?.['vault-dir']) return config['vault-integration']['vault-dir'];
78
- if (process.env.VAULT_DIR) return process.env.VAULT_DIR;
81
+ if (config?.['vault-integration']?.['vault-dir']) return expandTilde(config['vault-integration']['vault-dir']);
82
+ if (process.env.VAULT_DIR) return expandTilde(process.env.VAULT_DIR);
79
83
  return null;
80
84
  }
81
85
 
@@ -38,6 +38,7 @@
38
38
 
39
39
  import { existsSync, readFileSync, readdirSync, mkdirSync, appendFileSync } from 'node:fs';
40
40
  import { join } from 'node:path';
41
+ import { expandTilde } from '../../../scripts/lib/common.mjs';
41
42
 
42
43
  // ---------------------------------------------------------------------------
43
44
  // Minimal YAML frontmatter parser — top-level scalar fields only (<30 lines).
@@ -112,13 +113,18 @@ export async function runProbe(projectRoot, config) {
112
113
  try {
113
114
  // --- Config validation (early exit paths) ---
114
115
 
115
- const vaultDir = config?.['vault-integration']?.['vault-dir'];
116
- if (!vaultDir) {
116
+ const configuredVaultDir = config?.['vault-integration']?.['vault-dir'];
117
+ if (!configuredVaultDir) {
117
118
  return makeSkip('vault-dir not configured');
118
119
  }
120
+ // #1506 pt 6: the committed value is `~/Projects/vault`; unexpanded it
121
+ // resolved against the cwd (`<cwd>/~/Projects/vault`), where a stray `~`
122
+ // directory produced a false all-clear (0 projects scanned instead of 45).
123
+ const vaultDir = expandTilde(configuredVaultDir);
119
124
 
120
125
  if (!existsSync(vaultDir)) {
121
- return makeSkip(`vault-dir does not exist: ${vaultDir}`);
126
+ // The configured spelling, not the expanded one: no home dir in the output.
127
+ return makeSkip(`vault-dir does not exist: ${configuredVaultDir}`);
122
128
  }
123
129
 
124
130
  const projectsDir = join(vaultDir, '01-projects');
@@ -177,9 +177,15 @@ No Docs task generated for this audience.
177
177
  documents what the docs-writer MUST do when executing a dispatched Docs task.
178
178
 
179
179
  Before writing any documentation, the docs-writer MUST read and ingest the actual
180
- source material provided in the task prompt. The canonical four source types are:
181
-
182
- 1. **diff** — `git diff $SESSION_START_REF..HEAD` — the verbatim diff of all changes
180
+ source material provided in the task prompt. The canonical four source types are
181
+ below. `$SESSION_RANGE` is ONE complete diff range: inside a wave run it is
182
+ `"$SESSION_START_REF..HEAD"` (the SHA wave-executor pins at Pre-Wave 1a); at
183
+ session end it is what the accessor in `skills/session-end/plan-verification.md`
184
+ § SESSION_START_REF accessor exports — the pinned SHA, else the resolved
185
+ `<base>...HEAD`, never a hard-coded `origin/main`. Diffs take the range; the log
186
+ keeps the two-dot `$SESSION_START_REF..HEAD`, which is the correct form for `git log`.
187
+
188
+ 1. **diff** — `git diff "$SESSION_RANGE"` — the verbatim diff of all changes
183
189
  made during this session. This is the primary authoritative source for what changed.
184
190
  2. **git-log** — `git log $SESSION_START_REF..HEAD --format="%H %s%n%b"` — commit
185
191
  messages and associated bodies. Secondary context for all audiences; use to
@@ -284,9 +290,10 @@ The docs-writer MUST emit a brief structured output summary:
284
290
  1. Collect every task classified as `Docs` in the plan.
285
291
  2. For each task, resolve the expected `file-pattern` target and check for a diff:
286
292
  ```bash
287
- git diff --name-only $SESSION_START_REF..HEAD
293
+ git diff --name-only "$SESSION_RANGE"
288
294
  ```
289
- Match the output against the task's file-pattern using glob matching.
295
+ (`$SESSION_RANGE` from the session-end accessor above; an empty range is a skip
296
+ with a named reason, never "no docs changed".) Match the output against the task's file-pattern using glob matching.
290
297
  3. Classify gaps by severity:
291
298
  - **missing diff** — no matching file was changed; the task did not run or produced
292
299
  no output.
@@ -23,14 +23,16 @@ traceable source gets `<!-- REVIEW: source needed -->` — the agent NEVER inven
23
23
 
24
24
  | Source | Command / Path | Primary audience(s) | Notes |
25
25
  |--------|---------------|---------------------|-------|
26
- | **diff** | `git diff $SESSION_START_REF..HEAD` | Dev, Vault | Authoritative for what changed and when. Preferred primary source for Dev. |
26
+ | **diff** | `git diff "$SESSION_RANGE"` | Dev, Vault | Authoritative for what changed and when. Preferred primary source for Dev. |
27
27
  | **git-log** | `git log $SESSION_START_REF..HEAD --format="%H %s%n%b"` | All | Secondary context; use to reconstruct the "why" when diffs alone are ambiguous. |
28
28
  | **session-memory** | `~/.claude/projects/<project>/memory/session-*.md`, `.orchestrator/` outputs | Vault, User | Primary for Vault narratives (status updates, decisions made in conversation). Also primary for User tasks designed interactively. |
29
29
  | **affected-files** | Files in wave-scope `allowedPaths` / coordinator `affected-files` context block | User, Dev | Primary for understanding updated interfaces, config schemas, or module structures. |
30
30
 
31
+ `$SESSION_RANGE` is one complete diff range — `"$SESSION_START_REF..HEAD"` inside a wave run, the session-end accessor's export at close (`../session-end/plan-verification.md` § SESSION_START_REF accessor; never a hard-coded `origin/main`). The log keeps the two-dot `$SESSION_START_REF..HEAD`, the correct form for `git log`.
32
+
31
33
  **Detailed source descriptions:**
32
34
 
33
- 1. **diff** — Direct code changes from `git diff $SESSION_START_REF..HEAD`. Authoritative
35
+ 1. **diff** — Direct code changes from `git diff "$SESSION_RANGE"`. Authoritative
34
36
  for Dev narratives (what changed and why) and Vault decisions (what was decided, when).
35
37
  Preferred primary source for Dev audience tasks.
36
38
  2. **git-log** — Commit messages and associated PR/MR bodies from
@@ -90,7 +92,7 @@ Trigger: New `coordinator-snapshot.mjs` module added; CWD-drift guard introduced
90
92
  ## Allowed Sources
91
93
  Only these four sources are permitted. Do not use general knowledge.
92
94
 
93
- - diff: `git diff $SESSION_START_REF..HEAD` (provided below)
95
+ - diff: `git diff "$SESSION_RANGE"` (provided below)
94
96
  - git-log: `git log $SESSION_START_REF..HEAD --format="%H %s%n%b"` (provided below)
95
97
  - session-memory: wave summaries and agent outputs from
96
98
  ~/.claude/projects/<project>/memory/session-*.md and .orchestrator/ (provided below)
@@ -171,7 +171,10 @@ If user selects "Confidence ändern", "Ablauf verlängern", or "Delete specific
171
171
 
172
172
  Use the same archive-safe pipeline as Phase 3, Step 3.5 — **never** a hand-rolled `>` rewrite (#1017):
173
173
 
174
- 1. Read all lines from `learnings.jsonl`
174
+ 1. Snapshot the store into the sidecar exactly as Phase 3, Step 3.5(1) does (`--prune --snapshot`,
175
+ with the same `--file` / `--archive` the apply will use) and work on the sidecar, never on a
176
+ separate read of `learnings.jsonl` (#1486). A snapshot exit `1` (a malformed store line, or a
177
+ PATH that would replace a ledger) wrote nothing: surface it and stop
175
178
  2. Apply the selected operation to selected learnings:
176
179
  - **Boost:** +0.15 confidence (cap 1.0), reset expires_at to `deriveExpiresAt(now, type)` (per-type TTL, `scripts/lib/learnings/schema.mjs`)
177
180
  - **Reduce:** -0.2 confidence
@@ -182,7 +185,13 @@ Use the same archive-safe pipeline as Phase 3, Step 3.5 — **never** a hand-rol
182
185
  - **Extend:** reset expires_at to `deriveExpiresAt(now, type)` — `learning-expiry-days` is parsed but read by no script (`skills/_shared/config-reading.md` § Learning Expiry Semantics)
183
186
  3. Steps 3–5 of the old prose (prune / consolidate / rewrite) are `pruneLearnings()` — run the
184
187
  **exact** Step 3.5(5) invocation, writing the post-operation entry set to the `--entries`
185
- sidecar. It prunes
188
+ sidecar below its line-1 `_store_generation` header. Exit `1` or `2`: surface the error and
189
+ stop (`1` includes a sidecar snapshotted from another store than `--file`). Exit `3` means the
190
+ store changed after the snapshot and nothing was written: snapshot into a FRESH path,
191
+ re-apply the selected operations to the records in that file, and apply it once more — a
192
+ second consecutive `3`: stop and report. Never move the fresh header onto the old sidecar's
193
+ records, whose missing peer change the prune would archive `pruned` (Step 3.5(5) carries the
194
+ full exit-code rule). It prunes
186
195
  (`expires_at` < now → `expired`; `confidence <= 0.0` → `pruned`), consolidates duplicates
187
196
  (same `type` + non-empty `subject`, highest confidence wins, loser archived `superseded` with
188
197
  `_superseded_by`; null-subject entries preserved individually per #284), and rewrites through
@@ -208,7 +208,7 @@ If user selects "Skip all" or selects nothing, abort gracefully: "No learnings s
208
208
 
209
209
  For confirmed learnings, use atomic rewrite strategy:
210
210
 
211
- 1. Read ALL existing lines from `.orchestrator/metrics/learnings.jsonl` (if exists) into memory. If not found, check `<state-dir>/metrics/learnings.jsonl` as a legacy fallback. If legacy data is found, it will be migrated to the v2 path on write (step 5).
211
+ 1. Snapshot the store into the sidecar: `node scripts/sweep-expired-learnings.mjs --prune --snapshot .orchestrator/tmp/learnings-next.jsonl`. It writes every record of `.orchestrator/metrics/learnings.jsonl` to that file behind a line-1 `{"_store_generation": "sha256:…", "_store_path": "…"}` header naming the exact store state and the store they came from. Read the SIDECAR, not the store, as your starting set — step 5 explains why. On a non-default store, pass the SAME `--file` / `--archive` here as to the step-5 apply: the apply refuses (exit `1`) a sidecar snapshotted from another store. A store holding a malformed (unparseable) line does NOT stop the snapshot: it prints a stderr WARN naming the line numbers and adds a `malformed` count to its summary. The sidecar carries records only, and the step-5 apply keeps the store's malformed lines verbatim at the end of the rewrite, never archived — report the count to the operator; never hand-edit the store to remove them. The one sanctioned repair is `node scripts/sweep-expired-learnings.mjs --drop-malformed --line N [--line M …]`: a dry run by default, printing each line's preview, any readable record it still carries (`bom` / `embedded_record`) and the store generation; only after the operator confirmed those previews, `--drop-malformed --line N --apply --generation <TOKEN> --repo-root "$(pwd)"` removes the lines (backup `<stem>.pre-drop-malformed.jsonl.bak-<ISO>` beside the store). A line still holding a readable record needs `--accept-embedded`; a store that changed since the dry run exits `3`. If the snapshot holds zero records, check `<state-dir>/metrics/learnings.jsonl` as a legacy fallback and add its records below line 1; they are migrated to the v2 path on write (step 5).
212
212
  2. Apply confidence updates for confirmed existing learnings:
213
213
  - Increment confidence by +0.15
214
214
  - Cap at 1.0
@@ -235,14 +235,22 @@ For confirmed learnings, use atomic rewrite strategy:
235
235
  deleted 11 of 13 `learning-id` provenance targets referenced by rendered `.claude/rules/*.md`.
236
236
  Do not hand-roll a `jq | ... > learnings.jsonl` pass; it bypasses every #721 safety net.
237
237
 
238
- Write the full next-generation entry set (existing entries **with** the step-2/3 confidence
239
- updates, **plus** the step-4 new learnings) as JSONL to a temp sidecar **via the Write tool**
240
- (not a shell `>` redirect — the destructive-command guard blocks it) at
241
- `.orchestrator/tmp/learnings-next.jsonl` (the Write tool creates the missing
242
- `.orchestrator/tmp/` itself; from a shell, `mkdir -p .orchestrator/tmp` first). The sidecar
243
- lives under `.orchestrator/tmp/`, never `.orchestrator/metrics/`, because `tmp/` is outside
238
+ Turn the step-1 sidecar `.orchestrator/tmp/learnings-next.jsonl` into the full
239
+ next-generation entry set (existing entries **with** the step-2/3 confidence updates,
240
+ **plus** the step-4 new learnings) **via the Write or Edit tool** (not a shell `>` redirect —
241
+ the destructive-command guard blocks it), **keeping line 1, the `_store_generation` header,
242
+ unchanged and first**. The sidecar lives under `.orchestrator/tmp/`, never
243
+ `.orchestrator/metrics/`, because `tmp/` is outside
244
244
  the ledger guard's `ledger-delete-protected` denylist — a metrics-dir sidecar made the
245
- cleanup `rm` below a blocked command (#1453). Then invoke the
245
+ cleanup `rm` below a blocked command (#1453).
246
+
247
+ **Snapshot → token → apply refuses on drift (#1486).** Minutes pass between step 1 and the
248
+ call below, and a peer session may append to the store meanwhile. Such a record is on disk
249
+ but absent from your sidecar, so the prune would archive it `pruned` — a delete nobody
250
+ decided, which the store lock cannot prevent because your read happened before it, in
251
+ another process. So the prune re-reads the store under its lock and compares that
252
+ generation with the header: any difference writes nothing and exits `3`. A sidecar without
253
+ the header exits `1` — the header is mandatory, never optional. Then invoke the
246
254
  `--prune` subcommand of the sweep CLI. **This call is also `/evolve`'s ONLY
247
255
  `orchestrator.evolve.completed` success emit (#1206)** — export `N` (Step 3.5(4)'s
248
256
  new-learnings count), `M` (Step 3.5(2)'s reinforced-existing count) and `DURATION_MS`
@@ -258,7 +266,8 @@ For confirmed learnings, use atomic rewrite strategy:
258
266
  ```
259
267
 
260
268
  `--file` / `--archive` default to the canonical store + archive paths — pass them only when
261
- operating on a non-default pair. The command prints ONE JSON line; capture it as `$PRUNE` and
269
+ operating on a non-default pair, and then pass the same pair to the step-1 `--snapshot` too.
270
+ The command prints ONE JSON line; capture it as `$PRUNE` and
262
271
  report its `{scanned, kept, archived, byReason}` in the final summary — `$PRUNE.archived` is
263
272
  also the `pruned` counter the emit above just wrote, so there is nothing left to compute for
264
273
  the telemetry after this line. Preview first with `--prune --dry-run --json` (same counts,
@@ -273,12 +282,22 @@ For confirmed learnings, use atomic rewrite strategy:
273
282
  > independently of the write it reported on. Do not re-inline either, and do not hand-roll a
274
283
  > `jq | ... > learnings.jsonl` pass — that bypasses every #721 safety net.
275
284
 
276
- **Exit codes are the no-op rule.** `0` = applied (or a clean no-op). `1` = input error: the
277
- sidecar is absent, carries a malformed line, or holds no records — the store and the archive
278
- were **not touched**;
279
- re-write the sidecar and re-run. `2` = the prune itself failed inside the lib. On any non-zero
280
- exit, surface the error and stop — never retry with a shell rewrite, and never delete `$NEXT`
281
- (the `&&` above already withholds the `rm`, so the assembled generation survives for a retry).
285
+ **Exit codes are the no-op rule.** `0` = applied (or a clean no-op). On `1` and `2`, surface
286
+ the error and stop. `1` = input error: the sidecar is absent, carries a malformed line, holds
287
+ no records, lacks its line-1 `_store_generation` header, or was snapshotted from another store
288
+ than this call's `--file` — the store and the archive were **not touched**. `2` = the prune
289
+ itself failed inside the lib. `3` =
290
+ `store-generation-mismatch`: the store changed after step 1 and nothing was written. This one
291
+ is a retry, not a stop: snapshot into a FRESH path
292
+ (`--prune --snapshot .orchestrator/tmp/learnings-next-retry.jsonl`, with the same `--file` /
293
+ `--archive` as the apply), re-apply this run's
294
+ decisions to the records in THAT file — they now include the peer's change — and run the block
295
+ above with its `NEXT` set to the fresh path. Retry once: a second consecutive `3` means the
296
+ store keeps moving under you — stop and report it. Never move the fresh header onto the old
297
+ sidecar's records: they lack exactly the peer change the header exists to protect, so the
298
+ prune would archive it `pruned` (the error prints no token for that reason). On every exit,
299
+ never retry with a shell rewrite and never delete `$NEXT` — the `&&` above withholds the `rm`,
300
+ so the assembled generation survives as the record of this run's decisions.
282
301
 
283
302
  `pruneLearnings()` — the function the subcommand calls — performs steps 6 + 7 + 8 mechanically
284
303
  and archives **every** record that
@@ -313,13 +332,14 @@ For confirmed learnings, use atomic rewrite strategy:
313
332
 
314
333
  b. Resolve the vault directory: use `$CONFIG."vault-integration"."vault-dir"` if non-null, otherwise fall back to the `$VAULT_DIR` environment variable. If neither is set, emit a warning and skip. Report `vault-dir=<path> (Quelle: <source>)`, where `<source>` is `$CONFIG."vault-integration"."vault-dir-source"` (`env`, `match`, `owner`, `committed`), or `env VAULT_DIR` when the fallback was used.
315
334
 
316
- c. Invoke the mirror script. Derive a synthetic `EVOLVE_SESSION_ID` so the vault-mirror auto-commit phase (#31) produces a traceable commit subject (`chore(vault): mirror evolve-<date> — N learnings + 0 sessions`). Pass `--vault-name` when `vault-integration.vault-name` is set in Session Config:
335
+ c. Invoke the mirror script from the repo root, by its plugin path — `--repo-root "$PWD"` plus an absolute `--source` make the vault namespace this repo's identity, and a ledger from a different repo is refused (exit 2, `source-repo-mismatch`, #1503) instead of overwriting another project's notes. Derive a synthetic `EVOLVE_SESSION_ID` so the vault-mirror auto-commit phase (#31) produces a traceable commit subject (`chore(vault): mirror evolve-<date> — N learnings + 0 sessions`). Pass `--vault-name` when `vault-integration.vault-name` is set in Session Config:
317
336
  ```bash
318
337
  EVOLVE_SESSION_ID="evolve-$(date -u +%Y-%m-%d-%H%M)"
319
338
  EVOLVE_VAULT_NAME=$(echo "$CONFIG" | jq -r '."vault-integration"."vault-name" // empty')
320
339
  node "$PLUGIN_ROOT/scripts/vault-mirror.mjs" \
321
340
  --vault-dir "<vault-dir>" \
322
- --source .orchestrator/metrics/learnings.jsonl \
341
+ --repo-root "$PWD" \
342
+ --source "$PWD/.orchestrator/metrics/learnings.jsonl" \
323
343
  --kind learning \
324
344
  --session-id "$EVOLVE_SESSION_ID" \
325
345
  ${EVOLVE_VAULT_NAME:+--vault-name "$EVOLVE_VAULT_NAME"}
@@ -10,7 +10,7 @@
10
10
  Analyze the completed session to extract reusable learnings for future sessions.
11
11
 
12
12
  **What to extract:**
13
- - **Fragile files**: use `git log --name-only --format="" $SESSION_START_REF..HEAD | sort | uniq -c | sort -rn | head -10` to find files changed most frequently across commits this session. Files appearing in 3+ commits are candidates for fragile-file learnings. Cross-reference with `<state-dir>/STATE.md` Wave History to correlate with specific waves.
13
+ - **Fragile files**: use `git log --name-only --format="" $SESSION_START_REF..HEAD | sort | uniq -c | sort -rn | head -10` to find files changed most frequently across commits this session. Files appearing in 3+ commits are candidates for fragile-file learnings. Cross-reference with `<state-dir>/STATE.md` Wave History to correlate with specific waves. Skip this when `$SESSION_START_REF` is empty (the accessor's skip in `plan-verification.md`): `git log ..HEAD` reads as `HEAD..HEAD` and prints nothing with exit 0, which would pass for "no fragile files".
14
14
  - **Effective sizing**: actual agent count vs. planned — what worked for this complexity level
15
15
  - **Recurring issues**: same issue type appearing across waves (e.g., type errors, missing imports)
16
16
  - **Scope guidance**: was the scope too large/small? How many issues fit comfortably in one session?
@@ -82,7 +82,7 @@ Before writing new learnings, read `.orchestrator/metrics/learnings.jsonl` and c
82
82
  | 9 | 0.05 | active |
83
83
  | 10 | 0.00 | pruned next write |
84
84
 
85
- e. Exit codes: `0` applied (or a clean no-op); `1` input or validation error — the message names the record id and the failing field, and **nothing was written**: fix the sidecar and re-run; `2` store/IO error — report it, do not retry by hand.
86
- f. **Hand-writing the store is FORBIDDEN** — no shell rewrite, no shell append, no `jq … | tee`. Those bypass validation, the `.bak` snapshot and the archive (see `skills/evolve/SKILL.md` § Critical Rules). The blocking validator `scripts/lib/validate/check-learnings-shell-writes.mjs` fails `validate-plugin` on prose that instructs one.
85
+ e. Exit codes: `0` applied (or a clean no-op); `1` input or validation error — the message names the record id and the failing field, and **nothing was written**: fix the sidecar and re-run; `2` store/IO error — report it, do not retry by hand. Unparseable lines already in the store are NOT an error and never block the write: the CLI keeps them verbatim at the end of the store (never archived), reports `malformed: N` in its summary and prints a stderr WARN with their line numbers — carry that count into the Phase 6 report.
86
+ f. **Hand-writing the store is FORBIDDEN** — no shell rewrite, no shell append, no `jq … | tee`, and that includes "repairing" a malformed line the WARN reports: it stays until the operator decides. Those bypass validation, the `.bak` snapshot and the archive (see `skills/evolve/SKILL.md` § Critical Rules). The blocking validator `scripts/lib/validate/check-learnings-shell-writes.mjs` fails `validate-plugin` on prose that instructs one. Once the operator decides to remove a line, the ONE sanctioned path is the CLI (#1500): `node scripts/sweep-expired-learnings.mjs --drop-malformed --line N` previews it (dry run: the line's text and the store `generation`), then `node scripts/sweep-expired-learnings.mjs --drop-malformed --line N --generation <that token> --apply --repo-root .` removes it. The apply copies the whole store to `learnings.pre-drop-malformed.jsonl.bak-<ISO>` beside it first and appends one `orchestrator.learnings.malformed_dropped` record. Line numbers move with every rewrite (malformed lines go to the end), so a store changed since the dry run exits `3` — re-run the dry run; a line that is not malformed exits `1`. Both write nothing. A malformed line that still holds a readable record — behind a UTF-8 BOM, or fused after a torn prefix (the dry run reports `bom` / `embedded_record`) — is refused at `--apply` with exit `1` as well, because dropping it loses that record; `--accept-embedded` is the operator's explicit decision to drop it anyway (the pre-drop snapshot keeps the line).
87
87
  g. Proof it ran: `--apply` appends one `orchestrator.learnings.session_write_applied` record to `.orchestrator/metrics/events.jsonl` (`appended`, `confirmed`, `contradicted`, `decayed`, `pruned`). No record = no write.
88
88
  h. If no existing file and no new learnings: skip
@@ -19,7 +19,7 @@ Finalize session metrics by reading the wave data accumulated during execution:
19
19
  - `total_agents`: sum of agents across all waves
20
20
  - `total_files_changed`: unique files changed across entire session (from `git diff --stat`)
21
21
  - `agent_summary`: `{complete: N, partial: N, failed: N, spiral: N}`
22
- - **Coordinator-direct fallback (#1321):** when Wave History is empty AND the session ran coordinator-direct (housekeeping — `scripts/lib/session-shape.mjs:364-376` resolves it to ONE coordinator-direct wave that `/go` never hands to wave-executor, so no wave metrics are captured — or the express path), do NOT write `waves: []` / `total_waves: 0`. Write exactly one wave entry `{wave: 1, role: "Housekeeping", agent_count: 0, coordinator_direct: true, files_changed: <N>, quality: "<pass|fail|skip — the close quality-gate result>"}` with `total_waves: 1`, `total_agents: 0`. On this path both that wave's `files_changed` and `total_files_changed` are `git diff --name-only "$SESSION_START_REF..HEAD" | wc -l` (accessor: `plan-verification.md` § SESSION_START_REF accessor). The single definition of this shape is `isCoordinatorDirectHousekeeping(record)` in `scripts/lib/session-schema/filters.mjs` (every wave `role: "Housekeeping"` AND `coordinator_direct: true`). Consumers key on it (eval gate-health, evolve effective-sizing), so write `role` and `coordinator_direct` exactly as shown. `session_type` alone never marks it.
22
+ - **Coordinator-direct fallback (#1321):** when Wave History is empty AND the session ran coordinator-direct (housekeeping — `scripts/lib/session-shape.mjs:364-376` resolves it to ONE coordinator-direct wave that `/go` never hands to wave-executor, so no wave metrics are captured — or the express path), do NOT write `waves: []` / `total_waves: 0`. Write exactly one wave entry `{wave: 1, role: "Housekeeping", agent_count: 0, coordinator_direct: true, files_changed: <N>, quality: "<pass|fail|skip — the close quality-gate result>"}` with `total_waves: 1`, `total_agents: 0`. On this path both that wave's `files_changed` and `total_files_changed` are `git diff --name-only "$SESSION_RANGE" | wc -l` (accessor: `plan-verification.md` § SESSION_START_REF accessor; an empty `$SESSION_RANGE` is a skip, not zero changes — the validator still requires a number there, so name the accessor's reason in the record's `notes` beside it). The single definition of this shape is `isCoordinatorDirectHousekeeping(record)` in `scripts/lib/session-schema/filters.mjs` (every wave `role: "Housekeeping"` AND `coordinator_direct: true`). Consumers key on it (eval gate-health, evolve effective-sizing), so write `role` and `coordinator_direct` exactly as shown. `session_type` alone never marks it.
23
23
  3. Read `.orchestrator/metrics/events.jsonl` **once** and build both event aggregates in a single pass. If the file does not exist, treat both aggregates as zero events (omit both fields per the rules below) — do NOT fail the session close.
24
24
 
25
25
  Filter all lines where `session == <session_id>`, then partition by `event` value:
@@ -31,11 +31,12 @@ process.stdout.write(JSON.stringify(tasks));
31
31
  ⚠ Phase 3.2: docs-tasks parse error — <reason>. Manual docs verification required.
32
32
  ```
33
33
 
34
- ## Step 2 — Read SESSION_START_REF
34
+ ## Step 2 — Resolve SESSION_RANGE
35
35
 
36
- Read `session-start-ref` from STATE.md frontmatter. Full accessor and fallback chain are documented in `plan-verification.md § SESSION_START_REF accessor`. Summary:
37
- - Primary: `session-start-ref` field in STATE.md frontmatter.
38
- - Fallback: `git diff --name-only origin/main...HEAD` (no specific base SHA).
36
+ Run the accessor in `plan-verification.md § SESSION_START_REF accessor`; it exports `$SESSION_RANGE`, one complete diff range. Summary:
37
+ - Primary: the pinned `session-start-ref` from STATE.md frontmatter → `<sha>..HEAD`.
38
+ - Field absent: the baseline `resolveBaselineRange()` resolves → `<base>...HEAD` (never a hard-coded `origin/main`).
39
+ - Nothing resolves: `$SESSION_RANGE` is empty and the accessor names `no-baseline-ref` or `unresolvable-ref` — log `⚠ Phase 3.2: no session range (<reason>). Docs verification skipped.` and skip Phase 3.2. Never treat the empty range as "no docs changed".
39
40
 
40
41
  If `git diff` itself fails (network issue, corrupt repo), log:
41
42
  ```
@@ -46,7 +47,7 @@ and skip Phase 3.2. This is an explicit error, not a silent skip.
46
47
  ## Step 3 — Compute changed files
47
48
 
48
49
  ```bash
49
- CHANGED_FILES=$(git diff --name-only "$SESSION_START_REF..HEAD")
50
+ CHANGED_FILES=$(git diff --name-only "$SESSION_RANGE")
50
51
  ```
51
52
 
52
53
  Cache this list for the per-task loop below. If the command exits non-zero, surface the error per the guard above and skip Phase 3.2.
@@ -75,7 +76,7 @@ For each `task` in `docs-tasks`:
75
76
 
76
77
  3. **Matched → inspect diff:**
77
78
  ```bash
78
- git diff "$SESSION_START_REF..HEAD" -- <matched-file>
79
+ git diff "$SESSION_RANGE" -- <matched-file>
79
80
  ```
80
81
  - **Substantive content change** (non-whitespace, non-comment-only lines added/removed): outcome `ok`.
81
82
  - **Whitespace-only or structural-only diff** (no prose or code content changed): outcome `gap`.
@@ -9,19 +9,34 @@
9
9
 
10
10
  > Referenced by Phase 3.2 (Docs Verification) and Phase 1.1a (File-Level Grounding). Canonical definition lives here.
11
11
 
12
- Read `session-start-ref` from STATE.md frontmatter. If the field is missing (older session or persistence disabled), fall back to `git diff --name-only origin/main...HEAD` (compares current HEAD against origin/main rather than a pinned SHA). The fallback is less precise but functional. Always prefer the pinned SHA when available.
12
+ Read `session-start-ref` from STATE.md frontmatter; it is the pinned session-start SHA and always wins. If the field is missing (older session or persistence disabled), RESOLVE the base with `resolveBaselineRange()` (`scripts/lib/vcs-repo-spec.mjs`, #1039 — preferred remote → `<R>/HEAD` → `<R>/{main,master}` → local `main`/`master`), the same chain `computeDrift()` uses. Never assume `origin/main`: in a repo whose remote is not named `origin` or whose default branch is not `main` that literal fails every diff below.
13
+
14
+ The accessor exports two variables. Consumers that DIFF use `"$SESSION_RANGE"` as one complete range — never `"$SESSION_START_REF..HEAD"`, which turned the old fallback into a TWO-dot `origin/main..HEAD` (a diff that also counts everything `main` gained since the branch point). `$SESSION_START_REF` stays the log base (`git log "$SESSION_START_REF..HEAD"`).
13
15
 
14
16
  ```bash
15
- SESSION_START_REF=$(node --input-type=module -e "
17
+ SESSION_SPEC=$(node --input-type=module -e "
16
18
  import {readFileSync} from 'node:fs';
17
19
  import {parseStateMd} from '${PLUGIN_ROOT}/scripts/lib/state-md.mjs';
18
- const fm = parseStateMd(readFileSync('<state-dir>/STATE.md', 'utf8')).frontmatter;
19
- process.stdout.write(fm['session-start-ref'] ?? '');
20
- " 2>/dev/null)
21
- # Fallback when field absent
22
- [ -z "$SESSION_START_REF" ] && SESSION_START_REF="origin/main"
20
+ import {resolveBaselineRange, isQueryFailure} from '${PLUGIN_ROOT}/scripts/lib/vcs-repo-spec.mjs';
21
+ let ref = '';
22
+ try { ref = String(parseStateMd(readFileSync('<state-dir>/STATE.md', 'utf8')).frontmatter['session-start-ref'] ?? '').trim(); } catch {}
23
+ if (/^[0-9a-f]{7,64}$/.test(ref)) process.stdout.write(ref + ' ' + ref + '..HEAD');
24
+ else {
25
+ const r = resolveBaselineRange({ repoRoot: process.cwd() });
26
+ if (r.ok) process.stdout.write(r.base + ' ' + r.range);
27
+ else process.stderr.write('SESSION_RANGE skipped: ' + (isQueryFailure(r.reason) ? 'unresolvable-ref' : 'no-baseline-ref') + ' (' + r.reason + ')\n');
28
+ }
29
+ ")
30
+ SESSION_START_REF=${SESSION_SPEC%% *} # log base: the pinned SHA, else the resolved base ref
31
+ SESSION_RANGE=${SESSION_SPEC#* } # diff range: <sha>..HEAD (pinned), else <base>...HEAD
23
32
  ```
24
33
 
34
+ **Empty `$SESSION_RANGE` is an explicit skip, never "no changes".** The accessor names the reason on stderr, split by `isQueryFailure()`:
35
+ - `no-baseline-ref` — the repo genuinely has no base to measure against (no remote, no `main`/`master`). A real, benign state: report "no session range" and skip each diff-based step below.
36
+ - `unresolvable-ref` — git could not be asked (not a repo, git missing or failing). A degraded measurement: report it as such and skip; never read the empty file list as a clean session.
37
+
38
+ On either skip `$SESSION_START_REF` is empty too, so skip every LOG-based step as well: `git log --oneline "$SESSION_START_REF..HEAD"` then becomes `git log ..HEAD`, which git reads as `HEAD..HEAD` — it prints nothing and exits 0, an empty log indistinguishable from "no commits this session". The pinned value is accepted only as a hex object name (`/^[0-9a-f]{7,64}$/`), so a STATE.md value such as `--output=x` is never handed to git as an option; anything else falls through to the resolved base.
39
+
25
40
  Read back the session plan that was agreed at the start. For EACH planned item:
26
41
 
27
42
  ### 1.1 Done Items
@@ -36,7 +51,7 @@ Read back the session plan that was agreed at the start. For EACH planned item:
36
51
  Compare the files the plan said would be touched against the files actually changed in the session. Catches both **scope creep** (files changed that were not in any agent's prompt scope) and **incomplete coverage** (files in the plan that were never edited).
37
52
 
38
53
  1. **Planned files** = union of all file paths from agent prompt scopes across all waves. Source: STATE.md Wave History, falling back to the original session plan's per-agent "Files:" specs. Glob patterns are expanded against the working tree at session-start time.
39
- 2. **Actual files** = `git diff --name-only $SESSION_START_REF..HEAD`, where `$SESSION_START_REF` comes from the `session-start-ref` field in STATE.md frontmatter. If the field is missing (older session), fall back to `git diff --name-only origin/main...HEAD`.
54
+ 2. **Actual files** = `git diff --name-only "$SESSION_RANGE"` (accessor above: the pinned `session-start-ref`, else the resolved baseline range). Empty `$SESSION_RANGE` → skip 1.1a with the accessor's reason.
40
55
  3. **Compute discrepancies:**
41
56
  - **Touched** = files in both Planned and Actual
42
57
  - **Unplanned (scope creep)** = files in Actual but not in Planned
@@ -46,7 +61,7 @@ Compare the files the plan said would be touched against the files actually chan
46
61
  - Generated/lock files (`pnpm-lock.yaml`, `*.lock`, `dist/**`, `node_modules/**`) are excluded from both planned and actual sets
47
62
  - The `.claude/`, `.codex/`, and `.cursor/` state directories are excluded — they are session artifacts, not code
48
63
 
49
- > **Scope-drift cross-reference:** the S2 warn-only drift tripwire (below) uses its own separately-maintained filter list — `DRIFT_EXCLUDE_PATTERNS` in `scripts/lib/scope-baseline.mjs` — and is NOT derived from the filters above. That list is the shared filter source for both sides of its ratio IN CODE: `writeBaseline()`'s denominator (`countPlannedFiles()`) and `computeDrift()`'s numerator both call the same internal `filterExcluded()` helper (#894 review finding F1 — previously only the numerator was code-filtered; the denominator relied on a coordinator prose instruction to pre-filter before calling `writeBaseline()`, which is why three earlier PRD revisions shipped a tripwire that read a wrong ratio).
64
+ > **Scope-drift cross-reference:** the S2 warn-only drift tripwire (below) uses its own separately-maintained filter list — `DRIFT_EXCLUDE_PATTERNS` in `scripts/lib/scope-baseline.mjs` — and is NOT derived from the filters above. That list is the shared filter source for both sides of its ratio IN CODE: `writeBaseline()`'s denominator (`countPlannedFiles()`) and `computeDrift()`'s numerator both call the same internal `filterExcluded()` helper — the numerator additionally drops UNTRACKED paths under `.orchestrator/` (`isUntrackedRuntimeWrite()`), a rule keyed on tracked status that only the live numerator has (#894 review finding F1 — previously only the numerator was code-filtered; the denominator relied on a coordinator prose instruction to pre-filter before calling `writeBaseline()`, which is why three earlier PRD revisions shipped a tripwire that read a wrong ratio).
50
65
  5. **Report** in the verification output. Also call `computeDrift({ repoRoot, threshold: 2.0 })` (`scripts/lib/scope-baseline.mjs`) and append its result — warn-only, informational, never blocks close:
51
66
  ```
52
67
  File-level grounding:
@@ -56,6 +71,7 @@ Compare the files the plan said would be touched against the files actually chan
56
71
  - Untouched (planned but not edited): N files [list first 5]
57
72
  - Scope drift: filesRatio X.X (Y actual / Z planned, threshold 2.0) — [breached | ok | skipped: <reason>]
58
73
  ```
74
+ Read `skipped: unresolvable-ref` as "a git query the numerator needs failed", not only "the ref is dead": besides a `session-start-ref` lost to a rebase or force-push it also covers a checkout git cannot read, the working-tree or untracked query failing, output over 64 MiB (ENOBUFS) and the 10 s per-query timeout. Run `git rev-parse --verify <session-start-ref>` before concluding the ref is gone. `skipped: no-baseline-ref` means the repo has no base to measure against (no remotes, no `main`/`master`). A `breached` result can also be an over-report: files already dirty at session start, or a same-checkout peer's files, count as drift (see `computeDrift()`'s JSDoc).
59
75
  6. **Append to session metrics** (`grounding` field in the Phase 1.7 JSONL entry):
60
76
  ```json
61
77
  "grounding": {
@@ -26,6 +26,16 @@ rm -f <state-dir>/wave-scope.json
26
26
 
27
27
  This should have been cleaned up by wave-executor after the final wave, but crashed sessions or interrupted executions may leave it behind. A stale scope manifest from a previous session could incorrectly restrict the next session's enforcement hooks.
28
28
 
29
+ Delete THIS session's dispatch ledger the same way (`hooks/pre-task-scope-disjoint.mjs`, one file per session since #1493 — `skills/wave-executor/references/wave-loop-scope-manifest.md` step 7). Never a file named after another session id: that is a peer's live state.
30
+
31
+ ```bash
32
+ SID="${CLAUDE_CODE_SESSION_ID:-}"
33
+ case "$SID" in
34
+ ''|*[!A-Za-z0-9._-]*) ;; # no id on this harness, or not path-safe: leave the file (inert)
35
+ *) rm -f ".orchestrator/wave-dispatch-scopes.$SID.json" ".orchestrator/wave-dispatch-scopes.$SID.lock" ;;
36
+ esac
37
+ ```
38
+
29
39
  ### 3.1 SSOT Files
30
40
  - Update `STATUS.md` / `STATE.md` if they exist (metrics, dates, status)
31
41
  - Update `CLAUDE.md` (or `AGENTS.md` on Codex CLI) if patterns or conventions changed during this session
@@ -35,7 +45,7 @@ This should have been cleaned up by wave-executor after the final wave, but cras
35
45
 
36
46
  > Skip this subsection if `docs-orchestrator.enabled` config is not `true` (default: `false`). Also skip entirely if `docs-orchestrator.mode` is `off`.
37
47
 
38
- Reads `docs-tasks` from STATE.md frontmatter (written by wave-executor Pre-Wave 1b), computes `CHANGED_FILES` via `git diff --name-only "$SESSION_START_REF..HEAD"`, and runs a per-task verification loop (outcome: `ok`/`partial`/`gap`). In `warn` mode logs results non-blocking; in `strict` mode blocks on any gap and presents an AskUserQuestion override prompt. Emits a `### Documentation Coverage (docs-orchestrator)` block for inclusion in the Phase 6 Final Report.
48
+ Reads `docs-tasks` from STATE.md frontmatter (written by wave-executor Pre-Wave 1b), computes `CHANGED_FILES` via `git diff --name-only "$SESSION_RANGE"` (the `plan-verification.md` accessor; an empty range skips with its named reason), and runs a per-task verification loop (outcome: `ok`/`partial`/`gap`). In `warn` mode logs results non-blocking; in `strict` mode blocks on any gap and presents an AskUserQuestion override prompt. Emits a `### Documentation Coverage (docs-orchestrator)` block for inclusion in the Phase 6 Final Report.
39
49
 
40
50
  **See `phase-3-2-docs-verification.md` for full details.**
41
51
 
@@ -31,7 +31,7 @@
31
31
 
32
32
  1a. **Token Rollup (#644, #1244 — mechanical since #1436)** — no coordinator action. `scripts/emit-session.mjs` (step 2) runs `rollupSessionTokens()` itself whenever the incoming record carries no `total_tokens`: it resolves the session's raw UUID from `.orchestrator/current-session.json` (adopted only when its `semantic_session_id` equals the record's `session_id` and no process-local session id contradicts it), joins the sibling `subagents.jsonl` on `parent_session_id`, and fills `total_tokens`, `total_token_input`, `total_token_output`, the three cache buckets, `total_cost_usd`, `subagents_with_tokens`, `matched_records`, `cost_records_priced`, `cost_records_total` and `_token_schema` for every key the record does not already carry — plus `raw_session_id`. No matching records, an unreadable ledger or a foreign marker omits the fields with a WARN on stderr, never a fabricated `0`. An explicit key on `$METRICS_ENTRY` still wins. The same UUID also backs `session_start_ref` when STATE.md has none (the `head_sha` of the session's own `orchestrator.session.started` event).
33
33
 
34
- **Semantics:** `null` totals mean "no token data was captured for this session" — this is NOT the same as zero cost. Do NOT coerce null to 0 when displaying or summing across sessions. The same holds for `total_cost_usd`: it is omitted whenever `cost_records_priced < cost_records_total` (#1475) — at least one cost-relevant subagent record could not be priced (a model the price table does not know, a token-bearing turn naming no model, a transcript that yielded no tokens, e.g. one over the hook's size cap, or an agent with a start record whose stop found no transcript of its own, mostly `workflow-subagent`) — and also when `cost_records_total` is 0, because there was nothing to price. It is never "$0" for an unknown cost and never a partial sum over the v2 agents the ledger can see. Phantom stops (#939, no start record) are no agents and never count. The two counters are persisted beside it, so a missing cost says whether part of the session was unpriceable or nothing was priceable. Blind spots remain: an agent whose start record fell outside the hook's tail window looks like a phantom, an agent with neither start nor stop record is invisible, and a session spanning the 2026-09-09 schema boundary is priced over its v2 records only (its v1 records are neither priced nor counted as candidates — 4 distinct fleet sessions carry such a cost, 2026-10-01). A subagent whose turns span several models carries a per-model `models_usage` breakdown in `subagents.jsonl` (#1470) and is priced part by part. The emit-session warning names the reason via `match_status` (ledger absent, ledger with no readable records, or records present but none for the own raw UUID); none of these means zero cost.
34
+ **Semantics:** `null` totals mean "no token data was captured for this session" — this is NOT the same as zero cost. Do NOT coerce null to 0 when displaying or summing across sessions. The same holds for `total_cost_usd`: it is omitted whenever `cost_records_priced < cost_records_total` (#1475) — at least one cost-relevant subagent record could not be priced (a model the price table does not know, a token-bearing turn naming no model, a transcript that yielded no tokens, e.g. one over the hook's size cap, or an agent with a start record whose stop found no transcript of its own, mostly `workflow-subagent`, or an agent known only from `schema_version` 1 records — #1487 Pkt 1) — and also when `cost_records_total` is 0, because there was nothing to price. It is never "$0" for an unknown cost and never a partial sum over the v2 agents the ledger can see. Phantom stops (#939, no start record) are no agents and never count. The two counters are persisted beside it, so a missing cost says whether part of the session was unpriceable or nothing was priceable. Blind spots remain: an agent whose start record fell outside the hook's tail window looks like a phantom, an agent with neither start nor stop record is invisible. A session spanning the 2026-09-09 schema boundary therefore reports no cost (its v1 agents are unpriced candidates since 2026-10-02; before, it was priced over its v2 records only — 4 distinct fleet sessions carried such a partial sum). A subagent whose turns span several models carries a per-model `models_usage` breakdown in `subagents.jsonl` (#1470) and is priced part by part. The emit-session warning names the reason via `match_status` (ledger absent, ledger with no readable records, or records present but none for the own raw UUID); none of these means zero cost.
35
35
 
36
36
  **Provenance (#949):** the rollup sums ONLY records carrying `subagent_transcript_found: true` — the flag the producer sets when it read the subagent's own transcript. Pre-#949 records carry the PARENT transcript's running totals and are excluded, so a session made up entirely of them now reports `null` rather than a fabricated sum (73 historical sessions, 96,148,781 phantom tokens, measured 2026-08-11). Two consequences for readers: totals already written into `sessions.jsonl` before 2026-08-11 were produced by the unfiltered recipe and are a series break, not a trend; and `matched_records` counts start records and phantom stops alike, so it is NOT the denominator for a coverage ratio — use `subagents_with_tokens` against the session's real agent count.
37
37
 
@@ -98,7 +98,20 @@
98
98
  VM_DIR="$VAULT_DIR"
99
99
  VM_DIR_SOURCE="env VAULT_DIR"
100
100
  fi
101
- echo "vault-dir=$VM_DIR (Quelle: $VM_DIR_SOURCE)"
101
+ # SO#1490: display form for echo lines only — the home dir becomes `~`
102
+ # through the ONE redactor (`redactHomeDir`, scripts/lib/common.mjs,
103
+ # path-boundary aware: `/Users/bob` never rewrites `/Users/bobby`). The
104
+ # commands below still get the real value. VM_DIR has no producer to emit
105
+ # a display path, so one helper serves every echo; if node cannot load the
106
+ # module it falls back to `…/<basename>` (vault-mirror's #1479 form),
107
+ # never to the raw path. The `--` is load-bearing: without it a value that
108
+ # starts with `-` (vault-dir comes from the committed CLAUDE.md / AGENTS.md) is parsed as
109
+ # a node option, and `--import=data:…` would execute code at /close.
110
+ so_tilde() {
111
+ node --input-type=module -e "import { redactHomeDir } from '$PLUGIN_ROOT/scripts/lib/common.mjs'; process.stdout.write(redactHomeDir(process.argv[1]))" -- "$1" 2>/dev/null \
112
+ || printf '…/%s' "${1##*/}"
113
+ }
114
+ echo "vault-dir=$(so_tilde "$VM_DIR") (Quelle: $VM_DIR_SOURCE)"
102
115
 
103
116
  # Quality-gate thresholds (PRD F1.2). Defaults match
104
117
  # scripts/vault-mirror.mjs (400 chars / 0.5 confidence). The nested key
@@ -108,9 +121,14 @@
108
121
  VM_QUALITY_CONFIDENCE=$(echo "$CONFIG" | jq -r '."vault-mirror".quality."min-confidence" // 0.5')
109
122
  VM_VAULT_NAME=$(echo "$CONFIG" | jq -r '."vault-integration"."vault-name" // empty')
110
123
 
124
+ # #1503: name the repo being mirrored and pass the ledger ABSOLUTE. The
125
+ # namespace is that repo's identity, never the cwd's; a --source that lies in
126
+ # a different repo than --repo-root is refused (exit 2, source-repo-mismatch)
127
+ # instead of being written over another project's notes.
111
128
  VM_OUTPUT=$(node "$PLUGIN_ROOT/scripts/vault-mirror.mjs" \
112
129
  --vault-dir "$VM_DIR" \
113
- --source .orchestrator/metrics/sessions.jsonl \
130
+ --repo-root "$PWD" \
131
+ --source "$PWD/.orchestrator/metrics/sessions.jsonl" \
114
132
  --kind session \
115
133
  --session-id "$SESSION_ID" \
116
134
  ${VM_VAULT_NAME:+--vault-name "$VM_VAULT_NAME"} \
@@ -118,9 +136,11 @@
118
136
  --quality-min-confidence "$VM_QUALITY_CONFIDENCE" 2>&1)
119
137
  VM_EXIT=$?
120
138
 
121
- # Surface script output so user can see skipped-handwritten results
139
+ # Surface script output so user can see skipped-handwritten results.
140
+ # `printf '%s\n'`, not `echo`: zsh's echo expands the `\n` escapes inside
141
+ # JSON strings and would split an action line in two.
122
142
  if [[ -n "$VM_OUTPUT" ]]; then
123
- echo "$VM_OUTPUT"
143
+ printf '%s\n' "$VM_OUTPUT"
124
144
  fi
125
145
 
126
146
  if [[ $VM_EXIT -ne 0 ]]; then
@@ -133,18 +153,31 @@
133
153
  echo "WARNING: vault-mirror exited $VM_EXIT — session metrics were NOT mirrored to the vault. Set vault-integration.mode: strict to block on this error."
134
154
  fi
135
155
  else
136
- # Parse the destination path from the script's JSON output (one JSON line per action)
137
- VM_DEST=$(echo "$VM_OUTPUT" | jq -r 'select(.action == "created" or .action == "updated") | .path' 2>/dev/null | head -1)
156
+ # Parse the action lines. stdout carries one JSON object per entry, but
157
+ # this capture also holds stderr (`2>&1`): `SKIP …` / `WARN …` lines,
158
+ # routine since #1503. A plain `jq` stops at the first non-JSON line
159
+ # (exit 5) and silently loses every action after it, so read raw lines
160
+ # and keep only the ones that parse as JSON objects.
161
+ vm_actions() { printf '%s\n' "$VM_OUTPUT" | jq -R -r "fromjson? | objects | $1" 2>/dev/null; }
162
+
163
+ VM_DEST=$(vm_actions 'select(.action == "created" or .action == "updated") | .path' | head -1)
138
164
  if [[ -n "$VM_DEST" ]]; then
139
165
  echo "Mirrored session summary to $VM_DEST"
140
166
  fi
141
167
 
142
168
  # Quality gate summary (PRD F1.2): count entries skipped because they
143
169
  # failed the quality filter, so the operator can tune thresholds.
144
- VM_QUALITY_SKIP=$(echo "$VM_OUTPUT" | jq -rc 'select(.action == "skipped-quality-low")' 2>/dev/null | wc -l | tr -d ' ')
170
+ VM_QUALITY_SKIP=$(vm_actions 'select(.action == "skipped-quality-low") | .action' | wc -l | tr -d ' ')
145
171
  if [[ "${VM_QUALITY_SKIP:-0}" -gt 0 ]]; then
146
172
  echo "vault-mirror: ${VM_QUALITY_SKIP} entry/entries skipped by quality gate (set vault-mirror.quality.min-narrative-chars / min-confidence to tune)"
147
173
  fi
174
+
175
+ # #1503: session notes left untouched because they were rendered from a
176
+ # record that is not in this ledger (another repo's, or an id collision).
177
+ VM_FOREIGN_SKIP=$(vm_actions 'select(.action == "skipped-foreign-owner") | .action' | wc -l | tr -d ' ')
178
+ if [[ "${VM_FOREIGN_SKIP:-0}" -gt 0 ]]; then
179
+ echo "vault-mirror: ${VM_FOREIGN_SKIP} note(s) left untouched — owned by a session record outside this ledger (skipped-foreign-owner; see the SKIP lines above)"
180
+ fi
148
181
  fi
149
182
 
150
183
  # ── Durable Narrative Mirror (#675) ────────────────────────────────────
@@ -175,7 +208,7 @@
175
208
 
176
209
  if [[ $NM_EXIT -ne 0 ]]; then
177
210
  if [[ "$VM_MODE" == "strict" ]]; then
178
- echo "ERROR: narrative-mirror failed (exit $NM_EXIT) — session close blocked (vault-integration.mode=strict): $NM_OUTPUT"
211
+ echo "ERROR: narrative-mirror failed (exit $NM_EXIT) — session close blocked (vault-integration.mode=strict): $(so_tilde "$NM_OUTPUT")"
179
212
  echo "Fix the narrative mirror issue or set vault-integration.mode: warn to downgrade to a warning."
180
213
  exit 1
181
214
  else
@@ -185,7 +218,9 @@
185
218
  else
186
219
  # Surface the JSON result so the operator can see skipped-* / written outcomes.
187
220
  NM_ACTION=$(echo "$NM_OUTPUT" | jq -r '.action // empty' 2>/dev/null)
221
+ # `.path` is absolute (under the vault, i.e. the home dir) — display form only.
188
222
  NM_PATH=$(echo "$NM_OUTPUT" | jq -r '.path // empty' 2>/dev/null)
223
+ [[ -n "$NM_PATH" ]] && NM_PATH=$(so_tilde "$NM_PATH")
189
224
  if [[ "$NM_ACTION" == "written" && -n "$NM_PATH" ]]; then
190
225
  echo "Mirrored durable session narrative to $NM_PATH"
191
226
  elif [[ -n "$NM_ACTION" ]]; then
@@ -209,4 +244,4 @@
209
244
 
210
245
  > **Host-local overrides.** `enabled`/`mode` above are the values AFTER the host switch: env `SO_VAULT_INTEGRATION=off|warn|strict` > owner.yaml `vault-integration: { enabled: false | mode: … }` > committed. The host may only lower the committed level, never raise it; `."vault-integration"."host-override"` names the tier that lowered it (`env:SO_VAULT_INTEGRATION`, `owner.yaml`) and is `null` otherwise. `."vault-integration"."vault-dir-source"` (`env`, `match`, `owner`, `committed`) says where the vault path came from. See `docs/session-config-reference.md` § Vault Integration.
211
246
 
212
- > **Hand-written note protection:** `vault-mirror.mjs` checks for a `_generator: session-orchestrator-vault-mirror@1` marker before overwriting any existing file. When it skips an existing hand-written note it emits a JSON line `{"action":"skipped-handwritten","path":"<path>","kind":"<kind>","id":"<id>"}` — the step above surfaces this output so the user can see the result. Action names: `created`, `updated`, `skipped-noop`, `skipped-handwritten`, `skipped-collision-resolved`, `skipped-invalid` (entry failed required-field validation, or the mapper crashed rendering an otherwise-parseable record — the latter case carries `reason: "mapper-crash"`, #718), `skipped-quality-low` (entry failed quality gate — PRD F1.2; line carries a `reason` field).
247
+ > **Hand-written note protection:** `vault-mirror.mjs` checks for a `_generator: session-orchestrator-vault-mirror@1` marker before overwriting any existing file. When it skips an existing hand-written note it emits a JSON line `{"action":"skipped-handwritten","path":"<path>","kind":"<kind>","id":"<id>"}` — the step above surfaces this output so the user can see the result. Action names: `created`, `updated`, `skipped-noop`, `skipped-handwritten`, `skipped-collision-resolved`, `skipped-foreign-owner` (sessions: the note at the target path was rendered from a record outside this ledger — `source-record` mismatch — or names a different session id; file left untouched, line carries a `reason`, counted by the step above, #1503), `skipped-invalid` (entry failed required-field validation, or the mapper crashed rendering an otherwise-parseable record — the latter case carries `reason: "mapper-crash"`, #718), `skipped-quality-low` (entry failed quality gate — PRD F1.2; line carries a `reason` field).
@@ -222,7 +222,7 @@ Group issues by:
222
222
 
223
223
  ## Phase 4: SSOT & Environment Check
224
224
 
225
- > Always runs; every finding is a NON-BLOCKING banner in the Session Overview (never a gate — the Full Gate is the Quality wave's job). Covers SSOT freshness, the Baseline quality commands (resolved `.orchestrator/policy/quality-gates.json` → Session Config → defaults, each availability-checked with `command -v`), Pencil design status, plugin freshness, and the banner-probe family registered in `scripts/lib/session-start-probes.mjs` (bootstrap-lock-freshness, vault-staleness, telemetry-flush-health, ci-status, qg-command-drift, peer-cards, loop-readiness, instruction-budget, maintenance-due, sessions-staleness, sessions-integrity, owner-config, moc-staleness, context-coverage, claude-md-budget-lint, tests:src-ratio, project-hygiene, mirror-issues, git-config-drift — 19 total). Per-probe module path, return contract and exact banner wording: [`references/phase-4-ssot-environment-check.md`](references/phase-4-ssot-environment-check.md).
225
+ > Always runs; every finding is a NON-BLOCKING banner in the Session Overview (never a gate — the Full Gate is the Quality wave's job). Covers SSOT freshness, the Baseline quality commands (resolved `.orchestrator/policy/quality-gates.json` → Session Config → defaults, each availability-checked with `command -v`), Pencil design status, plugin freshness, and the banner-probe family registered in `scripts/lib/session-start-probes.mjs` (bootstrap-lock-freshness, vault-staleness, telemetry-flush-health, ci-status, qg-command-drift, peer-cards, loop-readiness, instruction-budget, maintenance-due, sessions-staleness, sessions-integrity, owner-config, moc-staleness, context-coverage, claude-md-budget-lint, tests:src-ratio, project-hygiene, mirror-issues, git-config-drift, events-retention — 20 total). Per-probe module path, return contract and exact banner wording: [`references/phase-4-ssot-environment-check.md`](references/phase-4-ssot-environment-check.md).
226
226
 
227
227
  ## Phase 4.5: Resource Health (v3.1.0)
228
228
 
@@ -354,7 +354,7 @@ Invokes `buildLiveSignals` (single SSOT for the signals shape) then `selectMode(
354
354
 
355
355
  > Skip silently when `persistence: false` (no ledger for the event). Runs BEFORE the first writing action; everything not stated here is in `skills/_shared/fleet-protocol.md`.
356
356
 
357
- Build the check-in JSON (fields per the reference; `session` from `.orchestrator/current-session.json`, `modus` = session type, `kandidaten` = the issue numbers you intend to claim) and pipe it into `node "${CLAUDE_PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:-$PLUGIN_ROOT}}/scripts/fleet-checkin.mjs"` — it validates, stores `checkin/<session_id>.json` (0600) under `~/.config/navigator/` and emits `orchestrator.fleet.checkin`. Its stdout carries `navigator_state`: on `active`, and only when a peer named `navigator-*` is reachable via `ListAgents`, send ONE hint naming the file path — never expect or wait for a reply (CSM-004). Then read `auflagen/<session_id>.json`; if it is still absent 10 min after `checkin.zeit`, the reference's Standard-Auflagen apply. Reading work and edits in the own worktree continue meanwhile.
357
+ Build the check-in JSON (fields per the reference; `session` from `.orchestrator/current-session.json`, `modus` = session type, `kandidaten` = the issue numbers you intend to claim) and pipe it into `node "${CLAUDE_PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:-$PLUGIN_ROOT}}/scripts/fleet-checkin.mjs"` — it validates, stores `checkin/<session_id>.json` (0600) under `~/.config/navigator/` and emits `orchestrator.fleet.checkin`. Its stdout carries `navigator_state` and `navigator_adresse` (the lease's validated `adresse`, else `null`): on `active`, and only when `navigator_adresse` is non-null and reachable via `ListAgents`, send ONE hint to exactly that name, naming the file path — never expect or wait for a reply (CSM-004). Then read `auflagen/<session_id>.json`; if it is still absent at the stdout's `fallback_due_at`, the reference's Standard-Auflagen apply (the CLI computes that deadline: `zeit` plus `CHECKIN_FALLBACK_MIN` while a navigator is active, `zeit` itself — immediately — otherwise). Reading work and edits in the own worktree continue meanwhile.
358
358
 
359
359
  ## Phase 8: Structured Presentation & Q&A
360
360
 
@@ -418,7 +418,7 @@ After user alignment:
418
418
  | `phase-2-5-docs-planning.md` | Phase 2.5 full procedural body — docs-orchestrator config, audience detection, AUQ confirmation, result block emission, non-overlap rules |
419
419
  | (inline) Phase 2.6 | Steering docs gate + load — reads `.orchestrator/steering/{product,tech,structure}.md`; silent no-op when directory absent |
420
420
  | `references/phase-2-7-portfolio-snapshot.md` | Phase 2.7 full procedural body — GitLab Portfolio Snapshot: dry-run aggregation banner; gated on `gitlab-portfolio.enabled: true` + `vault-integration.enabled: true`; dispatches `scripts/lib/gitlab-portfolio/cli.mjs --dry-run`; 8s timeout; never blocks session-start |
421
- | `references/phase-4-ssot-environment-check.md` | Phase 4 full procedural body — SSOT freshness, Baseline quality-command resolution + `command -v` availability check, Pencil status, plugin/`bootstrap.lock` freshness, and the 19-probe banner family (module path, return contract and exact wording per probe) |
421
+ | `references/phase-4-ssot-environment-check.md` | Phase 4 full procedural body — SSOT freshness, Baseline quality-command resolution + `command -v` availability check, Pencil status, plugin/`bootstrap.lock` freshness, and the 20-probe banner family (module path, return contract and exact wording per probe) |
422
422
  | `phase-4-5-resource-health.md` | Phase 4.5 full procedural body — resource probe, adaptive thresholds table, AUQ presentation, session-plan cap handoff |
423
423
  | `references/phase-6-5-forced-reads.md` | Phases 6.5.1 + 6.5.2 full procedural bodies — the two forced-read STATE.md continuity slots: `## What Not To Retry` (`readWhatNotToRetry`, #623) and `## Open Questions` (`readOpenQuestions`, #772), both rendered unconditionally and wrapped via `wrapHistorical` from `scripts/lib/historical-guard.mjs` |
424
424
  | `references/phase-6-6-project-intelligence.md` | Phase 6.6 full procedural body — active-learnings surface from `.orchestrator/metrics/learnings.jsonl`, cap+rank via `learnings-surface-top-n`, grouping by type, and the Surface health block |