session-orchestrator 5.3.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 (260) hide show
  1. package/.agents/skills/claude-md-drift-check/SKILL.md +1 -1
  2. package/.agents/skills/test-audit/SKILL.md +21 -0
  3. package/.agents/skills/test-audit/agents/openai.yaml +5 -0
  4. package/.claude-plugin/marketplace.json +1 -1
  5. package/.claude-plugin/plugin.json +1 -1
  6. package/.codex-plugin/plugin.json +1 -1
  7. package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
  8. package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
  9. package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
  10. package/.cursor/commands/test-audit.md +14 -0
  11. package/.cursor/rules/060-evolve.mdc +28 -13
  12. package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
  13. package/.cursor/skills/test-audit/SKILL.md +12 -0
  14. package/.cursor-plugin/plugin.json +1 -1
  15. package/.orchestrator/policy/blocked-commands.json +1 -1
  16. package/AGENTS.md +9 -31
  17. package/CHANGELOG.md +162 -0
  18. package/README.md +14 -10
  19. package/docs/agent-authoring.md +2 -2
  20. package/docs/baseline.md +10 -1
  21. package/docs/ci-setup.md +154 -4
  22. package/docs/components.md +11 -11
  23. package/docs/events-schema.md +25 -19
  24. package/docs/install.md +14 -2
  25. package/docs/migration-v4.md +24 -17
  26. package/docs/persona-panel.md +5 -4
  27. package/docs/plugin-architecture-v3.md +1 -1
  28. package/docs/rule-authoring.md +27 -12
  29. package/docs/scope-collision-guard.md +40 -16
  30. package/docs/session-config-reference.md +61 -12
  31. package/docs/session-config-template.md +11 -2
  32. package/docs/vault-docs-architecture.md +8 -4
  33. package/hooks/_lib/hook-import-set.json +42 -29
  34. package/hooks/_lib/lock-bootstrap.mjs +118 -26
  35. package/hooks/config-protection.mjs +1 -1
  36. package/hooks/enforce-commands.mjs +28 -13
  37. package/hooks/enforce-scope.mjs +201 -23
  38. package/hooks/hooks-codex.json +1 -1
  39. package/hooks/hooks-cursor.json +5 -0
  40. package/hooks/hooks-pi.json +5 -0
  41. package/hooks/hooks.json +6 -1
  42. package/hooks/on-session-start.mjs +57 -2
  43. package/hooks/on-stop.mjs +70 -168
  44. package/hooks/post-bash-write-verify.mjs +98 -17
  45. package/hooks/post-edit-validate.mjs +15 -5
  46. package/hooks/post-tool-batch-wave-signal.mjs +32 -152
  47. package/hooks/pre-bash-memory-propose-audit.mjs +71 -10
  48. package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
  49. package/hooks/pre-bash-staging-fence.mjs +29 -8
  50. package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
  51. package/hooks/pre-task-scope-disjoint.mjs +1143 -279
  52. package/hooks/subagent-telemetry.mjs +96 -19
  53. package/hooks/wave-scope-commit-guard.mjs +168 -72
  54. package/package.json +2 -6
  55. package/pi/prompts/test-audit.md +12 -0
  56. package/rules/README.md +6 -1
  57. package/rules/_index.md +3 -3
  58. package/rules/always-on/bash-harness-pitfalls.md +7 -1
  59. package/rules/opt-in-domain/prompt-caching.md +5 -1
  60. package/rules/opt-in-stack/backend-data.md +9 -1
  61. package/rules/opt-in-stack/backend.md +7 -1
  62. package/rules/opt-in-stack/frontend.md +6 -1
  63. package/rules/opt-in-stack/security-web.md +7 -1
  64. package/rules/opt-in-stack/swift.md +5 -1
  65. package/scripts/apply-session-learnings.mjs +513 -0
  66. package/scripts/archive-closed-prds.mjs +40 -12
  67. package/scripts/autopilot.mjs +57 -54
  68. package/scripts/backfill-abandoned-sessions.mjs +81 -6
  69. package/scripts/backfill-learnings-from-vault.mjs +49 -22
  70. package/scripts/baseline-archetypes.mjs +1 -1
  71. package/scripts/check-package-manager.mjs +62 -5
  72. package/scripts/codex-install.mjs +3 -0
  73. package/scripts/cursor-install.mjs +47 -6
  74. package/scripts/emit-session.mjs +298 -2
  75. package/scripts/export-hw-learnings.mjs +40 -10
  76. package/scripts/fleet-checkin.mjs +170 -0
  77. package/scripts/lib/agent-frontmatter.mjs +3 -3
  78. package/scripts/lib/auto-dream.mjs +7 -6
  79. package/scripts/lib/autopilot/flags.mjs +6 -1
  80. package/scripts/lib/autopilot/loop.mjs +32 -13
  81. package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
  82. package/scripts/lib/baseline-archetypes.mjs +41 -22
  83. package/scripts/lib/blocked-commands-policy.mjs +40 -0
  84. package/scripts/lib/common.mjs +41 -0
  85. package/scripts/lib/config/host-paths.mjs +79 -0
  86. package/scripts/lib/config/persona-gate-wave.mjs +3 -3
  87. package/scripts/lib/config/reaper.mjs +7 -6
  88. package/scripts/lib/config/vault-integration.mjs +151 -16
  89. package/scripts/lib/config-schema.mjs +13 -0
  90. package/scripts/lib/config.mjs +75 -10
  91. package/scripts/lib/eval/engine.mjs +13 -0
  92. package/scripts/lib/events-retention-banner.mjs +393 -0
  93. package/scripts/lib/events-rotation.mjs +21 -6
  94. package/scripts/lib/events.mjs +72 -5
  95. package/scripts/lib/fleet-protocol.mjs +288 -0
  96. package/scripts/lib/gates/gate-baseline.mjs +13 -3
  97. package/scripts/lib/gates/gate-full.mjs +7 -2
  98. package/scripts/lib/gates/gate-helpers.mjs +93 -2
  99. package/scripts/lib/gates/gate-incremental.mjs +23 -22
  100. package/scripts/lib/gates/gate-per-file.mjs +16 -7
  101. package/scripts/lib/hardening.mjs +1 -0
  102. package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
  103. package/scripts/lib/instruction-budget-guard.mjs +77 -11
  104. package/scripts/lib/learnings/expiry-sweep.mjs +420 -22
  105. package/scripts/lib/learnings/io.mjs +274 -37
  106. package/scripts/lib/learnings/schema.mjs +163 -15
  107. package/scripts/lib/learnings/sizing-subject.mjs +2 -2
  108. package/scripts/lib/lock-reaper.mjs +38 -14
  109. package/scripts/lib/maintenance-due-banner.mjs +23 -8
  110. package/scripts/lib/memory-proposals/schema.mjs +11 -5
  111. package/scripts/lib/named-baseline-resolver.mjs +53 -28
  112. package/scripts/lib/named-vault-resolver.mjs +147 -5
  113. package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
  114. package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
  115. package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
  116. package/scripts/lib/orphan-reaper/reaper-audit.mjs +323 -0
  117. package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
  118. package/scripts/lib/orphan-reaper/scan-throttle.mjs +144 -0
  119. package/scripts/lib/orphan-reaper/scan.mjs +510 -0
  120. package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
  121. package/scripts/lib/orphan-reaper.mjs +47 -1315
  122. package/scripts/lib/owner-yaml.mjs +104 -6
  123. package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
  124. package/scripts/lib/platform.mjs +206 -2
  125. package/scripts/lib/plugin-update-banner.mjs +3 -1
  126. package/scripts/lib/process-group.mjs +134 -22
  127. package/scripts/lib/project-hygiene.mjs +21 -1
  128. package/scripts/lib/quality-gate.mjs +45 -5
  129. package/scripts/lib/resource-probe.mjs +1 -1
  130. package/scripts/lib/rule-loader.mjs +100 -62
  131. package/scripts/lib/rules-sync.mjs +27 -12
  132. package/scripts/lib/scope-baseline.mjs +119 -29
  133. package/scripts/lib/scope-echo.mjs +7 -0
  134. package/scripts/lib/scope-gate.mjs +194 -27
  135. package/scripts/lib/session-close-backfill.mjs +129 -4
  136. package/scripts/lib/session-end/tail-runner.mjs +10 -5
  137. package/scripts/lib/session-lock-shape.mjs +6 -3
  138. package/scripts/lib/session-lock.mjs +320 -67
  139. package/scripts/lib/session-record-repair.mjs +183 -26
  140. package/scripts/lib/session-registry.mjs +67 -17
  141. package/scripts/lib/session-schema/constants.mjs +30 -4
  142. package/scripts/lib/session-schema/validator.mjs +28 -1
  143. package/scripts/lib/session-start-probes.mjs +242 -68
  144. package/scripts/lib/session-token-rollup.mjs +252 -54
  145. package/scripts/lib/sessions-canonical.mjs +100 -9
  146. package/scripts/lib/sessions-staleness-banner.mjs +3 -1
  147. package/scripts/lib/sh-quote.mjs +29 -0
  148. package/scripts/lib/subagents-schema.mjs +30 -3
  149. package/scripts/lib/tail-window.mjs +27 -3
  150. package/scripts/lib/telemetry/pricing.mjs +121 -71
  151. package/scripts/lib/telemetry/sync.mjs +17 -0
  152. package/scripts/lib/tests-src-ratio.mjs +215 -23
  153. package/scripts/lib/tmux-layout/layouts.mjs +1 -11
  154. package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
  155. package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
  156. package/scripts/lib/ux-grill/reconcile.mjs +1 -1
  157. package/scripts/lib/validate/check-agents.mjs +1 -1
  158. package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
  159. package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
  160. package/scripts/lib/validate/check-test-value-bans.mjs +388 -27
  161. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  162. package/scripts/lib/validate/check-unwired-features.mjs +1 -1
  163. package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
  164. package/scripts/lib/validate-vendored-rules.mjs +206 -81
  165. package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
  166. package/scripts/lib/vault-mirror/namespace.mjs +47 -26
  167. package/scripts/lib/vault-mirror/process.mjs +350 -62
  168. package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
  169. package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
  170. package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
  171. package/scripts/lib/vault-mirror/telemetry.mjs +25 -11
  172. package/scripts/lib/vault-status/board-writer.mjs +53 -12
  173. package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
  174. package/scripts/lib/vcs-repo-spec.mjs +8 -0
  175. package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
  176. package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
  177. package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
  178. package/scripts/lib/wave-resource-gate.mjs +34 -8
  179. package/scripts/materialize-wave-scope.mjs +7 -4
  180. package/scripts/memory-propose.mjs +18 -5
  181. package/scripts/release-session-lock.mjs +2 -1
  182. package/scripts/release.mjs +11 -3
  183. package/scripts/repair-invalid-sessions.mjs +37 -4
  184. package/scripts/run-migrate-v2-cross-repo.mjs +171 -31
  185. package/scripts/run-quality-gate.mjs +331 -307
  186. package/scripts/self-update.mjs +229 -0
  187. package/scripts/site-numbers.mjs +163 -5
  188. package/scripts/sweep-expired-learnings.mjs +603 -20
  189. package/scripts/sweep-expired-rules.mjs +2 -1
  190. package/scripts/validate-plugin.mjs +7 -0
  191. package/scripts/validate-wave-scope.mjs +21 -2
  192. package/scripts/vault-backfill.mjs +50 -37
  193. package/scripts/vault-integration-watcher.mjs +2 -1
  194. package/scripts/vault-mirror.mjs +238 -75
  195. package/scripts/wave-scope-binding.mjs +2 -1
  196. package/skills/_shared/config-reading.md +3 -3
  197. package/skills/_shared/fleet-protocol.md +134 -0
  198. package/skills/_shared/state-ownership.md +41 -12
  199. package/skills/autopilot/SKILL.md +25 -11
  200. package/skills/bootstrap/SKILL.md +6 -4
  201. package/skills/bootstrap/_shared-template.md +97 -2
  202. package/skills/bootstrap/fast-template.md +4 -1
  203. package/skills/bootstrap/private-contract.md +2 -1
  204. package/skills/bootstrap/public-fallback.md +6 -3
  205. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
  206. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
  207. package/skills/bootstrap/standard-template.md +2 -0
  208. package/skills/claude-md-drift-check/SKILL.md +10 -10
  209. package/skills/claude-md-drift-check/checker.mjs +26 -18
  210. package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
  211. package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
  212. package/skills/discovery/probes/vault-staleness.mjs +9 -3
  213. package/skills/docs-orchestrator/SKILL.md +12 -5
  214. package/skills/docs-orchestrator/audience-mapping.md +5 -3
  215. package/skills/evolve/SKILL.md +13 -4
  216. package/skills/evolve/references/evolve-analyze-mode.md +42 -18
  217. package/skills/memory-cleanup/SKILL.md +1 -1
  218. package/skills/persona-panel/SKILL.md +2 -2
  219. package/skills/persona-panel/persona-format.md +2 -2
  220. package/skills/persona-panel/presets/designer-lens.md +1 -1
  221. package/skills/persona-panel/presets/engineer-lens.md +1 -1
  222. package/skills/persona-panel/presets/pm-lens.md +1 -1
  223. package/skills/plan/mode-retro.md +19 -4
  224. package/skills/quality-gates/SKILL.md +6 -4
  225. package/skills/remote-offload/SKILL.md +4 -2
  226. package/skills/session-end/learning-patterns.md +23 -13
  227. package/skills/session-end/metrics-collection.md +6 -7
  228. package/skills/session-end/phase-3-2-docs-verification.md +7 -6
  229. package/skills/session-end/plan-verification.md +25 -9
  230. package/skills/session-end/references/phase-3-documentation-updates.md +13 -3
  231. package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
  232. package/skills/session-end/references/session-summary-template.md +1 -1
  233. package/skills/session-end/session-metrics-write.md +65 -68
  234. package/skills/session-plan/SKILL.md +0 -3
  235. package/skills/session-start/SKILL.md +8 -2
  236. package/skills/session-start/references/operations-contract.md +6 -0
  237. package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
  238. package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
  239. package/skills/session-start/references/phase-4-ssot-environment-check.md +10 -3
  240. package/skills/test-audit/SKILL.md +152 -0
  241. package/skills/test-audit/references/campaign.md +111 -0
  242. package/skills/test-audit/references/mutate.sh +50 -0
  243. package/skills/vault-mirror/SKILL.md +21 -11
  244. package/skills/wave-executor/SKILL.md +2 -2
  245. package/skills/wave-executor/circuit-breaker.md +1 -1
  246. package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
  247. package/skills/wave-executor/references/wave-loop-dispatch.md +11 -1
  248. package/skills/wave-executor/references/wave-loop-review.md +4 -6
  249. package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
  250. package/templates/personas/README.md +5 -5
  251. package/templates/personas/accounting-compliance.v1.md +1 -1
  252. package/templates/personas/accounting-tax-advisor.v1.md +1 -1
  253. package/templates/personas/buyer-p1-cto.v1.md +1 -1
  254. package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
  255. package/templates/personas/buyer-p3-build.v1.md +1 -1
  256. package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
  257. package/templates/personas/buyer-p5-solo.v1.md +1 -1
  258. package/templates/personas/buyer-p6-ld.v1.md +1 -1
  259. package/templates/personas/klima-ai-expert.v1.md +1 -1
  260. package/templates/personas/klima-physicist.v1.md +1 -1
