@lh8ppl/claude-memory-kit 0.3.0 → 0.3.2

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.
@@ -0,0 +1,223 @@
1
+ // The append-only decision journal — context/DECISIONS.md (Task 147, D-161).
2
+ //
3
+ // A chronological, human-readable page of every decision + its why. The VIEW
4
+ // the kit was missing: decisions are captured as facts but scattered across N
5
+ // per-fact files with no chronological decision page (cmk search is pull, not
6
+ // browse; MEMORY.md is bounded + rolls). This is the squad `decisions.md` /
7
+ // our own DECISION-LOG.md equivalent, made automatic.
8
+ //
9
+ // LIFECYCLE — APPEND-ONLY, never regenerated, never parked (D-161):
10
+ // - This is NOT a derived view like INDEX.md. Regenerating from live facts
11
+ // would silently ERASE superseded/forgotten decisions — rewriting history
12
+ // to look like the current state was always obvious (the exact failure the
13
+ // decision-trail-preservation rule exists to prevent).
14
+ // - A decision journal is unbounded by design: old decisions are the MOST
15
+ // valuable part (they explain why the codebase is shaped as it is), so the
16
+ // MEMORY.md rolling-window must NOT apply.
17
+ // - Mechanics: new decision → appended; tombstoned → its entry MARKED
18
+ // retracted IN PLACE (never removed); every pre-existing entry survives
19
+ // every update.
20
+ //
21
+ // The update is triggered like a derived view (runs where reindex runs, so the
22
+ // journal stays current) but its WRITE LOGIC is append-only — the file is the
23
+ // accumulator, the facts are only the trigger. Each entry carries a stable
24
+ // machine marker `<!-- decision:P-XXXXXXXX -->` so the updater knows which ids
25
+ // are already journaled + which entries to annotate, without parsing prose
26
+ // (and so a human can freely add their own prose between entries — preserved).
27
+ //
28
+ // v0.3.2 scope: explicit signals only (capture appends; forget marks retracted;
29
+ // explicit supersession annotates). AUTOMATIC semantic contradiction-detection
30
+ // is deferred to F-D / Task 95.
31
+
32
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
33
+ import { join } from 'node:path';
34
+ import { parse as parseFrontmatter } from './frontmatter.mjs';
35
+ import { ID_PATTERN } from './tier-paths.mjs';
36
+
37
+ export const DECISIONS_HEADER =
38
+ '# Decisions\n\n' +
39
+ '> Append-only decision journal — every decision the kit captured, in order, with its why.\n' +
40
+ '> Maintained by claude-memory-kit (`cmk digest`). Superseded/retracted entries stay (the trail is the point).';
41
+
42
+ // Only this fact type is a "decision" in the kit taxonomy (the project/state
43
+ // category — what project-memory's decisions.md and our DECISION-LOG track).
44
+ const DECISION_TYPE = 'project';
45
+
46
+ const markerFor = (id) => `<!-- decision:${id} -->`;
47
+ const RETRACT_TAG = '_(retracted';
48
+
49
+ /** The yyyy-mm-dd slice of an ISO timestamp, or the raw value if unparseable. */
50
+ function dateOnly(iso) {
51
+ if (typeof iso !== 'string') return 'unknown-date';
52
+ const m = iso.match(/^(\d{4}-\d{2}-\d{2})/);
53
+ return m ? m[1] : iso;
54
+ }
55
+
56
+ /**
57
+ * Render ONE journal entry for a decision fact. The machine marker lets the
58
+ * updater dedup + annotate; the human-readable lines are Title / date / Why / id.
59
+ *
60
+ * @param {{id:string,title:string,createdAt?:string,why?:string|null}} f
61
+ * @returns {string} the entry block (no trailing newline)
62
+ */
63
+ export function buildDecisionEntry(f) {
64
+ const date = dateOnly(f.createdAt);
65
+ const lines = [
66
+ markerFor(f.id),
67
+ `### ${f.title}`,
68
+ `**When:** ${date} · **Fact:** \`${f.id}\``,
69
+ ];
70
+ if (f.why && String(f.why).trim()) {
71
+ lines.push(`**Why:** ${String(f.why).trim()}`);
72
+ }
73
+ return lines.join('\n');
74
+ }
75
+
76
+ // The kit's id matcher, DERIVED from the canonical ID_PATTERN (tier-paths.mjs)
77
+ // so the base32 alphabet lives in exactly ONE place and can't drift. The
78
+ // original bug: this module hardcoded `[A-Z2-9]` (uppercase only), but the real
79
+ // alphabet includes a lowercase `a` — so any id containing `a` never matched
80
+ // "already journaled" → re-appended on EVERY digest run (the cut-gate find).
81
+ // Strip the `^…$` anchors to embed the pattern inside larger regexes.
82
+ const ID_CHARS = ID_PATTERN.source.replace(/^\^/, '').replace(/\$$/, '');
83
+
84
+ /** ids already present in the journal body (by their machine marker). */
85
+ function journaledIds(content) {
86
+ const ids = new Set();
87
+ const re = new RegExp(`<!-- decision:(${ID_CHARS}) -->`, 'g');
88
+ let m;
89
+ while ((m = re.exec(content)) !== null) ids.add(m[1]);
90
+ return ids;
91
+ }
92
+
93
+ /**
94
+ * Append-only journal update (D-161). Pure: content in → content out.
95
+ *
96
+ * @param {object} a
97
+ * @param {string} a.existingContent current DECISIONS.md (‘’ if absent)
98
+ * @param {Array} a.facts live decision-class facts ({id,type,title,createdAt,why})
99
+ * @param {Set<string>} a.tombstonedIds ids whose fact has been forgotten
100
+ * @param {string} a.now ISO timestamp for retraction stamps
101
+ * @returns {string} the new DECISIONS.md content
102
+ */
103
+ export function updateDecisionsJournal({ existingContent = '', facts = [], tombstonedIds = new Set(), now }) {
104
+ let content = existingContent.trim() === '' ? DECISIONS_HEADER + '\n' : existingContent;
105
+ const already = journaledIds(content);
106
+
107
+ // 1) Append entries for decision-class facts not yet journaled.
108
+ const newEntries = [];
109
+ for (const f of facts) {
110
+ if (f.type !== DECISION_TYPE) continue; // only decisions
111
+ if (already.has(f.id)) continue; // already journaled — never duplicate
112
+ newEntries.push(buildDecisionEntry(f));
113
+ already.add(f.id);
114
+ }
115
+ if (newEntries.length > 0) {
116
+ if (!content.endsWith('\n')) content += '\n';
117
+ content += '\n' + newEntries.join('\n\n') + '\n';
118
+ }
119
+
120
+ // 2) Mark retracted (in place) any journaled entry whose fact is now
121
+ // tombstoned and not already marked. Never removes the entry.
122
+ const stamp = dateOnly(now);
123
+ for (const id of tombstonedIds) {
124
+ const marker = markerFor(id);
125
+ const idx = content.indexOf(marker);
126
+ if (idx === -1) continue; // not journaled — nothing to retract
127
+ // Bound the search to THIS entry's span — up to the next decision marker
128
+ // (or end-of-file). Prevents a malformed/hand-edited entry with no heading
129
+ // from attaching the retraction note to the NEXT entry's heading.
130
+ const nextMarker = content.indexOf('<!-- decision:', idx + marker.length);
131
+ const spanEnd = nextMarker === -1 ? content.length : nextMarker;
132
+ // Find this entry's heading line (the `### …` after the marker, within span).
133
+ const headingStart = content.indexOf('### ', idx);
134
+ if (headingStart === -1 || headingStart >= spanEnd) continue;
135
+ const headingEnd = content.indexOf('\n', headingStart);
136
+ if (headingEnd === -1) continue;
137
+ // Already retracted? (the note sits right after the heading)
138
+ const afterHeading = content.slice(headingEnd + 1, headingEnd + 1 + RETRACT_TAG.length);
139
+ if (afterHeading === RETRACT_TAG) continue;
140
+ const note = `${RETRACT_TAG} ${stamp})_`;
141
+ content = content.slice(0, headingEnd + 1) + note + '\n' + content.slice(headingEnd + 1);
142
+ }
143
+
144
+ if (!content.endsWith('\n')) content += '\n';
145
+ return content;
146
+ }
147
+
148
+ // --- File-IO orchestration (the impure shell over the pure core) ----------
149
+
150
+ // Leading indent is [ \t]* (NOT \s*) so it can't match the newline the
151
+ // (?:^|\n) anchor already consumed — that overlap is the backtracking
152
+ // ambiguity SonarCloud flags as ReDoS. Disjoint character classes = linear.
153
+ const RICH_WHY_RE = /(?:^|\n)[ \t]*\*\*Why:\*\*[ \t]*([^\n]+)/;
154
+
155
+ /** Read decision-class facts (type:project) from the project tier. */
156
+ function readProjectDecisionFacts(projectRoot) {
157
+ const dir = join(projectRoot, 'context', 'memory');
158
+ const out = [];
159
+ if (!existsSync(dir)) return out;
160
+ for (const name of readdirSync(dir)) {
161
+ if (!name.endsWith('.md') || name === 'INDEX.md') continue;
162
+ try {
163
+ const { frontmatter, body } = parseFrontmatter(readFileSync(join(dir, name), 'utf8'));
164
+ if (!frontmatter?.id || frontmatter.type !== DECISION_TYPE) continue;
165
+ if (frontmatter.deleted_at) continue;
166
+ const whyMatch = String(body ?? '').match(RICH_WHY_RE);
167
+ out.push({
168
+ id: frontmatter.id,
169
+ type: frontmatter.type,
170
+ title: frontmatter.title ?? frontmatter.id,
171
+ createdAt: frontmatter.created_at ?? null,
172
+ why: whyMatch ? whyMatch[1].trim() : null,
173
+ });
174
+ } catch {
175
+ // unparseable file — reindex/HC-4 own that class; the journal skips it
176
+ }
177
+ }
178
+ // Stable chronological order (oldest first) so appends read like a timeline.
179
+ out.sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
180
+ return out;
181
+ }
182
+
183
+ /** ids of forgotten facts (tombstone archive). */
184
+ function readTombstonedIds(projectRoot) {
185
+ const ids = new Set();
186
+ const dir = join(projectRoot, 'context', 'memory', 'archive', 'tombstones');
187
+ if (!existsSync(dir)) return ids;
188
+ for (const name of readdirSync(dir)) {
189
+ const m = name.match(new RegExp(`^(${ID_CHARS})\\.md$`));
190
+ if (m) ids.add(m[1]);
191
+ }
192
+ return ids;
193
+ }
194
+
195
+ /**
196
+ * Read → append-only update → write context/DECISIONS.md. Idempotent: a run
197
+ * with nothing new is a no-op write (same bytes). Best-effort: never throws
198
+ * into the caller (a journal failure must not break a capture/read path).
199
+ *
200
+ * @returns {{written:boolean, path:string, appended:number}|{written:false,error:string}}
201
+ */
202
+ export function syncDecisionsJournal({ projectRoot, now } = {}) {
203
+ try {
204
+ const path = join(projectRoot, 'context', 'DECISIONS.md');
205
+ const existingContent = existsSync(path) ? readFileSync(path, 'utf8') : '';
206
+ const facts = readProjectDecisionFacts(projectRoot);
207
+ const tombstonedIds = readTombstonedIds(projectRoot);
208
+ const before = existingContent;
209
+ const next = updateDecisionsJournal({
210
+ existingContent,
211
+ facts,
212
+ tombstonedIds,
213
+ now: now ?? new Date().toISOString(),
214
+ });
215
+ if (next !== before) {
216
+ writeFileSync(path, next, 'utf8');
217
+ return { written: true, path, appended: next.length - before.length };
218
+ }
219
+ return { written: false, path, appended: 0 };
220
+ } catch (err) {
221
+ return { written: false, error: err?.message ?? String(err) };
222
+ }
223
+ }
package/src/digest.mjs ADDED
@@ -0,0 +1,89 @@
1
+ // `cmk digest` — a regenerated, readable render of everything the kit currently
2
+ // knows (Task 147, D-132). Facts by type + the persona + active threads, as one
3
+ // markdown page. The README-demo artifact.
4
+ //
5
+ // REGENERATED (not append-only): unlike DECISIONS.md (the permanent journal),
6
+ // the digest is a CURRENT-KNOWLEDGE snapshot — it should reflect only what
7
+ // exists now, so it is rebuilt on every invocation (the INDEX.md lifecycle,
8
+ // correct here). The two surfaces differ on purpose: digest = "what do we know
9
+ // now", DECISIONS.md = "what did we decide over time" (D-161).
10
+ //
11
+ // Read-only by contract: pure reads over the fact archive + scratchpads. The
12
+ // `--decisions` flag also triggers the DECISIONS.md journal sync (the one
13
+ // mutation, delegated to the append-only writer).
14
+
15
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ import { parse as parseFrontmatter } from './frontmatter.mjs';
18
+
19
+ const TYPE_ORDER = ['project', 'feedback', 'reference', 'user'];
20
+ const TYPE_LABEL = {
21
+ project: 'Decisions & project state',
22
+ feedback: 'Working-style & preferences',
23
+ reference: 'References',
24
+ user: 'About the user',
25
+ };
26
+
27
+ function readFacts(projectRoot) {
28
+ const dir = join(projectRoot, 'context', 'memory');
29
+ const facts = [];
30
+ if (!existsSync(dir)) return facts;
31
+ for (const name of readdirSync(dir)) {
32
+ if (!name.endsWith('.md') || name === 'INDEX.md') continue;
33
+ try {
34
+ const { frontmatter } = parseFrontmatter(readFileSync(join(dir, name), 'utf8'));
35
+ if (!frontmatter?.id || frontmatter.deleted_at) continue;
36
+ facts.push({
37
+ id: frontmatter.id,
38
+ type: frontmatter.type ?? 'unknown',
39
+ title: frontmatter.title ?? frontmatter.id,
40
+ trust: frontmatter.trust ?? 'unknown',
41
+ createdAt: frontmatter.created_at ?? null,
42
+ });
43
+ } catch {
44
+ // unparseable — reindex/HC-4 own that class
45
+ }
46
+ }
47
+ return facts;
48
+ }
49
+
50
+ /**
51
+ * Build the digest markdown from facts (pure — exported for testing).
52
+ * @param {Array} facts
53
+ * @param {{now?:string}} [opts]
54
+ */
55
+ export function buildDigest(facts, { now } = {}) {
56
+ const stamp = (now ?? new Date().toISOString()).slice(0, 10);
57
+ const lines = [`# Memory digest — ${stamp}`, ''];
58
+ if (facts.length === 0) {
59
+ lines.push('_Memory is empty — capture starts as you work._', '');
60
+ return lines.join('\n');
61
+ }
62
+ lines.push(`${facts.length} fact(s) in project memory.`, '');
63
+
64
+ const byType = new Map();
65
+ for (const f of facts) {
66
+ if (!byType.has(f.type)) byType.set(f.type, []);
67
+ byType.get(f.type).push(f);
68
+ }
69
+ const orderedTypes = [
70
+ ...TYPE_ORDER.filter((t) => byType.has(t)),
71
+ ...[...byType.keys()].filter((t) => !TYPE_ORDER.includes(t)),
72
+ ];
73
+ for (const type of orderedTypes) {
74
+ const group = byType.get(type).slice().sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
75
+ lines.push(`## ${TYPE_LABEL[type] ?? type} (${group.length})`, '');
76
+ for (const f of group) {
77
+ const date = String(f.createdAt ?? '').slice(0, 10) || '—';
78
+ lines.push(`- **${f.title}** · \`${f.id}\` · ${f.trust} · ${date}`);
79
+ }
80
+ lines.push('');
81
+ }
82
+ return lines.join('\n');
83
+ }
84
+
85
+ /** Read facts + render the digest for a project (read-only). */
86
+ export function digest({ projectRoot, now } = {}) {
87
+ const facts = readFacts(projectRoot);
88
+ return buildDigest(facts, { now });
89
+ }
package/src/doctor.mjs CHANGED
@@ -1,4 +1,4 @@
1
- // `cmk doctor` — health checks HC-1..HC-7 (Task 37, T-031; memsearch HC-1/HC-7 removed in Task 120).
1
+ // `cmk doctor` — health checks HC-1..HC-8 (Task 37, T-031; memsearch HC-1/HC-7 removed in Task 120; HC-8 native bindings added in Task 141a).
2
2
  //
