@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.
- package/package.json +1 -1
- package/src/audit-log.mjs +1 -0
- package/src/auto-persona.mjs +161 -18
- package/src/config-core.mjs +17 -15
- package/src/conflict-queue.mjs +18 -0
- package/src/graduation.mjs +39 -0
- package/src/heat.mjs +75 -0
- package/src/index-db.mjs +22 -0
- package/src/index-rebuild.mjs +67 -14
- package/src/inject-context.mjs +6 -0
- package/src/lessons-promote.mjs +95 -12
- package/src/mcp-server.mjs +10 -1
- package/src/memory-write.mjs +18 -0
- package/src/merge-facts.mjs +19 -0
- package/src/poison-guard.mjs +42 -0
- package/src/provenance.mjs +27 -0
- package/src/scratchpad.mjs +64 -25
- package/src/trust-score.mjs +120 -0
- package/src/trust-signal.mjs +73 -0
- package/src/write-fact.mjs +49 -4
package/src/index-rebuild.mjs
CHANGED
|
@@ -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
|
-
//
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
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
|
-
|
|
691
|
-
|
|
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
|
}
|
package/src/inject-context.mjs
CHANGED
|
@@ -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 ||
|
package/src/lessons-promote.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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:
|
|
97
|
-
section:
|
|
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
|
-
|
|
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:
|
|
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 ===
|
|
202
|
+
const supersededHit = res.superseded.find((s) => s.target === finalTarget);
|
|
120
203
|
if (supersededHit) {
|
|
121
|
-
return { action: 'promoted', id, target:
|
|
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 ===
|
|
208
|
+
const conflictHit = res.conflicts.find((q) => q.target === finalTarget);
|
|
126
209
|
if (conflictHit) {
|
|
127
|
-
return { action: 'queued', id, target:
|
|
210
|
+
return { action: 'queued', id, target: finalTarget, section: candidate.section, reason: 'conflict' };
|
|
128
211
|
}
|
|
129
|
-
const queuedHit = res.queued.find((q) => q.target ===
|
|
212
|
+
const queuedHit = res.queued.find((q) => q.target === finalTarget);
|
|
130
213
|
return {
|
|
131
214
|
action: 'queued',
|
|
132
215
|
id,
|
|
133
|
-
target:
|
|
216
|
+
target: finalTarget,
|
|
134
217
|
section: candidate.section,
|
|
135
218
|
reason: queuedHit?.reason ?? 'not-promoted',
|
|
136
219
|
};
|
package/src/mcp-server.mjs
CHANGED
|
@@ -411,7 +411,12 @@ function makeMkTrust({ projectRoot, userDir }) {
|
|
|
411
411
|
|
|
412
412
|
function makeMkLessonsPromote({ projectRoot, userDir }) {
|
|
413
413
|
return async ({ id, to }) => {
|
|
414
|
-
|
|
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
|
: {}),
|
package/src/memory-write.mjs
CHANGED
|
@@ -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,
|
package/src/merge-facts.mjs
CHANGED
|
@@ -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,
|
package/src/poison-guard.mjs
CHANGED
|
@@ -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];
|
package/src/provenance.mjs
CHANGED
|
@@ -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
|
+
}
|