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
@@ -43,6 +43,7 @@ import { parseArgs } from 'node:util';
43
43
 
44
44
  import { backfillAbandonedSession, isUuid } from './lib/session-close-backfill.mjs';
45
45
  import { SCAN_CHUNK_BYTES, emitEvent, listEventSourcesNewestFirst } from './lib/events.mjs';
46
+ import { ARCHIVE_DIR_NAME, ROTATION_EVENT } from './lib/events-schema.mjs';
46
47
  import { getProjectDir } from './lib/platform.mjs';
47
48
  import { isMainModule } from './lib/is-main-module.mjs';
48
49
  import { isLockLive, readLock } from './lib/session-lock.mjs';
@@ -52,6 +53,15 @@ const LOCK_ACQUIRED = 'orchestrator.session.lock.acquired';
52
53
  const SESSION_STARTED = 'orchestrator.session.started';
53
54
  const SESSION_ENDED = 'orchestrator.session.ended';
54
55
 
56
+ /**
57
+ * Events-ledger window for the #1401 census (`scripts/lib/events-retention-banner.mjs`):
58
+ * `null` — {@link planSessions} takes every `session.started` ever written as a
59
+ * candidate, so no day count bounds it (`DEFAULT_TTL_HOURS` is a minimum AGE,
60
+ * not a window). A pruned history yields fewer candidates; the run summary says
61
+ * so as `history_gaps` (#1498).
62
+ */
63
+ export const REQUIRED_EVENTS_WINDOW_DAYS = null;
64
+
55
65
  /**
56
66
  * Default cap on how many candidates may reach the (expensive) shared core in a
57
67
  * single run. Only the SessionStart path passes a limit; the CLI stays uncapped.
@@ -59,6 +69,25 @@ const SESSION_ENDED = 'orchestrator.session.ended';
59
69
  */
60
70
  export const SESSION_START_LIMIT = 25;
61
71
 
72
+ /**
73
+ * Total wall-clock budget, per SessionStart run, for scanning the ROTATED
74
+ * ledger on behalf of candidates whose events left the active file (#1498).
75
+ * ONE deadline shared by every candidate; a candidate it leaves unread is
76
+ * `skipped-history-unread` and retried by the next start — recorded ones drop
77
+ * out through the sessions.jsonl pre-filter, so the backlog drains.
78
+ *
79
+ * Named ceiling (BV-004): at ≈0.55-0.8 ms per MB per candidate (measured
80
+ * 2026-10-02, see `gatherSessionEvents` in `session-close-backfill.mjs`) a full
81
+ * default ledger (6 × 10 MB) fits ~10-15 scans per start. Measured the same
82
+ * day over 58 MB with 25 archived candidates (dry run, load ~5): unbudgeted
83
+ * 1.41 s; budget 0 — planning and active-file reads only — 0.55 s; budget 500
84
+ * 0.83-0.84 s, 10 candidates answered and 15 deferred. A single scan longer
85
+ * than the budget — a ledger past ~600 MB — never completes here; the CLI path
86
+ * is unbudgeted and still records it. Revisit if `skipped-history-unread`
87
+ * appears on more than a few consecutive starts of one repo.
88
+ */
89
+ export const SESSION_START_ARCHIVE_SCAN_BUDGET_MS = 500;
90
+
62
91
  /**
63
92
  * Read a JSONL file into parsed objects; MISSING (ENOENT) → `[]` silently,
64
93
  * UNREADABLE (EACCES/EISDIR/…) → `[]` with a stderr WARN (#1188); malformed
@@ -126,6 +155,23 @@ function readJsonl(filePath) {
126
155
  * @returns {Array<{ sessionId: string, semanticSessionId: string|null }>}
127
156
  */
