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
@@ -30,6 +30,11 @@
30
30
  * ONLY destructive step, and it reuses `rewriteLearnings()` from io.mjs
31
31
  * (#721), which itself snapshots a `.bak-<ISO>` backup before the atomic
32
32
  * rename — the same safety net that protects every other bulk rewrite.
33
+ * A store line that does not parse is carried through the rewrite
34
+ * verbatim (#1489): neither partition can judge it, and the rewrite is
35
+ * built from parsed records, so leaving it out deleted it with no archive
36
+ * record. Refusing the rewrite instead would stall the session-end sweep
37
+ * on every close until someone repaired the line by hand.
33
38
  * - Grace period (default 14 days): an entry that JUST expired stays in the
34
39
  * active store for `graceDays` more days. This absorbs two things: (a)
35
40
  * TTL edge-noise near the boundary, and (b) a window for /evolve's
@@ -54,15 +59,82 @@
54
59
  * (the incident that destroyed 107 live learnings via a bespoke writer
55
60
  * with no backup/dry-run safety net).
56
61
  *
62
+ * #1500 — {@link dropMalformedLines}, the one sanctioned removal of an
63
+ * unparseable store line: the lines the two writers above keep verbatim.
64
+ *
57
65
  * Sibling-module import convention (learnings.mjs barrel doc): import
58
66
  * directly from `./io.mjs`, never from `../learnings.mjs`, to preserve the
59
67
  * acyclic dependency graph.
60
68
  */
61
69
 
62
- import { existsSync } from 'node:fs';
63
- import { mkdir, appendFile } from 'node:fs/promises';
70
+ import { constants as fsConstants, existsSync } from 'node:fs';
71
+ import { mkdir, appendFile, copyFile } from 'node:fs/promises';
64
72
  import path from 'node:path';
65
- import { readLearnings, rewriteLearnings, withLearningsLock } from './io.mjs';
73
+ import { readLearnings, readLearningsSnapshot, rewriteLearnings, withLearningsLock } from './io.mjs';
74
+ import { redactHomeDir } from '../common.mjs';
75
+
76
+ /**
77
+ * Thrown by {@link pruneLearnings} when `expectedGeneration` no longer matches
78
+ * the store (#1486): the caller's next generation was derived from a store
79
+ * state that has since changed, so every record added after that derivation
80
+ * would read as caller-dropped and be archived `pruned`. Nothing is written.
81
+ */
82
+ export class StoreGenerationMismatchError extends Error {
83
+ /**
84
+ * @param {string} filePath
85
+ * @param {string} expected - token the caller derived its generation from
86
+ * @param {string} actual - token of the store as read under the lock
87
+ */
88
+ constructor(filePath, expected, actual) {
89
+ // The tokens live on the object, never in the message: callers print the
90
+ // message, and the CURRENT token copied onto the stale sidecar's line 1 is
91
+ // exactly what gets that sidecar past this check — measured, the copy
92
+ // archived the peer's record `pruned`, exit 0.
93
+ super(`store-generation-mismatch: ${filePath} changed since the next generation was derived — nothing written`);
94
+ this.name = 'StoreGenerationMismatchError';
95
+ this.code = 'store-generation-mismatch';
96
+ this.filePath = filePath;
97
+ this.expected = expected;
98
+ this.actual = actual;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Thrown by {@link dropMalformedLines} when a requested line may not be
104
+ * dropped. Nothing is written. Two reasons:
105
+ * - `not-unparseable`: a record, a blank line, or past the end. The message
106
+ * names where the unparseable lines are NOW — every rewrite moves them to
107
+ * the end, so a number from an earlier WARN is often stale (#1500 F3).
108
+ * - `holds-a-record`: the line still carries a readable record (behind a
109
+ * BOM, or fused after a torn one) and `acceptEmbedded` was not given.
110
+ */
111
+ export class MalformedLineRefusedError extends Error {
112
+ /**
113
+ * @param {string} filePath
114
+ * @param {number[]} lines - the requested lines that may not be dropped
115
+ * @param {{ reason?: 'not-unparseable'|'holds-a-record', unparseable?: number[] }} [opts]
116
+ * `unparseable`: the store's unparseable lines as read (for `not-unparseable`)
117
+ */
118
+ constructor(filePath, lines, { reason = 'not-unparseable', unparseable = [] } = {}) {
119
+ const where = `line ${lines.join(', ')} of ${redactHomeDir(filePath)}`;
120
+ const now =
121
+ unparseable.length === 0
122
+ ? 'none'
123
+ : unparseable.slice(0, 10).join(', ') + (unparseable.length > 10 ? `, +${unparseable.length - 10} more` : '');
124
+ super(
125
+ reason === 'holds-a-record'
126
+ ? `drop-malformed refused: ${where} still holds a readable record (see bom / embedded_record ` +
127
+ `in the dry run) — dropping it loses that record; nothing written. Pass --accept-embedded ` +
128
+ `to drop it anyway (the pre-drop snapshot keeps the line)`
129
+ : `drop-malformed refused: ${where} is not an unparseable line (a record, a blank line, or ` +
130
+ `past the end) — nothing written; unparseable lines now: ${now}`
131
+ );
132
+ this.name = 'MalformedLineRefusedError';
133
+ this.code = 'malformed-line-refused';
134
+ this.reason = reason;
135
+ this.lines = lines;
136
+ }
137
+ }
66
138
 
67
139
  /**
68
140
  * Composite-key separator for the in-memory consolidation Map.
@@ -202,6 +274,65 @@ function countByReason(batch) {
202
274
  return out;
203
275
  }
204
276
 
277
+ /**
278
+ * Result field for the store's unparseable lines: `{malformed: n}` when the
279
+ * read held `n > 0`, else `{}` — so the pinned zero-case result shape (the
280
+ * CLI's `--json` contract) is unchanged, and an absent field always means a
281
+ * read that found none. A non-zero count also goes to stderr, because a
282
+ * caller that forwards only its own fields (the session-end tail runner, the
283
+ * CLI's human-readable line) would otherwise drop it on the floor.
284
+ *
285
+ * @param {string} filePath
286
+ * @param {string[]} malformed - raw text of the lines that did not parse
287
+ * @param {number[]} lineNumbers - their 1-based store lines (`malformedLineNumbers`)
288
+ * @returns {{malformed?: number}}
289
+ */
290
+ function malformedField(filePath, malformed, lineNumbers) {
291
+ if (malformed.length === 0) return {};
292
+ warnUnparseableLines(filePath, malformed, lineNumbers);
293
+ return { malformed: malformed.length };
294
+ }
295
+
296
+ /**
297
+ * Store + line set already WARNed about in this process. One `/close` reads the
298
+ * same store twice in one process — the planner's dry run, then the apply — and
299
+ * printed the identical WARN twice. Grows by one key per distinct store+line
300
+ * set, so it stays tiny; revisit if a long-lived process ever sweeps many stores.
301
+ * @type {Set<string>}
302
+ */
303
+ const warnedUnparseable = new Set();
304
+
305
+ /**
306
+ * Print the one stderr WARN for a store's unparseable lines — at most once per
307
+ * store and identical line set per process. The policy it states is the store
308
+ * policy since #1489: such lines are kept verbatim at the end of every rewrite,
309
+ * never archived, and never a reason to refuse a write.
310
+ *
311
+ * @param {string} filePath
312
+ * @param {string[]} malformed - raw text of the lines that did not parse
313
+ * @param {number[]} [lineNumbers] - their 1-based store lines; at most ten listed
314
+ *
315
+ * The text names no dry-run state: one WARN per process is shared by the
316
+ * planner's dry run and the apply that follows it in a /close, so a dry-run
317
+ * qualifier would be printed for — and contradict — the applied rewrite.
318
+ */
319
+ export function warnUnparseableLines(filePath, malformed, lineNumbers = []) {
320
+ if (malformed.length === 0) return;
321
+ const key = `${path.resolve(filePath)}\0${malformed.join('\n')}`;
322
+ if (warnedUnparseable.has(key)) return;
323
+ warnedUnparseable.add(key);
324
+ const more = lineNumbers.length > 10 ? `, +${lineNumbers.length - 10} more` : '';
325
+ const where = lineNumbers.length > 0 ? ` (line(s) ${lineNumbers.slice(0, 10).join(', ')}${more})` : '';
326
+ console.error(
327
+ // The store path is absolute and lands in every /close transcript (#1490).
328
+ `[learnings] WARN: ${malformed.length} unparseable line(s) in ${redactHomeDir(filePath)}${where} kept verbatim, ` +
329
+ `never archived; report them to the operator — ` +
330
+ `never hand-edit the store. Once the operator decides, remove one with ` +
331
+ `\`node scripts/sweep-expired-learnings.mjs --drop-malformed --line N --file ${redactHomeDir(filePath)}\` ` +
332
+ `(dry run; it prints the --generation its --apply needs)`
333
+ );
334
+ }
335
+
205
336
  /** Resolve the `now` parameter (Date | epoch ms | undefined) to epoch ms. */
206
337
  function resolveNowMs(now) {
207
338
  if (now instanceof Date) return now.getTime();
@@ -223,15 +354,32 @@ function resolveNowMs(now) {
223
354
  * @param {string} opts.archivePath
224
355
  * @param {object[]} opts.keep — the next store generation
225
356
  * @param {{entry: object, verdict: {reason: string, tombstone: object|null}}[]} opts.archiveBatch
357
+ * @param {string[]} opts.malformed — unparseable store lines, written back
358
+ * verbatim after `keep` (see the module header)
359
+ * @param {number[]} opts.malformedLineNumbers — their 1-based store lines (WARN only)
226
360
  * @param {number} opts.nowMs
227
361
  * @param {boolean} opts.dryRun
228
- * @returns {Promise<{kept: number, archived: number, byReason: Record<string, number>}>}
362
+ * @returns {Promise<{kept: number, archived: number, byReason: Record<string, number>,
363
+ * malformedField: {malformed?: number}}>}
229
364
  */
230
- async function archiveThenRewrite({ filePath, archivePath, keep, archiveBatch, nowMs, dryRun }) {
365
+ async function archiveThenRewrite({
366
+ filePath,
367
+ archivePath,
368
+ keep,
369
+ archiveBatch,
370
+ malformed,
371
+ malformedLineNumbers,
372
+ nowMs,
373
+ dryRun,
374
+ }) {
231
375
  const byReason = countByReason(archiveBatch);
232
- if (dryRun) {
233
- return { kept: keep.length, archived: archiveBatch.length, byReason };
234
- }
376
+ const result = {
377
+ kept: keep.length,
378
+ archived: archiveBatch.length,
379
+ byReason,
380
+ malformedField: malformedField(filePath, malformed, malformedLineNumbers),
381
+ };
382
+ if (dryRun) return result;
235
383
 
236
384
  // Validate the KEEP batch BEFORE the archive append (dry-run rewrite: throws
237
385
  // on a bad record, writes nothing). Without this, an invalid-but-parseable
@@ -240,7 +388,7 @@ async function archiveThenRewrite({ filePath, archivePath, keep, archiveBatch, n
240
388
  // duplicates the archive append every time (never de-duplicated on read)
241
389
  // while the store is never actually pruned. Validating first means a bad
242
390
  // record throws here, before anything on disk has been touched.
243
- await rewriteLearnings(filePath, keep, { dryRun: true });
391
+ await rewriteLearnings(filePath, keep, { dryRun: true, malformedLines: malformed });
244
392
 
245
393
  // Crash-safe ordering: archive append FIRST. A duplicate re-append after a
246
394
  // crash is harmless (append-only, never de-duplicated on read); the reverse
@@ -259,9 +407,9 @@ async function archiveThenRewrite({ filePath, archivePath, keep, archiveBatch, n
259
407
  // Re-validates (cheap, idempotent) and snapshots a `.bak-<ISO>` backup of
260
408
  // the current store before the atomic rename (io.mjs #721). The KEEP batch
261
409
  // already passed the dry-run probe above, so this call cannot throw here.
262
- await rewriteLearnings(filePath, keep, { dryRun: false });
410
+ await rewriteLearnings(filePath, keep, { dryRun: false, malformedLines: malformed });
263
411
 
264
- return { kept: keep.length, archived: archiveBatch.length, byReason };
412
+ return result;
265
413
  }
266
414
 
267
415
  /**
@@ -279,7 +427,10 @@ async function archiveThenRewrite({ filePath, archivePath, keep, archiveBatch, n
279
427
  * @param {Date|number} [opts.now] - injectable clock; defaults to `Date.now()`
280
428
  * @param {boolean} [opts.dryRun=true] - when true, computes counts but writes nothing
281
429
  * @param {number} [opts.graceDays=14] - days past expiry before an entry is archived
282
- * @returns {Promise<{scanned: number, kept: number, archived: number, dryRun: boolean, archivePath: string}>}
430
+ * @returns {Promise<{scanned: number, kept: number, archived: number, dryRun: boolean, archivePath: string,
431
+ * malformed?: number}>} `malformed` is present only when the store held
432
+ * unparseable lines: their count. They are kept verbatim at the end of the
433
+ * rewritten store, never archived, and not counted in `scanned`/`kept`.
283
434
  * Never throws on a missing store — returns the zeroed shape instead. Both
284
435
  * `filePath` and `archivePath` are required; a missing/invalid `filePath`
285
436
  * throws a plain `Error` (programmer error, not a runtime data condition).
@@ -321,7 +472,7 @@ async function sweepExpiredLearningsUnlocked({
321
472
  const graceMs =
322
473
  (Number.isFinite(graceDays) && graceDays >= 0 ? graceDays : DEFAULT_GRACE_DAYS) * MS_PER_DAY;
323
474
 
324
- const { entries } = await readLearnings(filePath);
475
+ const { entries, malformed, malformedLineNumbers } = await readLearnings(filePath);
325
476
 
326
477
  const keep = [];
327
478
  const archiveBatch = [];
@@ -342,11 +493,13 @@ async function sweepExpiredLearningsUnlocked({
342
493
  }
343
494
  }
344
495
 
345
- const { kept, archived } = await archiveThenRewrite({
496
+ const { kept, archived, malformedField: malformedCount } = await archiveThenRewrite({
346
497
  filePath,
347
498
  archivePath,
348
499
  keep,
349
500
  archiveBatch,
501
+ malformed,
502
+ malformedLineNumbers,
350
503
  nowMs,
351
504
  dryRun,
352
505
  });
@@ -354,7 +507,7 @@ async function sweepExpiredLearningsUnlocked({
354
507
  // Result shape is pinned by the CLI (`scripts/sweep-expired-learnings.mjs`)
355
508
  // and its JSON contract — deliberately WITHOUT `byReason`, which only the
356
509
  // multi-reason prune path below can populate meaningfully.
357
- return { scanned: entries.length, kept, archived, dryRun, archivePath };
510
+ return { scanned: entries.length, kept, archived, dryRun, archivePath, ...malformedCount };
358
511
  }
359
512
 
360
513
  /**
@@ -411,8 +564,19 @@ async function sweepExpiredLearningsUnlocked({
411
564
  * @param {string|Function} [opts.dropReason='pruned'] - verdict for case 3
412
565
  * above: a reason string, or `(entry) => reason|{reason, supersededBy, mergedInto}`
413
566
  * for per-record routing (this is the seam a later `merged` producer uses).
567
+ * @param {string} [opts.expectedGeneration] - generation token
568
+ * (`readLearningsSnapshot().generation`) of the store state `entries` was
569
+ * derived from (#1486). Compared against the store as read here — under the
570
+ * lock on apply — and a mismatch throws {@link StoreGenerationMismatchError}
571
+ * before anything is written. Required whenever `entries` was derived in an
572
+ * earlier read, outside the lock (the `/evolve` sidecar); a caller that
573
+ * reads and prunes inside one `withLearningsLock` section may omit it.
414
574
  * @returns {Promise<{scanned: number, kept: number, archived: number,
415
- * byReason: Record<string, number>, dryRun: boolean, archivePath: string}>}
575
+ * byReason: Record<string, number>, dryRun: boolean, archivePath: string,
576
+ * malformed?: number}>} `malformed` as in {@link sweepExpiredLearnings}: the
577
+ * store's unparseable lines, kept verbatim — no caller generation can carry
578
+ * them, so they are never read as caller drops.
579
+ * @throws {StoreGenerationMismatchError} when `expectedGeneration` is stale
416
580
  */
417
581
  export async function pruneLearnings(opts = {}) {
418
582
  const { filePath, dryRun = true } = opts;
@@ -437,6 +601,7 @@ async function pruneLearningsUnlocked({
437
601
  now,
438
602
  dryRun = true,
439
603
  dropReason = 'pruned',
604
+ expectedGeneration,
440
605
  } = {}) {
441
606
  if (typeof filePath !== 'string' || filePath.length === 0) {
442
607
  throw new Error('pruneLearnings: filePath is required');
@@ -447,13 +612,33 @@ async function pruneLearningsUnlocked({
447
612
  if (entries !== undefined && !Array.isArray(entries)) {
448
613
  throw new Error('pruneLearnings: entries must be an array when provided');
449
614
  }
615
+ if (
616
+ expectedGeneration !== undefined &&
617
+ (typeof expectedGeneration !== 'string' || expectedGeneration.length === 0)
618
+ ) {
619
+ throw new Error('pruneLearnings: expectedGeneration must be a non-empty string when provided');
620
+ }
450
621
 
451
622
  const nowMs = resolveNowMs(now);
452
- const { entries: current } = await readLearnings(filePath);
623
+ // Token and records come from ONE read, so the comparison judges exactly the
624
+ // records loop (3) reconciles against.
625
+ const { entries: current, malformed, malformedLineNumbers, generation } = await readLearningsSnapshot(filePath);
626
+ if (expectedGeneration !== undefined && generation !== expectedGeneration) {
627
+ throw new StoreGenerationMismatchError(filePath, expectedGeneration, generation);
628
+ }
453
629
  const next = entries ?? current;
454
630
 
455
631
  if (next.length === 0 && current.length === 0) {
456
- return { scanned: 0, kept: 0, archived: 0, byReason: {}, dryRun, archivePath };
632
+ // Nothing to write — the malformed lines, if any, stay where they are.
633
+ return {
634
+ scanned: 0,
635
+ kept: 0,
636
+ archived: 0,
637
+ byReason: {},
638
+ dryRun,
639
+ archivePath,
640
+ ...malformedField(filePath, malformed, malformedLineNumbers),
641
+ };
457
642
  }
458
643
 
459
644
  const resolveDrop = (entry) =>
@@ -537,14 +722,196 @@ async function pruneLearningsUnlocked({
537
722
  dropped += 1;
538
723
  }
539
724
 
540
- const { kept, archived, byReason } = await archiveThenRewrite({
725
+ const { kept, archived, byReason, malformedField: malformedCount } = await archiveThenRewrite({
541
726
  filePath,
542
727
  archivePath,
543
728
  keep,
544
729
  archiveBatch,
730
+ malformed,
731
+ malformedLineNumbers,
545
732
  nowMs,
546
733
  dryRun,
547
734
  });
548
735
 
549
- return { scanned: next.length + dropped, kept, archived, byReason, dryRun, archivePath };
736
+ return {
737
+ scanned: next.length + dropped,
738
+ kept,
739
+ archived,
740
+ byReason,
741
+ dryRun,
742
+ archivePath,
743
+ ...malformedCount,
744
+ };
745
+ }
746
+
747
+ /** Characters of a dropped line shown in the dry-run preview. */
748
+ const DROP_PREVIEW_CHARS = 80;
749
+
750
+ /** U+FEFF — built, not typed: a literal BOM in source fails validate-plugin's unicode-safety check. */
751
+ const BOM = String.fromCharCode(0xfeff);
752
+
753
+ /**
754
+ * The readable record an unparseable line may still carry (#1500 F1) — the
755
+ * line counts as malformed, yet dropping it would delete a learning:
756
+ * - `bom`: the line is a JSON object behind a UTF-8 BOM (an editor's save);
757
+ * - `embedded_record`: a `{` after the line's start (after the BOM, if any)
758
+ * begins a suffix that parses as a JSON object — the #1489 fusion shape, a
759
+ * torn record with the next complete record appended to it. The smallest
760
+ * such offset wins; `id` is the record's id, or null.
761
+ *
762
+ * Ceiling (BV-004): one JSON.parse of the suffix per `{`, so quadratic in the
763
+ * line length — a few ms for the KB-sized lines a store holds; revisit if an
764
+ * unparseable line ever passes ~1 MB.
765
+ *
766
+ * @param {string} text - one unparseable store line
767
+ * @returns {{bom: boolean, embedded_record: {offset: number, id: string|null}|null}}
768
+ */
769
+ function inspectMalformedLine(text) {
770
+ const asObject = (s) => {
771
+ try {
772
+ const v = JSON.parse(s);
773
+ return v !== null && typeof v === 'object' && !Array.isArray(v) ? v : null;
774
+ } catch {
775
+ return null;
776
+ }
777
+ };
778
+ const hasBom = text.startsWith(BOM);
779
+ const bom = hasBom && asObject(text.slice(1)) !== null;
780
+ let embedded = null;
781
+ for (let i = text.indexOf('{', hasBom ? 2 : 1); i !== -1; i = text.indexOf('{', i + 1)) {
782
+ const record = asObject(text.slice(i));
783
+ if (record !== null) {
784
+ embedded = { offset: i, id: typeof record.id === 'string' ? record.id : null };
785
+ break;
786
+ }
787
+ }
788
+ return { bom, embedded_record: embedded };
789
+ }
790
+
791
+ /**
792
+ * Where {@link dropMalformedLines} snapshots the store before its rewrite:
793
+ * `<stem>.pre-drop-malformed.jsonl.bak-<ISO>` beside it. The name is chosen
794
+ * twice over: `.orchestrator/metrics/*.jsonl.bak-*` gitignores it (here; in a
795
+ * consumer repo once bootstrap's `#store-lock-ignore` snippet has run), and it
796
+ * does NOT start with `<store>.bak`, so the keep-3 rotation of
797
+ * `rewriteLearnings()` never prunes it — the line's text outlives any number
798
+ * of later closes.
799
+ *
800
+ * @param {string} filePath
801
+ * @param {number} nowMs
802
+ * @returns {string}
803
+ */
804
+ function dropSnapshotPath(filePath, nowMs) {
805
+ const base = path.basename(filePath);
806
+ const stem = base.endsWith('.jsonl') ? base.slice(0, -'.jsonl'.length) : base;
807
+ const ts = new Date(nowMs).toISOString().replace(/[:.]/g, '-');
808
+ return path.join(path.dirname(filePath), `${stem}.pre-drop-malformed.jsonl.bak-${ts}`);
809
+ }
810
+
811
+ /**
812
+ * Remove unparseable lines from a learnings store — the ONE sanctioned way
813
+ * (#1500). Every rewrite keeps such lines verbatim (#1489) and the WARN forbids
814
+ * hand edits, so without this the only removal was a library call.
815
+ *
816
+ * `lines` are 1-based store lines as the WARN or a dry run of this function
817
+ * reported them. Line numbers move: every rewrite puts the unparseable lines at
818
+ * the END of the store, so after the next /close sweep a number can name a
819
+ * different unparseable line. Hence an apply needs `expectedGeneration` — the
820
+ * `generation` a dry run returned — and the store read, the generation check,
821
+ * the line check, the snapshot and the rewrite all run under ONE store lock;
822
+ * read outside it, a record a peer appended meanwhile is lost to the rewrite.
823
+ *
824
+ * The rewrite goes through `rewriteLearnings()` (validation, `.bak-<ISO>`,
825
+ * atomic rename) after a full copy of the store to {@link dropSnapshotPath}.
826
+ *
827
+ * An unparseable line can still carry a readable record — fused after a torn
828
+ * one, or behind a BOM ({@link inspectMalformedLine}). The dry run flags it;
829
+ * an apply refuses such a line unless `acceptEmbedded` is set (#1500 F1): an
830
+ * 80-character preview hid a record at offset 250, and the apply deleted it.
831
+ *
832
+ * @param {object} opts
833
+ * @param {string} opts.filePath - the learnings store
834
+ * @param {number[]} opts.lines - 1-based lines to drop; each must be unparseable in the store as read
835
+ * @param {string} [opts.expectedGeneration] - generation the line numbers were read from;
836
+ * REQUIRED to apply, checked on a dry run when given
837
+ * @param {boolean} [opts.acceptEmbedded=false] - apply even to a line that still holds a readable record
838
+ * @param {boolean} [opts.dryRun=true] - check and validate, write nothing
839
+ * @param {Date|number} [opts.now] - injectable clock (snapshot name)
840
+ * @returns {Promise<{generation: string, dropped: {line: number, chars: number, preview: string,
841
+ * bom: boolean, embedded_record: {offset: number, id: string|null}|null}[], records: number,
842
+ * remainingMalformed: number, recordBearing: number, dryRun: boolean, snapshot: string|null}>}
843
+ * `generation` is the store's as read; `chars` the line's full length, `preview` its first
844
+ * {@link DROP_PREVIEW_CHARS} characters with the home dir as `~`; `recordBearing` counts the
845
+ * dropped lines that held a readable record
846
+ * @throws {StoreGenerationMismatchError} the store is not at `expectedGeneration` — nothing written
847
+ * @throws {MalformedLineRefusedError} a line is not unparseable, or (apply without
848
+ * `acceptEmbedded`) still holds a readable record — nothing written
849
+ */
850
+ export async function dropMalformedLines({
851
+ filePath,
852
+ lines,
853
+ expectedGeneration,
854
+ acceptEmbedded = false,
855
+ dryRun = true,
856
+ now,
857
+ } = {}) {
858
+ if (typeof filePath !== 'string' || filePath.length === 0) {
859
+ throw new Error('dropMalformedLines: filePath is required');
860
+ }
861
+ if (!Array.isArray(lines) || lines.length === 0 || !lines.every((n) => Number.isInteger(n) && n > 0)) {
862
+ throw new Error('dropMalformedLines: lines must be a non-empty array of positive integers');
863
+ }
864
+ if (!dryRun && (typeof expectedGeneration !== 'string' || expectedGeneration.length === 0)) {
865
+ throw new Error('dropMalformedLines: expectedGeneration is required to apply');
866
+ }
867
+ const run = () => dropMalformedLinesUnlocked({ filePath, lines, expectedGeneration, acceptEmbedded, dryRun, now });
868
+ // An absent store has no line to drop — the body refuses without a lock.
869
+ return dryRun || !existsSync(filePath) ? run() : withLearningsLock(filePath, run);
870
+ }
871
+
872
+ /**
873
+ * Body of {@link dropMalformedLines}; the caller holds the store lock or runs
874
+ * a pass that cannot write.
875
+ */
876
+ async function dropMalformedLinesUnlocked({ filePath, lines, expectedGeneration, acceptEmbedded, dryRun, now }) {
877
+ const snap = await readLearningsSnapshot(filePath);
878
+ if (expectedGeneration !== undefined && snap.generation !== expectedGeneration) {
879
+ throw new StoreGenerationMismatchError(filePath, expectedGeneration, snap.generation);
880
+ }
881
+ const wanted = [...new Set(lines)].sort((a, b) => a - b);
882
+ const notMalformed = wanted.filter((n) => !snap.malformedLineNumbers.includes(n));
883
+ if (notMalformed.length > 0) {
884
+ throw new MalformedLineRefusedError(filePath, notMalformed, { unparseable: snap.malformedLineNumbers });
885
+ }
886
+
887
+ const drop = new Set(wanted);
888
+ const keepMalformed = snap.malformed.filter((_, i) => !drop.has(snap.malformedLineNumbers[i]));
889
+ const dropped = wanted.map((line) => {
890
+ const text = snap.malformed[snap.malformedLineNumbers.indexOf(line)];
891
+ const cut = text.length > DROP_PREVIEW_CHARS ? `${text.slice(0, DROP_PREVIEW_CHARS)}…` : text;
892
+ return { line, chars: text.length, preview: redactHomeDir(cut), ...inspectMalformedLine(text) };
893
+ });
894
+ const recordBearing = dropped.filter((d) => d.bom || d.embedded_record !== null);
895
+ if (!dryRun && !acceptEmbedded && recordBearing.length > 0) {
896
+ throw new MalformedLineRefusedError(filePath, recordBearing.map((d) => d.line), { reason: 'holds-a-record' });
897
+ }
898
+ // Validate the next generation before anything touches disk, dry run too:
899
+ // a record the tolerant gate rejects throws here, before the snapshot.
900
+ await rewriteLearnings(filePath, snap.entries, { dryRun: true, malformedLines: keepMalformed });
901
+ const result = {
902
+ generation: snap.generation,
903
+ dropped,
904
+ records: snap.entries.length,
905
+ remainingMalformed: keepMalformed.length,
906
+ recordBearing: recordBearing.length,
907
+ dryRun,
908
+ snapshot: null,
909
+ };
910
+ if (dryRun) return result;
911
+
912
+ const snapshot = dropSnapshotPath(filePath, resolveNowMs(now));
913
+ // COPYFILE_EXCL: never overwrite an earlier snapshot.
914
+ await copyFile(filePath, snapshot, fsConstants.COPYFILE_EXCL);
915
+ await rewriteLearnings(filePath, snap.entries, { malformedLines: keepMalformed });
916
+ return { ...result, snapshot };
550
917
  }