session-orchestrator 5.5.0 → 5.7.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 (51) 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-plugin/plugin.json +1 -1
  5. package/CHANGELOG.md +46 -0
  6. package/README.md +27 -13
  7. package/docs/USER-GUIDE.md +1 -1
  8. package/docs/rule-authoring.md +10 -0
  9. package/docs/scope-collision-guard.md +5 -3
  10. package/docs/session-config-reference.md +1 -1
  11. package/docs/session-config-template.md +1 -1
  12. package/hooks/hooks-codex.json +1 -1
  13. package/hooks/hooks.json +1 -1
  14. package/hooks/on-stop.mjs +8 -2
  15. package/hooks/post-bash-write-verify.mjs +121 -7
  16. package/hooks/post-tool-batch-wave-signal.mjs +11 -4
  17. package/hooks/pre-bash-memory-propose-audit.mjs +13 -8
  18. package/hooks/pre-task-scope-disjoint.mjs +20 -35
  19. package/hooks/wave-scope-commit-guard.mjs +117 -20
  20. package/package.json +8 -3
  21. package/scripts/backfill-abandoned-sessions.mjs +5 -7
  22. package/scripts/backfill-learnings-from-vault.mjs +17 -6
  23. package/scripts/lib/dispatcher/rank.mjs +5 -1
  24. package/scripts/lib/events.mjs +62 -49
  25. package/scripts/lib/io.mjs +9 -4
  26. package/scripts/lib/learnings/candidates.mjs +7 -16
  27. package/scripts/lib/memory-proposals/store.mjs +9 -2
  28. package/scripts/lib/reconcile/rule-expiry-sweep.mjs +52 -0
  29. package/scripts/lib/scope-gate.mjs +8 -1
  30. package/scripts/lib/session-close-backfill.mjs +104 -13
  31. package/scripts/lib/validate/check-test-value-bans.mjs +9 -0
  32. package/scripts/lib/vault-mirror/process.mjs +388 -56
  33. package/scripts/lib/vault-mirror/prune.mjs +413 -0
  34. package/scripts/lib/vault-mirror/render-sessions.mjs +13 -5
  35. package/scripts/lib/vault-mirror/retention.mjs +388 -0
  36. package/scripts/lib/vault-status/board-writer.mjs +6 -6
  37. package/scripts/lib/wave-transcript-tail.mjs +11 -2
  38. package/scripts/print-applicable-rules.mjs +1 -25
  39. package/scripts/release.mjs +4 -0
  40. package/scripts/validate-wave-scope.mjs +39 -4
  41. package/scripts/vault-mirror-prune.mjs +145 -0
  42. package/scripts/vault-mirror.mjs +77 -12
  43. package/skills/_shared/parallel-aware-auq.md +1 -1
  44. package/skills/claude-md-drift-check/checker.mjs +6 -16
  45. package/skills/vault-mirror/SKILL.md +27 -2
  46. package/skills/wave-executor/references/wave-loop-scope-manifest.md +3 -1
  47. package/hooks/agent-teams-h3-test.sh +0 -362
  48. package/scripts/spikes/h3-agent-teams/preflight.sh +0 -53
  49. package/scripts/spikes/h3-agent-teams/run-h3.sh +0 -112
  50. package/scripts/spikes/h3-agent-teams/setup.sh +0 -137
  51. package/scripts/spikes/h3-agent-teams/toggle.sh +0 -38
