session-orchestrator 4.2.0 → 5.0.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 (129) hide show
  1. package/.agents/skills/session-start/SKILL.md +1 -1
  2. package/.agents/skills/ux-grill/SKILL.md +22 -0
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/.claude-plugin/plugin.json +3 -2
  5. package/.codex-plugin/plugin.json +1 -1
  6. package/.codex-plugin/skills/session-start/SKILL.md +1 -1
  7. package/.codex-plugin/skills/ux-grill/SKILL.md +21 -0
  8. package/.codex-plugin/skills/ux-grill/agents/openai.yaml +5 -0
  9. package/.cursor/commands/ux-grill.md +14 -0
  10. package/.cursor/skills/session-start/SKILL.md +1 -1
  11. package/.cursor/skills/ux-grill/SKILL.md +13 -0
  12. package/.cursor-plugin/plugin.json +1 -1
  13. package/AGENTS.md +2 -1
  14. package/CHANGELOG.md +94 -1
  15. package/README.md +98 -86
  16. package/agents/dialectic-deriver.md +11 -0
  17. package/commands/ux-grill.md +51 -0
  18. package/docs/USER-GUIDE.md +2 -2
  19. package/docs/codex-setup.md +8 -0
  20. package/docs/components.md +7 -7
  21. package/docs/events-schema.md +9 -5
  22. package/docs/github-mirror-protection.md +61 -20
  23. package/docs/migration-v5.md +62 -0
  24. package/docs/scope-collision-guard.md +14 -0
  25. package/hooks/_lib/hook-import-set.json +44 -2
  26. package/hooks/_lib/lock-bootstrap.mjs +84 -1
  27. package/hooks/_lib/vcs-create-matcher.mjs +190 -3
  28. package/hooks/enforce-scope.mjs +201 -0
  29. package/hooks/hooks-codex.json +1 -1
  30. package/hooks/hooks-cursor.json +5 -0
  31. package/hooks/hooks.json +7 -2
  32. package/hooks/on-session-start.mjs +171 -49
  33. package/hooks/post-bash-issue-budget-refund.mjs +375 -0
  34. package/hooks/pre-auq-clarity.mjs +70 -18
  35. package/hooks/pre-bash-issue-budget.mjs +51 -4
  36. package/package.json +2 -1
  37. package/pi/prompts/ux-grill.md +12 -0
  38. package/scripts/ci/assert-vitest-green.mjs +4 -2
  39. package/scripts/dialectic-deriver.mjs +32 -8
  40. package/scripts/emit-session.mjs +72 -1
  41. package/scripts/lib/agent-status.mjs +441 -9
  42. package/scripts/lib/auq/schema.mjs +10 -3
  43. package/scripts/lib/ci-status-banner.mjs +29 -6
  44. package/scripts/lib/claude-md-budget-lint.mjs +52 -2
  45. package/scripts/lib/config.mjs +12 -1
  46. package/scripts/lib/eval/engine.mjs +7 -1
  47. package/scripts/lib/file-lock.mjs +114 -13
  48. package/scripts/lib/git-porcelain.mjs +113 -0
  49. package/scripts/lib/instruction-budget-guard.mjs +415 -47
  50. package/scripts/lib/io.mjs +29 -4
  51. package/scripts/lib/issue-budget.mjs +336 -6
  52. package/scripts/lib/learnings/sizing-subject.mjs +44 -0
  53. package/scripts/lib/locks/staging-fence-lock.mjs +19 -38
  54. package/scripts/lib/locks/state-md-lock.mjs +19 -41
  55. package/scripts/lib/maintenance-due-banner.mjs +11 -1
  56. package/scripts/lib/peer-cards/merger.mjs +143 -0
  57. package/scripts/lib/pre-dispatch-check.mjs +20 -14
  58. package/scripts/lib/project-hygiene.mjs +81 -30
  59. package/scripts/lib/quality-gate.mjs +14 -65
  60. package/scripts/lib/reconcile/engine.mjs +19 -1
  61. package/scripts/lib/reconcile/writer.mjs +278 -11
  62. package/scripts/lib/scope-echo.mjs +346 -0
  63. package/scripts/lib/session-lock.mjs +62 -2
  64. package/scripts/lib/session-record-repair.mjs +91 -0
  65. package/scripts/lib/session-schema/filters.mjs +26 -1
  66. package/scripts/lib/session-start-probes.mjs +419 -53
  67. package/scripts/lib/test-runner/artifact-paths.mjs +30 -5
  68. package/scripts/lib/test-runner/issue-reconcile.mjs +45 -8
  69. package/scripts/lib/tmux-layout/layouts.mjs +62 -4
  70. package/scripts/lib/ux-grill/collect.mjs +1163 -0
  71. package/scripts/lib/ux-grill/compare.mjs +285 -0
  72. package/scripts/lib/ux-grill/manifest.mjs +618 -0
  73. package/scripts/lib/ux-grill/measures.mjs +431 -0
  74. package/scripts/lib/ux-grill/paths.mjs +224 -0
  75. package/scripts/lib/ux-grill/pencil-coverage.mjs +284 -0
  76. package/scripts/lib/ux-grill/reconcile.mjs +344 -0
  77. package/scripts/lib/ux-grill/run-record.mjs +316 -0
  78. package/scripts/lib/ux-grill/schema.mjs +321 -0
  79. package/scripts/lib/validate/check-untracked-test-deps.mjs +33 -19
  80. package/scripts/lib/validate/check-unwired-features.mjs +48 -20
  81. package/scripts/lib/vault-status/board-lock.mjs +18 -0
  82. package/scripts/lib/vault-status/board-writer.mjs +8 -0
  83. package/scripts/mcp-server.sh +16 -1
  84. package/scripts/release.mjs +7 -2
  85. package/skills/bootstrap/SKILL.md +12 -209
  86. package/skills/bootstrap/references/bootstrap-ecosystem-health-flow.md +48 -0
  87. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +37 -0
  88. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +108 -0
  89. package/skills/bootstrap/references/bootstrap-rules-fetch-bridge.md +64 -0
  90. package/skills/claude-md-drift-check/SKILL.md +9 -2
  91. package/skills/claude-md-drift-check/checker.mjs +213 -21
  92. package/skills/discovery/SKILL.md +6 -173
  93. package/skills/discovery/probes/vault-staleness.mjs +35 -5
  94. package/skills/discovery/probes-docs.md +8 -4
  95. package/skills/discovery/probes-supply-chain.md +4 -2
  96. package/skills/discovery/probes-ui.md +7 -3
  97. package/skills/discovery/probes-vault.md +12 -4
  98. package/skills/discovery/references/discovery-interactive-triage.md +139 -0
  99. package/skills/discovery/references/discovery-triage-state.md +54 -0
  100. package/skills/eval/rubric-v1.md +13 -0
  101. package/skills/evolve/SKILL.md +2 -458
  102. package/skills/evolve/references/evolve-analyze-mode.md +360 -0
  103. package/skills/evolve/references/evolve-dialectic-mode.md +139 -0
  104. package/skills/plan/mode-retro.md +4 -3
  105. package/skills/reconcile/SKILL.md +10 -0
  106. package/skills/session-end/drift-operations.md +20 -5
  107. package/skills/session-end/metrics-collection.md +1 -0
  108. package/skills/session-end/phase-3-6-tail.md +4 -2
  109. package/skills/session-end/references/phase-2-quality-gate.md +3 -3
  110. package/skills/session-end/references/phase-5-issue-cleanup.md +6 -1
  111. package/skills/session-end/session-metrics-write.md +2 -0
  112. package/skills/session-plan/SKILL.md +2 -144
  113. package/skills/session-plan/references/session-plan-task-classification.md +152 -0
  114. package/skills/session-start/SKILL.md +24 -6
  115. package/skills/session-start/references/operations-contract.md +114 -0
  116. package/skills/session-start/references/phase-4-ssot-environment-check.md +22 -20
  117. package/skills/session-start/soul.md +2 -2
  118. package/skills/test-runner/SKILL.md +1 -1
  119. package/skills/tmux-layout/SKILL.md +3 -1
  120. package/skills/ux-grill/SKILL.md +211 -0
  121. package/skills/ux-grill/rubric-v2.md +201 -0
  122. package/skills/ux-grill/soul.md +76 -0
  123. package/skills/wave-executor/SKILL.md +3 -128
  124. package/skills/wave-executor/references/wave-executor-quality-gate.md +61 -0
  125. package/skills/wave-executor/references/wave-executor-state-init.md +86 -0
  126. package/skills/wave-executor/references/wave-loop-dispatch.md +8 -0
  127. package/skills/wave-executor/references/wave-loop-review.md +18 -5
  128. package/templates/_shared/ux-manifest.template.md +149 -0
  129. package/templates/_shared/journey-manifest.md +0 -114
