session-orchestrator 5.4.0 → 5.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/rules/060-evolve.mdc +5 -5
  5. package/.cursor-plugin/plugin.json +1 -1
  6. package/CHANGELOG.md +95 -3
  7. package/README.md +11 -11
  8. package/docs/ci-setup.md +120 -23
  9. package/docs/components.md +7 -7
  10. package/docs/events-schema.md +17 -14
  11. package/docs/install.md +14 -2
  12. package/docs/migration-v4.md +24 -17
  13. package/docs/plugin-architecture-v3.md +1 -1
  14. package/docs/scope-collision-guard.md +15 -13
  15. package/docs/session-config-reference.md +7 -7
  16. package/docs/vault-docs-architecture.md +8 -4
  17. package/hooks/_lib/hook-import-set.json +17 -8
  18. package/hooks/_lib/lock-bootstrap.mjs +118 -26
  19. package/hooks/config-protection.mjs +1 -1
  20. package/hooks/enforce-commands.mjs +28 -13
  21. package/hooks/enforce-scope.mjs +201 -23
  22. package/hooks/hooks-codex.json +1 -1
  23. package/hooks/hooks.json +1 -1
  24. package/hooks/on-session-start.mjs +4 -2
  25. package/hooks/on-stop.mjs +70 -170
  26. package/hooks/post-bash-write-verify.mjs +98 -17
  27. package/hooks/post-edit-validate.mjs +15 -5
  28. package/hooks/post-tool-batch-wave-signal.mjs +32 -154
  29. package/hooks/pre-bash-memory-propose-audit.mjs +19 -7
  30. package/hooks/pre-bash-staging-fence.mjs +29 -8
  31. package/hooks/pre-task-scope-disjoint.mjs +584 -207
  32. package/hooks/wave-scope-commit-guard.mjs +168 -72
  33. package/package.json +2 -6
  34. package/scripts/apply-session-learnings.mjs +35 -14
  35. package/scripts/archive-closed-prds.mjs +38 -11
  36. package/scripts/autopilot.mjs +8 -1
  37. package/scripts/backfill-abandoned-sessions.mjs +80 -5
  38. package/scripts/backfill-learnings-from-vault.mjs +49 -22
  39. package/scripts/codex-install.mjs +3 -0
  40. package/scripts/cursor-install.mjs +46 -2
  41. package/scripts/emit-session.mjs +5 -3
  42. package/scripts/export-hw-learnings.mjs +18 -7
  43. package/scripts/fleet-checkin.mjs +26 -3
  44. package/scripts/lib/autopilot/loop.mjs +25 -10
  45. package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
  46. package/scripts/lib/blocked-commands-policy.mjs +40 -0
  47. package/scripts/lib/common.mjs +41 -0
  48. package/scripts/lib/config/vault-integration.mjs +151 -16
  49. package/scripts/lib/config.mjs +5 -1
  50. package/scripts/lib/eval/engine.mjs +13 -0
  51. package/scripts/lib/events-retention-banner.mjs +393 -0
  52. package/scripts/lib/events-rotation.mjs +21 -6
  53. package/scripts/lib/events.mjs +51 -3
  54. package/scripts/lib/fleet-protocol.mjs +20 -2
  55. package/scripts/lib/hardening.mjs +1 -0
  56. package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
  57. package/scripts/lib/instruction-budget-guard.mjs +8 -0
  58. package/scripts/lib/learnings/expiry-sweep.mjs +387 -20
  59. package/scripts/lib/learnings/io.mjs +125 -16
  60. package/scripts/lib/lock-reaper.mjs +38 -14
  61. package/scripts/lib/maintenance-due-banner.mjs +23 -8
  62. package/scripts/lib/named-vault-resolver.mjs +48 -5
  63. package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
  64. package/scripts/lib/orphan-reaper/reaper-audit.mjs +101 -69
  65. package/scripts/lib/orphan-reaper/scan-throttle.mjs +79 -27
  66. package/scripts/lib/orphan-reaper/scan.mjs +4 -0
  67. package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
  68. package/scripts/lib/orphan-reaper.mjs +5 -1
  69. package/scripts/lib/platform.mjs +206 -2
  70. package/scripts/lib/plugin-update-banner.mjs +3 -1
  71. package/scripts/lib/process-group.mjs +83 -22
  72. package/scripts/lib/scope-baseline.mjs +116 -25
  73. package/scripts/lib/scope-echo.mjs +7 -0
  74. package/scripts/lib/scope-gate.mjs +194 -27
  75. package/scripts/lib/session-close-backfill.mjs +129 -4
  76. package/scripts/lib/session-end/tail-runner.mjs +10 -5
  77. package/scripts/lib/session-lock-shape.mjs +6 -3
  78. package/scripts/lib/session-lock.mjs +320 -67
  79. package/scripts/lib/session-registry.mjs +41 -16
  80. package/scripts/lib/session-start-probes.mjs +242 -68
  81. package/scripts/lib/session-token-rollup.mjs +39 -34
  82. package/scripts/lib/sessions-staleness-banner.mjs +3 -1
  83. package/scripts/lib/tail-window.mjs +27 -3
  84. package/scripts/lib/telemetry/pricing.mjs +5 -2
  85. package/scripts/lib/telemetry/sync.mjs +17 -0
  86. package/scripts/lib/tests-src-ratio.mjs +77 -2
  87. package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
  88. package/scripts/lib/validate/check-test-value-bans.mjs +320 -32
  89. package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
  90. package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
  91. package/scripts/lib/vault-mirror/namespace.mjs +47 -26
  92. package/scripts/lib/vault-mirror/process.mjs +350 -62
  93. package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
  94. package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
  95. package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
  96. package/scripts/lib/vault-mirror/telemetry.mjs +18 -11
  97. package/scripts/lib/vault-status/board-writer.mjs +5 -1
  98. package/scripts/lib/vcs-repo-spec.mjs +8 -0
  99. package/scripts/materialize-wave-scope.mjs +7 -4
  100. package/scripts/release.mjs +1 -0
  101. package/scripts/run-migrate-v2-cross-repo.mjs +14 -2
  102. package/scripts/run-quality-gate.mjs +8 -5
  103. package/scripts/self-update.mjs +229 -0
  104. package/scripts/sweep-expired-learnings.mjs +560 -28
  105. package/scripts/validate-wave-scope.mjs +21 -2
  106. package/scripts/vault-backfill.mjs +50 -37
  107. package/scripts/vault-mirror.mjs +170 -33
  108. package/skills/_shared/fleet-protocol.md +74 -71
  109. package/skills/_shared/state-ownership.md +38 -14
  110. package/skills/autopilot/SKILL.md +1 -1
  111. package/skills/bootstrap/SKILL.md +5 -3
  112. package/skills/bootstrap/_shared-template.md +96 -1
  113. package/skills/bootstrap/fast-template.md +3 -0
  114. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
  115. package/skills/bootstrap/standard-template.md +2 -0
  116. package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
  117. package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
  118. package/skills/discovery/probes/vault-staleness.mjs +9 -3
  119. package/skills/docs-orchestrator/SKILL.md +12 -5
  120. package/skills/docs-orchestrator/audience-mapping.md +5 -3
  121. package/skills/evolve/SKILL.md +11 -2
  122. package/skills/evolve/references/evolve-analyze-mode.md +37 -17
  123. package/skills/session-end/learning-patterns.md +3 -3
  124. package/skills/session-end/metrics-collection.md +1 -1
  125. package/skills/session-end/phase-3-2-docs-verification.md +7 -6
  126. package/skills/session-end/plan-verification.md +25 -9
  127. package/skills/session-end/references/phase-3-documentation-updates.md +11 -1
  128. package/skills/session-end/session-metrics-write.md +45 -10
  129. package/skills/session-start/SKILL.md +3 -3
  130. package/skills/session-start/references/operations-contract.md +5 -3
  131. package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
  132. package/skills/session-start/references/phase-4-ssot-environment-check.md +8 -1
  133. package/skills/vault-mirror/SKILL.md +21 -11
  134. package/skills/wave-executor/SKILL.md +1 -1
  135. package/skills/wave-executor/circuit-breaker.md +1 -1
  136. package/skills/wave-executor/references/wave-executor-state-init.md +1 -1
  137. package/skills/wave-executor/references/wave-loop-dispatch.md +8 -0
  138. package/skills/wave-executor/references/wave-loop-review.md +2 -2
  139. package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
  140. package/.agents/skills/navigator/SKILL.md +0 -22
  141. package/.agents/skills/navigator/agents/openai.yaml +0 -5
  142. package/.codex-plugin/skills/navigator/SKILL.md +0 -22
  143. package/.codex-plugin/skills/navigator/agents/openai.yaml +0 -5
  144. package/.cursor/commands/navigator.md +0 -15
  145. package/.cursor/skills/navigator/SKILL.md +0 -13
  146. package/pi/prompts/navigator.md +0 -12
  147. package/skills/navigator/SKILL.md +0 -193
  148. package/skills/navigator/references/account-check.sh +0 -87
  149. package/skills/navigator/references/fleet-decisions.mjs +0 -231
  150. package/skills/navigator/references/slot-check.sh +0 -114