128
157
  export function planSessions({ repoRoot }) {
158
+ return planWithGaps({ repoRoot }).plan;
159
+ }
160
+
161
+ /**
162
+ * {@link planSessions} plus the count of rotation tombstones whose archive is
163
+ * not one of the sources read (#1498). Pruned history used to shrink the plan
164
+ * silently; non-zero `historyGaps` says the candidate count is a floor.
165
+ *
166
+ * The check is `readEventsWithRotations` rule 1 (`scripts/lib/events.mjs`):
167
+ * resolved by BASENAME against this ledger's own `_archive/` (#1411), and a
168
+ * file that exists but is not a source (renamed out of `ARCHIVE_NAME_RE`)
169
+ * counts as a gap too — existence is not reading (#1423).
170
+ *
171
+ * @param {{ repoRoot: string }} args
172
+ * @returns {{ plan: Array<{ sessionId: string, semanticSessionId: string|null }>, historyGaps: number }}
173
+ */
174
+ function planWithGaps({ repoRoot }) {
129
175
  const eventsPath = path.join(repoRoot, '.orchestrator', 'metrics', 'events.jsonl');
130
176
 
131
177
  // Two independent UUID -> semantic bridges (#1167). `lock.acquired` is the
@@ -150,9 +196,17 @@ export function planSessions({ repoRoot }) {
150
196
  // (`hooks/on-session-start.mjs` → `backfillOnSessionStart`). Reversing the
151
197
  // newest-first source list keeps first-seen order chronological, exactly as
152
198
  // the single-file read produced it.
153
- for (const source of [...listEventSourcesNewestFirst({ filePath: eventsPath })].reverse()) {
199
+ const sources = listEventSourcesNewestFirst({ filePath: eventsPath });
200
+ const read = new Set(sources.map((s) => s.path));
201
+ const archiveDir = path.join(path.dirname(eventsPath), ARCHIVE_DIR_NAME);
202
+ let historyGaps = 0;
203
+ for (const source of [...sources].reverse()) {
154
204
  forEachJsonlRecord(source.path, (ev) => {
155
205
  if (!ev || typeof ev !== 'object') return;
206
+ if (ev.event === ROTATION_EVENT && typeof ev.archived_as === 'string' && ev.archived_as.length > 0
207
+ && !read.has(path.join(archiveDir, path.basename(ev.archived_as)))) {
208
+ historyGaps += 1;
209
+ }
156
210
  if (typeof ev.session_id === 'string' && typeof ev.semantic_session_id === 'string') {
157
211
  if (ev.event === LOCK_ACQUIRED) semanticFromLock.set(ev.session_id, ev.semantic_session_id);
158
212
  else if (ev.event === SESSION_ENDED) {
@@ -181,7 +235,7 @@ export function planSessions({ repoRoot }) {
181
235
  || (semanticSessionId !== null && runningIds.has(semanticSessionId))) continue;
182
236
  plan.push({ sessionId, semanticSessionId });
183
237
  }
184
- return plan;
238
+ return { plan, historyGaps };
185
239
  }
186
240
 
187
241
  /**
@@ -365,6 +419,10 @@ function cheapRecordId({ sessionId, semanticSessionId }) {
365
419
  * those ancient candidates exhaust the whole budget before the run ever
366
420
  * reaches the genuinely-abandoned recent ones. Measured on a copy of this
367
421
  * repo's live store: first-seen order → backfilled 0, truncated true.
422
+ * @param {number|null} [args.archiveScanBudgetMs=null]
423
+ * #1498 — total budget for rotated-ledger scans across the whole run, turned
424
+ * into ONE deadline before the first candidate. Candidates it leaves unread
425
+ * count as `skipped.skipped-history-unread`. `null` (CLI default) = unbounded.
368
426
  * @returns {Promise<object>} aggregate summary
369
427
  */
370
428
  export async function runMigration({
@@ -373,13 +431,17 @@ export async function runMigration({
373
431
  assumeDeadBeforeMs = null,
374
432
  limit = null,
375
433
  newestFirst = false,
434
+ archiveScanBudgetMs = null,
376
435
  }) {
377
- const planned = planSessions({ repoRoot });
436
+ const { plan: planned, historyGaps } = planWithGaps({ repoRoot });
378
437
  const plan = newestFirst ? [...planned].reverse() : planned;
379
438
  const summary = {
380
439
  repoRoot,
381
440
  mode: apply ? 'apply' : 'dry-run',
382
441
  total: plan.length,
442
+ // #1498: rotated archives the ledger names but no longer holds. Non-zero ⇒
443
+ // sessions they recorded are not candidates and `total` is a floor.
444
+ history_gaps: historyGaps,
383
445
  backfilled: 0,
384
446
  would_backfill: 0,
385
447
  dead_by_age: 0,
@@ -397,6 +459,10 @@ export async function runMigration({
397
459
  // here so the dry-run total matches what --apply actually writes.
398
460
  const projected = new Set();
399
461
 
462
+ const archiveScanDeadlineMs = Number.isFinite(archiveScanBudgetMs)
463
+ ? Date.now() + archiveScanBudgetMs
464
+ : null;
465
+
400
466
  for (const item of plan) {
401
467
  // -- Cheap pre-filter: already recorded → skip WITHOUT entering the core --
402
468
  const known = cheapRecordId(item);
@@ -423,6 +489,7 @@ export async function runMigration({
423
489
  dryRun: !apply,
424
490
  relaxDeadByAge: true,
425
491
  assumeDeadBeforeMs,
492
+ archiveScanDeadlineMs,
426
493
  });
427
494
  switch (res.action) {
428
495
  case 'backfilled':
@@ -529,16 +596,21 @@ async function emitBackfillCompleted(repoRoot, item, res) {
529
596
  * @param {object} args
530
597
  * @param {string} args.repoRoot
531
598
  * @param {number|null} [args.limit=SESSION_START_LIMIT]
599
+ * @param {number|null} [args.archiveScanBudgetMs=SESSION_START_ARCHIVE_SCAN_BUDGET_MS]
532
600
  * @returns {Promise<object|null>} the summary, or null when disabled/failed
533
601
  */
534
- export async function backfillOnSessionStart({ repoRoot, limit = SESSION_START_LIMIT } = {}) {
602
+ export async function backfillOnSessionStart({
603
+ repoRoot,
604
+ limit = SESSION_START_LIMIT,
605
+ archiveScanBudgetMs = SESSION_START_ARCHIVE_SCAN_BUDGET_MS,
606
+ } = {}) {
535
607
  try {
536
608
  // Escape hatch for operators who never want ledger writes at session start.
537
609
  if (process.env.SO_DISABLE_STARTUP_BACKFILL === '1') return null;
538
610
  if (typeof repoRoot !== 'string' || repoRoot.length === 0) return null;
539
611
  // newestFirst is mandatory here — see runMigration's param docs for the
540
612
  // measured failure (budget burned on ancient already-recorded candidates).
541
- const summary = await runMigration({ repoRoot, apply: true, limit, newestFirst: true });
613
+ const summary = await runMigration({ repoRoot, apply: true, limit, newestFirst: true, archiveScanBudgetMs });
542
614
  // The telemetry flush rides the SAME start-time trigger, for the same reason
543
615
  // the backfill does — see flushTelemetryOnSessionStart.
544
616
  await flushTelemetryOnSessionStart(repoRoot);
@@ -596,6 +668,9 @@ function renderHuman(summary) {
596
668
  lines.push(`Backfill abandoned sessions — ${summary.mode}`);
597
669
  lines.push(` repo: ${summary.repoRoot}`);
598
670
  lines.push(` sessions seen: ${summary.total}`);
671
+ if (summary.history_gaps > 0) {
672
+ lines.push(` history gaps: ${summary.history_gaps} rotated archive(s) no longer readable — sessions seen is a floor`);
673
+ }
599
674
  if (summary.mode === 'apply') {
600
675
  lines.push(` backfilled: ${summary.backfilled}`);
601
676
  } else {
@@ -43,8 +43,9 @@
43
43
  * a record that never left.
44
44
  *
45
45
  * Idempotence: dry-run is pure (reads only). `--apply` re-checks store+archive
46
- * membership per record immediately before appending, so a second `--apply`
47
- * appends nothing and a later dry-run reports 0 orphans.
46
+ * membership and appends inside ONE store-lock section (`withLearningsLock`),
47
+ * so a second `--apply` — sequential or concurrent — appends nothing and a
48
+ * later dry-run reports 0 orphans.
48
49
  *
49
50
  * NOTE on discovery: every file here is read through `node:fs`, never grep — a
50
51
  * single NUL byte makes a tracked file invisible to grep (silent skip, exit 1,
@@ -70,7 +71,7 @@ import { parseSessionConfig } from './lib/config.mjs';
70
71
  import { subjectToSlug, parseFrontmatter } from './lib/vault-mirror/utils.mjs';
71
72
  import { kebab } from './lib/learnings/kebab.mjs';
72
73
  import { validateLearning } from './lib/learnings/schema.mjs';
73
- import { appendLearning, isBackupOf } from './lib/learnings/io.mjs';
74
+ import { appendLearning, isBackupOf, withLearningsLock } from './lib/learnings/io.mjs';
74
75
  import { isMainModule } from './lib/is-main-module.mjs';
75
76
 
76
77
  const DEFAULT_RULES_DIR = '.claude/rules';
@@ -907,28 +908,54 @@ export async function main(argv = [], deps = {}) {
907
908
  });
908
909
  }
909
910
 
910
- // ── Apply (append-only, re-checked) ──────────────────────────────────────
911
+ // ── Apply (append-only, re-checked under the store lock) ─────────────────
911
912
  let applied = 0;
912
913
  let skippedAlreadyPresent = 0;
913
914
  if (apply) {
914
- for (const r of records) {
915
- if (r.status !== 'orphan' || !r.restorable || !r.record) continue;
916
- // Re-check membership at apply time — this is what makes a second
917
- // --apply a no-op rather than a duplicate append.
918
- if (idsInStore(storeAbs).has(r.learning_id) || idsInStore(archiveAbs).has(r.learning_id)) {
919
- skippedAlreadyPresent += 1;
920
- r.applied = false;
921
- continue;
922
- }
923
- try {
924
- await appendLearning(storeAbs, r.record);
925
- applied += 1;
926
- r.applied = true;
927
- } catch (err) {
928
- r.applied = false;
929
- r.apply_error = err.message;
930
- process.stderr.write(`backfill-learnings-from-vault: append failed for ${r.learning_id}: ${err.message}\n`);
931
- }
915
+ const candidates = records.filter((r) => r.status === 'orphan' && r.restorable && r.record);
916
+ const failApply = (r, err) => {
917
+ r.applied = false;
918
+ r.apply_error = err.message;
919
+ process.stderr.write(`backfill-learnings-from-vault: append failed for ${r.learning_id}: ${err.message}\n`);
920
+ };
921
+ try {
922
+ // The membership re-check and the append are ONE critical section
923
+ // (#1487 item 12): checked outside the lock, two concurrent --apply runs
924
+ // — or one racing a session-end append — both saw "absent" and both
925
+ // appended. One section for the whole loop, not one per record: a single
926
+ // acquisition and a single read of store + archive instead of N of each.
927
+ // Hold time is linear in N small appends — fine for the tens of orphans a
928
+ // backfill restores; revisit (per-record sections) if a run ever holds
929
+ // the lock near the 10 s other writers wait. Archive movers (sweep/prune)
930
+ // also write under this lock, so the archive read is consistent too.
931
+ // appendLearning() re-enters the held lock (reentrant per async call
932
+ // chain, io.mjs).
933
+ await withLearningsLock(storeAbs, async () => {
934
+ const present = new Set([...idsInStore(storeAbs), ...idsInStore(archiveAbs)]);
935
+ for (const r of candidates) {
936
+ // This is what makes a second --apply a no-op rather than a
937
+ // duplicate append; `present.add` below covers two rules naming one id.
938
+ if (present.has(r.learning_id)) {
939
+ skippedAlreadyPresent += 1;
940
+ r.applied = false;
941
+ continue;
942
+ }
943
+ try {
944
+ await appendLearning(storeAbs, r.record);
945
+ applied += 1;
946
+ r.applied = true;
947
+ present.add(r.learning_id);
948
+ } catch (err) {
949
+ failApply(r, err);
950
+ }
951
+ }
952
+ });
953
+ } catch (err) {
954
+ // Lock not acquired (LearningsLockError), its directory not creatable, or
955
+ // a store read failed inside the section: no candidate still unhandled
956
+ // was written. Surface it per record, like any other append failure,
957
+ // instead of reporting a silent success.
958
+ for (const r of candidates) if (r.applied === undefined) failApply(r, err);
932
959
  }
933
960
  }
934
961
 
@@ -93,6 +93,9 @@ function runCodex(args) {
93
93
  const result = spawnSync('codex', args, {
94
94
  encoding: 'utf8',
95
95
  timeout: CODEX_COMMAND_TIMEOUT_MS,
96
+ // 'codex plugin list --available --json' exceeds the 1 MiB default once a
97
+ // few marketplaces are registered (measured 2026-09-22: 2.19 MB → ENOBUFS).
98
+ maxBuffer: 64 * 1024 * 1024,
96
99
  });
97
100
 
98
101
  if (result.error) {
@@ -15,7 +15,17 @@
15
15
  * 1 — source rules not found, or TARGET is not an existing directory
16
16
  */
17
17
 
18
- import { existsSync, mkdirSync, readdirSync, symlinkSync, statSync, lstatSync, writeFileSync } from 'node:fs';
18
+ import {
19
+ existsSync,
20
+ mkdirSync,
21
+ readdirSync,
22
+ readlinkSync,
23
+ symlinkSync,
24
+ statSync,
25
+ lstatSync,
26
+ unlinkSync,
27
+ writeFileSync,
28
+ } from 'node:fs';
19
29
  import path from 'node:path';
20
30
  import { fileURLToPath } from 'node:url';
21
31
 
@@ -66,7 +76,9 @@ function _isDir(p) {
66
76
  function linkPath(source, dest, label) {
67
77
  mkdirSync(path.dirname(dest), { recursive: true });
68
78
  if (_isSymlink(dest)) {
69
- process.stdout.write(` SKIP: ${label} (symlink exists)\n`);
79
+ // existsSync follows the link: false means it points at nothing.
80
+ const state = existsSync(dest) ? 'symlink exists' : 'dangling symlink — not replacing';
81
+ process.stdout.write(` SKIP: ${label} (${state})\n`);
70
82
  return 'skip';
71
83
  }
72
84
  if (existsSync(dest)) {
@@ -119,6 +131,38 @@ if (_isDir(SOURCE_SKILLS_DIR)) {
119
131
  }
120
132
  }
121
133
 
134
+ /**
135
+ * Remove the links an earlier install left for a wrapper this plugin no longer
136
+ * ships (#1501 item 6: 05c0264f dropped `commands/navigator.md` and
137
+ * `skills/navigator`, and every installed project kept a dead link). An entry is
138
+ * removed only when it is a symlink, dangles, AND resolves inside this plugin's
139
+ * `.cursor/<kind>/` — so it is provably ours. Regular files and live links are
140
+ * the project's own; a dangling link into another root is not ours to delete
141
+ * and gets a WARN — unless this plugin ships that name, where linkPath() has
142
+ * already reported it as dangling.
143
+ * @param {'rules'|'commands'|'skills'} kind
144
+ */
145
+ function pruneDeadLinks(kind) {
146
+ const sourceDir = path.join(SO_ROOT, '.cursor', kind);
147
+ const targetDir = path.join(TARGET, '.cursor', kind);
148
+ if (!_isDir(targetDir)) return;
149
+ for (const name of readdirSync(targetDir)) {
150
+ const dest = path.join(targetDir, name);
151
+ if (!_isSymlink(dest) || existsSync(dest)) continue;
152
+ const pointsTo = path.resolve(path.dirname(dest), readlinkSync(dest));
153
+ const rel = path.relative(sourceDir, pointsTo);
154
+ const ours = rel !== '' && rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel);
155
+ if (ours) {
156
+ unlinkSync(dest);
157
+ process.stdout.write(` PRUNE: ${kind}/${name} (dangling link into ${sourceDir})\n`);
158
+ } else if (!existsSync(path.join(sourceDir, name))) {
159
+ process.stdout.write(` WARN: ${kind}/${name} (dangling link outside this plugin, left alone: ${pointsTo})\n`);
160
+ }
161
+ }
162
+ }
163
+
164
+ for (const kind of ['rules', 'commands', 'skills']) pruneDeadLinks(kind);
165
+
122
166
  function renderHooksJson(soRoot) {
123
167
  const runNode = shQuote(path.join(soRoot, 'hooks', 'run-node.sh'));
124
168
  const bridge = shQuote(path.join(soRoot, 'scripts', 'lib', 'cursor-hook-bridge.mjs'));
@@ -480,9 +480,11 @@ async function main() {
480
480
  // explicit `total_tokens` means the caller already rolled up, so nothing is
481
481
  // merged. No matching records or a failed read OMITS the fields with a WARN —
482
482
  // never a fabricated 0; a null total is omitted rather than written — for
483
- // `total_cost_usd` that is any session with an unpriced subagent record
484
- // (#1475: unknown model, no model, or no tokens), which the persisted
485
- // `cost_records_priced < cost_records_total` then explains.
483
+ // `total_cost_usd` that is any session with an unpriced cost candidate
484
+ // (unknown model, no model, no tokens, a started agent without a transcript,
485
+ // or a v1 agent — the complete list is the `session-token-rollup.mjs` module
486
+ // doc, #1498), which the persisted `cost_records_priced < cost_records_total`
487
+ // then explains.
486
488
  // match_status explains the omission; diagnostic fields stay out of the record.
487
489
  if (ownUuid !== null && !hasOwn('total_tokens')) {
488
490
  let rollup = null;
@@ -60,7 +60,7 @@ import {
60
60
  CURRENT_ANONYMIZATION_VERSION,
61
61
  } from './lib/learnings.mjs';
62
62
  import { withLearningsLock } from './lib/learnings/io.mjs';
63
- import { findProjectRoot, resolveInstructionFile, expandTilde } from './lib/common.mjs';
63
+ import { findProjectRoot, resolveInstructionFile, expandTilde, redactHomeDir } from './lib/common.mjs';
64
64
  import { parseSessionConfig } from './lib/config.mjs';
65
65
  import { createSecretValueMasker } from './lib/secret-masker.mjs';
66
66
  import { stableHostname, readHostAliases } from './lib/host-identity.mjs';
@@ -85,21 +85,28 @@ const DEFAULT_VAULT_SUBPATH = path.join('01-projects', 'session-orchestrator', '
85
85
  * — forwarded to parseSessionConfig (tests pass `{ env: {}, ownerConfig: undefined }`
86
86
  * for hermetic, owner.yaml-free resolution — issue #653 bleed guard).
87
87
  * @returns {string|null} the resolved output path, or null when vault-dir is
88
- * unconfigured/unresolvable.
88
+ * unconfigured/unresolvable or vault-integration is not enabled.
89
89
  */
90
90
  export function resolveDefaultOutput({ repoRoot, hostPaths } = {}) {
91
91
  const root = repoRoot ?? findProjectRoot();
92
92
  const instr = resolveInstructionFile(root);
93
93
  if (!instr) return null;
94
94
 
95
- let vaultDir;
95
+ let vi;
96
96
  try {
97
97
  const content = readFileSync(instr.path, 'utf8');
98
98
  const config = parseSessionConfig(content, hostPaths ? { hostPaths } : undefined);
99
- vaultDir = config?.['vault-integration']?.['vault-dir'];
99
+ vi = config?.['vault-integration'];
100
100
  } catch {
101
101
  return null;
102
102
  }
103
+ // #1506.4a: this default target IS the vault, so it obeys the off switch the
104
+ // other vault writers obey (vault-backfill, board-writer, narrative-mirror:
105
+ // `enabled !== true` → no vault write), already host-lowered by
106
+ // parseSessionConfig (env SO_VAULT_INTEGRATION > owner.yaml > committed). An
107
+ // explicit `--output` is the caller's own path and is not gated.
108
+ if (vi?.enabled !== true) return null;
109
+ const vaultDir = vi['vault-dir'];
103
110
  if (!vaultDir || typeof vaultDir !== 'string' || vaultDir.trim() === '') return null;
104
111
 
105
112
  // Expand a `~`-prefixed vault-dir (e.g. the committed Session Config default
@@ -604,7 +611,8 @@ function parseArgs(argv) {
604
611
  if (!resolved) {
605
612
  process.stderr.write(
606
613
  'export-hw-learnings: could not resolve vault-integration.vault-dir for the default ' +
607
- '--output path (no CLAUDE.md/AGENTS.md found, or vault-dir is unset). ' +
614
+ '--output path (no CLAUDE.md/AGENTS.md found, vault-integration.enabled is not true, ' +
615
+ 'or vault-dir is unset). ' +
608
616
  'Pass --output <path> explicitly.\n'
609
617
  );
610
618
  process.exit(1);
@@ -661,7 +669,9 @@ async function promoteHwLearningsUnlocked(opts) {
661
669
 
662
670
  const flags = [];
663
671
  if (malformed.length > 0) {
664
- flags.push(`${malformed.length} malformed line(s) in learnings.jsonl were skipped`);
672
+ // Not promoted, and kept verbatim by the rewrite below (rewriteLearnings
673
+ // re-reads them under the lock — #1489); a WARN, not a loss.
674
+ flags.push(`${malformed.length} malformed line(s) in learnings.jsonl were not read (kept verbatim)`);
665
675
  }
666
676
 
667
677
  if (hwPrivate.length === 0) {
@@ -763,7 +773,8 @@ if (isMain) {
763
773
  };
764
774
 
765
775
  run().catch((err) => {
766
- process.stderr.write(`export failed: ${err.message}\n`);
776
+ // The message can carry absolute store/output paths — never print the home dir.
777
+ process.stderr.write(`export failed: ${redactHomeDir(String(err?.message ?? err))}\n`);
767
778
  process.exit(1);
768
779
  });
769
780
  }
@@ -16,7 +16,15 @@
16
16
  * payload `{ session, repo, modus, kandidaten, navigator_state }` — no paths, no
17
17
  * quota, no `auftrag_ref`.
18
18
  *
19
- * stdout: one JSON line `{ ok, path, navigator_state, event }`.
19
+ * stdout: one JSON line
20
+ * `{ ok, path, navigator_state, navigator_adresse, fallback_due_at, event }`.
21
+ * `navigator_adresse` is the active lease's `adresse` exactly as
22
+ * `readNavigatorLease()` validated it (a ListAgents peer name), else `null` — no
23
+ * active lease, no address in it, or one the validator refused. It is the only
24
+ * value the caller may address a hint to; an unvalidated string never reaches it.
25
+ * `fallback_due_at` is `fallbackDueAt(zeit, navigator_state)`: when the
26
+ * Standard-Auflagen apply if no `auflagen/` file has appeared by then. It is
27
+ * stdout only — the check-in FILE schema, which the navigator reads, is unchanged.
20
28
  *
21
29
  * Exit codes:
22
30
  * 0 — check-in written (an event failure only WARNs; `event: false` on stdout)
@@ -31,6 +39,7 @@ import path from 'node:path';
31
39
  import { emitEvent } from './lib/events.mjs';
32
40
  import {
33
41
  checkinPath,
42
+ fallbackDueAt,
34
43
  navigatorDir,
35
44
  readNavigatorLease,
36
45
  utcSecondsTimestamp,
@@ -106,7 +115,12 @@ async function main() {
106
115
  return 1;
107
116
  }
108
117
 
109
- const { state: navigatorState } = await readNavigatorLease();
118
+ const navigator = await readNavigatorLease();
119
+ const navigatorState = navigator.state;
120
+ // readNavigatorLease() returns a lease only when it passed validation, its
121
+ // `adresse` included (an invalid one makes the whole lease `unreadable`).
122
+ const navigatorAdresse =
123
+ navigator.state === 'active' && typeof navigator.lease.adresse === 'string' ? navigator.lease.adresse : null;
110
124
 
111
125
  let event = true;
112
126
  try {
@@ -132,7 +146,16 @@ async function main() {
132
146
  // Report the path with `~` for the home prefix: stdout lands in transcripts.
133
147
  const home = os.homedir();
134
148
  const shownPath = target.startsWith(`${home}${path.sep}`) ? `~${target.slice(home.length)}` : target;
135
- process.stdout.write(JSON.stringify({ ok: true, path: shownPath, navigator_state: navigatorState, event }) + '\n');
149
+ process.stdout.write(
150
+ JSON.stringify({
151
+ ok: true,
152
+ path: shownPath,
153
+ navigator_state: navigatorState,
154
+ navigator_adresse: navigatorAdresse,
155
+ fallback_due_at: fallbackDueAt(record.zeit, navigatorState),
156
+ event,
157
+ }) + '\n',
158
+ );
136
159
  return 0;
137
160
  }
138
161
 
@@ -38,6 +38,7 @@ function clampNumber(value, { min, max, fallback }) {
38
38
  * @property {string|null} kill_switch @property {string|null} kill_switch_detail @property {string[]} sessions
39
39
  * @property {string|null} host_class @property {string|null} resource_verdict_at_start
40
40
  * @property {boolean} fallback_to_manual @property {boolean} dry_run @property {number} total_tokens_used
41
+ * @property {number} tokens_unknown_sessions — #1487 Pkt 4: iterations whose own record carried no token figure; > 0 makes total_tokens_used a lower bound
41
42
  * @property {string|null} worktree_path @property {string|null} parent_run_id @property {number} stall_recovery_count
42
43
  * @property {number|null} blocked_by_issue — Phase D (#341): issue number this loop was waiting on (OPEN-4 commit-deps); null when not blocked
43
44
  * @property {boolean} aborted_by_cohort — Phase D (#341): set by cohort-abort policy (OPEN-5); mutated by W3 P5 orchestration-glue, not by runLoop itself
@@ -138,7 +139,12 @@ export async function runLoop(opts = {}) {
138
139
  // totals as `usage`; the accumulator sums them into `total_tokens_used`.
139
140
  // Zero or multiple matches throw → FAILED_WAVE, never foreign token usage.
140
141
  // CEILING: an own record without token totals adds 0 — the budget
141
- // undercounts, it never over-fires.
142
+ // undercounts, it never over-fires. Such sessions are counted in
143
+ // `tokens_unknown_sessions` (#1487 Pkt 4), and the switch still fires on the
144
+ // known lower bound: every fire stays correct, none is lost. Failing closed on
145
+ // an unknown session instead would stop an armed run on every
146
+ // coordinator-direct session (no subagents → no rollup totals); revisit if
147
+ // the session record ever carries the coordinator's own usage.
142
148
  max_tokens: opts.maxTokens ?? 0,
143
149
  iterations_completed: 0,
144
150
  kill_switch: null,
@@ -149,6 +155,9 @@ export async function runLoop(opts = {}) {
149
155
  fallback_to_manual: false,
150
156
  dry_run: dryRun,
151
157
  total_tokens_used: 0,
158
+ // #1487 Pkt 4 — completed iterations whose own record carried no token
159
+ // figure; non-zero means `total_tokens_used` is a lower bound.
160
+ tokens_unknown_sessions: 0,
152
161
  // ADR-364 additive fields — forward-compat; callers may omit, defaults are null/0.
153
162
  worktree_path: opts.worktreePath ?? null,
154
163
  parent_run_id: opts.parentRunId ?? null,
@@ -274,15 +283,21 @@ export async function runLoop(opts = {}) {
274
283
  }
275
284
  state.iterations_completed += 1;
276
285
 
277
- // Accumulate own-record output tokens (total_tokens fallback); missing totals
278
- // add 0. An unassignable child record already threw → FAILED_WAVE above.
279
- if (sessionResult && typeof sessionResult.usage === 'object' && sessionResult.usage !== null) {
280
- const outTokens = Number(
281
- sessionResult.usage.output_tokens ?? sessionResult.usage.total_tokens
282
- );
283
- if (Number.isFinite(outTokens) && outTokens > 0) {
284
- cumulativeTokens += outTokens;
285
- }
286
+ // Accumulate own-record output tokens (total_tokens fallback). An unassignable
287
+ // child record already threw → FAILED_WAVE above. An own record carrying
288
+ // neither figure has UNKNOWN usage, not 0 (#1487 Pkt 4): it adds nothing, so
289
+ // `total_tokens_used` is a lower bound, and it is counted in
290
+ // `tokens_unknown_sessions` so the record says how much the sum cannot see.
291
+ const usage = sessionResult?.usage;
292
+ const rawTokens = usage !== null && typeof usage === 'object'
293
+ ? (usage.output_tokens ?? usage.total_tokens)
294
+ : undefined;
295
+ // `Number(null)` is 0 — a null figure is a missing one, never a measured 0.
296
+ const outTokens = rawTokens === null || rawTokens === undefined ? NaN : Number(rawTokens);
297
+ if (Number.isFinite(outTokens) && outTokens >= 0) {
298
+ cumulativeTokens += outTokens;
299
+ } else {
300
+ state.tokens_unknown_sessions += 1;
286
301
  }
287
302
  state.total_tokens_used = cumulativeTokens;
288
303
 
@@ -8,7 +8,8 @@
8
8
  * `hooks/on-stop.mjs` refreshes it at every turn-end and
9
9
  * `hooks/post-tool-batch-wave-signal.mjs` at every tool batch.
10
10
  * 2. `autopilot.jsonl` mtime — the legacy marker, used only when no lock path
11
- * is supplied or the lock is absent/corrupt/timestamp-less.
11
+ * is supplied or the lock is absent/corrupt/heartbeat-less (`started_at`
12
+ * is never a stand-in — it does not advance during a run).
12
13
  *
13
14
  * The header of this module used to claim `autopilot.jsonl` carries "one record
14
15
  * per session". It does not: `telemetry.mjs` writes ONE record per /autopilot
@@ -74,11 +75,13 @@ function readHeartbeatMs(sessionLockPath) {
74
75
  return null;
75
76
  }
76
77
  if (typeof lock !== 'object' || lock === null) return null;
77
- // Same precedence `session-lock.mjs` uses on read: a v1 lock has no
78
- // `last_heartbeat`, and its `started_at` is the best available stand-in.
79
- const raw = typeof lock.last_heartbeat === 'string' && lock.last_heartbeat.length > 0
80
- ? lock.last_heartbeat
81
- : lock.started_at;
78
+ // `last_heartbeat` only — no `started_at` stand-in, matching `isLockLive()`
79
+ // since the #595 sunset (2026-10-02). `started_at` never advances during a
80
+ // run, so a lock without a heartbeat would read as a stall once 600 s passed
81
+ // (or as fresh within 30 s of its genesis) — a measurement the lock never
82
+ // made. No heartbeat → `null` → the mtime marker, exactly as for a lock
83
+ // with no timestamp at all.
84
+ const raw = lock.last_heartbeat;
82
85
  if (typeof raw !== 'string' || raw.length === 0) return null;
83
86
  const ms = Date.parse(raw);
84
87
  return Number.isFinite(ms) ? ms : null;
@@ -95,9 +98,10 @@ function readHeartbeatMs(sessionLockPath) {
95
98
  * @param {string} [opts.autopilotJsonlPath] — path to autopilot.jsonl (default
96
99
  * '.orchestrator/metrics/autopilot.jsonl').
97
100
  * @param {string} [opts.sessionLockPath] — path to `session.lock`. When supplied
98
- * and readable, its `last_heartbeat` (falling back to `started_at`, the schema
99
- * v1 normalisation `session-lock.mjs` applies on read) REPLACES the mtime
100
- * marker. Omitted → mtime only, which is what every pre-existing caller gets.
101
+ * and readable, its `last_heartbeat` REPLACES the mtime marker. A lock
102
+ * without a parsable `last_heartbeat` contributes nothing (no `started_at`
103
+ * fallback, as in `session-lock.mjs` `isLockLive()`) and the mtime marker is
104
+ * used. Omitted → mtime only, which is what every pre-existing caller gets.
101
105
  * @param {number} [opts.stallTimeoutSeconds] — kill-switch threshold (default
102
106
  * 600). NOT range-clamped here; caller is responsible for bounds.
103
107
  * @param {() => number} [opts.nowMs] — wall-clock supplier (default Date.now).