session-orchestrator 3.19.0 → 3.20.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 (66) 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/CHANGELOG.md +80 -0
  5. package/README.md +9 -9
  6. package/commands/session.md +6 -2
  7. package/docs/USER-GUIDE.md +1 -1
  8. package/docs/instruction-delivery.md +350 -0
  9. package/docs/session-config-reference.md +1 -41
  10. package/docs/session-config-template.md +0 -23
  11. package/hooks/_lib/guard-source-loader.mjs +304 -91
  12. package/hooks/enforce-commands.mjs +216 -17
  13. package/hooks/enforce-scope.mjs +133 -9
  14. package/hooks/hooks-codex.json +1 -1
  15. package/hooks/hooks.json +1 -1
  16. package/hooks/on-session-start.mjs +7 -4
  17. package/hooks/pre-bash-destructive-guard.mjs +146 -59
  18. package/hooks/pre-bash-sessions-ledger-guard.mjs +493 -66
  19. package/package.json +2 -2
  20. package/scripts/backfill-learnings-from-vault.mjs +967 -0
  21. package/scripts/emit-session.mjs +3 -40
  22. package/scripts/lib/command-blocker.mjs +322 -62
  23. package/scripts/lib/hardening.mjs +9 -9
  24. package/scripts/lib/learnings/affinity.mjs +434 -0
  25. package/scripts/lib/learnings/candidates.mjs +736 -0
  26. package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
  27. package/scripts/lib/learnings/judgment.mjs +782 -0
  28. package/scripts/lib/learnings/kebab.mjs +128 -0
  29. package/scripts/lib/learnings/select.mjs +550 -0
  30. package/scripts/lib/reconcile/emitter.mjs +107 -22
  31. package/scripts/lib/reconcile/engine.mjs +9 -15
  32. package/scripts/lib/reconcile/renderer.mjs +141 -25
  33. package/scripts/lib/reconcile/sanitize.mjs +518 -0
  34. package/scripts/lib/reconcile/writer.mjs +95 -1
  35. package/scripts/lib/scope-gate.mjs +194 -72
  36. package/scripts/lib/session-close-backfill.mjs +2 -2
  37. package/scripts/lib/session-record-repair.mjs +551 -0
  38. package/scripts/lib/session-schema/serializer.mjs +54 -0
  39. package/scripts/lib/session-schema.mjs +1 -0
  40. package/scripts/lib/session-token-rollup.mjs +68 -6
  41. package/scripts/lib/soul-resolve.mjs +12 -0
  42. package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
  43. package/scripts/lib/validate/check-banner-parity.mjs +376 -0
  44. package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
  45. package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
  46. package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
  47. package/scripts/lib/validate/check-rules.mjs +31 -5
  48. package/scripts/lib/validate/check-unwired-features.mjs +549 -0
  49. package/scripts/print-applicable-rules.mjs +170 -7
  50. package/scripts/print-learnings-index.mjs +474 -0
  51. package/scripts/repair-invalid-sessions.mjs +209 -0
  52. package/scripts/sweep-expired-learnings.mjs +192 -32
  53. package/scripts/validate-plugin.mjs +21 -0
  54. package/skills/brainstorm/soul.md +47 -1
  55. package/skills/evolve/SKILL.md +116 -18
  56. package/skills/gitlab-ops/SKILL.md +5 -0
  57. package/skills/grill/soul.md +44 -1
  58. package/skills/plan/soul.md +46 -3
  59. package/skills/session-end/SKILL.md +1 -24
  60. package/skills/session-end/phase-3-6-tail.md +30 -1
  61. package/skills/session-end/plan-verification.md +1 -5
  62. package/skills/session-end/session-metrics-write.md +2 -0
  63. package/skills/session-start/SKILL.md +2 -0
  64. package/skills/session-start/soul.md +41 -1
  65. package/skills/wave-executor/SKILL.md +1 -5
  66. package/skills/wave-executor/wave-loop.md +36 -71
