@lh8ppl/claude-memory-kit 0.4.2 → 0.4.3

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.
@@ -36,7 +36,7 @@ import {
36
36
  import { appendAuditEntry, nowIso, REASON_CODES } from './audit-log.mjs';
37
37
  import { ERROR_CATEGORIES, errorResult } from './result-shapes.mjs';
38
38
  import { writeBullet, parseBulletProvenance, isProvenanceCommentLine } from './provenance.mjs';
39
- import { graduateForCapRelief } from './graduation.mjs';
39
+ import { graduateForCapRelief, condenseScratchpadForCapRelief } from './graduation.mjs';
40
40
 
41
41
  const VALID_TRUST = new Set(['high', 'medium', 'low']);
42
42
  const VALID_WRITE_SOURCES = new Set([
@@ -227,6 +227,19 @@ export function ensureSectionExists(scratchpadPath, sectionTitle) {
227
227
 
228
228
  const EVICTED_ID_RE = /^- \(([PUL]-[A-Za-z0-9]+)\)/;
229
229
 
230
+ // Sweep order (Task 151.5, ADR-0016 §20.3): within the stale, non-high swept
231
+ // set, LOW-trust evicts before MEDIUM — value-ordered, not the value-BLIND
232
+ // single-axis sweep (MemoryOS-LFU / MemOS-top-N) the research flags as the
233
+ // Task-151 bug. The drop GATE stays a two-axis CONJUNCTION (not-high AND stale);
234
+ // this only orders the eviction LIST (→ the archive + audit order), so a
235
+ // debugger / a future partial-drop sees the cheapest bullets first.
236
+ // 151.6 re-eval RESOLVED (D-238): KEEP the `trust` ENUM here — the evolved
237
+ // trust_score is a FLOOR/protection signal (memclaw/letta/graphiti), NOT a sweep-
238
+ // ranking driver (rank-and-sweep-by-score = the MemoryOS-LFU/MemOS-top-N bug). The
239
+ // floor (never-zero) + the enum's high-trust-exempt already deliver the protection;
240
+ // no index-db I/O on this path. See design §20.3.
241
+ const CONSOLIDATE_TRUST_ORDER = { low: 0, medium: 1, high: 2 };
242
+
230
243
  function consolidate(text, { nowDate }) {
231
244
  const lines = text.split(/\r?\n/); // Task 139: CRLF-tolerant
232
245
  const removeIdx = new Set();
@@ -253,14 +266,28 @@ function consolidate(text, { nowDate }) {
253
266
  removeIdx.add(i + 1);
254
267
  // Task 91.2: capture the dropped bullet so the caller can ARCHIVE it
255
268
  // (recoverable, per the §6.5 tombstone principle) instead of hard-deleting.
269
+ // Carry trust + age so the eviction list can be value-ordered (151.5).
256
270
  const idMatch = bulletLine.match(EVICTED_ID_RE);
257
- evicted.push({ id: idMatch ? idMatch[1] : 'unknown', block: `${bulletLine}\n${commentLine}` });
271
+ evicted.push({
272
+ id: idMatch ? idMatch[1] : 'unknown',
273
+ block: `${bulletLine}\n${commentLine}`,
274
+ trust: prov.trust,
275
+ atMs: at.getTime(),
276
+ });
258
277
  bulletsRemoved++;
259
278
  }
260
279
 
261
280
  if (removeIdx.size === 0) {
262
281
  return { text, bulletsRemoved: 0, evicted: [] };
263
282
  }
283
+ // 151.5: order the eviction list LOW-trust first, then OLDEST first (the
284
+ // "long-unaccessed" axis, capture-age proxy until 151.6). The file removal
285
+ // (removeIdx) is unchanged — only the archive/audit order reflects value.
286
+ evicted.sort(
287
+ (a, b) =>
288
+ (CONSOLIDATE_TRUST_ORDER[a.trust] ?? 1) - (CONSOLIDATE_TRUST_ORDER[b.trust] ?? 1) ||
289
+ a.atMs - b.atMs,
290
+ );
264
291
  const out = lines.filter((_, i) => !removeIdx.has(i)).join('\n');
265
292
  return { text: out, bulletsRemoved, evicted };
266
293
  }
@@ -365,21 +392,23 @@ export function appendScratchpadBullet(opts = {}) {
365
392
  evictedBullets = consolidated.evicted ?? [];
366
393
  }
367
394
 
368
- // 2b. Graduation (Task 91, generalized to all tiers by Task 94 / §19.2). If
369
- // still over the LOAD-cap after stale-drop which is what happens when the
370
- // bullets are high-trust (consolidate() never drops those) graduate the
371
- // oldest high-trust bullets OUT of the hot index into the tier's permanent
372
- // fact store, keeping the injected slice small (the write already succeeded
373
- // via the load-cap; graduation is about injection budget, not write success).
374
- // ALL FACT-BEARING TIERS (D-61): project (MEMORY.md + SOUL.md) AND the user-
375
- // tier persona (USER/HABITS/LESSONS) graduating into the tier's existing
376
- // fact store (project context/memory/, user <userDir>/fragments/; writeFact
377
- // already routes tier-U facts there). Local tier (machine-paths/overrides) is
378
- // excluded: it's machine-specific config, not durable facts.
395
+ // 2b. Cap relief after stale-drop. The path DIFFERS by tier (Task 151.4, §20.3):
396
+ // - PROJECT (tier P): GRADUATE the oldest high-trust bullets OUT of the hot
397
+ // index into the permanent fact store (context/memory/*.md). Those stay
398
+ // recall-reachable (`cmk search`), so moving them out of MEMORY.md is fine
399
+ // it's an injection-budget trim, not data loss.
400
+ // - USER PERSONA (tier U): NEVER graduate the persona's value IS being
401
+ // injected at cold-open, and the user-tier graduation target (fragments/) is
402
+ // NOT read by inject-context, so graduating a promoted trait there strands it
403
+ // (Hole B the v0.3.1 cold-open bug). Instead CONDENSE in place (mechanical,
404
+ // no LLM drops no bullet); if still over cap the file grows past the inject
405
+ // budget (load-cap, not write-cap D-61) and the snapshot load-cap + sweep
406
+ // order (151.5) keeps the high-trust traits injected. LLM rewrite is Task 95.
407
+ // Local tier (machine-paths/overrides) is excluded: machine config, not facts.
379
408
  let bulletsGraduated = 0;
380
409
  let graduatedIds = [];
381
410
  let finalBytes = Buffer.byteLength(finalContent, 'utf8');
382
- if (finalBytes > cap && (tier === 'P' || tier === 'U')) {
411
+ if (finalBytes > cap && tier === 'P') {
383
412
  const grad = graduateForCapRelief({
384
413
  text: finalContent,
385
414
  capBytes: cap,
@@ -392,6 +421,10 @@ export function appendScratchpadBullet(opts = {}) {
392
421
  graduatedIds = grad.graduated;
393
422
  bulletsGraduated = graduatedIds.length;
394
423
  finalBytes = Buffer.byteLength(finalContent, 'utf8');
424
+ } else if (finalBytes > cap && tier === 'U') {
425
+ // Demote-not-evict: reclaim bytes without losing a single persona trait.
426
+ finalContent = condenseScratchpadForCapRelief(finalContent);
427
+ finalBytes = Buffer.byteLength(finalContent, 'utf8');
395
428
  }
396
429
 
397
430
  // 3. Load-cap, NOT write-cap (Task 94 / D-61 / design §19). The write ALWAYS
@@ -529,17 +562,23 @@ export function sweepScratchpadForCapRelief({
529
562
 
530
563
  let graduatedIds = [];
531
564
  if (Buffer.byteLength(working, 'utf8') > cap) {
532
- // tier is already guaranteed P||U by the gate above.
533
- const grad = graduateForCapRelief({
534
- text: working,
535
- capBytes: cap,
536
- tier,
537
- projectRoot,
538
- userDir,
539
- now: ts,
540
- });
541
- working = grad.text;
542
- graduatedIds = grad.graduated;
565
+ // Task 151.4 (§20.3): PROJECT graduates to the recall-reachable fact store;
566
+ // USER PERSONA condenses in place — never graduates to un-injected fragments/
567
+ // (Hole B). tier is guaranteed P||U by the gate above.
568
+ if (tier === 'P') {
569
+ const grad = graduateForCapRelief({
570
+ text: working,
571
+ capBytes: cap,
572
+ tier,
573
+ projectRoot,
574
+ userDir,
575
+ now: ts,
576
+ });
577
+ working = grad.text;
578
+ graduatedIds = grad.graduated;
579
+ } else {
580
+ working = condenseScratchpadForCapRelief(working);
581
+ }
543
582
  }
544
583
 
545
584
  if (working === original) {
@@ -0,0 +1,120 @@
1
+ // trust-score.mjs — the evolving PROTECTION field (Task 151.6+, ADR-0016 §20.2).
2
+ //
3
+ // `trust_score` is a FLOAT per fact, stored in the REBUILDABLE INDEX (the
4
+ // index-db `observations.trust_score` column), NOT in committed frontmatter
5
+ // (D-218: a moving value in a committed .md = git-diff noise; markdown-as-truth
6
+ // keeps committed files clean). It is reconstructed on every full reindex.
7
+ //
8
+ // Two trust signals, two homes (the kit's deliberate split — MemOS proves a
9
+ // single unified score lets a noisy fact rank high):
10
+ // - `trust` ENUM (high/medium/low) — committed frontmatter, the CURATED signal
11
+ // a human/classifier assigned; it gates promotion-protection coarsely and is
12
+ // the INIT seed here.
13
+ // - `trust_score` FLOAT — this module, the rebuildable index; it EVOLVES from
14
+ // passive outcomes (151.7 update rule + 151.8 signals) so a fact that keeps
15
+ // getting contradicted sinks and a fact that keeps getting restated rises,
16
+ // with NO ritual (D-169).
17
+ //
18
+ // 151.6 ships ONLY the INIT (source-based seed). 151.7 adds the asymmetric,
19
+ // clamped, floored update rule; 151.8 wires the three passive signals.
20
+
21
+ // The floor (memclaw `_adjust_weights`): trust_score never reaches zero, so a
22
+ // fact is never auto-deleted purely by trust decay — demote-not-evict (§20.3).
23
+ export const TRUST_SCORE_FLOOR = 0.05;
24
+ // The ceiling: cap below 1.0 so a maxed-out fact still has reinforcement headroom
25
+ // and the init never starts at the top.
26
+ export const TRUST_SCORE_CEIL = 0.95;
27
+
28
+ // Base score per committed trust enum — the curated coarse signal.
29
+ const TRUST_ENUM_BASE = { high: 0.8, medium: 0.5, low: 0.3 };
30
+ const DEFAULT_ENUM_BASE = 0.5; // unknown enum → treat as medium
31
+
32
+ // Source adjustment: a USER-ATTESTED write (the user stated/curated it) starts
33
+ // higher than a MACHINE-DERIVED one at the same enum — the design's
34
+ // "user-explicit > auto-extract" (memory-os source-based init). `imported` is
35
+ // neutral (provenance unknown); `seed` is a scaffold placeholder, slightly low.
36
+ const SOURCE_ADJUST = {
37
+ 'user-explicit': +0.1,
38
+ 'manual-edit': +0.1,
39
+ imported: 0,
40
+ 'auto-extract': -0.05,
41
+ compressor: -0.05,
42
+ seed: -0.1,
43
+ };
44
+ const DEFAULT_SOURCE_ADJUST = 0; // unknown source → neutral
45
+
46
+ // Recurrence contribution to the SEED (Task 151.8 — the research fix). A re-stated
47
+ // fact seeds HIGHER, and durably: this is reconstructed from the committed
48
+ // `recurrence_count` (151.1) on every reindex, so the "restatement reinforcement"
49
+ // can never be wiped by a reseed (unlike a fragile overlay delta would be). This
50
+ // is the MemoryOS/MemOS/honcho pattern — the recurrence COUNT is a TERM in the
51
+ // score, NOT a separate event. The cap is LOAD-BEARING (MemOS `min(count·w, 2)`):
52
+ // recurrence is a tie-breaker, never a runaway driver — a high-recurrence LOW-trust
53
+ // fact must never outrank a once-stated HIGH-trust one (the value-blind-LFU bug).
54
+ const RECUR_WEIGHT = 0.02; // per recurrence beyond the first
55
+ const RECUR_CONTRIB_CAP = 0.1; // max total recurrence lift (5 recurrences = full)
56
+
57
+ function recurrenceTerm(recurrenceCount) {
58
+ const n = Number.isFinite(recurrenceCount) && recurrenceCount > 1 ? recurrenceCount : 1;
59
+ return Math.min((n - 1) * RECUR_WEIGHT, RECUR_CONTRIB_CAP);
60
+ }
61
+
62
+ function clamp(n) {
63
+ if (!Number.isFinite(n)) return DEFAULT_ENUM_BASE;
64
+ return Math.min(TRUST_SCORE_CEIL, Math.max(TRUST_SCORE_FLOOR, n));
65
+ }
66
+
67
+ /**
68
+ * Initial trust_score for a fact, from its committed signals (Task 151.6 + .8).
69
+ * Pure + deterministic — same inputs, same score; reconstructed on every full
70
+ * reindex. A user-attested source outranks a machine-derived one at the same
71
+ * trust enum; a re-stated fact (higher `recurrenceCount`) seeds higher, CAPPED;
72
+ * the result is clamped to [FLOOR, CEIL].
73
+ *
74
+ * @param {object} o
75
+ * @param {string} [o.trust] the committed trust enum (high|medium|low)
76
+ * @param {string} [o.writeSource] the committed write source (user-explicit|…)
77
+ * @param {number} [o.recurrenceCount] the committed recurrence_count (151.1); a
78
+ * re-stated fact seeds higher (capped) — the
79
+ * DURABLE restatement-reinforcement (151.8)
80
+ * @returns {number} the seed trust_score in [FLOOR, CEIL]
81
+ */
82
+ export function initTrustScore({ trust, writeSource, recurrenceCount } = {}) {
83
+ const base = TRUST_ENUM_BASE[trust] ?? DEFAULT_ENUM_BASE;
84
+ const adjust = SOURCE_ADJUST[writeSource] ?? DEFAULT_SOURCE_ADJUST;
85
+ return clamp(base + adjust + recurrenceTerm(recurrenceCount));
86
+ }
87
+
88
+ // The asymmetric event deltas (Task 151.7; memclaw `evolve_service._adjust_weights`):
89
+ // a dampen moves MORE than a reinforce, so a contradicted/superseded fact sinks
90
+ // faster than a restated one rises — the conservative, fail-loud posture (a single
91
+ // failure outweighs a single success). Event-driven, NOT clock-driven.
92
+ export const REINFORCE_DELTA = +0.1;
93
+ export const DAMPEN_DELTA = -0.15;
94
+
95
+ // The event → delta map. An unknown event contributes 0 (no-op) so a future
96
+ // signal that isn't wired yet can't silently corrupt a score.
97
+ const EVENT_DELTA = {
98
+ reinforce: REINFORCE_DELTA,
99
+ dampen: DAMPEN_DELTA,
100
+ };
101
+
102
+ /**
103
+ * Apply one passive-outcome event to a fact's trust_score (Task 151.7).
104
+ * Pure + deterministic — the SAME (current, event) always yields the same result
105
+ * (event-driven, NOT time-decayed; recency decay is a search-ranking concern
106
+ * computed at read, never stored). The result is clamped to [FLOOR, CEIL]; the
107
+ * floor is load-bearing — repeated dampens settle at 0.05, never zero, so a fact
108
+ * is never auto-deleted by trust decay (demote-not-evict, §20.3). 151.8 maps the
109
+ * three passive signals (contradiction / supersession / restatement) onto the
110
+ * 'dampen' / 'reinforce' events.
111
+ *
112
+ * @param {number} current the fact's current trust_score
113
+ * @param {string} [event] 'reinforce' | 'dampen' (unknown → no-op)
114
+ * @returns {number} the updated trust_score in [FLOOR, CEIL]
115
+ */
116
+ export function updateTrustScore(current, event) {
117
+ const base = Number.isFinite(current) ? current : DEFAULT_ENUM_BASE;
118
+ const delta = EVENT_DELTA[event] ?? 0;
119
+ return clamp(base + delta);
120
+ }
@@ -0,0 +1,73 @@
1
+ // trust-signal.mjs — apply a PASSIVE outcome event to a fact's trust_score
2
+ // (Task 151.8, ADR-0016 §20.2). The I/O half of the trust layer; the pure
3
+ // arithmetic is `updateTrustScore` in trust-score.mjs.
4
+ //
5
+ // The three passive signals (D-169, zero ritual — no `cmk` command):
6
+ // - CONTRADICTION (a write conflicts with an existing fact) → dampen the existing
7
+ // - SUPERSESSION (`superseded_by` set / a replace) → dampen the OLD fact
8
+ // - RE-SURFACE (the 151.1 recurrence bump) → reinforce the fact
9
+ // 151.8 wires these call sites to this helper.
10
+ //
11
+ // DURABILITY POSTURE (decided 2026-06-30, D-237 — research- + code-grounded):
12
+ // trust_score lives in the REBUILDABLE index (the `observations.trust_score`
13
+ // column), which the kit treats as a "regenerable read-cache" (index-db.mjs).
14
+ // So this is a RUNTIME OVERLAY: the delta survives the common path (reindexBoot
15
+ // skips unchanged files) → durable session-to-session on a machine; a FULL
16
+ // reindex (`cmk reindex --full` / repair) reseeds from the committed `trust`
17
+ // enum and resets the overlay — acceptable because trust_score is the LOCAL
18
+ // protection signal, while the committed `trust` enum is the PORTABLE one (the
19
+ // two-fields design). Every surveyed system that evolves trust (memclaw
20
+ // `_adjust_weights`, MemoryOS, honcho, EverOS, captain-claw) keeps it in the
21
+ // runtime DB store, not in a committed/replayed log — this matches them.
22
+ //
23
+ // BEST-EFFORT: a trust nudge must NEVER break the primary write. Every failure
24
+ // (no projectRoot, no DB, missing row, bad input) returns a benign result and
25
+ // NEVER throws — the caller (a write path) is unaffected.
26
+
27
+ import { openIndexDb } from './index-db.mjs';
28
+ import { updateTrustScore } from './trust-score.mjs';
29
+
30
+ const SELECT_TRUST_SCORE_SQL = 'SELECT trust_score FROM observations WHERE id = ?';
31
+ const UPDATE_TRUST_SCORE_SQL = 'UPDATE observations SET trust_score = ? WHERE id = ?';
32
+
33
+ /**
34
+ * Apply one passive-outcome event to a fact's trust_score overlay (Task 151.8).
35
+ * BEST-EFFORT — swallows every error, never throws.
36
+ *
37
+ * @param {object} o
38
+ * @param {string} [o.projectRoot] the project whose index-db holds the row
39
+ * (ignored when `db` is supplied)
40
+ * @param {string} [o.id] the affected fact's canonical id
41
+ * @param {string} [o.event] 'reinforce' | 'dampen' (unknown → no-op write)
42
+ * @param {object} [o.db] an ALREADY-OPEN index-db handle to reuse — a
43
+ * caller that fires several signals in one op
44
+ * (e.g. a merge dampening idA+idB) passes one
45
+ * handle to avoid open/close per call. When given,
46
+ * this function does NOT close it (the caller owns
47
+ * its lifecycle). Omit → open + close our own.
48
+ * @returns {{action:'updated'|'not-found'|'skipped', id?:string, trust_score?:number}}
49
+ */
50
+ export function applyTrustSignal({ projectRoot, id, event, db: sharedDb } = {}) {
51
+ if (!id || (!projectRoot && !sharedDb)) return { action: 'skipped' };
52
+ let db = sharedDb;
53
+ try {
54
+ if (!db) db = openIndexDb({ projectRoot });
55
+ const row = db.prepare(SELECT_TRUST_SCORE_SQL).get(id);
56
+ if (!row) return { action: 'not-found', id };
57
+ const next = updateTrustScore(row.trust_score, event);
58
+ db.prepare(UPDATE_TRUST_SCORE_SQL).run(next, id);
59
+ return { action: 'updated', id, trust_score: next };
60
+ } catch {
61
+ // best-effort: a trust overlay update must never break the primary write.
62
+ return { action: 'skipped', id };
63
+ } finally {
64
+ // Only close a handle WE opened — never the caller's shared handle.
65
+ if (!sharedDb && db) {
66
+ try {
67
+ db.close();
68
+ } catch {
69
+ // ignore close errors on a best-effort path
70
+ }
71
+ }
72
+ }
73
+ }
@@ -106,6 +106,12 @@ function buildFrontmatterObject(opts, computed) {
106
106
  created_at: computed.createdAt,
107
107
  write_source: opts.writeSource,
108
108
  trust: opts.trust,
109
+ // Task 151.1 (ADR-0016 / design §20.1): the capped-recurrence promotion
110
+ // signal. Starts at 1 on create; the duplicate-hit path bumps it when the
111
+ // SAME canonical fact re-surfaces (same content-hash id). A promotion fact,
112
+ // so it lives in committed frontmatter (diffable) — unlike trust_score,
113
+ // which moves on every recall and lives in the rebuildable index (D-218).
114
+ recurrence_count: computed.recurrenceCount ?? 1,
109
115
  source_file: opts.sourceFile,
110
116
  source_line: opts.sourceLine,
111
117
  source_sha1: opts.sourceSha1,
@@ -141,6 +147,27 @@ function readExistingFactId(path) {
141
147
  return frontmatter?.id ?? null;
142
148
  }
143
149
 
150
+ // Task 151.1 (ADR-0016 / design §20.1): a duplicate write = the SAME canonical
151
+ // fact re-surfaced → bump its `recurrence_count` in place. Only the bumped fact
152
+ // is touched (the over-mutation guard). Returns the new count, or null if the
153
+ // file can't be read/parsed (best-effort: a re-surface bump must never turn a
154
+ // successful no-op into an error).
155
+ function bumpRecurrence(path) {
156
+ try {
157
+ const { frontmatter, body } = parse(readFileSync(path, 'utf8'));
158
+ if (!frontmatter) return null;
159
+ const current = Number.isInteger(frontmatter.recurrence_count)
160
+ ? frontmatter.recurrence_count
161
+ : 1; // pre-151 facts have no field → treat as 1, this re-surface makes 2
162
+ const next = current + 1;
163
+ frontmatter.recurrence_count = next;
164
+ writeFileSync(path, format({ frontmatter, body }), 'utf8');
165
+ return next;
166
+ } catch {
167
+ return null;
168
+ }
169
+ }
170
+
144
171
  export function writeFact(opts = {}) {
145
172
  const errors = validateOptions(opts);
146
173
  if (errors.length > 0) {
@@ -211,15 +238,25 @@ export function writeFact(opts = {}) {
211
238
  const existingIdAtPath = readExistingFactId(path);
212
239
  if (existingIdAtPath !== null) {
213
240
  if (existingIdAtPath === id) {
241
+ // Task 151.1: the same canonical fact re-surfaced → bump recurrence_count.
242
+ const recurrenceCount = bumpRecurrence(path);
214
243
  appendAuditEntry(tierRoot, {
215
244
  ts: createdAt,
216
- action: 'skipped',
245
+ action: 'recurrence',
217
246
  tier: opts.tier,
218
247
  id,
219
- reasonCode: REASON_CODES.DUPLICATE,
248
+ reasonCode: REASON_CODES.RECURRENCE,
249
+ extra: { recurrenceCount },
220
250
  paths: { before: path },
221
251
  });
222
- return { action: 'skipped', skipReason: 'duplicate', id, path };
252
+ // Task 151.8 (research fix): the re-surface RESTATEMENT signal is NOT a
253
+ // fragile overlay delta — `bumpRecurrence` just wrote the new recurrence_count
254
+ // to the committed file, and `initTrustScore` folds a CAPPED recurrence term
255
+ // into the seed, so the next reindex reconstructs a HIGHER trust_score from
256
+ // the durable count (MemoryOS/MemOS/honcho: the count IS a score term). No
257
+ // overlay write here — it would only be reseeded away by the reindex that the
258
+ // file change triggers. Durable-by-construction.
259
+ return { action: 'skipped', skipReason: 'duplicate', id, path, recurrenceCount };
223
260
  }
224
261
  return errorResult({
225
262
  category: ERROR_CATEGORIES.COLLISION,
@@ -233,20 +270,28 @@ export function writeFact(opts = {}) {
233
270
 
234
271
  const elsewhere = findExistingFactById(factDir, id);
235
272
  if (elsewhere) {
273
+ // Task 151.1: re-surface via a different slug → bump the ORIGINAL fact.
274
+ const recurrenceCount = bumpRecurrence(elsewhere);
236
275
  appendAuditEntry(tierRoot, {
237
276
  ts: createdAt,
238
- action: 'skipped',
277
+ action: 'recurrence',
239
278
  tier: opts.tier,
240
279
  id,
241
280
  reasonCode: REASON_CODES.DUPLICATE_ELSEWHERE,
281
+ extra: { recurrenceCount },
242
282
  paths: { before: elsewhere, after: path },
243
283
  });
284
+ // Task 151.8 (research fix): restatement reinforcement is DURABLE via the seed
285
+ // (initTrustScore folds the committed recurrence_count), not a doomed overlay —
286
+ // the bump rewrote the file, so any overlay write would be reseeded away. See
287
+ // the same-id branch above.
244
288
  return {
245
289
  action: 'skipped',
246
290
  skipReason: 'duplicate-elsewhere',
247
291
  id,
248
292
  path,
249
293
  duplicateAt: elsewhere,
294
+ recurrenceCount,
250
295
  };
251
296
  }
252
297