@@ -413,7 +413,10 @@ export async function appendProposal({
413
413
  * Reads proposals.jsonl without acquiring the lock — suitable for diagnostics
414
414
  * and reads where eventual consistency is acceptable.
415
415
  *
416
- * Returns 0 when the file does not exist.
416
+ * Returns 0 when the file does not exist (silently — `countWaveLines` maps
417
+ * ENOENT to 0). Any other read failure also returns 0, but with a stderr WARN
418
+ * so a 0 caused by an unreadable file is distinguishable from an empty queue
419
+ * (#1216).
417
420
  *
418
421
  * @param {object} opts
419
422
  * @param {string} opts.repoRoot
@@ -424,7 +427,11 @@ export async function countProposalsForWave({ repoRoot, waveId }) {
424
427
  const jsonlPath = jsonlPathFor(repoRoot);
425
428
  try {
426
429
  return countWaveLines(jsonlPath, waveId);
427
- } catch {
430
+ } catch (err) {
431
+ process.stderr.write(
432
+ `[memory-proposals] WARN: countProposalsForWave: ` +
433
+ `${err?.code ?? '?'}: ${err?.message ?? String(err)} — returning 0\n`,
434
+ );
428
435
  return 0;
429
436
  }
430
437
  }
@@ -590,6 +590,58 @@ export async function planRuleExpirySweep(opts = {}) {
590
590
  const advisory = headerAdvisory(parsed, expiryById);
591
591
  const raiseTo = headerRaiseTarget(parsed, expiryById);
592
592
 
593
+ if (
594
+ parsed.counterLine === -1 &&
595
+ parsed.entries.length === 0 &&
596
+ parsed.pairs.length === 1 &&
597
+ substantive.length === 1 &&
598
+ parsed.expiresAt !== null &&
599
+ Number.isFinite(Date.parse(parsed.expiresAt))
600
+ ) {
601
+ // SINGLE-ENTRY file (#1513): one learning, one pair, no `### ` entries,
602
+ // no counter sentence — the shape the reconciliation engine writes per
603
+ // learning in consumer repos (measured 2026-10-03: all 21 generated rules
604
+ // of the Meta-Vault). The fail-closed branch below skipped every one of
605
+ // them as `no-counter-sentence`, so they stayed on disk forever after
606
+ // expiry. Here the date is unambiguous — the file's own frontmatter
607
+ // `expires-at`, the one `rule-loader.mjs` acts on — and there is no
608
+ // body sentence to keep in agreement. The later of that date and the
609
+ // learning's store `expires_at` (when it resolves) decides, so a
610
+ // re-stamped learning never loses its rule. Expired → the same `delete`
611
+ // as a fully expired consolidated file: the pair is stamped terminal
612
+ // before the unlink, so `/reconcile` does not re-propose it.
613
+ const pair = substantive[0];
614
+ const storeAt = expiryById.has(pair.id) ? Date.parse(expiryById.get(pair.id)) : Number.NaN;
615
+ const fileAt = Date.parse(parsed.expiresAt);
616
+ const effectiveAt = Number.isFinite(storeAt) ? Math.max(storeAt, fileAt) : fileAt;
617
+ const base = {
618
+ file: rule.file,
619
+ expiredPairIds: [],
620
+ keptPairIds: [pair.id],
621
+ unresolvedPairIds: expiryById.has(pair.id) ? [] : [pair.id],
622
+ bytesBefore: Buffer.byteLength(content, 'utf8'),
623
+ headings: 0,
624
+ substantivePairs: 1,
625
+ };
626
+ if (effectiveAt < cutoffMs) {
627
+ plans.push({
628
+ ...base,
629
+ expiredPairIds: [pair.id],
630
+ keptPairIds: [],
631
+ unresolvedPairIds: [],
632
+ action: 'delete',
633
+ reason: 'single-entry-expired',
634
+ newExpiresAt: null,
635
+ newAbsorbedCount: 0,
636
+ bytesAfter: 0,
637
+ stampKeys: [pair.key],
638
+ });
639
+ } else {
640
+ plans.push({ ...base, action: 'keep', newExpiresAt: null, newAbsorbedCount: 1, bytesAfter: base.bytesBefore });
641
+ }
642
+ continue;
643
+ }
644
+
593
645
  if (parsed.counterLine === -1) {
594
646
  // FAIL-CLOSED (GH#70). Every write this module performs moves the
595
647
  // frontmatter `expires-at` AND the body sentence that restates it. When
@@ -1295,10 +1295,17 @@ function normalizeAgentScopes(agentScopes) {
1295
1295
  * NAMED CEILING: `..` segments are left alone — `a/../b` is not provably `b`
1296
1296
  * when `a` is a glob segment. Revisit if a plan ever declares a `..` entry.
1297
1297
  *
1298
+ * EXPORTED as the ONE spelling rule (#1510 g): `hooks/pre-task-scope-disjoint.mjs`
1299
+ * applies it (late-bound) before its own dedupe and dir-promotion, instead of a
1300
+ * hand-copied twin that had already drifted (`a/././b` kept a `./` segment).
1301
+ * Whitespace is NOT trimmed here: no writer of a scope entry produces padding
1302
+ * (the hook's prose cleaner trims before this rule runs; sidecar entries are
1303
+ * JSON strings the plan writes), and trimming would rewrite a declared path.
1304
+ *
1298
1305
  * @param {string} entry - a non-empty scope entry
1299
1306
  * @returns {string}
1300
1307
  */
1301
- function scopeEntrySpelling(entry) {
1308
+ export function scopeEntrySpelling(entry) {
1302
1309
  const spelled = entry.replace(/\/{2,}/g, '/').replace(/(^|\/)(?:\.\/)+/g, '$1');
1303
1310
  // `./` alone would spell as '' — keep the entry rather than invent a meaning.
1304
1311
  return spelled.length > 0 ? spelled : entry;
@@ -118,6 +118,22 @@ const EVENTS_REL = ['.orchestrator', 'metrics', 'events.jsonl'];
118
118
  const SESSIONS_REL = ['.orchestrator', 'metrics', 'sessions.jsonl'];
119
119
  const BACKFILL_LOG_REL = ['.orchestrator', 'metrics', 'session-close-backfill.log'];
120
120
 
121
+ /**
122
+ * Which outcomes reach the side-log (#1513). Only EVENTS — `backfilled`,
123
+ * `would-backfill`, `error` and any other non-skip action — are written; every
124
+ * `skipped-*` outcome is the steady state of a session close and stays out.
125
+ * Measured in the Meta-Vault 2026-10-03: 5.786 lines / 723 KB, 86 %
126
+ * `skipped-already-recorded`, one commit-worthy diff per close. The skips lose
127
+ * nothing: on-session-end emits every outcome, skips included, as a queryable
128
+ * event (#1068 AC2), and an injected `log` callback still receives them all.
129
+ *
130
+ * @param {unknown} action
131
+ * @returns {boolean}
132
+ */
133
+ export function isLoggedBackfillAction(action) {
134
+ return typeof action === 'string' && !action.startsWith('skipped-');
135
+ }
136
+
121
137
  // ---------------------------------------------------------------------------
122
138
  // Helpers
123
139
  // ---------------------------------------------------------------------------
@@ -348,8 +364,11 @@ export const REQUIRED_EVENTS_WINDOW_DAYS = null;
348
364
  * @param {string} eventsPath
349
365
  * @param {string} id
350
366
  * @param {number|null} deadlineMs — epoch ms the scan must end by; `null` = unbounded
351
- * @returns {Array<object>|null} `null` when the deadline had passed before the
352
- * scan or cut it short — a partial history is never handed on as a whole one.
367
+ * @returns {{records: Array<object>, lostFields: string[]}|null} `null` when the
368
+ * deadline had passed before the scan or cut it short, or a recoverable cut
369
+ * lies in the session's window — a partial history is never handed on as a
370
+ * whole one. `lostFields` names the record fields a PERMANENTLY pruned archive
371
+ * inside the window may have changed (see {@link windowGapVerdict}).
353
372
  */
354
373
  function recordsMentioning(eventsPath, id, deadlineMs) {
355
374
  let budgetMs;
@@ -358,7 +377,7 @@ function recordsMentioning(eventsPath, id, deadlineMs) {
358
377
  if (!(budgetMs > 0)) return null;
359
378
  }
360
379
  const newestFirst = [];
361
- const { truncated, unreadable } = scanEventsBackwards({
380
+ const { truncated, unreadable, gaps } = scanEventsBackwards({
362
381
  filePath: eventsPath,
363
382
  filter: id,
364
383
  budgetMs,
@@ -371,13 +390,73 @@ function recordsMentioning(eventsPath, id, deadlineMs) {
371
390
  // deadline does — its records are simply absent — so it defers the candidate
372
391
  // the same way. Ceiling (BV-004): a PERMANENTLY unreadable archive defers its
373
392
  // candidates on every run (`skipped-history-unread`, visible, never a record
374
- // dated NOW). `gaps` (pruned/unindexed archives) are deliberately NOT treated
375
- // so: the walk reports every gap of the whole ledger, mostly far older than
376
- // the candidate, and deferring on any of them would skip the candidate
377
- // forever — telling a gap inside the session's window apart needs its
378
- // first_ts/last_ts against the session's range, a follow-up.
379
- const cut = truncated || (Array.isArray(unreadable) && unreadable.length > 0);
380
- return cut ? null : newestFirst.reverse();
393
+ // dated NOW).
394
+ if (truncated || (Array.isArray(unreadable) && unreadable.length > 0)) return null;
395
+ const verdict = windowGapVerdict(gaps, newestFirst);
396
+ if (verdict.defer) return null;
397
+ return { records: newestFirst.reverse(), lostFields: verdict.lostFields };
398
+ }
399
+
400
+ /**
401
+ * What a pruned/unindexed archive (`gaps` of {@link scanEventsBackwards}) means
402
+ * for the session whose found records are `records` (#1512).
403
+ *
404
+ * The walk reports every gap of the WHOLE ledger, mostly far older than the
405
+ * candidate; deferring on any of them would skip the candidate forever. So a
406
+ * gap counts only when its `[first_ts, last_ts]` intersects the session's
407
+ * window `[earliest, latest]` timestamp of the records the walk found.
408
+ *
409
+ * A counted gap's KIND then decides. `unindexed-archive` — the file still sits
410
+ * in `_archive/` under a name the walk does not index — can resolve (a rename
411
+ * puts it back), so it DEFERS (`skipped-history-unread`). `missing-archive` —
412
+ * pruned, its bytes gone — never resolves, so deferring would skip the candidate
413
+ * on every run forever; the record is written instead, naming in `lostFields`
414
+ * what the lost span may have held: `session_type` and `session_profile` (the
415
+ * LAST `shape_resolved` decides both) always, `completed_at` when the gap reaches past the latest found
416
+ * record. An unknown kind defers (fail toward not writing a wrong record).
417
+ *
418
+ * Fail direction (BV-004): a gap missing either bound, or a session with no
419
+ * dated record, is NOT counted — an unbounded gap would intersect every later
420
+ * session and recreate the defer-forever bug. Such a candidate is written as
421
+ * before #1512, its unread start listed in `_backfill_incomplete_fields`.
422
+ * Likewise a gap that ate the session's START lies just before the earliest
423
+ * found record and stays outside the window — same incomplete-start answer.
424
+ * Symmetrically a gap that starts AFTER the newest found record (it ate the
425
+ * session's END) is not counted either, so a `shape_resolved` or stop event
426
+ * lost there goes unnamed in `lostFields`; revisit if a backfilled record's
427
+ * `completed_at` is later contradicted by a recovered archive.
428
+ * Ceiling: an `unindexed-archive` nobody renames back defers its candidate on
429
+ * every run, visibly. Revisit if tombstones without `first_ts`/`last_ts` still
430
+ * appear in ledgers, or if `skipped-history-unread` persists across many runs.
431
+ *
432
+ * @param {Array<{kind?: string, first_ts?: string|null, last_ts?: string|null}>|undefined} gaps
433
+ * @param {Array<object>} records
434
+ * @returns {{defer: boolean, lostFields: string[]}}
435
+ */
436
+ function windowGapVerdict(gaps, records) {
437
+ const none = { defer: false, lostFields: [] };
438
+ if (!Array.isArray(gaps) || gaps.length === 0) return none;
439
+ let lo = Infinity;
440
+ let hi = -Infinity;
441
+ for (const r of records) {
442
+ const ts = typeof r?.timestamp === 'string' ? Date.parse(r.timestamp) : NaN;
443
+ if (Number.isNaN(ts)) continue;
444
+ lo = Math.min(lo, ts);
445
+ hi = Math.max(hi, ts);
446
+ }
447
+ if (lo > hi) return none;
448
+ const lost = new Set();
449
+ for (const g of gaps) {
450
+ const first = typeof g?.first_ts === 'string' ? Date.parse(g.first_ts) : NaN;
451
+ const last = typeof g?.last_ts === 'string' ? Date.parse(g.last_ts) : NaN;
452
+ if (Number.isNaN(first) || Number.isNaN(last)) continue;
453
+ if (!(first <= hi && last >= lo)) continue;
454
+ if (g.kind !== 'missing-archive') return { defer: true, lostFields: [] };
455
+ lost.add('session_type');
456
+ lost.add('session_profile'); // same latest-wins `shape_resolved` record
457
+ if (last > hi) lost.add('completed_at');
458
+ }
459
+ return { defer: false, lostFields: [...lost] };
381
460
  }
382
461
 
383
462
  /**
@@ -414,19 +493,21 @@ function recordsMentioning(eventsPath, id, deadlineMs) {
414
493
  * @param {string} eventsPath
415
494
  * @param {{ sessionId: string|null, semanticSessionId: string|null }} ids
416
495
  * @param {number|null} [deadlineMs=null] — shared scan deadline (epoch ms); `null` = unbounded
417
- * @returns {ReturnType<typeof collectSessionEvents> & { historyUnread?: true }}
496
+ * @returns {ReturnType<typeof collectSessionEvents> & { historyUnread?: true, historyLostFields?: string[] }}
418
497
  */
419
498
  function gatherSessionEvents(readFileSync, eventsPath, ids, deadlineMs = null) {
420
499
  const gathered = collectSessionEvents(readJsonlSafe(readFileSync, eventsPath), ids);
421
500
  if (gathered.startedAt !== null) return gathered;
422
501
  try {
423
502
  let uuid = isUuid(ids.sessionId) ? ids.sessionId : null;
503
+ let bridgeLost = [];
424
504
  if (uuid === null) {
425
505
  const label = ids.semanticSessionId || ids.sessionId;
426
506
  if (!label) return gathered;
427
507
  const bridge = recordsMentioning(eventsPath, label, deadlineMs);
428
508
  if (bridge === null) return { ...gathered, historyUnread: true };
429
- const bridged = collectSessionEvents(bridge, ids).uuids;
509
+ bridgeLost = bridge.lostFields;
510
+ const bridged = collectSessionEvents(bridge.records, ids).uuids;
430
511
  if (bridged.size !== 1) return gathered;
431
512
  [uuid] = bridged;
432
513
  }
@@ -434,7 +515,12 @@ function gatherSessionEvents(readFileSync, eventsPath, ids, deadlineMs = null) {
434
515
  if (history === null) return { ...gathered, historyUnread: true };
435
516
  // Empty only when the scan found no source at all — e.g. a reader seam the
436
517
  // filesystem does not back; the active answer stands.
437
- return history.length > 0 ? collectSessionEvents(history, ids) : gathered;
518
+ if (history.records.length === 0) return gathered;
519
+ const collected = collectSessionEvents(history.records, ids);
520
+ // #1512: a permanently pruned archive inside the window — written, with
521
+ // what it may have held named instead of deferring forever.
522
+ const lostFields = [...new Set([...bridgeLost, ...history.lostFields])];
523
+ return lostFields.length > 0 ? { ...collected, historyLostFields: lostFields } : collected;
438
524
  } catch {
439
525
  return gathered;
440
526
  }
@@ -559,6 +645,9 @@ function synthesizeRecord({ recordId, synthetic, gathered, nowMs, status = 'aban
559
645
  // no terminal event was found, so mark it incomplete so downstream duration
560
646
  // consumers can tell an events-attested end apart from an estimate (#914 R1).
561
647
  if (completedEstimated) incomplete.push('completed_at');
648
+ // #1512: fields a permanently pruned archive inside the session window may
649
+ // have held (`windowGapVerdict`), named rather than deferring forever.
650
+ for (const f of gathered.historyLostFields ?? []) if (!incomplete.includes(f)) incomplete.push(f);
562
651
 
563
652
  const record = {
564
653
  session_id: recordId,
@@ -1096,6 +1185,7 @@ export async function backfillAbandonedSession({
1096
1185
  return;
1097
1186
  }
1098
1187
  if (typeof repoRoot !== 'string' || repoRoot.length === 0) return;
1188
+ if (!isLoggedBackfillAction(res.action)) return;
1099
1189
  const logPath = path.join(repoRoot, ...BACKFILL_LOG_REL);
1100
1190
  fs.mkdirSync(path.dirname(logPath), { recursive: true });
1101
1191
  fs.appendFileSync(
@@ -1359,6 +1449,7 @@ export async function backfillCompletedFromStateMd({
1359
1449
  return;
1360
1450
  }
1361
1451
  if (typeof repoRoot !== 'string' || repoRoot.length === 0) return;
1452
+ if (!isLoggedBackfillAction(res.action)) return;
1362
1453
  const logPath = path.join(repoRoot, ...BACKFILL_LOG_REL);
1363
1454
  fs.mkdirSync(path.dirname(logPath), { recursive: true });
1364
1455
  fs.appendFileSync(
@@ -406,6 +406,15 @@ const SEAM_CLOCK_ARG = new RegExp(`${CLOCK_NAME}:`);
406
406
  * `{ now }` or a positional `now` loses its seam proof and its bombs go unseen.
407
407
  * REVISIT on the first B5 finding whose subject is a helper, or the first CI
408
408
  * `test-value-bans` B5 finding the ±days clock-shift probe shows to be false.
409
+ *
410
+ * CEILING (#1507 item 3) — three known blind spots of the SCREAMING_CASE forms,
411
+ * left deliberately: (a) a bare `NOW)` / `NOW,` counts as a handover, whatever
412
+ * the constant holds; (b) arithmetic on a collected clock (`NOW - DAY`) is NOT
413
+ * seen as a handover, the terminator follows the operand, not the name; (c) an
414
+ * IMPORTED clock constant is not checked for provenance — `NOW` from a helper
415
+ * module that holds `Date.now()` reads as controlled. Measured impact
416
+ * 2026-10-03: 0 changed findings over 722 test files.
417
+ * REVISIT on the first B5 miss or false finding traced to (a), (b) or (c).
409
418
  */
410
419
  const CONTROL_CLOCK_ARG = new RegExp(String.raw`${CLOCK_NAME}(:|,|\}|\))`, 'g');
411
420