@lh8ppl/claude-memory-kit 0.3.2 → 0.3.4

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
@@ -20,7 +20,7 @@
20
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
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.
22
22
  - **Per-project, in-repo** — `context/` lives inside your project and travels with `git clone`. Each project keeps its own memory.
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.
23
+ - **9 health checks** — `cmk doctor` validates hook wiring, distill freshness, transcript firing, INDEX consistency, cron registration, native-memory coexistence, stale locks, native-binding health (npm 12 readiness), and version drift (a project scaffold behind your installed `cmk` after an update) — each failure with a repair command.
24
24
 
25
25
  ## Install — pick ONE route
26
26
 
@@ -47,11 +47,13 @@ cmk doctor # verify, then restart Claude Code
47
47
  Inside Claude Code:
48
48
 
49
49
  ```text
50
- /plugin marketplace add LH8PPL/claude-memory-kit
51
- /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)
52
54
  ```
53
55
 
54
- 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).
55
57
 
56
58
  ## CLI
57
59
 
@@ -60,9 +62,9 @@ Most-used commands (full list via `cmk --help`):
60
62
  | Command | Purpose |
61
63
  | --- | --- |
62
64
  | `cmk install` | Scaffold `context/` + the `memory-write`/`memory-search` skills + `.gitignore` + CLAUDE.md block + wire hooks (`--no-hooks` for scaffold-only) |
63
- | `cmk doctor` | Run HC-1..HC-8 health checks, surface repair commands |
65
+ | `cmk doctor` | Run HC-1..HC-9 health checks, surface repair commands |
64
66
  | `cmk repair --hooks` / `--locks` / `--index` / `--all` | Idempotent self-repair |
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 |
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") |
66
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) |
67
69
  | `cmk roll --scope now\|today\|recent` | Manually trigger a compression pipeline |
