session-orchestrator 5.3.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 (260) hide show
  1. package/.agents/skills/claude-md-drift-check/SKILL.md +1 -1
  2. package/.agents/skills/test-audit/SKILL.md +21 -0
  3. package/.agents/skills/test-audit/agents/openai.yaml +5 -0
  4. package/.claude-plugin/marketplace.json +1 -1
  5. package/.claude-plugin/plugin.json +1 -1
  6. package/.codex-plugin/plugin.json +1 -1
  7. package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
  8. package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
  9. package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
  10. package/.cursor/commands/test-audit.md +14 -0
  11. package/.cursor/rules/060-evolve.mdc +28 -13
  12. package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
  13. package/.cursor/skills/test-audit/SKILL.md +12 -0
  14. package/.cursor-plugin/plugin.json +1 -1
  15. package/.orchestrator/policy/blocked-commands.json +1 -1
  16. package/AGENTS.md +9 -31
  17. package/CHANGELOG.md +162 -0
  18. package/README.md +14 -10
  19. package/docs/agent-authoring.md +2 -2
  20. package/docs/baseline.md +10 -1
  21. package/docs/ci-setup.md +154 -4
  22. package/docs/components.md +11 -11
  23. package/docs/events-schema.md +25 -19
  24. package/docs/install.md +14 -2
  25. package/docs/migration-v4.md +24 -17
  26. package/docs/persona-panel.md +5 -4
  27. package/docs/plugin-architecture-v3.md +1 -1
  28. package/docs/rule-authoring.md +27 -12
  29. package/docs/scope-collision-guard.md +40 -16
  30. package/docs/session-config-reference.md +61 -12
  31. package/docs/session-config-template.md +11 -2
  32. package/docs/vault-docs-architecture.md +8 -4
  33. package/hooks/_lib/hook-import-set.json +42 -29
  34. package/hooks/_lib/lock-bootstrap.mjs +118 -26
  35. package/hooks/config-protection.mjs +1 -1
  36. package/hooks/enforce-commands.mjs +28 -13
  37. package/hooks/enforce-scope.mjs +201 -23
  38. package/hooks/hooks-codex.json +1 -1
  39. package/hooks/hooks-cursor.json +5 -0
  40. package/hooks/hooks-pi.json +5 -0
  41. package/hooks/hooks.json +6 -1
  42. package/hooks/on-session-start.mjs +57 -2
  43. package/hooks/on-stop.mjs +70 -168
  44. package/hooks/post-bash-write-verify.mjs +98 -17
  45. package/hooks/post-edit-validate.mjs +15 -5
  46. package/hooks/post-tool-batch-wave-signal.mjs +32 -152
  47. package/hooks/pre-bash-memory-propose-audit.mjs +71 -10
  48. package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
  49. package/hooks/pre-bash-staging-fence.mjs +29 -8
  50. package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
  51. package/hooks/pre-task-scope-disjoint.mjs +1143 -279
  52. package/hooks/subagent-telemetry.mjs +96 -19
  53. package/hooks/wave-scope-commit-guard.mjs +168 -72
  54. package/package.json +2 -6
  55. package/pi/prompts/test-audit.md +12 -0
  56. package/rules/README.md +6 -1
  57. package/rules/_index.md +3 -3
  58. package/rules/always-on/bash-harness-pitfalls.md +7 -1
  59. package/rules/opt-in-domain/prompt-caching.md +5 -1
  60. package/rules/opt-in-stack/backend-data.md +9 -1
  61. package/rules/opt-in-stack/backend.md +7 -1
  62. package/rules/opt-in-stack/frontend.md +6 -1
  63. package/rules/opt-in-stack/security-web.md +7 -1
  64. package/rules/opt-in-stack/swift.md +5 -1
  65. package/scripts/apply-session-learnings.mjs +513 -0
  66. package/scripts/archive-closed-prds.mjs +40 -12
  67. package/scripts/autopilot.mjs +57 -54
  68. package/scripts/backfill-abandoned-sessions.mjs +81 -6
  69. package/scripts/backfill-learnings-from-vault.mjs +49 -22
  70. package/scripts/baseline-archetypes.mjs +1 -1
  71. package/scripts/check-package-manager.mjs +62 -5
  72. package/scripts/codex-install.mjs +3 -0
  73. package/scripts/cursor-install.mjs +47 -6
  74. package/scripts/emit-session.mjs +298 -2
  75. package/scripts/export-hw-learnings.mjs +40 -10
  76. package/scripts/fleet-checkin.mjs +170 -0
  77. package/scripts/lib/agent-frontmatter.mjs +3 -3
  78. package/scripts/lib/auto-dream.mjs +7 -6
  79. package/scripts/lib/autopilot/flags.mjs +6 -1
  80. package/scripts/lib/autopilot/loop.mjs +32 -13
  81. package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
  82. package/scripts/lib/baseline-archetypes.mjs +41 -22
  83. package/scripts/lib/blocked-commands-policy.mjs +40 -0
  84. package/scripts/lib/common.mjs +41 -0
  85. package/scripts/lib/config/host-paths.mjs +79 -0
  86. package/scripts/lib/config/persona-gate-wave.mjs +3 -3
  87. package/scripts/lib/config/reaper.mjs +7 -6
  88. package/scripts/lib/config/vault-integration.mjs +151 -16
  89. package/scripts/lib/config-schema.mjs +13 -0
  90. package/scripts/lib/config.mjs +75 -10
  91. package/scripts/lib/eval/engine.mjs +13 -0
  92. package/scripts/lib/events-retention-banner.mjs +393 -0
  93. package/scripts/lib/events-rotation.mjs +21 -6
  94. package/scripts/lib/events.mjs +72 -5
  95. package/scripts/lib/fleet-protocol.mjs +288 -0
  96. package/scripts/lib/gates/gate-baseline.mjs +13 -3
  97. package/scripts/lib/gates/gate-full.mjs +7 -2
  98. package/scripts/lib/gates/gate-helpers.mjs +93 -2
  99. package/scripts/lib/gates/gate-incremental.mjs +23 -22
  100. package/scripts/lib/gates/gate-per-file.mjs +16 -7
  101. package/scripts/lib/hardening.mjs +1 -0
  102. package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
  103. package/scripts/lib/instruction-budget-guard.mjs +77 -11
  104. package/scripts/lib/learnings/expiry-sweep.mjs +420 -22
  105. package/scripts/lib/learnings/io.mjs +274 -37
  106. package/scripts/lib/learnings/schema.mjs +163 -15
  107. package/scripts/lib/learnings/sizing-subject.mjs +2 -2
  108. package/scripts/lib/lock-reaper.mjs +38 -14
  109. package/scripts/lib/maintenance-due-banner.mjs +23 -8
  110. package/scripts/lib/memory-proposals/schema.mjs +11 -5
  111. package/scripts/lib/named-baseline-resolver.mjs +53 -28
  112. package/scripts/lib/named-vault-resolver.mjs +147 -5
  113. package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
  114. package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
  115. package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
  116. package/scripts/lib/orphan-reaper/reaper-audit.mjs +323 -0
  117. package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
  118. package/scripts/lib/orphan-reaper/scan-throttle.mjs +144 -0
  119. package/scripts/lib/orphan-reaper/scan.mjs +510 -0
  120. package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
  121. package/scripts/lib/orphan-reaper.mjs +47 -1315
  122. package/scripts/lib/owner-yaml.mjs +104 -6
  123. package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
  124. package/scripts/lib/platform.mjs +206 -2
  125. package/scripts/lib/plugin-update-banner.mjs +3 -1
  126. package/scripts/lib/process-group.mjs +134 -22
  127. package/scripts/lib/project-hygiene.mjs +21 -1
  128. package/scripts/lib/quality-gate.mjs +45 -5
  129. package/scripts/lib/resource-probe.mjs +1 -1
  130. package/scripts/lib/rule-loader.mjs +100 -62
  131. package/scripts/lib/rules-sync.mjs +27 -12
  132. package/scripts/lib/scope-baseline.mjs +119 -29
  133. package/scripts/lib/scope-echo.mjs +7 -0
  134. package/scripts/lib/scope-gate.mjs +194 -27
  135. package/scripts/lib/session-close-backfill.mjs +129 -4
  136. package/scripts/lib/session-end/tail-runner.mjs +10 -5
  137. package/scripts/lib/session-lock-shape.mjs +6 -3
  138. package/scripts/lib/session-lock.mjs +320 -67
  139. package/scripts/lib/session-record-repair.mjs +183 -26
  140. package/scripts/lib/session-registry.mjs +67 -17
  141. package/scripts/lib/session-schema/constants.mjs +30 -4
  142. package/scripts/lib/session-schema/validator.mjs +28 -1
  143. package/scripts/lib/session-start-probes.mjs +242 -68
  144. package/scripts/lib/session-token-rollup.mjs +252 -54
  145. package/scripts/lib/sessions-canonical.mjs +100 -9
  146. package/scripts/lib/sessions-staleness-banner.mjs +3 -1
  147. package/scripts/lib/sh-quote.mjs +29 -0
  148. package/scripts/lib/subagents-schema.mjs +30 -3
  149. package/scripts/lib/tail-window.mjs +27 -3
  150. package/scripts/lib/telemetry/pricing.mjs +121 -71
  151. package/scripts/lib/telemetry/sync.mjs +17 -0
  152. package/scripts/lib/tests-src-ratio.mjs +215 -23
  153. package/scripts/lib/tmux-layout/layouts.mjs +1 -11
  154. package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
  155. package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
  156. package/scripts/lib/ux-grill/reconcile.mjs +1 -1
  157. package/scripts/lib/validate/check-agents.mjs +1 -1
  158. package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
  159. package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
  160. package/scripts/lib/validate/check-test-value-bans.mjs +388 -27
  161. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  162. package/scripts/lib/validate/check-unwired-features.mjs +1 -1
  163. package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
  164. package/scripts/lib/validate-vendored-rules.mjs +206 -81
  165. package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
  166. package/scripts/lib/vault-mirror/namespace.mjs +47 -26
  167. package/scripts/lib/vault-mirror/process.mjs +350 -62
  168. package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
  169. package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
  170. package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
  171. package/scripts/lib/vault-mirror/telemetry.mjs +25 -11
  172. package/scripts/lib/vault-status/board-writer.mjs +53 -12
  173. package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
  174. package/scripts/lib/vcs-repo-spec.mjs +8 -0
  175. package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
  176. package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
  177. package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
  178. package/scripts/lib/wave-resource-gate.mjs +34 -8
  179. package/scripts/materialize-wave-scope.mjs +7 -4
  180. package/scripts/memory-propose.mjs +18 -5
  181. package/scripts/release-session-lock.mjs +2 -1
  182. package/scripts/release.mjs +11 -3
  183. package/scripts/repair-invalid-sessions.mjs +37 -4
  184. package/scripts/run-migrate-v2-cross-repo.mjs +171 -31
  185. package/scripts/run-quality-gate.mjs +331 -307
  186. package/scripts/self-update.mjs +229 -0
  187. package/scripts/site-numbers.mjs +163 -5
  188. package/scripts/sweep-expired-learnings.mjs +603 -20
  189. package/scripts/sweep-expired-rules.mjs +2 -1
  190. package/scripts/validate-plugin.mjs +7 -0
  191. package/scripts/validate-wave-scope.mjs +21 -2
  192. package/scripts/vault-backfill.mjs +50 -37
  193. package/scripts/vault-integration-watcher.mjs +2 -1
  194. package/scripts/vault-mirror.mjs +238 -75
  195. package/scripts/wave-scope-binding.mjs +2 -1
  196. package/skills/_shared/config-reading.md +3 -3
  197. package/skills/_shared/fleet-protocol.md +134 -0
  198. package/skills/_shared/state-ownership.md +41 -12
  199. package/skills/autopilot/SKILL.md +25 -11
  200. package/skills/bootstrap/SKILL.md +6 -4
  201. package/skills/bootstrap/_shared-template.md +97 -2
  202. package/skills/bootstrap/fast-template.md +4 -1
  203. package/skills/bootstrap/private-contract.md +2 -1
  204. package/skills/bootstrap/public-fallback.md +6 -3
  205. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
  206. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
  207. package/skills/bootstrap/standard-template.md +2 -0
  208. package/skills/claude-md-drift-check/SKILL.md +10 -10
  209. package/skills/claude-md-drift-check/checker.mjs +26 -18
  210. package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
  211. package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
  212. package/skills/discovery/probes/vault-staleness.mjs +9 -3
  213. package/skills/docs-orchestrator/SKILL.md +12 -5
  214. package/skills/docs-orchestrator/audience-mapping.md +5 -3
  215. package/skills/evolve/SKILL.md +13 -4
  216. package/skills/evolve/references/evolve-analyze-mode.md +42 -18
  217. package/skills/memory-cleanup/SKILL.md +1 -1
  218. package/skills/persona-panel/SKILL.md +2 -2
  219. package/skills/persona-panel/persona-format.md +2 -2
  220. package/skills/persona-panel/presets/designer-lens.md +1 -1
  221. package/skills/persona-panel/presets/engineer-lens.md +1 -1
  222. package/skills/persona-panel/presets/pm-lens.md +1 -1
  223. package/skills/plan/mode-retro.md +19 -4
  224. package/skills/quality-gates/SKILL.md +6 -4
  225. package/skills/remote-offload/SKILL.md +4 -2
  226. package/skills/session-end/learning-patterns.md +23 -13
  227. package/skills/session-end/metrics-collection.md +6 -7
  228. package/skills/session-end/phase-3-2-docs-verification.md +7 -6
  229. package/skills/session-end/plan-verification.md +25 -9
  230. package/skills/session-end/references/phase-3-documentation-updates.md +13 -3
  231. package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
  232. package/skills/session-end/references/session-summary-template.md +1 -1
  233. package/skills/session-end/session-metrics-write.md +65 -68
  234. package/skills/session-plan/SKILL.md +0 -3
  235. package/skills/session-start/SKILL.md +8 -2
  236. package/skills/session-start/references/operations-contract.md +6 -0
  237. package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
  238. package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
  239. package/skills/session-start/references/phase-4-ssot-environment-check.md +10 -3
  240. package/skills/test-audit/SKILL.md +152 -0
  241. package/skills/test-audit/references/campaign.md +111 -0
  242. package/skills/test-audit/references/mutate.sh +50 -0
  243. package/skills/vault-mirror/SKILL.md +21 -11
  244. package/skills/wave-executor/SKILL.md +2 -2
  245. package/skills/wave-executor/circuit-breaker.md +1 -1
  246. package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
  247. package/skills/wave-executor/references/wave-loop-dispatch.md +11 -1
  248. package/skills/wave-executor/references/wave-loop-review.md +4 -6
  249. package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
  250. package/templates/personas/README.md +5 -5
  251. package/templates/personas/accounting-compliance.v1.md +1 -1
  252. package/templates/personas/accounting-tax-advisor.v1.md +1 -1
  253. package/templates/personas/buyer-p1-cto.v1.md +1 -1
  254. package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
  255. package/templates/personas/buyer-p3-build.v1.md +1 -1
  256. package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
  257. package/templates/personas/buyer-p5-solo.v1.md +1 -1
  258. package/templates/personas/buyer-p6-ld.v1.md +1 -1
  259. package/templates/personas/klima-ai-expert.v1.md +1 -1
  260. package/templates/personas/klima-physicist.v1.md +1 -1
