@lh8ppl/claude-memory-kit 0.3.0 → 0.3.1

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,333 @@
1
+ // `cmk import-claude-md` (Task 142, D-130).
2
+ //
3
+ // Public boundary:
4
+ // async importClaudeMd({projectRoot, file?, now?, dryRun?, acceptAll?, writeFactImpl?})
5
+ // → {action, mode?, reason?, proposals, accepted, skipped, rejected, errors, sourcePath, duration_ms}
6
+ //
7
+ // Onboards a project from the rules file the user already owns (CLAUDE.md,
8
+ // .cursorrules, AGENTS.md, any markdown/plain rules file): parses it into
9
+ // TYPED fact candidates and writes each through writeFact() — the kit's one
10
+ // safe write path. That composition (not re-implementation) is the point:
11
+ // writeFact already gives Poison_Guard screening, home-path sanitization,
12
+ // content-addressed dedup, INDEX reindex, and create-audit. The D-125 bug
13
+ // (import-anthropic hand-rolling its provenance comment and breaking the next
14
+ // reindex) is the precedent this design avoids.
15
+ //
16
+ // Differences from `cmk import-anthropic-memory` (the structural template):
17
+ // - target is the GRANULAR fact archive (context/memory/), not MEMORY.md
18
+ // bullets — rules-file content is durable and typed, not scratchpad;
19
+ // - fact `type` is inferred from the nearest markdown heading
20
+ // (user / feedback / reference, default project);
21
+ // - candidates inside the kit's own managed CLAUDE.md block and inside
22
+ // code fences are never proposed (boilerplate / shell examples).
23
+ //
24
+ // Explicit user action only. Never automatic. `--dry-run` previews; apply
25
+ // requires explicit `--yes` (same confirmation contract as the precedent).
26
+
27
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
28
+ import { isAbsolute, join } from 'node:path';
29
+ import { canonicalize, generateId } from '@lh8ppl/cmk-canonicalize';
30
+ import { hashContent } from './content-hash.mjs';
31
+ import { appendAuditEntry, nowIso, REASON_CODES } from './audit-log.mjs';
32
+ import { ERROR_CATEGORIES, errorResult } from './result-shapes.mjs';
33
+ import { writeFact } from './write-fact.mjs';
34
+ import { slugifyFact } from './rich-fact.mjs';
35
+ import { sanitizeHomePaths } from './sanitize.mjs';
36
+ import { parse as parseFrontmatter } from './frontmatter.mjs';
37
+
38
+ const DEFAULT_FILE = 'CLAUDE.md';
39
+ const IMPORT_SOURCE = 'claude-md';
40
+ // Below this length a line is noise ("go", "etc."), not a rule.
41
+ const MIN_CANDIDATE_CHARS = 8;
42
+
43
+ const MANAGED_BLOCK_START = /<!--\s*claude-memory-kit:start\b/;
44
+ const MANAGED_BLOCK_END = /<!--\s*claude-memory-kit:end\s*-->/;
45
+ // Linear-time by construction (S5852, the D-128 class): every adjacent
46
+ // pair is disjoint — `[ \t]+` can never donate characters to the `\S` that
47
+ // starts the capture — so the regex engine has no backtracking ambiguity.
48
+ // Captures keep trailing whitespace; every consumer already calls .trim().
49
+ const HEADING = /^(#{1,6})[ \t]+(\S.*)$/;
50
+ const LIST_ITEM = /^[ \t]*(?:[-*+]|\d+[.)])[ \t]+(\S.*)$/;
51
+ const CODE_FENCE = /^\s*(```|~~~)/;
52
+
53
+ /**
54
+ * Infer the kit fact type from the heading a candidate sits under.
55
+ * Heuristic by design — `--dry-run` shows the inferred type so the user can
56
+ * inspect before applying. Order matters: user-profile phrasing wins over the
57
+ * broad rule/style class, and \b on "reference" keeps "Preferences" from
58
+ * matching it.
59
+ *
60
+ * @param {string|null} heading
61
+ * @returns {'user'|'feedback'|'project'|'reference'}
62
+ */
63
+ export function inferFactType(heading) {
64
+ if (!heading) return 'project';
65
+ const h = String(heading).toLowerCase();
66
+ if (/prefer|about (me|the user)|profile|persona|communicat/.test(h)) return 'user';
67
+ if (/\b(link|reference|resource|url|bookmark)/.test(h)) return 'reference';
68
+ if (/rule|discipline|workflow|convention|anti-pattern|style|verification|review|testing|engineering|working/.test(h)) {
69
+ return 'feedback';
70
+ }
71
+ return 'project';
72
+ }
73
+
74
+ /**
75
+ * Parse a rules file into typed fact candidates.
76
+ *
77
+ * Primary shape: markdown list items (-, *, +, 1.) with the nearest heading
78
+ * as type context. Fallback shape (.cursorrules and other plain-text rules
79
+ * files): when the file has NO list items at all, every non-empty,
80
+ * non-heading line outside code fences is a candidate.
81
+ *
82
+ * Skipped in both shapes: code-fence content (shell examples, not rules) and
83
+ * the kit's own managed CLAUDE.md block (importing our boilerplate back into
84
+ * memory would be noise for every kit user).
85
+ *
86
+ * @param {string} text - the rules-file content.
87
+ * @returns {Array<{text: string, line: number, heading: string|null, type: string}>}
88
+ */
89
+ export function parseRulesFile(text) {
90
+ const lines = String(text).split(/\r?\n/);
91
+ const bullets = [];
92
+ const plain = [];
93
+ let heading = null;
94
+ let inFence = false;
95
+ let inManagedBlock = false;
96
+
97
+ for (let i = 0; i < lines.length; i++) {
98
+ const line = lines[i];
99
+ if (MANAGED_BLOCK_START.test(line)) {
100
+ inManagedBlock = true;
101
+ continue;
102
+ }
103
+ if (inManagedBlock) {
104
+ if (MANAGED_BLOCK_END.test(line)) inManagedBlock = false;
105
+ continue;
106
+ }
107
+ if (CODE_FENCE.test(line)) {
108
+ inFence = !inFence;
109
+ continue;
110
+ }
111
+ if (inFence) continue;
112
+
113
+ const h = HEADING.exec(line);
114
+ if (h) {
115
+ heading = h[2].trim();
116
+ continue;
117
+ }
118
+
119
+ const item = { line: i + 1, heading, type: inferFactType(heading) };
120
+ const m = LIST_ITEM.exec(line);
121
+ if (m && m[1].trim().length >= MIN_CANDIDATE_CHARS) {
122
+ bullets.push({ ...item, text: m[1].trim() });
123
+ continue;
124
+ }
125
+ const t = line.trim();
126
+ if (!m && t.length >= MIN_CANDIDATE_CHARS && !t.startsWith('<!--')) {
127
+ plain.push({ ...item, text: t });
128
+ }
129
+ }
130
+
131
+ return bullets.length > 0 ? bullets : plain;
132
+ }
133
+
134
+ // Canonical forms already present in memory: every MEMORY.md scratchpad
135
+ // bullet + every granular fact body. Imported fact bodies are the bare rule
136
+ // text, so a re-run canonicalize-matches its own first run here.
137
+ function collectExistingCanonical(projectRoot) {
138
+ const existing = new Set();
139
+ const memPath = join(projectRoot, 'context', 'MEMORY.md');
140
+ if (existsSync(memPath)) {
141
+ try {
142
+ for (const line of readFileSync(memPath, 'utf8').split(/\r?\n/)) {
143
+ const m = LIST_ITEM.exec(line);
144
+ if (m) {
145
+ const c = canonicalize(m[1].trim());
146
+ if (c) existing.add(c);
147
+ }
148
+ }
149
+ } catch {
150
+ // best-effort: unreadable scratchpad means no dedup hits from it
151
+ }
152
+ }
153
+ const factDir = join(projectRoot, 'context', 'memory');
154
+ if (existsSync(factDir)) {
155
+ for (const name of readdirSync(factDir)) {
156
+ if (!name.endsWith('.md') || name === 'INDEX.md') continue;
157
+ try {
158
+ const { body } = parseFrontmatter(readFileSync(join(factDir, name), 'utf8'));
159
+ const c = canonicalize(String(body ?? '').trim());
160
+ if (c) existing.add(c);
161
+ } catch {
162
+ // skip unparseable files; writeFact's own id dedup still backstops
163
+ }
164
+ }
165
+ }
166
+ return existing;
167
+ }
168
+
169
+ /**
170
+ * Run the import pipeline.
171
+ *
172
+ * @param {object} opts
173
+ * @param {string} opts.projectRoot
174
+ * @param {string} [opts.file] - rules file, relative to projectRoot or absolute (default CLAUDE.md)
175
+ * @param {string} [opts.now]
176
+ * @param {boolean} [opts.dryRun] - preview proposals; no file modified
177
+ * @param {boolean} [opts.acceptAll] - apply every proposal (the CLI's --yes)
178
+ * @param {Function} [opts.writeFactImpl] - test seam (default: the real writeFact)
179
+ * @returns {Promise<object>}
180
+ */
181
+ export async function importClaudeMd({
182
+ projectRoot,
183
+ file,
184
+ now,
185
+ dryRun = false,
186
+ acceptAll = false,
187
+ writeFactImpl = writeFact,
188
+ } = {}) {
189
+ const ts = now ?? nowIso();
190
+ const t0 = Date.now();
191
+
192
+ if (!projectRoot) {
193
+ return errorResult({
194
+ category: ERROR_CATEGORIES.MISSING_PROJECT_ROOT,
195
+ errors: ['projectRoot is required'],
196
+ duration_ms: Date.now() - t0,
197
+ });
198
+ }
199
+
200
+ const fileRel = file && String(file).trim() ? String(file).trim() : DEFAULT_FILE;
201
+ const sourcePath = isAbsolute(fileRel) ? fileRel : join(projectRoot, fileRel);
202
+ const done = (extra) => ({
203
+ action: 'completed',
204
+ proposals: [],
205
+ accepted: 0,
206
+ skipped: 0,
207
+ rejected: 0,
208
+ errors: 0,
209
+ sourcePath,
210
+ duration_ms: Date.now() - t0,
211
+ ...extra,
212
+ });
213
+
214
+ if (!existsSync(sourcePath)) return done({ reason: 'no-source' });
215
+
216
+ let sourceText;
217
+ try {
218
+ sourceText = readFileSync(sourcePath, 'utf8');
219
+ } catch (err) {
220
+ return done({ errors: 1, reason: `read-source-failed: ${err?.message ?? err}` });
221
+ }
222
+
223
+ const existingCanonical = collectExistingCanonical(projectRoot);
224
+ const tierRoot = join(projectRoot, 'context');
225
+ const proposals = [];
226
+ let skipped = 0;
227
+ // Dry-run / requires-confirmation must not touch ANY file — including the
228
+ // audit log. Skip entries are only audited when the user actually applied.
229
+ const auditSkips = acceptAll && !dryRun;
230
+
231
+ for (const candidate of parseRulesFile(sourceText)) {
232
+ // Sanitize BEFORE canonicalizing so the dedup key matches what writeFact
233
+ // actually lands on disk (it ids the sanitized body).
234
+ const sanitized = sanitizeHomePaths(candidate.text);
235
+ const canonical = canonicalize(sanitized);
236
+ if (!canonical) continue;
237
+ const id = generateId('P', sanitized);
238
+ if (existingCanonical.has(canonical)) {
239
+ skipped += 1;
240
+ if (auditSkips) {
241
+ try {
242
+ appendAuditEntry(tierRoot, {
243
+ ts,
244
+ action: 'import',
245
+ tier: 'P',
246
+ id,
247
+ reasonCode: REASON_CODES.IMPORT_SKIPPED_DUPLICATE,
248
+ extra: { source: IMPORT_SOURCE },
249
+ });
250
+ } catch {
251
+ // best-effort — never block the import flow on audit-log failure
252
+ }
253
+ }
254
+ continue;
255
+ }
256
+ existingCanonical.add(canonical); // same-file duplicates collapse to one proposal
257
+ proposals.push({
258
+ text: candidate.text,
259
+ line: candidate.line,
260
+ heading: candidate.heading,
261
+ type: candidate.type,
262
+ id,
263
+ });
264
+ }
265
+
266
+ if (dryRun) return done({ mode: 'dry-run', proposals, skipped });
267
+ if (!acceptAll && proposals.length > 0) {
268
+ return done({ mode: 'requires-confirmation', proposals, skipped });
269
+ }
270
+ if (proposals.length === 0) return done({ mode: 'apply', skipped });
271
+
272
+ let accepted = 0;
273
+ let rejected = 0;
274
+ let errors = 0;
275
+ // Two distinct rules can share a 60-char slug prefix (slugifyFact caps);
276
+ // the second would hit writeFact's filename-collision error and be lost.
277
+ // De-collide within the run by suffixing the (unique) source line.
278
+ const usedSlugs = new Set();
279
+ // The committed source_file field must never carry a username from an
280
+ // absolute --file argument (the D-51 name-privacy class).
281
+ const sourceFileField = sanitizeHomePaths(fileRel);
282
+ for (const p of proposals) {
283
+ const title = p.text.split('\n')[0].slice(0, 80);
284
+ let slug = slugifyFact(title);
285
+ if (usedSlugs.has(`${p.type}/${slug}`)) slug = `${slug}-l${p.line}`;
286
+ usedSlugs.add(`${p.type}/${slug}`);
287
+ const r = writeFactImpl({
288
+ tier: 'P',
289
+ type: p.type,
290
+ slug,
291
+ title,
292
+ body: p.text,
293
+ writeSource: 'imported',
294
+ trust: 'medium',
295
+ sourceFile: sourceFileField,
296
+ sourceLine: p.line,
297
+ // Content fingerprint for provenance — NOT a security context. Routes
298
+ // through the shared hashContent (SHA-256, D-149); see remember-core.mjs.
299
+ sourceSha1: hashContent(p.text),
300
+ projectRoot,
301
+ // writeFact's default create-audit is replaced by the richer-semantic
302
+ // IMPORT_APPLIED entry below (the merge-facts precedent).
303
+ audit: false,
304
+ });
305
+ if (r.action === 'created') {
306
+ accepted += 1;
307
+ try {
308
+ appendAuditEntry(tierRoot, {
309
+ ts,
310
+ action: 'import',
311
+ tier: 'P',
312
+ id: r.id,
313
+ reasonCode: REASON_CODES.IMPORT_APPLIED,
314
+ paths: { after: r.path },
315
+ extra: { source: IMPORT_SOURCE, trust: 'medium', write_source: 'imported' },
316
+ });
317
+ } catch {
318
+ // best-effort
319
+ }
320
+ } else if (r.action === 'skipped') {
321
+ skipped += 1;
322
+ } else if (r.errorCategory === ERROR_CATEGORIES.POISON_GUARD) {
323
+ // writeFact already logged the rejection to poison-guard.log (Door 4);
324
+ // count it honestly — a rejected secret is not an "error", it's the
325
+ // guard doing its job.
326
+ rejected += 1;
327
+ } else {
328
+ errors += 1;
329
+ }
330
+ }
331
+
332
+ return done({ mode: 'apply', proposals, accepted, skipped, rejected, errors });
333
+ }
@@ -42,11 +42,11 @@
42
42
  // established sources of truth and does NOT re-implement bullet/frontmatter
43
43
  // parsing or path resolution.
44
44
 
45
- import { createHash } from 'node:crypto';
46
45
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
47
46
  import { basename, join, relative } from 'node:path';
48
47
  import chokidar from 'chokidar';
49
48
  import { INDEX_DB_SCHEMA } from './index-db.mjs';
49
+ import { hashContent } from './content-hash.mjs';
50
50
  import { syncTranscriptChunks } from './transcript-index.mjs';
51
51
  import { readBullet, parseBulletProvenance } from './provenance.mjs';
52
52
  import { parse as parseFrontmatter } from './frontmatter.mjs';
@@ -95,8 +95,12 @@ export function listObservationSources({ projectRoot, userDir }) {
95
95
 
96
96
  // --- Helpers ----------------------------------------------------------
97
97
 
98
+ // Content fingerprint for the `files`-table mtime+sha1 diff key. The column
99
+ // name stays `sha1` for checkpoint back-compat; hashContent is SHA-256 (D-149).
100
+ // On the first boot after the algorithm change every checkpoint mismatches
101
+ // once and self-heals via the normal reindex.
98
102
  function sha1OfContent(content) {
99
- return createHash('sha1').update(content, 'utf8').digest('hex');
103
+ return hashContent(content);
100
104
  }
101
105
 
102
106
  function isoToEpochMs(iso) {
package/src/index.mjs CHANGED
@@ -70,6 +70,16 @@ export function buildProgram() {
70
70
  childCmd.action(() => sub.action(child.name));
71
71
  }
72
72
  }
73
+ // Task 129: a parent that has children AND its own action (e.g. `cmk
74
+ // config --show-origin <key>`, handled by the parent while get/set are
75
+ // children) must wire the parent action too — otherwise commander
76
+ // falls to the default "show help, exit 1" on a bare parent invocation
77
+ // with a flag. (Caught by the Task-129 live-test: `--show-origin`
78
+ // printed help instead of running.) Children still take precedence
79
+ // when a subcommand name is given.
80
+ if (typeof sub.action === 'function') {
81
+ cmd.action((...cmdArgs) => sub.action(...cmdArgs));
82
+ }
73
83
  } else {
74
84
  cmd.action((...cmdArgs) => sub.action(...cmdArgs));
75
85
  }