68
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.2",
3
+ "version": "0.3.4",
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": {
@@ -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',
package/src/claude-md.mjs CHANGED
@@ -64,7 +64,10 @@ function buildBlock(content, version) {
64
64
  * gracefully from a corrupted block (e.g. the user accidentally
65
65
  * deleted the end marker by hand).
66
66
  */
67
- function findManagedBlock(text) {
67
+ // Exported (Task 162) for version-drift.mjs (HC-9) — reads the managed-block
68
+ // version marker without re-implementing the parser. Public contract: returns
69
+ // `{version, corrupted, ...}` or null.
70
+ export function findManagedBlock(text) {
68
71
  const startMatch = text.match(MARKER_START_RE);
69
72
  if (!startMatch) return null;
70
73
 
@@ -107,7 +110,9 @@ function parseVersion(v) {
107
110
  * compareVersions('1.0.0', '1.0.0') === 0
108
111
  * compareVersions('2.0.0', '1.9.9') === 1
109
112
  */
110
- function compareVersions(a, b) {
113
+ // Exported (Task 162) for version-drift.mjs (HC-9). Public contract: -1/0/1,
114
+ // strips a `-prerelease` suffix before comparing.
115
+ export function compareVersions(a, b) {
111
116
  const av = parseVersion(a);
112
117
  const bv = parseVersion(b);
113
118
  for (let i = 0; i < 3; i++) {
@@ -0,0 +1,155 @@
1
+ // Bounded, transient-only retry for the Haiku compress call (Task 161 / D-175).
2
+ //
3
+ // WHY this exists: the v0.3.3 cut-gate surfaced `haiku_timeout` / `compress_failed`
4
+ // failures on the compression path. Measurement (D-174) proved the failure is
5
+ // ENVIRONMENTAL/TRANSIENT, not input-size-driven — the kit's own compress.log shows
6
+ // the largest SUCCESS (470 KB) bigger than the largest timeout (334 KB), and a 9 KB
7
+ // input timed out. So the fix is a RETRY (a re-call usually succeeds), not an input
8
+ // cap. The kit inherited the no-retry shape from claude-remember (its precedent —
9
+ // which doesn't retry either); the rest of the field does.
10
+ //
11
+ // The SHAPE is grounded in a 9-system code read
12
+ // (docs/research/2026-06-19-llm-call-retry-patterns-cross-system.md), which converges
13
+ // unanimously on: bounded attempts, exponential backoff, retry ONLY the transient
14
+ // class keyed on the error TYPE, NEVER the deterministic class, reraise after
15
+ // exhaustion. graphiti's `is_server_or_retry_error` predicate + Letta's
16
+ // ValueError(retry)-vs-RuntimeError(don't) split are the model.
17
+ //
18
+ // COMPOSITION (design §8.5 / D-42): the SessionEnd-hook `compressSession` runs under
19
+ // a 60s ceiling CONCURRENT with autoPersona — a 50s attempt + a 50s retry = 100s
20
+ // blows the ceiling. So callers under the ceiling pass `maxAttempts: 1` (no retry —
21
+ // they delegate the retry to the ceiling-free lazy path via the existing
22
+ // restore-on-failure, D-79); only the ceiling-free paths (daily-distill /
23
+ // weekly-curate / lazy compress) pass `maxAttempts: 2`.
24
+ //
25
+ // COOLDOWN INTERACTION (skill-review I-1): the 120s Haiku cooldown marker is touched
26
+ // on SUCCESS only, and the callers gate `isCooldownActive` ONCE before the retry loop.
27
+ // A retry WIDENS the existing "no marker until success" window (~50s → ~100s), so a
28
+ // second hook firing mid-retry could pass the gate and start its own compress. This
29
+ // is NOT a new bug class — the window pre-exists the retry — and the D-79 claim-rename
30
+ // mutex (renameSync of now.md is the real lock) still prevents any corruption: only
31
+ // one roll wins the rename; the other reads an empty buffer and skips. The retry only
32
+ // makes a pre-existing benign window ~2× wider; no marker change is warranted.
33
+ //
34
+ // NO JITTER (skill-review M-2): the field (graphiti) jitters its backoff
35
+ // (wait_random_exponential) to avoid thundering-herd across many concurrent clients.
36
+ // The kit's compress is a single low-concurrency local process (one detached child at
37
+ // a time, gated by the cooldown), so there is no herd to avoid — a plain exponential
38
+ // backoff is sufficient and keeps the timing deterministic for tests.
39
+
40
+ /**
41
+ * Classify a compress() rejection as transient (worth a retry) or deterministic
42
+ * (a re-call re-fails identically — don't waste the attempt or the budget).
43
+ *
44
+ * Transient (retry):
45
+ * - HaikuTimeoutError (`category: 'haiku_timeout'`) — `claude --print` was slow;
46
+ * the D-174 environmental case. A re-call usually succeeds.
47
+ * - HaikuFailedError (`category: 'haiku_failed'`) whose stderr looks like a
48
+ * transient server/overload/rate-limit blip (the field's 5xx/429/overloaded
49
+ * class), classified from the `exit_code`/`stderr` that 161.6a now captures.
50
+ *
51
+ * Deterministic (do NOT retry):
52
+ * - A spawn error (`code: 'ENOENT'` etc.) — the binary isn't there; re-spawning
53
+ * re-fails.
54
+ * - A HaikuFailedError whose stderr is a known-deterministic class (auth /
55
+ * invalid-key / policy / bad-request) — retrying always re-fails (graphiti's
56
+ * explicit "retrying policy-violating content will always fail").
57
+ * - Anything unrecognized — default to NOT retryable (conservative: an unknown
58
+ * failure is more likely a real bug than a blip, and a wasted retry costs the
59
+ * hook budget).
60
+ *
61
+ * @param {unknown} err
62
+ * @returns {boolean}
63
+ */
64
+ export function isRetryableCompressError(err) {
65
+ if (!err || typeof err !== 'object') return false;
66
+
67
+ // Timeout = the transient/environmental case (D-174). Always retryable.
68
+ if (err.category === 'haiku_timeout') return true;
69
+
70
+ // Spawn-level failure (ENOENT / EACCES / EINVAL) — the binary/permissions are
71
+ // wrong; re-spawning re-fails identically. Never retryable.
72
+ if (typeof err.code === 'string' && /^E[A-Z]+$/.test(err.code)) return false;
73
+
74
+ // Non-zero exit — conditional on WHY (the exit_code/stderr 161.6a captures).
75
+ if (err.category === 'haiku_failed') {
76
+ const stderr = String(err.stderr ?? '').toLowerCase();
77
+ // Known-DETERMINISTIC classes: a re-call re-fails. Never retry these.
78
+ // (Skill-review I-2: `not found` was DROPPED — it appears in transient
79
+ // contexts too, e.g. a transient "host not found" / "upstream not found,
80
+ // retrying"; a deterministic 404 from `claude --print` is unlikely, and the
81
+ // conservative default below already catches a genuine unknown deterministic
82
+ // failure. Keeping only HIGH-CONFIDENCE deterministic markers.)
83
+ if (
84
+ /auth|invalid[_ -]?(api[_ -]?)?key|unauthor|forbidden|permission|policy|invalid[_ -]?request|bad[_ -]?request/.test(
85
+ stderr,
86
+ )
87
+ ) {
88
+ return false;
89
+ }
90
+ // Known-TRANSIENT classes: server/overload/rate blips recover on a re-call.
91
+ if (/overload|rate[_ -]?limit|429|5\d\d|timeout|timed[_ -]?out|temporar|unavailable|connection|network|reset/.test(stderr)) {
92
+ return true;
93
+ }
94
+ // Unknown non-zero exit → conservative: do NOT retry (treat as deterministic).
95
+ return false;
96
+ }
97
+
98
+ return false;
99
+ }
100
+
101
+ /**
102
+ * Call `backend.compress(opts)` with a bounded, transient-only retry.
103
+ *
104
+ * @param {{compress: (opts: object) => Promise<any>}} backend
105
+ * @param {object} opts — passed verbatim to backend.compress on every attempt.
106
+ * @param {object} [config]
107
+ * @param {number} [config.maxAttempts=2] — TOTAL attempts (1 = no retry; the ceiling-bound contract). Field range is 2–4; the kit uses ≤2 (one retry) to fit the budget.
108
+ * @param {number} [config.baseBackoffMs=600] — exponential backoff base: wait `baseBackoffMs * 2**(attempt-1)` before attempt N+1. 0 disables the wait (tests).
109
+ * @param {(err: unknown) => boolean} [config.isRetryable=isRetryableCompressError]
110
+ * @param {(ms: number) => Promise<void>} [config.sleep] — injectable for tests.
111
+ * @param {(info: {attempt: number, error: unknown}) => void} [config.onRetry] — fired once
112
+ * per retry (Task 161.12 observability), BEFORE the backoff, with the FAILED attempt
113
+ * number + the (transient) error. Callers use it to record a `retries` count on their
114
+ * compress.log entry so a frequent-retry rate (the degrading-environment signal D-174
115
+ * is about) is visible. Not fired on a first-try success or a non-retryable failure.
116
+ * @returns {Promise<any>} the backend.compress result; reraises the last error after exhaustion.
117
+ */
118
+ export async function compressWithRetry(
119
+ backend,
120
+ opts,
121
+ {
122
+ maxAttempts = 2,
123
+ baseBackoffMs = 600,
124
+ isRetryable = isRetryableCompressError,
125
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
126
+ onRetry,
127
+ } = {},
128
+ ) {
129
+ const attempts = Math.max(1, maxAttempts);
130
+ let lastErr;
131
+ for (let attempt = 1; attempt <= attempts; attempt++) {
132
+ try {
133
+ return await backend.compress(opts);
134
+ } catch (err) {
135
+ lastErr = err;
136
+ // Stop immediately if this is the last attempt OR the error isn't transient.
137
+ if (attempt >= attempts || !isRetryable(err)) {
138
+ throw err;
139
+ }
140
+ // We're going to retry — surface it for observability (161.12), before the wait.
141
+ if (typeof onRetry === 'function') {
142
+ try {
143
+ onRetry({ attempt, error: err });
144
+ } catch {
145
+ // onRetry is best-effort instrumentation — never let it break the retry.
146
+ }
147
+ }
148
+ // Exponential backoff before the next attempt (skip the wait when base is 0).
149
+ const delay = baseBackoffMs > 0 ? baseBackoffMs * 2 ** (attempt - 1) : 0;
150
+ if (delay > 0) await sleep(delay);
151
+ }
152
+ }
153
+ // Unreachable (the loop either returns or throws), but satisfies control-flow analysis.
154
+ throw lastErr;
155
+ }
@@ -37,6 +37,7 @@ import { join, dirname } from 'node:path';
37
37
  import { nowIso } from './audit-log.mjs';
38
38
  import { ERROR_CATEGORIES } from './result-shapes.mjs';
39
39
  import { HaikuTimeoutError } from './compressor.mjs';
40
+ import { compressWithRetry } from './compress-retry.mjs';
40
41
  import {
41
42
  DEFAULT_COOLDOWN_MS,
42
43
  isCooldownActive,
@@ -225,6 +226,12 @@ export async function compressSession({
225
226
  now,
226
227
  cooldownMs = DEFAULT_COOLDOWN_MS,
227
228
  maxOutputBytes = DEFAULT_MAX_OUTPUT_BYTES,
229
+ // Task 161 / D-175: retry policy. DEFAULT 1 = NO retry — the SessionEnd-hook
230
+ // contract: this fn runs under the 60s ceiling CONCURRENT with autoPersona, where
231
+ // a 50s attempt + a 50s retry = 100s blows the ceiling. The ceiling-free LAZY
232
+ // caller (runLazyCompress) passes maxAttempts:2 to opt into one retry; the hook
233
+ // keeps its restore-on-failure (D-79) and delegates the retry to that lazy path.
234
+ maxAttempts = 1,
228
235
  } = {}) {
229
236
  const ts = now ?? nowIso();
230
237
  const date = dateFromIso(ts);
@@ -325,14 +332,21 @@ export async function compressSession({
325
332
  // restoreRolling call, so the buffer is never stranded in the rolling file.
326
333
  // See design §8.5 for the composition rationale.
327
334
  let result;
335
+ let retries = 0; // Task 161.12: count retries (only the lazy maxAttempts:2 path can retry).
328
336
  try {
329
- result = await backend.compress({
330
- input: wrapBufferForPrompt(buffer),
331
- instructions,
332
- preserveCitationIds: true,
333
- maxOutputBytes,
334
- timeoutMs: 50_000,
335
- });
337
+ // maxAttempts default 1 (hook contract: no retry); the lazy caller passes 2.
338
+ // compressWithRetry is a no-op wrapper at maxAttempts:1 (single attempt, reraise).
339
+ result = await compressWithRetry(
340
+ backend,
341
+ {
342
+ input: wrapBufferForPrompt(buffer),
343
+ instructions,
344
+ preserveCitationIds: true,
345
+ maxOutputBytes,
346
+ timeoutMs: 50_000,
347
+ },
348
+ { maxAttempts, onRetry: () => { retries += 1; } },
349
+ );
336
350
  } catch (err) {
337
351
  // Distinguish HAIKU_TIMEOUT (slow Anthropic) from COMPRESS_FAILED
338
352
  // (non-zero subprocess exit / spawn ENOENT / etc). Analytics
@@ -357,6 +371,13 @@ export async function compressSession({
357
371
  duration_ms,
358
372
  success: false,
359
373
  error_category: errorCategory,
374
+ // Task 161 (D-173 observability): capture the STRUCTURED failure reason
375
+ // (subprocess exit code + stderr) so a `compress_failed` is diagnosable.
376
+ // Pre-161 the log kept only error_category — the WHY was discarded, which
377
+ // is why the kit's own 329-byte compress_failed could not be explained.
378
+ ...(err?.exitCode != null ? { exit_code: err.exitCode } : {}),
379
+ ...(err?.stderr ? { error_detail: String(err.stderr).slice(0, 500) } : {}),
380
+ ...(retries > 0 ? { retries } : {}), // 161.12: failed AFTER retrying (lazy path)
360
381
  };
361
382
  writeCompressLogEntry({ projectRoot, date, entry });
362
383
  return {
@@ -397,6 +418,7 @@ export async function compressSession({
397
418
  cost_usd: result?.costUSD ?? 0,
398
419
  duration_ms,
399
420
  success: true,
421
+ ...(retries > 0 ? { retries } : {}), // 161.12: succeeded after a transient retry (lazy path)
400
422
  };
401
423
  writeCompressLogEntry({ projectRoot, date, entry });
402
424
 
@@ -94,6 +94,23 @@ export class HaikuTimeoutError extends Error {
94
94
  }
95
95
  }
96
96
 
97
+ // Non-zero subprocess exit (the `compress_failed` category). Carries the
98
+ // STRUCTURED exit code + captured stderr so callers can write the real
99
+ // failure reason into compress.log — pre-161 this was a plain Error with
100
+ // the detail buried in `.message`, and the log kept only `error_category`,
101
+ // making a `compress_failed` undiagnosable (the 329-byte failure in the
102
+ // kit's own log that the D-173 investigation could not explain). Mirrors
103
+ // HaikuTimeoutError so the two failure modes carry parallel diagnostics.
104
+ export class HaikuFailedError extends Error {
105
+ constructor(message, { exitCode, stderr }) {
106
+ super(message);
107
+ this.name = 'HaikuFailedError';
108
+ this.category = 'haiku_failed';
109
+ this.exitCode = exitCode ?? null;
110
+ this.stderr = stderr ?? '';
111
+ }
112
+ }
113
+
97
114
  // SIGTERM → grace window → SIGKILL escalation. Exported so the kill
98
115
  // chain itself is independently testable against real OS processes
99
116
  // (see tests/spawn-smoke-kill-chain.test.js) — the production code
@@ -292,8 +309,9 @@ export class HaikuViaAnthropicApi extends CompressorBackend {
292
309
  if (settled) return; // timeout already fired
293
310
  if (code !== 0) {
294
311
  settleReject(
295
- new Error(
312
+ new HaikuFailedError(
296
313
  `HaikuViaAnthropicApi: claude --print exit ${code}: ${stderr.trim() || '(no stderr)'}`,
314
+ { exitCode: code, stderr: stderr.trim() },
297
315
  ),
298
316
  );
299
317
  return;
@@ -28,6 +28,7 @@ import { join } from 'node:path';
28
28
  import { nowIso } from './audit-log.mjs';
29
29
  import { ERROR_CATEGORIES } from './result-shapes.mjs';
30
30
  import { HaikuTimeoutError } from './compressor.mjs';
31
+ import { compressWithRetry } from './compress-retry.mjs';
31
32
  import {
32
33
  DEFAULT_COOLDOWN_MS,
33
34
  isCooldownActive,
@@ -195,14 +196,23 @@ export async function dailyDistill({
195
196
  const instructions = buildDistillInstructions(maxOutputBytes);
196
197
 
197
198
  let result;
199
+ let retries = 0; // Task 161.12: count retries so the log shows the retry RATE.
198
200
  try {
199
- result = await backend.compress({
200
- input: buffer,
201
- instructions,
202
- preserveCitationIds: true,
203
- maxOutputBytes,
204
- timeoutMs: 50_000,
205
- });
201
+ // Task 161 / D-175: ceiling-free path (cron/detached child, NO 60s hook ceiling)
202
+ // → bounded transient-only retry. A re-call recovers the D-174 environmental
203
+ // timeout / transient non-zero exit; a deterministic failure (ENOENT/auth) fails
204
+ // fast (isRetryableCompressError). maxAttempts:2 = one retry.
205
+ result = await compressWithRetry(
206
+ backend,
207
+ {
208
+ input: buffer,
209
+ instructions,
210
+ preserveCitationIds: true,
211
+ maxOutputBytes,
212
+ timeoutMs: 50_000,
213
+ },
214
+ { maxAttempts: 2, onRetry: () => { retries += 1; } },
215
+ );
206
216
  touchCooldownMarker({ projectRoot, now: ts });
207
217
  } catch (err) {
208
218
  touchCooldownMarker({ projectRoot, now: ts });
@@ -217,6 +227,10 @@ export async function dailyDistill({
217
227
  ts, scope: 'daily-distill', input_bytes, output_bytes: 0,
218
228
  model_id: typeof backend.modelId === 'function' ? backend.modelId() : null,
219
229
  cost_usd: 0, duration_ms, success: false, error_category: errorCategory,
230
+ // Task 161 (D-173 observability): structured failure reason — see compress-session.mjs.
231
+ ...(err?.exitCode != null ? { exit_code: err.exitCode } : {}),
232
+ ...(err?.stderr ? { error_detail: String(err.stderr).slice(0, 500) } : {}),
233
+ ...(retries > 0 ? { retries } : {}), // 161.12: failed AFTER retrying
220
234
  },
221
235
  });
222
236
  return {
@@ -246,6 +260,7 @@ export async function dailyDistill({
246
260
  (typeof backend.modelId === 'function' ? backend.modelId() : null),
247
261
  cost_usd: result?.costUSD ?? 0,
248
262
  duration_ms, success: true, source_days: files.length,
263
+ ...(retries > 0 ? { retries } : {}), // 161.12: succeeded after a transient retry
249
264
  },
250
265
  });
251
266
  return {
package/src/doctor.mjs CHANGED
@@ -1,4 +1,4 @@
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).
1
+ // `cmk doctor` — health checks HC-1..HC-9 (Task 37, T-031; memsearch HC-1/HC-7 removed in Task 120; HC-8 native bindings added in Task 141a; HC-9 version-drift/update-path added in Task 162 / D-176).
2
2
  //
3
3
  // Public boundary:
4
4
  // async runDoctor({projectRoot, userDir, now, promptUser?, ...overrides})
@@ -46,6 +46,8 @@ import { cronSentinelPath } from './lazy-compress.mjs';
46
46
  import { getNativeAutoMemoryState } from './native-memory.mjs';
47
47
  import { checkKitBinding, checkEmbedderBinding } from './native-binding.mjs';
48
48
  import { resolveDefaultSearchMode } from './semantic-backend.mjs';
49
+ import { checkVersionDrift } from './version-drift.mjs';
50
+ import { getKitVersion } from './install.mjs';
49
51
 
50
52
  const TWO_DAYS_MS = 2 * 24 * 60 * 60 * 1000;
51
53
  const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000;
@@ -541,10 +543,27 @@ async function hc8NativeBindings({ projectRoot, kitBindingProbe, embedderBinding
541
543
  * parameter lands at that PR alongside the actual consent flow — not
542
544
  * pre-empted in v0.1.0 to avoid the "forward-compat hooks rot" pattern.
543
545
  */
546
+ // --- HC-9: project scaffold version matches the installed cmk (Task 162 / D-176) ---
547
+ // After `npm i -g @latest`, a project's version-stamped scaffold stays at the OLD
548
+ // version until `cmk install` re-runs there (the easily-forgotten per-project step).
549
+ // HC-9 reads the project's CLAUDE.md managed-block version + the installed binary
550
+ // version and tells the user to re-run `cmk install` when the project is behind.
551
+ function hc9VersionDrift({ projectRoot, kitVersion }) {
552
+ const claudeMdPath = join(projectRoot, 'CLAUDE.md');
553
+ let claudeMdText = null;
554
+ try {
555
+ if (existsSync(claudeMdPath)) claudeMdText = readFileSync(claudeMdPath, 'utf8');
556
+ } catch {
557
+ claudeMdText = null; // unreadable → skip (treated as not-installed)
558
+ }
559
+ return checkVersionDrift({ claudeMdText, kitVersion });
560
+ }
561
+
544
562
  export async function runDoctor({
545
563
  projectRoot,
546
564
  userDir,
547
565
  now,
566
+ kitVersion,
548
567
  kitBindingProbe,
549
568
  embedderBindingProbe,
550
569
  } = {}) {
@@ -569,10 +588,12 @@ export async function runDoctor({
569
588
  const c6 = hc6NativeAutoMemory({ projectRoot, now: ts });
570
589
  const c7 = hc7StaleLocks({ projectRoot, userDir: resolvedUserDir });
571
590
  const c8 = await hc8NativeBindings({ projectRoot, kitBindingProbe, embedderBindingProbe });
591
+ // HC-9: kitVersion injectable for tests; defaults to the installed binary's version.
592
+ const c9 = hc9VersionDrift({ projectRoot, kitVersion: kitVersion ?? getKitVersion() });
572
593
 
573
594
  return {
574
595
  action: 'completed',
575
- checks: [c1, c2, c3, c4, c5, c6, c7, c8],
596
+ checks: [c1, c2, c3, c4, c5, c6, c7, c8, c9],
576
597
  duration_ms: Date.now() - t0,
577
598
  };
578
599
  }
@@ -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