@lh8ppl/claude-memory-kit 0.4.1 → 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.
@@ -48,10 +48,12 @@ import chokidar from 'chokidar';
48
48
  import { INDEX_DB_SCHEMA } from './index-db.mjs';
49
49
  import { hashContent } from './content-hash.mjs';
50
50
  import { syncTranscriptChunks } from './transcript-index.mjs';
51
- import { readBullet, parseBulletProvenance } from './provenance.mjs';
51
+ import { readBullet, parseBulletProvenance, isSeedProvenance } from './provenance.mjs';
52
52
  import { parse as parseFrontmatter } from './frontmatter.mjs';
53
+ import { initTrustScore } from './trust-score.mjs';
53
54
  import {
54
55
  VALID_TIERS,
56
+ SCRATCHPADS_BY_TIER,
55
57
  resolveTierRoot,
56
58
  resolveFactDir,
57
59
  ID_PATTERN,
@@ -70,10 +72,19 @@ export function listObservationSources({ projectRoot, userDir }) {
70
72
  for (const tier of ['P', 'L', 'U']) {
71
73
  const root = resolveTierRoot({ tier, projectRoot, userDir });
72
74
  if (!existsSync(root)) continue;
73
- // Scratchpad: <tier>/MEMORY.md
74
- const scratchpad = join(root, 'MEMORY.md');
75
- if (existsSync(scratchpad)) {
76
- sources.push({ path: scratchpad, tier, kind: 'scratchpad' });
75
+ // Scratchpads: EVERY canonical scratchpad for the tier, not just MEMORY.md
76
+ // (Task 182 / D-247). Pre-182 this hardcoded `<tier>/MEMORY.md`, so the
77
+ // project-tier SOUL.md AND the entire user-tier persona (USER/HABITS/
78
+ // LESSONS.md where `cmk lessons promote` writes) were never indexed, and
79
+ // a promoted persona fact was unsearchable even in-session. Iterating
80
+ // SCRATCHPADS_BY_TIER (the same allow-list the writer/cap layer uses) fixes
81
+ // all of them at once with no filename drift. They all share the
82
+ // bullet + provenance-comment shape the scratchpad parser already handles.
83
+ for (const scratchpadName of SCRATCHPADS_BY_TIER[tier] ?? []) {
84
+ const scratchpad = join(root, scratchpadName);
85
+ if (existsSync(scratchpad)) {
86
+ sources.push({ path: scratchpad, tier, kind: 'scratchpad' });
87
+ }
77
88
  }
78
89
  // Granular fact files: <tier>/memory/*.md (excluding INDEX.md)
79
90
  const factDir = resolveFactDir(tier, root);
@@ -132,10 +143,12 @@ function relativeSource(absPath, { projectRoot, userDir }) {
132
143
  // --- Parsing ----------------------------------------------------------
133
144
 
134
145
  /**
135
- * Parse a scratchpad MEMORY.md into observations.
146
+ * Parse a scratchpad (MEMORY.md / SOUL.md / USER.md / HABITS.md / LESSONS.md /
147
+ * machine-paths.md / overrides.md — any of SCRATCHPADS_BY_TIER) into observations.
136
148
  *
137
149
  * Walks line-by-line tracking the most recent h2 heading. For each
138
150
  * bullet+comment pair, calls readBullet() to extract id/text/provenance.
151
+ * Scaffold `(example)` seed bullets (all-zero sha1) are skipped (Task 183).
139
152
  * Returns one row per bullet conforming to the observations schema.
140
153
  *
141
154
  * Tolerant: bullets without a following provenance comment are skipped
@@ -174,6 +187,11 @@ export function parseObservationsFromScratchpad({
174
187
  const bullet = readBullet({ bulletLine: line, commentLine: next });
175
188
  if (!bullet) continue;
176
189
  const { id, text, provenance } = bullet;
190
+ // Task 183 (D-247): skip scaffold `(example)` seed bullets (all-zero sha1)
191
+ // — they must never enter the search index, else a fresh install returns
192
+ // only misleading placeholders. Mirrors the inject path's existing seed
193
+ // filter (shared `isSeedProvenance`, so both surfaces stay in agreement).
194
+ if (isSeedProvenance(provenance)) continue;
177
195
  const heading_path = currentHeading
178
196
  ? `${baseName} > ${currentHeading}`
179
197
  : baseName;
@@ -266,6 +284,14 @@ export function parseObservationsFromFactFile({
266
284
  body: (body ?? '').trim() || (frontmatter.title ?? ''),
267
285
  write_source: frontmatter.write_source,
268
286
  trust: frontmatter.trust,
287
+ // 151.8: the committed recurrence_count (151.1) seeds the trust_score's
288
+ // DURABLE restatement term — survives every reindex (reconstructed from this).
289
+ // Floor to 1 for a missing OR malformed (≤0) value — consistent with the other
290
+ // four recurrence readers (write-fact / assembleProjectCorpus / trust-score)
291
+ // which all treat <1 as the 1× baseline (the field starts at 1, only increments).
292
+ recurrence_count: Number.isFinite(frontmatter.recurrence_count) && frontmatter.recurrence_count > 0
293
+ ? frontmatter.recurrence_count
294
+ : 1,
269
295
  created_at: isoToEpochMs(frontmatter.created_at),
270
296
  superseded_by: frontmatter.superseded_by ?? null,
271
297
  deleted_at: frontmatter.deleted_at ? isoToEpochMs(frontmatter.deleted_at) : null,
@@ -332,19 +358,19 @@ const DELETE_OBSERVATION_BY_ID_SQL = `DELETE FROM observations WHERE id = ?`;
332
358
  const INSERT_OBSERVATION_SQL = `
333
359
  INSERT INTO observations
334
360
  (id, tier, source_file, source_line, source_sha1, heading_path, body,
335
- write_source, trust, created_at, superseded_by, deleted_at)
361
+ write_source, trust, trust_score, created_at, superseded_by, deleted_at)
336
362
  VALUES
337
363
  (@id, @tier, @source_file, @source_line, @source_sha1, @heading_path, @body,
338
- @write_source, @trust, @created_at, @superseded_by, @deleted_at)
364
+ @write_source, @trust, @trust_score, @created_at, @superseded_by, @deleted_at)
339
365
  `;
340
366
 
341
367
  const INSERT_SCRATCHPAD_OBSERVATION_SQL = `
342
368
  INSERT INTO observations
343
369
  (id, tier, source_file, source_line, source_sha1, heading_path, body,
344
- write_source, trust, created_at, superseded_by, deleted_at)
370
+ write_source, trust, trust_score, created_at, superseded_by, deleted_at)
345
371
  VALUES
346
372
  (@id, @tier, @source_file, @source_line, @source_sha1, @heading_path, @body,
347
- @write_source, @trust, @created_at, @superseded_by, @deleted_at)
373
+ @write_source, @trust, @trust_score, @created_at, @superseded_by, @deleted_at)
348
374
  ON CONFLICT(id) DO NOTHING
349
375
  `;
350
376
 
@@ -396,17 +422,37 @@ function replaceObservationsForFile(db, { source, observations, mtime, sha1, pro
396
422
  // safe: ids are content-addressed WITH the tier as a prefix (`P-`/`L-`/`U-`),
397
423
  // so a P-tier and U-tier fact can never share an id — no cross-tier delete is
398
424
  // possible. (Defended by the P/U-same-content tier test below.)
425
+ // 151.6/151.8: seed trust_score from the fact's committed signals — enum +
426
+ // source + the DURABLE recurrence term (151.8: a re-stated fact seeds higher,
427
+ // reconstructed from the committed recurrence_count so it survives every
428
+ // reindex). Computed here at insert (one place). `recurrence_count` is consumed
429
+ // by the seed but is NOT an `observations` column, so it's stripped from the
430
+ // bound row (better-sqlite3 rejects unknown named params). The asymmetric
431
+ // DAMPEN deltas (151.8 contradiction/supersession) stay as runtime overlays on
432
+ // the trust_score column — they survive a boot reindex (unchanged files skipped)
433
+ // and reseed only on a full rebuild (the local-protection-signal posture, D-237).
434
+ const withTrustScore = (obs) => {
435
+ const { recurrence_count, ...row } = obs;
436
+ return {
437
+ ...row,
438
+ trust_score: initTrustScore({
439
+ trust: obs.trust,
440
+ writeSource: obs.write_source,
441
+ recurrenceCount: recurrence_count,
442
+ }),
443
+ };
444
+ };
399
445
  if (source.kind === 'fact') {
400
446
  const deleteById = db.prepare(DELETE_OBSERVATION_BY_ID_SQL);
401
447
  const insert = db.prepare(INSERT_OBSERVATION_SQL);
402
448
  for (const obs of observations) {
403
449
  deleteById.run(obs.id);
404
- insert.run(obs);
450
+ insert.run(withTrustScore(obs));
405
451
  }
406
452
  } else {
407
453
  const insert = db.prepare(INSERT_SCRATCHPAD_OBSERVATION_SQL);
408
454
  for (const obs of observations) {
409
- insert.run(obs);
455
+ insert.run(withTrustScore(obs));
410
456
  }
411
457
  }
412
458
  db.prepare(UPSERT_FILE_SQL).run({
@@ -687,8 +733,15 @@ export function startRuntimeWatcher({
687
733
  const root = resolveTierRoot({ tier, projectRoot, userDir });
688
734
  if (!existsSync(root)) continue;
689
735
  tierRoots.push({ tier, root });
690
- const scratchpad = join(root, 'MEMORY.md');
691
- if (existsSync(scratchpad)) watchPaths.push(scratchpad);
736
+ // Watch EVERY canonical scratchpad for the tier (Task 182 / D-247), not
737
+ // just MEMORY.md — must stay in lockstep with listObservationSources above,
738
+ // else a live edit to SOUL.md / a `cmk lessons promote` into HABITS.md
739
+ // wouldn't trigger a runtime re-index (indexed on full reindex but not
740
+ // watched — the composition gap the caller-map rule catches).
741
+ for (const scratchpadName of SCRATCHPADS_BY_TIER[tier] ?? []) {
742
+ const scratchpad = join(root, scratchpadName);
743
+ if (existsSync(scratchpad)) watchPaths.push(scratchpad);
744
+ }
692
745
  const factDir = resolveFactDir(tier, root);
693
746
  if (existsSync(factDir)) watchPaths.push(factDir);
694
747
  }
@@ -502,6 +502,12 @@ function truncateTierToBudget(blockText, budget, valueById = new Map()) {
502
502
  // Drop order: lowest aggregate trust first → oldest first → later-in-file
503
503
  // first (the legacy tail tiebreak, so equal-value blocks still drop from the
504
504
  // end). High-value sections are evicted only after everything cheaper is gone.
505
+ // 151.5: this IS the value-ordered sweep (high-trust survives, low-trust drops
506
+ // first) — the inject half of ADR-0016 §20.3. 151.6 re-eval RESOLVED (D-238):
507
+ // KEEP the `maxTrust` enum here — adding an index-db trust_score lookup would put
508
+ // DB I/O on the 500ms inject path + rank on an overlay that resets on full reindex
509
+ // (non-deterministic across repair). The evolved score is a FLOOR/protection
510
+ // signal, not a sweep-ranking driver (rank-by-score = the cautionary bug). §20.3.
505
511
  const dropOrder = [...sections].sort(
506
512
  (a, b) =>
507
513
  a.maxTrust - b.maxTrust ||
@@ -29,6 +29,69 @@ const DEFAULT_SECTION = Object.freeze({
29
29
  'USER.md': 'Profile',
30
30
  });
31
31
 
32
+ // Task 151.9 — the offline TOPIC-router (fixes Hole C, §20.4). Before this, every
33
+ // no-arg `cmk lessons promote` funnelled into LESSONS § Cross-Project Lessons →
34
+ // single-section overflow. routeTopic spreads promotes across the three user-tier
35
+ // files by CONTENT, using auto-persona's taxonomy (USER=identity/preferences,
36
+ // HABITS=working-style, LESSONS=cross-project lessons) — but OFFLINE + deterministic
37
+ // (NO Haiku: the explicit command stays instant + network-free; the Haiku
38
+ // classifier stays on the AUTOMATIC path, which already runs an LLM. The two paths
39
+ // each topic-route, each by the router that fits — D-238-style two-mechanism split).
40
+ // Ordered most-specific → fallback; LESSONS is the safe catch-all for a
41
+ // cross-project fact. Each route's default section is per-file below.
42
+ const ROUTE_RULES = [
43
+ // identity / preferences → USER.md
44
+ { target: 'USER.md', section: 'Preferences', re: /\b(i ?a?m a |i'?m a |my name|my role|i'?m an? |i prefer|i like|i dislike|i favou?r|i'?m the|as a developer|i work as)\b/i },
45
+ // working-style / process / cadence → HABITS.md
46
+ { target: 'HABITS.md', section: 'Working Style', re: /\b(i always|i never|from now on|going forward|how i work|my workflow|my process|my cadence|i (commit|branch|review|test|deploy|lint|format)|always .{0,30}before|never .{0,30}without)\b/i },
47
+ // cross-project lessons / tooling gotchas → LESSONS.md
48
+ { target: 'LESSONS.md', section: 'Tooling Lessons', re: /\b(learned|lesson|gotcha|til\b|the hard way|turns out|pitfall|caveat|watch out|footgun|bug:|broke)\b/i },
49
+ ];
50
+
51
+ /**
52
+ * Route a promote to {target, section} by content (Task 151.9). Pure + offline +
53
+ * deterministic — no LLM. Falls back to LESSONS § Cross-Project Lessons (the safe
54
+ * cross-project catch-all) when nothing matches.
55
+ *
56
+ * @param {string} [text] the fact body
57
+ * @returns {{target:string, section:string}}
58
+ */
59
+ export function routeTopic(text) {
60
+ const t = String(text ?? '');
61
+ for (const rule of ROUTE_RULES) {
62
+ if (rule.re.test(t)) return { target: rule.target, section: rule.section };
63
+ }
64
+ return { target: 'LESSONS.md', section: DEFAULT_SECTION['LESSONS.md'] };
65
+ }
66
+
67
+ // Task 151.11 — the recurrence at which a promotion is worth an optional MENTION.
68
+ // A one-off promote stays silent; a fact that has RECURRED this many times earns a
69
+ // fire-and-forget heads-up. Matches the promotion gate floor (heat.PROMOTE_THRESHOLD
70
+ // = 3) — "seen ≥3× → durable enough to be worth a word."
71
+ export const MENTION_RECURRENCE = 3;
72
+
73
+ /**
74
+ * Build the optional in-conversation MENTION for a high-recurrence promotion
75
+ * (Task 151.11, awrshift warmth, §20.4). Returns a short heads-up STRING Claude
76
+ * MAY relay — or `null` below the recurrence threshold (stay silent). It is NOT a
77
+ * gate: it frames the post-hoc revert ("say so if wrong" / `cmk forget`), never
78
+ * asks a blocking question (D-169 — no human-in-the-loop). Pure.
79
+ *
80
+ * @param {object} o
81
+ * @param {string} [o.text] the promoted fact text (trimmed into the note)
82
+ * @param {number} [o.recurrenceCount] how many times the fact has recurred
83
+ * @param {string} [o.target] the user-tier file it landed in
84
+ * @returns {string|null}
85
+ */
86
+ export function buildPromotionMention({ text, recurrenceCount, target } = {}) {
87
+ const n = Number.isFinite(recurrenceCount) ? recurrenceCount : 0;
88
+ if (n < MENTION_RECURRENCE) return null;
89
+ const snippet = String(text ?? '').replace(/\s+/g, ' ').trim().slice(0, 80);
90
+ const where = target ? ` (now in your ${target} persona)` : '';
91
+ // A statement + a revert offer — never a question (would re-introduce the gate).
92
+ return `Noticed "${snippet}" has recurred ${n}× across your work — promoted it to your cross-project persona${where}. Tell me to forget it if that's wrong.`;
93
+ }
94
+
32
95
  /**
33
96
  * Promote a project-tier fact to the user tier through the safe path.
34
97
  *
@@ -41,11 +104,14 @@ const DEFAULT_SECTION = Object.freeze({
41
104
  * @param {string} [opts.now] ISO timestamp override (tests)
42
105
  * @returns {{action:string, id?:string, target?:string, section?:string, ...}}
43
106
  */
44
- export function lessonsPromote({ id, projectRoot, userDir, to = 'LESSONS.md', section, now } = {}) {
107
+ export function lessonsPromote({ id, projectRoot, userDir, to, section, now } = {}) {
45
108
  if (!userDir) {
46
109
  return errorResult({ category: 'schema', errors: ['userDir is required (lessons promote writes to the user tier)'] });
47
110
  }
48
- if (!VALID_TARGETS.has(to)) {
111
+ // An EXPLICIT `to` is validated up front; an absent `to` is filled by the 151.9
112
+ // topic-router below (after the fact body is known), so the no-arg promote spreads
113
+ // across USER/HABITS/LESSONS by content instead of funnelling to one section.
114
+ if (to !== undefined && !VALID_TARGETS.has(to)) {
49
115
  return errorResult({ category: 'schema', errors: [`invalid target '${to}' (expected USER.md | HABITS.md | LESSONS.md)`] });
50
116
  }
51
117
  // `lessons promote` carries a PROJECT observation to the user tier. Reject a
@@ -92,9 +158,17 @@ export function lessonsPromote({ id, projectRoot, userDir, to = 'LESSONS.md', se
92
158
  return errorResult({ category: 'schema', errors: [`fact '${id}' has no body to promote`], id });
93
159
  }
94
160
 
161
+ // Task 151.9 — TOPIC-route when the user didn't pin a target (fixes Hole C).
162
+ // An explicit `--to` (and/or `--section`) always wins; otherwise the offline
163
+ // router picks target+section by content so no-arg promotes spread across
164
+ // USER/HABITS/LESSONS instead of piling into one section.
165
+ const routed = to === undefined ? routeTopic(text) : { target: to, section: DEFAULT_SECTION[to] };
166
+ const finalTarget = routed.target;
167
+ const finalSection = section || routed.section;
168
+
95
169
  const candidate = {
96
- target: to,
97
- section: section || DEFAULT_SECTION[to],
170
+ target: finalTarget,
171
+ section: finalSection,
98
172
  text,
99
173
  confidence: 'high', // explicit user action → clears the confidence gate (promotes, not queued)
100
174
  };
@@ -110,27 +184,36 @@ export function lessonsPromote({ id, projectRoot, userDir, to = 'LESSONS.md', se
110
184
  source: 'user-explicit',
111
185
  });
112
186
 
113
- const promotedHit = res.promoted.find((p) => p.target === to);
187
+ // Task 151.11 optional heads-up on a HIGH-RECURRENCE promotion. Fire-and-
188
+ // forget: it rides on the SUCCESS result for Claude to optionally relay; it
189
+ // never gates or blocks (null below the threshold → silent, the D-169 default).
190
+ const mention = buildPromotionMention({
191
+ text,
192
+ recurrenceCount: found.frontmatter?.recurrence_count,
193
+ target: finalTarget,
194
+ });
195
+
196
+ const promotedHit = res.promoted.find((p) => p.target === finalTarget);
114
197
  if (promotedHit) {
115
- return { action: 'promoted', id, target: to, section: candidate.section, newId: promotedHit.id ?? null };
198
+ return { action: 'promoted', id, target: finalTarget, section: candidate.section, newId: promotedHit.id ?? null, ...(mention ? { mention } : {}) };
116
199
  }
117
200
  // A supersede is ALSO success: the promotion replaced an existing same-topic
118
201
  // lesson with this updated one (common when the user re-promotes a refined rule).
119
- const supersededHit = res.superseded.find((s) => s.target === to);
202
+ const supersededHit = res.superseded.find((s) => s.target === finalTarget);
120
203
  if (supersededHit) {
121
- return { action: 'promoted', id, target: to, section: candidate.section, newId: supersededHit.newId, superseded: supersededHit.oldId };
204
+ return { action: 'promoted', id, target: finalTarget, section: candidate.section, newId: supersededHit.newId, superseded: supersededHit.oldId, ...(mention ? { mention } : {}) };
122
205
  }
123
206
  // Routed to the conflict queue (e.g. it clashes with a hand-curated entry the
124
207
  // kit won't silently overwrite) or otherwise didn't land — surface honestly.
125
- const conflictHit = res.conflicts.find((q) => q.target === to);
208
+ const conflictHit = res.conflicts.find((q) => q.target === finalTarget);
126
209
  if (conflictHit) {
127
- return { action: 'queued', id, target: to, section: candidate.section, reason: 'conflict' };
210
+ return { action: 'queued', id, target: finalTarget, section: candidate.section, reason: 'conflict' };
128
211
  }
129
- const queuedHit = res.queued.find((q) => q.target === to);
212
+ const queuedHit = res.queued.find((q) => q.target === finalTarget);
130
213
  return {
131
214
  action: 'queued',
132
215
  id,
133
- target: to,
216
+ target: finalTarget,
134
217
  section: candidate.section,
135
218
  reason: queuedHit?.reason ?? 'not-promoted',
136
219
  };
@@ -411,7 +411,12 @@ function makeMkTrust({ projectRoot, userDir }) {
411
411
 
412
412
  function makeMkLessonsPromote({ projectRoot, userDir }) {
413
413
  return async ({ id, to }) => {
414
- const r = lessonsPromote({ id, projectRoot, userDir, to: to ?? 'LESSONS.md' });
414
+ // 151.9: pass `to` THROUGH (undefined when the caller omits it) so the offline
415
+ // TOPIC-router spreads the promote across USER/HABITS/LESSONS by content. The
416
+ // old `to ?? 'LESSONS.md'` forced every MCP-driven promote into LESSONS,
417
+ // bypassing the router (Hole C) — and the MCP path is the PRIMARY one (Claude
418
+ // drives the kit via the tool, not the CLI). An explicit `to` still wins.
419
+ const r = lessonsPromote({ id, projectRoot, userDir, to });
415
420
  if (r.action !== 'promoted' && r.action !== 'queued') return mcpToolError(r);
416
421
  return {
417
422
  content: [{ type: 'text', text: JSON.stringify(
@@ -421,6 +426,10 @@ function makeMkLessonsPromote({ projectRoot, userDir }) {
421
426
  id: r.id,
422
427
  target: r.target,
423
428
  section: r.section,
429
+ // 151.11: surface the optional high-recurrence MENTION so Claude MAY
430
+ // relay it in conversation (a heads-up, NOT a gate — never blocks; only
431
+ // present when the fact recurred enough to be worth a word).
432
+ ...(r.mention ? { mention: r.mention } : {}),
424
433
  ...(r.action === 'queued'
425
434
  ? { status: 'queued', hint: 'Promotion routed to the user-tier review/conflict queue — it lands once resolved.' }
426
435
  : {}),
@@ -59,6 +59,7 @@ import { checkPoisonGuard, logPoisonGuardRejection } from './poison-guard.mjs';
59
59
  import { detectConflicts, writeConflictEntry } from './conflict-queue.mjs';
60
60
  import { sanitizeHomePaths } from './sanitize.mjs';
61
61
  import { sanitizePrivacyTags } from './privacy.mjs';
62
+ import { applyTrustSignal } from './trust-signal.mjs';
62
63
 
63
64
  const VALID_ACTIONS = new Set(['add', 'replace', 'remove']);
64
65
 
@@ -354,6 +355,11 @@ function doAdd(opts) {
354
355
  // named-args — Task 25 originally called it as an object.)
355
356
  const proposedId = generateId(addOpts.tier, addOpts.text);
356
357
  const ts = opts.now ?? nowIso();
358
+ // Task 151.8: a new write CONTRADICTING an existing fact (routed to the
359
+ // conflict queue) is the contradiction passive signal → DAMPEN the existing
360
+ // fact's trust_score. Best-effort overlay on the rebuildable index; the queue
361
+ // routing is unaffected.
362
+ applyTrustSignal({ projectRoot: opts.projectRoot, id: conflict.existingId, event: 'dampen' });
357
363
  return writeConflictEntry({
358
364
  tier: opts.tier,
359
365
  projectRoot: opts.projectRoot,
@@ -499,6 +505,18 @@ function doReplace(opts) {
499
505
  extra: { oldId: match.id, newId: addResult.id, scratchpad: opts.scratchpad },
500
506
  });
501
507
 
508
+ // Task 151.8: a replace SUPERSEDES the old fact → DAMPEN its trust_score (the
509
+ // supersession passive signal). Covers the REPLACE path (direct + auto-persona's
510
+ // persona-supersede, which calls memoryWrite({action:'replace'}) → here).
511
+ // KNOWN GAP (honest scope): two OTHER supersession writers set `superseded_by`
512
+ // WITHOUT routing through doReplace and therefore do NOT dampen yet —
513
+ // conflict-queue.mjs::mergeScratchpadBullets (merge-both) and
514
+ // merge-facts.mjs::moveToSuperseded. Wiring those is deferred to Task 151.12
515
+ // (the supersede-hooks sub-task), where a single `superseded_by`-write chokepoint
516
+ // is the right place. Best-effort overlay on the rebuildable index — never breaks
517
+ // the replace.
518
+ applyTrustSignal({ projectRoot: opts.projectRoot, id: match.id, event: 'dampen' });
519
+
502
520
  return {
503
521
  action: 'replaced',
504
522
  oldId: match.id,
@@ -27,6 +27,8 @@ import { appendAuditEntry, nowIso, REASON_CODES } from './audit-log.mjs';
27
27
  import { ERROR_CATEGORIES, errorResult, notFoundResult } from './result-shapes.mjs';
28
28
  import { writeFact } from './write-fact.mjs';
29
29
  import { reindex } from './reindex.mjs';
30
+ import { applyTrustSignal } from './trust-signal.mjs';
31
+ import { openIndexDb } from './index-db.mjs';
30
32
 
31
33
  function listLiveFactFiles(factDir) {
32
34
  if (!existsSync(factDir)) return [];
@@ -205,6 +207,23 @@ export function mergeFacts(opts = {}) {
205
207
  // index rebuild is best-effort; the merge already succeeded
206
208
  }
207
209
 
210
+ // Task 151.12 — a merge SUPERSEDES the two originals → DAMPEN their trust_score
211
+ // (the supersession passive signal; 151.8 wired the replace path, this closes
212
+ // the merge-path gap). Best-effort overlay — never breaks the merge; a superseded
213
+ // fact's row may already be filtered, in which case applyTrustSignal no-ops.
214
+ // Share ONE index-db handle across the two dampens (avoid open/close per id).
215
+ try {
216
+ const sigDb = openIndexDb({ projectRoot });
217
+ try {
218
+ applyTrustSignal({ id: idA, event: 'dampen', db: sigDb });
219
+ applyTrustSignal({ id: idB, event: 'dampen', db: sigDb });
220
+ } finally {
221
+ sigDb.close();
222
+ }
223
+ } catch {
224
+ // best-effort: the trust dampen must never break the merge.
225
+ }
226
+
208
227
  const ts = now ?? nowIso();
209
228
  appendAuditEntry(tierRoot, {
210
229
  ts,
@@ -36,6 +36,25 @@ import {
36
36
  } from 'node:fs';
37
37
  import { join, dirname } from 'node:path';
38
38
 
39
+ // Task 70.4 — the invisible / zero-width / bidi code points, listed EXPLICITLY
40
+ // (no literal invisible chars in source — those are unreadable + editor-mangleable).
41
+ // Built into a regex char-class via `String.fromCodePoint` at module load.
42
+ const INVISIBLE_UNICODE_CODEPOINTS = [
43
+ 0x00ad, // soft hyphen
44
+ 0x061c, // Arabic letter mark
45
+ 0x180e, // Mongolian vowel separator
46
+ 0x200b, 0x200c, 0x200d, // zero-width space / non-joiner / joiner
47
+ 0x2060, // word joiner
48
+ 0x2066, 0x2067, 0x2068, 0x2069, // bidi isolates: LRI / RLI / FSI / PDI
49
+ 0x202a, 0x202b, 0x202c, 0x202d, 0x202e, // bidi embeds/overrides: LRE/RLE/PDF/LRO/RLO
50
+ 0xfeff, // BOM / zero-width no-break space
51
+ ];
52
+ function buildInvisibleUnicodeRe() {
53
+ const cls = INVISIBLE_UNICODE_CODEPOINTS.map((cp) => `\\u${cp.toString(16).padStart(4, '0')}`).join('');
54
+ return new RegExp(`[${cls}]`);
55
+ }
56
+ const INVISIBLE_UNICODE_RE = buildInvisibleUnicodeRe();
57
+
39
58
  // --- Pattern catalog -------------------------------------------------
40
59
  // Each pattern is { id, re, category }. The id is the stable
41
60
  // machine-parseable name that shows up in poison-guard.log NDJSON +
@@ -204,6 +223,29 @@ const INJECTION_PATTERNS = [
204
223
  category: 'injection',
205
224
  re: /disregard the above/i,
206
225
  },
226
+ // Task 70.4 — invisible / zero-width / bidi Unicode. A hidden-instruction
227
+ // vector: characters invisible to a human reviewer can smuggle text past the
228
+ // eye AND past the other patterns, then ship with `git clone` in committed
229
+ // memory. The set (Hermes parity + the Trojan-Source bidi class — kiro-design
230
+ // §699 / kiro-requirements):
231
+ // • zero-width: U+200B ZWSP, U+200C ZWNJ, U+200D ZWJ, U+2060 word-joiner,
232
+ // U+FEFF BOM/ZWNBSP
233
+ // • bidi controls (Trojan-Source): U+202A–U+202E (LRE/RLE/PDF/LRO/RLO),
234
+ // U+2066–U+2069 (LRI/RLI/FSI/PDI)
235
+ // • other invisibles: U+00AD soft hyphen, U+061C Arabic letter mark,
236
+ // U+180E Mongolian vowel separator
237
+ // NOT included: ordinary whitespace (space/tab/newline) — those are visible
238
+ // structure, not a hidden vector, so legitimate prose never false-positives.
239
+ // Implemented as a Unicode-property + explicit-codepoint class via the
240
+ // `buildInvisibleUnicodeRe()` helper below (NOT literal invisible chars inline,
241
+ // which would be unreadable + editor-mangleable). Verified to match exactly the
242
+ // 17 code points listed above with zero false positives on whitespace / ASCII /
243
+ // accents / CJK / emoji.
244
+ {
245
+ id: 'injection_invisible_unicode',
246
+ category: 'injection',
247
+ re: INVISIBLE_UNICODE_RE,
248
+ },
207
249
  ];
208
250
 
209
251
  const ALL_PATTERNS = [...SECRET_PATTERNS, ...INJECTION_PATTERNS];
@@ -237,3 +237,30 @@ export function readBullet(opts = {}) {
237
237
  if (!provenance) return null;
238
238
  return { id, text, provenance };
239
239
  }
240
+
241
+ // The template-seed sentinel (Task 183 / D-247): every scaffolded `(example)`
242
+ // placeholder bullet (SOUL/USER/HABITS/LESSONS/machine-paths/overrides) ships
243
+ // with an all-zero content sha1 (`0{40}`) + `at: 2020-01-01T…`. A REAL captured
244
+ // fact always has a real content hash, so the all-zero sha1 is an unambiguous
245
+ // "scaffolding the user never replaced" marker. Shared here (the module that
246
+ // owns bullet provenance) so the indexer and inject-context agree — a seed the
247
+ // inject path already skips must not sneak into the search index either.
248
+ export const SEED_SENTINEL_SHA1 = '0'.repeat(40);
249
+
250
+ /**
251
+ * True if a parsed provenance object is a scaffold seed (all-zero sha1).
252
+ *
253
+ * NOTE (Task 183 review M1): inject-context.mjs has a BROADER local check —
254
+ * all-zero-sha1 OR the literal `(P-XXX) (example)` text shape. The two agree on
255
+ * the load-bearing sha1 sentinel (a real seed always carries both markers), so
256
+ * they don't diverge in practice. A v0.4.x follow-up may unify them (have
257
+ * inject import this + layer its text check on top — one sentinel, two
258
+ * consumers); deliberately NOT done here to avoid churning a working inject
259
+ * security-path filter right before the v0.4.3 release.
260
+ *
261
+ * @param {object|null} provenance a parseBulletProvenance() result
262
+ * @returns {boolean}
263
+ */
264
+ export function isSeedProvenance(provenance) {
265
+ return !!provenance && provenance.sha1 === SEED_SENTINEL_SHA1;
266
+ }