@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.
@@ -35,6 +35,7 @@
35
35
  import { compressSession } from './compress-session.mjs';
36
36
  import { autoPersona } from './auto-persona.mjs';
37
37
  import { graduateAllScratchpads } from './graduate-session.mjs';
38
+ import { syncDecisionsJournal } from './decisions-journal.mjs';
38
39
 
39
40
  /**
40
41
  * Run the two independent SessionEnd Haiku passes concurrently.
@@ -45,7 +46,7 @@ import { graduateAllScratchpads } from './graduate-session.mjs';
45
46
  * @param {() => object} opts.makeBackend - factory returning a fresh CompressorBackend
46
47
  * per call (each concurrent pass gets its own instance — no shared state).
47
48
  * @param {string} [opts.now] - ISO timestamp override (tests).
48
- * @returns {Promise<{compressOutcome: PromiseSettledResult, personaOutcome: PromiseSettledResult, graduationOutcome: PromiseSettledResult}>}
49
+ * @returns {Promise<{compressOutcome: PromiseSettledResult, personaOutcome: PromiseSettledResult, graduationOutcome: PromiseSettledResult, journalOutcome: PromiseSettledResult}>}
49
50
  */
50
51
  export async function runSessionEndTasks({ projectRoot, userDir, makeBackend, now }) {
51
52
  const [compressOutcome, personaOutcome] = await Promise.allSettled([
@@ -75,7 +76,30 @@ export async function runSessionEndTasks({ projectRoot, userDir, makeBackend, no
75
76
  graduationOutcome = { status: 'rejected', reason: err };
76
77
  }
77
78
 
78
- return { compressOutcome, personaOutcome, graduationOutcome };
79
+ // Task 159 (D-169): auto-sync the decision journal. This is what makes
80
+ // DECISIONS.md "automatic" (D-164) — Task 147 BUILT the append logic but wired
81
+ // it to ONLY the manual `cmk digest`, so the journal never populated on its own.
82
+ // Same shape as the graduation sweep: SEQUENTIAL, pure local file I/O (reads the
83
+ // type:project fact files auto-extract wrote per-turn → rewrites DECISIONS.md),
84
+ // no Haiku/network (~175ms), no hook-ceiling risk, wrapped so a throw can't reject
85
+ // the hook. DISJOINT from compress (sessions/ tree) + persona (user-tier) +
86
+ // graduation (persona scratchpads) — nothing else in the block touches DECISIONS.md,
87
+ // so no lock contention. Session-end is the natural "this session's decisions
88
+ // landed → render them" boundary (squad's session-end Scribe instinct, made
89
+ // deterministic — the kit's typed-fact substrate needs no LLM to merge).
90
+ // syncDecisionsJournal is already best-effort (its own try/catch returns
91
+ // {written:false,error}); the wrapper here guards the unexpected synchronous throw.
92
+ let journalOutcome;
93
+ try {
94
+ journalOutcome = {
95
+ status: 'fulfilled',
96
+ value: syncDecisionsJournal({ projectRoot, now }),
97
+ };
98
+ } catch (err) {
99
+ journalOutcome = { status: 'rejected', reason: err };
100
+ }
101
+
102
+ return { compressOutcome, personaOutcome, graduationOutcome, journalOutcome };
79
103
  }
80
104
 
81
105
  /**
@@ -86,7 +110,7 @@ export async function runSessionEndTasks({ projectRoot, userDir, makeBackend, no
86
110
  * @param {{compressOutcome: PromiseSettledResult, personaOutcome: PromiseSettledResult}} outcomes
87
111
  * @returns {string[]}
88
112
  */
89
- export function summarizeSessionEnd({ compressOutcome, personaOutcome, graduationOutcome }) {
113
+ export function summarizeSessionEnd({ compressOutcome, personaOutcome, graduationOutcome, journalOutcome }) {
90
114
  const lines = [];
91
115
 
92
116
  if (compressOutcome.status === 'fulfilled') {
@@ -123,5 +147,18 @@ export function summarizeSessionEnd({ compressOutcome, personaOutcome, graduatio
123
147
  }
124
148
  }
125
149
 
150
+ // journalOutcome is optional (Task 159) — pre-159 callers render no journal line.
151
+ if (journalOutcome) {
152
+ if (journalOutcome.status === 'fulfilled') {
153
+ const j = journalOutcome.value ?? {};
154
+ lines.push(
155
+ `cmk-compress-session: journal (written: ${j.written ?? false}, appended: ${j.appended ?? 0})\n`,
156
+ );
157
+ } else {
158
+ const e = journalOutcome.reason;
159
+ lines.push(`cmk-compress-session: journal sync failed: ${e?.message ?? e}\n`);
160
+ }
161
+ }
162
+
126
163
  return lines;
127
164
  }
@@ -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;
@@ -869,6 +897,32 @@ function runReindex(options /* , command */) {
869
897
  }
870
898
  }
871
899
 
900
+ /**
901
+ * `cmk digest` (Task 147) — print a regenerated, readable render of everything
902
+ * the kit currently knows, AND sync the append-only context/DECISIONS.md
903
+ * journal (the permanent decision ledger; D-161). The digest goes to stdout;
904
+ * the journal is a committed file the sync maintains in place.
905
+ */
906
+ async function runDigestCli(options) {
907
+ const projectRoot = resolvePath(process.cwd());
908
+ const { digest } = await import('./digest.mjs');
909
+ const { syncDecisionsJournal } = await import('./decisions-journal.mjs');
910
+
911
+ // Keep the permanent decision journal current (append-only; best-effort —
912
+ // a journal hiccup must not break the digest render).
913
+ const sync = syncDecisionsJournal({ projectRoot });
914
+
915
+ console.log(digest({ projectRoot }));
916
+
917
+ if (sync.written) {
918
+ console.log(`\ncontext/DECISIONS.md updated (+${sync.appended} bytes) — the append-only decision journal.`);
919
+ } else if (sync.error) {
920
+ console.error(`\n(decision journal not updated: ${sync.error})`);
921
+ } else {
922
+ console.log('\ncontext/DECISIONS.md is up to date.');
923
+ }
924
+ }
925
+
872
926
  /**
873
927
  * `cmk forget <id-or-query>` — wired in Task 9. Tombstones the matching
874
928
  * fact (moves it to <tier>/<memory|fragments>/archive/tombstones/<id>.md
@@ -1973,6 +2027,12 @@ export const subcommands = [
1973
2027
  description: 'fetch full observation bodies + provenance by ID (parity with the mk_get MCP tool)',
1974
2028
  milestone: 108,
1975
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
+ ],
1976
2036
  action: runGet,
1977
2037
  },
1978
2038
  {
@@ -2019,6 +2079,12 @@ export const subcommands = [
2019
2079
  milestone: 37,
2020
2080
  action: runDoctorCli,
2021
2081
  },
2082
+ {
2083
+ name: 'digest',
2084
+ description: 'print a readable digest of everything in memory + sync the append-only DECISIONS.md decision journal',
2085
+ milestone: 147,
2086
+ action: runDigestCli,
2087
+ },
2022
2088
  {
2023
2089
  name: 'config',
2024
2090
  description: 'read/write kit settings (context/settings.json) without hand-editing JSON',
@@ -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