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
@@ -13,6 +13,17 @@
13
13
  * 3. committed Session Config default (the value the committed CLAUDE.md —
14
14
  * AGENTS.md on Codex CLI — produced)
15
15
  *
16
+ * `vault-dir` has one more tier (agents/vault#319): an owner.yaml `vault-dirs:`
17
+ * cwd match between env and paths.vault-dir. That tier lives in
18
+ * `resolveVaultDir` in scripts/lib/config.mjs, NOT here: this module is one of
19
+ * the three direct CP11 helpers check-owner-leakage.mjs imports, and a standalone
20
+ * vendored copy carries only those three files. A new import edge from here
21
+ * (e.g. to named-baseline-resolver.mjs) makes CP11 fail closed in that copy.
22
+ *
23
+ * `resolveVaultIntegrationHost` is the host-local switch for the
24
+ * `vault-integration` gate itself (SO#1448). It may only LOWER the committed
25
+ * level (strict → warn → off), never raise it.
26
+ *
16
27
  * SYNCHRONOUS by design: `parseSessionConfig` in scripts/lib/config.mjs is sync,
17
28
  * so this layer reuses the SYNC owner loader (`loadOwnerConfig`) and exposes only
18
29
  * sync functions. An empty/whitespace value at any tier is treated as "unset" and
@@ -90,3 +101,71 @@ export function resolveHostPath(key, committedDefault, { env = process.env, owne
90
101
 
91
102
  return committedDefault;
92
103
  }
104
+
105
+ /** vault-integration levels, weakest first. The index order IS the ordering. */
106
+ const VAULT_INTEGRATION_LEVELS = /** @type {readonly string[]} */ (['off', 'warn', 'strict']);
107
+
108
+ /**
109
+ * @param {unknown} v
110
+ * @returns {'off'|'warn'|'strict'|undefined} a valid level, or undefined for unset/invalid
111
+ */
112
+ function coerceVaultIntegrationLevel(v) {
113
+ if (typeof v !== 'string') return undefined;
114
+ const lower = v.trim().toLowerCase();
115
+ return VAULT_INTEGRATION_LEVELS.includes(lower)
116
+ ? /** @type {'off'|'warn'|'strict'} */ (lower)
117
+ : undefined;
118
+ }
119
+
120
+ /**
121
+ * Overlay the host-local `vault-integration` switch onto the committed block
122
+ * (SO#1448). Precedence: env `SO_VAULT_INTEGRATION=off|warn|strict` > owner.yaml
123
+ * `vault-integration: { enabled: false | mode: off|warn|strict }` > committed.
124
+ * An invalid value at a tier counts as unset and falls through, mirroring
125
+ * resolveDispatcherAutonomy in dispatcher-autonomy.mjs.
126
+ *
127
+ * LOWER-ONLY: the host's level is applied only when it is weaker than the
128
+ * committed one. A host that could raise it would mirror repos into the vault
129
+ * that never opted in, so `enabled: true` or a stronger mode is ignored.
130
+ * Applying `off` sets both `enabled: false` and `mode: 'off'`, so either
131
+ * consumer check (`enabled`, `mode != off`) sees the integration as off.
132
+ *
133
+ * @param {{ enabled?: boolean, mode?: string } & Record<string, unknown>} vi — parsed committed block
134
+ * @param {{ env?: Record<string, string|undefined>, ownerConfig?: object }} [ctx] — from loadHostPaths()
135
+ * @returns {Record<string, unknown> & { enabled?: boolean, mode?: string, 'host-override': 'env:SO_VAULT_INTEGRATION'|'owner.yaml'|null }}
136
+ * a copy of `vi`; `host-override` names the tier that lowered it, `null` when none did
137
+ */
138
+ export function resolveVaultIntegrationHost(vi, { env = process.env, ownerConfig } = {}) {
139
+ /** @type {Record<string, unknown> & { enabled?: boolean, mode?: string, 'host-override': 'env:SO_VAULT_INTEGRATION'|'owner.yaml'|null }} */
140
+ const result = { ...vi, 'host-override': null };
141
+
142
+ /** @type {'off'|'warn'|'strict'|undefined} */
143
+ let requested = coerceVaultIntegrationLevel(env?.SO_VAULT_INTEGRATION);
144
+ /** @type {'env:SO_VAULT_INTEGRATION'|'owner.yaml'} */
145
+ let source = 'env:SO_VAULT_INTEGRATION';
146
+ if (requested === undefined) {
147
+ const owner = ownerConfig?.['vault-integration'];
148
+ if (owner !== null && typeof owner === 'object' && !Array.isArray(owner)) {
149
+ requested = owner.enabled === false ? 'off' : coerceVaultIntegrationLevel(owner.mode);
150
+ source = 'owner.yaml';
151
+ }
152
+ }
153
+ if (requested === undefined) return result;
154
+
155
+ const committedLevel =
156
+ vi?.enabled === true ? (coerceVaultIntegrationLevel(vi.mode) ?? 'warn') : 'off';
157
+ if (
158
+ VAULT_INTEGRATION_LEVELS.indexOf(requested) >= VAULT_INTEGRATION_LEVELS.indexOf(committedLevel)
159
+ ) {
160
+ return result;
161
+ }
162
+
163
+ if (requested === 'off') {
164
+ result.enabled = false;
165
+ result.mode = 'off';
166
+ } else {
167
+ result.mode = requested;
168
+ }
169
+ result['host-override'] = source;
170
+ return result;
171
+ }
@@ -10,7 +10,7 @@
10
10
  * after — 'quality' | 'impl-polish' (default: 'quality')
11
11
  * threshold — string spec parsed via parseThreshold (default: 'all')
12
12
  * personas — string[] (each matches SAFE_PERSONA_NAME_RE) (default: [])
13
- * dispatch-model — model alias or full ID (default: 'claude-opus-4-7')
13
+ * dispatch-model — model alias or full ID (default: 'opus' — tracks the current Opus)
14
14
  * mode — 'off' | 'warn' | 'strict' (default: 'off')
15
15
  *
16
16
  * Used by skills/wave-executor/wave-loop.md § 3b: Persona-Gate Hook to decide
@@ -32,7 +32,7 @@ const DEFAULTS = Object.freeze({
32
32
  threshold: 'all',
33
33
  threshold_parsed: null, // cached ParsedThreshold result; populated by _normalizePersonaGateWave
34
34
  personas: [],
35
- 'dispatch-model': 'claude-opus-4-7',
35
+ 'dispatch-model': 'opus',
36
36
  mode: 'off',
37
37
  });
38
38
 
@@ -138,7 +138,7 @@ export function _normalizePersonaGateWave(parsed) {
138
138
  }
139
139
  if (!ALLOWED_MODEL_ALIASES.has(m) && !MODEL_ID_RE.test(m)) {
140
140
  throw new Error(
141
- `persona-gate-wave.dispatch-model must be one of ${[...ALLOWED_MODEL_ALIASES].map((a) => JSON.stringify(a)).join('|')} or a full model ID like "claude-opus-4-7" (got ${JSON.stringify(m)})`,
141
+ `persona-gate-wave.dispatch-model must be one of ${[...ALLOWED_MODEL_ALIASES].map((a) => JSON.stringify(a)).join('|')} or a full model ID like "claude-opus-5-5" (got ${JSON.stringify(m)})`,
142
142
  );
143
143
  }
144
144
  out['dispatch-model'] = m;
@@ -27,18 +27,19 @@ import { preprocessBlockLines } from './block-preprocess.mjs';
27
27
  * Startwerte für Stufe 1 — NOT calibrated. Every number carries its provenance
28
28
  * from the PRD §4 parameter table ("keine Zahl ohne ihre Population").
29
29
  *
30
- * RUNTIME SSOT is `REAPER_DEFAULTS` in `scripts/lib/orphan-reaper.mjs`; these
31
- * literals mirror it deliberately rather than importing it, because that module
32
- * pulls `process-group.mjs` + `resource-probe/parsers.mjs` into the config
33
- * import graph, which every `parseSessionConfig()` caller would then pay for.
34
- * The parity is held by a test, not by an import
30
+ * RUNTIME SSOT is `REAPER_DEFAULTS` in `scripts/lib/orphan-reaper/defaults.mjs`; these
31
+ * literals mirror it rather than importing it. The original reason — the
32
+ * single-file `orphan-reaper.mjs` pulled `process-group.mjs` +
33
+ * `resource-probe/parsers.mjs` into the config import graph — no longer holds
34
+ * since the #1437 split (`defaults.mjs` imports only `node:path`); revisit the
35
+ * mirror there. The parity is held by a test, not by an import
35
36
  * (`tests/lib/config/reaper.test.mjs` § defaults mirror REAPER_DEFAULTS).
36
37
  */
37
38
  /** 300 s — DevWatchdog's hard limit for `tsgo`; the 2026-09-20 orphans were 7–17 min old. */
38
39
  const DEFAULT_MIN_AGE_SECONDS = 300;
39
40
  /** 30 s — DevWatchdog's normal scan cadence; keeps a PostToolBatch storm from taxing every tool call. */
40
41
  const DEFAULT_MIN_SCAN_INTERVAL_SECONDS = 30;
41
- /** 10 000 ms — `DEFAULT_KILL_GRACE_MS` from `dispatch-common.mjs:61`; repo convention, not newly invented. */
42
+ /** 10 000 ms — `DEFAULT_KILL_GRACE_MS` from `process-group.mjs`; repo convention, not newly invented. */
42
43
  const DEFAULT_KILL_GRACE_MS = 10_000;
43
44
  /** 500 ms — wait before reading the effect back; without it the 2026-09-20 check falsely reported "still alive". */
44
45
  const DEFAULT_VERIFY_WAIT_MS = 500;
@@ -57,16 +57,31 @@ const MODE_ALLOWED = ['warn', 'strict', 'off'];
57
57
  * value portion (including any literal `**` inside it) is untouched.
58
58
  *
59
59
  * Defaults:
60
- * enabled: false
61
- * vault-dir: null
62
- * mode: "warn" (invalid values silently fall back to "warn")
63
- * vault-name: null (absent = downstream callers use deriveRepo() default)
60
+ * enabled: false
61
+ * vault-dir: null
62
+ * mode: "warn" (invalid values silently fall back to "warn")
63
+ * vault-name: null (absent = downstream callers use deriveRepo() default)
64
+ * gitlab-groups: null (#1094 — non-empty string[] or null; see _groupsFromScalar)
65
+ *
66
+ * `gitlab-groups` accepts a comma string (`a, b`), a flow array (`[a, b]`) and,
67
+ * in block form only, a YAML block list (`gitlab-groups:` + indented `- a`
68
+ * lines — the shape docs/session-config-template.md documents). Inside the
69
+ * inline-object form more than one group needs the `[a, b]` array, because the
70
+ * literal is split on commas. YAML semantics throughout: `null` / `~` / empty
71
+ * mean unset (also as a list element), and each element loses one pair of
72
+ * matching quotes.
64
73
  *
65
74
  * @param {string} content — full file contents
66
- * @returns {{enabled: boolean, "vault-dir": string|null, mode: string, "vault-name": string|null}}
75
+ * @returns {{enabled: boolean, "vault-dir": string|null, mode: string, "vault-name": string|null, "gitlab-groups": string[]|null}}
67
76
  */
68
77
  export function _parseVaultIntegration(content) {
69
- const defaults = { enabled: false, 'vault-dir': null, mode: 'warn', 'vault-name': null };
78
+ const defaults = {
79
+ enabled: false,
80
+ 'vault-dir': null,
81
+ mode: 'warn',
82
+ 'vault-name': null,
83
+ 'gitlab-groups': null,
84
+ };
70
85
  if (typeof content !== 'string' || content === '') return defaults;
71
86
 
72
87
  // #1162: NOT a raw split. `preprocessBlockLines` drops HTML-commented lines
@@ -106,21 +121,36 @@ export function _parseVaultIntegration(content) {
106
121
  let vaultDir = null;
107
122
  let mode = 'warn';
108
123
  let vaultName = null;
124
+ let gitlabGroups = null;
125
+ // Items of a YAML block list under a value-less `gitlab-groups:` line; null
126
+ // while no such list is open.
127
+ let groupItems = null;
109
128
 
110
129
  for (const rawLine of blockLines) {
111
130
  const clean = rawLine.replace(/\s*#.*$/, '').replace(/\s+$/, '');
112
131
  if (!clean.trim()) continue;
113
132
 
114
- // Only first-level sub-keys (single indent). Skip deeper nested keys like
115
- // `gitlab-groups:` list items (handled as a no-op — they aren't in this
116
- // parser's return shape).
133
+ if (groupItems !== null) {
134
+ const item = clean.match(/^\s+-(?:\s+(.*))?$/);
135
+ if (item) {
136
+ const value = _groupName((item[1] ?? '').trim());
137
+ if (value !== null) groupItems.push(value);
138
+ continue;
139
+ }
140
+ }
141
+
142
+ // Sub-keys at any indent. A list-item line (`- x`) never matches, so list
143
+ // items under a key this parser does not own are skipped here.
117
144
  const kvMatch = clean.match(/^\s+([a-zA-Z_-]+):\s*(.*)$/);
118
145
  if (!kvMatch) continue;
119
146
 
147
+ if (groupItems !== null) {
148
+ if (groupItems.length > 0) gitlabGroups = groupItems;
149
+ groupItems = null;
150
+ }
151
+
120
152
  const k = kvMatch[1];
121
- let v = kvMatch[2].trim();
122
- if (v.startsWith('"') && v.endsWith('"') && v.length >= 2) v = v.slice(1, -1);
123
- else if (v.startsWith("'") && v.endsWith("'") && v.length >= 2) v = v.slice(1, -1);
153
+ const v = _stripQuotes(kvMatch[2].trim());
124
154
 
125
155
  switch (k) {
126
156
  case 'enabled':
@@ -140,21 +170,118 @@ export function _parseVaultIntegration(content) {
140
170
  if (v === '' || v === 'none' || v === 'null') vaultName = null;
141
171
  else vaultName = v;
142
172
  break;
173
+ case 'gitlab-groups': {
174
+ // A value replaces any earlier one; an empty value opens a block list,
175
+ // which replaces it only once it has collected an item. The value goes
176
+ // to `_groupsFromScalar` WITH its quotes, as the inline form's does —
177
+ // stripping them here first read `"null"` as unset in this form only (#1496).
178
+ const raw = kvMatch[2].trim();
179
+ if (raw === '') groupItems = [];
180
+ else gitlabGroups = _groupsFromScalar(raw);
181
+ break;
182
+ }
143
183
  }
144
184
  }
185
+ if (groupItems !== null && groupItems.length > 0) gitlabGroups = groupItems;
186
+
187
+ return {
188
+ enabled,
189
+ 'vault-dir': vaultDir,
190
+ mode,
191
+ 'vault-name': vaultName,
192
+ 'gitlab-groups': gitlabGroups,
193
+ };
194
+ }
145
195
 
146
- return { enabled, 'vault-dir': vaultDir, mode, 'vault-name': vaultName };
196
+ /**
197
+ * Strip ONE pair of matching surrounding quotes (`"x"` / `'x'`). A lone quote
198
+ * character is returned unchanged.
199
+ *
200
+ * @param {string} v — an already-trimmed value
201
+ * @returns {string}
202
+ */
203
+ function _stripQuotes(v) {
204
+ if (v.length >= 2 && ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")))) {
205
+ return v.slice(1, -1);
206
+ }
207
+ return v;
147
208
  }
148
209
 
210
+ /**
211
+ * The YAML 1.2 core-schema null scalars, plus the empty value. `none` is NOT
212
+ * one: YAML reads it as a string, so it stays a literal group name.
213
+ */
214
+ const YAML_NULL_RE = /^(?:|null|Null|NULL|~)$/;
215
+
216
+ /**
217
+ * One `gitlab-groups` element as YAML reads it: an unquoted null scalar (or an
218
+ * empty element) is no group at all → `null`; otherwise ONE pair of matching
219
+ * quotes is stripped, so `"null"` stays the string `null` exactly as in YAML.
220
+ *
221
+ * @param {string} raw — an already-trimmed element
222
+ * @returns {string|null}
223
+ */
224
+ function _groupName(raw) {
225
+ if (YAML_NULL_RE.test(raw)) return null;
226
+ const name = _stripQuotes(raw).trim();
227
+ return name === '' ? null : name;
228
+ }
229
+
230
+ /**
231
+ * Normalise a scalar `gitlab-groups` value to a group list with YAML semantics:
232
+ * a null scalar (`null`, `~`, empty) is unset → `null`; otherwise drop one
233
+ * leading `[` and one trailing `]`, split on commas, trim, and read each element
234
+ * through `_groupName` (null elements dropped, one quote pair stripped). An
235
+ * empty result is `null`, the documented default. Before this, `null` scanned a
236
+ * GitLab group named "null" and `["a"]` one named `"a"` with the quotes.
237
+ *
238
+ * The ONE normalisation for both forms, so both pass the value with its quotes
239
+ * (#1496). A wholly quoted value is a YAML string, never a null: it loses that
240
+ * quote pair, is split on commas, and its elements are taken literally —
241
+ * `"null"` is the group "null", `"a, b"` the groups a and b.
242
+ *
243
+ * @param {string} v
244
+ * @returns {string[]|null}
245
+ */
246
+ function _groupsFromScalar(v) {
247
+ const trimmed = v.trim();
248
+ if (YAML_NULL_RE.test(trimmed)) return null;
249
+ const unquoted = _stripQuotes(trimmed);
250
+ const readElement = unquoted === trimmed ? _groupName : (g) => (g === '' ? null : g);
251
+ const groups = unquoted
252
+ .replace(/^\[/, '')
253
+ .replace(/\]$/, '')
254
+ .split(',')
255
+ .map((g) => readElement(g.trim()))
256
+ .filter((g) => g !== null);
257
+ return groups.length > 0 ? groups : null;
258
+ }
259
+
260
+ /**
261
+ * `gitlab-groups: [ ... ]` inside an inline object literal (braces already
262
+ * removed). Taken out BEFORE the comma split, which would otherwise cut the
263
+ * array apart; every other pair is split exactly as before.
264
+ */
265
+ const INLINE_GROUPS_ARRAY_RE = /(^|,)\s*gitlab-groups\s*:\s*(\[[^\]]*\])\s*(?=,|$)/;
266
+
149
267
  /**
150
268
  * Parse an inline YAML object literal `{ key: val, key: val }` into the
151
269
  * vault-integration return shape. Supports unquoted values including `~/` paths.
152
270
  *
153
271
  * @param {string} raw — full literal including braces
154
- * @returns {{enabled: boolean, "vault-dir": string|null, mode: string}}
272
+ * @returns {{enabled: boolean, "vault-dir": string|null, mode: string, "vault-name": string|null, "gitlab-groups": string[]|null}}
155
273
  */
156
274
  function _parseInlineObject(raw) {
157
- const stripped = raw.replace(/^\s*\{/, '').replace(/\}\s*$/, '').trim();
275
+ let stripped = raw.replace(/^\s*\{/, '').replace(/\}\s*$/, '').trim();
276
+ let groupsArray = null;
277
+ const arrayMatch = stripped.match(INLINE_GROUPS_ARRAY_RE);
278
+ if (arrayMatch) {
279
+ groupsArray = arrayMatch[2];
280
+ stripped =
281
+ stripped.slice(0, arrayMatch.index) +
282
+ arrayMatch[1] +
283
+ stripped.slice(arrayMatch.index + arrayMatch[0].length);
284
+ }
158
285
  const kv = new Map();
159
286
  if (stripped !== '') {
160
287
  for (const pair of stripped.split(',')) {
@@ -180,7 +307,15 @@ function _parseInlineObject(raw) {
180
307
  vaultNameRaw === undefined || vaultNameRaw === '' || vaultNameRaw === 'none' || vaultNameRaw === 'null'
181
308
  ? null
182
309
  : vaultNameRaw;
183
- return { enabled, 'vault-dir': vaultDir, mode, 'vault-name': vaultName };
310
+ const groupsRaw = groupsArray ?? kv.get('gitlab-groups');
311
+ const gitlabGroups = groupsRaw === undefined ? null : _groupsFromScalar(groupsRaw);
312
+ return {
313
+ enabled,
314
+ 'vault-dir': vaultDir,
315
+ mode,
316
+ 'vault-name': vaultName,
317
+ 'gitlab-groups': gitlabGroups,
318
+ };
184
319
  }
185
320
 
186
321
  // ---------------------------------------------------------------------------
@@ -120,6 +120,19 @@ export function validateSessionConfig(config) {
120
120
  }
121
121
  }
122
122
 
123
+ // #1465 cross-field: offload-first with no declared host can never place a
124
+ // wave anywhere — advisory (warn-level), it never flips `ok`.
125
+ if (config['offload-first'] === true) {
126
+ const hosts = config['remote-hosts'];
127
+ if (!Array.isArray(hosts) || hosts.length === 0) {
128
+ warnings.push({
129
+ path: 'remote-hosts',
130
+ rule: 'offload-first-cross-field',
131
+ message: 'offload-first: true but no remote-hosts declared — every wave stays local',
132
+ });
133
+ }
134
+ }
135
+
123
136
  if (errors.length > 0) {
124
137
  return { ok: false, errors, warnings };
125
138
  }
@@ -78,8 +78,61 @@ import { _parseRemoteHosts } from './config/remote-hosts.mjs';
78
78
  import { _parseEvolve, _parseEvolveDecay } from './config/evolve.mjs';
79
79
  import { _parseSkillEvolution } from './config/skill-evolution.mjs';
80
80
  import { _parseDispatcherAutonomy, resolveDispatcherAutonomy } from './config/dispatcher-autonomy.mjs';
81
- import { loadHostPaths, resolveHostPath } from './config/host-paths.mjs';
81
+ import { loadHostPaths, resolveHostPath, resolveVaultIntegrationHost } from './config/host-paths.mjs';
82
82
  import { resolveNamedBaseline } from './named-baseline-resolver.mjs';
83
+ import { expandTilde } from './common.mjs';
84
+
85
+ /**
86
+ * Resolve `vault-dir` and report which tier produced it (agents/vault#319).
87
+ *
88
+ * Precedence (highest first): SO_VAULT_DIR env > owner.yaml `vault-dirs:`
89
+ * path-prefix match against the cwd > owner.yaml `paths.vault-dir` > committed.
90
+ * The env tier stays on top because tests/setup/vault-guard.mjs relies on it to
91
+ * shadow every host-local vault for the whole suite.
92
+ *
93
+ * Lives here, not in config/host-paths.mjs: host-paths.mjs is a direct CP11
94
+ * helper of check-owner-leakage.mjs, whose standalone vendored copy carries only
95
+ * its three direct helpers. The named-baseline-resolver import edge would make
96
+ * CP11 fail closed there (tests/husky/pre-commit-owner-leakage.test.mjs).
97
+ *
98
+ * The two owner.yaml tiers (`match`, `owner`) come back tilde-expanded: owner.yaml
99
+ * is host-local and `~/…` is the natural way to write a path there, but consumers
100
+ * such as vault-mirror.mjs `existsSync(resolve(vaultDir))` would read a literal
101
+ * `~` as `<cwd>/~/…`. The `env` and `committed` tiers keep their pass-through;
102
+ * vault-mirror.mjs expands its `--vault-dir` at entry for every tier anyway.
103
+ *
104
+ * `cwd` in the ctx is a test-only DI seam; production reads `process.cwd()`.
105
+ *
106
+ * Exported for vault writers that read only the `vault-integration` block and
107
+ * cannot afford `parseSessionConfig`, which throws on an invalid value in any
108
+ * unrelated block (scripts/vault-backfill.mjs, #1094).
109
+ *
110
+ * @param {string|null|undefined} committed — value the committed Session Config produced
111
+ * @param {{ env?: Record<string, string|undefined>, ownerConfig?: object, cwd?: string }} [ctx] — from loadHostPaths()
112
+ * @returns {{ value: string|null|undefined, source: 'env'|'match'|'owner'|'committed' }}
113
+ */
114
+ export function resolveVaultDir(committed, { env = process.env, ownerConfig, cwd } = {}) {
115
+ const isNonBlank = (v) => typeof v === 'string' && v.trim() !== '';
116
+
117
+ const envVal = env?.SO_VAULT_DIR;
118
+ if (isNonBlank(envVal)) return { value: envVal, source: 'env' };
119
+
120
+ const matched = resolveNamedBaseline({
121
+ cwd: cwd ?? process.cwd(),
122
+ ownerConfig,
123
+ env,
124
+ section: 'vault-dirs',
125
+ envKey: 'SO_VAULT_DIR',
126
+ });
127
+ if (matched.source === 'match' && isNonBlank(matched.path)) {
128
+ return { value: expandTilde(matched.path), source: 'match' };
129
+ }
130
+
131
+ const ownerVal = ownerConfig?.paths?.['vault-dir'];
132
+ if (isNonBlank(ownerVal)) return { value: expandTilde(ownerVal), source: 'owner' };
133
+
134
+ return { value: committed, source: 'committed' };
135
+ }
83
136
  // express-path lives one level UP from config/ (see its module header for why):
84
137
  // the parser is a sibling of config/state-md-lock.mjs in every respect except
85
138
  // its directory. Only the pure parser is imported here — `evaluateExpressPath`
@@ -114,8 +167,9 @@ export { readConfigFile } from './config/io.mjs';
114
167
  * hermetic ctx (e.g. `{ env: {}, ownerConfig: undefined }`) when asserting COMMITTED values:
115
168
  * the default reads the real `owner.yaml`, so a host-local `paths:` override would otherwise
116
169
  * bleed into fixture assertions (incident: 2026-07-03 Full-Gate red after the operator set
117
- * `paths.baseline-path` host-locally). `cwd` feeds the `baselines:` match tier (#819) — a
118
- * test-only DI seam; production always resolves it from `process.cwd()`.
170
+ * `paths.baseline-path` host-locally). `cwd` feeds the `baselines:` (#819) and
171
+ * `vault-dirs:` (agents/vault#319) match tiers — a test-only DI seam; production
172
+ * always resolves it from `process.cwd()`.
119
173
  * @returns {object} config object with EXACT same shape as parse-config.sh stdout
120
174
  * @throws if any enum value is invalid
121
175
  */
@@ -230,6 +284,9 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
230
284
  // heavy-repo / worktree-cleanup: HR-003 preflight fields (templates/shared/.claude/rules/heavy-repo.md),
231
285
  // documented but previously unwired — silently dropped by the parser (baseline issue #60).
232
286
  const heavyRepo = _coerceBoolean(kv, 'heavy-repo', false);
287
+ // offload-first (#1465): route offloadable wave roles to a ready remote host
288
+ // even on a `proceed` resource verdict (consumed by wave-resource-gate.mjs).
289
+ const offloadFirst = _coerceBoolean(kv, 'offload-first', false);
233
290
 
234
291
  // List fields
235
292
  const crossRepos = _coerceList(kv, 'cross-repos', undefined);
@@ -331,18 +388,25 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
331
388
  // vault-integration: parsed from full content (block-scoped, avoids the
332
389
  // pre-#593 KV-name collision where `enabled:` was shared with 15+ other
333
390
  // blocks like docs-orchestrator/vault-staleness/slopcheck).
334
- const vaultIntegration = _parseVaultIntegration(mdContent);
335
- // Host-local override (issue #653) — applied here, NOT inside _parseVaultIntegration,
336
- // so claude-md-drift-check (which calls the parser directly) keeps seeing raw values.
337
- vaultIntegration['vault-dir'] = resolveHostPath('vault-dir', vaultIntegration['vault-dir'], hostCtx);
391
+ // Host-local overrides — applied here, NOT inside _parseVaultIntegration, so
392
+ // claude-md-drift-check (which calls the parser directly) keeps seeing raw values.
393
+ // - the enabled/mode switch (SO#1448): env SO_VAULT_INTEGRATION > owner.yaml
394
+ // `vault-integration:` > committed, LOWER-ONLY; `host-override` names the tier.
395
+ // - vault-dir (#653, agents/vault#319): env > owner.yaml `vault-dirs:` cwd match >
396
+ // owner.yaml paths.vault-dir > committed; `vault-dir-source` names the tier.
397
+ const vaultIntegration = resolveVaultIntegrationHost(_parseVaultIntegration(mdContent), hostCtx);
398
+ const vaultIntegrationDir = resolveVaultDir(vaultIntegration['vault-dir'], hostCtx);
399
+ vaultIntegration['vault-dir'] = vaultIntegrationDir.value;
400
+ vaultIntegration['vault-dir-source'] = vaultIntegrationDir.source;
338
401
 
339
402
  // resource-thresholds sub-keys (v3.1.0 env-aware — issue #166)
340
403
  const resourceThresholds = _parseResourceThresholds(kv);
341
404
 
342
405
  // vault-sync: parsed from full content (can live outside Session Config)
343
406
  const vaultSync = _parseVaultSync(mdContent);
344
- // Host-local override (issue #653) — same host source as vault-integration above.
345
- vaultSync['vault-dir'] = resolveHostPath('vault-dir', vaultSync['vault-dir'], hostCtx);
407
+ // Host-local override (issue #653) — same resolver as vault-integration above, so the
408
+ // two blocks cannot disagree about which vault this repo writes to (agents/vault#319).
409
+ vaultSync['vault-dir'] = resolveVaultDir(vaultSync['vault-dir'], hostCtx).value;
346
410
 
347
411
  // drift-check: parsed from full content (standalone top-level block)
348
412
  const driftCheck = _parseDriftCheck(mdContent);
@@ -408,7 +472,7 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
408
472
  // dispatcher-autonomy: opt-in cross-repo dispatcher autonomy block (Epic #673 / issue #679).
409
473
  // Parser stays pure (raw committed value) for claude-md-drift-check raw-value parity;
410
474
  // the host-local override (env > owner.yaml > committed > off) is overlaid onto the
411
- // FINAL object only — mirroring the vault-dir resolveHostPath pattern above. Reuses the
475
+ // FINAL object only — mirroring the vault-dir host-override pattern above. Reuses the
412
476
  // already-loaded hostCtx.ownerConfig so owner.yaml is not read twice.
413
477
  const dispatcherAutonomy = _parseDispatcherAutonomy(mdContent);
414
478
  dispatcherAutonomy.autonomy = resolveDispatcherAutonomy({
@@ -552,6 +616,7 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
552
616
  'resource-awareness': resourceAwareness,
553
617
  'enable-host-banner': enableHostBanner,
554
618
  'heavy-repo': heavyRepo,
619
+ 'offload-first': offloadFirst,
555
620
  'worktree-cleanup': worktreeCleanup,
556
621
  'resource-thresholds': resourceThresholds,
557
622
  'worktree-exclude': worktreeExclude,
@@ -840,6 +840,19 @@ function resolveModel(model, env, resolveModelFromEnv) {
840
840
  // Public API
841
841
  // ---------------------------------------------------------------------------
842
842
 
843
+ /**
844
+ * Events window this reader needs retained (#1401 part 3): NONE FIXED, hence
845
+ * `null` rather than a made-up number. `evaluateSession` reads the whole
846
+ * ledger through `readEventsWithRotations` to score ONE named past session,
847
+ * arbitrarily old (`computeWindow`, #1407) — the window it needs is that
848
+ * session's own age, which no finite retention can be sized for. A session
849
+ * older than the retained ledger surfaces through `ledgerGapNote` instead.
850
+ * Read by `scripts/lib/events-retention-banner.mjs`.
851
+ *
852
+ * @type {number|null}
853
+ */
854
+ export const REQUIRED_EVENTS_WINDOW_DAYS = null;
855
+
843
856
  /**
844
857
  * Evaluate one completed session against the rubric-v2 dimensions.
845
858
  *