@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.
- package/README.md +6 -3
- package/package.json +1 -1
- package/src/audit-log.mjs +1 -0
- package/src/auto-drain.mjs +17 -1
- package/src/auto-extract.mjs +4 -5
- package/src/auto-persona.mjs +86 -1
- package/src/capture-prompt.mjs +2 -1
- package/src/config-core.mjs +161 -0
- package/src/conflict-queue.mjs +2 -2
- package/src/content-hash.mjs +30 -0
- package/src/doctor.mjs +62 -3
- package/src/import-anthropic-memory.mjs +2 -2
- package/src/import-claude-md.mjs +333 -0
- package/src/index-rebuild.mjs +6 -2
- package/src/index.mjs +10 -0
- package/src/inject-context.mjs +130 -1
- package/src/install.mjs +75 -2
- package/src/mcp-server.mjs +6 -1
- package/src/memory-health.mjs +229 -0
- package/src/memory-write.mjs +32 -10
- package/src/native-binding.mjs +142 -0
- package/src/poison-guard.mjs +55 -0
- package/src/remember-core.mjs +53 -8
- package/src/repair.mjs +20 -3
- package/src/semantic-backend.mjs +114 -0
- package/src/subcommands.mjs +268 -27
- package/src/transcript-index.mjs +5 -2
- package/src/write-fact.mjs +34 -3
- package/template/.claude/skills/memory-search/SKILL.md +1 -1
- package/template/.gitattributes.fragment +16 -0
- package/template/CLAUDE.md.template +1 -1
package/README.md
CHANGED
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
- **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
12
|
- **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
13
|
- **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.
|
|
14
|
+
- **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
15
|
- **Per-project, in-repo** — `context/` lives inside your project and travels with `git clone`. Each project keeps its own memory.
|
|
15
|
-
- **
|
|
16
|
+
- **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
17
|
|
|
17
18
|
## Install — pick ONE route
|
|
18
19
|
|
|
@@ -26,10 +27,11 @@ cd ~/my-project
|
|
|
26
27
|
cmk install # scaffolds context/ + the memory-write + memory-search skills AND wires the lifecycle hooks into .claude/settings.json
|
|
27
28
|
cmk install --with-semantic # (optional) local semantic recall — one-time ~260 MB, search defaults to hybrid
|
|
28
29
|
cmk register-crons # (optional) scheduled background compression — otherwise self-heals lazily
|
|
30
|
+
cmk import-claude-md --yes # (optional) seed memory from an existing CLAUDE.md / .cursorrules (--dry-run previews)
|
|
29
31
|
cmk doctor # verify, then restart Claude Code
|
|
30
32
|
```
|
|
31
33
|
|
|
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).
|
|
34
|
+
`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
35
|
|
|
34
36
|
> 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
37
|
|
|
@@ -51,7 +53,7 @@ Most-used commands (full list via `cmk --help`):
|
|
|
51
53
|
| Command | Purpose |
|
|
52
54
|
| --- | --- |
|
|
53
55
|
| `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-
|
|
56
|
+
| `cmk doctor` | Run HC-1..HC-8 health checks, surface repair commands |
|
|
55
57
|
| `cmk repair --hooks` / `--locks` / `--index` / `--all` | Idempotent self-repair |
|
|
56
58
|
| `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
59
|
| `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 +65,7 @@ Most-used commands (full list via `cmk --help`):
|
|
|
63
65
|
| `cmk persona generate` | Run cross-project persona synthesis on demand (instead of waiting for the weekly pass) |
|
|
64
66
|
| `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
67
|
| `cmk import-anthropic-memory [--dry-run] [--yes]` | Merge bullets from Anthropic's native auto-memory into MEMORY.md |
|
|
68
|
+
| `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
69
|
|
|
67
70
|
## Requirements
|
|
68
71
|
|
package/package.json
CHANGED
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)
|
package/src/auto-drain.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/auto-extract.mjs
CHANGED
|
@@ -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
|
-
//
|
|
667
|
-
//
|
|
668
|
-
|
|
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
|
});
|
package/src/auto-persona.mjs
CHANGED
|
@@ -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' +
|
package/src/capture-prompt.mjs
CHANGED
|
@@ -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
|
+
}
|
package/src/conflict-queue.mjs
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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
|
+
}
|
package/src/doctor.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// `cmk doctor` — health checks HC-1..HC-
|
|
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
|
|
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
|
}
|
|
@@ -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 {
|
|
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 =
|
|
239
|
+
const sha1 = hashContent(p.text);
|
|
240
240
|
const formatted = writeBullet({
|
|
241
241
|
id: p.id,
|
|
242
242
|
text: p.text,
|