@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.
@@ -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
 
@@ -44,6 +44,7 @@ import {
44
44
  import { dailyDistill } from './daily-distill.mjs';
45
45
  import { weeklyCurate } from './weekly-curate.mjs';
46
46
  import { compressSession } from './compress-session.mjs';
47
+ import { syncDecisionsJournal } from './decisions-journal.mjs';
47
48
 
48
49
  const DEFAULT_DAILY_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
49
50
  const DEFAULT_WEEKLY_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
@@ -132,6 +133,61 @@ function recentMdMtimeMs(projectRoot) {
132
133
  }
133
134
  }
134
135
 
136
+ const MEMORY_REL = ['context', 'memory'];
137
+ const DECISIONS_MD_REL = ['context', 'DECISIONS.md'];
138
+
139
+ /**
140
+ * Task 159 (D-169): is the decision journal behind the captured decision facts?
141
+ *
142
+ * INDEPENDENT of compress staleness — a compress-fresh session can still have
143
+ * new `type:project` decision facts that aren't yet rendered into DECISIONS.md.
144
+ * So this is its OWN boolean (NOT a competing detectStaleness verdict, which can
145
+ * only return ONE action and would suppress compress work). Used as an ADDITIONAL
146
+ * spawn condition in inject-context, and the journal is synced unconditionally
147
+ * inside runLazyCompress.
148
+ *
149
+ * **O(1) — runs inline on EVERY SessionStart, so it must compose with the 500ms
150
+ * NFR-1 budget.** It uses `context/memory/INDEX.md` as the freshness proxy:
151
+ * `write-fact.mjs` rewrites INDEX.md on every fact write, so `INDEX.md` mtime ≥
152
+ * the newest fact file always (verified). Comparing two file mtimes is O(1) — vs
153
+ * stat-every-fact, which was ~130ms on a 307-fact corpus and grew linearly (a
154
+ * self-review find; that approach would blow the budget on a large repo).
155
+ *
156
+ * Stale ⇔ a `project_*.md` fact exists (short-circuit on the first one — no stat)
157
+ * AND (DECISIONS.md is missing OR older than INDEX.md). Trade-off: INDEX.md
158
+ * covers ALL fact types, so a feedback-only write can flag the journal stale →
159
+ * one spurious detached sync (~175ms, idempotent, never a correctness issue) —
160
+ * acceptable for an O(1) check on the hot SessionStart path. Defensive: any
161
+ * throw → false (never block SessionStart on a stat error).
162
+ *
163
+ * @param {string} projectRoot
164
+ * @returns {boolean}
165
+ */
166
+ export function isJournalStale(projectRoot) {
167
+ if (!projectRoot) return false;
168
+ try {
169
+ const memDir = join(projectRoot, ...MEMORY_REL);
170
+ if (!existsSync(memDir)) return false;
171
+ // Any project (decision) fact at all? Short-circuit on the first — no stat,
172
+ // just the dirent name. No project facts → nothing to journal → not stale.
173
+ const hasDecisionFact = readdirSync(memDir).some(
174
+ (name) => name.startsWith('project_') && name.endsWith('.md'),
175
+ );
176
+ if (!hasDecisionFact) return false;
177
+ const journalPath = join(projectRoot, ...DECISIONS_MD_REL);
178
+ if (!existsSync(journalPath)) return true; // facts exist, journal missing → stale
179
+ // INDEX.md is the O(1) freshness proxy (rewritten on every fact write). If
180
+ // it's absent (pre-index repo), fall back to "facts exist + journal exists"
181
+ // → treat as fresh (a reindex will create INDEX.md; the session-end sync
182
+ // covers the journal regardless).
183
+ const indexPath = join(memDir, 'INDEX.md');
184
+ if (!existsSync(indexPath)) return false;
185
+ return statSync(indexPath).mtimeMs > statSync(journalPath).mtimeMs;
186
+ } catch {
187
+ return false;
188
+ }
189
+ }
190
+
135
191
  /**
136
192
  * Cheap inline staleness check. Runs in <5ms — one stat + a few existsSync.
137
193
  *
@@ -254,6 +310,31 @@ export async function runLazyCompress({
254
310
  });
255
311
  }
256
312
 
313
+ // Task 159 (D-169): sync the decision journal UNCONDITIONALLY, before any
314
+ // compress gate. This is the SessionStart fallback path for sessions that never
315
+ // cleanly closed (Claude Code fires SessionEnd only on clean window-close — the
316
+ // Task-105/D-75 class), where the primary session-end sync never ran. It must
317
+ // run regardless of the compress verdict (cooldown / cron-active / fresh) — a
318
+ // cron-active or compress-fresh session can still have new decisions. Cheap pure
319
+ // file I/O (~175ms), idempotent (a no-change run rewrites nothing), best-effort
320
+ // (syncDecisionsJournal has its own try/catch + soft-error return). It does NOT
321
+ // touch the Haiku cooldown — that gate is for the LLM compress passes only.
322
+ // Door 4: log the outcome to lazy-compress.log so a silent fallback-path
323
+ // failure (e.g. a DECISIONS.md permissions error) leaves a trace — the rest of
324
+ // this function is fully NDJSON-observable, and the journal sync must be too.
325
+ const journalResult = syncDecisionsJournal({ projectRoot, now: ts });
326
+ writeLazyLogEntry({
327
+ projectRoot,
328
+ entry: {
329
+ ts,
330
+ scope: 'journal-sync',
331
+ action: journalResult?.error ? 'error' : journalResult?.written ? 'written' : 'no-change',
332
+ written: journalResult?.written ?? false,
333
+ appended: journalResult?.appended ?? 0,
334
+ ...(journalResult?.error ? { error: journalResult.error } : {}),
335
+ },
336
+ });
337
+
257
338
  // Cooldown gate up front — composes with shared 120s marker.
258
339
  if (isCooldownActive({ projectRoot, now: ts, cooldownMs })) {
259
340
  const duration_ms = Date.now() - t0;
@@ -330,6 +411,11 @@ export async function runLazyCompress({
330
411
  backend,
331
412
  now: ts,
332
413
  cooldownMs: 0,
414
+ // Task 161 / D-175: the lazy path is a DETACHED SessionStart child with NO 60s
415
+ // hook ceiling, so it opts into the one retry the hook path can't afford. This
416
+ // is where the SessionEnd-hook's failed roll (which restored now.md, D-79) gets
417
+ // its real bounded retry.
418
+ maxAttempts: 2,
333
419
  });
334
420
  } else if (verdict.action === 'stale-weekly') {
335
421
  delegatedTo = 'weekly-curate';
@@ -155,6 +155,7 @@ function makeMkSearch({ db, semanticBackend, projectRoot }) {
155
155
  db, query,
156
156
  mode: wantMode,
157
157
  scope,
158
+ projectRoot, // Task 156: the decisions scope reads context/DECISIONS.md
158
159
  tier,
159
160
  since,
160
161
  limit,
@@ -181,6 +182,13 @@ function makeMkSearch({ db, semanticBackend, projectRoot }) {
181
182
  function makeMkGet({ db }) {
182
183
  // Thin adapter over the shared read core (read-core.getObservations) — the
183
184
  // SAME logic the CLI `cmk get` calls (ADR-0014 parity).
185
+ //
186
+ // D-163 (BINDING): mk_get is tombstone-BLIND and must stay that way. It calls
187
+ // getObservations WITHOUT `includeTombstoned`, so a forgotten fact returns
188
+ // `not found`. Tombstone recovery is a HUMAN-only verb (`cmk get
189
+ // --include-tombstoned`); the agent must NEVER recover a fact the user
190
+ // forgot (resurfacing a deleted fact is the worst memory-product failure).
191
+ // Do NOT add an includeTombstoned param to this tool.
184
192
  return async ({ ids }) => ({
185
193
  content: [{ type: 'text', text: JSON.stringify(getObservations(db, ids), null, 2) }],
186
194
  });
@@ -563,7 +571,7 @@ export function buildMcpServer({ projectRoot, userDir, db, semanticBackend }) {
563
571
  inputSchema: {
564
572
  query: z.string().min(1).describe('search query'),
565
573
  mode: z.enum(['keyword', 'semantic', 'hybrid']).optional(),
566
- scope: z.enum(['facts', 'transcripts']).optional().describe("'facts' (default) = curated memory; 'transcripts' = the raw session record — the LAST-RESORT recall tier, search it only when curated memory has no answer"),
574
+ scope: z.enum(['facts', 'transcripts', 'decisions']).optional().describe("'facts' (default) = curated memory; 'transcripts' = the raw session record (LAST-RESORT only when curated memory has no answer); 'decisions' = the append-only decision journal (context/DECISIONS.md) — use for decision-HISTORY / evolution / 'what did we reject / why did X change' queries (it returns superseded + retracted entries the live fact store no longer carries)"),
567
575
  tier: z.enum(['U', 'P', 'L']).optional(),
568
576
  since: z.string().optional().describe('ISO 8601 timestamp'),
569
577
  limit: z.number().int().positive().max(1000).optional(),
package/src/read-core.mjs CHANGED
@@ -6,7 +6,10 @@
6
6
  // surfaces, one implementation. Pure (db + args in, plain data out); the MCP
7
7
  // adapter wraps the result in a content envelope, the CLI adapter prints it.
8
8
 
9
+ import { existsSync, readFileSync } from 'node:fs';
10
+ import { join } from 'node:path';
9
11
  import { ID_PATTERN } from './tier-paths.mjs';
12
+ import { parse as parseFrontmatter } from './frontmatter.mjs';
10
13
 
11
14
  const GET_COLUMNS =
12
15
  'id, body, heading_path, source_file, source_line, tier, trust, ' +
@@ -15,17 +18,76 @@ const GET_COLUMNS =
15
18
  /**
16
19
  * Fetch full observation rows by id. An invalid-format or missing id becomes
17
20
  * a `{ id, error }` entry (the array stays positionally aligned with `ids`).
21
+ *
22
+ * Task 155 (D-163) — opt-in tombstone recovery. By DEFAULT this is live-only:
23
+ * a forgotten id (its index row pruned by Task 110, the body moved to
24
+ * `context/memory/archive/tombstones/<id>.md`) returns `not found`. The
25
+ * automatic recall surfaces (the SessionStart snapshot, `mk_search`, `mk_get`)
26
+ * MUST stay on this default — a deleted fact must remain invisible to the agent
27
+ * (resurfacing it is the worst memory-product failure). ONLY an explicit
28
+ * HUMAN-driven `cmk get --include-tombstoned` opts in, passing
29
+ * `{ includeTombstoned: true, projectRoot }`; on a live miss it then reads the
30
+ * tombstone file directly and returns its body marked `tombstoned: true`.
31
+ *
32
+ * @param {object} [opts]
33
+ * @param {boolean} [opts.includeTombstoned=false] human-only recovery opt-in
34
+ * @param {string} [opts.projectRoot] required when includeTombstoned (to find the archive)
18
35
  */