@@ -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,29 @@
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).
59
+ * A NEW record (its `id` not in the store) must pass strict
60
+ * `validateLearning()`, else exit 1 untouched (GH#69);
61
+ * records already in the store keep the tolerant path.
40
62
  * Omitted ⇒ a pure prune+consolidate pass over the on-disk
41
63
  * store.
42
64
  * --file PATH Learnings store (default: .orchestrator/metrics/learnings.jsonl)
@@ -52,7 +74,28 @@
52
74
  * --skipped a,b PRUNE ONLY (#1206). Comma-separated list of optional
53
75
  * steps that ran but were themselves skipped this run
54
76
  * (HR-105 — e.g. `skill-evolution-off`). Default none.
55
- * --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
56
99
  * `orchestrator.evolve.completed` record is pinned to.
57
100
  * No default and NO process.cwd() fallback (#1119) — when
58
101
  * omitted, no event is emitted at all (stderr WARN, exit
@@ -61,41 +104,104 @@
61
104
  *
62
105
  * Exit codes:
63
106
  * 0 Success (including no-op when nothing is archive-eligible)
64
- * 1 Usage/input error (bad flag/value, flag used in the wrong mode, or an
65
- * absent/malformed/empty `--entries` sidecar)
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
110
+ * absent/malformed/empty `--entries` sidecar, or a NEW `--entries` record
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)
66
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.
67
123
  */
68
124
 
69
- import { existsSync } from 'node:fs';
70
- import { sweepExpiredLearnings, pruneLearnings } from './lib/learnings/expiry-sweep.mjs';
71
- import { readLearnings } from './lib/learnings/io.mjs';
125
+ import { existsSync, realpathSync, statSync } from 'node:fs';
126
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
127
+ import path from 'node:path';
128
+ import { isDeepStrictEqual } from 'node:util';
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';
138
+ import { validateLearning } from './lib/learnings/schema.mjs';
139
+ import { validatePathInsideProject } from './lib/path-utils.mjs';
72
140
  import { emitEvolveCompleted } from './lib/learnings/evolve-telemetry.mjs';
73
141
 
74
142
  const DEFAULT_FILE = '.orchestrator/metrics/learnings.jsonl';
75
143
  const DEFAULT_ARCHIVE = '.orchestrator/metrics/learnings-archive.jsonl';
76
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';
77
151
 
78
152
  function printHelp() {
79
153
  process.stdout.write(
80
- `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]]
81
156
 
82
157
  Modes:
83
158
  (default) Expiry sweep — archive entries expired past the grace window
84
159
  --prune Prune + consolidate + rewrite (issue #1017): archives
85
160
  expired / zero-confidence / superseded / caller-dropped
86
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
87
168
 
88
169
  Options:
89
170
  --dry-run Preview counts; write nothing (default)
90
171
  --apply Perform the archive append + store rewrite
91
172
  --json Emit a single machine-parseable JSON summary line
92
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
93
180
  --entries PATH JSONL sidecar with the next store generation; prune only.
94
- Must exist, parse cleanly, and hold >= 1 record
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
184
+ validation, else exit 1 with nothing written
95
185
  --file PATH Learnings store (default: ${DEFAULT_FILE})
96
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
97
194
 
98
- Exit codes: 0 success 1 usage/input error 2 sweep/prune error
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)
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
99
205
  `
100
206
  );
101
207
  }
@@ -106,6 +212,16 @@ function usageError(message) {
106
212
  process.exit(1);
107
213
  }
108
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
+
109
225
  function parseArgs(argv) {
110
226
  const args = {
111
227
  prune: false,
@@ -114,6 +230,7 @@ function parseArgs(argv) {
114
230
  graceDays: DEFAULT_GRACE_DAYS,
115
231
  graceDaysExplicit: false,
116
232
  entries: null,
233
+ snapshot: null,
117
234
  file: DEFAULT_FILE,
118
235
  archive: DEFAULT_ARCHIVE,
119
236
  appended: 0,
@@ -130,11 +247,34 @@ function parseArgs(argv) {
130
247
  // fail-closed contract as `emitReconcileCompleted` / `express-path.mjs`.
131
248
  repoRoot: null,
132
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,
133
258
  };
134
259
  for (let i = 0; i < argv.length; i++) {
135
260
  const a = argv[i];
136
261
  if (a === '--prune') {
137
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;
138
278
  } else if (a === '--apply') {
139
279
  args.dryRun = false;
140
280
  } else if (a === '--dry-run') {
@@ -150,11 +290,14 @@ function parseArgs(argv) {
150
290
  args.graceDays = v;
151
291
  args.graceDaysExplicit = true;
152
292
  } else if (a === '--entries') {
153
- args.entries = argv[++i];
293
+ args.entries = pathValue(a, argv[++i]);
294
+ } else if (a === '--snapshot') {
295
+ args.snapshot = pathValue(a, argv[++i]);
154
296
  } else if (a === '--file') {
155
- args.file = argv[++i];
297
+ args.file = pathValue(a, argv[++i]);
156
298
  } else if (a === '--archive') {
157
- args.archive = argv[++i];
299
+ args.archive = pathValue(a, argv[++i]);
300
+ args.archiveExplicit = true;
158
301
  } else if (a === '--appended') {
159
302
  const raw = argv[++i];
160
303
  const v = Number(raw);
@@ -163,6 +306,7 @@ function parseArgs(argv) {
163
306
  }
164
307
  args.appended = v;
165
308
  args.telemetryExplicit = true;
309
+ args.countersExplicit = true;
166
310
  } else if (a === '--boosted') {
167
311
  const raw = argv[++i];
168
312
  const v = Number(raw);
@@ -171,6 +315,7 @@ function parseArgs(argv) {
171
315
  }
172
316
  args.boosted = v;
173
317
  args.telemetryExplicit = true;
318
+ args.countersExplicit = true;
174
319
  } else if (a === '--duration-ms') {
175
320
  const raw = argv[++i];
176
321
  const v = Number(raw);
@@ -179,12 +324,15 @@ function parseArgs(argv) {
179
324
  }
180
325
  args.durationMs = v;
181
326
  args.telemetryExplicit = true;
327
+ args.countersExplicit = true;
182
328
  } else if (a === '--skipped') {
183
329
  const raw = argv[++i];
184
330
  args.skipped = typeof raw === 'string' ? raw.split(',').filter((s) => s.length > 0) : [];
185
331
  args.telemetryExplicit = true;
332
+ args.countersExplicit = true;
186
333
  } else if (a === '--repo-root') {
187
- args.repoRoot = argv[++i];
334
+ // Absolute on purpose: emitEvent refuses a relative root (#1468).
335
+ args.repoRoot = path.resolve(pathValue(a, argv[++i]));
188
336
  args.telemetryExplicit = true;
189
337
  } else if (a === '--help' || a === '-h') {
190
338
  printHelp();
@@ -198,12 +346,61 @@ function parseArgs(argv) {
198
346
  // that the prune path ignores would read as "the grace window applied" in a
199
347
  // transcript, and an `--entries` the sweep ignores would read as "my next
200
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
+ }
201
392
  if (args.prune && args.graceDaysExplicit) {
202
393
  usageError('--grace-days is not valid with --prune (the prune path has no grace window)');
203
394
  }
204
395
  if (!args.prune && args.entries !== null) {
205
396
  usageError('--entries is only valid with --prune');
206
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
+ }
207
404
  if (!args.prune && args.telemetryExplicit) {
208
405
  usageError(
209
406
  '--appended/--boosted/--duration-ms/--skipped/--repo-root are only valid with --prune ' +
@@ -277,8 +474,28 @@ async function runSweep(args) {
277
474
  * explicit `entries: []` written in CODE is a statement, and the lib keeps it
278
475
  * expressible. Only the FILE is ambiguous, so only the file is guarded.
279
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
+ *
280
495
  * @param {string} entriesPath
281
- * @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)
282
499
  */
283
500
  async function loadEntriesSidecar(entriesPath) {
284
501
  if (!existsSync(entriesPath)) {
@@ -287,12 +504,15 @@ async function loadEntriesSidecar(entriesPath) {
287
504
  `next generation would archive the whole store)`
288
505
  );
289
506
  }
290
- let read;
507
+ let raw;
291
508
  try {
292
- read = await readLearnings(entriesPath);
509
+ raw = await readFile(entriesPath, 'utf8');
293
510
  } catch (err) {
294
511
  usageError(`--entries sidecar unreadable: ${entriesPath}: ${err.message}`);
295
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));
296
516
  if (read.malformed.length > 0) {
297
517
  usageError(
298
518
  `refusing to prune — ${read.malformed.length} malformed line(s) in ${entriesPath}`
@@ -305,7 +525,238 @@ async function loadEntriesSidecar(entriesPath) {
305
525
  `pure prune+consolidate pass)`
306
526
  );
307
527
  }
308
- 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
+ }
694
+ }
695
+
696
+ /**
697
+ * Strict-validate every `--entries` record the store does not already hold.
698
+ *
699
+ * `pruneLearnings()` rewrites through `rewriteLearnings()`, which runs
700
+ * `legacyTolerant` so records ALREADY in the store (legacy shapes, EventDrop
701
+ * #386) survive a round-trip. Without this gate the same tolerance admitted
702
+ * NEW records too — a sidecar record with `scope: "src/app/x.tsx"` and no
703
+ * `created_at`/`expires_at` was written (GH#69). New = its `id` is absent from
704
+ * the store; an id-less record counts as known only when an id-less store
705
+ * record is deep-equal to it (the carry-through case — linear scan over the
706
+ * id-less subset, fine while such records stay rare; revisit if a store holds
707
+ * hundreds of them). Runs before any write, in dry run and apply alike.
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
+ *
717
+ * @param {object[]} entries - the parsed `--entries` generation
718
+ * @param {string} expectedGeneration - the sidecar's `_store_generation`
719
+ * @param {ReturnType<typeof parseArgs>} args
720
+ */
721
+ async function rejectInvalidNewRecords(entries, expectedGeneration, args) {
722
+ let store;
723
+ try {
724
+ store = await readLearningsSnapshot(args.file);
725
+ } catch (err) {
726
+ process.stderr.write(`sweep-expired-learnings: prune failed: cannot read ${args.file}: ${err.message}\n`);
727
+ process.exit(2);
728
+ }
729
+ const idOf = (e) => (typeof e?.id === 'string' && e.id.length > 0 ? e.id : null);
730
+ const storeIds = new Set(store.entries.map(idOf).filter((id) => id !== null));
731
+ const storeIdless = store.entries.filter((e) => idOf(e) === null);
732
+ for (const entry of entries) {
733
+ const id = idOf(entry);
734
+ const known = id !== null ? storeIds.has(id) : storeIdless.some((s) => isDeepStrictEqual(s, entry));
735
+ if (known) continue;
736
+ try {
737
+ validateLearning(entry);
738
+ } catch (err) {
739
+ if (store.generation !== expectedGeneration) exitGenerationMismatch(args);
740
+ usageError(`--entries: new record ${id ?? '(no id)'} is invalid: ${err.message} — nothing written`);
741
+ }
742
+ }
743
+ }
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);
309
760
  }
310
761
 
311
762
  /**
@@ -314,17 +765,40 @@ async function loadEntriesSidecar(entriesPath) {
314
765
  * @param {ReturnType<typeof parseArgs>} args
315
766
  */
316
767
  async function runPrune(args) {
317
- const entries = args.entries === null ? undefined : await loadEntriesSidecar(args.entries);
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
+ }
318
790
 
319
791
  let result;
320
792
  try {
321
793
  result = await pruneLearnings({
322
794
  filePath: args.file,
323
795
  archivePath: args.archive,
324
- entries,
796
+ entries: sidecar?.entries,
797
+ expectedGeneration: sidecar?.generation,
325
798
  dryRun: args.dryRun,
326
799
  });
327
800
  } catch (err) {
801
+ if (err instanceof StoreGenerationMismatchError) exitGenerationMismatch(args);
328
802
  process.stderr.write(`sweep-expired-learnings: prune failed: ${err.message}\n`);
329
803
  process.exit(2);
330
804
  }
@@ -370,8 +844,117 @@ async function runPrune(args) {
370
844
  }
371
845
  }
372
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
+
373
952
  async function main() {
374
953
  const args = parseArgs(process.argv.slice(2));
954
+ if (args.dropMalformed) {
955
+ await runDropMalformed(args);
956
+ return;
957
+ }
375
958
  if (args.prune) {
376
959
  await runPrune(args);
377
960
  return;