@@ -26,14 +26,19 @@ import {
26
26
  readdirSync,
27
27
  appendFileSync,
28
28
  statSync,
29
+ openSync,
30
+ readSync,
31
+ closeSync,
29
32
  } from 'node:fs';
30
33
  import { spawn } from 'node:child_process';
31
34
  import { join } from 'node:path';
32
35
  import { homedir } from 'node:os';
33
- import { SCRATCHPADS_BY_TIER, resolveTierRoot } from './tier-paths.mjs';
36
+ import { SCRATCHPADS_BY_TIER, resolveTierRoot, ID_PATTERN } from './tier-paths.mjs';
34
37
  import { nowIso } from './audit-log.mjs';
35
38
  import { detectStaleness } from './lazy-compress.mjs';
36
39
  import { isProvenanceCommentLine, parseBulletProvenance } from './provenance.mjs';
40
+ import { listConflictQueue } from './conflict-queue.mjs';
41
+ import { listReviewQueue } from './review-queue.mjs';
37
42
 
38
43
  // Importance ranking for value-ordered inject eviction (Task 93 / design §19.3).
39
44
  // When a tier exceeds its budget we drop the LOWEST-value sections first, not the
@@ -800,7 +805,14 @@ export function injectContext({
800
805
  // 7. Emit the Anthropic SessionStart hook output shape (design §5.1 +
801
806
  // Anthropic hook protocol). When the snapshot is empty, we still emit
802
807
  // the shape so downstream tooling can rely on the field's presence.
808
+ //
809
+ // Task 145 (D-130): `systemMessage` is the USER-DISPLAY channel (the
810
+ // D-116 primary-source check: additionalContext is model-facing,
811
+ // systemMessage is shown to the user) — one status line per session
812
+ // start, zero model-token cost. The trust loop every silent system
813
+ // lacks: when the kit works, the user finally SEES it working.
803
814
  const hookOutput = {
815
+ systemMessage: buildStatusLine({ snapshot, projectRoot, now: ts }),
804
816
  hookSpecificOutput: {
805
817
  hookEventName: HOOK_EVENT_NAME,
806
818
  additionalContext: snapshot,
@@ -816,3 +828,120 @@ export function injectContext({
816
828
  bytes: Buffer.byteLength(snapshot, 'utf8'),
817
829
  };
818
830
  }
831
+
832
+ // --- Task 145: the session-start status line (user-display) -------------
833
+
834
+ // Tail-read budget for audit.log: recency lives at the end; reading the
835
+ // whole file would grow with project age inside a 500ms-budget hook.
836
+ const STATUS_AUDIT_TAIL_BYTES = 64 * 1024;
837
+ const DAY_MS = 24 * 60 * 60 * 1000;
838
+ // Derived from the shared ID_PATTERN (tier-paths.mjs) — strip its ^/$
839
+ // anchors and wrap in the `(id)` bullet form. One alphabet, one source.
840
+ const SNAPSHOT_ID_RE = new RegExp(`\\((${ID_PATTERN.source.slice(1, -1)})\\)`, 'g');
841
+
842
+ /**
843
+ * One user-facing line summarizing what the kit just did for this session.
844
+ * Best-effort everywhere: a status line must NEVER turn a working hook into
845
+ * a crash — every data source degrades to its zero independently.
846
+ *
847
+ * @param {object} opts
848
+ * @param {string} opts.snapshot - the composed injection snapshot.
849
+ * @param {string} opts.projectRoot
850
+ * @param {string} [opts.now]
851
+ * @param {Function} [opts.listConflictsImpl] - test seam (default: the real queue lister).
852
+ * @param {Function} [opts.listReviewImpl] - test seam.
853
+ * @returns {string} the status line (always a string, never throws).
854
+ */
855
+ export function buildStatusLine({
856
+ snapshot,
857
+ projectRoot,
858
+ now,
859
+ listConflictsImpl,
860
+ listReviewImpl,
861
+ } = {}) {
862
+ const prefix = 'claude-memory-kit:';
863
+ try {
864
+ // 1. Unique injected fact ids — what the model can actually see.
865
+ const ids = new Set();
866
+ for (const m of String(snapshot ?? '').matchAll(SNAPSHOT_ID_RE)) ids.add(m[1]);
867
+
868
+ if (ids.size === 0) {
869
+ return `${prefix} memory is empty — capture starts this session`;
870
+ }
871
+ const parts = [`${ids.size} fact(s) in context`];
872
+
873
+ // 2. Captures in the last 24h, from the audit-log tail. A capture is a
874
+ // `created` entry or an APPLIED import — `action: 'import'` alone also
875
+ // covers skipped duplicates (reasonCode import-skipped-duplicate), and
876
+ // counting those would let a re-run import inflate the line by its
877
+ // whole dup count (skill-review finding, 2026-06-12).
878
+ const nowMs = Date.parse(now ?? nowIso());
879
+ let recent = 0;
880
+ try {
881
+ const auditPath = join(projectRoot, 'context', '.locks', 'audit.log');
882
+ if (existsSync(auditPath)) {
883
+ // Positioned read of the LAST 64KB only — recency lives at the end,
884
+ // and this runs inside the 500ms-budget SessionStart hook; reading a
885
+ // months-old multi-MB log in full would pay for history we discard.
886
+ const size = statSync(auditPath).size;
887
+ const start = Math.max(0, size - STATUS_AUDIT_TAIL_BYTES);
888
+ const buf = Buffer.alloc(size - start);
889
+ const fd = openSync(auditPath, 'r');
890
+ try {
891
+ readSync(fd, buf, 0, buf.length, start);
892
+ } finally {
893
+ closeSync(fd);
894
+ }
895
+ // Drop the (possibly torn) first line when we started mid-file.
896
+ const tail = start > 0 ? buf.toString('utf8').replace(/^[^\n]*\n/, '') : buf.toString('utf8');
897
+ for (const line of tail.split(/\r?\n/)) {
898
+ if (!line.trim()) continue;
899
+ try {
900
+ const e = JSON.parse(line);
901
+ const isCapture =
902
+ e.action === 'created' ||
903
+ (e.action === 'import' && e.reasonCode === 'import-applied');
904
+ if (
905
+ isCapture &&
906
+ nowMs - Date.parse(e.ts) <= DAY_MS &&
907
+ nowMs - Date.parse(e.ts) >= 0
908
+ ) {
909
+ recent += 1;
910
+ }
911
+ } catch {
912
+ // torn NDJSON line — skip
913
+ }
914
+ }
915
+ }
916
+ } catch {
917
+ // audit log unreadable — the count degrades to absent
918
+ }
919
+ if (recent > 0) parts.push(`${recent} captured in the last 24h`);
920
+
921
+ // 3. Pending curation — only mentioned when non-zero (a quiet queue
922
+ // earns a quiet line).
923
+ let conflicts = 0;
924
+ let review = 0;
925
+ try {
926
+ conflicts = (listConflictsImpl ?? listConflictQueue)({ tier: 'P', projectRoot }).length;
927
+ } catch {
928
+ // queue unreadable — degrade to zero
929
+ }
930
+ try {
931
+ review = (listReviewImpl ?? listReviewQueue)({ tier: 'P', projectRoot }).length;
932
+ } catch {
933
+ // queue unreadable — degrade to zero
934
+ }
935
+ if (conflicts > 0 || review > 0) {
936
+ const q = [];
937
+ if (conflicts > 0) q.push(`${conflicts} conflict(s)`);
938
+ if (review > 0) q.push(`${review} review item(s)`);
939
+ parts.push(`${q.join(' + ')} pending — cmk queue`);
940
+ }
941
+
942
+ return `${prefix} ${parts.join(', ')}`;
943
+ } catch {
944
+ // The line is decoration; the snapshot is the cargo. Never crash.
945
+ return `${prefix} memory loaded`;
946
+ }
947
+ }
package/src/install.mjs CHANGED
@@ -43,6 +43,7 @@ import { spawnSync } from 'node:child_process';
43
43
  import { basename, dirname, join, relative, resolve } from 'node:path';
44
44
  import { fileURLToPath } from 'node:url';
45
45
  import { injectClaudeMdBlock } from './claude-md.mjs';
46
+ import { checkKitBinding, npmSupportsAllowScripts } from './native-binding.mjs';
46
47
  import { writeKitHooks, writeKitMcpServer } from './settings-hooks.mjs';
47
48
  import { appendAuditEntry, nowIso, REASON_CODES } from './audit-log.mjs';
48
49
 
@@ -58,6 +59,13 @@ const CLI_PKG_DIR = resolve(CLI_SRC_DIR, '..');
58
59
  // it must not show a stale hardcode (was `v0.1.0` in every install). Built per
59
60
  // install from the kit version; see gitignoreStartMarker().
60
61
  const GITIGNORE_END = '# claude-memory-kit:gitignore:end';
62
+ // D-126 CRLF-prevention: the .gitattributes managed block uses the SAME
63
+ // marker discipline as .gitignore (version-stamped start, in-place refresh).
64
+ const GITATTRIBUTES_END = '# claude-memory-kit:gitattributes:end';
65
+
66
+ function gitattributesStartMarker(version) {
67
+ return `# claude-memory-kit:gitattributes:start v${version}`;
68
+ }
61
69
 
62
70
  function gitignoreStartMarker(version) {
63
71
  return `# claude-memory-kit:gitignore:start v${version}`;
@@ -233,6 +241,52 @@ function buildGitignoreBlock(templateDir, version = getKitVersion()) {
233
241
  return `${gitignoreStartMarker(version)}\n${fragment}\n${GITIGNORE_END}\n`;
234
242
  }
235
243
 
244
+ /**
245
+ * Build the canonical .gitattributes managed block from
246
+ * template/.gitattributes.fragment (D-126 CRLF prevention — force LF on the
247
+ * committed memory tiers so default Windows git doesn't mangle the bytes at
248
+ * clone). Same marker discipline as the .gitignore block.
249
+ */
250
+ function buildGitattributesBlock(templateDir, version = getKitVersion()) {
251
+ const fragmentPath = join(templateDir, '.gitattributes.fragment');
252
+ const fragment = existsSync(fragmentPath)
253
+ ? readFileSync(fragmentPath, 'utf8').trim()
254
+ : 'context/**/*.md text eol=lf\ncontext/**/*.json text eol=lf';
255
+ return `${gitattributesStartMarker(version)}\n${fragment}\n${GITATTRIBUTES_END}\n`;
256
+ }
257
+
258
+ /**
259
+ * Inject (or refresh) the managed .gitattributes block. Same algorithm as
260
+ * injectGitignore (create / append-if-no-markers / replace-in-place),
261
+ * byte-preserving everything outside the markers.
262
+ *
263
+ * Returns: { action: 'created' | 'replaced' | 'unchanged', path: string }
264
+ */
265
+ function injectGitattributes(projectRoot, block) {
266
+ const gaPath = join(projectRoot, '.gitattributes');
267
+ const startRe = /# claude-memory-kit:gitattributes:start[^\n]*\n/;
268
+ const endRe = /# claude-memory-kit:gitattributes:end\n?/;
269
+
270
+ if (!existsSync(gaPath)) {
271
+ writeFileSync(gaPath, block, 'utf8');
272
+ return { action: 'created', path: gaPath };
273
+ }
274
+ const existing = readFileSync(gaPath, 'utf8');
275
+ const startMatch = existing.match(startRe);
276
+ const endMatch = existing.match(endRe);
277
+ if (!startMatch || !endMatch || startMatch.index > endMatch.index) {
278
+ const sep = existing.endsWith('\n') ? '\n' : '\n\n';
279
+ writeFileSync(gaPath, existing + sep + block, 'utf8');
280
+ return { action: 'created', path: gaPath };
281
+ }
282
+ const before = existing.slice(0, startMatch.index);
283
+ const after = existing.slice(endMatch.index + endMatch[0].length);
284
+ const next = before + block + after;
285
+ if (next === existing) return { action: 'unchanged', path: gaPath };
286
+ writeFileSync(gaPath, next, 'utf8');
287
+ return { action: 'replaced', path: gaPath };
288
+ }
289
+
236
290
  /**
237
291
  * Inject (or refresh) the managed .gitignore block in `<projectRoot>/.gitignore`.
238
292
  *
@@ -328,6 +382,10 @@ export async function install(options = {}) {
328
382
  }
329
383
 
330
384
  const gitignore = injectGitignore(projectRoot, buildGitignoreBlock(templateDir, version));
385
+ // D-126 CRLF prevention: pin LF on the committed memory tiers so default
386
+ // Windows git can't mangle the bytes at clone (the read-side self-heal
387
+ // shipped in v0.3.0; this prevents the mangling in the first place).
388
+ const gitattributes = injectGitattributes(projectRoot, buildGitattributesBlock(templateDir, version));
331
389
 
332
390
  // CLAUDE.md loader block — Task 4. Read the block content from the kit's
333
391
  // template/ and inject (or refresh) it inside marker delimiters. Never
@@ -433,7 +491,14 @@ export async function install(options = {}) {
433
491
  if (!r.ok) errors.push({ path: r.path, error: r.error });
434
492
  }
435
493
 
436
- return { projectRoot, userTier, created, skipped, gitignore, claudeMd, hooks, mcpServer, semantic, errors };
494
+ // Task 141a (D-129): probe the kit's native binding so the CLI can ask the
495
+ // user to fix it INLINE (npm 12 blocks better-sqlite3's binding build on a
496
+ // fresh install). Reported, never an installer error — scaffold + hooks
497
+ // are fully functional without it; only search/reindex need the binding.
498
+ const bindingProbe = options.bindingProbe ?? checkKitBinding;
499
+ const nativeBinding = bindingProbe();
500
+
501
+ return { projectRoot, userTier, created, skipped, gitignore, gitattributes, claudeMd, hooks, mcpServer, semantic, nativeBinding, errors };
437
502
  }
438
503
 
439
504
  /**
@@ -470,10 +535,18 @@ export function mergeProjectSettings(projectRoot, patch) {
470
535
  */
471
536
  export function buildDefaultNpmRunner({ spawnSyncImpl = spawnSync } = {}) {
472
537
  return () => {
538
+ // Task 141a (D-129): on npm ≥ 11.16 the `allow-scripts` config exists
539
+ // and npm 12 BLOCKS onnxruntime-node's install script without it — the
540
+ // kit runs this install itself, so it carries the allow flag itself
541
+ // (no user friction). Older npm: plain command, no unknown-config noise.
542
+ const { supported } = npmSupportsAllowScripts({ spawnSyncImpl });
543
+ const cmd = supported
544
+ ? 'npm install -g @huggingface/transformers --allow-scripts=onnxruntime-node'
545
+ : 'npm install -g @huggingface/transformers';
473
546
  // One constant command string under shell:true (no user input — and
474
547
  // an args array + shell:true trips Node's DEP0190). npm is npm.cmd
475
548
  // on Windows; the shell resolves it cross-platform.
476
- const r = spawnSyncImpl('npm install -g @huggingface/transformers', {
549
+ const r = spawnSyncImpl(cmd, {
477
550
  encoding: 'utf8',
478
551
  stdio: 'inherit',
479
552
  shell: true,
@@ -40,7 +40,7 @@ import { openIndexDb } from './index-db.mjs';
40
40
  import { reindexBoot } from './index-rebuild.mjs';
41
41
  import { search, SEARCH_MODES } from './search.mjs';
42
42
  import { memoryWrite } from './memory-write.mjs';
43
- import { rememberRich, nonProjectTierNote } from './remember-core.mjs';
43
+ import { rememberRich, nonProjectTierNote, prepareNearDupGuard } from './remember-core.mjs';
44
44
  import { forget } from './forget.mjs';
45
45
  import { overrideTrust } from './trust.mjs';
46
46
  import { lessonsPromote } from './lessons-promote.mjs';
@@ -281,6 +281,10 @@ function makeMkRemember({ projectRoot, userDir }) {
281
281
  ],
282
282
  };
283
283
  }
284
+ // Task 143 (D-130): the semantic near-dup guard (one embed of the
285
+ // incoming text when the project is semantic-configured + the embedder
286
+ // is available; {} = literal pipeline, never blocks capture).
287
+ const nearDup = await prepareNearDupGuard({ projectRoot, text });
284
288
  const r = memoryWrite({
285
289
  action: 'add',
286
290
  text,
@@ -291,6 +295,7 @@ function makeMkRemember({ projectRoot, userDir }) {
291
295
  sessionId: 'mcp-server',
292
296
  projectRoot,
293
297
  userDir,
298
+ ...nearDup,
294
299
  });
295
300
  if (r.action === 'error') {
296
301
  return {