@@ -29,68 +29,15 @@
29
29
 
30
30
  > **#701.2 DOC NOTE — `completed_at >= started_at` guard:** This invariant is enforced mechanically by `scripts/emit-session.mjs`. The writer applies `clampTimestampsMonotonic()` (from `scripts/lib/session-schema/timestamps.mjs`) before `validateSession()`, clamping any inversion of `completed_at < started_at` to `started_at` and recording forensics in `_clamped: true` / `_original_completed_at`. Previously-inverted entries (e.g. `main-2026-06-21-session-4`) are already corrected. **No per-session coordinator action is needed** — the writer enforces the invariant at write time. Do not add defensive clamping logic here; the canonical guard lives in `emit-session.mjs`.
31
31
 
32
- 1a. **Token Rollup (#644, extended #1244)** — before emitting the JSONL record, aggregate token usage from `subagents.jsonl` and merge the rollup fields onto the in-memory `$METRICS_ENTRY` JSON object. The join key is the session's UUID (`session_id` / `parent_session_id` on subagents.jsonl — the UUID form, not the semantic slug).
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
- **This prose step is the ONLY path by which the hook's token fix reaches `sessions.jsonl`** — `rollupSessionTokens()` has no other production caller, so skipping or partially copying this step leaves the entire #1244 cache-token fix inert at the session level while the per-agent ledger looks correct.
35
-
36
- **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`: `null` means "at least one agent ran on a model the price table does not know", never "$0".
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.
37
35
 
38
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.
39
37
 
40
38
  **Schema boundary (#1244, 2026-09-09):** from `schema_version: 2` a subagent record's `token_input` is BILLABLE PROMPT VOLUME (uncached + cache_read + cache_creation); v1 records held raw uncached input only and are therefore EXCLUDED from every total and reported as `legacy_v1_records`. Sessions spanning the boundary are a second series break — do not trend across it.
41
39
 
42
- Example (coordinator pseudo-code — adapt to your shell/JS context):
43
-
44
- ```js
45
- // Available from scripts/lib/session-token-rollup.mjs
46
- import { rollupSessionTokens } from '../../scripts/lib/session-token-rollup.mjs';
47
-
48
- const rollup = rollupSessionTokens({ parentSessionId: SESSION_UUID });
49
- // rollup: { total_token_input, total_token_output, subagents_with_tokens, matched_records,
50
- // total_token_input_uncached, total_token_cache_read, total_token_cache_creation,
51
- // total_cost_usd, cost_records_priced, cost_records_total, legacy_v1_records,
52
- // _token_schema }
53
- // Merge into the record — every field below is optional in the session schema (additive).
54
- metricsEntry.total_token_input = rollup.total_token_input; // number | null
55
- metricsEntry.total_token_output = rollup.total_token_output; // number | null
56
- metricsEntry.subagents_with_tokens = rollup.subagents_with_tokens; // number (0 when no coverage)
57
- metricsEntry.total_token_input_uncached = rollup.total_token_input_uncached; // number | null
58
- metricsEntry.total_token_cache_read = rollup.total_token_cache_read; // number | null
59
- metricsEntry.total_token_cache_creation = rollup.total_token_cache_creation; // number | null
60
- metricsEntry.total_cost_usd = rollup.total_cost_usd; // number | null (null = unknown model)
61
- metricsEntry._token_schema = rollup._token_schema; // 2
62
- ```
63
-
64
- Report `cost_records_priced / cost_records_total` and `legacy_v1_records` in the session summary when either is non-zero — an unpriced or excluded remainder is what makes a cost figure honest.
65
-
66
- Or, from a bash context, call the rollup via a helper node invocation and `jq`-merge the fields into `$METRICS_ENTRY` before step 2:
67
-
68
- ```bash
69
- ROLLUP_JSON=$(node -e "
70
- import('$(dirname "$0")/../scripts/lib/session-token-rollup.mjs').then(m => {
71
- const r = m.rollupSessionTokens({ parentSessionId: process.env.SESSION_UUID });
72
- process.stdout.write(JSON.stringify(r));
73
- });
74
- " 2>/dev/null) || ROLLUP_JSON='{}'
75
-
76
- # Merge token fields into METRICS_ENTRY (null for fields absent from rollup)
77
- METRICS_ENTRY=$(printf '%s' "$METRICS_ENTRY" | jq \
78
- --argjson r "${ROLLUP_JSON:-{}}" \
79
- '. + {
80
- total_token_input: ($r.total_token_input // null),
81
- total_token_output: ($r.total_token_output // null),
82
- subagents_with_tokens: ($r.subagents_with_tokens // 0),
83
- total_token_input_uncached: ($r.total_token_input_uncached // null),
84
- total_token_cache_read: ($r.total_token_cache_read // null),
85
- total_token_cache_creation: ($r.total_token_cache_creation // null),
86
- total_cost_usd: ($r.total_cost_usd // null),
87
- _token_schema: ($r._token_schema // 2)
88
- }')
89
- ```
90
-
91
- **If the rollup call fails** (e.g., `subagents.jsonl` absent, parse error), set the numeric totals to `null` / `0` and continue — the rollup is non-blocking. A session without token data still writes cleanly.
92
-
93
- 2. Append the prepared JSONL entry (from Phase 1.7, now including token fields from step 1a) via the validating writer `scripts/emit-session.mjs` (issue #249):
40
+ 2. Append the prepared JSONL entry (from Phase 1.7; the writer itself adds the token fields — step 1a) via the validating writer `scripts/emit-session.mjs` (issue #249):
94
41
  ```bash
95
42
  printf '%s' "$METRICS_ENTRY" | node "$PLUGIN_ROOT/scripts/emit-session.mjs" --file .orchestrator/metrics/sessions.jsonl
96
43
  EMIT_EXIT=$?
@@ -135,11 +82,36 @@
135
82
  ```bash
136
83
  VM_ENABLED=$(echo "$CONFIG" | jq -r '."vault-integration".enabled // false')
137
84
  VM_MODE=$(echo "$CONFIG" | jq -r '."vault-integration".mode // "warn"')
138
-
139
- if [[ "$VM_ENABLED" == "true" && "$VM_MODE" != "off" ]]; then
140
- # Resolve vault directory: config field takes precedence, env var as fallback
85
+ VM_HOST_OVERRIDE=$(echo "$CONFIG" | jq -r '."vault-integration"."host-override" // empty')
86
+
87
+ if [[ -n "$VM_HOST_OVERRIDE" && ( "$VM_ENABLED" != "true" || "$VM_MODE" == "off" ) ]]; then
88
+ # SO#1448: this host switched the mirror off on purpose (env
89
+ # SO_VAULT_INTEGRATION or owner.yaml `vault-integration:`). Say so, instead of
90
+ # running into vault-mirror's exit 2 (missing-vault-dir) and a strict block.
91
+ echo "vault-mirror: bewusst aus auf diesem Host ($VM_HOST_OVERRIDE)"
92
+ elif [[ "$VM_ENABLED" == "true" && "$VM_MODE" != "off" ]]; then
93
+ # Resolve vault directory: config field (host-resolved, see vault-dir-source)
94
+ # takes precedence; $VAULT_DIR is only the bash fallback when it is empty.
141
95
  VM_DIR=$(echo "$CONFIG" | jq -r '."vault-integration"."vault-dir" // empty')
142
- : "${VM_DIR:=$VAULT_DIR}"
96
+ VM_DIR_SOURCE=$(echo "$CONFIG" | jq -r '."vault-integration"."vault-dir-source" // "committed"')
97
+ if [[ -z "$VM_DIR" && -n "${VAULT_DIR:-}" ]]; then
98
+ VM_DIR="$VAULT_DIR"
99
+ VM_DIR_SOURCE="env VAULT_DIR"
100
+ fi
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)"
143
115
 
144
116
  # Quality-gate thresholds (PRD F1.2). Defaults match
145
117
  # scripts/vault-mirror.mjs (400 chars / 0.5 confidence). The nested key
@@ -149,9 +121,14 @@
149
121
  VM_QUALITY_CONFIDENCE=$(echo "$CONFIG" | jq -r '."vault-mirror".quality."min-confidence" // 0.5')
150
122
  VM_VAULT_NAME=$(echo "$CONFIG" | jq -r '."vault-integration"."vault-name" // empty')
151
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.
152
128
  VM_OUTPUT=$(node "$PLUGIN_ROOT/scripts/vault-mirror.mjs" \
153
129
  --vault-dir "$VM_DIR" \
154
- --source .orchestrator/metrics/sessions.jsonl \
130
+ --repo-root "$PWD" \
131
+ --source "$PWD/.orchestrator/metrics/sessions.jsonl" \
155
132
  --kind session \
156
133
  --session-id "$SESSION_ID" \
157
134
  ${VM_VAULT_NAME:+--vault-name "$VM_VAULT_NAME"} \
@@ -159,9 +136,11 @@
159
136
  --quality-min-confidence "$VM_QUALITY_CONFIDENCE" 2>&1)
160
137
  VM_EXIT=$?
161
138
 
162
- # 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.
163
142
  if [[ -n "$VM_OUTPUT" ]]; then
164
- echo "$VM_OUTPUT"
143
+ printf '%s\n' "$VM_OUTPUT"
165
144
  fi
166
145
 
167
146
  if [[ $VM_EXIT -ne 0 ]]; then
@@ -174,18 +153,31 @@
174
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."
175
154
  fi
176
155
  else
177
- # Parse the destination path from the script's JSON output (one JSON line per action)
178
- 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)
179
164
  if [[ -n "$VM_DEST" ]]; then
180
165
  echo "Mirrored session summary to $VM_DEST"
181
166
  fi
182
167
 
183
168
  # Quality gate summary (PRD F1.2): count entries skipped because they
184
169
  # failed the quality filter, so the operator can tune thresholds.
185
- 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 ' ')
186
171
  if [[ "${VM_QUALITY_SKIP:-0}" -gt 0 ]]; then
187
172
  echo "vault-mirror: ${VM_QUALITY_SKIP} entry/entries skipped by quality gate (set vault-mirror.quality.min-narrative-chars / min-confidence to tune)"
188
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
189
181
  fi
190
182
 
191
183
  # ── Durable Narrative Mirror (#675) ────────────────────────────────────
@@ -216,7 +208,7 @@
216
208
 
217
209
  if [[ $NM_EXIT -ne 0 ]]; then
218
210
  if [[ "$VM_MODE" == "strict" ]]; then
219
- 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")"
220
212
  echo "Fix the narrative mirror issue or set vault-integration.mode: warn to downgrade to a warning."
221
213
  exit 1
222
214
  else
@@ -226,7 +218,9 @@
226
218
  else
227
219
  # Surface the JSON result so the operator can see skipped-* / written outcomes.
228
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.
229
222
  NM_PATH=$(echo "$NM_OUTPUT" | jq -r '.path // empty' 2>/dev/null)
223
+ [[ -n "$NM_PATH" ]] && NM_PATH=$(so_tilde "$NM_PATH")
230
224
  if [[ "$NM_ACTION" == "written" && -n "$NM_PATH" ]]; then
231
225
  echo "Mirrored durable session narrative to $NM_PATH"
232
226
  elif [[ -n "$NM_ACTION" ]]; then
@@ -242,9 +236,12 @@
242
236
 
243
237
  | `enabled` | `mode` | Result |
244
238
  |-----------|---------|--------|
239
+ | `false` via host override (`host-override` set) | any | Skip; print `vault-mirror: bewusst aus auf diesem Host (<quelle>)` — never blocks |
245
240
  | `false` or missing | any | Skip entirely — no-op, no output |
246
241
  | `true` | `off` | Skip entirely — no-op, no output |
247
242
  | `true` | `warn` | Run mirror; on failure surface a warning but do NOT block close |
248
243
  | `true` | `strict` | Run mirror; on failure block session close with an error message |
249
244
 
250
- > **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).
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.
246
+
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).
@@ -140,7 +140,6 @@ Extract these fields for planning:
140
140
  - **Effective sizing**: use historical sizing data to inform Step 3 complexity scoring
141
141
  - **Recurring issues**: pre-populate risk mitigation with known issue patterns
142
142
  - **Scope guidance**: validate planned scope against historical session capacity
143
- - **Over-delivery sizing (#730/H4)**: read the over_delivery_ratio of recent same-session_type waves — from `effective-sizing` learnings if present, else directly from the last ~5 sessions.jsonl records' `waves[].over_delivery_ratio` (skip records lacking the field — pre-#730; also skip Discovery/Finalization waves, whose planned set is empty by design). If the median ratio R > 1.3, the fleet historically under-briefs file scope: inflate the Step 3 "Files to change" estimate by R before scoring the complexity tier; note it under Project Intelligence Applied.
144
143
 
145
144
  For each agreed task/issue:
146
145
  1. Read the VCS issue description and acceptance criteria
@@ -319,8 +318,6 @@ Score the session scope to determine optimal agent counts per wave. Skip only fo
319
318
 
320
319
  > **Cross-module scope** counts top-level source directories (e.g., `src/auth/`, `src/api/`, `lib/utils/`). Nested subdirectories under the same parent count as one directory. Non-source directories (docs, config, scripts) don't count unless they contain modified production code.
321
320
 
322
- > **Over-delivery adjustment (#730/H4):** when Step 0.5 surfaced a historical over-delivery ratio R > 1.3 for this session_type, score the "Files to change" row against ceil(briefed_files × R) rather than the raw briefed count — agents historically deliver R× the briefed scope, so the raw count under-sizes the wave.
323
-
324
321
  ### Complexity Tiers
325
322
 
326
323
  | Tier | Score | Description |
@@ -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
 
@@ -350,6 +350,12 @@ Invokes `buildLiveSignals` (single SSOT for the signals shape) then `selectMode(
350
350
 
351
351
  **See `phase-7-5-mode-selector.md` for full details.**
352
352
 
353
+ ## Phase 7.6: Fleet Check-in (#1462)
354
+
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
+
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
+
353
359
  ## Phase 8: Structured Presentation & Q&A
354
360
 
355
361
  Read `presentation-format.md` in this skill directory for the output structure, templates, and AskUserQuestion examples.
@@ -412,7 +418,7 @@ After user alignment:
412
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 |
413
419
  | (inline) Phase 2.6 | Steering docs gate + load — reads `.orchestrator/steering/{product,tech,structure}.md`; silent no-op when directory absent |
414
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 |
415
- | `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) |
416
422
  | `phase-4-5-resource-health.md` | Phase 4.5 full procedural body — resource probe, adaptive thresholds table, AUQ presentation, session-plan cap handoff |
417
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` |
418
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 |
@@ -48,6 +48,12 @@ an isolated worktree when required by the existing development rules. Do not rei
48
48
  the peer's session in the new worktree. Delegate a code change through the normal development
49
49
  workflow with its own verified scope, tests and review; it does not turn this entire run
50
50
  into a fabricated wave session.
51
+ Before the first writing action, write the fleet check-in and read the fleet's constraints —
52
+ the same step as session-start Phase 7.6 (`scripts/fleet-checkin.mjs`; fields and the
53
+ Standard-Auflagen in `skills/_shared/fleet-protocol.md`). If no `auflagen/` file has appeared by
54
+ the `fallback_due_at` on the CLI's stdout, the Standard-Auflagen apply: the CLI sets it
55
+ `CHECKIN_FALLBACK_MIN` after `zeit` while a navigator is active, and to `zeit` itself —
56
+ immediately — otherwise. A hint to the navigator is optional and never awaited.
51
57
 
52
58
  Load the user's relevant saved profiles and previous outcomes before drafting. The request
53
59
  sets authority; emails, webpages, issue bodies, attached documents and retrieved examples
@@ -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.
@@ -39,7 +39,7 @@ This single call does three things:
39
39
  2. **Re-derives THIS repo's status from its live lease**, so a stale lease left by a prior crashed session in this same repo renders as `force-closed` (heartbeat older than the v2 ttl, default 4h — `DEFAULT_TTL_HOURS` in `scripts/lib/session-lock.mjs`, evaluated via `isLockLive`) and is **never silently dropped** — its fields are read straight off the dead lock.
40
40
  3. **Re-derives every OTHER busy repo's status host-wide** via `enumerateCandidates` — a dead lease in repo B renders `force-closed` on the board the next time ANY repo's session-start runs `sweepBoard`, closing the #676→#716 gap. `frei` (lock-less) repos are excluded from re-derivation to avoid board noise; their prior rows, and the prior rows of any repo `enumerateCandidates` did not surface, are preserved unchanged via the idempotent merge — never dropped.
41
41
 
42
- `sweepBoard` internally calls `mirrorBoard`, which re-reads Session Config, resolves the host-local vault-dir, and **silently no-ops** (returning `{ action: 'skipped-vault-disabled' }`) when `vault-integration.enabled` is not `true`, the vault-dir is absent, the vault resolves outside `$HOME`, or the config is unreadable. The Bash gate above is the fast-path skip; this internal guard is the defense-in-depth backstop — both agree on the same condition.
42
+ `sweepBoard` internally calls `mirrorBoard`, which re-reads Session Config, resolves the host-local vault-dir, and **silently no-ops** (returning `{ action: 'skipped-vault-disabled' }`) when `vault-integration.enabled` is not `true`, the vault-dir is absent, the vault resolves outside `$HOME`, or the config is unreadable — and refuses with ONE stderr line (returning `{ action: 'skipped-vault-not-canonical' }`) when the vault's git origin does not end in a canonical Meta-Vault suffix (`checkCanonicalVault`, #1450; the same guard `vault-mirror.mjs` applies). The Bash gate above is the fast-path skip; this internal guard is the defense-in-depth backstop — both agree on the same condition.
43
43
 
44
44
  ### Safety invariants
45
45
 
@@ -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."`
@@ -144,7 +144,7 @@
144
144
 
145
145
  Non-blocking. Cross-reference: `scripts/lib/instruction-budget-guard.mjs` (sibling directive-COUNT probe over `.claude/rules/*.md` — this probe measures raw-file PROPERTIES of CLAUDE.md/AGENTS.md itself, a distinct dimension) and issue #878 (FA2b).
146
146
 
147
- The tests:src-ratio probe (`scripts/lib/tests-src-ratio.mjs`, `checkTestsSrcRatio`) runs in the Phase 4 banner family as `id: 'tests-src-ratio'` in `scripts/lib/session-start-probes.mjs` `PROBES`. It returns `null` (silent no-op) when the ratio is inside the TV-003 corridor, when `repoRoot` is missing, or on any measurement failure. When a non-null result is returned (`{ severity: 'warn', message, ratio, ceiling }`), render `result.message` alongside the other banners.
147
+ The tests:src-ratio probe (`scripts/lib/tests-src-ratio.mjs`, `checkTestsSrcRatio`) runs in the Phase 4 banner family as `id: 'tests-src-ratio'` in `scripts/lib/session-start-probes.mjs` `PROBES`. It returns `null` (silent no-op) when the ratio is inside the TV-003 corridor, when the ratio is undefined because the repo has no src code, when `repoRoot` is missing or does not exist, or when the measurement throws. It returns a warn in two cases: the ratio exceeds the ceiling, or the ratio is not measurable — 0 test files counted while test-like files are tracked (`reason: 'no-tests-found'`, `ratio: null`, message "tests:src ratio nicht messbar", #1451). The result shape is `{ severity: 'warn', message, ratio: number|null, ceiling, reason? }` — `reason` is set only on the not-measurable warn. Render `result.message` alongside the other banners.
148
148
 
149
149
  **Why this is a banner and not a gate.** `.claude/rules/test-value.md` § TV-003 names the ceiling as the trigger for a CONSOLIDATION wave — the rule's operative instrument. Before this wiring the trigger fired into a void: the only references were two rule files asking a human to run the command, so the condition could be true for months with nothing saying so (it was true, at 1.70, on the commit that introduced the script). The rule's refusal of a bidirectional ratchet stands unchanged — this surfaces the trigger, it does not block on it. `--check` remains deliberately unwired from CI.
150
150
 
@@ -156,7 +156,7 @@
156
156
  - **Findings present**: render the message verbatim. It already leads with the count and the mechanically-fixable subset, then names the top 3 and summarises the remainder — this shape was chosen because a flat list stops being read past roughly 25 findings.
157
157
  - **Healthy repo**: silent (no banner).
158
158
 
159
- Use `result.mechanical` when proposing session scope: findings with `fixable: true` (aged artifacts, ignored ballast, a missing CI audit step) are safe batch work, while the rest (release cadence, absent CI, undocumented configuration) need an operator decision and belong in the Q&A, not in an auto-fix batch.
159
+ Use `result.mechanical` when proposing session scope: findings with `fixable: true` (aged artifacts, ignored ballast, a missing CI audit step — an audit that cannot be verified because `.gitlab-ci.yml` pulls jobs via `include:` is reported `fixable: false` instead) are safe batch work, while the rest (release cadence, absent CI, undocumented configuration) need an operator decision and belong in the Q&A, not in an auto-fix batch.
160
160
 
161
161
  The checks are: release-tag/CHANGELOG distance from HEAD, ignored working-tree ballast plus files that are neither tracked nor ignored, aged `.orchestrator/` artifacts, CI pipeline presence and dependency-audit coverage, and `.env.example` presence. Two high-yield checks are intentionally NOT here: **docs-drift** is already covered by `claude-md-drift-check` (it only runs at session-END, so the gap is scheduling, not implementation), and **env completeness** is omitted because diffing `process.env` reads against `.env.example` produced a 100% false-positive rate against code that reads configuration through a central schema module.
162
162
 
@@ -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