@lh8ppl/claude-memory-kit 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.
@@ -40,11 +47,13 @@ cmk doctor # verify, then restart Claude Code
40
47
  Inside Claude Code:
41
48
 
42
49
  ```text
43
- /plugin marketplace add LH8PPL/claude-memory-kit
44
- /plugin install claude-memory-kit
50
+ /plugin marketplace add LH8PPL/claude-memory-kit # add this repo as a plugin source (once per machine)
51
+ /plugin install claude-memory-kit # install the global machinery — hooks + skills (once per machine)
52
+ cd ~/my-project # the project you want memory in — bootstrap scaffolds into the CURRENT dir
53
+ /claude-memory-kit:bootstrap # scaffold this project's context/ memory tree (once per project)
45
54
  ```
46
55
 
47
- Then say *"bootstrap the memory system"* to scaffold this project's `context/`. The plugin bundles the hooks + the `bootstrap`, `memory-write`, and `memory-search` skills, so it's complete without the npm CLI (add the CLI later only if you want `cmk search` / `cmk doctor` / cron).
56
+ The first two commands are **global** (per machine); `bootstrap` is **per project** — run it again (after a `cd`) in each project. The plugin bundles the hooks + the `bootstrap`, `memory-write`, and `memory-search` skills, so it's complete without the npm CLI (add the CLI later only if you want `cmk search` / `cmk doctor` / cron).
48
57
 
49
58
  ## CLI
50
59
 
@@ -55,7 +64,7 @@ Most-used commands (full list via `cmk --help`):
55
64
  | `cmk install` | Scaffold `context/` + the `memory-write`/`memory-search` skills + `.gitignore` + CLAUDE.md block + wire hooks (`--no-hooks` for scaffold-only) |
56
65
  | `cmk doctor` | Run HC-1..HC-8 health checks, surface repair commands |
