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
@@ -14,12 +14,14 @@ import {
14
14
  copyFile,
15
15
  readdir,
16
16
  unlink,
17
+ open,
17
18
  } from 'node:fs/promises';
18
19
  import { existsSync, realpathSync } from 'node:fs';
19
20
  import { AsyncLocalStorage } from 'node:async_hooks';
20
21
  import { randomUUID } from 'node:crypto';
21
22
  import path from 'node:path';
22
23
  import { withFileLock } from '../file-lock.mjs';
24
+ import { digestSha256 } from '../crypto-digest-utils.mjs';
23
25
  import {
24
26
  validateLearning,
25
27
  normalizeLearning,
@@ -219,26 +221,69 @@ function serializeLearningLineChecked(validated, { legacyTolerant = false } = {}
219
221
  /**
220
222
  * Read all learnings from the given JSONL path. Returns normalized entries
221
223
  * (missing extended fields are defaulted). Malformed lines are skipped with
222
- * their raw text preserved in the result's `malformed` array.
224
+ * their raw text preserved in the result's `malformed` array, and their
225
+ * 1-based line numbers in `malformedLineNumbers` (same order).
223
226
  *
224
227
  * @param {string} filePath — absolute or project-relative path to learnings.jsonl
225
- * @returns {Promise<{entries: object[], malformed: string[]}>}
228
+ * @returns {Promise<{entries: object[], malformed: string[], malformedLineNumbers: number[]}>}
226
229
  */
227
230
  export async function readLearnings(filePath) {
228
- if (!existsSync(filePath)) return { entries: [], malformed: [] };
229
- const raw = await readFile(filePath, 'utf8');
230
- const lines = raw.split('\n').filter((l) => l.length > 0);
231
+ if (!existsSync(filePath)) return { entries: [], malformed: [], malformedLineNumbers: [] };
232
+ return parseLearningsText(await readFile(filePath, 'utf8'));
233
+ }
234
+
235
+ /**
236
+ * Read a learnings store together with its generation token (#1486), both
237
+ * from ONE read, so the token describes exactly the records returned.
238
+ *
239
+ * The token is `sha256:<hex>` over the file's full text: any append, rewrite
240
+ * or sweep changes it. An absent store reads as empty text — zero records
241
+ * either way, so the two states share a token.
242
+ *
243
+ * @param {string} filePath
244
+ * @returns {Promise<{entries: object[], malformed: string[], malformedLineNumbers: number[],
245
+ * generation: string}>}
246
+ */
247
+ export async function readLearningsSnapshot(filePath) {
248
+ const raw = existsSync(filePath) ? await readFile(filePath, 'utf8') : '';
249
+ return { ...parseLearningsText(raw), generation: `sha256:${digestSha256(raw)}` };
250
+ }
251
+
252
+ /**
253
+ * Parse learnings JSONL text — the line parser behind {@link readLearnings},
254
+ * exported for callers that must split a header off the text first.
255
+ *
256
+ * A blank line — empty, whitespace-only, or a lone `\r` from a CRLF blank line
257
+ * — carries no record and is skipped, never counted as malformed: malformed
258
+ * lines are kept verbatim by every rewrite and WARNed on every close, so a
259
+ * stray blank would otherwise be reported forever (#1489).
260
+ *
261
+ * A line that parses but is not a JSON object (`null`, `42`, `[]`, `"x"`) is
262
+ * malformed too (#1500): `normalizeLearning` turned it into a record of
263
+ * defaults alone, and every rewrite wrote that invented record back.
264
+ *
265
+ * @param {string} raw
266
+ * @returns {{entries: object[], malformed: string[], malformedLineNumbers: number[]}}
267
+ * `malformedLineNumbers[i]` is the 1-based line of `malformed[i]` in `raw`
268
+ */
269
+ export function parseLearningsText(raw) {
231
270
  const entries = [];
232
271
  const malformed = [];
233
- for (const line of lines) {
272
+ const malformedLineNumbers = [];
273
+ raw.split('\n').forEach((line, i) => {
274
+ if (line.trim().length === 0) return;
234
275
  try {
235
276
  const parsed = JSON.parse(line);
277
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
278
+ throw new TypeError('not a JSON object');
279
+ }
236
280
  entries.push(normalizeLearning(parsed));
237
281
  } catch {
238
282
  malformed.push(line);
283
+ malformedLineNumbers.push(i + 1);
239
284
  }
240
- }
241
- return { entries, malformed };
285
+ });
286
+ return { entries, malformed, malformedLineNumbers };
242
287
  }
243
288
 
244
289
  // ---------------------------------------------------------------------------
@@ -263,6 +308,11 @@ export async function readLearnings(filePath) {
263
308
  * that landed between a rewriter's read and its rename was silently lost.
264
309
  * Validation runs first, outside the lock — a rejected record never waits.
265
310
  *
311
+ * A store whose last line is torn (a crash mid-append left no trailing
312
+ * newline) gets a `\n` written first, inside the same append: without it the
313
+ * new record fused onto the torn text into ONE unparseable line, and no reader
314
+ * ever saw the record (#1489).
315
+ *
266
316
  * @param {string} filePath
267
317
  * @param {object} entry
268
318
  * @param {{ lockTimeoutMs?: number }} [opts] — store-lock wait, default
@@ -296,13 +346,46 @@ export async function appendLearning(filePath, entry, { lockTimeoutMs } = {}) {
296
346
  // parses back AND re-validates before any append touches disk. Throws
297
347
  // ValidationError on a non-round-tripping record — file is left untouched.
298
348
  const line = serializeLearningLineChecked(validated);
299
- // The lock path resolution creates the parent directory.
300
- await withLearningsLock(filePath, () => appendFile(filePath, line, 'utf8'), {
301
- timeoutMs: lockTimeoutMs,
302
- });
349
+ // The lock path resolution creates the parent directory. The torn-tail check
350
+ // reads inside the lock, so no other writer can change the tail in between.
351
+ await withLearningsLock(
352
+ filePath,
353
+ async () => {
354
+ const lead = (await endsWithoutNewline(filePath)) ? '\n' : '';
355
+ await appendFile(filePath, lead + line, 'utf8');
356
+ },
357
+ { timeoutMs: lockTimeoutMs }
358
+ );
303
359
  return validated;
304
360
  }
305
361
 
362
+ /**
363
+ * True when `filePath` is a non-empty file whose last byte is not `\n`. An
364
+ * absent file is `false` (the append creates it). Reads one byte, so the cost
365
+ * does not grow with the store.
366
+ *
367
+ * @param {string} filePath
368
+ * @returns {Promise<boolean>}
369
+ */
370
+ async function endsWithoutNewline(filePath) {
371
+ let fh;
372
+ try {
373
+ fh = await open(filePath, 'r');
374
+ } catch (err) {
375
+ if (err?.code === 'ENOENT') return false;
376
+ throw err;
377
+ }
378
+ try {
379
+ const { size } = await fh.stat();
380
+ if (size === 0) return false;
381
+ const buf = Buffer.alloc(1);
382
+ await fh.read(buf, 0, 1, size - 1);
383
+ return buf[0] !== 0x0a;
384
+ } finally {
385
+ await fh.close();
386
+ }
387
+ }
388
+
306
389
  /**
307
390
  * Number of `${basename}.bak-<ISO>` siblings retained after a backup rotation.
308
391
  * The atomic rewrite is destructive on a gitignored store (no VCS restore), so
@@ -423,17 +506,35 @@ async function rotateBackups(dir, baseName, keep = BACKUP_KEEP) {
423
506
  * `NaN`) is still caught by the #662 checked serializer regardless of this
424
507
  * flag — see {@link serializeLearningLineChecked}. Pass `false` to restore
425
508
  * the fully-strict behaviour from before EventDrop #386.
509
+ * - `malformedLines` (#1489): raw text of store lines that did not parse —
510
+ * the `malformed` array {@link readLearnings} returns. Written back
511
+ * verbatim, one per line, AFTER the validated records. A round-trip writer
512
+ * that rewrites from parsed entries alone deletes every such line with no
513
+ * archive record (only the keep-3 `.bak` still holds it). OMITTED (the
514
+ * default), the store's OWN malformed lines are re-read under the lock and
515
+ * kept — so a caller that forgets this option no longer deletes them
516
+ * (`export-hw-learnings` did). Pass an array to write exactly those lines;
517
+ * `[]` is the explicit opt-out that drops them. Each entry must be a
518
+ * non-empty string without a newline; anything else throws a TypeError
519
+ * before any disk access, dry run included.
426
520
  *
427
521
  * @param {string} filePath
428
522
  * @param {object[]} entries
429
- * @param {{dryRun?: boolean, backup?: boolean, legacyTolerant?: boolean}} [opts]
523
+ * @param {{dryRun?: boolean, backup?: boolean, legacyTolerant?: boolean, malformedLines?: string[]}} [opts]
430
524
  * @returns {Promise<object[]>} validated entries (always returned, even dryRun)
431
525
  */
432
526
  export async function rewriteLearnings(
433
527
  filePath,
434
528
  entries,
435
- { dryRun = false, backup = true, legacyTolerant = true } = {}
529
+ { dryRun = false, backup = true, legacyTolerant = true, malformedLines } = {}
436
530
  ) {
531
+ if (
532
+ malformedLines !== undefined &&
533
+ (!Array.isArray(malformedLines) ||
534
+ malformedLines.some((l) => typeof l !== 'string' || l.length === 0 || l.includes('\n')))
535
+ ) {
536
+ throw new TypeError('rewriteLearnings: malformedLines must be an array of non-empty single-line strings');
537
+ }
437
538
  const validated = entries.map((e) =>
438
539
  validateLearning(
439
540
  {
@@ -454,12 +555,20 @@ export async function rewriteLearnings(
454
555
  // rewrite, no backup — and hand the validated entries back to the caller.
455
556
  if (dryRun) return validated;
456
557
 
457
- const body = lines.join('');
458
-
459
558
  // Under the store lock (#1447 point 8) so no append lands between the backup
460
559
  // and the rename. The CALLER's read must sit inside the same lock for a
461
560
  // read-modify-write to be safe — this call joins it reentrantly.
462
561
  await withLearningsLock(filePath, async () => {
562
+ // Default: the store's own malformed lines as they stand right now, read
563
+ // under the lock that guards the rename below.
564
+ const kept =
565
+ malformedLines ??
566
+ (existsSync(filePath) ? parseLearningsText(await readFile(filePath, 'utf8')).malformed : []);
567
+ // Malformed lines last, each newline-terminated: a truncated final line
568
+ // otherwise fuses with the next append into one more unparseable line.
569
+ const body = lines.join('') + kept.map((l) => `${l}\n`).join('');
570
+
571
+
463
572
  // Backup-before-rewrite (#721): snapshot the current store to a timestamped
464
573
  // sidecar BEFORE the destructive rename, then prune to keep-N. Only meaningful
465
574
  // when the target already exists (a first-time write has nothing to lose).
@@ -66,7 +66,7 @@ import os from 'node:os';
66
66
  import path from 'node:path';
67
67
 
68
68
  import { enumerateCandidates } from './dispatcher/enumerate.mjs';
69
- import { readLock, isLockLive, isPidAliveOnHost, LOCK_PATH, DEFAULT_TTL_HOURS } from './session-lock.mjs';
69
+ import { readLock, isLockLive, isPidAliveOnHost, LOCK_PATH, DEFAULT_TTL_HOURS, FUTURE_HEARTBEAT_TOLERANCE_MS } from './session-lock.mjs';
70
70
  import { emitEvent } from './events.mjs';
71
71
  import { hostnamesMatch, lockHostCandidate } from './host-identity.mjs';
72
72
 
@@ -109,21 +109,42 @@ function lockPathFor(repoRoot) {
109
109
  }
110
110
 
111
111
  /**
112
- * Fractional hours since a lock's last heartbeat (the liveness basis). Falls
113
- * back to started_at when last_heartbeat is absent (v1 lock). Returns null when
114
- * neither parses.
112
+ * Fractional hours since a lock's last heartbeat — the figure the reaper
113
+ * REPORTS (candidate list `ageHours`, `orchestrator.session.lock.reaped`
114
+ * `age_hours`). Display only: the reap decision is `isLockLive()`.
115
+ *
116
+ * Reads `last_heartbeat` only, like `isLockLive()` since the #595 sunset
117
+ * (2026-10-02). A lock without one has no heartbeat age, so this returns
118
+ * `null` (the CLI renders "unknown age") instead of passing off the age since
119
+ * `started_at` as heartbeat staleness.
115
120
  *
116
121
  * @param {object} lock
117
122
  * @param {number} nowMs
118
- * @returns {number|null}
123
+ * @returns {number|null} null when `last_heartbeat` is absent or unparseable.
119
124
  */
120
125
  function ageHoursOf(lock, nowMs) {
121
- const hb = (typeof lock.last_heartbeat === 'string' && lock.last_heartbeat.length > 0)
122
- ? lock.last_heartbeat
123
- : lock.started_at;
124
- const ms = Date.parse(hb);
126
+ if (typeof lock.last_heartbeat !== 'string' || lock.last_heartbeat.length === 0) return null;
127
+ const ms = Date.parse(lock.last_heartbeat);
125
128
  if (Number.isNaN(ms)) return null;
126
- return Number(((nowMs - ms) / (3600 * 1000)).toFixed(2));
129
+ const hours = futureSafeAgeHours(ms, nowMs);
130
+ return hours === null ? null : Number(hours.toFixed(2));
131
+ }
132
+
133
+ /**
134
+ * Hours from `ms` to `nowMs`, or `null` when `ms` lies further in the future
135
+ * than {@link FUTURE_HEARTBEAT_TOLERANCE_MS} (#1505.2). Such a stamp measures
136
+ * no age, and a negative one is worse than none: it reads as "younger than
137
+ * every TTL" and was emitted as a negative `age_hours`. `null` — not 0 — so an
138
+ * unmeasurable age never looks like a fresh one. Within the tolerance (clock
139
+ * skew) the age clamps to 0.
140
+ *
141
+ * @param {number} ms
142
+ * @param {number} nowMs
143
+ * @returns {number|null}
144
+ */
145
+ function futureSafeAgeHours(ms, nowMs) {
146
+ if (ms > nowMs + FUTURE_HEARTBEAT_TOLERANCE_MS) return null;
147
+ return Math.max(0, nowMs - ms) / (3600 * 1000);
127
148
  }
128
149
 
129
150
  /**
@@ -223,12 +244,15 @@ function currentSessionPathFor(repoRoot) {
223
244
  function currentSessionAgeHours(parsed, filePath, nowMs, fsDep) {
224
245
  const ts = typeof parsed?.timestamp === 'string' ? parsed.timestamp : null;
225
246
  const ms = ts ? Date.parse(ts) : NaN;
226
- if (!Number.isNaN(ms)) {
227
- return (nowMs - ms) / (3600 * 1000);
228
- }
247
+ // #1505.2: a `timestamp` beyond the future tolerance measures nothing — it
248
+ // fell through as a negative age, always under the TTL, and kept the file
249
+ // 'fresh' forever. It is treated like an absent one: the mtime decides.
250
+ const fromTimestamp = Number.isNaN(ms) ? null : futureSafeAgeHours(ms, nowMs);
251
+ if (fromTimestamp !== null) return fromTimestamp;
229
252
  try {
230
253
  const info = fsDep.statSync(filePath);
231
- return (nowMs - info.mtimeMs) / (3600 * 1000);
254
+ // A future mtime too → null → 'fresh': kept until the clock passes it.
255
+ return futureSafeAgeHours(info.mtimeMs, nowMs);
232
256
  } catch {
233
257
  return null;
234
258
  }
@@ -132,6 +132,14 @@ const MS_PER_DAY = 86_400_000;
132
132
  /** The event name whose ABSENCE is the `evolve` signal. */
133
133
  const EVOLVE_EVENT = 'orchestrator.evolve.completed';
134
134
 
135
+ /**
136
+ * Events-ledger window for the #1401 census (`events-retention-banner.mjs`):
137
+ * `null` — the one ledger question here ("was {@link EVOLVE_EVENT} EVER
138
+ * recorded?") is about every line ever written, so no day count bounds it; a
139
+ * pruned history turns the answer `undeterminable`, never "never" (#1489).
140
+ */
141
+ export const REQUIRED_EVENTS_WINDOW_DAYS = null;
142
+
135
143
  /** Pending-proposal sidecars, repo-relative (owned by auto-dream / auto-dialectic). */
136
144
  const PENDING_SIDECARS = ['.orchestrator/pending-dream.md', '.orchestrator/dialectic-pending.md'];
137
145
 
@@ -187,11 +195,13 @@ export const EVOLVE_SCAN_BUDGET_MS = 1000;
187
195
  * /evolve, which is the HR-101 failure mode this module exists to avoid.
188
196
  *
189
197
  * @param {string} repoRoot
190
- * @returns {{ok: boolean, lastAt: string|null, truncated: boolean}} `ok: false`
191
- * ⇒ a source exists but could not be read; `truncated: true` ⇒ the budget ran
192
- * out before the walk finished. In BOTH cases the caller must record
193
- * `undeterminable`, never clean — a walk that did not finish has not proven
194
- * "never".
198
+ * @returns {{ok: boolean, lastAt: string|null, truncated: boolean, historyCut: boolean}}
199
+ * `ok: false` ⇒ a source exists but could not be read; `truncated: true` ⇒
200
+ * the budget ran out before the walk finished. In BOTH cases the caller must
201
+ * record `undeterminable`, never clean — a walk that did not finish has not
202
+ * proven "never". `historyCut: true` ⇒ the walk finished but the history it
203
+ * read begins after an archive that is gone (#1489: pruned by
204
+ * `events-rotation.max-backups`), so a miss has not proven "never" either.
195
205
  */
196
206
  function readLastEvolveRun(repoRoot) {
197
207
  const file = path.join(repoRoot, '.orchestrator', 'metrics', 'events.jsonl');
@@ -209,8 +219,10 @@ function readLastEvolveRun(repoRoot) {
209
219
  return true;
210
220
  },
211
221
  });
212
- if (scan.unreadable.length > 0) return { ok: false, lastAt: null, truncated: false };
213
- return { ok: true, lastAt, truncated: scan.truncated };
222
+ if (scan.unreadable.length > 0) {
223
+ return { ok: false, lastAt: null, truncated: false, historyCut: false };
224
+ }
225
+ return { ok: true, lastAt, truncated: scan.truncated, historyCut: scan.gaps.length > 0 };
214
226
  }
215
227
 
216
228
  /**
@@ -301,7 +313,10 @@ export async function computeMaintenanceDue(opts = {}) {
301
313
  if (!evolve.ok || evolve.truncated) {
302
314
  undeterminable.push('evolve');
303
315
  } else if (evolve.lastAt === null && nudge.activeLearnings >= MAINTENANCE_MIN_LEARNINGS) {
304
- markDue('evolve', `never, ${nudge.activeLearnings} active learnings`);
316
+ // #1489: judged only where it would flip the verdict, so a cut history
317
+ // adds no row to a repo the floor already keeps silent (HR-101).
318
+ if (evolve.historyCut) undeterminable.push('evolve');
319
+ else markDue('evolve', `never, ${nudge.activeLearnings} active learnings`);
305
320
  }
306
321
 
307
322
  // --- reconcile (S3) ----------------------------------------------------
@@ -555,9 +555,26 @@ export function normalizeRemote(url) {
555
555
  * - `file:` URL, or a bare/relative path (git's rule: no `://` and no `:`
556
556
  * before the first `/`, a Windows drive letter counting as a path)
557
557
  * → `file://<local path>`.
558
- * - Every other form (`https://`, `ssh://`, scp-like `git@host:path`) keeps its
559
- * host/path and goes through {@link redactUrlCredentials}, so an
560
- * `https://user:token@host/…` origin cannot leak its credential either.
558
+ * - Login rule, both remote forms: a userinfo other than the forge-shared `git`
559
+ * is the operator's account (or a password-shaped `user:pass`) → `***@`, the
560
+ * same marker {@link redactUrlCredentials} uses; it never takes part in the
561
+ * suffix match, so dropping it costs the line no diagnostic value.
562
+ * - A URL form (`ssh://`, `https://`, … #1490 item 5) applies the login rule,
563
+ * keeps its host/port, and keeps its path UNLESS the path has the absolute
564
+ * home-dir shape (`/home/<u>…`, `/Users/<u>…`, `/root…`, `/~…`) →
565
+ * `/<remote path>`. In URL form every path starts with `/`, so a forge's
566
+ * `/org/repo` and a plain `/srv/vault` cannot be told from a filesystem path;
567
+ * only the home-dir shape is the owner leak, and the forge shape is what the
568
+ * suffix comparison is about. The result still goes through
569
+ * {@link redactUrlCredentials}, so a bare `https://git@` (a token slot there)
570
+ * is redacted as before. A `://` with no scheme before it (`/srv/a://b`,
571
+ * `host:/p://q`) is no URL form: the path and scp rules read it (#1496).
572
+ * - A scp-like `[user@]host:path` (#1487 item 10) applies the login rule and
573
+ * keeps the host and a RELATIVE path — the repo shape the canonical-suffix
574
+ * comparison is about. Here a path starting with `/` or `~` is unambiguous: a
575
+ * filesystem path on that host (typically a home dir) → `<remote path>`. A
576
+ * bracketed IPv6 host (`[::1]:path`, `user@[::1]:path`, `[user@::1]:path`)
577
+ * follows the same two rules.
561
578
  *
562
579
  * Returns `''` for an empty/absent origin; callers supply their own wording.
563
580
  * @param {string|undefined|null} url
@@ -567,9 +584,35 @@ export function describeOriginForLog(url) {
567
584
  const s = String(url ?? '').trim();
568
585
  if (!s) return '';
569
586
  if (/^file:/i.test(s)) return 'file://<local path>';
570
- if (s.includes('://')) return redactUrlCredentials(s);
587
+ const login = (u) => (u === undefined ? '' : u === 'git' ? 'git@' : '***@');
588
+ // A `://` with no scheme in front of it is no URL — git's is_url() reads
589
+ // such a string by the scp/local rules below, and so does this (#1496: it
590
+ // used to be printed verbatim, home dir and all).
591
+ const m = /^([a-z][a-z0-9+.-]*:\/\/)([^/]*)(.*)$/is.exec(s);
592
+ if (m) {
593
+ const [, scheme, authority, urlPath] = m;
594
+ // userinfo runs to the LAST '@' of the authority (a password may hold one).
595
+ const at = authority.lastIndexOf('@');
596
+ const user = at === -1 ? undefined : authority.slice(0, at);
597
+ const homeShaped = /^\/(?:~|(?:home|Users)\/[^/]+(?:\/|$)|root(?:\/|$))/.test(urlPath);
598
+ return redactUrlCredentials(
599
+ `${scheme}${login(user)}${authority.slice(at + 1)}${homeShaped ? '/<remote path>' : urlPath}`,
600
+ );
601
+ }
571
602
  const scpLike = /^[^/\\]*:/.test(s) && !/^[a-z]:[\\/]/i.test(s);
572
- return scpLike ? redactUrlCredentials(s) : 'file://<local path>';
603
+ if (!scpLike) return 'file://<local path>';
604
+ // Split host from path the way git's connect.c host_end() does: a host in
605
+ // brackets — leading, or right after `user@` — runs to its `]`, so the ':'
606
+ // inside an IPv6 literal (`[::1]`, `[alice@::1]`) is never the separator.
607
+ // Otherwise the userinfo runs to the last '@' and the host to the next ':';
608
+ // `scpLike` guarantees a ':' with no '/' or '\' before it, so that second
609
+ // pattern always matches.
610
+ const [, user, host, path] =
611
+ /^(?:([^[]*)@)?(\[[^\]]*\]):(.*)$/s.exec(s) ?? /^(?:(.*)@)?([^@:]*):(.*)$/s.exec(s);
612
+ // A login inside the brackets is the same account as one before them.
613
+ const shownHost = host.replace(/^\[([^\]]*)@/, (_, u) => `[${login(u)}`);
614
+ const where = /^[/~]/.test(path) ? '<remote path>' : path;
615
+ return redactUrlCredentials(`${login(user)}${shownHost}:${where}`);
573
616
  }
574
617
 
575
618
  /**
@@ -0,0 +1,87 @@
1
+ /**
2
+ * orphan-reaper/no-follow-append.mjs — the ONE hardened JSONL append the
3
+ * reaper's two ledgers share: the B5 kill audit (`reaper-audit.mjs`) and the
4
+ * gate-process ledger (`../process-group.mjs`). It was inline in the audit
5
+ * writer; the gate ledger still used a plain `appendFileSync(path)` (#1489).
6
+ *
7
+ * Imports nothing but `node:fs`, so `process-group.mjs` takes it without
8
+ * growing its closure. The read-side twin is `readTailWindow(file, n,
9
+ * { noFollow: true })` in `../tail-window.mjs`.
10
+ */
11
+
12
+ import { appendFileSync, closeSync, constants as fsConstants, fstatSync, openSync } from 'node:fs';
13
+
14
+ /** The one refusal every hardened ledger writer prints — the appends here and
15
+ * the atomic prune rewrites (`replaceRegularFile`) in both ledger modules. */
16
+ export const NOT_A_REGULAR_FILE = 'not a regular file (a symlink is never written through) — left untouched';
17
+
18
+ /**
19
+ * `open(2)` flags of the append. `O_NOFOLLOW` makes the kernel refuse a
20
+ * symlink as the last path component (ELOOP) in the same call that opens the
21
+ * file, so there is no lstat-then-open window; `O_NONBLOCK` turns a planted FIFO
22
+ * with no reader into ENXIO instead of blocking in `open` (a plain append open
23
+ * of a FIFO waits for a reader that never comes — reproduced 2026-10-02, #1487).
24
+ * On a regular file `O_NONBLOCK` changes nothing.
25
+ * Named ceiling (BV-004): both are POSIX-only — Node leaves them undefined on
26
+ * Windows, where a linked ledger is still followed; revisit if the reaper or the
27
+ * gate ledger ever runs there.
28
+ */
29
+ const APPEND_FLAGS = fsConstants.O_WRONLY | fsConstants.O_APPEND | fsConstants.O_CREAT
30
+ | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0);
31
+
32
+ /** @returns {Error & {code: string}} */
33
+ function notARegularFile() {
34
+ return Object.assign(new Error(NOT_A_REGULAR_FILE), { code: 'ERR_NOT_REGULAR_FILE' });
35
+ }
36
+
37
+ /**
38
+ * Append `data` to `target`, creating it if absent — but never THROUGH a link
39
+ * and never blocked by a FIFO.
40
+ *
41
+ * The check is the open itself ({@link APPEND_FLAGS}) plus an `fstat` of the
42
+ * descriptor that was opened — never a separate lstat. That `fstat` refuses
43
+ * what the open let through: a FIFO that HAD a reader, a directory, a device,
44
+ * and a HARD-linked file (`nlink !== 1`): `ln <victim> <ledger>` passes every
45
+ * symlink check, and each append would then land in the victim (reproduced
46
+ * 2026-10-02 against the audit).
47
+ *
48
+ * THROWS — mapping a failure to a WARN line is the caller's job, as with
49
+ * `readTailWindow`. A symlink or any non-regular file throws
50
+ * `code: 'ERR_NOT_REGULAR_FILE'` with message {@link NOT_A_REGULAR_FILE}; a
51
+ * hard-linked file throws `code: 'ERR_HARD_LINKED'`; a FIFO without a reader
52
+ * throws the kernel's ENXIO unchanged; every other fs error passes through.
53
+ *
54
+ * @param {string} target Absolute path; its directory must already exist.
55
+ * @param {string} data
56
+ * @returns {void}
57
+ */
58
+ export function appendNoFollow(target, data) {
59
+ let fd;
60
+ try {
61
+ fd = openSync(target, APPEND_FLAGS);
62
+ } catch (err) {
63
+ throw err?.code === 'ELOOP' ? notARegularFile() : err;
64
+ }
65
+ try {
66
+ const stats = fstatSync(fd);
67
+ if (!stats.isFile()) throw notARegularFile();
68
+ // nlink 0: the name was replaced between open and fstat (a concurrent prune
69
+ // renames a fresh inode over it). Not a planted link — say so, so the
70
+ // operator does not go hunting for one.
71
+ if (stats.nlink === 0) {
72
+ throw Object.assign(
73
+ new Error('replaced while being opened (a concurrent rewrite renamed a new file over it) — this record was not written'),
74
+ { code: 'ERR_UNLINKED' },
75
+ );
76
+ }
77
+ if (stats.nlink !== 1) {
78
+ throw Object.assign(
79
+ new Error(`hard-linked (${stats.nlink} names) — an append would write into every one of them; left untouched`),
80
+ { code: 'ERR_HARD_LINKED' },
81
+ );
82
+ }
83
+ appendFileSync(fd, data, 'utf8');
84
+ } finally {
85
+ try { closeSync(fd); } catch { /* nothing left to release */ }
86
+ }
87
+ }