@@ -1,15 +1,29 @@
1
1
  /**
2
- * learnings/expiry-sweep.mjs — mechanical expiry/archive sweep for learnings.jsonl.
3
- *
4
- * Epic #723 B4. Fleet audit found expired-resident learnings accumulating in
5
- * 6+ repos (Vault: 70% expired) because nothing MECHANICALLY moves expired
6
- * entries out of the active store `memory-cleanup-soft-limit` and the
7
- * `evolve` confidence-decay pass both operate on the live store but never
8
- * relocate anything. This module is the missing mechanical sweep: it
9
- * partitions `learnings.jsonl` into KEEP (still active, or too-recently
10
- * expired to move yet) and ARCHIVE (expired past the grace window), appends
11
- * the archive candidates to an append-only sidecar, then rewrites the store
12
- * with only the KEEP set.
2
+ * learnings/expiry-sweep.mjs — mechanical archive-safe writers for learnings.jsonl.
3
+ *
4
+ * Epic #723 B4 (`sweepExpiredLearnings`) + issue #1017 (`pruneLearnings`).
5
+ *
6
+ * Fleet audit found expired-resident learnings accumulating in 6+ repos
7
+ * (Vault: 70% expired) because nothing MECHANICALLY moves expired entries out
8
+ * of the active store `memory-cleanup-soft-limit` and the `evolve`
9
+ * confidence-decay pass both operate on the live store but never relocate
10
+ * anything. This module is the missing mechanical sweep: it partitions
11
+ * `learnings.jsonl` into KEEP (still active, or too-recently expired to move
12
+ * yet) and ARCHIVE (expired past the grace window), appends the archive
13
+ * candidates to an append-only sidecar, then rewrites the store with only the
14
+ * KEEP set.
15
+ *
16
+ * #1017 — the SAME pipeline, one predicate apart. `/evolve`'s prune step
17
+ * ("remove entries where `expires_at` < now OR `confidence` <= 0", then
18
+ * consolidate duplicates, then rewrite the store) was specified as coordinator
19
+ * PROSE doing a read-modify-`>`-rewrite by hand, with no archive append at
20
+ * all. Measured consequence on the live corpus: 11 of 13 `learning-id`
21
+ * provenance pointers in rendered `.claude/rules/*.md` resolved to NOTHING —
22
+ * the ids were in neither the store, the archive, nor any `.bak-*` snapshot
23
+ * (85% dead pointers). {@link pruneLearnings} is that path routed through this
24
+ * file's pipeline instead: the two callers differ ONLY in how they partition
25
+ * (time-driven vs decision-driven); the crash-safe ordering, the KEEP-batch
26
+ * probe, and the `.bak` snapshot are one shared code path, never two.
13
27
  *
14
28
  * Design constraints (deliberate, do not "simplify" away):
15
29
  * - NEVER deletes data. Archive is append-only; the store rewrite is the
@@ -50,9 +64,206 @@ import { mkdir, appendFile } from 'node:fs/promises';
50
64
  import path from 'node:path';
51
65
  import { readLearnings, rewriteLearnings } from './io.mjs';
52
66
 
67
+ /**
68
+ * Composite-key separator for the in-memory consolidation Map.
69
+ *
70
+ * Built via `String.fromCharCode(0)` rather than a literal NUL byte in this file.
71
+ * A single NUL makes a tracked text file classify as BINARY, and grep/ugrep then
72
+ * skip it SILENTLY — exit 1, no output, no warning. That removed an entire deny
73
+ * path from a security census once (see the recorded anti-pattern rule on NUL
74
+ * bytes and grep-based audits). The separator byte is unchanged at runtime.
75
+ */
76
+ const KEY_SEP = String.fromCharCode(0);
77
+
53
78
  const MS_PER_DAY = 86_400_000;
54
79
  const DEFAULT_GRACE_DAYS = 14;
