@lh8ppl/claude-memory-kit 0.1.2 → 0.2.1

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.
Files changed (51) hide show
  1. package/README.md +12 -5
  2. package/bin/cmk-auto-extract.mjs +13 -0
  3. package/bin/cmk-compress-session.mjs +31 -17
  4. package/bin/cmk-inject-context.mjs +12 -2
  5. package/bin/cmk-weekly-curate.mjs +14 -2
  6. package/package.json +3 -2
  7. package/src/audit-log.mjs +6 -0
  8. package/src/auto-drain.mjs +59 -0
  9. package/src/auto-extract.mjs +117 -6
  10. package/src/auto-persona.mjs +544 -0
  11. package/src/bullet-lookup.mjs +59 -0
  12. package/src/capture-turn.mjs +54 -0
  13. package/src/compress-session.mjs +6 -8
  14. package/src/compressor.mjs +19 -4
  15. package/src/conflict-queue.mjs +8 -1
  16. package/src/daily-distill.mjs +19 -11
  17. package/src/doctor.mjs +74 -23
  18. package/src/forget.mjs +14 -0
  19. package/src/graduate-session.mjs +65 -0
  20. package/src/graduation.mjs +179 -0
  21. package/src/inject-context.mjs +206 -59
  22. package/src/install.mjs +52 -7
  23. package/src/lessons-promote.mjs +137 -0
  24. package/src/memory-write.mjs +2 -2
  25. package/src/native-memory.mjs +98 -0
  26. package/src/persona-portability.mjs +253 -0
  27. package/src/provenance.mjs +23 -5
  28. package/src/read-hook-stdin.mjs +47 -0
  29. package/src/register-crons.mjs +17 -8
  30. package/src/scratchpad.mjs +247 -19
  31. package/src/session-end-tasks.mjs +127 -0
  32. package/src/settings-hooks.mjs +33 -3
  33. package/src/subcommands.mjs +339 -16
  34. package/src/weekly-curate.mjs +53 -6
  35. package/src/write-fact.mjs +14 -0
  36. package/template/.claude/skills/memory-write/SKILL.md +47 -88
  37. package/template/.gitignore.fragment +6 -0
  38. package/template/CLAUDE.md.template +15 -9
  39. package/template/local/machine-paths.md.template +1 -12
  40. package/template/local/overrides.md.template +1 -11
  41. package/template/project/MEMORY.md.template +5 -26
  42. package/template/project/SOUL.md.template +1 -10
  43. package/template/user/fragments/INDEX.md.template +1 -1
  44. package/template/.claude/hooks/pre-tool-memory.js +0 -78
  45. package/template/.claude/hooks/transcript-capture.js +0 -69
  46. package/template/.claude/settings.json +0 -27
  47. package/template/support/scripts/auto-extract-memory.sh +0 -102
  48. package/template/support/scripts/refresh-distill-timestamp.py +0 -35
  49. package/template/support/scripts/register-crons.py +0 -242
  50. package/template/support/scripts/run-daily-distill.sh +0 -67
  51. package/template/support/scripts/run-weekly-curate.sh +0 -58