@@ -198,6 +198,46 @@ export function mergePolicies(floor, overlay) {
198
198
  return { rules: merged, warnings };
199
199
  }
200
200
 
201
+ // ---------------------------------------------------------------------------
202
+ // Wave-manifest projection
203
+ // ---------------------------------------------------------------------------
204
+
205
+ /**
206
+ * The patterns a wave manifest's `blockedCommands` may carry (#1509): the
207
+ * `pattern` of every `severity: 'block'` rule that has NO `type`.
208
+ *
209
+ * A TYPED rule's `pattern` is not a command pattern. `redirect-truncate` rules
210
+ * carry `pattern: ">"` and `path-delete` rules a free-text label; both are
211
+ * decided by their TARGET, type-aware, in hooks/pre-bash-destructive-guard.mjs.
212
+ * `hooks/enforce-commands.mjs` reads every `blockedCommands` entry as a command
213
+ * pattern, so the flattened `">"` denied interpreter payloads such as
214
+ * `bash -c "echo hi > /tmp/x"` (measured 2026-10-03 @ 8292050e). An untyped
215
+ * rule is the one class whose pattern means the same thing to both hooks.
216
+ *
217
+ * An UNKNOWN `type` is excluded as well: its pattern's meaning is unknown, and
218
+ * the destructive guard still evaluates the rule on every Bash call, so leaving
219
+ * it out of the manifest drops no protection — admitting it repeats #1509 for
220
+ * the next rule type.
221
+ *
222
+ * Total: a non-array `rules` (e.g. `loadEffectivePolicy()`'s `null`) yields `[]`.
223
+ *
224
+ * @param {unknown} rules - the `rules` of {@link loadEffectivePolicy}
225
+ * @returns {string[]}
226
+ */
227
+ export function blockedCommandPatterns(rules) {
228
+ if (!Array.isArray(rules)) return [];
229
+ return rules
230
+ .filter(
231
+ (r) =>
232
+ r !== null &&
233
+ typeof r === 'object' &&
234
+ r.severity === 'block' &&
235
+ (r.type ?? null) === null &&
236
+ typeof r.pattern === 'string',
237
+ )
238
+ .map((r) => r.pattern);
239
+ }
240
+
201
241
  // ---------------------------------------------------------------------------