3
3
  // Public boundary:
4
4
  // async runDoctor({projectRoot, userDir, now, promptUser?, ...overrides})
@@ -44,6 +44,8 @@ import { nowIso } from './audit-log.mjs';
44
44
  import { detectStaleLocks } from './lock-discipline.mjs';
45
45
  import { cronSentinelPath } from './lazy-compress.mjs';
46
46
  import { getNativeAutoMemoryState } from './native-memory.mjs';
47
+ import { checkKitBinding, checkEmbedderBinding } from './native-binding.mjs';
48
+ import { resolveDefaultSearchMode } from './semantic-backend.mjs';
47
49
 
48
50
  const TWO_DAYS_MS = 2 * 24 * 60 * 60 * 1000;
49
51
  const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000;
@@ -470,13 +472,67 @@ function hc7StaleLocks({ projectRoot, userDir }) {
470
472
  };
471
473
  }
472
474
 
475
+ // --- HC-8: native bindings present (npm 12 readiness, Task 141a) -------
476
+ // The BACKSTOP, not the primary UX: `cmk install` probes + asks inline
477
+ // (the user's 2026-06-12 steer); HC-8 catches the after-the-fact states
478
+ // (npm upgraded later, package reinstalled without the allow flag).
479
+ // The repair is an `npm install -g` → requiresInstall per the design §14
480
+ // ask-before-install rule.
481
+ async function hc8NativeBindings({ projectRoot, kitBindingProbe, embedderBindingProbe }) {
482
+ const kitProbe = kitBindingProbe ?? checkKitBinding;
483
+ const kit = kitProbe();
484
+ if (!kit.ok) {
485
+ return {
486
+ id: 'HC-8',
487
+ name: 'Native bindings present (npm 12 readiness)',
488
+ status: 'fail',
489
+ message: `better-sqlite3 native binding unavailable (${kit.reason}) — most common cause: npm 12 blocks dependency install scripts by default, so a fresh install skips the binding build (a Node major upgrade is the other); search/reindex will crash until it is rebuilt`,
490
+ recoveryCommand: kit.remedy,
491
+ requiresInstall: true,
492
+ };
493
+ }
494
+ // The embedder matters only when this project actually defaults to it.
495
+ const mode = resolveDefaultSearchMode({ projectRoot });
496
+ if (mode === 'keyword') {
497
+ return {
498
+ id: 'HC-8',
499
+ name: 'Native bindings present (npm 12 readiness)',
500
+ status: 'pass',
501
+ message: 'better-sqlite3 binding healthy (semantic not configured — embedder not checked)',
502
+ };
503
+ }
504
+ const embedderProbe = embedderBindingProbe ?? checkEmbedderBinding;
505
+ const embedder = await embedderProbe();
506
+ if (!embedder.ok) {
507
+ const state = embedder.installed
508
+ ? `installed but its native binding failed (${embedder.reason}) — npm 12 blocks onnxruntime-node's install script by default`
509
+ : `not installed, but search.default_mode is '${mode}'`;
510
+ return {
511
+ id: 'HC-8',
512
+ name: 'Native bindings present (npm 12 readiness)',
513
+ status: 'fail',
514
+ message: `semantic embedder ${state}; searches degrade to keyword until fixed`,
515
+ recoveryCommand: embedder.remedy,
516
+ requiresInstall: true,
517
+ };
518
+ }
519
+ return {
520
+ id: 'HC-8',
521
+ name: 'Native bindings present (npm 12 readiness)',
522
+ status: 'pass',
523
+ message: `better-sqlite3 binding healthy; embedder import OK (default mode: ${mode}; the deep pipeline check runs at --with-semantic warm)`,
524
+ };
525
+ }
526
+
473
527
  /**
474
- * Run the full 7-check health audit.
528
+ * Run the full 8-check health audit.
475
529
  *
476
530
  * @param {object} opts
477
531
  * @param {string} opts.projectRoot
478
532
  * @param {string} [opts.userDir]
479
533
  * @param {string} [opts.now]
534
+ * @param {Function} [opts.kitBindingProbe] - HC-8 test seam.
535
+ * @param {Function} [opts.embedderBindingProbe] - HC-8 test seam.
480
536
  * @returns {Promise<{action, checks, duration_ms}>}
481
537
  *
482
538
  * Note: M3 fix (skill-review 2026-05-28) dropped the v0.1.0 `promptUser`
@@ -489,6 +545,8 @@ export async function runDoctor({
489
545
  projectRoot,
490
546
  userDir,
491
547
  now,
548
+ kitBindingProbe,
549
+ embedderBindingProbe,
492
550
  } = {}) {
493
551
  const t0 = Date.now();
494
552
  if (!projectRoot) {
@@ -510,10 +568,11 @@ export async function runDoctor({
510
568
  const c5 = hc5CronRegistered({ projectRoot });
511
569
  const c6 = hc6NativeAutoMemory({ projectRoot, now: ts });
512
570
  const c7 = hc7StaleLocks({ projectRoot, userDir: resolvedUserDir });
571
+ const c8 = await hc8NativeBindings({ projectRoot, kitBindingProbe, embedderBindingProbe });
513
572
 
514
573
  return {
515
574
  action: 'completed',
516
- checks: [c1, c2, c3, c4, c5, c6, c7],
575
+ checks: [c1, c2, c3, c4, c5, c6, c7, c8],
517
576
  duration_ms: Date.now() - t0,
518
577
  };
519
578
  }
package/src/forget.mjs CHANGED
@@ -172,6 +172,12 @@ function scrubAllScratchpads(tierRoot, id) {
172
172
  if (!entry.isFile()) continue;
173
173
  if (!entry.name.endsWith('.md')) continue;
174
174
  if (entry.name === 'INDEX.md') continue;
175
+ // DECISIONS.md is the APPEND-ONLY decision journal (Task 147 / D-161), NOT
176
+ // a scratchpad — forget must NOT strip its id-bearing lines (the marker +
177
+ // **Fact:** line). The journal sync marks the entry RETRACTED in place
178
+ // instead (preserving the trail). Scrubbing it here would delete the
179
+ // entry's marker and break the retract-in-place path (composition bug).
180
+ if (entry.name === 'DECISIONS.md') continue;
175
181
  const p = join(tierRoot, entry.name);
176
182
  const r = scrubScratchpadFile(p, id);
177
183
  if (r.changed) edits.push({ path: p, removed: r.removed });
@@ -40,7 +40,7 @@ import {
40
40
  } from './audit-log.mjs';
41
41
  import { ERROR_CATEGORIES, errorResult } from './result-shapes.mjs';
42
42
  import { writeBullet } from './provenance.mjs';
43
- import { createHash } from 'node:crypto';
43
+ import { hashContent } from './content-hash.mjs';
44
44
 
45
45
  const MEMORY_REL = ['context', 'MEMORY.md'];
46
46
 
@@ -236,7 +236,7 @@ export async function importAnthropicMemory({
236
236
  // an import failed and search degraded to the stale index (cut-gate9 F-13).
237
237
  const bulletLines = proposals
238
238
  .map((p) => {
239
- const sha1 = createHash('sha1').update(p.text, 'utf8').digest('hex');
239
+ const sha1 = hashContent(p.text);
240
240
  const formatted = writeBullet({
241
241
  id: p.id,
242
242
  text: p.text,