@@ -0,0 +1,544 @@
1
+ // Auto-persona generation (Task 45, T-014) — v0.2 Phase 2.
2
+ //
3
+ // The friend-handoff gate. The 2026-05-30 self-test (finding #2)
4
+ // reproduced design §16.16's predicted failure: cross-project doctrine
5
+ // ("how I work everywhere" — venv-3.13, layered-backend) was captured
6
+ // but filed PROJECT-tier; the USER tier stayed empty, collapsing the
7
+ // 3-tier value prop to project+local. Lior won't hand-curate the user
8
+ // tier ("too much of a hassle"), so the user tier must fill itself.
9
+ //
10
+ // Posture (tasks.md 45.6 — supersedes 45.2/45.3's manual gate):
11
+ // OPTIMISTIC AUTO-PROMOTE. Lior 2026-05-30: "i dont want to do
12
+ // anything, i want it to be automatic." A synthesized doctrine that
13
+ // applies beyond the current project is auto-promoted to the user tier
14
+ // at trust:medium — no manual `cmk persona accept` step. A confidence
15
+ // gate (not a manual gate) routes only LOW-confidence candidates to the
16
+ // review queue, which the daily/weekly passes auto-drain.
17
+ //
18
+ // Design B (chosen): piggyback Task 34's weekly-curate consolidator —
19
+ // it already runs the CompressorBackend (Haiku) with no extra API call.
20
+ // The backend classifies each captured fact as cross-project doctrine
21
+ // (or not), names a user-tier target + section, and a confidence.
22
+ //
23
+ // Public boundary:
24
+ // autoPersona({projectRoot, userDir, backend, now, cooldownMs?,
25
+ // autoPromote?, settings?})
26
+ // → {action: 'promoted' | 'skipped' | 'error',
27
+ // promoted: [{id, target, section, text, trust}],
28
+ // queued: [{id, text, target, section, reason}],
29
+ // superseded: [{oldId, newId, target}],
30
+ // conflicts: [{id, text, target, section}],
31
+ // duration_ms, errorCategory?, errors?}
32
+ //
33
+ // Trust: auto-promoted entries land at trust:medium (system-derived,
34
+ // not user-attested); a `cmk persona accept` (45.2, still available)
35
+ // promotes to high. Write source: 'compressor' (Haiku-backend synthesis).
36
+ //
37
+ // Composes on: tier-paths, scratchpad (appendScratchpadBullet — the
38
+ // promotion primitive), audit-log, result-shapes, cooldown, compressor.
39
+ // Per design §16.16 + §6.2 (conflict) + §6.8 (auto-drain) + §8.3 + tasks.md 45.
40
+
41
+ import { readFileSync, appendFileSync, mkdirSync, existsSync, readdirSync } from 'node:fs';
42
+ import { join, dirname } from 'node:path';
43
+ import { generateId } from '@lh8ppl/cmk-canonicalize';
44
+ import { ERROR_CATEGORIES, errorResult } from './result-shapes.mjs';
45
+ import { resolveTierRoot, resolveScratchpadPath } from './tier-paths.mjs';
46
+ import { ensureSectionExists } from './scratchpad.mjs';
47
+ import { listObservationSources } from './index-rebuild.mjs';
48
+ import { parse } from './frontmatter.mjs';
49
+ import { memoryWrite } from './memory-write.mjs';
50
+ import { detectConflicts } from './conflict-queue.mjs';
51
+ import { appendAuditEntry, REASON_CODES } from './audit-log.mjs';
52
+ import { DEFAULT_COOLDOWN_MS, isCooldownActive, touchCooldownMarker } from './cooldown.mjs';
53
+
54
+ // User-tier scratchpads auto-persona is allowed to promote into. A
55
+ // classifier-named target outside this set is dropped defensively (the
56
+ // backend is Haiku — never trust its routing blindly; §NFR-9 spirit).
57
+ const VALID_TARGETS = new Set(['USER.md', 'HABITS.md', 'LESSONS.md']);
58
+
59
+ // F2 (Task 64): a section name we're willing to CREATE on the user tier. Must
60
+ // read like a heading — starts with a letter, then letters/digits/spaces and a
61
+ // few mild separators (& / -), bounded length. Rejects path traversal, markdown
62
+ // metachars (`#`), punctuation noise, and overlong strings so Haiku can't inject
63
+ // a junk or unsafe heading. (c.section is already `.trim()`-ed by the parser.)
64
+ const SAFE_SECTION_NAME = /^[A-Za-z][A-Za-z0-9 &/-]{1,48}$/;
65
+
66
+ // One classifier candidate per line. The consolidator's Haiku Step-3
67
+ // (Design B) emits, for each captured fact that is cross-project
68
+ // doctrine, a line of this exact shape. Project-specific facts are NOT
69
+ // surfaced. Free text is the trailing group (may contain '=' / '|').
70
+ export const PERSONA_CANDIDATE_RE =
71
+ /^PERSONA CANDIDATE \| target=(.+?) \| section=(.+?) \| confidence=(\w+) \| (.+)$/;
72
+
73
+ // Assemble the PROJECT-tier captured facts (granular fact files +
74
+ // MEMORY.md scratchpad bullets) into one corpus the backend classifies.
75
+ // userDir is passed through to listObservationSources purely to keep the
76
+ // U-tier resolution sandbox-scoped (never walk the real home dir —
77
+ // design §16.36); we then filter to tier P, the synthesis SOURCE.
78
+ function assembleProjectCorpus({ projectRoot, userDir }) {
79
+ const sources = listObservationSources({ projectRoot, userDir });
80
+ const parts = [];
81
+ for (const s of sources) {
82
+ if (s.tier !== 'P') continue;
83
+ let content;
84
+ try {
85
+ content = readFileSync(s.path, 'utf8');
86
+ } catch {
87
+ continue;
88
+ }
89
+ if (s.kind === 'fact') {
90
+ const { frontmatter, body } = parse(content);
91
+ const title = frontmatter?.title ?? frontmatter?.id ?? '';
92
+ parts.push(`### ${title}\n${(body ?? '').trim()}`);
93
+ } else {
94
+ parts.push((content ?? '').trim());
95
+ }
96
+ }
97
+ return parts.filter(Boolean).join('\n\n');
98
+ }
99
+
100
+ // Default size of the recent-transcript window handed to the SessionEnd persona
101
+ // classifier (Task 86c / D-44). Bounded — like hermes' "conversation snapshot"
102
+ // and claude-mem's last-message — so the focused Haiku call stays cheap and the
103
+ // MOST RECENT turns dominate. The classifier maxOutputBytes is 4096; input larger.
104
+ //
105
+ // RECALL vs PRECISION tradeoff (86c skill-review I1): a standing rule stated EARLY
106
+ // in a long session ("from now on …" at turn 2, then 40 more turns) can scroll out
107
+ // of the window — and the usual backstops don't recover it (the inline per-turn
108
+ // path is what drops under load, the reason this dedicated pass exists; the fact
109
+ // corpus already lost the cross-project signal per D-44). So the window must be
110
+ // generous enough to hold a typical full session, not just the last few turns.
111
+ // 40k chars ≈ a long session's worth of turns ≈ ~10k tokens — trivial cost for a
112
+ // once-per-session call, and the classifier prompt's "IGNORE anything specific to
113
+ // this ONE project" instruction guards precision at the larger size (live test:
114
+ // clean 2/2, no false promotes). The exact bound is a lior-test-9 tuning item.
115
+ // KNOWN LIMITATION (documented, not yet fixed): only the most-recent date-named
116
+ // file is read, so a session spanning midnight loses the pre-midnight turns. Rare;
117
+ // a multi-file read is the follow-up if it bites.
118
+ export const TRANSCRIPT_WINDOW_BYTES = 40_000;
119
+
120
+ // Assemble the recent-conversation window for the persona classifier (Task 86c).
121
+ // Reads the most-recent date-named transcript (`context/transcripts/{date}.md`,
122
+ // written per-turn by capture-turn) and returns its tail, snapped FORWARD to a
123
+ // `## ` turn boundary so the window never starts mid-line. Returns '' when no
124
+ // transcript exists (a no-turn session — nothing to classify).
125
+ //
126
+ // WHY the transcript, not the fact corpus (D-44, primary-source-verified):
127
+ // auto-extract distills a user's universal rule into project-scoped fact text
128
+ // ("Use uv … pip is not used in THIS PROJECT"), stripping the cross-project
129
+ // signal the classifier needs. The verbatim signal ("from now on …", "in every
130
+ // project") survives only in the transcript. hermes' background_review reviews
131
+ // "the conversation above"; claude-mem's summarize reads transcriptPath — both
132
+ // classify the raw conversation, never distilled memory.
133
+ //
134
+ // Injection posture (86c skill-review NOTE): the transcript is privacy-sanitized
135
+ // at write time (capture-turn → sanitizePrivacyTags) but NOT prompt-structure
136
+ // sanitized, so a user could type a literal "PERSONA CANDIDATE | …" line that the
137
+ // classifier echoes. The blast radius is bounded: it's the user's OWN single-user
138
+ // conversation, and the promote path (promoteCandidatesToUserTier → memoryWrite)
139
+ // is gated by VALID_TARGETS + the section-name guard + the confidence gate. A
140
+ // self-authored persona entry is the feature, not a third-party threat.
141
+ export function assembleTranscriptWindow({ projectRoot, maxBytes = TRANSCRIPT_WINDOW_BYTES }) {
142
+ const dir = join(projectRoot, 'context', 'transcripts');
143
+ if (!existsSync(dir)) return '';
144
+ let files;
145
+ try {
146
+ files = readdirSync(dir).filter((f) => f.endsWith('.md')).sort();
147
+ } catch {
148
+ return '';
149
+ }
150
+ if (files.length === 0) return '';
151
+ // Date-named (YYYY-MM-DD) → lexical sort = chronological; take the latest.
152
+ const latest = files[files.length - 1];
153
+ let text;
154
+ try {
155
+ text = readFileSync(join(dir, latest), 'utf8');
156
+ } catch {
157
+ return '';
158
+ }
159
+ if (!text.trim()) return '';
160
+ if (text.length <= maxBytes) return text;
161
+ let tail = text.slice(text.length - maxBytes);
162
+ // Snap forward to the first whole turn so we don't begin mid-sentence.
163
+ const boundary = tail.indexOf('\n## ');
164
+ if (boundary !== -1) tail = tail.slice(boundary + 1);
165
+ return tail;
166
+ }
167
+
168
+ // Shared persona grading rule (Task 78 — the wedge's AUTO half). The
169
+ // `confidence` axis encodes EXPLICIT-vs-INFERRED, which drives BOTH the promote
170
+ // gate (only `high` promotes; medium/low queue) AND the write trust on the
171
+ // inline path (an explicitly-stated rule is user-attested → trust:high). The
172
+ // live-test gap (D-30): the user STATED a universal rule but the classifier
173
+ // under-graded it to medium → it queued instead of landing in the user tier, so
174
+ // the cross-project persona never filled on its own. This rule makes the
175
+ // stated-vs-observed distinction explicit. Kept in ONE place so the inline
176
+ // (auto-extract) and weekly (classifier) prompts can never drift apart.
177
+ export const PERSONA_CONFIDENCE_RULE = [
178
+ 'GRADE confidence by whether the user STATED it as a standing rule vs you INFERRED it from behavior:',
179
+ ' - confidence=high → the user EXPLICITLY STATED a standing, cross-project rule: an imperative with universal scope — "always …", "never …", "in every project", "from now on", "going forward, in all my projects", "as a rule I …". Use high ONLY for a rule the user actually stated.',
180
+ ' - confidence=medium → you are INFERRING the doctrine from how they worked this session (they did it, but did NOT declare it as a standing rule).',
181
+ ' - When unsure whether it was stated-as-a-rule or merely-observed, use medium.',
182
+ ].join('\n');
183
+
184
+ // `source` selects the INPUT framing (Task 86c / D-44):
185
+ // - 'transcript' (SessionEnd path): the input is the raw recent conversation,
186
+ // where standing-rule statements ("from now on …", "in every project") are
187
+ // verbatim — the reliable cross-project signal. This is the mature-product
188
+ // shape (hermes reviews "the conversation above"; claude-mem reads the
189
+ // transcript).
190
+ // - 'facts' (default; weekly-curate + manual `cmk persona generate`): the input
191
+ // is the distilled project fact corpus — appropriate for a whole-project
192
+ // sweep, but lossy for cross-project signal (D-44), so NOT used at SessionEnd.
193
+ // Only the framing lines differ; the routing + confidence rule are shared so the
194
+ // two paths can never drift apart.
195
+ export function buildClassifierInstructions(source = 'facts') {
196
+ const isTranscript = source === 'transcript';
197
+ const opener = isTranscript
198
+ ? 'You are a persona archivist for claude-memory-kit. The input below is the RECENT CONVERSATION (user and assistant turns) from ONE project session.'
199
+ : 'You are a persona archivist for claude-memory-kit. The input below is a set of facts captured while the user worked on ONE project.';
200
+ const jobLine = isTranscript
201
+ ? 'Your job: identify ONLY the things the user REVEALED or STATED that express CROSS-PROJECT doctrine — how this user works EVERYWHERE (tooling habits, how they structure their work, communication style, process rules). IGNORE anything specific to this ONE project (a particular value, name, or detail that would not carry to their other projects; one-off task state).'
202
+ : 'Your job: identify ONLY the facts that express CROSS-PROJECT doctrine — how this user works EVERYWHERE (tooling habits, how they structure their work, communication style, process rules). IGNORE anything specific to this ONE project (a particular value, name, or detail that would not carry to their other projects; one-off task state).';
203
+ const beginMarker = isTranscript
204
+ ? '=== BEGIN RECENT CONVERSATION ==='
205
+ : '=== BEGIN CAPTURED PROJECT FACTS ===';
206
+ return [
207
+ opener,
208
+ '',
209
+ jobLine,
210
+ '',
211
+ 'For EACH cross-project fact, emit exactly one line, nothing else, in this EXACT format:',
212
+ 'PERSONA CANDIDATE | target=<FILE> | section=<SECTION> | confidence=<high|medium|low> | <one-line restatement>',
213
+ '',
214
+ 'Routing:',
215
+ ' - target=HABITS.md → working-style habits. sections: Iteration Cadence | Destructive Operations | Communication Style',
216
+ ' - target=LESSONS.md → cross-project lessons. sections: Tooling Lessons | Process Lessons | Anti-patterns',
217
+ ' - target=USER.md → identity/preferences. sections: About | Preferences | Working Style',
218
+ ' PREFER an existing section above — route to the closest fit. Only if NONE genuinely fits may you name a new short Title-Case section (2-4 words, letters/spaces only). Never invent a new section when an existing one fits.',
219
+ '',
220
+ PERSONA_CONFIDENCE_RULE,
221
+ '',
222
+ 'Output ONLY PERSONA CANDIDATE lines. No preamble, no commentary. If nothing is cross-project, output nothing.',
223
+ '',
224
+ beginMarker,
225
+ ].join('\n');
226
+ }
227
+
228
+ export function parsePersonaCandidates(outputText) {
229
+ const candidates = [];
230
+ for (const raw of (outputText ?? '').split('\n')) {
231
+ const line = raw.trim();
232
+ const m = PERSONA_CANDIDATE_RE.exec(line);
233
+ if (!m) continue;
234
+ const [, target, section, confidence, text] = m;
235
+ candidates.push({
236
+ target: target.trim(),
237
+ section: section.trim(),
238
+ confidence: confidence.trim().toLowerCase(),
239
+ text: text.trim(),
240
+ });
241
+ }
242
+ return candidates;
243
+ }
244
+
245
+ /**
246
+ * Run auto-persona synthesis: classify project-tier captured facts,
247
+ * auto-promote cross-project doctrine into the user tier (trust:medium).
248
+ *
249
+ * @returns {Promise<object>} action: 'promoted' | 'skipped' | 'error'
250
+ */
251
+ export async function autoPersona(opts = {}) {
252
+ const t0 = Date.now();
253
+ const { projectRoot, userDir, backend, now, settings, cooldownMs = DEFAULT_COOLDOWN_MS, source = 'facts' } = opts;
254
+
255
+ if (!projectRoot) {
256
+ return errorResult({
257
+ category: ERROR_CATEGORIES.MISSING_PROJECT_ROOT,
258
+ errors: ['projectRoot is required'],
259
+ duration_ms: Date.now() - t0,
260
+ });
261
+ }
262
+ if (!userDir) {
263
+ return errorResult({
264
+ category: ERROR_CATEGORIES.SCHEMA,
265
+ errors: ['userDir is required (auto-persona promotes to the user tier)'],
266
+ duration_ms: Date.now() - t0,
267
+ });
268
+ }
269
+ if (!backend || typeof backend.compress !== 'function') {
270
+ return errorResult({
271
+ category: ERROR_CATEGORIES.MISSING_BACKEND,
272
+ errors: ['backend (CompressorBackend) is required'],
273
+ duration_ms: Date.now() - t0,
274
+ });
275
+ }
276
+
277
+ const ts = now ?? new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
278
+
279
+ // Shared 120s Haiku cooldown (same marker daily-distill / weekly-curate /
280
+ // auto-extract touch). cooldownMs:0 override lets a caller run auto-persona
281
+ // inside an already-gated cycle (e.g. weekly-curate, per §8.7.2 — both Haiku
282
+ // calls belong to one cycle, not two independent invocations).
283
+ if (cooldownMs > 0 && isCooldownActive({ projectRoot, now: ts, cooldownMs })) {
284
+ return { action: 'skipped', reason: 'cooldown', promoted: [], queued: [], duration_ms: Date.now() - t0 };
285
+ }
286
+
287
+ // Task 86c (D-44): the SessionEnd path classifies the RAW TRANSCRIPT (where a
288
+ // user's standing rule survives verbatim); the default 'facts' path classifies
289
+ // the distilled project corpus (whole-project sweep — weekly/manual).
290
+ const corpus = source === 'transcript'
291
+ ? assembleTranscriptWindow({ projectRoot })
292
+ : assembleProjectCorpus({ projectRoot, userDir });
293
+ if (!corpus) {
294
+ const reason = source === 'transcript' ? 'no-transcript' : 'no-facts';
295
+ return { action: 'skipped', reason, promoted: [], queued: [], duration_ms: Date.now() - t0 };
296
+ }
297
+
298
+ let result;
299
+ try {
300
+ result = await backend.compress({
301
+ input: corpus,
302
+ instructions: buildClassifierInstructions(source),
303
+ preserveCitationIds: false,
304
+ maxOutputBytes: 4096,
305
+ timeoutMs: 50_000,
306
+ });
307
+ // Spent a Haiku call — refresh the shared cooldown marker so the next
308
+ // gated caller backs off. (touch even on cooldownMs:0 cycles: the call
309
+ // happened, so the marker should reflect it for any LATER gated caller.)
310
+ touchCooldownMarker({ projectRoot, now: ts });
311
+ } catch (err) {
312
+ touchCooldownMarker({ projectRoot, now: ts });
313
+ return errorResult({
314
+ category: ERROR_CATEGORIES.COMPRESS_FAILED,
315
+ errors: [err?.message ?? String(err)],
316
+ duration_ms: Date.now() - t0,
317
+ });
318
+ }
319
+
320
+ const candidates = parsePersonaCandidates(result?.outputText);
321
+ const { promoted, queued, superseded, conflicts, reviewQueuePath } = promoteCandidatesToUserTier({
322
+ candidates,
323
+ userDir,
324
+ now: ts,
325
+ settings,
326
+ });
327
+
328
+ const duration_ms = Date.now() - t0;
329
+ // A supersede IS a promotion outcome (the user tier changed).
330
+ if (promoted.length === 0 && superseded.length === 0) {
331
+ return { action: 'skipped', reason: 'no-promotions', promoted, queued, superseded, conflicts, reviewQueuePath, duration_ms };
332
+ }
333
+ return { action: 'promoted', promoted, queued, superseded, conflicts, reviewQueuePath, duration_ms };
334
+ }
335
+
336
+ /**
337
+ * Promote classified PERSONA CANDIDATE rows into the user tier. Shared by
338
+ * autoPersona (the weekly janitor) and auto-extract (Task 61 — inline at
339
+ * capture time). High-confidence → memoryWrite to the user-tier scratchpad
340
+ * (auto-supersede on conflict); low/medium → surfaced in `queued`.
341
+ *
342
+ * Trust posture (the `trust`/`source` params):
343
+ * - DEFAULT (no params) → trust:'medium', source:'persona-synthesis'. The
344
+ * SYSTEM-DERIVED posture (45.6) used by the weekly janitor and any
345
+ * inferred promotion. **45.4 invariant (original, pre-2026-06-02):** a
346
+ * medium write never overwrites a hand-curated trust:high rule — a
347
+ * same-topic collision against a high entry routes to the review queue
348
+ * (medium < high → queue), so hand-curated highs are protected from
349
+ * inferred noise. This still holds for every medium/inferred write.
350
+ * - trust:'high' (explicit path — Task 76 `cmk lessons promote` + Task 78
351
+ * inline grading of an EXPLICITLY-STATED rule). **45.4 REFINEMENT
352
+ * (2026-06-02, D-32 — Lior chose "latest explicit wins"):** an explicit,
353
+ * user-attested rule at trust:high MAY supersede an equal-trust same-topic
354
+ * entry (high >= high → supersede). The newest explicit statement wins,
355
+ * even over a hand-curated high. The original protection is unchanged for
356
+ * non-explicit (medium) writes; only an explicit high can replace a high.
357
+ *
358
+ * @returns {{promoted:Array, queued:Array, superseded:Array, conflicts:Array}}
359
+ */
360
+ // Persist low/medium-confidence (and otherwise-not-promoted) candidates to a
361
+ // durable review-queue FILE at <userDir>/queues/persona-review.md, so they are
362
+ // not lost when only returned in the response (Lior 2026-05-31: "response
363
+ // object can get lost — i dont like it"). Dedup by canonical id against what's
364
+ // already in the file so repeated synthesis passes don't pile up duplicates.
365
+ // Returns the queue path (or null when there's nothing to write).
366
+ export function appendPersonaReviewQueue({ userDir, entries, now }) {
367
+ if (!entries || entries.length === 0) return null;
368
+ const ts = now ?? new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
369
+ const userTierRoot = resolveTierRoot({ tier: 'U', userDir });
370
+ const queuePath = join(userTierRoot, 'queues', 'persona-review.md');
371
+ mkdirSync(dirname(queuePath), { recursive: true });
372
+
373
+ let existing = '';
374
+ try {
375
+ existing = readFileSync(queuePath, 'utf8');
376
+ } catch {
377
+ // file not created yet — fine.
378
+ }
379
+
380
+ const blocks = [];
381
+ for (const e of entries) {
382
+ const id = generateId('U', e.text);
383
+ if (existing.includes(`(${id})`)) continue; // already queued in a prior pass
384
+ blocks.push(
385
+ `- (${id}) [${e.target} § ${e.section}] ${e.text}\n` +
386
+ ` <!-- target: ${e.target}, section: ${e.section}, confidence: ${e.confidence ?? 'unknown'}, reason: ${e.reason ?? 'pending-review'}, source: persona-synthesis, at: ${ts} -->`,
387
+ );
388
+ }
389
+ if (blocks.length === 0) return queuePath;
390
+
391
+ const header = `## ${ts} — persona-synthesis (pending review)`;
392
+ appendFileSync(queuePath, `${header}\n${blocks.join('\n')}\n\n`, 'utf8');
393
+ return queuePath;
394
+ }
395
+
396
+ export function promoteCandidatesToUserTier({ candidates, userDir, now, settings, trust = 'medium', source = 'persona-synthesis' }) {
397
+ // `trust`/`source` default to the AUTO-persona posture (medium, system-derived
398
+ // — 45.6). The EXPLICIT path (`cmk lessons promote`) passes trust:'high' +
399
+ // source:'user-explicit' so a user-attested promotion is durable (the
400
+ // maintenance passes never age out / auto-supersede a trust:high entry — the
401
+ // 45.4 invariant) instead of decaying like an inferred preference.
402
+ const ts = now ?? new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
403
+ const userTierRoot = resolveTierRoot({ tier: 'U', userDir });
404
+ const promoted = [];
405
+ const queued = [];
406
+ const superseded = [];
407
+ const conflicts = [];
408
+ for (const c of candidates) {
409
+ if (!VALID_TARGETS.has(c.target)) continue; // defensive: drop bad routing
410
+ if (c.confidence !== 'high') {
411
+ // Confidence gate (not a manual gate): low/medium route to the review
412
+ // queue. They are returned in `queued` AND written to the durable
413
+ // queue FILE below (appendPersonaReviewQueue) so they survive past the
414
+ // response — the daily/weekly auto-drain (or a manual review) acts on them.
415
+ queued.push({ target: c.target, section: c.section, text: c.text, confidence: c.confidence, reason: `confidence-${c.confidence}` });
416
+ continue;
417
+ }
418
+
419
+ // D-13: promote THROUGH memoryWrite so the write inherits home-path
420
+ // sanitization (privacy — finding #1 class), Poison_Guard, dedup,
421
+ // cap/consolidation, and audit — none of which a raw scratchpad
422
+ // append would carry. We pre-detect conflicts ourselves only to pick
423
+ // the verb: memoryWrite's v0.1.0 'supersede' path merely appends, so
424
+ // for the auto-supersede contract (45.6) we issue an explicit
425
+ // `replace`; the `queue` case (new<existing, e.g. vs a trust:high
426
+ // hand-curated rule — the 45.4 invariant) is left to memoryWrite's
427
+ // own conflict-queue routing via a plain `add`.
428
+ const scratchpadPath = resolveScratchpadPath({ tier: 'U', scratchpad: c.target, userDir });
429
+
430
+ // F2 (Task 64): the user tier grows sections organically. If Haiku routes a
431
+ // candidate to a sane-but-not-yet-existing section (the live test: HABITS.md
432
+ // § "Architecture Preferences"), CREATE the heading instead of letting
433
+ // memoryWrite schema-fail to the review queue → empty HABITS.md. Guard the
434
+ // name first so a malformed/unsafe section can't inject a junk heading.
435
+ // NB: the guard also gates an *existing* but unsafe-named section (e.g. a
436
+ // hand-edited weird heading) → such a candidate queues rather than promotes;
437
+ // acceptable, since every section the kit itself creates is guard-valid.
438
+ if (!SAFE_SECTION_NAME.test(c.section)) {
439
+ queued.push({ target: c.target, section: c.section, text: c.text, confidence: c.confidence, reason: 'not-promoted-bad-section-name' });
440
+ continue;
441
+ }
442
+ // Heading-creation is intentionally cap-exempt: it's ~30 bytes and the
443
+ // memoryWrite below enforces the scratchpad byte cap on the bullet (§7.1).
444
+ const ensured = ensureSectionExists(scratchpadPath, c.section);
445
+ if (ensured.error) {
446
+ queued.push({ target: c.target, section: c.section, text: c.text, confidence: c.confidence, reason: `not-promoted-${ensured.error}` });
447
+ continue;
448
+ }
449
+ if (ensured.created) {
450
+ // Door 4: a new section is a structural change to a committed/shared
451
+ // scratchpad — record it so "why did HABITS.md grow this section?" is
452
+ // answerable from the audit log, not just inferred from the bullet below.
453
+ appendAuditEntry(userTierRoot, {
454
+ ts,
455
+ action: 'persona-section-created',
456
+ tier: 'U',
457
+ // A deterministic id for the structural event (there's no bullet id yet
458
+ // — the bullet is written below); audit-log requires a non-null id.
459
+ id: generateId('U', `section:${c.target}:${c.section}`),
460
+ reasonCode: REASON_CODES.PERSONA_SECTION_CREATED,
461
+ reasonText: `${c.target} § ${c.section}`,
462
+ paths: { after: scratchpadPath },
463
+ });
464
+ }
465
+
466
+ const conflict = detectConflicts({
467
+ newText: c.text,
468
+ newTrust: trust,
469
+ scratchpadPath,
470
+ sectionTitle: c.section,
471
+ });
472
+
473
+ const common = {
474
+ tier: 'U',
475
+ scratchpad: c.target,
476
+ section: c.section,
477
+ text: c.text,
478
+ trust, // 'medium' (auto, 45.6) | 'high' (explicit `cmk lessons promote`)
479
+ source, // 'persona-synthesis' (auto) | 'user-explicit' (explicit promote)
480
+ userDir,
481
+ now: ts,
482
+ settings,
483
+ };
484
+
485
+ if (conflict.conflict === true && conflict.action === 'supersede') {
486
+ // New medium-trust persona fact contradicts an existing same-or-
487
+ // lower-trust one → replace it (no duplicate; closes finding #3
488
+ // Gap B). doReplace needs the old bullet's exact text.
489
+ // doReplace returns {action:'replaced', oldId, newId, path}.
490
+ const res = memoryWrite({ action: 'replace', oldText: conflict.existingText, ...common });
491
+ if (res.action !== 'replaced') {
492
+ queued.push({ target: c.target, section: c.section, text: c.text, confidence: c.confidence, reason: `not-superseded-${res.errorCategory ?? res.action}` });
493
+ continue;
494
+ }
495
+ appendAuditEntry(userTierRoot, {
496
+ ts,
497
+ action: 'persona-supersede',
498
+ tier: 'U',
499
+ id: res.newId,
500
+ reasonCode: REASON_CODES.PERSONA_SUPERSEDED,
501
+ // Carry `source` so the audit trail distinguishes an explicit
502
+ // `cmk lessons promote` (user-explicit) from an auto-synthesis promote.
503
+ reasonText: `${c.target} § ${c.section} (superseded ${res.oldId}; ${source})`,
504
+ paths: { after: res.path },
505
+ });
506
+ superseded.push({ oldId: res.oldId, newId: res.newId, target: c.target, section: c.section });
507
+ continue;
508
+ }
509
+
510
+ const res = memoryWrite({ action: 'add', ...common });
511
+
512
+ if (res.action === 'queued') {
513
+ // memoryWrite routed to queues/conflicts.md (new<existing trust —
514
+ // never overwrite a hand-curated trust:high rule, the 45.4 invariant).
515
+ conflicts.push({ id: res.id, target: c.target, section: c.section, text: c.text, conflictsWith: res.conflictsWith });
516
+ continue;
517
+ }
518
+ if (res.action !== 'appended') {
519
+ // Bad section / cap / dedup-skip — surface, don't silently lose.
520
+ queued.push({ target: c.target, section: c.section, text: c.text, confidence: c.confidence, reason: `not-promoted-${res.errorCategory ?? res.action}` });
521
+ continue;
522
+ }
523
+
524
+ appendAuditEntry(userTierRoot, {
525
+ ts,
526
+ action: 'persona-promote',
527
+ tier: 'U',
528
+ id: res.id,
529
+ reasonCode: REASON_CODES.PERSONA_PROMOTED,
530
+ // Carry `source` so the audit trail distinguishes an explicit
531
+ // `cmk lessons promote` (user-explicit) from an auto-synthesis promote.
532
+ reasonText: `${c.target} § ${c.section} (${source})`,
533
+ paths: { after: res.path },
534
+ });
535
+
536
+ promoted.push({ id: res.id, target: c.target, section: c.section, text: c.text, trust });
537
+ }
538
+
539
+ // Persist the queued (low/medium-confidence + not-promoted) candidates to
540
+ // the durable review-queue file so they survive past this response.
541
+ const reviewQueuePath = appendPersonaReviewQueue({ userDir, entries: queued, now: ts });
542
+
543
+ return { promoted, queued, superseded, conflicts, reviewQueuePath };
544
+ }
@@ -0,0 +1,59 @@
1
+ // bullet-lookup.mjs — "is this id a scratchpad BULLET (not a graduated fact)?"
2
+ //
3
+ // Why this exists (the cut-gate F-3/F-7 finding, 2026-06-06): `cmk search`
4
+ // surfaces ids for BOTH graduated facts (context/memory/<type>_<slug>.md) AND
5
+ // scratchpad bullets (a `- (ID) …` line in MEMORY.md / SOUL.md / the user-tier
6
+ // persona). But `cmk lessons promote` and `cmk forget` operate on FACTS only —
7
+ // so pasting a bullet id from `cmk search` into either returns a flat, unhelpful
8
+ // "no matching fact for ID", even though the id is right there in a scratchpad.
9
+ //
10
+ // findBulletScratchpad turns that dead end into an actionable error: the caller,
11
+ // on a fact-not-found, asks "but is it a bullet?" and if so explains what the id
12
+ // actually is and where the fact ids live. Pure read-only lookup; no mutation.
13
+
14
+ import { existsSync, readFileSync } from 'node:fs';
15
+ import {
16
+ VALID_TIERS,
17
+ SCRATCHPADS_BY_TIER,
18
+ resolveScratchpadPath,
19
+ } from './tier-paths.mjs';
20
+
21
+ /**
22
+ * Find the scratchpad that holds `id` as a bullet, if any.
23
+ *
24
+ * Scans only the id's OWN tier (the tier prefix is authoritative — a P- id can
25
+ * only be a project-tier bullet), across that tier's scratchpad files, for a
26
+ * line beginning `- (ID)` (the canonical bullet shape, matching the writer in
27
+ * scratchpad.mjs / provenance.mjs).
28
+ *
29
+ * @param {string} id citation id, e.g. "P-XXXXXXXX"
30
+ * @param {object} [opts]
31
+ * @param {string} [opts.projectRoot]
32
+ * @param {string} [opts.userDir]
33
+ * @returns {string|null} the scratchpad filename (e.g. "MEMORY.md") or null
34
+ */
35
+ export function findBulletScratchpad(id, { projectRoot, userDir } = {}) {
36
+ if (typeof id !== 'string' || id.length < 2) return null;
37
+ const tier = id[0];
38
+ if (!VALID_TIERS.has(tier)) return null;
39
+ const scratchpads = SCRATCHPADS_BY_TIER[tier];
40
+ if (!scratchpads) return null;
41
+
42
+ const needle = `- (${id})`;
43
+ for (const scratchpad of scratchpads) {
44
+ const path = resolveScratchpadPath({ tier, scratchpad, projectRoot, userDir });
45
+ if (!path || !existsSync(path)) continue;
46
+ let text;
47
+ try {
48
+ text = readFileSync(path, 'utf8');
49
+ } catch {
50
+ continue; // unreadable scratchpad — skip, not a hard error
51
+ }
52
+ // Bullet lines start at column 0 with "- (ID)"; a line-anchored prefix scan
53
+ // avoids matching the id where it appears inside a provenance comment or body.
54
+ if (text.split('\n').some((line) => line.startsWith(needle))) {
55
+ return scratchpad;
56
+ }
57
+ }
58
+ return null;
59
+ }