57
66
  | `cmk repair --hooks` / `--locks` / `--index` / `--all` | Idempotent self-repair |
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 |
67
+ | `cmk search "<query>" [--mode keyword\|semantic\|hybrid] [--scope facts\|transcripts\|decisions]` | Search memory — by meaning with the embedder (hybrid default after `--with-semantic`); `--scope transcripts` = the raw session record; `--scope decisions` = the decision journal (history / "what did we reject") |
59
68
  | `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) |
60
69
  | `cmk roll --scope now\|today\|recent` | Manually trigger a compression pipeline |
61
70
  | `cmk register-crons [--dry-run] [--unregister]` | Register daily + weekly jobs with cron / launchd / Task Scheduler |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lh8ppl/claude-memory-kit",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
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
  },
@@ -53,6 +53,7 @@ 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';
56
+ import { sanitizeForTitle } from './sanitize.mjs';
56
57
  import { HaikuTimeoutError } from './compressor.mjs';
57
58
  import { pidIsAlive } from './lock-discipline.mjs';
58
59
  import { nowIso } from './audit-log.mjs';
@@ -638,9 +639,16 @@ function routeMedium({ candidate, projectRoot, ts }) {
638
639
  // Direct-to-fact-store (NOT the review queue the terse medium-trust path uses):
639
640
  // the point of Task 103 is AUTOMATIC native-parity capture — native writes its
640
641
  // fact files with no approval step, so parity requires the same. The fact store
641
- // is searchable-but-not-full-trust-injected, writeFact already screens every
642
- // write (home-path sanitize + Poison_Guard + schema + INDEX/reindex), and a
643
- // later explicit `cmk remember` (trust:high) supersedes. See design §6.4.
642
+ // is searchable-but-not-full-trust-injected, writeFact screens the body +
643
+ // frontmatter (Poison_Guard + home-path sanitize + schema + INDEX/reindex), and
644
+ // a later explicit `cmk remember` (trust:high) supersedes. See design §6.4.
645
+ //
646
+ // CAVEAT (F-V0.3.3-2): writeFact does NOT sanitize the SLUG/filename — the slug
647
+ // is `slugifyFact(title)` derived HERE, before writeFact runs. So the title MUST
648
+ // be routed through sanitizeForTitle first, or a home path in Haiku's candidate
649
+ // title (auto-extract runs every turn, no user action) leaks the username into a
650
+ // COMMITTED filename. This was the same bug as cmk remember — the old comment
651
+ // here wrongly assumed "writeFact already sanitizes" the whole write.
644
652
  //
645
653
  // trust:medium / write_source:auto-extract marks it as a Haiku synthesis
646
654
  // (proposal-grade), below the explicit-high tier. The body is built by the SAME
@@ -652,11 +660,14 @@ function routeRichFact({ candidate, projectRoot, ts }) {
652
660
  why: candidate.why,
653
661
  how: candidate.how,
654
662
  });
663
+ // Sanitize the title BEFORE deriving the slug (F-V0.3.3-2) — writeFact won't
664
+ // catch a home path in the slug/filename. One helper, same as cmk remember.
665
+ const title = sanitizeForTitle(candidate.title);
655
666
  return writeFact({
656
667
  tier: 'P',
657
668
  type: candidate.type,
658
- slug: slugifyFact(candidate.title),
659
- title: candidate.title,
669
+ slug: slugifyFact(title),
670
+ title,
660
671
  body,
661
672
  writeSource: 'auto-extract',
662
673
  trust: 'medium',
@@ -0,0 +1,223 @@
1
+ // The append-only decision journal — context/DECISIONS.md (Task 147, D-161).
2
+ //
3
+ // A chronological, human-readable page of every decision + its why. The VIEW
4
+ // the kit was missing: decisions are captured as facts but scattered across N
5
+ // per-fact files with no chronological decision page (cmk search is pull, not
6
+ // browse; MEMORY.md is bounded + rolls). This is the squad `decisions.md` /
7
+ // our own DECISION-LOG.md equivalent, made automatic.
8
+ //
9
+ // LIFECYCLE — APPEND-ONLY, never regenerated, never parked (D-161):
10
+ // - This is NOT a derived view like INDEX.md. Regenerating from live facts
11
+ // would silently ERASE superseded/forgotten decisions — rewriting history
12
+ // to look like the current state was always obvious (the exact failure the
13
+ // decision-trail-preservation rule exists to prevent).
14
+ // - A decision journal is unbounded by design: old decisions are the MOST
15
+ // valuable part (they explain why the codebase is shaped as it is), so the
16
+ // MEMORY.md rolling-window must NOT apply.
17
+ // - Mechanics: new decision → appended; tombstoned → its entry MARKED
18
+ // retracted IN PLACE (never removed); every pre-existing entry survives
19
+ // every update.
20
+ //
21
+ // The update is triggered like a derived view (runs where reindex runs, so the
22
+ // journal stays current) but its WRITE LOGIC is append-only — the file is the
23
+ // accumulator, the facts are only the trigger. Each entry carries a stable
24
+ // machine marker `<!-- decision:P-XXXXXXXX -->` so the updater knows which ids
25
+ // are already journaled + which entries to annotate, without parsing prose
26
+ // (and so a human can freely add their own prose between entries — preserved).
27
+ //
28
+ // v0.3.2 scope: explicit signals only (capture appends; forget marks retracted;
29
+ // explicit supersession annotates). AUTOMATIC semantic contradiction-detection
30
+ // is deferred to F-D / Task 95.
31
+
32
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
33
+ import { join } from 'node:path';
34
+ import { parse as parseFrontmatter } from './frontmatter.mjs';
35
+ import { ID_PATTERN } from './tier-paths.mjs';
36
+
37
+ export const DECISIONS_HEADER =
38
+ '# Decisions\n\n' +
39
+ '> Append-only decision journal — every decision the kit captured, in order, with its why.\n' +
40
+ '> Maintained by claude-memory-kit (`cmk digest`). Superseded/retracted entries stay (the trail is the point).';
41
+
42
+ // Only this fact type is a "decision" in the kit taxonomy (the project/state
43
+ // category — what project-memory's decisions.md and our DECISION-LOG track).
44
+ const DECISION_TYPE = 'project';
45
+
46
+ const markerFor = (id) => `<!-- decision:${id} -->`;
47
+ const RETRACT_TAG = '_(retracted';
48
+
49
+ /** The yyyy-mm-dd slice of an ISO timestamp, or the raw value if unparseable. */
50
+ function dateOnly(iso) {
51
+ if (typeof iso !== 'string') return 'unknown-date';
52
+ const m = iso.match(/^(\d{4}-\d{2}-\d{2})/);
53
+ return m ? m[1] : iso;
54
+ }
55
+
56
+ /**
57
+ * Render ONE journal entry for a decision fact. The machine marker lets the
58
+ * updater dedup + annotate; the human-readable lines are Title / date / Why / id.
59
+ *
60
+ * @param {{id:string,title:string,createdAt?:string,why?:string|null}} f
61
+ * @returns {string} the entry block (no trailing newline)
62
+ */
63
+ export function buildDecisionEntry(f) {
64
+ const date = dateOnly(f.createdAt);
65
+ const lines = [
66
+ markerFor(f.id),
67
+ `### ${f.title}`,
68
+ `**When:** ${date} · **Fact:** \`${f.id}\``,
69
+ ];
70
+ if (f.why && String(f.why).trim()) {
71
+ lines.push(`**Why:** ${String(f.why).trim()}`);
72
+ }
73
+ return lines.join('\n');
74
+ }
75
+
76
+ // The kit's id matcher, DERIVED from the canonical ID_PATTERN (tier-paths.mjs)
77
+ // so the base32 alphabet lives in exactly ONE place and can't drift. The
78
+ // original bug: this module hardcoded `[A-Z2-9]` (uppercase only), but the real
79
+ // alphabet includes a lowercase `a` — so any id containing `a` never matched
80
+ // "already journaled" → re-appended on EVERY digest run (the cut-gate find).
81
+ // Strip the `^…$` anchors to embed the pattern inside larger regexes.
82
+ const ID_CHARS = ID_PATTERN.source.replace(/^\^/, '').replace(/\$$/, '');
83
+
84
+ /** ids already present in the journal body (by their machine marker). */
85
+ function journaledIds(content) {
86
+ const ids = new Set();
87
+ const re = new RegExp(`<!-- decision:(${ID_CHARS}) -->`, 'g');
88
+ let m;
89
+ while ((m = re.exec(content)) !== null) ids.add(m[1]);
90
+ return ids;
91
+ }
92
+
93
+ /**
94
+ * Append-only journal update (D-161). Pure: content in → content out.
95
+ *
96
+ * @param {object} a
97
+ * @param {string} a.existingContent current DECISIONS.md (‘’ if absent)
98
+ * @param {Array} a.facts live decision-class facts ({id,type,title,createdAt,why})
99
+ * @param {Set<string>} a.tombstonedIds ids whose fact has been forgotten
100
+ * @param {string} a.now ISO timestamp for retraction stamps
101
+ * @returns {string} the new DECISIONS.md content
102
+ */
103
+ export function updateDecisionsJournal({ existingContent = '', facts = [], tombstonedIds = new Set(), now }) {
104
+ let content = existingContent.trim() === '' ? DECISIONS_HEADER + '\n' : existingContent;
105
+ const already = journaledIds(content);
106
+
107
+ // 1) Append entries for decision-class facts not yet journaled.
108
+ const newEntries = [];
109
+ for (const f of facts) {
110
+ if (f.type !== DECISION_TYPE) continue; // only decisions
111
+ if (already.has(f.id)) continue; // already journaled — never duplicate
112
+ newEntries.push(buildDecisionEntry(f));
113
+ already.add(f.id);
114
+ }
115
+ if (newEntries.length > 0) {
116
+ if (!content.endsWith('\n')) content += '\n';
117
+ content += '\n' + newEntries.join('\n\n') + '\n';
118
+ }
119
+
120
+ // 2) Mark retracted (in place) any journaled entry whose fact is now
121
+ // tombstoned and not already marked. Never removes the entry.
122
+ const stamp = dateOnly(now);
123
+ for (const id of tombstonedIds) {
124
+ const marker = markerFor(id);
125
+ const idx = content.indexOf(marker);
126
+ if (idx === -1) continue; // not journaled — nothing to retract
127
+ // Bound the search to THIS entry's span — up to the next decision marker
128
+ // (or end-of-file). Prevents a malformed/hand-edited entry with no heading
129
+ // from attaching the retraction note to the NEXT entry's heading.
130
+ const nextMarker = content.indexOf('<!-- decision:', idx + marker.length);
131
+ const spanEnd = nextMarker === -1 ? content.length : nextMarker;
132
+ // Find this entry's heading line (the `### …` after the marker, within span).
133
+ const headingStart = content.indexOf('### ', idx);
134
+ if (headingStart === -1 || headingStart >= spanEnd) continue;
135
+ const headingEnd = content.indexOf('\n', headingStart);
136
+ if (headingEnd === -1) continue;
137
+ // Already retracted? (the note sits right after the heading)
138
+ const afterHeading = content.slice(headingEnd + 1, headingEnd + 1 + RETRACT_TAG.length);
139
+ if (afterHeading === RETRACT_TAG) continue;
140
+ const note = `${RETRACT_TAG} ${stamp})_`;
141
+ content = content.slice(0, headingEnd + 1) + note + '\n' + content.slice(headingEnd + 1);
142
+ }
143
+
144
+ if (!content.endsWith('\n')) content += '\n';
145
+ return content;
146
+ }
147
+
148
+ // --- File-IO orchestration (the impure shell over the pure core) ----------
149
+
150
+ // Leading indent is [ \t]* (NOT \s*) so it can't match the newline the
151
+ // (?:^|\n) anchor already consumed — that overlap is the backtracking
152
+ // ambiguity SonarCloud flags as ReDoS. Disjoint character classes = linear.
153
+ const RICH_WHY_RE = /(?:^|\n)[ \t]*\*\*Why:\*\*[ \t]*([^\n]+)/;
154
+
155
+ /** Read decision-class facts (type:project) from the project tier. */
156
+ function readProjectDecisionFacts(projectRoot) {
157
+ const dir = join(projectRoot, 'context', 'memory');
158
+ const out = [];
159
+ if (!existsSync(dir)) return out;
160
+ for (const name of readdirSync(dir)) {
161
+ if (!name.endsWith('.md') || name === 'INDEX.md') continue;
162
+ try {
163
+ const { frontmatter, body } = parseFrontmatter(readFileSync(join(dir, name), 'utf8'));
164
+ if (!frontmatter?.id || frontmatter.type !== DECISION_TYPE) continue;
165
+ if (frontmatter.deleted_at) continue;
166
+ const whyMatch = String(body ?? '').match(RICH_WHY_RE);
167
+ out.push({
168
+ id: frontmatter.id,
169
+ type: frontmatter.type,
170
+ title: frontmatter.title ?? frontmatter.id,
171
+ createdAt: frontmatter.created_at ?? null,
172
+ why: whyMatch ? whyMatch[1].trim() : null,
173
+ });
174
+ } catch {
175
+ // unparseable file — reindex/HC-4 own that class; the journal skips it
176
+ }
177
+ }
178
+ // Stable chronological order (oldest first) so appends read like a timeline.
179
+ out.sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
180
+ return out;
181
+ }
182
+
183
+ /** ids of forgotten facts (tombstone archive). */
184
+ function readTombstonedIds(projectRoot) {
185
+ const ids = new Set();
186
+ const dir = join(projectRoot, 'context', 'memory', 'archive', 'tombstones');
187
+ if (!existsSync(dir)) return ids;
188
+ for (const name of readdirSync(dir)) {
189
+ const m = name.match(new RegExp(`^(${ID_CHARS})\\.md$`));
190
+ if (m) ids.add(m[1]);
191
+ }
192
+ return ids;
193
+ }
194
+
195
+ /**
196
+ * Read → append-only update → write context/DECISIONS.md. Idempotent: a run
197
+ * with nothing new is a no-op write (same bytes). Best-effort: never throws
198
+ * into the caller (a journal failure must not break a capture/read path).
199
+ *
200
+ * @returns {{written:boolean, path:string, appended:number}|{written:false,error:string}}
201
+ */
202
+ export function syncDecisionsJournal({ projectRoot, now } = {}) {
203
+ try {
204
+ const path = join(projectRoot, 'context', 'DECISIONS.md');
205
+ const existingContent = existsSync(path) ? readFileSync(path, 'utf8') : '';
206
+ const facts = readProjectDecisionFacts(projectRoot);
207
+ const tombstonedIds = readTombstonedIds(projectRoot);
208
+ const before = existingContent;
209
+ const next = updateDecisionsJournal({
210
+ existingContent,
211
+ facts,
212
+ tombstonedIds,
213
+ now: now ?? new Date().toISOString(),
214
+ });
215
+ if (next !== before) {
216
+ writeFileSync(path, next, 'utf8');
217
+ return { written: true, path, appended: next.length - before.length };
218
+ }
219
+ return { written: false, path, appended: 0 };
220
+ } catch (err) {
221
+ return { written: false, error: err?.message ?? String(err) };
222
+ }
223
+ }
package/src/digest.mjs ADDED
@@ -0,0 +1,89 @@
1
+ // `cmk digest` — a regenerated, readable render of everything the kit currently
2
+ // knows (Task 147, D-132). Facts by type + the persona + active threads, as one
3
+ // markdown page. The README-demo artifact.
4
+ //
5
+ // REGENERATED (not append-only): unlike DECISIONS.md (the permanent journal),
6
+ // the digest is a CURRENT-KNOWLEDGE snapshot — it should reflect only what
7
+ // exists now, so it is rebuilt on every invocation (the INDEX.md lifecycle,
8
+ // correct here). The two surfaces differ on purpose: digest = "what do we know
9
+ // now", DECISIONS.md = "what did we decide over time" (D-161).
10
+ //
11
+ // Read-only by contract: pure reads over the fact archive + scratchpads. The
12
+ // `--decisions` flag also triggers the DECISIONS.md journal sync (the one
13
+ // mutation, delegated to the append-only writer).
14
+
15
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ import { parse as parseFrontmatter } from './frontmatter.mjs';
18
+
19
+ const TYPE_ORDER = ['project', 'feedback', 'reference', 'user'];
20
+ const TYPE_LABEL = {
21
+ project: 'Decisions & project state',
22
+ feedback: 'Working-style & preferences',
23
+ reference: 'References',
24
+ user: 'About the user',
25
+ };
26
+
27
+ function readFacts(projectRoot) {
28
+ const dir = join(projectRoot, 'context', 'memory');
29
+ const facts = [];
30
+ if (!existsSync(dir)) return facts;
31
+ for (const name of readdirSync(dir)) {
32
+ if (!name.endsWith('.md') || name === 'INDEX.md') continue;
33
+ try {
34
+ const { frontmatter } = parseFrontmatter(readFileSync(join(dir, name), 'utf8'));
35
+ if (!frontmatter?.id || frontmatter.deleted_at) continue;
36
+ facts.push({
37
+ id: frontmatter.id,
38
+ type: frontmatter.type ?? 'unknown',
39
+ title: frontmatter.title ?? frontmatter.id,
40
+ trust: frontmatter.trust ?? 'unknown',
41
+ createdAt: frontmatter.created_at ?? null,
42
+ });
43
+ } catch {
44
+ // unparseable — reindex/HC-4 own that class
45
+ }
46
+ }
47
+ return facts;
48
+ }
49
+
50
+ /**
51
+ * Build the digest markdown from facts (pure — exported for testing).
52
+ * @param {Array} facts
53
+ * @param {{now?:string}} [opts]
54
+ */
55
+ export function buildDigest(facts, { now } = {}) {
56
+ const stamp = (now ?? new Date().toISOString()).slice(0, 10);
57
+ const lines = [`# Memory digest — ${stamp}`, ''];
58
+ if (facts.length === 0) {
59
+ lines.push('_Memory is empty — capture starts as you work._', '');
60
+ return lines.join('\n');
61
+ }
62
+ lines.push(`${facts.length} fact(s) in project memory.`, '');
63
+
64
+ const byType = new Map();
65
+ for (const f of facts) {
66
+ if (!byType.has(f.type)) byType.set(f.type, []);
67
+ byType.get(f.type).push(f);
68
+ }
69
+ const orderedTypes = [
70
+ ...TYPE_ORDER.filter((t) => byType.has(t)),
71
+ ...[...byType.keys()].filter((t) => !TYPE_ORDER.includes(t)),
72
+ ];
73
+ for (const type of orderedTypes) {
74
+ const group = byType.get(type).slice().sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
75
+ lines.push(`## ${TYPE_LABEL[type] ?? type} (${group.length})`, '');
76
+ for (const f of group) {
77
+ const date = String(f.createdAt ?? '').slice(0, 10) || '—';
78
+ lines.push(`- **${f.title}** · \`${f.id}\` · ${f.trust} · ${date}`);
79
+ }
80
+ lines.push('');
81
+ }
82
+ return lines.join('\n');
83
+ }
84
+
85
+ /** Read facts + render the digest for a project (read-only). */
86
+ export function digest({ projectRoot, now } = {}) {
87
+ const facts = readFacts(projectRoot);
88
+ return buildDigest(facts, { now });
89
+ }
package/src/forget.mjs CHANGED
@@ -172,6 +172,12 @@ function scrubAllScratchpads(tierRoot, id) {
172
172
  if (!entry.isFile()) continue;
173
173
  if (!entry.name.endsWith('.md')) continue;
174
174
  if (entry.name === 'INDEX.md') continue;
175
+ // DECISIONS.md is the APPEND-ONLY decision journal (Task 147 / D-161), NOT
176
+ // a scratchpad — forget must NOT strip its id-bearing lines (the marker +
177
+ // **Fact:** line). The journal sync marks the entry RETRACTED in place
178
+ // instead (preserving the trail). Scrubbing it here would delete the
179
+ // entry's marker and break the retract-in-place path (composition bug).
180
+ if (entry.name === 'DECISIONS.md') continue;
175
181
  const p = join(tierRoot, entry.name);
176
182
  const r = scrubScratchpadFile(p, id);
177
183
  if (r.changed) edits.push({ path: p, removed: r.removed });
@@ -32,7 +32,7 @@ import { appendAuditEntry, nowIso, REASON_CODES } from './audit-log.mjs';
32
32
  import { ERROR_CATEGORIES, errorResult } from './result-shapes.mjs';
33
33
  import { writeFact } from './write-fact.mjs';
34
34
  import { slugifyFact } from './rich-fact.mjs';
35
- import { sanitizeHomePaths } from './sanitize.mjs';
35
+ import { sanitizeHomePaths, sanitizeForTitle } from './sanitize.mjs';
36
36
  import { parse as parseFrontmatter } from './frontmatter.mjs';
37
37
 
38
38
  const DEFAULT_FILE = 'CLAUDE.md';
@@ -280,7 +280,12 @@ export async function importClaudeMd({
280
280
  // absolute --file argument (the D-51 name-privacy class).
281
281
  const sourceFileField = sanitizeHomePaths(fileRel);
282
282
  for (const p of proposals) {
283
- const title = p.text.split('\n')[0].slice(0, 80);
283
+ // Sanitize BEFORE deriving the title/slug (F-V0.3.3-2) p.text is the RAW
284
+ // rule text (the `sanitized` above feeds only the dedup key/id), and the
285
+ // slug becomes the committed filename, which writeFact won't sanitize. A
286
+ // CLAUDE.md rule mentioning C:\Users\you\ would otherwise leak the username
287
+ // into the imported fact's filename. Same one helper as the other slug sites.
288
+ const title = sanitizeForTitle(p.text).split('\n')[0].slice(0, 80);
284
289
  let slug = slugifyFact(title);
285
290
  if (usedSlugs.has(`${p.type}/${slug}`)) slug = `${slug}-l${p.line}`;
286
291
  usedSlugs.add(`${p.type}/${slug}`);
@@ -295,6 +295,40 @@ function parseSource(source, { projectRoot, userDir }) {
295
295
 
296
296
  // --- DB write helpers -------------------------------------------------
297
297
 
298
+ // Bug 1 (2026-06-16, fact P-UCG4RKNL): the kit dual-writes a fact to BOTH the
299
+ // MEMORY.md scratchpad bullet AND its granular archive file, both carrying the
300
+ // SAME content-addressed id. `observations.id` is a global PRIMARY KEY, so a
301
+ // plain INSERT of the second source's row collided (`UNIQUE constraint failed:
302
+ // observations.id`) and aborted the whole reindex. The fix is id-keyed upsert
303
+ // with deterministic ARCHIVE-BEATS-SCRATCHPAD precedence — validated against
304
+ // three markdown-first analogs that all key replacement on the id, never the
305
+ // file (TencentDB `ON CONFLICT(record_id) DO UPDATE`; basic-memory
306
+ // resolve-permalink precedence + partial unique index; memweave content-hash
307
+ // dedup). See docs/research/2026-06-16-index-uniqueness-id-vs-file-scoped-delete.md.
308
+ //
309
+ // Two precedence-keyed paths, order-INDEPENDENT (the source walk order must not
310
+ // change the surviving row):
311
+ // - fact (granular archive = the canonical Why/How home) → explicit
312
+ // DELETE-by-id then INSERT: always wins, overwriting any scratchpad row for
313
+ // the id.
314
+ // - scratchpad (the hot working-copy bullet) → ON CONFLICT(id) DO NOTHING:
315
+ // inserts only when no row exists yet; never overwrites a fact row.
316
+ // Whichever is walked first, the fact row is the one that survives.
317
+ //
318
+ // FTS5 CORRECTNESS (the self-review catch): the fact path uses an explicit
319
+ // DELETE-by-id, NOT `INSERT OR REPLACE`. `observations_fts` is an
320
+ // external-content FTS5 table whose only safe delete path is the
321
+ // `obs_after_delete` trigger firing the 'delete' SENTINEL with the OLD row's
322
+ // column values (index-db.mjs §4.4.3 comment). `INSERT OR REPLACE` reuses the
323
+ // conflicting row's rowid, so its internal delete+insert leaves the OLD
324
+ // scratchpad body orphaned in the FTS index (it keeps MATCH-ing with no backing
325
+ // row — silent stale-hit corruption). An explicit `DELETE FROM observations
326
+ // WHERE id = ?` fires obs_after_delete cleanly (sentinel removes the old terms),
327
+ // then the plain INSERT fires obs_after_insert. This is the same delete-then-
328
+ // insert pattern every other writer in the kit uses against this table.
329
+
330
+ const DELETE_OBSERVATION_BY_ID_SQL = `DELETE FROM observations WHERE id = ?`;
331
+
298
332
  const INSERT_OBSERVATION_SQL = `
299
333
  INSERT INTO observations
300
334
  (id, tier, source_file, source_line, source_sha1, heading_path, body,
@@ -304,6 +338,16 @@ VALUES
304
338
  @write_source, @trust, @created_at, @superseded_by, @deleted_at)
305
339
  `;
306
340
 
341
+ const INSERT_SCRATCHPAD_OBSERVATION_SQL = `
342
+ INSERT INTO observations
343
+ (id, tier, source_file, source_line, source_sha1, heading_path, body,
344
+ write_source, trust, created_at, superseded_by, deleted_at)
345
+ VALUES
346
+ (@id, @tier, @source_file, @source_line, @source_sha1, @heading_path, @body,
347
+ @write_source, @trust, @created_at, @superseded_by, @deleted_at)
348
+ ON CONFLICT(id) DO NOTHING
349
+ `;
350
+
307
351
  const UPSERT_FILE_SQL = `
308
352
  INSERT INTO files (path, mtime, sha1, indexed_at)
309
353
  VALUES (@path, @mtime, @sha1, @indexed_at)
@@ -322,10 +366,48 @@ const DELETE_OBSERVATIONS_FOR_PATH_SQL = `DELETE FROM observations WHERE source_
322
366
  */
323
367
  function replaceObservationsForFile(db, { source, observations, mtime, sha1, projectRoot, userDir, now }) {
324
368
  const source_file = relativeSource(source.path, { projectRoot, userDir });
369
+ // File-scoped delete clears THIS file's own rows so a re-index of a changed
370
+ // file is idempotent. It only matches rows whose source_file is this path, so
371
+ // a fact's row (source_file = context/memory/*.md) is untouched when the
372
+ // scratchpad (context/MEMORY.md) is re-indexed, and vice versa — the
373
+ // cross-file id collision is handled by the precedence-keyed insert below,
374
+ // NOT by this delete (Bug 1).
325
375
  db.prepare(DELETE_OBSERVATIONS_FOR_PATH_SQL).run(source_file);
326
- const insert = db.prepare(INSERT_OBSERVATION_SQL);
327
- for (const obs of observations) {
328
- insert.run(obs);
376
+ // Archive-beats-scratchpad precedence (Bug 1): a fact row wins the id by
377
+ // explicitly deleting any existing row for that id first (firing the FTS
378
+ // 'delete' sentinel cleanly) then inserting; a scratchpad row yields via
379
+ // ON CONFLICT(id) DO NOTHING. Within a FULL pass (reindexFull, or a
380
+ // reindexBoot that re-walks both sources) this is order-independent — the
381
+ // fact row always wins (listObservationSources walks scratchpad-before-facts
382
+ // per tier, but either order lands the same surviving row).
383
+ //
384
+ // INCREMENTAL caveat (skill-review I1): on the mtime-skip boot path / the
385
+ // single-file watcher path, only the CHANGED source is re-processed. If a
386
+ // fact file is removed while its scratchpad twin (same id) is untouched, the
387
+ // orphan-prune drops the fact row and the skipped scratchpad's DO-NOTHING
388
+ // insert never re-fires — so the id momentarily vanishes from search until
389
+ // the scratchpad is next edited (which re-inserts it). `cmk forget` does NOT
390
+ // hit this: it tombstones the fact AND scrubs the scratchpad bullet in the
391
+ // same op (forget.mjs scrubAllScratchpads), so the only window is a manual
392
+ // hand-`rm` of a context/memory/*.md leaving the bullet behind — a rare,
393
+ // self-healing transition, documented + tested rather than resurrected.
394
+ //
395
+ // The DELETE-by-id is UNQUALIFIED (no tier/source_file filter) by design and
396
+ // safe: ids are content-addressed WITH the tier as a prefix (`P-`/`L-`/`U-`),
397
+ // so a P-tier and U-tier fact can never share an id — no cross-tier delete is
398
+ // possible. (Defended by the P/U-same-content tier test below.)
399
+ if (source.kind === 'fact') {
400
+ const deleteById = db.prepare(DELETE_OBSERVATION_BY_ID_SQL);
401
+ const insert = db.prepare(INSERT_OBSERVATION_SQL);
402
+ for (const obs of observations) {
403
+ deleteById.run(obs.id);
404
+ insert.run(obs);
405
+ }
406
+ } else {
407
+ const insert = db.prepare(INSERT_SCRATCHPAD_OBSERVATION_SQL);
408
+ for (const obs of observations) {
409
+ insert.run(obs);
410
+ }
329
411
  }
330
412
  db.prepare(UPSERT_FILE_SQL).run({
331
413
  path: source_file,
@@ -370,6 +452,9 @@ export function reindexBoot({ projectRoot, userDir, db, now }) {
370
452
  userDir,
371
453
  now: ts,
372
454
  });
455
+ // observationsAffected counts insert-ATTEMPTS, not net rows: a fact that
456
+ // displaces a same-id scratchpad row (Bug 1 precedence) is net-zero but
457
+ // counts as one here. It's a "work done" metric, not a row-count invariant.
373
458
  return result.observations.length;
374
459
  });
375
460
 
@@ -537,6 +622,9 @@ export function reindexFull({ projectRoot, userDir, db, now }) {
537
622
  userDir,
538
623
  now: ts,
539
624
  });
625
+ // observationsAffected counts insert-ATTEMPTS, not net rows: a fact that
626
+ // displaces a same-id scratchpad row (Bug 1 precedence) is net-zero but
627
+ // counts as one here. It's a "work done" metric, not a row-count invariant.
540
628
  return result.observations.length;
541
629
  });
542
630
 
@@ -35,7 +35,7 @@ import { join } from 'node:path';
35
35
  import { homedir } from 'node:os';
36
36
  import { SCRATCHPADS_BY_TIER, resolveTierRoot, ID_PATTERN } from './tier-paths.mjs';
37
37
  import { nowIso } from './audit-log.mjs';
38
- import { detectStaleness } from './lazy-compress.mjs';
38
+ import { detectStaleness, isJournalStale } from './lazy-compress.mjs';
39
39
  import { isProvenanceCommentLine, parseBulletProvenance } from './provenance.mjs';
40
40
  import { listConflictQueue } from './conflict-queue.mjs';
41
41
  import { listReviewQueue } from './review-queue.mjs';
@@ -787,18 +787,28 @@ export function injectContext({
787
787
  let lazyTrigger = null;
788
788
  try {
789
789
  const verdict = detectStaleness({ projectRoot, now: ts });
790
- lazyTrigger = { verdict: verdict.action, reason: verdict.reason };
791
- if (
790
+ // Task 159 (D-169): journal-staleness is an INDEPENDENT spawn trigger — the
791
+ // detached lazy worker syncs DECISIONS.md unconditionally, so a session that's
792
+ // compress-fresh (or cron-active) but has new un-journaled decisions must
793
+ // still spawn, else the journal never renders without a clean SessionEnd
794
+ // (the Task-105/D-75 no-clean-exit class). Cron handles compress but NOT the
795
+ // journal, so cron-active + a stale journal SHOULD spawn (compress skips
796
+ // inside, the journal syncs). It is NOT a competing detectStaleness verdict
797
+ // (one verdict → one compress dispatch; folding journal in would suppress
798
+ // compress work — the separately-correct-jointly-broken class).
799
+ const journalStale = isJournalStale(projectRoot);
800
+ lazyTrigger = { verdict: verdict.action, reason: verdict.reason, journalStale };
801
+ const compressStale =
792
802
  verdict.action === 'stale-now' ||
793
803
  verdict.action === 'stale-daily' ||
794
- verdict.action === 'stale-weekly'
795
- ) {
804
+ verdict.action === 'stale-weekly';
805
+ if (compressStale || journalStale) {
796
806
  const spawner = typeof testSpawnLazy === 'function' ? testSpawnLazy : spawnLazyCompress;
797
807
  const spawnResult = spawner(projectRoot, compressLazyPath);
798
808
  lazyTrigger = { ...lazyTrigger, ...spawnResult };
799
809
  }
800
810
  } catch (err) {
801
- // detectStaleness should be defensive; if it throws, log + continue.
811
+ // detectStaleness / isJournalStale should be defensive; if they throw, log + continue.
802
812
  lazyTrigger = { verdict: 'error', error: err?.message ?? String(err) };
803
813
  }
804
814