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
@@ -22,8 +22,10 @@
22
22
  *
23
23
  * Usage:
24
24
  * node scripts/sweep-expired-learnings.mjs [--prune] [--dry-run|--apply] [--json]
25
- * [--grace-days N] [--entries PATH] [--file PATH] [--archive PATH]
25
+ * [--grace-days N] [--snapshot PATH|--entries PATH] [--file PATH] [--archive PATH]
26
26
  * [--appended N] [--boosted M] [--duration-ms D] [--skipped a,b] [--repo-root PATH]
27
+ * node scripts/sweep-expired-learnings.mjs --drop-malformed --line N [--line M ...]
28
+ * [--file PATH] [--json] [--apply --generation TOKEN --repo-root PATH [--accept-embedded]]
27
29
  *
28
30
  * Flags:
29
31
  * --prune Decision-driven prune+consolidate+rewrite instead of the
@@ -34,9 +36,26 @@
34
36
  * (default: human-readable one-liner)
35
37
  * --grace-days N Days past expiry before archiving (default: 14).
36
38
  * SWEEP ONLY — `--prune` has no grace window by design.
39
+ * --snapshot PATH PRUNE ONLY (#1486). Write the store's current records to
40
+ * PATH, line 1 a `{"_store_generation": "<token>",
41
+ * "_store_path": "<store>"}` header naming the exact store
42
+ * state and the store they came from. This is the sidecar
43
+ * to edit into the next generation. Writes nothing else;
44
+ * takes no --entries/--apply/telemetry flag. A PATH that
45
+ * would replace the --file store or the --archive sidecar
46
+ * exits 1 untouched. A store holding a malformed line is
47
+ * snapshotted with a stderr WARN (line numbers) and a
48
+ * `malformed` count: the sidecar carries records only, and
49
+ * the prune applying it keeps the store's malformed lines
50
+ * verbatim (#1489).
37
51
  * --entries PATH JSONL sidecar holding the caller's next store generation.
38
52
  * PRUNE ONLY. Must exist, parse cleanly, and hold at least
39
53
  * one record — absent/malformed/empty all exit 1 untouched.
54
+ * Line 1 must be the `_store_generation` header written by
55
+ * --snapshot, else exit 1 untouched; a sidecar snapshotted
56
+ * from another store than --file exits 1 untouched; a
57
+ * store that changed since that snapshot exits 3
58
+ * untouched (#1486).
40
59
  * A NEW record (its `id` not in the store) must pass strict
41
60
  * `validateLearning()`, else exit 1 untouched (GH#69);
42
61
  * records already in the store keep the tolerant path.
@@ -55,7 +74,28 @@
55
74
  * --skipped a,b PRUNE ONLY (#1206). Comma-separated list of optional
56
75
  * steps that ran but were themselves skipped this run
57
76
  * (HR-105 — e.g. `skill-evolution-off`). Default none.
58
- * --repo-root PATH PRUNE ONLY (#1206). Repo root the
77
+ * --drop-malformed Remove unparseable store lines (#1500) — the one sanctioned
78
+ * repair; the store keeps such lines verbatim through every
79
+ * rewrite. Takes --line (required), --generation, --file,
80
+ * --json, --apply, --repo-root, --accept-embedded; nothing
81
+ * else. A dry run (default) checks each line and prints its
82
+ * length, preview, any readable record it still carries
83
+ * (bom / embedded_record) and the store generation. --apply re-reads the store under its
84
+ * lock, copies it to <stem>.pre-drop-malformed.jsonl.bak-<ISO>
85
+ * beside it, rewrites it without those lines, and records
86
+ * one orchestrator.learnings.malformed_dropped event.
87
+ * --line N DROP-MALFORMED ONLY. 1-based store line (repeatable). Must
88
+ * be an unparseable line in the store as read, else exit 1.
89
+ * --generation TOKEN DROP-MALFORMED ONLY; required with --apply. The
90
+ * generation the dry run printed: line numbers move with
91
+ * every rewrite, so a changed store exits 3.
92
+ * --accept-embedded DROP-MALFORMED ONLY. Apply even to a line that still
93
+ * holds a readable record (behind a BOM, or fused after a
94
+ * torn record) — without it such a line exits 1.
95
+ * --repo-root PATH With --drop-malformed --apply: REQUIRED, the repo the
96
+ * event is pinned to; --file must lie inside it (judged
97
+ * on realpaths).
98
+ * Otherwise PRUNE ONLY (#1206). Repo root the
59
99
  * `orchestrator.evolve.completed` record is pinned to.
60
100
  * No default and NO process.cwd() fallback (#1119) — when
61
101
  * omitted, no event is emitted at all (stderr WARN, exit
@@ -64,48 +104,104 @@
64
104
  *
65
105
  * Exit codes:
66
106
  * 0 Success (including no-op when nothing is archive-eligible)
67
- * 1 Usage/input error (bad flag/value, flag used in the wrong mode, an
107
+ * 1 Usage/input error (bad flag/value, flag used in the wrong mode, a
108
+ * --drop-malformed --line that is not an unparseable line or, without
109
+ * --accept-embedded, still holds a readable record, an
68
110
  * absent/malformed/empty `--entries` sidecar, or a NEW `--entries` record
69
- * that fails strict schema validation — dry run and apply alike)
111
+ * that fails strict schema validation, or an `--entries` sidecar without
112
+ * its `_store_generation` header or snapshotted from another store than
113
+ * `--file` — dry run and apply alike; at `--snapshot`, a PATH that would
114
+ * replace a ledger)
70
115
  * 2 Sweep/prune error (I/O or validation failure inside the lib)
116
+ * 3 store-generation-mismatch (#1486, #1500): the store changed after the
117
+ * --drop-malformed dry run that printed --generation, or after the
118
+ * `--entries` sidecar was snapshotted — nothing written. Drop: re-run the
119
+ * dry run, re-check its previews, apply with ITS generation. Prune: re-run
120
+ * `--snapshot` into a FRESH path, re-apply the edits to the records in
121
+ * that file, apply it. Never copy the new header onto the old sidecar:
122
+ * its records lack exactly the peer change the token exists to protect.
71
123
  */
72
124
 
73
- import { existsSync } from 'node:fs';
125
+ import { existsSync, realpathSync, statSync } from 'node:fs';
126
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
74
127
  import path from 'node:path';
75
128
  import { isDeepStrictEqual } from 'node:util';
76
- import { sweepExpiredLearnings, pruneLearnings } from './lib/learnings/expiry-sweep.mjs';
77
- import { readLearnings } from './lib/learnings/io.mjs';
129
+ import {
130
+ sweepExpiredLearnings,
131
+ pruneLearnings,
132
+ dropMalformedLines,
133
+ MalformedLineRefusedError,
134
+ StoreGenerationMismatchError,
135
+ warnUnparseableLines,
136
+ } from './lib/learnings/expiry-sweep.mjs';
137
+ import { readLearningsSnapshot, parseLearningsText } from './lib/learnings/io.mjs';
78
138
  import { validateLearning } from './lib/learnings/schema.mjs';
139
+ import { validatePathInsideProject } from './lib/path-utils.mjs';
79
140
  import { emitEvolveCompleted } from './lib/learnings/evolve-telemetry.mjs';
80
141
 
81
142
  const DEFAULT_FILE = '.orchestrator/metrics/learnings.jsonl';
82
143
  const DEFAULT_ARCHIVE = '.orchestrator/metrics/learnings-archive.jsonl';
83
144
  const DEFAULT_GRACE_DAYS = 14;
145
+ /** Key of the `--entries` sidecar's first line, written by `--snapshot` (#1486). */
146
+ const GENERATION_KEY = '_store_generation';
147
+ /** Header key naming the store the snapshot was read from (resolved path). */
148
+ const STORE_PATH_KEY = '_store_path';
149
+ /** Ledger record of one applied `--drop-malformed` (#1500). */
150
+ const DROP_MALFORMED_EVENT = 'orchestrator.learnings.malformed_dropped';
84
151
 
85
152
  function printHelp() {
86
153
  process.stdout.write(
87
- `Usage: node scripts/sweep-expired-learnings.mjs [--prune] [--dry-run|--apply] [--json] [--grace-days N] [--entries PATH] [--file PATH] [--archive PATH]
154
+ `Usage: node scripts/sweep-expired-learnings.mjs [--prune] [--dry-run|--apply] [--json] [--grace-days N] [--snapshot PATH|--entries PATH] [--file PATH] [--archive PATH]
155
+ node scripts/sweep-expired-learnings.mjs --drop-malformed --line N [--line M ...] [--file PATH] [--json] [--apply --generation TOKEN --repo-root PATH [--accept-embedded]]
88
156
 
89
157
  Modes:
90
158
  (default) Expiry sweep — archive entries expired past the grace window
91
159
  --prune Prune + consolidate + rewrite (issue #1017): archives
92
160
  expired / zero-confidence / superseded / caller-dropped
93
161
  records instead of deleting them
162
+ --drop-malformed Remove unparseable store lines (#1500) — never hand-edit
163
+ the store. The dry run (default) checks each --line and
164
+ prints its preview and the store generation; --apply with
165
+ --generation <that token> --repo-root <root> snapshots the
166
+ store beside it, rewrites it without those lines and
167
+ records one ${DROP_MALFORMED_EVENT} event
94
168
 
95
169
  Options:
96
170
  --dry-run Preview counts; write nothing (default)
97
171
  --apply Perform the archive append + store rewrite
98
172
  --json Emit a single machine-parseable JSON summary line
99
173
  --grace-days N Days past expiry before archiving (default: ${DEFAULT_GRACE_DAYS}); sweep only
174
+ --snapshot PATH Prune only. Write the store's records to PATH, headed by a
175
+ ${GENERATION_KEY} line; edit that file into the next
176
+ generation and pass it as --entries. Writes nothing else;
177
+ a PATH that is the --file store or --archive sidecar
178
+ exits 1. Malformed store lines are WARNed and counted,
179
+ never put in the sidecar; the prune keeps them verbatim
100
180
  --entries PATH JSONL sidecar with the next store generation; prune only.
101
- Must exist, parse cleanly, and hold >= 1 record; a NEW
102
- record (id not in the store) must pass strict schema
181
+ Must exist, parse cleanly, start with the --snapshot
182
+ header of the same --file store, and hold >= 1 record; a
183
+ NEW record (id not in the store) must pass strict schema
103
184
  validation, else exit 1 with nothing written
104
185
  --file PATH Learnings store (default: ${DEFAULT_FILE})
105
186
  --archive PATH Archive sidecar (default: ${DEFAULT_ARCHIVE})
187
+ --line N Drop-malformed only. 1-based store line, repeatable; must be
188
+ an unparseable line of the store as read, else exit 1
189
+ --generation TOKEN Drop-malformed only, required with --apply: the dry
190
+ run's generation; a store changed since exits 3
191
+ --accept-embedded Drop-malformed only: apply even to a line that still holds
192
+ a readable record (bom / embedded_record in the dry run)
193
+ --repo-root PATH Drop-malformed --apply: required, --file must lie inside it
106
194
 
107
- Exit codes: 0 success 1 usage/input error (incl. an invalid new --entries record)
195
+ Exit codes: 0 success 1 usage/input error (incl. a --line that is not unparseable,
196
+ an invalid new --entries record,
197
+ a sidecar from another --file store, a --snapshot PATH that
198
+ would replace the store or the archive)
108
199
  2 sweep/prune error
200
+ 3 store-generation-mismatch: the store changed after the --entries
201
+ snapshot or the drop-malformed dry run; nothing written —
202
+ drop: re-run the dry run and apply with its generation;
203
+ prune: re-snapshot into a fresh path, re-apply the edits
204
+ there, re-run with that path
109
205
  `
110
206
  );
111
207
  }
@@ -116,6 +212,16 @@ function usageError(message) {
116
212
  process.exit(1);
117
213
  }
118
214
 
215
+ /**
216
+ * The value of a PATH flag, or a usage error (exit 1) when it is missing. An
217
+ * absent value used to surface later as a lib TypeError — exit 2, which the
218
+ * exit-code contract reserves for a failure inside the sweep/prune itself.
219
+ */
220
+ function pathValue(flag, raw) {
221
+ if (typeof raw !== 'string' || raw.length === 0) usageError(`${flag} requires a PATH`);
222
+ return raw;
223
+ }
224
+
119
225
  function parseArgs(argv) {
120
226
  const args = {
121
227
  prune: false,
@@ -124,6 +230,7 @@ function parseArgs(argv) {
124
230
  graceDays: DEFAULT_GRACE_DAYS,
125
231
  graceDaysExplicit: false,
126
232
  entries: null,
233
+ snapshot: null,
127
234
  file: DEFAULT_FILE,
128
235
  archive: DEFAULT_ARCHIVE,
129
236
  appended: 0,
@@ -140,11 +247,34 @@ function parseArgs(argv) {
140
247
  // fail-closed contract as `emitReconcileCompleted` / `express-path.mjs`.
141
248
  repoRoot: null,
142
249
  telemetryExplicit: false,
250
+ // The four /evolve counters alone — `--repo-root` is telemetry too, but it
251
+ // is also the event destination of `--drop-malformed`.
252
+ countersExplicit: false,
253
+ archiveExplicit: false,
254
+ dropMalformed: false,
255
+ lines: [],
256
+ generation: null,
257
+ acceptEmbedded: false,
143
258
  };
144
259
  for (let i = 0; i < argv.length; i++) {
145
260
  const a = argv[i];
146
261
  if (a === '--prune') {
147
262
  args.prune = true;
263
+ } else if (a === '--drop-malformed') {
264
+ args.dropMalformed = true;
265
+ } else if (a === '--line') {
266
+ const raw = argv[++i];
267
+ const v = Number(raw);
268
+ if (!Number.isInteger(v) || v < 1) {
269
+ usageError(`--line requires a positive integer (a 1-based store line), got: ${raw}`);
270
+ }
271
+ args.lines.push(v);
272
+ } else if (a === '--generation') {
273
+ const raw = argv[++i];
274
+ if (typeof raw !== 'string' || raw.length === 0) usageError('--generation requires a TOKEN');
275
+ args.generation = raw;
276
+ } else if (a === '--accept-embedded') {
277
+ args.acceptEmbedded = true;
148
278
  } else if (a === '--apply') {
149
279
  args.dryRun = false;
150
280
  } else if (a === '--dry-run') {
@@ -160,11 +290,14 @@ function parseArgs(argv) {
160
290
  args.graceDays = v;
161
291
  args.graceDaysExplicit = true;
162
292
  } else if (a === '--entries') {
163
- args.entries = argv[++i];
293
+ args.entries = pathValue(a, argv[++i]);
294
+ } else if (a === '--snapshot') {
295
+ args.snapshot = pathValue(a, argv[++i]);
164
296
  } else if (a === '--file') {
165
- args.file = argv[++i];
297
+ args.file = pathValue(a, argv[++i]);
166
298
  } else if (a === '--archive') {
167
- args.archive = argv[++i];
299
+ args.archive = pathValue(a, argv[++i]);
300
+ args.archiveExplicit = true;
168
301
  } else if (a === '--appended') {
169
302
  const raw = argv[++i];
170
303
  const v = Number(raw);
@@ -173,6 +306,7 @@ function parseArgs(argv) {
173
306
  }
174
307
  args.appended = v;
175
308
  args.telemetryExplicit = true;
309
+ args.countersExplicit = true;
176
310
  } else if (a === '--boosted') {
177
311
  const raw = argv[++i];
178
312
  const v = Number(raw);
@@ -181,6 +315,7 @@ function parseArgs(argv) {
181
315
  }
182
316
  args.boosted = v;
183
317
  args.telemetryExplicit = true;
318
+ args.countersExplicit = true;
184
319
  } else if (a === '--duration-ms') {
185
320
  const raw = argv[++i];
186
321
  const v = Number(raw);
@@ -189,13 +324,15 @@ function parseArgs(argv) {
189
324
  }
190
325
  args.durationMs = v;
191
326
  args.telemetryExplicit = true;
327
+ args.countersExplicit = true;
192
328
  } else if (a === '--skipped') {
193
329
  const raw = argv[++i];
194
330
  args.skipped = typeof raw === 'string' ? raw.split(',').filter((s) => s.length > 0) : [];
195
331
  args.telemetryExplicit = true;
332
+ args.countersExplicit = true;
196
333
  } else if (a === '--repo-root') {
197
334
  // Absolute on purpose: emitEvent refuses a relative root (#1468).
198
- args.repoRoot = path.resolve(argv[++i]);
335
+ args.repoRoot = path.resolve(pathValue(a, argv[++i]));
199
336
  args.telemetryExplicit = true;
200
337
  } else if (a === '--help' || a === '-h') {
201
338
  printHelp();
@@ -209,12 +346,61 @@ function parseArgs(argv) {
209
346
  // that the prune path ignores would read as "the grace window applied" in a
210
347
  // transcript, and an `--entries` the sweep ignores would read as "my next
211
348
  // generation was written".
349
+ if (args.dropMalformed) {
350
+ // Its own mode: nothing of the sweep or the prune applies, and an
351
+ // `--archive` would read as "the dropped line was archived" — it never is.
352
+ if (
353
+ args.prune ||
354
+ args.graceDaysExplicit ||
355
+ args.archiveExplicit ||
356
+ args.countersExplicit ||
357
+ args.entries !== null ||
358
+ args.snapshot !== null
359
+ ) {
360
+ usageError(
361
+ '--drop-malformed takes no --prune, --grace-days, --archive, --entries, --snapshot or ' +
362
+ '/evolve counter flag (a dropped line is never archived)'
363
+ );
364
+ }
365
+ if (args.lines.length === 0) usageError('--drop-malformed requires at least one --line N');
366
+ if (!args.dryRun) {
367
+ if (args.generation === null) {
368
+ usageError(
369
+ '--drop-malformed --apply requires --generation TOKEN — the generation its dry run printed ' +
370
+ '(line numbers move with every rewrite); nothing written'
371
+ );
372
+ }
373
+ if (args.repoRoot === null) {
374
+ usageError(
375
+ '--drop-malformed --apply requires --repo-root PATH — the event recording the drop is ' +
376
+ 'pinned there (#1119: no cwd fallback); nothing written'
377
+ );
378
+ }
379
+ // Canonical, not lexical (#1500 F4): a root spelled through a symlink
380
+ // (macOS /var → /private/var) refused a store inside it. The file goes
381
+ // through resolveLedger (realpath when it exists), because
382
+ // canonicalizeRoot realpaths only the root.
383
+ if (!validatePathInsideProject(resolveLedger(args.file), args.repoRoot, { canonicalizeRoot: true }).ok) {
384
+ usageError(`--file ${args.file} lies outside --repo-root ${args.repoRoot} — nothing written`);
385
+ }
386
+ }
387
+ return args;
388
+ }
389
+ if (args.lines.length > 0 || args.generation !== null || args.acceptEmbedded) {
390
+ usageError('--line/--generation/--accept-embedded are only valid with --drop-malformed');
391
+ }
212
392
  if (args.prune && args.graceDaysExplicit) {
213
393
  usageError('--grace-days is not valid with --prune (the prune path has no grace window)');
214
394
  }
215
395
  if (!args.prune && args.entries !== null) {
216
396
  usageError('--entries is only valid with --prune');
217
397
  }
398
+ if (!args.prune && args.snapshot !== null) {
399
+ usageError('--snapshot is only valid with --prune');
400
+ }
401
+ if (args.snapshot !== null && (args.entries !== null || !args.dryRun || args.telemetryExplicit)) {
402
+ usageError('--snapshot writes only the sidecar — it takes no --entries, --apply, or telemetry flag');
403
+ }
218
404
  if (!args.prune && args.telemetryExplicit) {
219
405
  usageError(
220
406
  '--appended/--boosted/--duration-ms/--skipped/--repo-root are only valid with --prune ' +
@@ -288,8 +474,28 @@ async function runSweep(args) {
288
474
  * explicit `entries: []` written in CODE is a statement, and the lib keeps it
289
475
  * expressible. Only the FILE is ambiguous, so only the file is guarded.
290
476
  *
477
+ * A fourth guard (#1486): line 1 must be the `_store_generation` header that
478
+ * `--snapshot` wrote. The sidecar is derived minutes before the prune, in
479
+ * another process, so a record a peer appends in between is absent from it and
480
+ * would be archived `pruned`; the header lets `pruneLearnings()` refuse under
481
+ * the store lock instead. A headerless sidecar is refused, not trusted: a check
482
+ * that runs only when the token is present is bypassed by every producer that
483
+ * forgets it. The producers are prose, so every one must say `--snapshot`:
484
+ * `skills/evolve/references/evolve-analyze-mode.md` § 3.5, `skills/evolve/SKILL.md`
485
+ * § 4.4, and `.cursor/rules/060-evolve.mdc` (analyze Step 5 + review Step 3),
486
+ * which `scripts/cursor-install.mjs` installs into consumer repos. Census
487
+ * 2026-10-02: `rg --hidden -e --entries` over the tracked tree; plain `rg`
488
+ * skips `.cursor/`, which is how the first census missed the third producer.
489
+ *
490
+ * The header also names the store it was snapshotted from (`_store_path`);
491
+ * `runPrune()` refuses a sidecar applied to another `--file`. A header without
492
+ * that key is the earlier format of this same unreleased flag and is accepted
493
+ * on its token alone.
494
+ *
291
495
  * @param {string} entriesPath
292
- * @returns {Promise<object[]>} the validated, non-empty next generation
496
+ * @returns {Promise<{entries: object[], generation: string, storePath: string|null}>}
497
+ * the validated, non-empty next generation, the store generation it was
498
+ * derived from, and the store it was snapshotted from (null: not recorded)
293
499
  */
294
500
  async function loadEntriesSidecar(entriesPath) {
295
501
  if (!existsSync(entriesPath)) {
@@ -298,12 +504,15 @@ async function loadEntriesSidecar(entriesPath) {
298
504
  `next generation would archive the whole store)`
299
505
  );
300
506
  }
301
- let read;
507
+ let raw;
302
508
  try {
303
- read = await readLearnings(entriesPath);
509
+ raw = await readFile(entriesPath, 'utf8');
304
510
  } catch (err) {
305
511
  usageError(`--entries sidecar unreadable: ${entriesPath}: ${err.message}`);
306
512
  }
513
+ const firstLine = raw.split('\n').find((l) => l.length > 0) ?? '';
514
+ const header = parseHeader(firstLine);
515
+ const read = parseLearningsText(header === null ? raw : raw.slice(raw.indexOf(firstLine) + firstLine.length));
307
516
  if (read.malformed.length > 0) {
308
517
  usageError(
309
518
  `refusing to prune — ${read.malformed.length} malformed line(s) in ${entriesPath}`
@@ -316,7 +525,172 @@ async function loadEntriesSidecar(entriesPath) {
316
525
  `pure prune+consolidate pass)`
317
526
  );
318
527
  }
319
- return read.entries;
528
+ if (header === null) {
529
+ // The recovery names a FRESH path: snapshotting onto the --entries path
530
+ // would overwrite the decisions this run assembled there.
531
+ usageError(
532
+ `--entries sidecar has no "${GENERATION_KEY}" header line: ${entriesPath} (refusing ` +
533
+ `to prune — without it a record appended after the sidecar was derived would be ` +
534
+ `archived as pruned; run --prune --snapshot into a FRESH path, re-apply this run's ` +
535
+ `edits to the records in that file keeping its line 1, and pass it as --entries)`
536
+ );
537
+ }
538
+ return { entries: read.entries, generation: header.generation, storePath: header.storePath };
539
+ }
540
+
541
+ /**
542
+ * The header a sidecar's first line carries, or `null` when that line is not
543
+ * a `{"_store_generation": "<non-empty string>"}` header. `_store_path` may be
544
+ * absent (earlier format, `storePath: null`); present, it must be a non-empty
545
+ * string, else the whole header is refused — a mangled line 1 is not trusted.
546
+ *
547
+ * @param {string} line
548
+ * @returns {{generation: string, storePath: string|null}|null}
549
+ */
550
+ function parseHeader(line) {
551
+ let parsed;
552
+ try {
553
+ parsed = JSON.parse(line);
554
+ } catch {
555
+ return null;
556
+ }
557
+ if (parsed === null || typeof parsed !== 'object') return null;
558
+ const isToken = (v) => typeof v === 'string' && v.length > 0;
559
+ const generation = parsed[GENERATION_KEY];
560
+ const storePath = parsed[STORE_PATH_KEY];
561
+ if (!isToken(generation) || (storePath !== undefined && !isToken(storePath))) return null;
562
+ return { generation, storePath: storePath ?? null };
563
+ }
564
+
565
+ /**
566
+ * A comparable key for the directory entry a write-then-rename onto `p`
567
+ * replaces: the nearest EXISTING ancestor directory by `dev`+`ino` (so a
568
+ * symlinked directory, a firmlink, or a differently-cased spelling of it is
569
+ * the same key), then the rest of the path case-folded. Case-folding the rest
570
+ * is what a case-insensitive volume (default APFS, NTFS) does to the name;
571
+ * on a case-sensitive volume it can only over-match — two names differing in
572
+ * case alone get refused, which costs a re-run with another name. Deliberate
573
+ * ceiling: the fold is `NFC` + `toLowerCase()`, so a name differing only by a
574
+ * fold outside that (`ß`/`SS`) is not caught — revisit if a ledger name ever
575
+ * leaves ASCII.
576
+ *
577
+ * @param {string} p
578
+ * @returns {string}
579
+ */
580
+ function renameTargetKey(p) {
581
+ const abs = path.resolve(p);
582
+ const rest = [path.basename(abs)];
583
+ for (let dir = path.dirname(abs); ; ) {
584
+ try {
585
+ const { dev, ino } = statSync(dir, { bigint: true });
586
+ return `${dev}:${ino}/${rest.join('/').normalize('NFC').toLowerCase()}`;
587
+ } catch {
588
+ const parent = path.dirname(dir);
589
+ if (parent === dir) return abs.normalize('NFC').toLowerCase(); // not even the root stats
590
+ rest.unshift(path.basename(dir));
591
+ dir = parent;
592
+ }
593
+ }
594
+ }
595
+
596
+ /**
597
+ * A ledger path resolved to the file it names: through symlinks when it
598
+ * exists, else absolute as given.
599
+ *
600
+ * @param {string} p
601
+ * @returns {string}
602
+ */
603
+ function resolveLedger(p) {
604
+ try {
605
+ return realpathSync(p);
606
+ } catch {
607
+ return path.resolve(p);
608
+ }
609
+ }
610
+
611
+ /**
612
+ * Exit 1 when `--snapshot` would replace the store or the archive. The rename
613
+ * lands the header as the store's line 1 with no `.bak` (and drops malformed
614
+ * lines), or replaces the append-only archive with the store's records —
615
+ * measured on 91b35d4b, both exit 0. The target is compared by
616
+ * {@link renameTargetKey} against the ledger's own entry and the file it
617
+ * resolves to. A path-string compare (44fc80e4) closed the symlinked-directory
618
+ * spelling but not letter case: on this host's case-insensitive APFS,
619
+ * `--snapshot <metrics>/Learnings.jsonl`, `METRICS/learnings.jsonl` and
620
+ * `LEARNINGS-ARCHIVE.jsonl` each replaced a ledger, exit 0 (measured
621
+ * 2026-10-02). Hard links need no check: rename replaces the directory entry,
622
+ * never the inode behind it.
623
+ *
624
+ * @param {ReturnType<typeof parseArgs>} args
625
+ */
626
+ function refuseLedgerSnapshotTarget(args) {
627
+ const target = renameTargetKey(args.snapshot);
628
+ for (const [label, ledger] of [['store', args.file], ['archive', args.archive]]) {
629
+ if ([renameTargetKey(ledger), renameTargetKey(resolveLedger(ledger))].includes(target)) {
630
+ usageError(
631
+ `--snapshot ${args.snapshot} would replace the learnings ${label} ${ledger} (refusing — ` +
632
+ `nothing written; snapshot into a sidecar such as .orchestrator/tmp/learnings-next.jsonl)`
633
+ );
634
+ }
635
+ }
636
+ }
637
+
638
+ /**
639
+ * `--prune --snapshot PATH` (#1486): write the store's current records to PATH
640
+ * behind a `_store_generation` header, token and records from ONE read. This
641
+ * is the sidecar `/evolve` edits into its next generation; `--entries` later
642
+ * refuses it if the store moved in between. Write-then-rename, so a crash
643
+ * never leaves a header over a truncated record list.
644
+ *
645
+ * A store holding a malformed line is snapshotted with a WARN (its line
646
+ * numbers) and a `malformed` count in the summary. The sidecar holds records
647
+ * only — a malformed line is no record — and the prune applying it keeps the
648
+ * store's malformed lines verbatim at the end of the rewrite, never archived
649
+ * (`pruneLearnings()` reads them under the lock, #1489). Refusing here, as
650
+ * before 26746202 made the rewrite keep them, would block every `/evolve`
651
+ * write on that store with no sanctioned repair path.
652
+ *
653
+ * @param {ReturnType<typeof parseArgs>} args
654
+ */
655
+ async function runSnapshot(args) {
656
+ refuseLedgerSnapshotTarget(args);
657
+ let snap;
658
+ try {
659
+ snap = await readLearningsSnapshot(args.file);
660
+ } catch (err) {
661
+ process.stderr.write(`sweep-expired-learnings: snapshot failed: ${err.message}\n`);
662
+ process.exit(2);
663
+ }
664
+ warnUnparseableLines(args.file, snap.malformed, snap.malformedLineNumbers);
665
+ try {
666
+ const lines = [
667
+ JSON.stringify({ [GENERATION_KEY]: snap.generation, [STORE_PATH_KEY]: resolveLedger(args.file) }),
668
+ ...snap.entries.map((e) => JSON.stringify(e)),
669
+ ];
670
+ await mkdir(path.dirname(args.snapshot), { recursive: true });
671
+ const tmp = `${args.snapshot}.tmp-${process.pid}`;
672
+ await writeFile(tmp, lines.join('\n') + '\n', 'utf8');
673
+ await rename(tmp, args.snapshot);
674
+ } catch (err) {
675
+ process.stderr.write(`sweep-expired-learnings: snapshot failed: ${err.message}\n`);
676
+ process.exit(2);
677
+ }
678
+ const summary = {
679
+ file: args.file,
680
+ snapshot: args.snapshot,
681
+ generation: snap.generation,
682
+ records: snap.entries.length,
683
+ // Present only when non-zero — the convention of the sweep/prune results.
684
+ ...(snap.malformed.length > 0 ? { malformed: snap.malformed.length } : {}),
685
+ };
686
+ if (args.json) {
687
+ process.stdout.write(JSON.stringify(summary) + '\n');
688
+ } else {
689
+ process.stdout.write(
690
+ `sweep-expired-learnings: snapshot records=${summary.records} ` +
691
+ `generation=${summary.generation} -> ${summary.snapshot}\n`
692
+ );
693
+ }
320
694
  }
321
695
 
322
696
  /**
@@ -332,20 +706,29 @@ async function loadEntriesSidecar(entriesPath) {
332
706
  * id-less subset, fine while such records stay rare; revisit if a store holds
333
707
  * hundreds of them). Runs before any write, in dry run and apply alike.
334
708
  *
709
+ * A failure is judged against the store's generation first: a peer that
710
+ * dropped a snapshotted legacy record turns it "new", and strict validation
711
+ * then reported exit 1 ("re-write the sidecar") for what is a stale snapshot —
712
+ * exit 3 (measured on 91b35d4b). The generation decides ONLY on a failure, so
713
+ * a stale sidecar that validates still reaches the authoritative re-read under
714
+ * the lock in `pruneLearnings()`; an unconditional comparison here pre-empted
715
+ * that check and left it untested (disabled, the suite stayed 32/32 green).
716
+ *
335
717
  * @param {object[]} entries - the parsed `--entries` generation
336
- * @param {string} filePath - the learnings store
718
+ * @param {string} expectedGeneration - the sidecar's `_store_generation`
719
+ * @param {ReturnType<typeof parseArgs>} args
337
720
  */
338
- async function rejectInvalidNewRecords(entries, filePath) {
721
+ async function rejectInvalidNewRecords(entries, expectedGeneration, args) {
339
722
  let store;
340
723
  try {
341
- ({ entries: store } = await readLearnings(filePath));
724
+ store = await readLearningsSnapshot(args.file);
342
725
  } catch (err) {
343
- process.stderr.write(`sweep-expired-learnings: prune failed: cannot read ${filePath}: ${err.message}\n`);
726
+ process.stderr.write(`sweep-expired-learnings: prune failed: cannot read ${args.file}: ${err.message}\n`);
344
727
  process.exit(2);
345
728
  }
346
729
  const idOf = (e) => (typeof e?.id === 'string' && e.id.length > 0 ? e.id : null);
347
- const storeIds = new Set(store.map(idOf).filter((id) => id !== null));
348
- const storeIdless = store.filter((e) => idOf(e) === null);
730
+ const storeIds = new Set(store.entries.map(idOf).filter((id) => id !== null));
731
+ const storeIdless = store.entries.filter((e) => idOf(e) === null);
349
732
  for (const entry of entries) {
350
733
  const id = idOf(entry);
351
734
  const known = id !== null ? storeIds.has(id) : storeIdless.some((s) => isDeepStrictEqual(s, entry));
@@ -353,29 +736,69 @@ async function rejectInvalidNewRecords(entries, filePath) {
353
736
  try {
354
737
  validateLearning(entry);
355
738
  } catch (err) {
739
+ if (store.generation !== expectedGeneration) exitGenerationMismatch(args);
356
740
  usageError(`--entries: new record ${id ?? '(no id)'} is invalid: ${err.message} — nothing written`);
357
741
  }
358
742
  }
359
743
  }
360
744
 
745
+ /**
746
+ * Exit 3 for a stale `--entries` sidecar (#1486). The recovery names a FRESH
747
+ * snapshot path and prints no generation token: the current token copied onto
748
+ * this sidecar's line 1 would carry its stale records past the guard.
749
+ *
750
+ * @param {ReturnType<typeof parseArgs>} args
751
+ */
752
+ function exitGenerationMismatch(args) {
753
+ process.stderr.write(
754
+ `sweep-expired-learnings: store-generation-mismatch: ${args.file} changed after ${args.entries} ` +
755
+ `was snapshotted — nothing written. Run --prune --snapshot into a FRESH path, re-apply this ` +
756
+ `run's edits to the records in that file, and pass it as --entries; never copy a newer ` +
757
+ `${GENERATION_KEY} header onto ${args.entries}\n`
758
+ );
759
+ process.exit(3);
760
+ }
761
+
361
762
  /**
362
763
  * Decision-driven prune + consolidate + rewrite (issue #1017).
363
764
  *
364
765
  * @param {ReturnType<typeof parseArgs>} args
365
766
  */
366
767
  async function runPrune(args) {
367
- const entries = args.entries === null ? undefined : await loadEntriesSidecar(args.entries);
368
- if (entries !== undefined) await rejectInvalidNewRecords(entries, args.file);
768
+ if (args.snapshot !== null) {
769
+ await runSnapshot(args);
770
+ return;
771
+ }
772
+ const sidecar = args.entries === null ? undefined : await loadEntriesSidecar(args.entries);
773
+ if (sidecar !== undefined) {
774
+ // Before any generation comparison: a sidecar applied to another store can
775
+ // never match it, so exit 3 sent the documented retry — snapshot without
776
+ // that --file, apply with it — round the loop forever (measured
777
+ // 2026-10-02), its message naming the wrong file.
778
+ if (
779
+ sidecar.storePath !== null &&
780
+ renameTargetKey(resolveLedger(sidecar.storePath)) !== renameTargetKey(resolveLedger(args.file))
781
+ ) {
782
+ usageError(
783
+ `--entries ${args.entries} was snapshotted from ${sidecar.storePath}, not from the --file ` +
784
+ `store ${args.file} (refusing — nothing written; pass the same --file/--archive to ` +
785
+ `--snapshot and to this call)`
786
+ );
787
+ }
788
+ await rejectInvalidNewRecords(sidecar.entries, sidecar.generation, args);
789
+ }
369
790
 
370
791
  let result;
371
792
  try {
372
793
  result = await pruneLearnings({
373
794
  filePath: args.file,
374
795
  archivePath: args.archive,
375
- entries,
796
+ entries: sidecar?.entries,
797
+ expectedGeneration: sidecar?.generation,
376
798
  dryRun: args.dryRun,
377
799
  });
378
800
  } catch (err) {
801
+ if (err instanceof StoreGenerationMismatchError) exitGenerationMismatch(args);
379
802
  process.stderr.write(`sweep-expired-learnings: prune failed: ${err.message}\n`);
380
803
  process.exit(2);
381
804
  }
@@ -421,8 +844,117 @@ async function runPrune(args) {
421
844
  }
422
845
  }
423
846
 
847
+ /**
848
+ * Record one applied `--drop-malformed` in the repo's ledger. Best-effort: the
849
+ * rewrite already happened and is authoritative, so a telemetry failure is
850
+ * reported on stderr and never changes the exit code. No line text goes in —
851
+ * the record travels over the optional webhook unredacted; the snapshot holds it.
852
+ *
853
+ * @param {ReturnType<typeof parseArgs>} args - `repoRoot` set, `file` inside it (parseArgs)
854
+ * @param {Awaited<ReturnType<typeof dropMalformedLines>>} result
855
+ */
856
+ async function emitMalformedDropped(args, result) {
857
+ // Both sides canonical, as the parseArgs check judged them: a lexical
858
+ // relative() across /var vs /private/var reads `../../private/...`.
859
+ const root = resolveLedger(args.repoRoot);
860
+ const rel = (p) => path.relative(root, resolveLedger(p));
861
+ try {
862
+ const { emitEvent, sessionAttribution } = await import('./lib/events.mjs');
863
+ await emitEvent(
864
+ DROP_MALFORMED_EVENT,
865
+ {
866
+ file: rel(args.file),
867
+ dropped: result.dropped.length,
868
+ lines: result.dropped.map((d) => d.line),
869
+ remaining_malformed: result.remainingMalformed,
870
+ record_bearing: result.recordBearing,
871
+ snapshot: rel(result.snapshot),
872
+ source: 'sweep-expired-learnings-cli',
873
+ ...sessionAttribution(args.repoRoot),
874
+ },
875
+ { repoRoot: args.repoRoot },
876
+ );
877
+ } catch (err) {
878
+ process.stderr.write(`sweep-expired-learnings: ${DROP_MALFORMED_EVENT} not recorded: ${err?.message ?? err}\n`);
879
+ }
880
+ }
881
+
882
+ /**
883
+ * `--drop-malformed --line N` (#1500): remove unparseable store lines — the
884
+ * sanctioned repair the WARN and learning-patterns.md §f name. A dry run
885
+ * (default) checks each line and prints its preview plus the store generation;
886
+ * `--apply --generation <that token> --repo-root <root>` re-reads the store
887
+ * under its lock, refuses a changed store (exit 3) or a line that is not
888
+ * unparseable (exit 1), snapshots the store beside it, rewrites it, and
889
+ * records one {@link DROP_MALFORMED_EVENT}.
890
+ *
891
+ * @param {ReturnType<typeof parseArgs>} args
892
+ */
893
+ async function runDropMalformed(args) {
894
+ let result;
895
+ try {
896
+ result = await dropMalformedLines({
897
+ filePath: args.file,
898
+ lines: args.lines,
899
+ expectedGeneration: args.generation ?? undefined,
900
+ acceptEmbedded: args.acceptEmbedded,
901
+ dryRun: args.dryRun,
902
+ });
903
+ } catch (err) {
904
+ if (err instanceof MalformedLineRefusedError) usageError(err.message);
905
+ if (err instanceof StoreGenerationMismatchError) {
906
+ process.stderr.write(
907
+ `sweep-expired-learnings: store-generation-mismatch: ${args.file} changed after the dry run ` +
908
+ `that printed --generation — nothing written. Its line numbers may name other lines now: ` +
909
+ `re-run the dry run, check the previews it prints, and apply with ITS generation\n`
910
+ );
911
+ process.exit(3);
912
+ }
913
+ process.stderr.write(`sweep-expired-learnings: drop-malformed failed: ${err.message}\n`);
914
+ process.exit(2);
915
+ }
916
+
917
+ if (!args.dryRun) await emitMalformedDropped(args, result);
918
+
919
+ const summary = {
920
+ file: args.file,
921
+ generation: result.generation,
922
+ dropped: result.dropped,
923
+ records: result.records,
924
+ remaining_malformed: result.remainingMalformed,
925
+ record_bearing: result.recordBearing,
926
+ dry_run: result.dryRun,
927
+ snapshot: result.snapshot,
928
+ };
929
+ if (args.json) {
930
+ process.stdout.write(JSON.stringify(summary) + '\n');
931
+ return;
932
+ }
933
+ // The flags say what the 80-char preview cannot show (#1500 F1).
934
+ const flags = (d) =>
935
+ (d.bom ? ' [a readable record behind a UTF-8 BOM]' : '') +
936
+ (d.embedded_record
937
+ ? ` [a readable record (id ${d.embedded_record.id ?? 'none'}) at offset ${d.embedded_record.offset}]`
938
+ : '');
939
+ process.stdout.write(
940
+ `sweep-expired-learnings: drop-malformed lines=${JSON.stringify(summary.dropped.map((d) => d.line))} ` +
941
+ `records=${summary.records} remaining_malformed=${summary.remaining_malformed} ` +
942
+ `record_bearing=${summary.record_bearing} dry_run=${summary.dry_run} generation=${summary.generation}` +
943
+ (summary.snapshot ? ` snapshot=${summary.snapshot}` : '') +
944
+ '\n' +
945
+ summary.dropped.map((d) => ` line ${d.line} (${d.chars} chars): ${d.preview}${flags(d)}\n`).join('') +
946
+ (summary.dry_run && summary.record_bearing > 0
947
+ ? ' --apply refuses a line that holds a readable record unless --accept-embedded is given\n'
948
+ : '')
949
+ );
950
+ }
951
+
424
952
  async function main() {
425
953
  const args = parseArgs(process.argv.slice(2));
954
+ if (args.dropMalformed) {
955
+ await runDropMalformed(args);
956
+ return;
957
+ }
426
958
  if (args.prune) {
427
959
  await runPrune(args);
428
960
  return;