@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.
package/README.md CHANGED
@@ -1,3 +1,10 @@
1
+ <p align="center">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/LH8PPL/claude-memory-kit/main/docs/public/assets/wordmark-dark.svg">
4
+ <img src="https://raw.githubusercontent.com/LH8PPL/claude-memory-kit/main/docs/public/assets/wordmark.svg" alt="claude-memory-kit" width="340">
5
+ </picture>
6
+ </p>
7
+
1
8
  # @lh8ppl/claude-memory-kit
2
9
 
3
10
  **`cmk`** — the CLI for [claude-memory-kit](https://github.com/LH8PPL/claude-memory-kit), a per-project, in-repo memory system for [Claude Code](https://docs.claude.com/en/docs/claude-code). It fixes Claude's per-session amnesia so you don't have to re-tell the backstory every time you start a new session.
@@ -11,8 +18,9 @@
11
18
  - **Explicit capture when you want it** — say "remember this" / "from now on" / "we decided" / "forget X" (the `memory-write` skill), or run `cmk remember "<fact>"`. Both dedup, screen for secrets, abstract machine paths to `~`, and write silently. For backtick/quote-heavy rich facts, capture them shell-safe as JSON: `cmk remember --from-file fact.json` (or `--json` from stdin) — content never touches the shell.
12
19
  - **Search + MCP — Claude runs every memory op for you, in conversation** — `cmk search "<term>"` (keyword over facts + scratchpads; with the optional local embedder, **semantic + hybrid recall**: ask in your own words and get the fact even with zero keyword overlap — measured R@5 0.941 / paraphrase 1.000 on the kit's benchmark, no API calls). `cmk install` registers the kit's **MCP server**, so Claude can do the whole memory surface as tools without you ever typing `cmk`: capture (`mk_remember`, rich Why/How too), recall (`mk_search` / `mk_get` / `mk_timeline` / `mk_cite`), adjust trust (`mk_trust`), promote a fact across projects (`mk_lessons_promote`), forget (`mk_forget` — previews first, then deletes on confirm), and clear the review/conflict queues (`mk_queue_list` / `mk_queue_resolve`). The tools are allow-listed on install, so they run prompt-free.
13
20
  - **Bounded by compression** — session → daily → weekly Haiku rollups (cron or lazy-on-read) keep the snapshot small as history grows. The session-buffer rollup self-heals at session start too, so memory stays bounded even if you never cleanly close the window.
21
+ - **Don't start empty — import the rules you already own** — `cmk import-claude-md` parses an existing `CLAUDE.md` / `.cursorrules` / `AGENTS.md` into typed, searchable facts through the same safe write path (secret screening, sanitization, dedup), with provenance back to source file + line. `--dry-run` previews first.
14
22
  - **Per-project, in-repo** — `context/` lives inside your project and travels with `git clone`. Each project keeps its own memory.
15
- - **7 health checks** — `cmk doctor` validates hook wiring, distill freshness, transcript firing, INDEX consistency, cron registration, native-memory coexistence, and stale locks — each failure with a repair command.
23
+ - **8 health checks** — `cmk doctor` validates hook wiring, distill freshness, transcript firing, INDEX consistency, cron registration, native-memory coexistence, stale locks, and native-binding health (npm 12 readiness) — each failure with a repair command.
16
24
 
17
25
  ## Install — pick ONE route
18
26
 
@@ -26,10 +34,11 @@ cd ~/my-project
26
34
  cmk install # scaffolds context/ + the memory-write + memory-search skills AND wires the lifecycle hooks into .claude/settings.json
27
35
  cmk install --with-semantic # (optional) local semantic recall — one-time ~260 MB, search defaults to hybrid
28
36
  cmk register-crons # (optional) scheduled background compression — otherwise self-heals lazily
37
+ cmk import-claude-md --yes # (optional) seed memory from an existing CLAUDE.md / .cursorrules (--dry-run previews)
29
38
  cmk doctor # verify, then restart Claude Code
30
39
  ```
31
40
 
32
- `cmk install` is a complete entry point: it scaffolds `context/`, drops the `memory-write` + `memory-search` skills into `.claude/skills/` (committed — travels with `git clone`), and writes the 5 lifecycle hooks (PATH-resolved, cross-OS) into the project's `.claude/settings.json`. It also **registers the kit's MCP server** in `.mcp.json` and allow-lists its tools (`mcp__cmk__*`) in `.claude/settings.json`, so Claude can drive memory as tools with no per-call prompt. No separate `/plugin` step needed. Use `cmk install --no-hooks` to skip the hooks + MCP wiring (scaffold-only).
41
+ `cmk install` is a complete entry point: it scaffolds `context/`, drops the `memory-write` + `memory-search` skills into `.claude/skills/` (committed — travels with `git clone`), and writes the 5 lifecycle hooks (PATH-resolved, cross-OS) into the project's `.claude/settings.json`. It also **registers the kit's MCP server** in `.mcp.json` and allow-lists its tools (`mcp__cmk__*`) in `.claude/settings.json`, so Claude can drive memory as tools with no per-call prompt, and writes a `.gitattributes` block pinning committed memory to LF (so a Windows clone can't mangle line endings — your memory stays readable cross-platform). No separate `/plugin` step needed. Use `cmk install --no-hooks` to skip the hooks + MCP wiring (scaffold-only).
33
42
 
34
43
  > Installing the package globally adds the `cmk` CLI **and** the installer. It's the `cmk install` *subcommand* that wires the hooks — not the bare `npm install`.
35
44
 
@@ -51,7 +60,7 @@ Most-used commands (full list via `cmk --help`):
51
60
  | Command | Purpose |
52
61
  | --- | --- |
53
62
  | `cmk install` | Scaffold `context/` + the `memory-write`/`memory-search` skills + `.gitignore` + CLAUDE.md block + wire hooks (`--no-hooks` for scaffold-only) |
54
- | `cmk doctor` | Run HC-1..HC-7 health checks, surface repair commands |
63
+ | `cmk doctor` | Run HC-1..HC-8 health checks, surface repair commands |
55
64
  | `cmk repair --hooks` / `--locks` / `--index` / `--all` | Idempotent self-repair |
56
65
  | `cmk search "<query>" [--mode keyword\|semantic\|hybrid] [--scope facts\|transcripts]` | Search memory — by meaning with the embedder (hybrid default after `--with-semantic`); `--scope transcripts` = the raw session record |
57
66
  | `cmk get <id…>` / `cmk timeline <id>` / `cmk cite <id>` / `cmk recent-activity` | Read the index back — full fact bodies + provenance, sequential context around an observation, a canonical citation link, recent changes (the CLI side of the `mk_*` MCP read tools) |
@@ -63,6 +72,7 @@ Most-used commands (full list via `cmk --help`):
63
72
  | `cmk persona generate` | Run cross-project persona synthesis on demand (instead of waiting for the weekly pass) |
64
73
  | `cmk persona export <file>` / `import <file>` | Carry your cross-project persona (the user tier) to another of **your** machines — export to one portable bundle, import on the other (overwrites with backup + rollback). The persona stays private (never committed to a project) |
65
74
  | `cmk import-anthropic-memory [--dry-run] [--yes]` | Merge bullets from Anthropic's native auto-memory into MEMORY.md |
75
+ | `cmk import-claude-md [file] [--dry-run] [--yes]` | Onboard from the rules you already own — parse an existing `CLAUDE.md` / `.cursorrules` / `AGENTS.md` into typed facts through the safe write path (Poison_Guard + sanitization + dedup) |
66
76
 
67
77
  ## Requirements
68
78
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lh8ppl/claude-memory-kit",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "cmk — the CLI for claude-memory-kit. Per-project, in-repo memory system for Claude Code.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -34,7 +34,7 @@
34
34
  "better-sqlite3": "^12.10.0",
35
35
  "chokidar": "^5.0.0",
36
36
  "commander": "^15.0.0",
37
- "js-yaml": "^4.1.0",
37
+ "js-yaml": "^4.2.0",
38
38
  "sqlite-vec": "^0.1.9",
39
39
  "zod": "^4.4.3"
40
40
  },
package/src/audit-log.mjs CHANGED
@@ -33,6 +33,7 @@ export const REASON_CODES = Object.freeze({
33
33
  FACT_CREATED: 'fact-created', // writeFact: a new fact file was written (Task 123.A — the default create audit; callers emitting a richer code opt out via audit:false)
34
34
  DUPLICATE: 'duplicate', // writeFact: same path + same id
35
35
  DUPLICATE_ELSEWHERE: 'duplicate-elsewhere', // writeFact: different path + same id
36
+ INDEX_REBUILD_FAILED: 'index-rebuild-failed', // writeFact: the fact landed on disk but the best-effort INDEX.md rebuild threw (e.g. a detached auto-extract child killed mid-rebuild). Surfaces what was previously a SILENTLY swallowed catch (D-152) so a lagging committed INDEX is diagnosable; the next reindex/cmk reindex self-heals.
36
37
  USER_REQUESTED: 'user-requested', // forget: user-initiated tombstone
37
38
  CURATED_MERGE: 'curated-merge', // mergeFacts: explicit merge of A + B → C
38
39
  SCRATCHPAD_APPEND: 'scratchpad-append', // scratchpad: appendScratchpadBullet (Task 12)
@@ -22,6 +22,7 @@
22
22
 
23
23
  import { resolveReviewQueue } from './review-queue.mjs';
24
24
  import { resolveConflictQueue } from './conflict-queue.mjs';
25
+ import { resolvePersonaReviewQueue } from './auto-persona.mjs';
25
26
  import { mergeFacts } from './merge-facts.mjs';
26
27
 
27
28
  // Stateless optimistic resolvers (no per-entry judgement — that's the point).
@@ -55,5 +56,20 @@ export async function autoDrainQueues({ tier = 'P', projectRoot, userDir, scratc
55
56
  mergeFn: mergeFacts, // never invoked under KEEP_OLD; wired for correctness
56
57
  });
57
58
 
58
- return { review, conflict };
59
+ // Persona-review queue (D-154): the medium-confidence cross-project persona
60
+ // candidates that were ROUTED here with the promise of an auto-drain that was
61
+ // never implemented — so they stranded (the v0.3.1 cold-open found the user's
62
+ // architecture philosophy stuck here). Drain it optimistically like the review
63
+ // queue (sync; userDir-scoped so it runs regardless of `tier`). Best-effort: a
64
+ // persona-drain hiccup must not fail the project-tier review/conflict drain.
65
+ let persona = { promoted: 0, drained: 0, queuePath: null };
66
+ if (userDir) {
67
+ try {
68
+ persona = resolvePersonaReviewQueue({ userDir });
69
+ } catch {
70
+ // best-effort; the queue file survives for the next pass
71
+ }
72
+ }
73
+
74
+ return { review, conflict, persona };
59
75
  }
@@ -48,8 +48,8 @@ import {
48
48
  appendFileSync,
49
49
  } from 'node:fs';
50
50
  import { join, dirname } from 'node:path';
51
- import { createHash } from 'node:crypto';
52
51
  import { generateId } from '@lh8ppl/cmk-canonicalize';
52
+ import { hashContent } from './content-hash.mjs';
53
53
  import { memoryWrite } from './memory-write.mjs';
54
54
  import { writeFact } from './write-fact.mjs';
55
55
  import { buildRichFactBody, slugifyFact } from './rich-fact.mjs';
@@ -663,10 +663,9 @@ function routeRichFact({ candidate, projectRoot, ts }) {
663
663
  sourceFile: 'auto-extract',
664
664
  sourceLine: 1,
665
665
  // Content fingerprint for the provenance field — NOT a security context.
666
- // Matches the kit's sha1-of-content convention (write-fact.mjs caller in
667
- // subcommands.runRememberRich, memory-write.mjs); writeFact dedups by the
668
- // content-addressed id, this is just source_sha1. // NOSONAR
669
- sourceSha1: createHash('sha1').update(body).digest('hex'), // NOSONAR
666
+ // Routes through the shared hashContent (SHA-256, D-149); writeFact dedups
667
+ // by the content-addressed id, this is just source_sha1 metadata.
668
+ sourceSha1: hashContent(body),
670
669
  createdAt: ts,
671
670
  projectRoot,
672
671
  });
@@ -38,7 +38,7 @@
38
38
  // promotion primitive), audit-log, result-shapes, cooldown, compressor.
39
39
  // Per design §16.16 + §6.2 (conflict) + §6.8 (auto-drain) + §8.3 + tasks.md 45.
40
40
 
41
- import { readFileSync, appendFileSync, mkdirSync, existsSync, readdirSync } from 'node:fs';
41
+ import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, readdirSync } from 'node:fs';
42
42
  import { join, dirname } from 'node:path';
43
43
  import { generateId } from '@lh8ppl/cmk-canonicalize';
44
44
  import { ERROR_CATEGORIES, errorResult } from './result-shapes.mjs';
@@ -425,6 +425,91 @@ export function appendPersonaReviewQueue({ userDir, entries, now }) {
425
425
  return queuePath;
426
426
  }
427
427
 
428
+ // Parse persona-review.md back into candidate objects. The queue lines are
429
+ // - (U-XXXXXXXX) [TARGET § SECTION] <text>
430
+ // <!-- target: TARGET, section: SECTION, confidence: C, reason: ..., ... -->
431
+ // The HTML comment is authoritative for target/section/confidence (the bracket
432
+ // prefix is human-readable redundancy); fall back to the bracket if absent.
433
+ // ReDoS-safe: NEGATED character classes (not lazy `.+?...+?` pairs) so the regex
434
+ // is linear — each group matches "anything but the delimiter that ends it", which
435
+ // cannot backtrack across that delimiter (the canonicalize stripTrailingPunct
436
+ // lesson — Task 140 / D-143 — applied at write).
437
+ const PERSONA_QUEUE_LINE_RE = /^- \([UPL]-[^)]+\)\s+\[([^§\]]+)§([^\]]+)\]\s+(\S.*)$/;
438
+ // No `\s*` sits adjacent to a `[^,]+` capture: `\s*` and `[^,]+` both match a
439
+ // space, and that overlap is the super-linear-backtracking ambiguity Sonar
440
+ // flags. Each value is captured by `[^,]+` (which absorbs leading/trailing
441
+ // space — we `.trim()` below), with the `,` and label as fixed delimiters.
442
+ const PERSONA_QUEUE_META_RE = /target:([^,]+),\s*section:([^,]+),\s*confidence:\s*(\w+)/;
443
+ export function parsePersonaReviewQueue(text) {
444
+ const lines = (text ?? '').split(/\r?\n/);
445
+ const candidates = [];
446
+ for (let i = 0; i < lines.length; i++) {
447
+ const m = PERSONA_QUEUE_LINE_RE.exec(lines[i].trim());
448
+ if (!m) continue;
449
+ let [, target, section, body] = m;
450
+ let confidence = 'medium';
451
+ const meta = PERSONA_QUEUE_META_RE.exec(lines[i + 1] ?? '');
452
+ if (meta) {
453
+ target = meta[1].trim();
454
+ section = meta[2].trim();
455
+ confidence = meta[3].trim().toLowerCase();
456
+ }
457
+ candidates.push({ target: target.trim(), section: section.trim(), confidence, text: body.trim() });
458
+ }
459
+ return candidates;
460
+ }
461
+
462
+ /**
463
+ * Auto-drain the persona-review queue (the down-payment for Task 151 / D-154).
464
+ *
465
+ * The medium-confidence persona candidates were ROUTED to persona-review.md with
466
+ * the documented promise that "the daily/weekly auto-drain acts on them" — but
467
+ * that drain was never implemented, so they STRANDED (the v0.3.1 cold-open found
468
+ * the user's architecture philosophy stuck here, never reaching the persona).
469
+ * This makes the promise real: the same optimistic auto-promote the review queue
470
+ * already gets (D-6) — trust the synthesis, mistakes self-correct via `cmk forget`
471
+ * (the post-hoc-reversibility model every surveyed memory system uses instead of
472
+ * a pre-promotion human gate). NOT a manual command: runs inside autoDrainQueues
473
+ * on the daily/weekly maintenance passes. The full recurrence-scored redesign is
474
+ * Task 151 (v0.4); this just stops the stranding.
475
+ *
476
+ * @returns {{promoted: number, drained: number, queuePath: string|null}}
477
+ */
478
+ export function resolvePersonaReviewQueue({ userDir, now, settings } = {}) {
479
+ const userTierRoot = resolveTierRoot({ tier: 'U', userDir });
480
+ const queuePath = join(userTierRoot, 'queues', 'persona-review.md');
481
+ let text;
482
+ try {
483
+ text = readFileSync(queuePath, 'utf8');
484
+ } catch {
485
+ return { promoted: 0, drained: 0, queuePath: null }; // no queue → nothing to drain
486
+ }
487
+ const candidates = parsePersonaReviewQueue(text);
488
+ if (candidates.length === 0) return { promoted: 0, drained: 0, queuePath };
489
+
490
+ // Re-feed through the SAME promote path the synthesis uses (home-path sanitize
491
+ // + Poison_Guard + dedup + audit all inherited). OPTIMISTIC AUTO-DRAIN: these
492
+ // candidates already SURVIVED a synthesis pass without being superseded; the
493
+ // drain IS the decision to promote them (the field-standard "auto-promote then
494
+ // post-hoc revert via cmk forget" posture — see the persona-promotion research
495
+ // note). So force confidence:'high' to clear promoteCandidatesToUserTier's
496
+ // confidence gate — otherwise they'd re-queue forever (the gate that stranded
497
+ // them in the first place). The full recurrence-scored model is Task 151 (v0.4).
498
+ const promotable = candidates.map((c) => ({ ...c, confidence: 'high' }));
499
+ const r = promoteCandidatesToUserTier({ candidates: promotable, userDir, now, settings });
500
+ const promoted = r.promoted?.length ?? 0;
501
+
502
+ // Clear the queue — the candidates are now resolved (promoted or de-duped into
503
+ // existing persona). Leave a tombstone header so the file isn't silently empty.
504
+ const ts = now ?? new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
505
+ writeFileSync(
506
+ queuePath,
507
+ `<!-- persona-review queue — auto-drained ${ts}: ${candidates.length} candidate(s) promoted to the persona. -->\n`,
508
+ 'utf8',
509
+ );
510
+ return { promoted, drained: candidates.length, queuePath };
511
+ }
512
+
428
513
  export function promoteCandidatesToUserTier({ candidates, userDir, now, settings, trust = 'medium', source = 'persona-synthesis' }) {
429
514
  // `trust`/`source` default to the AUTO-persona posture (medium, system-derived
430
515
  // — 45.6). The EXPLICIT path (`cmk lessons promote`) passes trust:'high' +
@@ -63,7 +63,8 @@ export function buildMemoryHint({ projectRoot, prompt } = {}) {
63
63
  }
64
64
  return (
65
65
  '[claude-memory-kit] Recorded memory available beyond the session snapshot — ' +
66
- 'use the memory-search skill when the answer may already be recorded (prior decisions, history, conventions).'
66
+ 'use the memory-search skill when the answer may already be recorded (prior decisions, history, conventions, ' +
67
+ 'project structure/architecture, where things live). Recall it; do not re-read the code to reconstruct it.'
67
68
  );
68
69
  }
69
70
 
@@ -0,0 +1,161 @@
1
+ // `cmk config get/set/--show-origin` core (Task 129, D-121).
2
+ //
3
+ // The v0.1.0 stub became real the day `--with-semantic` shipped:
4
+ // context/settings.json now carries a user-facing setting
5
+ // (search.default_mode) and hand-editing JSON was the only path. This is
6
+ // the read-merge-write surface over the kit's settings files.
7
+ //
8
+ // Settings live in `<tier-root>/settings.json` for each of the three tiers
9
+ // (resolveTierRoot — the shared module, not re-derived). Resolution
10
+ // precedence mirrors the kit's memory model + git config semantics:
11
+ // local (context.local/) > project (context/) > user (~/.claude-memory-kit/)
12
+ // A `get` returns the highest-precedence tier that defines the dotted key;
13
+ // `--show-origin` lists every tier that defines it (winner + shadowed), the
14
+ // direnv lesson (design §7.2: "without --show-origin, users rage-quit when
15
+ // settings appear from nowhere"). `set` writes one tier (project default),
16
+ // preserving every sibling key (the mergeProjectSettings discipline,
17
+ // generalized per tier).
18
+ //
19
+ // Scope (D-121): the kit's own JSON settings files. NOT the richer
20
+ // settings-or-observation `--show-origin` sketch in design §7.2's example
21
+ // (observations have their own provenance/shadowed_by surface, §6); this is
22
+ // the concrete settings half the semantic default forced into existence.
23
+
24
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
25
+ import { dirname, join } from 'node:path';
26
+ import { resolveTierRoot } from './tier-paths.mjs';
27
+
28
+ // Highest-precedence first.
29
+ const TIERS = Object.freeze([
30
+ { name: 'local', tier: 'L' },
31
+ { name: 'project', tier: 'P' },
32
+ { name: 'user', tier: 'U' },
33
+ ]);
34
+
35
+ // Keys that would pollute the prototype chain — rejected on both read and
36
+ // write. `cmk config set __proto__.x y` must never reach Object.prototype
37
+ // (skill-review blocking finding); a key path containing any of these is
38
+ // invalid, not a silent no-op.
39
+ const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
40
+ function hasForbiddenSegment(dottedKey) {
41
+ return dottedKey.split('.').some((p) => FORBIDDEN_KEYS.has(p));
42
+ }
43
+
44
+ function settingsPathFor(tierName, { projectRoot, userDir }) {
45
+ const tier = TIERS.find((t) => t.name === tierName)?.tier;
46
+ return join(resolveTierRoot({ tier, projectRoot, userDir }), 'settings.json');
47
+ }
48
+
49
+ function readSettings(path) {
50
+ if (!existsSync(path)) return null;
51
+ try {
52
+ return JSON.parse(readFileSync(path, 'utf8'));
53
+ } catch {
54
+ // A malformed settings file is treated as absent for resolution — never
55
+ // throw on a read (a hand-broken JSON shouldn't crash `cmk config get`).
56
+ return null;
57
+ }
58
+ }
59
+
60
+ // Walk a dotted path; returns {found, value}. `found` distinguishes a key
61
+ // set to `undefined`-ish from a key that isn't there (the honesty contract).
62
+ function dig(obj, dottedKey) {
63
+ if (obj == null || typeof obj !== 'object') return { found: false };
64
+ const parts = dottedKey.split('.');
65
+ let cur = obj;
66
+ for (const p of parts) {
67
+ if (cur == null || typeof cur !== 'object' || !(p in cur)) return { found: false };
68
+ cur = cur[p];
69
+ }
70
+ return { found: true, value: cur };
71
+ }
72
+
73
+ /**
74
+ * Resolve a dotted setting key across tiers (local > project > user).
75
+ *
76
+ * @returns {{found: boolean, value?: *, tier?: 'local'|'project'|'user'}}
77
+ */
78
+ export function configGet(key, { projectRoot, userDir } = {}) {
79
+ if (!key || !String(key).trim()) return { found: false };
80
+ if (hasForbiddenSegment(key)) return { found: false };
81
+ for (const { name } of TIERS) {
82
+ const settings = readSettings(settingsPathFor(name, { projectRoot, userDir }));
83
+ const hit = dig(settings, key);
84
+ if (hit.found) return { found: true, value: hit.value, tier: name };
85
+ }
86
+ return { found: false };
87
+ }
88
+
89
+ /** Scalar coercion: true/false/null → primitives, integer/float strings →
90
+ * numbers, everything else stays a string. JSON settings are typed, and a
91
+ * CLI arg is always a string — `cmk config set x true` should write a bool. */
92
+ function coerce(raw) {
93
+ if (raw === 'true') return true;
94
+ if (raw === 'false') return false;
95
+ if (raw === 'null') return null;
96
+ if (/^-?\d+$/.test(raw)) return Number.parseInt(raw, 10);
97
+ if (/^-?\d*\.\d+$/.test(raw)) return Number.parseFloat(raw);
98
+ return raw;
99
+ }
100
+
101
+ function setDeep(obj, dottedKey, value) {
102
+ const parts = dottedKey.split('.');
103
+ let cur = obj;
104
+ for (let i = 0; i < parts.length - 1; i++) {
105
+ const p = parts[i];
106
+ if (cur[p] == null || typeof cur[p] !== 'object' || Array.isArray(cur[p])) cur[p] = {};
107
+ cur = cur[p];
108
+ }
109
+ cur[parts[parts.length - 1]] = value;
110
+ }
111
+
112
+ /**
113
+ * Set a dotted key in one tier's settings.json (project default), preserving
114
+ * every sibling key (read-merge-write).
115
+ *
116
+ * @returns {{ok: boolean, tier?: string, path?: string, error?: string}}
117
+ */
118
+ export function configSet(key, rawValue, { projectRoot, userDir, tier = 'project' } = {}) {
119
+ if (!key || !String(key).trim()) return { ok: false, error: 'key is required (dotted path)' };
120
+ if (hasForbiddenSegment(key)) {
121
+ return { ok: false, error: `key contains a forbidden segment (${[...FORBIDDEN_KEYS].join('/')}) — prototype-pollution guard` };
122
+ }
123
+ if (!TIERS.some((t) => t.name === tier)) {
124
+ return { ok: false, error: `tier must be one of local/project/user (got ${tier})` };
125
+ }
126
+ const path = settingsPathFor(tier, { projectRoot, userDir });
127
+ try {
128
+ const current = readSettings(path) ?? {};
129
+ setDeep(current, key, coerce(String(rawValue)));
130
+ mkdirSync(dirname(path), { recursive: true });
131
+ writeFileSync(path, JSON.stringify(current, null, 2) + '\n', 'utf8');
132
+ return { ok: true, tier, path };
133
+ } catch (err) {
134
+ return { ok: false, error: err?.message ?? String(err) };
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Every tier that defines the key, highest-precedence first. The winner is
140
+ * the first; the rest carry `shadowedBy` = the winning tier (the direnv
141
+ * "where did this come from?" surface).
142
+ *
143
+ * @returns {{found: boolean, entries: Array<{tier, value, path, winner, shadowedBy?}>}}
144
+ */
145
+ export function configShowOrigin(key, { projectRoot, userDir } = {}) {
146
+ const entries = [];
147
+ if (!key || !String(key).trim()) return { found: false, entries };
148
+ if (hasForbiddenSegment(key)) return { found: false, entries };
149
+ for (const { name } of TIERS) {
150
+ const path = settingsPathFor(name, { projectRoot, userDir });
151
+ const hit = dig(readSettings(path), key);
152
+ if (hit.found) entries.push({ tier: name, value: hit.value, path });
153
+ }
154
+ if (entries.length === 0) return { found: false, entries: [] };
155
+ const winnerTier = entries[0].tier;
156
+ for (let i = 0; i < entries.length; i++) {
157
+ entries[i].winner = i === 0;
158
+ if (i > 0) entries[i].shadowedBy = winnerTier;
159
+ }
160
+ return { found: true, entries };
161
+ }
@@ -50,7 +50,7 @@ import {
50
50
  import { join } from 'node:path';
51
51
  import { resolveTierRoot, VALID_TIERS } from './tier-paths.mjs';
52
52
  import { writeBullet } from './provenance.mjs';
53
- import { createHash } from 'node:crypto';
53
+ import { hashContent } from './content-hash.mjs';
54
54
  import { nowIso, appendAuditEntry, REASON_CODES } from './audit-log.mjs';
55
55
  import { ERROR_CATEGORIES, errorResult } from './result-shapes.mjs';
56
56
  import { generateId } from '@lh8ppl/cmk-canonicalize';
@@ -792,7 +792,7 @@ export function mergeScratchpadBullets({
792
792
  // no `write:` key, so the first reindex after a merge-both resolution hit
793
793
  // the NOT-NULL observations.write_source constraint. Canonical shape via
794
794
  // the shared builder; the merged_from trail lives in the audit entry below.
795
- const sha1 = createHash('sha1').update(combinedText, 'utf8').digest('hex');
795
+ const sha1 = hashContent(combinedText);
796
796
  const formatted = writeBullet({
797
797
  id: newId,
798
798
  text: combinedText,
@@ -0,0 +1,30 @@
1
+ // Content-fingerprint helper — the single home for the kit's content hash.
2
+ //
3
+ // Every "fingerprint this text/file content" site (provenance source_sha1,
4
+ // the `files` checkpoint diff key, transcript dedup, conflict-merge keys)
5
+ // MUST route through hashContent so the algorithm is defined in exactly one
6
+ // place. Eight modules previously rolled their own `createHash('sha1')`,
7
+ // which (a) let the algorithm drift per-site and (b) tripped CodeQL's
8
+ // js/weak-cryptographic-algorithm on each one independently.
9
+ //
10
+ // SHA-256, not SHA-1: the digests are non-cryptographic content fingerprints
11
+ // (dedup + change-detection), so SHA-1 was never a security flaw here — but a
12
+ // weak-hash sink on every site is noise that hides real findings, and the
13
+ // whole-convention move to SHA-256 (the user's call, D-149) removes the sink
14
+ // kit-wide while keeping the digest consistent across writers. The on-disk
15
+ // FIELD name stays `source_sha1` / `sha1` for back-compat (renaming the YAML
16
+ // key + db column would break existing fact files + checkpoints); only the
17
+ // algorithm changes. Existing `files`-table checkpoints mismatch once on the
18
+ // first boot after upgrade and self-heal via the normal reindex.
19
+
20
+ import { createHash } from 'node:crypto';
21
+
22
+ /**
23
+ * Hash text/file content to a hex digest used as a non-cryptographic
24
+ * fingerprint (dedup, drift-detection, provenance). UTF-8 input.
25
+ * @param {string} content
26
+ * @returns {string} 64-char lowercase hex SHA-256 digest
27
+ */
28
+ export function hashContent(content) {
29
+ return createHash('sha256').update(content, 'utf8').digest('hex');
30
+ }