session-orchestrator 3.17.0 → 3.19.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 (187) 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/030-wave-execution.mdc +17 -1
  5. package/CHANGELOG.md +105 -412
  6. package/README.md +12 -9
  7. package/SECURITY.md +190 -27
  8. package/agents/AGENTS.md +20 -3
  9. package/agents/code-implementer.md +6 -6
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/qa-strategist.md +31 -6
  12. package/agents/schemas/qa-strategist.schema.json +27 -0
  13. package/agents/schemas/test-writer.schema.json +60 -2
  14. package/agents/security-reviewer.md +1 -1
  15. package/agents/session-reviewer.md +1 -1
  16. package/agents/test-writer.md +29 -10
  17. package/agents/ui-developer.md +1 -1
  18. package/commands/contract-version-bump.md +28 -0
  19. package/commands/portfolio.md +1 -1
  20. package/docs/USER-GUIDE.md +8 -3
  21. package/docs/ci-setup.md +121 -7
  22. package/docs/codex-setup.md +1 -1
  23. package/docs/components.md +6 -6
  24. package/docs/cursor-setup.md +22 -9
  25. package/docs/events-schema.md +5 -1
  26. package/docs/instruction-delivery.md +444 -0
  27. package/docs/rule-authoring.md +58 -9
  28. package/docs/session-config-reference.md +244 -9
  29. package/docs/session-config-template.md +39 -3
  30. package/hooks/_lib/guard-source-loader.mjs +467 -0
  31. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  32. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  33. package/hooks/config-protection.mjs +0 -0
  34. package/hooks/enforce-commands.mjs +10 -2
  35. package/hooks/hooks-codex.json +1 -1
  36. package/hooks/hooks-cursor.json +11 -2
  37. package/hooks/hooks-pi.json +10 -0
  38. package/hooks/hooks.json +21 -1
  39. package/hooks/on-session-end.mjs +178 -18
  40. package/hooks/on-session-start.mjs +23 -0
  41. package/hooks/post-bash-write-verify.mjs +977 -0
  42. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  43. package/hooks/pre-bash-destructive-guard.mjs +525 -160
  44. package/hooks/pre-bash-issue-budget.mjs +167 -0
  45. package/hooks/pre-bash-sessions-ledger-guard.mjs +627 -0
  46. package/hooks/pre-bash-templates-first.mjs +96 -63
  47. package/hooks/subagent-telemetry.mjs +527 -37
  48. package/package.json +5 -2
  49. package/pi/prompts/contract-version-bump.md +12 -0
  50. package/rules/README.md +32 -0
  51. package/scripts/archive-closed-prds.mjs +12 -22
  52. package/scripts/autopilot-multi.mjs +103 -20
  53. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  54. package/scripts/check-doc-consistency.sh +17 -1
  55. package/scripts/eval-session.mjs +50 -9
  56. package/scripts/fleet-instruction-scan.mjs +141 -0
  57. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  58. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  59. package/scripts/lib/backlog-scan.mjs +39 -6
  60. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  61. package/scripts/lib/ci-status-banner.mjs +75 -12
  62. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  63. package/scripts/lib/command-blocker.mjs +1013 -58
  64. package/scripts/lib/config/config-protection.mjs +2 -1
  65. package/scripts/lib/config/drift-check.mjs +9 -1
  66. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  67. package/scripts/lib/config/issue-budget.mjs +123 -0
  68. package/scripts/lib/config/reconcile.mjs +21 -0
  69. package/scripts/lib/config/section-extractor.mjs +121 -1
  70. package/scripts/lib/config-schema.mjs +23 -3
  71. package/scripts/lib/config.mjs +17 -0
  72. package/scripts/lib/convergence-monitor.mjs +49 -3
  73. package/scripts/lib/description-surface.mjs +535 -0
  74. package/scripts/lib/dispatcher/enumerate.mjs +26 -40
  75. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  76. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  77. package/scripts/lib/eval/engine.mjs +47 -5
  78. package/scripts/lib/events.mjs +59 -7
  79. package/scripts/lib/gates/gate-full.mjs +15 -3
  80. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  81. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  82. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  83. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  84. package/scripts/lib/handover-gate.mjs +7 -3
  85. package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
  86. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  87. package/scripts/lib/io.mjs +345 -10
  88. package/scripts/lib/issue-budget.mjs +269 -0
  89. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  90. package/scripts/lib/label-scope.mjs +47 -0
  91. package/scripts/lib/learnings/schema.mjs +43 -3
  92. package/scripts/lib/lock-reaper.mjs +1 -2
  93. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  94. package/scripts/lib/peer-discovery.mjs +645 -0
  95. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  96. package/scripts/lib/product-repo-detect.mjs +9 -8
  97. package/scripts/lib/project-hygiene.mjs +432 -0
  98. package/scripts/lib/quality-gate.mjs +167 -0
  99. package/scripts/lib/recommendations-v0.mjs +1 -1
  100. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  101. package/scripts/lib/reconcile/emitter.mjs +23 -4
  102. package/scripts/lib/reconcile/engine.mjs +147 -39
  103. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  104. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  105. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  106. package/scripts/lib/resource-probe.mjs +19 -0
  107. package/scripts/lib/rule-loader.mjs +6 -0
  108. package/scripts/lib/scope-baseline.mjs +564 -0
  109. package/scripts/lib/scope-gate.mjs +399 -98
  110. package/scripts/lib/session-close-backfill.mjs +61 -6
  111. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  112. package/scripts/lib/session-id.mjs +221 -41
  113. package/scripts/lib/session-lock.mjs +304 -6
  114. package/scripts/lib/session-schema/constants.mjs +22 -3
  115. package/scripts/lib/session-schema/validator.mjs +16 -0
  116. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  117. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  118. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  119. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  120. package/scripts/lib/spiral-carryover.mjs +142 -30
  121. package/scripts/lib/state-md/mission-status.mjs +53 -3
  122. package/scripts/lib/subagents-schema.mjs +43 -9
  123. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  124. package/scripts/lib/tests-src-ratio.mjs +484 -0
  125. package/scripts/lib/validate/check-agents.mjs +56 -0
  126. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  127. package/scripts/lib/validate/check-rules.mjs +217 -35
  128. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  129. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  130. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  131. package/scripts/lib/vault-archive.mjs +17 -2
  132. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  133. package/scripts/lib/vault-mirror/process.mjs +30 -0
  134. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  135. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  136. package/scripts/lib/wave-resource-gate.mjs +115 -11
  137. package/scripts/lib/worktree/listing.mjs +44 -7
  138. package/scripts/mcp-server.sh +17 -3
  139. package/scripts/measure-context-overhead.sh +151 -0
  140. package/scripts/memory-propose.mjs +72 -9
  141. package/scripts/print-applicable-rules.mjs +51 -12
  142. package/scripts/release.mjs +534 -0
  143. package/scripts/run-quality-gate.mjs +123 -5
  144. package/scripts/validate-wave-scope.mjs +182 -17
  145. package/scripts/vault-integration-watcher.mjs +32 -10
  146. package/skills/_shared/config-reading.md +2 -2
  147. package/skills/bootstrap/fast-template.md +1 -1
  148. package/skills/claude-md-drift-check/checker.mjs +145 -28
  149. package/skills/contract-version-bump/SKILL.md +219 -0
  150. package/skills/discovery/SKILL.md +4 -4
  151. package/skills/discovery/issue-templates.md +11 -11
  152. package/skills/discovery/probes-audit.md +1 -1
  153. package/skills/discovery/probes-feature.md +1 -1
  154. package/skills/discovery/probes-session.md +26 -5
  155. package/skills/ecosystem-health/SKILL.md +1 -1
  156. package/skills/ecosystem-health/wizard.md +4 -4
  157. package/skills/evolve/SKILL.md +1 -0
  158. package/skills/gitlab-ops/SKILL.md +20 -12
  159. package/skills/gitlab-portfolio/SKILL.md +2 -2
  160. package/skills/hook-development/SKILL.md +1 -1
  161. package/skills/mode-selector/SKILL.md +1 -1
  162. package/skills/npm-publish/SKILL.md +17 -1
  163. package/skills/plan/SKILL.md +5 -5
  164. package/skills/plan/mode-feature.md +4 -4
  165. package/skills/plan/mode-new.md +10 -10
  166. package/skills/plan/mode-retro.md +1 -1
  167. package/skills/quality-gates/SKILL.md +1 -1
  168. package/skills/reconcile/SKILL.md +21 -4
  169. package/skills/session-end/SKILL.md +34 -13
  170. package/skills/session-end/discovery-scan.md +4 -2
  171. package/skills/session-end/drift-operations.md +4 -4
  172. package/skills/session-end/metrics-collection.md +13 -0
  173. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  174. package/skills/session-end/phase-3-6-tail.md +2 -1
  175. package/skills/session-end/plan-verification.md +5 -2
  176. package/skills/session-end/vault-operations.md +1 -1
  177. package/skills/session-end/verification-checklist.md +1 -1
  178. package/skills/session-plan/SKILL.md +6 -2
  179. package/skills/session-plan/wave-template.md +2 -0
  180. package/skills/session-start/SKILL.md +73 -7
  181. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  182. package/skills/test-runner/SKILL.md +2 -2
  183. package/skills/vault-sync/validator.mjs +108 -7
  184. package/skills/wave-executor/SKILL.md +5 -2
  185. package/skills/wave-executor/circuit-breaker.md +2 -0
  186. package/skills/wave-executor/wave-loop.md +163 -10
  187. package/templates/_shared/loop.md +4 -4
