@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.
@@ -416,7 +416,19 @@ async function runSearch(queryParts, options) {
416
416
  let mode = explicitMode ?? resolveDefaultSearchMode({ projectRoot });
417
417
  // Task 104.2 — the L3 raw tier: `--scope transcripts` searches the
418
418
  // separate transcript-chunk index (synthetic T: ids; no tier/trust).
419
+ // Task 156 — `--scope decisions` scans context/DECISIONS.md (the decision
420
+ // journal) for decision-history / "what did we reject" recall.
419
421
  const scope = options?.scope ?? 'facts';
422
+ // Task 156 / v0.3.3 cut-gate-16: the `decisions` scope is keyword-only BY
423
+ // DESIGN — it scans the flat `context/DECISIONS.md` journal, which is NOT
424
+ // embedded (no vec table). So it can never go through the semantic backend.
425
+ // Coerce to keyword BEFORE the semantic block, silently — a user who has the
426
+ // hybrid default (from `--with-semantic`) must not see a scary
427
+ // "unknown-scope:decisions" warning (configured default) or hard exit-2
428
+ // (explicit --mode) for using a real, shipped scope. The recall just works.
429
+ if (scope === 'decisions') {
430
+ mode = SEARCH_MODES.KEYWORD;
431
+ }
420
432
  let semanticBackend;
421
433
  if (mode === SEARCH_MODES.SEMANTIC || mode === SEARCH_MODES.HYBRID) {
422
434
  const { prepareSemanticBackend } = await import('./semantic-backend.mjs');
@@ -443,6 +455,7 @@ async function runSearch(queryParts, options) {
443
455
  query,
444
456
  mode,
445
457
  scope,
458
+ projectRoot, // Task 156: the decisions scope reads context/DECISIONS.md
446
459
  minTrust: options?.minTrust,
447
460
  tier: options?.tier,
448
461
  since: options?.since,
@@ -464,9 +477,15 @@ async function runSearch(queryParts, options) {
464
477
  for (const hit of r.results) {
465
478
  // Plain-text output suitable for terminal piping. Snippet uses
466
479
  // FTS5's <b>...</b> markers; preserved as-is so callers can pipe
467
- // to a TUI that renders them OR strip via sed. Transcript hits carry
468
- // no tier/trust (raw chunks) the column shows the scope instead.
469
- const provenance = hit.tier ? `${hit.tier}/${hit.trust}` : 'transcript';
480
+ // to a TUI that renders them OR strip via sed. Hits with no tier/trust
481
+ // (raw transcript chunks; decision-journal entries) show the scope's
482
+ // label instead 'transcript' for the L3 raw tier, 'decision' for the
483
+ // journal (Task 156), plus a `(retracted)` marker so the "what did we
484
+ // reject" trail is visible at a glance.
485
+ let provenance;
486
+ if (hit.tier) provenance = `${hit.tier}/${hit.trust}`;
487
+ else if (r.scope === 'decisions') provenance = hit.retracted ? 'decision (retracted)' : 'decision';
488
+ else provenance = 'transcript';
470
489
  console.log(
471
490
  `${hit.id}\t${provenance}\t${hit.source_file}:${hit.source_line}\t${hit.snippet}`,
472
491
  );
@@ -512,10 +531,19 @@ export function withReadDb(fn, deps = {}) {
512
531
  }
513
532
  }
514
533
 
515
- export function runGet(ids, _options = {}, _command, deps = {}) {
534
+ export function runGet(ids, options = {}, _command, deps = {}) {
516
535
  const log = deps.log ?? console.log;
517
536
  const list = Array.isArray(ids) ? ids : [ids];
518
- const rows = withReadDb((db) => getObservations(db, list), deps);
537
+ // Task 155 (D-163): `--include-tombstoned` is the HUMAN-only recovery opt-in.
538
+ // It's a CLI flag ONLY — the MCP mk_get tool never exposes it, so automatic
539
+ // recall stays tombstone-blind. projectRoot is resolved the same way
540
+ // withReadDb does, so the tombstone-file fallback can find the archive.
541
+ const includeTombstoned = options.includeTombstoned === true;
542
+ const projectRoot = deps.projectRoot ?? resolvePath(process.cwd());
543
+ const rows = withReadDb(
544
+ (db) => getObservations(db, list, { includeTombstoned, projectRoot }),
545
+ deps,
546
+ );
519
547
  log(JSON.stringify(rows, null, 2));
520
548
  // All-missing/invalid → exit 2 (lets a script tell "nothing matched" from a hit).
521
549
  if (rows.length > 0 && rows.every((r) => r.error)) process.exitCode = 2;
@@ -1999,6 +2027,12 @@ export const subcommands = [
1999
2027
  description: 'fetch full observation bodies + provenance by ID (parity with the mk_get MCP tool)',
2000
2028
  milestone: 108,
2001
2029
  argSpec: [{ flags: '<ids...>', description: 'one or more citation IDs (e.g. P-S79MJHFN)' }],
2030
+ optionSpec: [
2031
+ {
2032
+ flags: '--include-tombstoned',
2033
+ description: 'also recover forgotten (tombstoned) facts from the archive — human-only; the AI never reads tombstones',
2034
+ },
2035
+ ],
2002
2036
  action: runGet,
2003
2037
  },
2004
2038
  {
@@ -0,0 +1,72 @@
1
+ // HC-9: version-drift detection (Task 162 / D-176).
2
+ //
3
+ // WHY: after a user updates the global `cmk` (npm i -g @latest), a project's
4
+ // version-stamped scaffold — the CLAUDE.md managed block, the hooks, the skills —
5
+ // stays at the OLD version until `cmk install` re-runs in that project. Updating the
6
+ // npm package ALONE does not touch a project (the per-project re-install is the
7
+ // easily-forgotten step). Pre-162 the kit was silent about it (D-172: no update path).
8
+ // HC-9 makes `cmk doctor` TELL the user the project is behind + the exact command.
9
+ //
10
+ // The project's installed version lives in the CLAUDE.md managed-block start marker
11
+ // (`<!-- claude-memory-kit:start v0.3.3 -->`); the installed binary version is
12
+ // getKitVersion(). Drift = binary NEWER than the project marker → "run cmk install".
13
+ // A project marker NEWER than the binary is a downgrade (older global cli opening a
14
+ // newer-scaffolded project), NOT drift — flag pass, not a false alarm.
15
+
16
+ import { findManagedBlock, compareVersions } from './claude-md.mjs';
17
+
18
+ /**
19
+ * Pure HC-9 check. Injectable inputs (no disk read here) so the logic is unit-tested
20
+ * without a fixture tree; the doctor wiring reads CLAUDE.md + getKitVersion() and
21
+ * passes them in.
22
+ *
23
+ * @param {object} args
24
+ * @param {string|null} args.claudeMdText — the project's CLAUDE.md content, or null if absent.
25
+ * @param {string} args.kitVersion — the installed binary version (getKitVersion()).
26
+ * @returns {{id:'HC-9', name:string, status:'pass'|'fail'|'skip', message:string, recoveryCommand?:string}}
27
+ */
28
+ export function checkVersionDrift({ claudeMdText, kitVersion } = {}) {
29
+ const id = 'HC-9';
30
+ const name = 'Project scaffold version matches the installed cmk';
31
+
32
+ // No CLAUDE.md, or no managed block → the project isn't kit-installed (or the block
33
+ // was hand-removed). Not a drift signal; skip (HC-1/repair owns the missing-block case).
34
+ if (!claudeMdText) {
35
+ return { id, name, status: 'skip', message: 'no CLAUDE.md found — project not kit-installed' };
36
+ }
37
+ const block = findManagedBlock(claudeMdText);
38
+ if (!block) {
39
+ return { id, name, status: 'skip', message: 'no claude-memory-kit managed block in CLAUDE.md' };
40
+ }
41
+
42
+ // `block.version` is the `:start vX` marker value (findManagedBlock recovers it
43
+ // even from a corrupted/orphan-start block — a stale corrupted block still earns
44
+ // the `cmk install` advice, which fixes both). compareVersions strips any
45
+ // `-prerelease` tag, so a `v0.3.4-beta` scaffold reads as `0.3.4` (the kit ships
46
+ // no prereleases today; this is the intended "close enough" behavior).
47
+ const projectVersion = block.version;
48
+ const cmp = compareVersions(kitVersion, projectVersion);
49
+
50
+ if (cmp <= 0) {
51
+ // Binary == project (match) OR binary < project (a downgrade — older cli, newer
52
+ // scaffold). Neither is "re-run install to catch up." Pass.
53
+ return {
54
+ id,
55
+ name,
56
+ status: 'pass',
57
+ message:
58
+ cmp === 0
59
+ ? `project scaffold (v${projectVersion}) matches the installed cmk (v${kitVersion})`
60
+ : `project scaffold (v${projectVersion}) is newer than the installed cmk (v${kitVersion}) — likely an older global cli; not drift`,
61
+ };
62
+ }
63
+
64
+ // Binary NEWER than the project marker → the project is stale. THE drift case.
65
+ return {
66
+ id,
67
+ name,
68
+ status: 'fail',
69
+ message: `this project's scaffold is v${projectVersion} but your installed cmk is v${kitVersion} — re-run \`cmk install\` here to refresh the CLAUDE.md block, hooks, and skills (then restart Claude Code)`,
70
+ recoveryCommand: 'cmk install',
71
+ };
72
+ }
@@ -39,6 +39,7 @@ import { canonicalize } from '@lh8ppl/cmk-canonicalize';
39
39
  import { nowIso } from './audit-log.mjs';
40
40
  import { ERROR_CATEGORIES, errorResult } from './result-shapes.mjs';
41
41
  import { HaikuTimeoutError } from './compressor.mjs';
42
+ import { compressWithRetry } from './compress-retry.mjs';
42
43
  import {
43
44
  DEFAULT_COOLDOWN_MS,
44
45
  isCooldownActive,
@@ -388,14 +389,21 @@ export async function weeklyCurate({
388
389
  const sourceDates = old.map((f) => f.date);
389
390
 
390
391
  let result;
392
+ let retries = 0; // Task 161.12: count retries so the log shows the retry RATE.
391
393
  try {
392
- result = await backend.compress({
393
- input: buffer,
394
- instructions,
395
- preserveCitationIds: true,
396
- maxOutputBytes: archiveMaxBytes,
397
- timeoutMs: 50_000,
398
- });
394
+ // Task 161 / D-175: ceiling-free path (cron/detached child, NO 60s hook ceiling)
395
+ // → bounded transient-only retry (maxAttempts:2 = one retry). See compress-retry.mjs.
396
+ result = await compressWithRetry(
397
+ backend,
398
+ {
399
+ input: buffer,
400
+ instructions,
401
+ preserveCitationIds: true,
402
+ maxOutputBytes: archiveMaxBytes,
403
+ timeoutMs: 50_000,
404
+ },
405
+ { maxAttempts: 2, onRetry: () => { retries += 1; } },
406
+ );
399
407
  touchCooldownMarker({ projectRoot, now: ts });
400
408
  } catch (err) {
401
409
  touchCooldownMarker({ projectRoot, now: ts });
@@ -418,6 +426,10 @@ export async function weeklyCurate({
418
426
  duration_ms,
419
427
  success: false,
420
428
  error_category: errorCategory,
429
+ // Task 161 (D-173 observability): structured failure reason — see compress-session.mjs.
430
+ ...(err?.exitCode != null ? { exit_code: err.exitCode } : {}),
431
+ ...(err?.stderr ? { error_detail: String(err.stderr).slice(0, 500) } : {}),
432
+ ...(retries > 0 ? { retries } : {}), // 161.12: failed AFTER retrying
421
433
  },
422
434
  });
423
435
  return errorResult({
@@ -487,6 +499,7 @@ export async function weeklyCurate({
487
499
  archived_days: old.length,
488
500
  current_days: current.length,
489
501
  recent_rebuild_action: recentResult?.action ?? 'skipped',
502
+ ...(retries > 0 ? { retries } : {}), // 161.12: succeeded after a transient retry
490
503
  ...(deletionErrors.length > 0 ? { deletion_errors: deletionErrors } : {}),
491
504
  },
492
505
  });
@@ -63,6 +63,26 @@ Hits are raw turn excerpts (dialogue + the tools the agent ran), keyed
63
63
  whole turns. If something found here is durably useful, say so in the
64
64
  summary so the caller can capture it as a proper fact.
65
65
 
66
+ ## Decision HISTORY — the `decisions` scope
67
+
68
+ For "what did we DECIDE about X" a normal fact search (steps 1-3) is enough —
69
+ the decision fact carries its own **Why**. But when the question is about how a
70
+ decision **evolved**, what we **reject**ed or moved away from, or **why X
71
+ changed** ("did we ever consider Y?", "weren't we using Postgres?", "what did
72
+ we decide and did it change?"), search the **decision journal** — the
73
+ append-only `context/DECISIONS.md`, which keeps superseded + retracted entries
74
+ the live fact store no longer carries:
75
+
76
+ - MCP: `mk_search` with `scope: "decisions"`.
77
+ - CLI: `cmk search "<topic>" --scope decisions`
78
+
79
+ Hits are decision entries keyed by their fact id, labelled `decision` (or
80
+ `decision (retracted)` for a reversed one). The retracted/superseded entries
81
+ ARE the answer to "what did we reject" — surface them explicitly, with the
82
+ date, so the caller sees the trail. Use this scope IN ADDITION to the fact
83
+ ladder when the question has a history/evolution axis; the fact search answers
84
+ the "current decision", the journal answers "how it got there".
85
+
66
86
  ## When the query is vague
67
87
 
68
88
  If you cannot form a concrete query, look at recent activity first, then
@@ -29,6 +29,7 @@ The `cmk doctor` health checks verify each layer is wired correctly: install int
29
29
  The snapshot injected at session start is a **bounded hot index, not everything** — there is a deeper, queryable archive. When a question is "what did we decide / what's our X / how does the user work / what's the setup / **how is this project structured or built / where does X live / what's the architecture**," **query your memory instead of re-deriving the answer from scratch** — the structure is a recorded decision, recall it before re-reading the files to reconstruct it:
30
30
 
31
31
  - **`cmk search "<topic>"`** — find any captured fact (decisions, preferences, config, lessons) across the project + user tiers.
32
+ - **`cmk search "<topic>" --scope decisions`** — the append-only **decision journal** (`context/DECISIONS.md`). Use it for decision **history / evolution** — "what did we reject", "did X change", "why did we move away from Y" — it keeps superseded + retracted decisions the live fact store drops. (A plain `cmk search` answers "what's the current decision"; this answers "how it got there".)
32
33
  - **`context/memory/<type>_<slug>.md`** — the granular fact archive with full **Why / How** rationale (`context/memory/INDEX.md` lists them).
33
34
  - **`~/.claude-memory-kit/` (`USER.md` / `HABITS.md` / `LESSONS.md`)** — how this user works across *all* their projects.
34
35