@@ -492,6 +492,11 @@ export function readLockDetailed(opts = {}) {
492
492
  * recorded pids dead, INCLUDING the currently heartbeating session's
493
493
  * own lock), so `stale-pid-alive` was unreachable same-host and every
494
494
  * stale lock rendered as "confirmed dead" in the recovery AUQ.
495
+ * { ok: false, reason: 'missing-session-id' }
496
+ * — no usable `sessionId` given; NOTHING was written. Same reason string
497
+ * and same predicate as forceAcquire() (see its docblock for why an
498
+ * ownerless lock is worse than no lock). No `exclusivityClass`: the
499
+ * call never reached classification.
495
500
  * { ok: false, reason: 'fs-error', error, exclusivityClass? }
496
501
  * — filesystem failure
497
502
  * { ok: false, reason: 'active-incompatible-exclusive', allActiveSessions, blockingSession, exclusivityClass }
@@ -509,6 +514,28 @@ export function readLockDetailed(opts = {}) {
509
514
  * obtaining user consent.
510
515
  */
511
516
  export function acquire({ sessionId, mode, ttlHours = DEFAULT_TTL_HOURS, repoRoot, activeSessions, semanticSessionId, quiet = false } = {}) {
517
+ // -------------------------------------------------------------------------
518
+ // Ownerless-lock guard. Measured 2026-09-11 in a tmp repoRoot:
519
+ // acquire({sessionId: undefined}) -> {ok:true}, lockfile written with NO
520
+ // `session_id` key at all; acquire({sessionId: ''}) -> {ok:true}, lockfile
521
+ // written with `"session_id": ""`. Both are the exact "present but owned by
522
+ // nobody" state forceAcquire()'s docblock below describes: every reader
523
+ // compares such a lock against its own id, finds no match, classifies it
524
+ // FOREIGN — and foreign is the classification that SKIPS enforcement.
525
+ // The guard lived only on forceAcquire(), the rare path (explicit
526
+ // stale-lock takeover); acquire() is the primary one. The production
527
+ // caller hooks/_lib/lock-bootstrap.mjs gates empty ids upstream, but the
528
+ // prose path skills/session-start/references/phase-1-2-session-lock.md
529
+ // calls acquire() straight from the coordinator LLM with no gate at all.
530
+ // Placed FIRST, before classifyMode and before any fs work: nothing is
531
+ // written. `exclusivityClass` is deliberately absent from this shape — the
532
+ // caller's class is a property of a call that got far enough to be
533
+ // classified, and this one did not.
534
+ // -------------------------------------------------------------------------
535
+ if (!hasUsableSessionId(sessionId)) {
536
+ return { ok: false, reason: 'missing-session-id' };
537
+ }
538
+
512
539
  const lockFile = lockPathFor(repoRoot);
513
540
 
514
541
  // -------------------------------------------------------------------------
@@ -671,12 +698,45 @@ export function acquire({ sessionId, mode, ttlHours = DEFAULT_TTL_HOURS, repoRoo
671
698
  * Call only after the user has explicitly authorised stale-lock takeover.
672
699
  *
673
700
  * Returns:
674
- * { ok: true, lock, replacedLock? } — lock written (replacedLock present if one was overwritten)
675
- * { ok: false, reason: 'fs-error', ... } filesystem failure
701
+ * { ok: true, lock, replacedLock? } — lock written (replacedLock present if one was overwritten)
702
+ * { ok: false, reason: 'missing-session-id' } no usable `sessionId` given; NOTHING was written
703
+ * { ok: false, reason: 'fs-error', ... } — filesystem failure
704
+ *
705
+ * The `missing-session-id` guard is not cosmetic. `buildLock` assigns
706
+ * `session_id: sessionId` verbatim, and `JSON.stringify` DROPS an `undefined`
707
+ * value — so a `forceAcquire()` without a sessionId used to write a lock file
708
+ * carrying no `session_id` key at all. Such a lock is present but owned by
709
+ * nobody: every reader compares it against its own id, finds no match, and
710
+ * classifies it as FOREIGN — and foreign is precisely the classification that
711
+ * skips enforcement. The same failure class is why the wave scope manifest
712
+ * forbids `"session_id": ""` and requires the key be omitted instead.
713
+ *
714
+ * The result SHAPE (not a throw) is deliberate and matches the callers: the
715
+ * only production call site, `hooks/_lib/lock-bootstrap.mjs`, branches on
716
+ * `acquireResult.ok !== true` and bails non-blocking; a throw there would be
717
+ * swallowed by its `catch` and reported as the same `null`, i.e. it would add
718
+ * no information while breaking the direct `forceAcquire()` calls documented in
719
+ * `skills/session-start/references/phase-1-2-session-lock.md`.
676
720
  *
677
721
  * @param {{ sessionId: string, mode: string, ttlHours?: number, repoRoot?: string, semanticSessionId?: string }} args
678
722
  */
723
+ /**
724
+ * A `sessionId` is usable only when it is a non-blank string. ONE predicate,
725
+ * shared by acquire() and forceAcquire(), so the two entry points cannot drift
726
+ * apart again — they did: the guard existed on forceAcquire() alone until
727
+ * 2026-09-11, leaving the primary path able to write an ownerless lock.
728
+ *
729
+ * @param {unknown} sessionId
730
+ * @returns {boolean}
731
+ */
732
+ function hasUsableSessionId(sessionId) {
733
+ return typeof sessionId === 'string' && sessionId.trim().length > 0;
734
+ }
735
+
679
736
  export function forceAcquire({ sessionId, mode, ttlHours = DEFAULT_TTL_HOURS, repoRoot, semanticSessionId } = {}) {
737
+ if (!hasUsableSessionId(sessionId)) {
738
+ return { ok: false, reason: 'missing-session-id' };
739
+ }
680
740
  try {
681
741
  const replacedLock = readLock({ repoRoot });
682
742
  const lock = buildLock({ sessionId, mode, ttlHours, semanticSessionId });
@@ -42,6 +42,14 @@
42
42
  * had defaulted, which is what lets a downstream consumer tell a measured zero
43
43
  * apart from a repaired-to-zero.
44
44
  *
45
+ * It also never DISCARDS what it replaces: any field whose present value is
46
+ * overwritten by a default is first copied to a `_<field>_raw` sidecar
47
+ * (`preserveRaw`, same convention and same non-clobber guard as
48
+ * `session-schema/normalizer.mjs`'s `_express_path_detail`). Without it the
49
+ * repair was itself a data-loss event — 7 of 40 records in the
50
+ * `projects-baseline` ledger (S119, 2026-09-10) lost narrative `agent_summary`
51
+ * strings and `total_files_changed` path lists to `{...0}` / `0`.
52
+ *
45
53
  * Plain Node ESM. Named exports. DI-friendly via `deps`.
46
54
  *
47
55
  * Cross-references:
@@ -112,6 +120,70 @@ function orderIncompleteFields(fields) {
112
120
  return [...fields].sort((a, b) => rank(a) - rank(b) || (a < b ? -1 : a > b ? 1 : 0));
113
121
  }
114
122
 
123
+ /**
124
+ * Preserve the pre-repair value of `key` under a `_<key>_raw` sidecar BEFORE a
125
+ * default overwrites it.
126
+ *
127
+ * Convention lifted from `session-schema/normalizer.mjs`
128
+ * (`_express_path_detail`, `if (!('_express_path_detail' in next))`) — but its
129
+ * non-clobber guard is keyed on WHO WROTE the sidecar, not on its mere presence.
130
+ * That distinction is the fix for a measured data-loss bug (2026-09-11): the
131
+ * presence guard read `out`, and `out = { ...record }` carries the INPUT's key
132
+ * space, so a record arriving with its own `_agent_summary_raw` kept that
133
+ * unverifiable value and dropped the real `agent_summary` the repair was about
134
+ * to overwrite:
135
+ *
136
+ * in: agent_summary: 'ECHTE NARRATIVE ZUSAMMENFASSUNG',
137
+ * _agent_summary_raw: 'ANGREIFER-WERT'
138
+ * out: _agent_summary_raw = 'ANGREIFER-WERT' ← the real value was gone
139
+ *
140
+ * That is the mirror image of the promise this module's docblock makes, in the
141
+ * one case where the promise matters. So: `rescued` — a per-`repairRecord()`-call
142
+ * Set of sidecar keys THIS pass has written — replaces `sidecar in out`. A value
143
+ * that is provably real RIGHT NOW always outranks a sidecar of unknown origin.
144
+ *
145
+ * The multi-pass concern the old comment named is not what kept the sidecar
146
+ * safe, and was measured unreachable: after run 1 the field holds a VALID
147
+ * default, so run 2 records no defect for it and never calls preserveRaw at all
148
+ * (measured: `repairRecord(repairRecord(x).record).changed === false`, defects
149
+ * `[]`, sidecar intact). Within ONE pass the Set still gives first-write-wins,
150
+ * which is what the two branches per field (waves, agent_summary) rely on.
151
+ *
152
+ * NAMED CEILING (BV-004): if a record is RE-CORRUPTED with a different real
153
+ * value for the same key BETWEEN two repair runs, run 2's original wins and run
154
+ * 1's rescue is dropped. Reaching it requires corruption after a repair; the
155
+ * alternative — trusting an input-supplied sidecar over a value that is
156
+ * demonstrably real — is the bug above. Revisit if a ledger is ever repaired
157
+ * in a loop that can re-break the same key.
158
+ *
159
+ * Motivation is measured, not hypothetical: 7 of 40 repaired records in the
160
+ * `projects-baseline` ledger (S119, 2026-09-10) carried narrative strings in
161
+ * `agent_summary` and path lists in `total_files_changed`; both were replaced
162
+ * by `{...0}` / `0` and had to be restored by hand. This module's own docblock
163
+ * promises "nothing is invented" — discarding an unreadable original is the
164
+ * mirror-image violation of that promise.
165
+ *
166
+ * `undefined` and `null` are NOT preserved: an absent field has nothing to
167
+ * lose (the defect classes are named `*_absent` / `*_missing` for exactly that
168
+ * reason), and `JSON.stringify` drops an `undefined` sidecar anyway.
169
+ *
170
+ * The sidecars are schema-safe: `validateSession` has no unknown-key rejection
171
+ * (see `validator.mjs` — every `_validate*` helper checks named fields only),
172
+ * and `serializeSessionLineChecked` round-trips extra keys untouched.
173
+ *
174
+ * @param {Record<string, any>} out — the mutable repaired copy
175
+ * @param {string} key — the field about to be defaulted
176
+ * @param {any} rawValue — its ORIGINAL value (read from the untouched input)
177
+ * @param {Set<string>} rescued — sidecar keys already written by THIS call
178
+ */
179
+ function preserveRaw(out, key, rawValue, rescued) {
180
+ if (rawValue === undefined || rawValue === null) return;
181
+ const sidecar = `_${key}_raw`;
182
+ if (rescued.has(sidecar)) return;
183
+ out[sidecar] = rawValue;
184
+ rescued.add(sidecar);
185
+ }
186
+
115
187
  /** Compact ISO stamp for backup filenames: `20260805T091500Z`. */
116
188
  export function backupStamp(date = new Date()) {
117
189
  return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
@@ -143,6 +215,9 @@ export function repairRecord(record) {
143
215
  const out = { ...record };
144
216
  const defects = [];
145
217
  const incomplete = new Set();
218
+ // Sidecar keys written by THIS pass — the provenance the non-clobber guard
219
+ // needs. `out`'s own key space cannot serve: it is the INPUT's. See preserveRaw.
220
+ const rescued = new Set();
146
221
 
147
222
  // -- waves ----------------------------------------------------------------
148
223
  // A NUMBER here is not garbage: it IS the wave count, written by an older
@@ -158,6 +233,7 @@ export function repairRecord(record) {
158
233
  } else {
159
234
  defects.push('waves_not_array');
160
235
  }
236
+ preserveRaw(out, 'waves', record.waves, rescued);
161
237
  out.waves = [];
162
238
  incomplete.add('waves');
163
239
  }
@@ -173,6 +249,11 @@ export function repairRecord(record) {
173
249
  if (out.waves.length > 0 && out.waves.every(isPlainObject)) {
174
250
  const needsRenumber = out.waves.some((w) => !isCount(w.wave) || w.wave < 1);
175
251
  if (needsRenumber) {
252
+ // The ORIGINAL ordinals are not recoverable from the renumbered output
253
+ // (the input may mix valid, absent and 0-based `wave` values), so the
254
+ // pre-renumber array is preserved whole — same sidecar as the
255
+ // not-an-array branch above, which cannot have fired on this path.
256
+ preserveRaw(out, 'waves', record.waves, rescued);
176
257
  out.waves = out.waves.map((w, i) => ({ ...w, wave: i + 1 }));
177
258
  defects.push('wave_index_invalid');
178
259
  incomplete.add('waves[].wave');
@@ -181,6 +262,7 @@ export function repairRecord(record) {
181
262
 
182
263
  // -- total_waves ----------------------------------------------------------
183
264
  if (!isCount(out.total_waves)) {
265
+ preserveRaw(out, 'total_waves', record.total_waves, rescued);
184
266
  out.total_waves = wavesNumber !== null ? wavesNumber : out.waves.length;
185
267
  defects.push('total_waves_missing');
186
268
  incomplete.add('total_waves');
@@ -188,12 +270,18 @@ export function repairRecord(record) {
188
270
 
189
271
  // -- agent_summary --------------------------------------------------------
190
272
  if (!isPlainObject(out.agent_summary)) {
273
+ preserveRaw(out, 'agent_summary', record.agent_summary, rescued);
191
274
  out.agent_summary = { complete: 0, partial: 0, failed: 0, spiral: 0 };
192
275
  defects.push('agent_summary_absent');
193
276
  incomplete.add('agent_summary');
194
277
  } else {
195
278
  const missing = AGENT_SUMMARY_FIELDS.filter((f) => !isCount(out.agent_summary[f]));
196
279
  if (missing.length > 0) {
280
+ // Preserve the WHOLE original summary object rather than one sidecar per
281
+ // defaulted counter: it carries every original field value at once and
282
+ // keeps the sidecar namespace flat (`_agent_summary.spiral_raw` would be
283
+ // a second, uglier convention for the same job).
284
+ preserveRaw(out, 'agent_summary', record.agent_summary, rescued);
197
285
  out.agent_summary = { ...out.agent_summary };
198
286
  for (const f of missing) {
199
287
  out.agent_summary[f] = 0;
@@ -209,6 +297,7 @@ export function repairRecord(record) {
209
297
 
210
298
  // -- total_agents ---------------------------------------------------------
211
299
  if (!isCount(out.total_agents)) {
300
+ preserveRaw(out, 'total_agents', record.total_agents, rescued);
212
301
  // Prefer the record's own evidence: an agent_summary PRESENT in the
213
302
  // original sums to the real agent count (live line 71 sums to 30 where
214
303
  // waves.length is 5 — W2/A4 review finding). Fall back to waves.length
@@ -226,6 +315,7 @@ export function repairRecord(record) {
226
315
 
227
316
  // -- total_files_changed --------------------------------------------------
228
317
  if (!isCount(out.total_files_changed)) {
318
+ preserveRaw(out, 'total_files_changed', record.total_files_changed, rescued);
229
319
  out.total_files_changed = 0;
230
320
  defects.push('total_files_changed_missing');
231
321
  incomplete.add('total_files_changed');
@@ -239,6 +329,7 @@ export function repairRecord(record) {
239
329
  // i.e. "unknown", and flagged as such. NEVER the next record's timestamp:
240
330
  // the ledger is not chronologically ordered (see the module docblock).
241
331
  if (typeof out.completed_at !== 'string' && typeof out.started_at === 'string') {
332
+ preserveRaw(out, 'completed_at', record.completed_at, rescued);
242
333
  const endedMs = typeof out.ended_at === 'string' ? Date.parse(out.ended_at) : NaN;
243
334
  const startedMs = Date.parse(out.started_at);
244
335
  out.completed_at =
@@ -33,7 +33,8 @@
33
33
  * schema migrations and dedup checks all legitimately need the phantoms. Use
34
34
  * these helpers only where the window is meant to represent REAL WORK.
35
35
  *
36
- * Exports: isRealSession, filterRealSessions, tailRealSessions
36
+ * Exports: isRealSession, filterRealSessions, tailRealSessions,
37
+ * isCoordinatorDirectHousekeeping
37
38
  */
38
39
 
39
40
  // ---------------------------------------------------------------------------
@@ -86,3 +87,27 @@ export function tailRealSessions(records, n) {
86
87
  if (typeof n !== 'number' || !Number.isFinite(n) || n <= 0) return [];
87
88
  return real.slice(-Math.floor(n));
88
89
  }
90
+
91
+ /**
92
+ * True when a record's waves are ALL coordinator-direct `Housekeeping` waves —
93
+ * the shape the session-end writer rule (#1321) records for a session that ran
94
+ * no dispatched waves. The single definition of that shape: consumers treat such
95
+ * a record as "no waves ran" (eval gate-health) and exclude its 0-agent waves
96
+ * from agents-per-wave ratios.
97
+ *
98
+ * Keys on the wave SHAPE only, never on `session_type`: a housekeeping session
99
+ * that ran real waves is not this shape. Nor on `coordinator_direct` alone —
100
+ * whole multi-wave feature sessions carry it too.
101
+ *
102
+ * @param {unknown} record — a parsed sessions.jsonl entry
103
+ * @returns {boolean} — false for empty/absent `waves`
104
+ */
105
+ export function isCoordinatorDirectHousekeeping(record) {
106
+ if (record === null || typeof record !== 'object' || Array.isArray(record)) return false;
107
+ const { waves } = record;
108
+ return (
109
+ Array.isArray(waves) &&
110
+ waves.length > 0 &&
111
+ waves.every((w) => w?.role === 'Housekeeping' && w?.coordinator_direct === true)
112
+ );
113
+ }