@@ -221,9 +221,27 @@ function isCandidateDeadByAge({ relaxDeadByAge, assumeDeadBeforeMs, lastEventMs,
221
221
  function synthesizeRecord({ recordId, synthetic, gathered, nowMs }) {
222
222
  const startedIso = canonicalIso(gathered.startedAt, gathered.earliestMs ?? nowMs);
223
223
  const startedMs = Date.parse(startedIso);
224
- const terminalMs = Number.isFinite(gathered.lastTerminalMs) ? gathered.lastTerminalMs : nowMs;
225
- // completed_at = last terminal event (or now), never earlier than started_at.
226
- const completedIso = new Date(Math.max(startedMs, terminalMs)).toISOString();
224
+ // completed_at is events-attested, never the backfill-run wall-clock (#914 R1).
225
+ // A fabricated `nowMs` produced ~64h of phantom runtime on real records
226
+ // (e.g. main-2026-07-18-session-2: started 2026-07-18, "completed" 2026-07-21).
227
+ // Precedence, all events-derived — the schema requires a string, so `null`
228
+ // is not an option (session-schema/validator.mjs rejects non-string):
229
+ // 1. lastTerminalMs — a real STOPPED/ENDED event: the true end.
230
+ // 2. lastEventMs — last life-sign; an ESTIMATE (flagged), never the run time.
231
+ // 3. startedIso — no post-start event at all → duration 0 (flagged).
232
+ let completedEstimated = false;
233
+ let completedMs;
234
+ if (Number.isFinite(gathered.lastTerminalMs)) {
235
+ completedMs = gathered.lastTerminalMs;
236
+ } else if (Number.isFinite(gathered.lastEventMs)) {
237
+ completedMs = gathered.lastEventMs;
238
+ completedEstimated = true;
239
+ } else {
240
+ completedMs = startedMs;
241
+ completedEstimated = true;
242
+ }
243
+ // Guard the same monotonic invariant as before: never earlier than started_at.
244
+ const completedIso = new Date(Math.max(startedMs, completedMs)).toISOString();
227
245
 
228
246
  let sessionType = 'housekeeping';
229
247
  let inferred = true;
@@ -238,6 +256,10 @@ function synthesizeRecord({ recordId, synthetic, gathered, nowMs }) {
238
256
  const incomplete = ['total_waves', 'waves', 'agent_summary', 'total_agents', 'total_files_changed'];
239
257
  if (!startedFound) incomplete.push('started_at');
240
258
  if (!branchFound) incomplete.push('branch');
259
+ // completed_at was estimated from lastEventMs (or defaulted to started_at) —
260
+ // no terminal event was found, so mark it incomplete so downstream duration
261
+ // consumers can tell an events-attested end apart from an estimate (#914 R1).
262
+ if (completedEstimated) incomplete.push('completed_at');
241
263
 
242
264
  const record = {
243
265
  session_id: recordId,
@@ -261,6 +283,7 @@ function synthesizeRecord({ recordId, synthetic, gathered, nowMs }) {
261
283
  if (branchFound) record.branch = gathered.branch;
262
284
  if (inferred) record._session_type_inferred = true;
263
285
  if (synthetic) record._synthetic_session_id = true;
286
+ if (completedEstimated) record._completed_at_estimated = true;
264
287
  return record;
265
288
  }
266
289
 
@@ -296,6 +319,7 @@ function checkAlreadyRecorded(readFileSync, sessionsPath, { recordId, sessionId
296
319
  * { action: 'would-backfill', sessionId, record, deadByAge? } — dryRun only, not written
297
320
  * { action: 'skipped-no-identifier' } — neither id known
298
321
  * { action: 'skipped-already-recorded', sessionId } — already in sessions.jsonl
322
+ * { action: 'skipped-own-live-lock', sessionId } — candidate IS this live session (#863)
299
323
  * { action: 'skipped-foreign-live-lock', sessionId, lockSessionId }
300
324
  * { action: 'skipped-marker-exists', sessionId } — lost the TOCTOU claim
301
325
  * { action: 'error', error, sessionId? } — any failure, swallowed
@@ -421,7 +445,14 @@ export async function backfillAbandonedSession({
421
445
  if (dupe) return dupe;
422
446
  }
423
447
 
424
- // -- Liveness guard — never overwrite a FOREIGN live lock ---------------
448
+ // -- Liveness guard — never overwrite a FOREIGN live lock, and never ----
449
+ // record OUR OWN live lock as 'abandoned' (#863 defect 1). Before this
450
+ // fix, the guard below only ever ran when `foreign` was true — the
451
+ // "this candidate IS the currently-live session" branch fell straight
452
+ // through to synthesis + append, backfilling a session as 'abandoned'
453
+ // mere seconds after it started (observed on-disk: main-2026-07-21-
454
+ // session-2, started 13:58:28.189Z, recorded abandoned 13:58:31.065Z).
455
+ //
425
456
  // deadByAge (#731): set when a foreign live lock was present but the
426
457
  // candidate qualified for relaxation — surfaced on the final result so
427
458
  // callers (the migration CLI's summary) can count relaxed backfills.
@@ -436,8 +467,32 @@ export async function backfillAbandonedSession({
436
467
  const ownByUuid = Boolean(sessionId) && lock.session_id === sessionId;
437
468
  const ownBySemantic =
438
469
  (Boolean(semanticSessionId) && lock.semantic_session_id === semanticSessionId) ||
439
- (Boolean(recordId) && lock.semantic_session_id === recordId);
440
- const foreign = !ownByUuid && !ownBySemantic;
470
+ (Boolean(recordId) && lock.semantic_session_id === recordId) ||
471
+ // #863 (d) lock-shape trap: some on-disk locks store the semantic
472
+ // id directly in `session_id` with no separate `semantic_session_id`
473
+ // field at all (the "generated-semantic" acquisition path in
474
+ // on-session-start.mjs mints `session_id === the semantic id`, and
475
+ // bootstrapLock's v2 enrichment step — which would otherwise add
476
+ // `semantic_session_id` — never ran for that lock). Without this
477
+ // fallback, ownBySemantic is dead code for that shape and a
478
+ // genuinely-own lock is misclassified `foreign`, which can then be
479
+ // wrongly bypassed by the dead-by-age relaxation below despite
480
+ // being live right now.
481
+ (Boolean(semanticSessionId) && lock.session_id === semanticSessionId) ||
482
+ (Boolean(recordId) && lock.session_id === recordId);
483
+ const own = ownByUuid || ownBySemantic;
484
+ const foreign = !own;
485
+
486
+ // #863 defect 1 — an OWN lock that is still live means this session
487
+ // is actively running right now; it must never be recorded
488
+ // 'abandoned'. Runs BEFORE the foreign-live-lock guard below (which
489
+ // only ever fires when `foreign` is true). A STALE own lock
490
+ // (isLockLive === false) falls through unchanged — this is a
491
+ // liveness gate, not a blanket own-session off-switch.
492
+ if (own && isLockLive(lock, nowMs)) {
493
+ return { action: 'skipped-own-live-lock', sessionId: recordId };
494
+ }
495
+
441
496
  if (foreign && isLockLive(lock, nowMs)) {
442
497
  const relaxed = isCandidateDeadByAge({
443
498
  relaxDeadByAge,
@@ -276,6 +276,7 @@ async function decideReconcile({ repoRoot, cfg }) {
276
276
  ruleExpiryDays: cfg?.reconcile?.['rule-expiry-days'] ?? undefined,
277
277
  minRuleDays: cfg?.reconcile?.['min-rule-days'] ?? undefined,
278
278
  minInsightChars: cfg?.reconcile?.['min-insight-chars'] ?? undefined,
279
+ maxProposalsPerRun: cfg?.reconcile?.['max-proposals-per-run'] ?? undefined,
279
280
  now: new Date(),
280
281
  dryRun: true, // never write the candidate sidecar from the aggregator
281
282
  });
@@ -2,8 +2,9 @@
2
2
  * session-id.mjs — Semantic session-ID generation and dual-format parsing.
3
3
  *
4
4
  * Public API:
5
- * - resolveSemanticSessionId({ branch, mode, activeSessions, repoRoot, history }): Promise<string>
5
+ * - resolveSemanticSessionId({ branch, mode, activeSessions, repoRoot, sources }): Promise<string>
6
6
  * - parseSessionId(id): { format: 'semantic'|'uuid', ...fields, raw } | null
7
+ * - DEFAULT_SESSION_ID_SOURCES — the default `sources` array (see below)
7
8
  * - SEMANTIC_ID_RE — source-of-truth regex for semantic session IDs
8
9
  * - UUID_V4_RE — regex for UUID-v4 format session IDs
9
10
  *
@@ -19,20 +20,26 @@
19
20
  * - resolveSemanticSessionId wraps its body in withStateMdLock so two
20
21
  * concurrent preambles cannot assign duplicate n values (PSA-005).
21
22
  * - parseSessionId is a pure synchronous function — no I/O, no side effects.
22
- * - History-aware (#585): the n-increment consults three candidate sources:
23
+ * - History-aware (#585): the n-increment consults four candidate sources:
23
24
  * 1. activeSessions (the legacy source — live lockfiles + registry).
24
25
  * 2. sessions.jsonl history (closed sessions; appended on session-end).
25
26
  * 3. STATE.md frontmatter `session:` (last-resort survivor of crashed sessions).
26
- * Sources 2 and 3 are read inside the existing withStateMdLock so their
27
- * visibility is consistent with the n-claim that follows. Both are opt-out
28
- * via opts.history.{consultHistory,consultStateMd} and DI-overridable via
29
- * opts.history.{readHistoryImpl,readStateMdSessionImpl} for tests.
27
+ * 4. events.jsonl `orchestrator.session.lock.acquired` (#952 Teil B
28
+ * append-only mint-ledger; the only source written at CLAIM time).
29
+ * Sources 2-4 are DEFAULT_SESSION_ID_SOURCES — deferred readers invoked
30
+ * inside the existing withStateMdLock, so their visibility is consistent
31
+ * with the n-claim that follows. They are configured through the single
32
+ * `opts.sources` array (#956): opting out is passing a shorter array,
33
+ * overriding is passing your own reader, and adding a fifth source costs
34
+ * no new interface element. Source 1 stays a separate parameter — it is
35
+ * already-resolved data gathered BEFORE the lock, so it is deliberately
36
+ * not disguised as a deferred, lock-covered reader.
30
37
  * - Reader helpers never throw: missing files, malformed JSONL lines, and
31
38
  * unparseable frontmatter are all treated as "no signal" (empty/null).
32
39
  * - Production code is silent: no console.log, no console.warn.
33
40
  */
34
41
 
35
- import { readFile } from 'node:fs/promises';
42
+ import { open, readFile } from 'node:fs/promises';
36
43
  import path from 'node:path';
37
44
 
38
45
  import { withStateMdLock } from './session-lock.mjs';
@@ -139,6 +146,118 @@ async function readSessionIdsFromHistory(repoRoot) {
139
146
  return ids;
140
147
  }
141
148
 
149
+ /**
150
+ * Event name emitted by `hooks/_lib/lock-bootstrap.mjs` on every successful
151
+ * session-lock acquisition — i.e. on every semantic-ID MINT.
152
+ * @type {string}
153
+ */
154
+ const LOCK_ACQUIRED_EVENT = 'orchestrator.session.lock.acquired';
155
+
156
+ /**
157
+ * Byte window for the events.jsonl tail read (1 MiB).
158
+ *
159
+ * Sizing rationale: the match downstream is `date === today`, so the window
160
+ * only has to cover today's events. Worst observed single-day volume in this
161
+ * repo is ~164 KiB (measured 2026-07-31 over the live log), and rotation caps
162
+ * the file at `events-rotation.max-size-mb` (default 10 MiB). 1 MiB is ~6x the
163
+ * worst observed day and 10% of the rotation ceiling.
164
+ * @type {number}
165
+ */
166
+ const EVENTS_TAIL_BYTES = 1024 * 1024;
167
+
168
+ /**
169
+ * Age cutoff for events considered by the reader (2 days).
170
+ *
171
+ * Anything older cannot influence a `date === today` match, so dropping it
172
+ * early keeps the candidate set small. Records whose timestamp is absent or
173
+ * unparseable are KEPT, never dropped — under-reporting a claimed n is exactly
174
+ * the failure this source exists to prevent (#952).
175
+ * @type {number}
176
+ */
177
+ const EVENTS_MAX_AGE_MS = 2 * 24 * 60 * 60 * 1000;
178
+
179
+ /**
180
+ * Read minted semantic session IDs from
181
+ * `<repoRoot>/.orchestrator/metrics/events.jsonl`.
182
+ *
183
+ * Why this source exists (#952 Teil B): `sessions.jsonl` is written at
184
+ * session-END. A session that is killed before its SessionEnd hook fires never
185
+ * writes a record, so the `n` it consumed becomes invisible to every other
186
+ * source and the NEXT session mints the same ID. Proven collision (live ledger,
187
+ * 2026-07-29): `main-2026-07-29-session-2` was minted twice, 7h57m apart.
188
+ * `events.jsonl` is append-only and written at CLAIM time by
189
+ * `hooks/_lib/lock-bootstrap.mjs`, so a minted `n` survives a crash by
190
+ * construction.
191
+ *
192
+ * Read strategy: a bounded TAIL read (`EVENTS_TAIL_BYTES` from the end of the
193
+ * file), not a whole-file read — events.jsonl grows without bound between
194
+ * rotations (~1.9 MB at time of writing). When the window does not reach the
195
+ * start of the file, the first (possibly truncated) line is discarded.
196
+ *
197
+ * Robustness contract (mirrors `readSessionIdsFromHistory`):
198
+ * - Missing file (ENOENT) → returns [].
199
+ * - Permission/FS/read error → returns [] (fail-open; a telemetry read must
200
+ * never block the n-claim it feeds).
201
+ * - Malformed JSONL line → silently skipped (per-line try/catch).
202
+ * - Freshly rotated file → legitimately yields [] or few records. Not an
203
+ * error: the three other sources remain in play unchanged.
204
+ *
205
+ * @param {string} repoRoot
206
+ * @returns {Promise<string[]>} Array of semantic_session_id strings (may include duplicates).
207
+ */
208
+ async function readSessionIdsFromEvents(repoRoot) {
209
+ const filePath = path.join(repoRoot, '.orchestrator', 'metrics', 'events.jsonl');
210
+
211
+ let text = '';
212
+ // Assigned in the try below; every path that reaches its read site has
213
+ // passed through that assignment (the catch returns early).
214
+ let windowIsPartial;
215
+ let handle = null;
216
+ try {
217
+ handle = await open(filePath, 'r');
218
+ const { size } = await handle.stat();
219
+ const start = size > EVENTS_TAIL_BYTES ? size - EVENTS_TAIL_BYTES : 0;
220
+ windowIsPartial = start > 0;
221
+ const length = size - start;
222
+ if (length > 0) {
223
+ const buf = Buffer.alloc(length);
224
+ const { bytesRead } = await handle.read(buf, 0, length, start);
225
+ text = buf.subarray(0, bytesRead).toString('utf8');
226
+ }
227
+ } catch {
228
+ return [];
229
+ } finally {
230
+ if (handle !== null) await handle.close().catch(() => {});
231
+ }
232
+
233
+ const lines = text.split(/\r?\n/);
234
+ // The window started mid-file, so line 0 may be a truncated record.
235
+ if (windowIsPartial) lines.shift();
236
+
237
+ const cutoff = Date.now() - EVENTS_MAX_AGE_MS;
238
+ const ids = [];
239
+ for (const line of lines) {
240
+ const trimmed = line.trim();
241
+ if (trimmed === '') continue;
242
+ // Cheap substring pre-filter: events.jsonl carries many event kinds and
243
+ // JSON.parse on every line of a 1 MiB window is the dominant cost.
244
+ if (!trimmed.includes(LOCK_ACQUIRED_EVENT)) continue;
245
+ let parsed;
246
+ try {
247
+ parsed = JSON.parse(trimmed);
248
+ } catch {
249
+ // Malformed line — skip silently (same contract as sessions.jsonl).
250
+ continue;
251
+ }
252
+ if (parsed?.event !== LOCK_ACQUIRED_EVENT) continue;
253
+ const ts = Date.parse(parsed.timestamp);
254
+ // Only drop on a PARSEABLE, definitely-stale timestamp (see EVENTS_MAX_AGE_MS).
255
+ if (Number.isFinite(ts) && ts < cutoff) continue;
256
+ if (typeof parsed.semantic_session_id === 'string') ids.push(parsed.semantic_session_id);
257
+ }
258
+ return ids;
259
+ }
260
+
142
261
  /**
143
262
  * Read the `session:` field from `<repoRoot>/.claude/STATE.md` (or `.codex`,
144
263
  * `.cursor`, `.pi` — `resolveStateMdPath` picks the active/existing candidate).
@@ -170,10 +289,53 @@ async function readSessionIdFromStateMd(repoRoot) {
170
289
  return typeof sessionField === 'string' ? sessionField : null;
171
290
  }
172
291
 
292
+ /**
293
+ * Shape adapter for the STATE.md source.
294
+ *
295
+ * STATE.md has exactly ONE `session:` slot, so its reader naturally returns
296
+ * `string|null` while every other source returns `string[]`. This adapter
297
+ * normalises it to the uniform source signature `(repoRoot) => Promise<string[]>`,
298
+ * which is what lets `sources` be a single homogeneous array. Before #956 the
299
+ * mismatch was special-cased twice at the merge site (a `.catch(() => null)`
300
+ * that differed from its siblings, plus a `stateMdId !== null` spread guard).
301
+ *
302
+ * @param {string} repoRoot
303
+ * @returns {Promise<string[]>} `[]` when absent/unreadable, else `[sessionId]`.
304
+ */
305
+ async function readSessionIdsFromStateMd(repoRoot) {
306
+ const id = await readSessionIdFromStateMd(repoRoot);
307
+ return id === null ? [] : [id];
308
+ }
309
+
173
310
  // ---------------------------------------------------------------------------
174
311
  // Public API
175
312
  // ---------------------------------------------------------------------------
176
313
 
314
+ /**
315
+ * The default `sources` array for {@link resolveSemanticSessionId} — the three
316
+ * on-disk candidate-ID readers, in the order they were introduced:
317
+ *
318
+ * 1. `readSessionIdsFromHistory` — `.orchestrator/metrics/sessions.jsonl` (#585)
319
+ * 2. `readSessionIdsFromStateMd` — STATE.md frontmatter `session:` (#585)
320
+ * 3. `readSessionIdsFromEvents` — `.orchestrator/metrics/events.jsonl` (#952)
321
+ *
322
+ * Order is irrelevant to the result: aggregation is `max` over the UNION of all
323
+ * sources, so it is commutative and monotone — an additional source can only
324
+ * raise `n`, never lower it.
325
+ *
326
+ * Exported so callers can derive from it rather than restate it:
327
+ * - add a source: `sources: [...DEFAULT_SESSION_ID_SOURCES, myReader]`
328
+ * - drop a source: `sources: DEFAULT_SESSION_ID_SOURCES.filter(s => s !== …)`
329
+ * - legacy mode: `sources: []` (activeSessions only)
330
+ *
331
+ * @type {ReadonlyArray<(repoRoot: string) => Promise<string[]>>}
332
+ */
333
+ export const DEFAULT_SESSION_ID_SOURCES = Object.freeze([
334
+ readSessionIdsFromHistory,
335
+ readSessionIdsFromStateMd,
336
+ readSessionIdsFromEvents,
337
+ ]);
338
+
177
339
  /**
178
340
  * Parse a session ID string into a structured object.
179
341
  *
@@ -228,23 +390,33 @@ export function parseSessionId(id) {
228
390
  * `^[a-z0-9._/-]+-\d{4}-\d{2}-\d{2}-[a-z-]+-\d+$`
229
391
  *
230
392
  * Counter semantics (PRD §3.A P2 Ubiquitous + #585 history-aware extension):
231
- * n = max(existing n values for same branch+date+mode across THREE sources) + 1
393
+ * n = max(existing n values for same branch+date+mode across FOUR sources) + 1
232
394
  * When no matching sessions exist anywhere → n = 1.
233
395
  * Gaps are never filled: if existing n = [1, 3] the next is 4, not 2.
234
396
  *
235
397
  * Sources consulted (all merged into a single candidate set):
236
398
  * A. opts.activeSessions — live sessions (lockfiles + host-wide registry).
237
- * B. sessions.jsonl — closed-session history (opt-out via opts.history.consultHistory=false).
238
- * C. STATE.md `session:` — last-resort survivor (opt-out via opts.history.consultStateMd=false).
399
+ * B. sessions.jsonl — closed-session history.
400
+ * C. STATE.md `session:` — last-resort survivor.
401
+ * D. events.jsonl — mint ledger, written at CLAIM time.
239
402
  *
240
- * Defaults for B and C are ON historically only source A was consulted,
403
+ * B, C and D are `opts.sources` (default: DEFAULT_SESSION_ID_SOURCES, i.e.
404
+ * all three ON) — historically only source A was consulted,
241
405
  * which caused n to reset to 1 once the previous session deregistered itself
242
406
  * (root-cause of duplicate-ID incidents documented in #585).
243
407
  *
408
+ * Source D closes the residual hole (#952 Teil B): B is written at session-END
409
+ * and C has exactly ONE slot (an interleaved session of a different mode
410
+ * overwrites it, and the mode filter below then discards it). A session killed
411
+ * before its SessionEnd hook therefore leaves NO trace in A, B or C, and the
412
+ * next session re-mints its n. Proven live: `main-2026-07-29-session-2` was
413
+ * minted twice, 7h57m apart. D would have yielded maxN=2 → `session-3`.
414
+ *
244
415
  * Concurrency safety (PSA-005):
245
- * All three reads and the n-claim are wrapped in `withStateMdLock` so two
416
+ * All `sources` reads and the n-claim are wrapped in `withStateMdLock` so two
246
417
  * concurrent preambles in parallel worktrees observe a consistent view and
247
- * cannot assign the same n.
418
+ * cannot assign the same n. The #952 collision was NOT a concurrency defect —
419
+ * the lock held; the candidate set was incomplete.
248
420
  *
249
421
  * UUID-v4 entries (in any source) are silently dropped (parseSessionId returns
250
422
  * format:'uuid' which the filter excludes). Malformed semantic-looking IDs are
@@ -258,21 +430,27 @@ export function parseSessionId(id) {
258
430
  * @param {Array<{sessionId: string}>} [opts.activeSessions=[]] - Active sessions
259
431
  * array from session-discovery. Each element must have a `.sessionId` string.
260
432
  * Defaults to an empty array when omitted or undefined.
433
+ * Deliberately NOT part of `sources`: it is `{sessionId}` objects rather than
434
+ * strings, and it is already-resolved data gathered BEFORE the call (see
435
+ * `hooks/on-session-start.mjs` `deriveSemanticCandidate`), so it is the one
436
+ * source NOT covered by the lock's consistent-read guarantee. Wrapping it as
437
+ * `async () => data` would hide both facts.
261
438
  * @param {string} [opts.repoRoot] - Absolute path to the repo root. Used by
262
- * `withStateMdLock`, the sessions.jsonl reader, and the STATE.md reader.
439
+ * `withStateMdLock` and passed to every entry of `sources`.
263
440
  * Defaults to `process.cwd()` when omitted.
264
- * @param {object} [opts.history] - Opt-out + DI controls for the history-aware
265
- * sources introduced in #585. All fields optional.
266
- * @param {boolean} [opts.history.consultHistory=true] - When false, the
267
- * sessions.jsonl reader is skipped entirely (legacy-only behaviour).
268
- * @param {boolean} [opts.history.consultStateMd=true] - When false, the
269
- * STATE.md `session:` reader is skipped entirely (legacy-only behaviour).
270
- * @param {(repoRoot: string) => Promise<string[]>} [opts.history.readHistoryImpl]
271
- * Test/DI override for the sessions.jsonl reader. Signature must mirror the
272
- * internal helper: returns an array of session_id strings (no throws).
273
- * @param {(repoRoot: string) => Promise<string|null>} [opts.history.readStateMdSessionImpl]
274
- * Test/DI override for the STATE.md reader. Signature must mirror the
275
- * internal helper: returns a session_id string or null (no throws).
441
+ * @param {ReadonlyArray<(repoRoot: string) => Promise<string[]>>} [opts.sources=DEFAULT_SESSION_ID_SOURCES]
442
+ * The candidate-ID readers for sources B/C/D the ONLY DI knob (#956).
443
+ * Each entry is a deferred thunk invoked with the effective repo root INSIDE
444
+ * `withStateMdLock`, and must resolve to an array of session-ID strings.
445
+ * Non-string and unknown-format entries are dropped downstream, so a reader
446
+ * may return raw IDs without pre-filtering.
447
+ * The default is {@link DEFAULT_SESSION_ID_SOURCES} =
448
+ * `[sessions.jsonl reader, STATE.md reader, events.jsonl reader]`.
449
+ * Opting a source out = passing a shorter array (`[]` for the pre-#585
450
+ * activeSessions-only behaviour); overriding one = passing your own function;
451
+ * adding a fifth = appending to the default. Each entry is individually
452
+ * error-swallowed (a throwing or rejecting source contributes `[]` and never
453
+ * blocks the n-claim), so one bad reader cannot fail the mint.
276
454
  * @returns {Promise<string>} The next semantic session ID, e.g. "main-2026-05-27-deep-2".
277
455
  * @throws {TypeError} When `branch` is missing, empty, or contains invalid characters.
278
456
  * @throws {TypeError} When `mode` is missing, empty, or contains characters other than
@@ -284,7 +462,7 @@ export async function resolveSemanticSessionId({
284
462
  mode,
285
463
  activeSessions,
286
464
  repoRoot,
287
- history,
465
+ sources,
288
466
  } = {}) {
289
467
  // Input validation — validate before acquiring the lock to fail fast.
290
468
  if (!isValidBranch(branch)) {
@@ -298,30 +476,32 @@ export async function resolveSemanticSessionId({
298
476
  );
299
477
  }
300
478
 
301
- // Normalise the history opts bag. Both flags default ON see audit §2.3.
302
- const consultHistory = history?.consultHistory !== false;
303
- const consultStateMd = history?.consultStateMd !== false;
304
- const historyImpl = history?.readHistoryImpl ?? readSessionIdsFromHistory;
305
- const stateMdImpl = history?.readStateMdSessionImpl ?? readSessionIdFromStateMd;
479
+ // Resolve the source set. An explicit array (including []) wins; anything
480
+ // else omitted, undefined, non-array — falls back to the documented default.
481
+ const effectiveSources = Array.isArray(sources) ? sources : DEFAULT_SESSION_ID_SOURCES;
306
482
  const effectiveRoot = repoRoot ?? process.cwd();
307
483
 
308
484
  return withStateMdLock(repoRoot, async () => {
309
485
  // Derive the current UTC date as YYYY-MM-DD.
310
486
  const today = new Date().toISOString().slice(0, 10);
311
487
 
312
- // Read the two history-aware sources in parallel. Errors from either source
313
- // are swallowed (the .catch() guards belt-and-braces; helpers already
314
- // never throw, but a third-party DI impl might).
315
- const [historicalIds, stateMdId] = await Promise.all([
316
- consultHistory ? historyImpl(effectiveRoot).catch(() => []) : Promise.resolve([]),
317
- consultStateMd ? stateMdImpl(effectiveRoot).catch(() => null) : Promise.resolve(null),
318
- ]);
488
+ // Read every source in parallel. Each is individually error-swallowed:
489
+ // the built-in readers already never throw, but a caller-supplied source
490
+ // might — and one bad reader must never block the n-claim it feeds. The
491
+ // Promise.resolve().then() wrapper also catches a SYNCHRONOUS throw, which
492
+ // a bare `.catch()` on the return value would not.
493
+ const perSourceIds = await Promise.all(
494
+ effectiveSources.map((read) =>
495
+ Promise.resolve()
496
+ .then(() => read(effectiveRoot))
497
+ .catch(() => []),
498
+ ),
499
+ );
319
500
 
320
501
  // Build a single candidate stream. Duplicates are fine — Math.max handles them.
321
502
  const candidateIds = [
322
503
  ...(activeSessions ?? []).map((s) => s?.sessionId),
323
- ...historicalIds,
324
- ...(stateMdId !== null ? [stateMdId] : []),
504
+ ...perSourceIds.flat(),
325
505
  ];
326
506
 
327
507
  // Match against (branch, date, mode) and project to n.