55
80
 
81
+ /**
82
+ * Canonical, key-order-independent JSON for a record — the identity of LAST
83
+ * RESORT, used only when a record carries no usable `id`.
84
+ *
85
+ * `JSON.stringify` is not sufficient here: two reads of the same record can
86
+ * differ in key ORDER (the store and the `--entries` sidecar are separate
87
+ * files, written by separate passes), and an order-sensitive fingerprint would
88
+ * read those as two different records.
89
+ *
90
+ * @param {unknown} value
91
+ * @returns {string}
92
+ */
93
+ function stableStringify(value) {
94
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
95
+ if (value !== null && typeof value === 'object') {
96
+ return `{${Object.keys(value)
97
+ .sort()
98
+ .map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`)
99
+ .join(',')}}`;
100
+ }
101
+ // `undefined` has no JSON form — normalize it to the same token as null so a
102
+ // present-but-undefined key cannot make a record unfingerprintable.
103
+ return JSON.stringify(value) ?? 'null';
104
+ }
105
+
106
+ /**
107
+ * Reconciliation identity for {@link pruneLearnings} step (3): the `id` when
108
+ * the record carries a usable one, else a content fingerprint.
109
+ *
110
+ * The fallback is load-bearing, not a nicety. `id` IS a required schema field,
111
+ * but `validateLearning` only checks key PRESENCE — `id: ''`, `id: null` and
112
+ * `id: undefined` all pass it, and `readLearnings()` never rejects anything —
113
+ * so an id-less record can and does reach disk. Such a record can never appear
114
+ * in `keep` (which is built from `next` alone), so leaving it unreconciled
115
+ * meant the store rewrite dropped it with no archive line: gone from BOTH
116
+ * places, which is the exact #1017 data loss this module exists to prevent.
117
+ *
118
+ * @param {object} entry
119
+ * @returns {string}
120
+ */
121
+ function reconcileKey(entry) {
122
+ const id = entry?.id;
123
+ return typeof id === 'string' && id.length > 0
124
+ ? `id${KEY_SEP}${id}`
125
+ : `sig${KEY_SEP}${stableStringify(entry)}`;
126
+ }
127
+
128
+ /**
129
+ * Closed vocabulary for `_archive_reason` (#1017). Every record that leaves
130
+ * the active store carries exactly one of these, so the archive answers WHY a
131
+ * `learning-id` stopped resolving — not merely THAT it did.
132
+ *
133
+ * expired — `expires_at` elapsed (past the grace window, for the sweep).
134
+ * pruned — dropped by a decision: confidence decayed to <= 0, or the
135
+ * caller removed it from the next store generation.
136
+ * superseded — a duplicate `(type, subject)` lost to a higher-confidence
137
+ * twin. Carries `_superseded_by: <winning id>`.
138
+ * merged — folded into another record. Carries `_merged_into: <id>`.
139
+ *
140
+ * `superseded` / `merged` REQUIRE their tombstone pointer — an archive record
141
+ * that says "this was replaced" without naming the replacement recreates the
142
+ * dangling-pointer defect one level down. {@link normalizeArchiveVerdict}
143
+ * fails closed on a missing pointer, before anything touches disk.
144
+ */
145
+ export const ARCHIVE_REASONS = Object.freeze(['expired', 'pruned', 'superseded', 'merged']);
146
+
147
+ /** Reason -> the tombstone field that reason MUST carry. */
148
+ const TOMBSTONE_FIELD = Object.freeze({
149
+ superseded: '_superseded_by',
150
+ merged: '_merged_into',
151
+ });
152
+
153
+ /** Reason -> the camelCase caller-facing alias for its tombstone pointer. */
154
+ const TOMBSTONE_ALIAS = Object.freeze({
155
+ superseded: 'supersededBy',
156
+ merged: 'mergedInto',
157
+ });
158
+
159
+ /**
160
+ * Normalize an archive verdict into `{reason, tombstone}` and fail closed on
161
+ * an unknown reason or a missing tombstone pointer.
162
+ *
163
+ * Accepts a bare reason string (`'pruned'`) or an object
164
+ * (`{reason: 'superseded', supersededBy: '<id>'}`; the underscore form
165
+ * `_superseded_by` is accepted too, so a caller may pass the on-disk shape).
166
+ *
167
+ * Callers MUST normalize while building the archive batch — i.e. BEFORE the
168
+ * first disk write — so a bad verdict aborts with the store and the archive
169
+ * both untouched.
170
+ *
171
+ * @param {string|{reason: string, supersededBy?: string, mergedInto?: string}} raw
172
+ * @returns {{reason: string, tombstone: object|null}}
173
+ */
174
+ function normalizeArchiveVerdict(raw) {
175
+ const v = typeof raw === 'string' ? { reason: raw } : (raw ?? {});
176
+ const { reason } = v;
177
+ if (!ARCHIVE_REASONS.includes(reason)) {
178
+ throw new Error(
179
+ `archive reason must be one of ${ARCHIVE_REASONS.join('|')}, got: ${JSON.stringify(reason)}`
180
+ );
181
+ }
182
+ const field = TOMBSTONE_FIELD[reason];
183
+ if (!field) return { reason, tombstone: null };
184
+ const target = v[TOMBSTONE_ALIAS[reason]] ?? v[field];
185
+ if (typeof target !== 'string' || target.length === 0) {
186
+ throw new Error(`archive reason "${reason}" requires a non-empty ${field} pointer`);
187
+ }
188
+ return { reason, tombstone: { [field]: target } };
189
+ }
190
+
191
+ /** Stamp an archive record with its provenance tail. */
192
+ function tagArchiveRecord(entry, { reason, tombstone }, nowIso) {
193
+ return { ...entry, _archived_at: nowIso, _archive_reason: reason, ...(tombstone ?? {}) };
194
+ }
195
+
196
+ /** `{expired: 2, superseded: 1}` roll-up over a normalized archive batch. */
197
+ function countByReason(batch) {
198
+ const out = {};
199
+ for (const { verdict } of batch) {
200
+ out[verdict.reason] = (out[verdict.reason] ?? 0) + 1;
201
+ }
202
+ return out;
203
+ }
204
+
205
+ /** Resolve the `now` parameter (Date | epoch ms | undefined) to epoch ms. */
206
+ function resolveNowMs(now) {
207
+ if (now instanceof Date) return now.getTime();
208
+ if (typeof now === 'number') return now;
209
+ return Date.now();
210
+ }
211
+
212
+ /**
213
+ * THE shared write pipeline — the only place in this module that touches disk.
214
+ * Both public entry points funnel through it, so the crash-safe ordering, the
215
+ * KEEP-batch probe, and the `.bak` snapshot cannot drift apart between the
216
+ * time-driven and the decision-driven caller (#1017).
217
+ *
218
+ * Order is load-bearing (see the module header): probe the KEEP batch with
219
+ * zero disk writes, THEN append the archive, THEN rewrite the store.
220
+ *
221
+ * @param {object} opts
222
+ * @param {string} opts.filePath
223
+ * @param {string} opts.archivePath
224
+ * @param {object[]} opts.keep — the next store generation
225
+ * @param {{entry: object, verdict: {reason: string, tombstone: object|null}}[]} opts.archiveBatch
226
+ * @param {number} opts.nowMs
227
+ * @param {boolean} opts.dryRun
228
+ * @returns {Promise<{kept: number, archived: number, byReason: Record<string, number>}>}
229
+ */
230
+ async function archiveThenRewrite({ filePath, archivePath, keep, archiveBatch, nowMs, dryRun }) {
231
+ const byReason = countByReason(archiveBatch);
232
+ if (dryRun) {
233
+ return { kept: keep.length, archived: archiveBatch.length, byReason };
234
+ }
235
+
236
+ // Validate the KEEP batch BEFORE the archive append (dry-run rewrite: throws
237
+ // on a bad record, writes nothing). Without this, an invalid-but-parseable
238
+ // KEEP record survives readLearnings() but blows up rewriteLearnings() later
239
+ // — AFTER the archive append already landed. On a repeated --apply run that
240
+ // duplicates the archive append every time (never de-duplicated on read)
241
+ // while the store is never actually pruned. Validating first means a bad
242
+ // record throws here, before anything on disk has been touched.
243
+ await rewriteLearnings(filePath, keep, { dryRun: true });
244
+
245
+ // Crash-safe ordering: archive append FIRST. A duplicate re-append after a
246
+ // crash is harmless (append-only, never de-duplicated on read); the reverse
247
+ // order risks losing an archive-worthy entry if the process dies after the
248
+ // store rewrite but before the archive write.
249
+ if (archiveBatch.length > 0) {
250
+ const nowIso = new Date(nowMs).toISOString();
251
+ const body =
252
+ archiveBatch
253
+ .map(({ entry, verdict }) => JSON.stringify(tagArchiveRecord(entry, verdict, nowIso)))
254
+ .join('\n') + '\n';
255
+ await mkdir(path.dirname(archivePath), { recursive: true });
256
+ await appendFile(archivePath, body, 'utf8');
257
+ }
258
+
259
+ // Re-validates (cheap, idempotent) and snapshots a `.bak-<ISO>` backup of
260
+ // the current store before the atomic rename (io.mjs #721). The KEEP batch
261
+ // already passed the dry-run probe above, so this call cannot throw here.
262
+ await rewriteLearnings(filePath, keep, { dryRun: false });
263
+
264
+ return { kept: keep.length, archived: archiveBatch.length, byReason };
265
+ }
266
+
56
267
  /**
57
268
  * Sweep expired learnings out of `filePath` into `archivePath`.
58
269
  *
@@ -91,15 +302,15 @@ export async function sweepExpiredLearnings({
91
302
  return { scanned: 0, kept: 0, archived: 0, dryRun, archivePath };
92
303
  }
93
304
 
94
- const nowMs =
95
- now instanceof Date ? now.getTime() : typeof now === 'number' ? now : Date.now();
305
+ const nowMs = resolveNowMs(now);
96
306
  const graceMs =
97
307
  (Number.isFinite(graceDays) && graceDays >= 0 ? graceDays : DEFAULT_GRACE_DAYS) * MS_PER_DAY;
98
308
 
99
309
  const { entries } = await readLearnings(filePath);
100
310
 
101
311
  const keep = [];
102
- const archiveCandidates = [];
312
+ const archiveBatch = [];
313
+ const expiredVerdict = normalizeArchiveVerdict('expired');
103
314
 
104
315
  for (const entry of entries) {
105
316
  const expiresMs = typeof entry?.expires_at === 'string' ? Date.parse(entry.expires_at) : NaN;
@@ -110,55 +321,199 @@ export async function sweepExpiredLearnings({
110
321
  }
111
322
  // Expired — but does it clear the grace window?
112
323
  if (expiresMs + graceMs < nowMs) {
113
- archiveCandidates.push(entry);
324
+ archiveBatch.push({ entry, verdict: expiredVerdict });
114
325
  } else {
115
326
  keep.push(entry);
116
327
  }
117
328
  }
118
329
 
119
- if (dryRun) {
120
- return {
121
- scanned: entries.length,
122
- kept: keep.length,
123
- archived: archiveCandidates.length,
124
- dryRun: true,
125
- archivePath,
126
- };
330
+ const { kept, archived } = await archiveThenRewrite({
331
+ filePath,
332
+ archivePath,
333
+ keep,
334
+ archiveBatch,
335
+ nowMs,
336
+ dryRun,
337
+ });
338
+
339
+ // Result shape is pinned by the CLI (`scripts/sweep-expired-learnings.mjs`)
340
+ // and its JSON contract — deliberately WITHOUT `byReason`, which only the
341
+ // multi-reason prune path below can populate meaningfully.
342
+ return { scanned: entries.length, kept, archived, dryRun, archivePath };
343
+ }
344
+
345
+ /**
346
+ * Decision-driven sibling of {@link sweepExpiredLearnings} — the archive-safe
347
+ * replacement for `/evolve`'s prune + consolidate + rewrite steps (#1017).
348
+ *
349
+ * Same pipeline, different predicate. Every record that leaves the store is
350
+ * appended to the archive with a reason from {@link ARCHIVE_REASONS} first;
351
+ * the store rewrite goes through `rewriteLearnings()` (validation + `.bak`
352
+ * snapshot + atomic rename), never a `>` redirect.
353
+ *
354
+ * Three ways a record leaves the store, each routed to its own reason:
355
+ * 1. **prune predicate** — `expires_at` elapsed (`expired`) or
356
+ * `confidence <= 0` (`pruned`). This is `/evolve` SKILL.md Step 3.5(6) /
357
+ * Step 4.4(3) verbatim, minus the deletion.
358
+ * 2. **consolidation** — duplicate `(type, subject)` with a NON-EMPTY
359
+ * subject: the highest-confidence record wins, the losers are archived
360
+ * `superseded` with `_superseded_by: <winner id>`. Null/empty-subject
361
+ * records are NEVER collapsed (issue #284) — each is keyed by its `id`.
362
+ * 3. **caller drop** — a record present on disk but absent from `entries`
363
+ * (the caller's next store generation). This is the mechanical guarantee
364
+ * that makes the prose-driven caller safe by construction: whatever an
365
+ * LLM-authored next generation omits is tombstoned automatically rather
366
+ * than silently deleted. Reconciliation is by `id`, falling back to a
367
+ * content fingerprint for a record with no usable one, and it COUNTS
368
+ * rather than tests membership — see {@link reconcileKey}. Deliberate
369
+ * ceiling: an id-less record the caller MUTATED (rather than dropped)
370
+ * fingerprints as a drop, so it is tombstoned while the mutated copy
371
+ * stays in the store — a duplicate archive line, never a loss. Revisit if
372
+ * a caller ever needs to mutate id-less records in bulk; the fix is to
373
+ * stamp an `id` at the read funnel, not to loosen this loop.
374
+ *
375
+ * **No `graceDays` here, by design.** The grace window exists for two reasons
376
+ * (see the module header): TTL edge-noise, and "a window for /evolve's
377
+ * confidence-reinforcement pass to re-stamp `expires_at` before the entry is
378
+ * moved out from under it". On THIS path reason 2 is structurally already
379
+ * satisfied: `/evolve` applies its reinforcement (+0.15 and a fresh
380
+ * `expires_at`) in the SAME run, strictly before the prune step — so an entry
381
+ * still expired at prune time is one the analyzer just declined to reinforce,
382
+ * not one that is about to be. Carrying a grace window here would instead
383
+ * CHANGE `/evolve`'s documented prune semantics (entries the operator expects
384
+ * gone would linger). The ping-pong hazard the grace window guards against on
385
+ * the sweep is also cheap here: this path archives rather than deletes, so a
386
+ * later re-derivation costs a duplicate archive line, not data.
387
+ *
388
+ * @param {object} opts
389
+ * @param {string} opts.filePath - active learnings.jsonl
390
+ * @param {string} opts.archivePath - append-only archive sidecar
391
+ * @param {object[]} [opts.entries] - the caller's next store generation (post
392
+ * confidence-update / append). Defaults to the on-disk set, i.e. a pure
393
+ * prune+consolidate pass.
394
+ * @param {Date|number} [opts.now] - injectable clock
395
+ * @param {boolean} [opts.dryRun=true]
396
+ * @param {string|Function} [opts.dropReason='pruned'] - verdict for case 3
397
+ * above: a reason string, or `(entry) => reason|{reason, supersededBy, mergedInto}`
398
+ * for per-record routing (this is the seam a later `merged` producer uses).
399
+ * @returns {Promise<{scanned: number, kept: number, archived: number,
400
+ * byReason: Record<string, number>, dryRun: boolean, archivePath: string}>}
401
+ */
402
+ export async function pruneLearnings({
403
+ filePath,
404
+ archivePath,
405
+ entries,
406
+ now,
407
+ dryRun = true,
408
+ dropReason = 'pruned',
409
+ } = {}) {
410
+ if (typeof filePath !== 'string' || filePath.length === 0) {
411
+ throw new Error('pruneLearnings: filePath is required');
412
+ }
413
+ if (typeof archivePath !== 'string' || archivePath.length === 0) {
414
+ throw new Error('pruneLearnings: archivePath is required');
415
+ }
416
+ if (entries !== undefined && !Array.isArray(entries)) {
417
+ throw new Error('pruneLearnings: entries must be an array when provided');
127
418
  }
128
419
 
129
- // Validate the KEEP batch BEFORE the archive append (dry-run rewrite: throws
130
- // on a bad record, writes nothing). Without this, an invalid-but-parseable
131
- // KEEP record survives readLearnings() but blows up rewriteLearnings() later
132
- // — AFTER the archive append already landed. On a repeated --apply run that
133
- // duplicates the archive append every time (never de-duplicated on read)
134
- // while the store is never actually pruned. Validating first means a bad
135
- // record throws here, before anything on disk has been touched.
136
- await rewriteLearnings(filePath, keep, { dryRun: true });
420
+ const nowMs = resolveNowMs(now);
421
+ const { entries: current } = await readLearnings(filePath);
422
+ const next = entries ?? current;
137
423
 
138
- // Crash-safe ordering: archive append FIRST. A duplicate re-append after a
139
- // crash is harmless (append-only, never de-duplicated on read); the reverse
140
- // order risks losing an archive-worthy entry if the process dies after the
141
- // store rewrite but before the archive write.
142
- if (archiveCandidates.length > 0) {
143
- const nowIso = new Date(nowMs).toISOString();
144
- const body =
145
- archiveCandidates
146
- .map((e) => JSON.stringify({ ...e, _archived_at: nowIso, _archive_reason: 'expired' }))
147
- .join('\n') + '\n';
148
- await mkdir(path.dirname(archivePath), { recursive: true });
149
- await appendFile(archivePath, body, 'utf8');
424
+ if (next.length === 0 && current.length === 0) {
425
+ return { scanned: 0, kept: 0, archived: 0, byReason: {}, dryRun, archivePath };
150
426
  }
151
427
 
152
- // Re-validates (cheap, idempotent) and snapshots a `.bak-<ISO>` backup of
153
- // the current store before the atomic rename (io.mjs #721). The KEEP batch
154
- // already passed the dry-run probe above, so this call cannot throw here.
155
- await rewriteLearnings(filePath, keep, { dryRun: false });
428
+ const resolveDrop = (entry) =>
429
+ normalizeArchiveVerdict(typeof dropReason === 'function' ? dropReason(entry) : dropReason);
430
+
431
+ const archiveBatch = [];
432
+ const survivors = [];
433
+
434
+ // (1) prune predicate — SKILL.md Step 3.5(6) / 4.4(3), routed by cause.
435
+ for (const entry of next) {
436
+ const expiresMs = typeof entry?.expires_at === 'string' ? Date.parse(entry.expires_at) : NaN;
437
+ if (Number.isFinite(expiresMs) && expiresMs < nowMs) {
438
+ archiveBatch.push({ entry, verdict: normalizeArchiveVerdict('expired') });
439
+ continue;
440
+ }
441
+ if (typeof entry?.confidence === 'number' && entry.confidence <= 0) {
442
+ archiveBatch.push({ entry, verdict: normalizeArchiveVerdict('pruned') });
443
+ continue;
444
+ }
445
+ survivors.push(entry);
446
+ }
447
+
448
+ // (2) consolidation — highest confidence wins per (type, non-empty subject).
449
+ const winners = new Map();
450
+ for (const entry of survivors) {
451
+ const subject = typeof entry?.subject === 'string' ? entry.subject.trim() : '';
452
+ if (subject.length === 0) continue; // #284: never collapse null/empty subjects
453
+ const key = `${entry?.type}${KEY_SEP}${subject}`;
454
+ const incumbent = winners.get(key);
455
+ const score = typeof entry?.confidence === 'number' ? entry.confidence : -Infinity;
456
+ const incumbentScore =
457
+ incumbent && typeof incumbent.confidence === 'number' ? incumbent.confidence : -Infinity;
458
+ if (!incumbent || score > incumbentScore) winners.set(key, entry);
459
+ }
460
+ const keep = [];
461
+ for (const entry of survivors) {
462
+ const subject = typeof entry?.subject === 'string' ? entry.subject.trim() : '';
463
+ if (subject.length === 0) {
464
+ keep.push(entry);
465
+ continue;
466
+ }
467
+ const winner = winners.get(`${entry?.type}${KEY_SEP}${subject}`);
468
+ if (winner === entry) {
469
+ keep.push(entry);
470
+ continue;
471
+ }
472
+ archiveBatch.push({
473
+ entry,
474
+ verdict: normalizeArchiveVerdict({ reason: 'superseded', supersededBy: String(winner?.id) }),
475
+ });
476
+ }
156
477
 
157
- return {
158
- scanned: entries.length,
159
- kept: keep.length,
160
- archived: archiveCandidates.length,
161
- dryRun: false,
478
+ // (3) caller drops — on disk but absent from the next generation.
479
+ //
480
+ // Identity is {@link reconcileKey}: the `id` when the record has a usable
481
+ // one, else a content fingerprint. NOTHING is skipped here — a record this
482
+ // loop passes over is a record the store rewrite deletes without a tombstone,
483
+ // because `keep` is built from `next` alone (loops 1-2) and an on-disk record
484
+ // absent from `next` has no other way in. This loop IS the rescue; an early
485
+ // `continue` in it is a silent delete, not a no-op.
486
+ //
487
+ // COUNTS, not membership: `next` may legitimately carry fewer copies of a key
488
+ // than the store does (identical id-less records; a duplicate `id` on disk
489
+ // reconciled against one entry in `next`). Set-membership would skip every
490
+ // copy while `keep` holds only one — dropping the surplus with no archive
491
+ // line, the same hole one level down. A multiset archives exactly the surplus.
492
+ const nextKeyCounts = new Map();
493
+ for (const entry of next) {
494
+ const key = reconcileKey(entry);
495
+ nextKeyCounts.set(key, (nextKeyCounts.get(key) ?? 0) + 1);
496
+ }
497
+ let dropped = 0;
498
+ for (const entry of current) {
499
+ const key = reconcileKey(entry);
500
+ const remaining = nextKeyCounts.get(key) ?? 0;
501
+ if (remaining > 0) {
502
+ nextKeyCounts.set(key, remaining - 1);
503
+ continue;
504
+ }
505
+ archiveBatch.push({ entry, verdict: resolveDrop(entry) });
506
+ dropped += 1;
507
+ }
508
+
509
+ const { kept, archived, byReason } = await archiveThenRewrite({
510
+ filePath,
162
511
  archivePath,
163
- };
512
+ keep,
513
+ archiveBatch,
514
+ nowMs,
515
+ dryRun,
516
+ });
517
+
518
+ return { scanned: next.length + dropped, kept, archived, byReason, dryRun, archivePath };
164
519
  }