202
242
  // Load (with per-path mtime cache)
203
243
  // ---------------------------------------------------------------------------
@@ -54,6 +54,47 @@ export function expandTilde(p) {
54
54
  return p;
55
55
  }
56
56
 
57
+ /**
58
+ * Replace the current user's home directory with `~` wherever it occurs in
59
+ * `text` — the inverse of {@link expandTilde}, for free text as well as a bare
60
+ * path: an `err.message` from `node:fs` (`ENOENT: …, open '/Users/bob/v/x.md'`),
61
+ * git stderr, a resolved vault dir. Every occurrence is replaced.
62
+ *
63
+ * Log lines of the vault writers and the session-end shell land in CI logs and
64
+ * session transcripts, where the home dir is an owner-privacy leak (#1490,
65
+ * `.claude/rules/security.md` § Owner-Privacy).
66
+ *
67
+ * Boundary-aware on both sides. On the right the home dir must end at a path
68
+ * separator, the end of the text, whitespace, a quote/bracket/list delimiter
69
+ * (`'"\`)]},;:`) or a sentence-final `.`, so `/Users/bob` never rewrites
70
+ * `/Users/bobby/x` or `/Users/bob.old`. On the left it must not continue a
71
+ * path segment, so `/data/Users/bob` stays. A home dir that is empty, the
72
+ * filesystem root or a bare drive root is a no-op — collapsing it would
73
+ * rewrite every absolute path. Non-strings pass through unchanged.
74
+ *
75
+ * @param {string} text
76
+ * @param {string} [home] — defaults to `os.homedir()`; an unresolvable home
77
+ * leaves `text` unchanged (a log redactor must never turn an error path into
78
+ * a crash)
79
+ * @returns {string}
80
+ */
81
+ export function redactHomeDir(text, home) {
82
+ if (typeof text !== 'string' || text.length === 0) return text;
83
+ let h = home;
84
+ if (h === undefined) {
85
+ try {
86
+ h = os.homedir();
87
+ } catch {
88
+ return text;
89
+ }
90
+ }
91
+ h = String(h ?? '').replace(/[/\\]+$/, '');
92
+ if (h.length === 0 || /^[a-z]:$/i.test(h)) return text;
93
+ const escaped = h.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
94
+ const re = new RegExp(`(?<![\\w.~-])${escaped}(?=$|[/\\\\\\s'"\`)\\]},;:]|\\.(?:$|\\s))`, 'g');
95
+ return text.replace(re, '~');
96
+ }
97
+
57
98
  // ---------------------------------------------------------------------------
58
99
  // Timestamp helpers
59
100
  // ---------------------------------------------------------------------------
@@ -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
  // ---------------------------------------------------------------------------
@@ -103,11 +103,15 @@ import { expandTilde } from './common.mjs';
103
103
  *
104
104
  * `cwd` in the ctx is a test-only DI seam; production reads `process.cwd()`.
105
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
+ *
106
110
  * @param {string|null|undefined} committed — value the committed Session Config produced
107
111
  * @param {{ env?: Record<string, string|undefined>, ownerConfig?: object, cwd?: string }} [ctx] — from loadHostPaths()
108
112
  * @returns {{ value: string|null|undefined, source: 'env'|'match'|'owner'|'committed' }}
109
113
  */
110
- function resolveVaultDir(committed, { env = process.env, ownerConfig, cwd } = {}) {
114
+ export function resolveVaultDir(committed, { env = process.env, ownerConfig, cwd } = {}) {
111
115
  const isNonBlank = (v) => typeof v === 'string' && v.trim() !== '';
112
116
 
113
117
  const envVal = env?.SO_VAULT_DIR;
@@ -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
  *