19
- export function getObservations(db, ids) {
36
+ export function getObservations(db, ids, { includeTombstoned = false, projectRoot } = {}) {
20
37
  const stmt = db.prepare(`SELECT ${GET_COLUMNS} FROM observations WHERE id = ?`);
21
38
  return ids.map((id) => {
22
39
  if (!ID_PATTERN.test(id)) return { id, error: 'invalid id format' };
23
40
  const row = stmt.get(id);
24
- if (!row) return { id, error: 'not found' };
25
- return row;
41
+ if (row) return row; // a LIVE hit always wins — recovery is a miss-only fallback
42
+ // Live miss. Recovery is opt-in AND needs projectRoot to locate the archive.
43
+ if (includeTombstoned && projectRoot) {
44
+ const recovered = readTombstone(projectRoot, id);
45
+ if (recovered) return recovered;
46
+ }
47
+ return { id, error: 'not found' };
26
48
  });
27
49
  }
28
50
 
51
+ /**
52
+ * Read a tombstoned fact's body + deletion provenance from
53
+ * `<projectRoot>/context/memory/archive/tombstones/<id>.md`. Returns a row-like
54
+ * object marked `tombstoned: true`, or null if no tombstone exists for the id.
55
+ * Read-only; never un-tombstones (that would be a separate `restore` verb).
56
+ */
57
+ function readTombstone(projectRoot, id) {
58
+ // SAFETY: `id` is interpolated into the path, but every caller reaches here
59
+ // ONLY after getObservations' `ID_PATTERN.test(id)` gate (anchored
60
+ // /^[PUL]-[base32]{8}$/ — no `.`/`/`/`\`), so it cannot path-traverse out of
61
+ // the tombstones dir. Do NOT call readTombstone before that validation.
62
+ const tombPath = join(
63
+ projectRoot, 'context', 'memory', 'archive', 'tombstones', `${id}.md`,
64
+ );
65
+ if (!existsSync(tombPath)) return null;
66
+ const { frontmatter, body } = parseFrontmatter(readFileSync(tombPath, 'utf8'));
67
+ const fm = frontmatter ?? {};
68
+ // `tombstoned: true` is the SOLE discriminator for recovered-vs-live — a live
69
+ // row never carries it. Consumers must key off this, NOT off `deleted_at`
70
+ // presence (a live row can carry a null deleted_at too). A malformed/garbled
71
+ // tombstone still returns its raw body + null provenance (graceful degrade —
72
+ // a human recovering is precisely the case where something went wrong).
73
+ return {
74
+ id,
75
+ body: body ?? '',
76
+ heading_path: fm.title ?? null,
77
+ source_file: `context/memory/archive/tombstones/${id}.md`,
78
+ source_line: 1, // synthetic — the tombstone file has no meaningful source line
79
+ tier: fm.tier ?? null,
80
+ trust: fm.trust ?? null,
81
+ write_source: fm.write_source ?? null,
82
+ created_at: fm.created_at ?? fm.at ?? null,
83
+ superseded_by: fm.superseded_by ?? null,
84
+ deleted_at: fm.deleted_at ?? null,
85
+ deleted_reason: fm.deleted_reason ?? null,
86
+ deleted_by: fm.deleted_by ?? null,
87
+ tombstoned: true,
88
+ };
89
+ }
90
+
29
91
  /** The canonical Markdown citation link for an id. Pure (no DB). */
30
92
  export function citeLink(id) {
31
93
  if (!ID_PATTERN.test(id)) return { ok: false, error: 'id must match ID_PATTERN' };
@@ -18,7 +18,7 @@
18
18
 
19
19
  import { resolve as resolvePath } from 'node:path';
20
20
  import { hashContent } from './content-hash.mjs';
21
- import { sanitizePrivacyTags } from './privacy.mjs';
21
+ import { sanitizeForTitle } from './sanitize.mjs';
22
22
  import { writeFact as defaultWriteFact } from './write-fact.mjs';
23
23
  import { buildRichFactBody, slugifyFact } from './rich-fact.mjs';
24
24
 
@@ -54,16 +54,15 @@ export function rememberRich(text, options = {}, deps = {}) {
54
54
  const projectRoot = deps.projectRoot ?? resolvePath(process.cwd());
55
55
  const write = deps.writeFact ?? defaultWriteFact;
56
56
 
57
- // Strip <private>…</private> BEFORE deriving/slicing the title (cut-gate
58
- // v0.3.1 clean-build finding). writeFact also strips, but it receives a title
59
- // already sliced to 80 chars and an 80-char cut that lands inside a private
60
- // span SEVERS the closing tag, so writeFact's `<private>…</private>` regex no
61
- // longer matches and the secret survives in the frontmatter title + INDEX.md.
62
- // Stripping the intact text here means the slice only ever sees redacted text.
63
- const headline = sanitizePrivacyTags(String(text).trim());
64
- const safeTitle = options.title
65
- ? sanitizePrivacyTags(String(options.title).trim())
66
- : '';
57
+ // Sanitize BEFORE deriving/slicing the title — the slug is `slugifyFact(title)`,
58
+ // so anything still in the title here lands in the committed FILENAME + INDEX,
59
+ // which writeFact's later body/title sanitization can't undo. sanitizeForTitle
60
+ // (the ONE shared helper sanitize.mjs) strips <private> + abstracts home
61
+ // paths, the two cut-gate findings (v0.3.1 + F-V0.3.3-2). The body itself keeps
62
+ // its <private> redaction via the headline below; home paths in the body are
63
+ // abstracted by writeFact downstream.
64
+ const headline = sanitizeForTitle(text);
65
+ const safeTitle = options.title ? sanitizeForTitle(options.title) : '';
67
66
  const title = safeTitle || headline.split('\n')[0].slice(0, 80);
68
67
  const body = buildRichFactBody({ text: headline, why: options.why, how: options.how });
69
68
  // `links` arrives as an ARRAY from the MCP tool (z.array) and as a
@@ -97,10 +96,11 @@ export function rememberRich(text, options = {}, deps = {}) {
97
96
 
98
97
  /** The title rememberRich() will derive for `text`/`options` (for caller messages). */
99
98
  export function richFactTitle(text, options = {}) {
100
- // Mirror rememberRich: strip <private> before slicing so the preview a caller
101
- // echoes to the console never carries private content either (cut-gate v0.3.1).
102
- const safeTitle = options.title ? sanitizePrivacyTags(String(options.title).trim()) : '';
103
- return safeTitle || sanitizePrivacyTags(String(text).trim()).split('\n')[0].slice(0, 80);
99
+ // Mirror rememberRich EXACTLY (the SAME sanitizeForTitle helper) so the preview
100
+ // a caller echoes never carries <private> content or the username, and stays
101
+ // identical to the title rememberRich actually derives + stores.
102
+ const safeTitle = options.title ? sanitizeForTitle(options.title) : '';
103
+ return safeTitle || sanitizeForTitle(text).split('\n')[0].slice(0, 80);
104
104
  }
105
105
 
106
106
  /**
package/src/sanitize.mjs CHANGED
@@ -13,6 +13,8 @@
13
13
  // (local, gitignored) — machine-specific absolute paths are the whole point
14
14
  // of the local tier, so they stay verbatim there.
15
15
 
16
+ import { sanitizePrivacyTags } from './privacy.mjs';
17
+
16
18
  // Each pattern matches an absolute home-directory prefix up to (but not
17
19
  // including) the next path separator / whitespace / quote, so the remainder
18
20
  // of the path is preserved. Username char class excludes separators, spaces,
@@ -37,3 +39,31 @@ export function sanitizeHomePaths(text) {
37
39
  for (const re of HOME_PATH_PATTERNS) out = out.replace(re, '~');
38
40
  return out;
39
41
  }
42
+
43
+ /**
44
+ * Sanitize a string that is about to become a fact TITLE — and therefore the
45
+ * fact's SLUG (`slugifyFact(title)`) and committed FILENAME + INDEX.md link.
46
+ *
47
+ * THE INVARIANT (F-V0.3.3-2, cut-blocker): a slug is derived from the title
48
+ * BEFORE `writeFact` runs, and `writeFact` only sanitizes the body + the
49
+ * frontmatter `title:` field — NOT the slug/filename. So anything still in the
50
+ * title at slug-derivation time leaks into the COMMITTED FILENAME, which no
51
+ * downstream sanitization can undo. Every caller that derives a slug from
52
+ * user/Haiku text MUST route the title through THIS helper first, so the leak
53
+ * class is closed in ONE place instead of being re-missed per call site
54
+ * (cmk remember had it; auto-extract had the same bug — the comment there even
55
+ * wrongly claimed "writeFact already sanitizes").
56
+ *
57
+ * Two transforms, both required, privacy-first:
58
+ * - sanitizePrivacyTags: strip `<private>…</private>` (v0.3.1 — a later
59
+ * 80-char title slice that severs the closing tag defeats writeFact's regex).
60
+ * - sanitizeHomePaths: `C:\Users\<you>` → `~` (F-V0.3.3-2 — the username leak).
61
+ * Privacy-first is the safe order: the private span (which may itself contain a
62
+ * home path) is removed wholesale before homepath-sanitize ever sees a fragment.
63
+ *
64
+ * @param {string} s
65
+ * @returns {string} the redacted + abstracted, trimmed string (safe to slug)
66
+ */
67
+ export function sanitizeForTitle(s) {
68
+ return sanitizeHomePaths(sanitizePrivacyTags(String(s).trim()));
69
+ }
package/src/search.mjs CHANGED
@@ -42,6 +42,8 @@
42
42
  // hybrid + semantic paths. Production callers (the `cmk search` CLI in
43
43
  // subcommands.mjs) pass undefined; v0.1.x lands the real backend.
44
44
 
45
+ import { existsSync, readFileSync } from 'node:fs';
46
+ import { join } from 'node:path';
45
47
  import { ERROR_CATEGORIES, errorResult } from './result-shapes.mjs';
46
48
  import { VALID_TIERS } from './tier-paths.mjs';
47
49
 
@@ -58,9 +60,16 @@ const MAX_LIMIT = 1000;
58
60
  // index (L1, the default). 'transcripts' = the SEPARATE raw-transcript
59
61
  // chunk index (the L3 last-resort tier) — reached ONLY when explicitly
60
62
  // asked, so raw history never pollutes curated results.
63
+ // Task 156 (D-168) — 'decisions' = the append-only decision journal
64
+ // (context/DECISIONS.md). Deliberately NOT FTS-indexed (a derived view,
65
+ // skipped like INDEX.md), so this scope scans the markdown file directly. It
66
+ // is the recall path for decision-HISTORY / "what did we reject / why did X
67
+ // change" queries — the journal carries the retract/supersede trail the flat
68
+ // fact store no longer holds. Keyword-only (the journal is not embedded).
61
69
  export const SEARCH_SCOPES = Object.freeze({
62
70
  FACTS: 'facts',
63
71
  TRANSCRIPTS: 'transcripts',
72
+ DECISIONS: 'decisions',
64
73
  });
65
74
 
66
75
  const TRUST_ORDINAL = Object.freeze({
@@ -117,8 +126,12 @@ function validateInput(opts) {
117
126
  }
118
127
  }
119
128
  const scope = opts.scope ?? SEARCH_SCOPES.FACTS;
120
- if (scope !== SEARCH_SCOPES.FACTS && scope !== SEARCH_SCOPES.TRANSCRIPTS) {
121
- errors.push(`scope: must be one of facts/transcripts (got ${JSON.stringify(scope)})`);
129
+ if (
130
+ scope !== SEARCH_SCOPES.FACTS &&
131
+ scope !== SEARCH_SCOPES.TRANSCRIPTS &&
132
+ scope !== SEARCH_SCOPES.DECISIONS
133
+ ) {
134
+ errors.push(`scope: must be one of facts/transcripts/decisions (got ${JSON.stringify(scope)})`);
122
135
  }
123
136
  if (scope === SEARCH_SCOPES.TRANSCRIPTS) {
124
137
  // Chunks carry no tier/trust/created_at — rejecting these is more honest
@@ -133,6 +146,26 @@ function validateInput(opts) {
133
146
  }
134
147
  }
135
148
  }
149
+ if (scope === SEARCH_SCOPES.DECISIONS) {
150
+ // The journal is a flat markdown file, not the index: it carries no
151
+ // tier/trust/created_at columns and isn't embedded. Reject those filters +
152
+ // semantic/hybrid modes (same explicit-vs-configured honesty as transcripts).
153
+ for (const [key, label] of [
154
+ ['tier', 'tier'],
155
+ ['minTrust', 'minTrust'],
156
+ ['since', 'since'],
157
+ ]) {
158
+ if (opts[key] !== undefined) {
159
+ errors.push(`${label}: not supported under the decisions scope (journal entries carry no ${label})`);
160
+ }
161
+ }
162
+ if (mode !== SEARCH_MODES.KEYWORD) {
163
+ errors.push(`mode: only keyword is supported under the decisions scope (the journal is not embedded)`);
164
+ }
165
+ if (typeof opts.projectRoot !== 'string' || opts.projectRoot.length === 0) {
166
+ errors.push('projectRoot: required for the decisions scope (to locate context/DECISIONS.md)');
167
+ }
168
+ }
136
169
  return { errors, mode, scope };
137
170
  }
138
171
 
@@ -394,6 +427,87 @@ function flattenSnippet(s) {
394
427
  return flat.length > TRANSCRIPT_SNIPPET_MAX ? flat.slice(0, TRANSCRIPT_SNIPPET_MAX) + '…' : flat;
395
428
  }
396
429
 
430
+ // --- Decisions-scope keyword backend (Task 156, the decision journal) ---
431
+
432
+ // The journal entry shape (decisions-journal.mjs buildDecisionEntry):
433
+ // <!-- decision:P-XXXXXXXX -->
434
+ // ### <title> (a retracted entry carries _(retracted DATE)_)
435
+ // **When:** <date> · **Fact:** `<id>`
436
+ // **Why:** <why> (optional)
437
+ // Entries are separated by the machine marker; we split on it, match the query
438
+ // as a case-insensitive substring over the entry text, and report the retract
439
+ // marker so recall can answer "did this change / what did we reject".
440
+ const DECISION_MARKER_RE = /<!--\s*decision:([PUL]-[^\s]+)\s*-->/g;
441
+ const DECISIONS_SNIPPET_MAX = 240;
442
+
443
+ function runDecisionsKeywordSearch(_db, opts) {
444
+ const file = join(opts.projectRoot, 'context', 'DECISIONS.md');
445
+ if (!existsSync(file)) return []; // no journal yet → empty, not an error
446
+ const content = readFileSync(file, 'utf8');
447
+
448
+ // Split the body into entry spans keyed by the decision marker. Each span runs
449
+ // from its marker to the next marker (or EOF). A marker is an entry boundary
450
+ // ONLY at line-start — the writer (buildDecisionEntry) always emits it first
451
+ // on its own line, so a marker QUOTED inside a Why/body (a meta-decision about
452
+ // the journal format, or a fact citing another's marker) does NOT false-split
453
+ // the entry (skill-review I2). DECISION_MARKER_RE is module-level /g + reset
454
+ // here; the function is fully synchronous (no await between reset and the
455
+ // loop), so there is no shared-state re-entrancy hazard.
456
+ const markers = [];
457
+ let m;
458
+ DECISION_MARKER_RE.lastIndex = 0;
459
+ while ((m = DECISION_MARKER_RE.exec(content)) !== null) {
460
+ const atLineStart = m.index === 0 || content[m.index - 1] === '\n';
461
+ if (atLineStart) markers.push({ id: m[1], start: m.index });
462
+ }
463
+
464
+ const needle = opts.query.trim().toLowerCase();
465
+ const hits = [];
466
+ for (let i = 0; i < markers.length; i++) {
467
+ const start = markers[i].start;
468
+ const end = i + 1 < markers.length ? markers[i + 1].start : content.length;
469
+ const block = content.slice(start, end);
470
+ // Strip the plumbing (the `<!-- decision:ID -->` marker + the `### ` heading
471
+ // hashes) BEFORE matching, so the query matches the human signal (title /
472
+ // When / Why) — NOT the literal word "decision" inside every marker comment
473
+ // (the self-review false-positive: searching "decision" matched all entries
474
+ // via their markers). Uses a FRESH regex (not the shared module-level
475
+ // DECISION_MARKER_RE) so the loop's .exec lastIndex isn't clobbered.
476
+ const cleaned = block
477
+ .replace(/<!--\s*decision:[PUL]-[^\s]+\s*-->/g, '')
478
+ .replace(/^#{1,6}\s+/gm, '');
479
+ if (!cleaned.toLowerCase().includes(needle)) continue;
480
+
481
+ // The line offset of the marker = source_line drill-back into DECISIONS.md.
482
+ const sourceLine = content.slice(0, start).split('\n').length;
483
+ // Retracted-tag detection mirrors the WRITER's contract: the tag sits on its
484
+ // own line DIRECTLY after the `### ` heading (decisions-journal.mjs §2), so
485
+ // scope the check there — NOT a raw-block substring, which would mislabel an
486
+ // active entry whose Why merely MENTIONS "_(retracted" (skill-review I1).
487
+ const headingIdx = block.indexOf('### ');
488
+ const afterHeading =
489
+ headingIdx === -1 ? '' : block.slice(block.indexOf('\n', headingIdx) + 1);
490
+ const retracted = afterHeading.startsWith('_(retracted');
491
+ hits.push({
492
+ id: markers[i].id,
493
+ snippet: flattenSnippet(cleaned).slice(0, DECISIONS_SNIPPET_MAX),
494
+ source_file: 'context/DECISIONS.md',
495
+ source_line: sourceLine,
496
+ retracted,
497
+ // `score` is POSITIONAL (the marker index), NOT an FTS relevance rank —
498
+ // the journal is chronological, so a lower score = an earlier decision.
499
+ // Don't fuse/sort this against the facts/transcripts scopes' rank scores.
500
+ score: i,
501
+ });
502
+ // NB: `limit` is a CHRONOLOGICAL head, not a relevance top-N — it returns
503
+ // the first N matches in journal (oldest→newest) order, so a strongly
504
+ // relevant decision far down a long journal can be cut. Acceptable: the
505
+ // journal is bounded and chronological by design (M1, deliberate).
506
+ if (hits.length >= (opts.limit ?? DEFAULT_LIMIT)) break;
507
+ }
508
+ return hits;
509
+ }
510
+
397
511
  // --- Reciprocal-rank fusion (hybrid mode) -----------------------------
398
512
 
399
513
  /**
@@ -445,8 +559,9 @@ export function search(opts = {}) {
445
559
  // Scope dispatch (Task 104.2): the transcripts scope swaps the keyword
446
560
  // backend; semantic/hybrid use the caller-prepared backend exactly like
447
561
  // the facts scope (prepareSemanticBackend({scope}) embeds the right table).
448
- const keywordBackend =
449
- scope === SEARCH_SCOPES.TRANSCRIPTS ? runTranscriptKeywordSearch : runKeywordSearch;
562
+ let keywordBackend = runKeywordSearch;
563
+ if (scope === SEARCH_SCOPES.TRANSCRIPTS) keywordBackend = runTranscriptKeywordSearch;
564
+ else if (scope === SEARCH_SCOPES.DECISIONS) keywordBackend = runDecisionsKeywordSearch;
450
565
 
451
566
  // Semantic + hybrid require an injected backend. Production v0.1.0
452
567
  // passes undefined → error with the not-yet-shipped hint. A future
@@ -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
  }