moflo 4.10.20 → 4.10.21

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 (46) hide show
  1. package/.claude/guidance/shipped/moflo-cli-reference.md +1 -0
  2. package/.claude/guidance/shipped/moflo-core-guidance.md +1 -0
  3. package/.claude/guidance/shipped/moflo-skills-reference.md +108 -0
  4. package/.claude/guidance/shipped/moflo-yaml-reference.md +13 -0
  5. package/.claude/skills/commune/SKILL.md +140 -0
  6. package/.claude/skills/divine/SKILL.md +130 -0
  7. package/.claude/skills/meditate/SKILL.md +122 -0
  8. package/README.md +39 -3
  9. package/bin/index-all.mjs +2 -1
  10. package/bin/index-reference.mjs +221 -0
  11. package/bin/lib/file-sync.mjs +50 -1
  12. package/bin/lib/hook-io.mjs +63 -0
  13. package/bin/lib/index-fingerprint.mjs +0 -0
  14. package/bin/lib/internal-skills.mjs +16 -0
  15. package/bin/lib/meditate.mjs +497 -0
  16. package/bin/lib/pii-scrub.mjs +119 -0
  17. package/bin/lib/reference-docs.mjs +218 -0
  18. package/bin/lib/session-continuity.mjs +372 -0
  19. package/bin/lib/shipped-scripts.json +36 -0
  20. package/bin/lib/shipped-scripts.mjs +33 -0
  21. package/bin/lib/yaml-upgrader.mjs +62 -0
  22. package/bin/meditate-capture.mjs +123 -0
  23. package/bin/meditate-distill.mjs +121 -0
  24. package/bin/session-continuity.mjs +206 -0
  25. package/bin/session-start-launcher.mjs +140 -60
  26. package/dist/src/cli/config/moflo-config.js +18 -0
  27. package/dist/src/cli/init/executor.js +11 -17
  28. package/dist/src/cli/init/moflo-init.js +21 -19
  29. package/dist/src/cli/init/moflo-yaml-template.js +21 -0
  30. package/dist/src/cli/init/settings-generator.js +23 -1
  31. package/dist/src/cli/init/shipped-scripts.js +39 -0
  32. package/dist/src/cli/memory/bridge-core.js +20 -0
  33. package/dist/src/cli/memory/bridge-entries.js +8 -2
  34. package/dist/src/cli/memory/memory-bridge.js +6 -2
  35. package/dist/src/cli/memory/memory-initializer.js +6 -2
  36. package/dist/src/cli/services/hook-block-hash.js +9 -1
  37. package/dist/src/cli/services/hook-wiring.js +38 -0
  38. package/dist/src/cli/transfer/anonymization/index.js +146 -40
  39. package/dist/src/cli/transfer/deploy-seraphine.js +1 -1
  40. package/dist/src/cli/transfer/export.js +2 -2
  41. package/dist/src/cli/transfer/store/publish.js +1 -1
  42. package/dist/src/cli/version.js +1 -1
  43. package/package.json +2 -2
  44. package/scripts/post-install-bootstrap.mjs +22 -42
  45. package/dist/src/cli/hooks/llm/index.js +0 -11
  46. package/dist/src/cli/hooks/llm/llm-hooks.js +0 -382
@@ -0,0 +1,497 @@
1
+ /**
2
+ * Auto-meditate (#1198) — pure logic shared by the capture hook
3
+ * (`bin/meditate-capture.mjs`, wired to UserPromptSubmit + Stop) and the distill
4
+ * orchestrator (`bin/meditate-distill.mjs`, fired detached by the session-start
5
+ * launcher).
6
+ *
7
+ * DESIGN (owner-signed-off, #1198): two-stage Recognize → Distill.
8
+ * - RECOGNIZE happens in the LIVE session. A UserPromptSubmit hook detects a
9
+ * strong signal (user correction / error→fix / explicit decision) and, on a
10
+ * hit, injects an answer-first directive asking the model — which has full
11
+ * context at the moment of insight — to append ONE <meditate-capture> line IF
12
+ * a durable lesson emerged. A Stop hook scrapes that tag into a JSON ledger.
13
+ * No moflo.db write, no dedup yet — the ledger is raw, high-quality material.
14
+ * - DISTILL happens at the NEXT session-start. The launcher fire-and-forgets a
15
+ * detached process that runs ONE bounded headless Haiku `claude --print`
16
+ * executing #1187's /meditate distillation over the ledger one-liners —
17
+ * dedup-search `learnings` (≥0.80 → update same key), store via memory_store
18
+ * (routes through the daemon = writer-safe). Haiku is a FORMATTER, not a
19
+ * judge: the durability gate already passed upstream in the live model, so
20
+ * unattended runs cannot pollute `learnings` with junk.
21
+ *
22
+ * Default-ON (opt out via moflo.yaml `auto_meditate.enabled: false`). Shipped
23
+ * default-on from #1198; the `rc` dist-tag gated the initial rollout. Every
24
+ * entry point still early-returns cheaply when the flag is off OR
25
+ * `CLAUDE_CODE_HEADLESS` is set — the distill's own headless session must never
26
+ * re-enter capture or re-spawn distill (the #860 / infinite-spawn guard).
27
+ *
28
+ * STORAGE: `.moflo/meditate-ledger.json` (single file) + `.moflo/meditate-state.json`
29
+ * (rate-limit). Deliberately NOT under `.moflo/continuity/` — that directory is
30
+ * rotation-managed by #1185 (`rotateDigests` would delete a stray file there).
31
+ *
32
+ * Cross-platform (Rule #1): Node fs/path primitives only; no shell.
33
+ */
34
+
35
+ import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync, unlinkSync } from 'fs';
36
+ import { resolve, join, dirname } from 'path';
37
+ import { randomBytes } from 'crypto';
38
+
39
+ // ── Tunables (exported for tests) ───────────────────────────────────────────
40
+ /** Model used for the bounded distillation pass (cheap; Haiku is a formatter). */
41
+ export const HAIKU_MODEL_ID = 'claude-haiku-4-5-20251001';
42
+ /** Min gap between in-session capture directives, so a chatty correction streak
43
+ * can't spam the model. */
44
+ export const INJECT_WINDOW_MS = 8 * 60_000;
45
+ /** Hard cap on directives per session (belt-and-braces over the time window). */
46
+ export const INJECT_MAX_PER_SESSION = 6;
47
+ /** Ledger size cap — distilled entries are pruned first, then oldest. */
48
+ export const MAX_LEDGER_ENTRIES = 200;
49
+ /** Keep this many already-distilled entries for idempotency/debug before pruning. */
50
+ export const KEEP_DISTILLED = 50;
51
+ /** Hard cap on a single captured lesson (defensive against a runaway tag). */
52
+ export const MAX_LESSON_CHARS = 320;
53
+ /** Bytes of transcript tail the capture hook scans for signals / tags. */
54
+ export const TRANSCRIPT_TAIL_BYTES = 32_768;
55
+
56
+ const LEDGER_FILE = 'meditate-ledger.json';
57
+ const STATE_FILE = 'meditate-state.json';
58
+
59
+ // Pre-rebrand filenames. Before auto-reflect → auto-meditate, the capture
60
+ // pipeline wrote `.moflo/reflect-ledger.json` (pending lessons) and
61
+ // `.moflo/reflect-state.json` (rate-limit). The new code writes meditate-*.json,
62
+ // so on upgrade the old pair is orphaned — never read, never updated. We purge
63
+ // rather than migrate: the state file is throwaway rate-limit data, and the
64
+ // ledger is drained by the distill pass every session, so the residual is at
65
+ // most a handful of undistilled one-liners. Leaving them beside the new files
66
+ // just confuses users (#auto-meditate rebrand).
67
+ export const LEGACY_STATE_FILES = ['reflect-ledger.json', 'reflect-state.json'];
68
+
69
+ /**
70
+ * Delete orphaned pre-rebrand reflect-*.json files from `.moflo/`. Idempotent
71
+ * (no-op once they're gone) and tolerant of a concurrent unlink. Returns the
72
+ * names actually removed, so the launcher can log a one-time crumb.
73
+ */
74
+ export function purgeLegacyFiles(projectRoot) {
75
+ const removed = [];
76
+ for (const name of LEGACY_STATE_FILES) {
77
+ const p = resolve(projectRoot, '.moflo', name);
78
+ if (!existsSync(p)) continue;
79
+ try {
80
+ unlinkSync(p);
81
+ removed.push(name);
82
+ } catch {
83
+ /* deleted between stat and unlink, or held open — non-fatal */
84
+ }
85
+ }
86
+ return removed;
87
+ }
88
+
89
+ // ── moflo.yaml config (regex read, matching the launcher's no-js-yaml style) ─
90
+
91
+ /**
92
+ * Read the `auto_meditate` block from moflo.yaml without a YAML parser. Defaults
93
+ * ON — opt out with `auto_meditate.enabled: false`. (#1198 ships default-on; the
94
+ * `rc` dist-tag gated the initial rollout.) Stays ON on a mid-write/malformed
95
+ * file, consistent with the on default.
96
+ *
97
+ * Back-compat: also honours the legacy `auto_reflect:` key (pre-rebrand) so a
98
+ * consumer's opt-out survives the window between upgrade and the yaml-upgrader
99
+ * renaming the key to `auto_meditate:`.
100
+ *
101
+ * @param {string} projectRoot
102
+ * @returns {{enabled: boolean}}
103
+ */
104
+ export function readMeditateConfig(projectRoot) {
105
+ const cfg = { enabled: true };
106
+ try {
107
+ const yamlPath = resolve(projectRoot, 'moflo.yaml');
108
+ if (!existsSync(yamlPath)) return cfg;
109
+ const text = readFileSync(yamlPath, 'utf-8');
110
+ const enabled = text.match(/auto_(?:meditate|reflect):\s*\n(?:\s+\w+:.*\n)*?\s+enabled:\s*(true|false)/);
111
+ if (enabled) cfg.enabled = enabled[1] === 'true';
112
+ } catch {
113
+ // Default ON keeps the feature live if the file is mid-write / malformed.
114
+ }
115
+ return cfg;
116
+ }
117
+
118
+ /** True when running inside a spawned headless Claude (the distill pass, or any
119
+ * other moflo headless worker). Capture + distill MUST no-op here so the
120
+ * distill session can't re-enter capture or re-spawn another distill (#860). */
121
+ export function isHeadless(env = process.env) {
122
+ return env.CLAUDE_CODE_HEADLESS === 'true' || env.CLAUDE_CODE_HEADLESS === '1';
123
+ }
124
+
125
+ // ── Stage 1: signal detection (pure) ────────────────────────────────────────
126
+ //
127
+ // Recall over precision by design: the live model's "append nothing" is the
128
+ // real precision filter, so a mis-fire just produces no tag (~zero cost). These
129
+ // patterns aim to catch the genuine "moment of insight", not to be exhaustive.
130
+
131
+ const CORRECTION_PATTERNS = [
132
+ /^\s*(no|nope|nah|stop|wait)\b/i,
133
+ /\bthat'?s (not|wrong|incorrect)\b/i,
134
+ /\b(that|this) is (wrong|incorrect|not right)\b/i,
135
+ /\bdo ?n'?t\b/i,
136
+ /\b(actually|instead)\b/i,
137
+ /\bi (said|asked|told you|meant|wanted)\b/i,
138
+ /\byou (were supposed to|should(n'?t)? have|misunderstood|got it wrong)\b/i,
139
+ /\b(revert|undo|roll ?back)\b/i,
140
+ /\bwhy did you\b/i,
141
+ /\bnot what i\b/i,
142
+ /\b(that'?s|this is) not (it|right|correct)\b/i,
143
+ ];
144
+
145
+ const DECISION_PATTERNS = [
146
+ /\blet'?s (go with|use|do|stick with)\b/i,
147
+ /\bwe'?ll (go with|use|do)\b/i,
148
+ /\b(decided|decision)\b/i,
149
+ /\bgoing with\b/i,
150
+ /\bthe plan is\b/i,
151
+ /\bwe should (use|go with|do)\b/i,
152
+ /\bi'?ll go with\b/i,
153
+ ];
154
+
155
+ // Tight, low-false-positive markers only — tool output mentions "error" benignly
156
+ // all the time, so we anchor on structured failure signals, not the bare word.
157
+ // `[1-9]\d*` (not `\d+`) so a SUCCESS line like "0 failed" never triggers.
158
+ const ERROR_PATTERNS = [
159
+ /"is_error"\s*:\s*true/,
160
+ /\bexit code [1-9]\d*/i,
161
+ /Traceback \(most recent call last\)/,
162
+ /\b[1-9]\d* (failed|failing)\b/i,
163
+ ];
164
+
165
+ function anyMatch(patterns, text) {
166
+ if (!text) return false;
167
+ for (const re of patterns) if (re.test(text)) return true;
168
+ return false;
169
+ }
170
+
171
+ /**
172
+ * Classify the strongest signal in the current turn. `prompt` is the user's
173
+ * message; `transcriptTail` is recent transcript text (for error→fix / decision
174
+ * markers that live in the assistant/tool output).
175
+ *
176
+ * @param {string} prompt
177
+ * @param {string} [transcriptTail]
178
+ * @returns {{hit: boolean, kind: 'correction'|'decision'|'error_fix'|null}}
179
+ */
180
+ export function detectSignal(prompt, transcriptTail = '') {
181
+ const p = typeof prompt === 'string' ? prompt : '';
182
+ const t = typeof transcriptTail === 'string' ? transcriptTail : '';
183
+ if (anyMatch(CORRECTION_PATTERNS, p)) return { hit: true, kind: 'correction' };
184
+ if (anyMatch(DECISION_PATTERNS, p) || anyMatch(DECISION_PATTERNS, t)) return { hit: true, kind: 'decision' };
185
+ if (anyMatch(ERROR_PATTERNS, t)) return { hit: true, kind: 'error_fix' };
186
+ return { hit: false, kind: null };
187
+ }
188
+
189
+ /**
190
+ * Text of the MOST RECENT turn — everything in the transcript tail after the
191
+ * last user message. The detect hook scopes error/decision detection to this
192
+ * slice so STALE markers from earlier in the session (a test failure 20 turns
193
+ * ago) don't keep re-firing the capture directive. Falls back to the whole tail
194
+ * when no user line is present. Tolerates a truncated leading JSONL line.
195
+ *
196
+ * @param {string} tailText - raw JSONL tail
197
+ * @returns {string}
198
+ */
199
+ export function recentTranscriptTurn(tailText) {
200
+ if (typeof tailText !== 'string' || !tailText) return '';
201
+ const lines = tailText.split('\n');
202
+ let lastUserIdx = -1;
203
+ for (let i = 0; i < lines.length; i++) {
204
+ const t = lines[i].trim();
205
+ if (!t || t[0] !== '{') continue;
206
+ let obj;
207
+ try { obj = JSON.parse(t); } catch { continue; }
208
+ const role = obj?.message?.role ?? obj?.role;
209
+ if (role === 'user') lastUserIdx = i;
210
+ }
211
+ return (lastUserIdx >= 0 ? lines.slice(lastUserIdx + 1) : lines).join('\n');
212
+ }
213
+
214
+ // ── Stage 1: the injected directive (DRY with #1187 /meditate) ───────────────
215
+
216
+ /** The durability bar — single canonical wording shared by the capture
217
+ * directive AND the distill prompt, mirroring `.claude/skills/meditate/SKILL.md`
218
+ * Step 2 so the automatic path applies the exact same standard as `/meditate`. */
219
+ export const DURABILITY_BAR =
220
+ 'A durable lesson would help a FUTURE session on a DIFFERENT task: a reusable ' +
221
+ 'pattern ("for X do Y because Z"), a recurring gotcha/trap, or a decision plus ' +
222
+ 'the rationale future work must honor. NOT durable: "fixed bug X in file Y" ' +
223
+ '(that is git history), restating an existing rule, or session state.';
224
+
225
+ const KIND_LABEL = {
226
+ correction: 'course-correction',
227
+ decision: 'decision',
228
+ error_fix: 'error-then-fix',
229
+ };
230
+
231
+ /**
232
+ * Build the answer-first capture directive injected as UserPromptSubmit context.
233
+ * Answer-first so the user's actual request is never degraded; "append nothing"
234
+ * is framed as the correct common outcome so a mis-fire is cheap.
235
+ *
236
+ * @param {'correction'|'decision'|'error_fix'} kind
237
+ * @returns {string}
238
+ */
239
+ export function buildCaptureDirective(kind) {
240
+ const label = KIND_LABEL[kind] || 'notable moment';
241
+ return [
242
+ `[auto-meditate] A ${label} just occurred this session.`,
243
+ `FIRST: fully address the user's request as you normally would — do NOT let this note change your answer.`,
244
+ `THEN, only if a durable, reusable lesson emerged, append exactly ONE final line of the form:`,
245
+ `<meditate-capture>LESSON</meditate-capture>`,
246
+ `where LESSON is a single concise sentence (a pattern, gotcha, or decision+rationale).`,
247
+ DURABILITY_BAR,
248
+ `If nothing clears that bar, append nothing — silence is the correct, common outcome. Never emit more than one such line.`,
249
+ ].join('\n');
250
+ }
251
+
252
+ // ── Stage 1: rate-limit state ───────────────────────────────────────────────
253
+
254
+ function stateFilePath(projectRoot) {
255
+ return resolve(projectRoot, '.moflo', STATE_FILE);
256
+ }
257
+
258
+ /**
259
+ * Decide whether an injection is allowed now, given persisted state. Limits:
260
+ * one directive per INJECT_WINDOW_MS, and at most INJECT_MAX_PER_SESSION per
261
+ * session. A new session id resets the per-session counter.
262
+ *
263
+ * @param {{sessionId?: string|null, lastInjectMs?: number, count?: number}|null} state
264
+ * @param {string|null} sessionId
265
+ * @param {number} now
266
+ * @returns {boolean}
267
+ */
268
+ export function injectionAllowed(state, sessionId, now) {
269
+ if (!state || typeof state !== 'object') return true;
270
+ const sameSession = state.sessionId && sessionId && state.sessionId === sessionId;
271
+ if (!sameSession) return true; // fresh session — window + counter reset
272
+ if (typeof state.lastInjectMs === 'number' && now - state.lastInjectMs < INJECT_WINDOW_MS) return false;
273
+ if (typeof state.count === 'number' && state.count >= INJECT_MAX_PER_SESSION) return false;
274
+ return true;
275
+ }
276
+
277
+ /** Read rate-limit state (never throws). */
278
+ export function readMeditateState(projectRoot) {
279
+ try {
280
+ return JSON.parse(readFileSync(stateFilePath(projectRoot), 'utf-8'));
281
+ } catch {
282
+ return null;
283
+ }
284
+ }
285
+
286
+ /** Persist rate-limit state after an injection, incrementing the per-session
287
+ * counter (resets when the session id changes). */
288
+ export function recordInjection(projectRoot, sessionId, now, prevState = null) {
289
+ const sameSession = prevState && prevState.sessionId === sessionId;
290
+ const count = sameSession && typeof prevState.count === 'number' ? prevState.count + 1 : 1;
291
+ atomicWriteJson(stateFilePath(projectRoot), { sessionId: sessionId ?? null, lastInjectMs: now, count });
292
+ }
293
+
294
+ // ── Stage 1: <meditate-capture> tag extraction (pure) ────────────────────────
295
+
296
+ const CAPTURE_TAG_RE = /<meditate-capture>([\s\S]*?)<\/meditate-capture>/gi;
297
+ // Drop the directive's own placeholder + non-lessons.
298
+ const PLACEHOLDER_RE = /^(lesson|none|n\/?a|nothing)$/i;
299
+
300
+ /** Extract lesson strings from a block of assistant text. Bounded + de-noised.
301
+ * Uses matchAll (fresh internal iterator) rather than exec-with-/g so there's
302
+ * no shared lastIndex state to reset/leak across calls. */
303
+ export function extractCaptureTags(text) {
304
+ const out = [];
305
+ if (typeof text !== 'string' || !text) return out;
306
+ for (const m of text.matchAll(CAPTURE_TAG_RE)) {
307
+ const lesson = String(m[1] || '').replace(/\s+/g, ' ').trim();
308
+ if (lesson.length < 8) continue; // empties / fragments
309
+ if (PLACEHOLDER_RE.test(lesson)) continue; // the directive's "LESSON" placeholder
310
+ out.push(lesson.slice(0, MAX_LESSON_CHARS));
311
+ }
312
+ return out;
313
+ }
314
+
315
+ /**
316
+ * Extract captures from a transcript tail, restricted to ASSISTANT-role
317
+ * messages. This is what keeps the directive's own example tag (which rides in
318
+ * UserPromptSubmit context, not assistant output) from being scraped as a real
319
+ * capture. Tolerates a truncated leading JSONL line.
320
+ *
321
+ * @param {string} tailText - raw JSONL tail
322
+ * @returns {string[]}
323
+ */
324
+ export function extractCapturesFromTranscript(tailText) {
325
+ const out = [];
326
+ if (typeof tailText !== 'string' || !tailText) return out;
327
+ for (const line of tailText.split('\n')) {
328
+ const t = line.trim();
329
+ if (!t || t[0] !== '{') continue;
330
+ let obj;
331
+ try { obj = JSON.parse(t); } catch { continue; } // partial / non-JSON line
332
+ const role = obj?.message?.role ?? obj?.role;
333
+ if (role !== 'assistant') continue;
334
+ let content = obj?.message?.content ?? obj?.content;
335
+ if (Array.isArray(content)) {
336
+ content = content.map((b) => (typeof b === 'string' ? b : b?.text || '')).join('\n');
337
+ }
338
+ if (typeof content !== 'string') continue;
339
+ for (const lesson of extractCaptureTags(content)) out.push(lesson);
340
+ }
341
+ return out;
342
+ }
343
+
344
+ // ── Ledger (.moflo/meditate-ledger.json) ─────────────────────────────────────
345
+
346
+ function ledgerFilePath(projectRoot) {
347
+ return resolve(projectRoot, '.moflo', LEDGER_FILE);
348
+ }
349
+
350
+ /** Atomic temp+rename JSON write (crash-safe; a half-write never lands). */
351
+ function atomicWriteJson(dest, obj) {
352
+ mkdirSync(dirname(dest), { recursive: true });
353
+ const tmp = `${dest}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
354
+ writeFileSync(tmp, JSON.stringify(obj), 'utf-8');
355
+ renameSync(tmp, dest);
356
+ }
357
+
358
+ /** Read the ledger (never throws; returns an empty ledger on absence/malformed). */
359
+ export function readLedger(projectRoot) {
360
+ try {
361
+ const obj = JSON.parse(readFileSync(ledgerFilePath(projectRoot), 'utf-8'));
362
+ if (obj && Array.isArray(obj.entries)) return obj;
363
+ } catch {
364
+ // absent or mid-write — start fresh
365
+ }
366
+ return { entries: [] };
367
+ }
368
+
369
+ /** Entries not yet distilled, oldest first. */
370
+ export function pendingEntries(ledger) {
371
+ const entries = ledger && Array.isArray(ledger.entries) ? ledger.entries : [];
372
+ return entries.filter((e) => e && !e.distilled && typeof e.lesson === 'string');
373
+ }
374
+
375
+ function normalizeLesson(s) {
376
+ return String(s || '').replace(/\s+/g, ' ').trim().toLowerCase();
377
+ }
378
+
379
+ /** Cap ledger size — drop distilled entries first (keep KEEP_DISTILLED newest),
380
+ * then oldest pending if still over cap. Pure; returns a new entries array.
381
+ * Membership is keyed by entry id (a Set), not object reference, so the cap
382
+ * stays correct even if entries are ever cloned (e.g. a JSON round-trip). */
383
+ function capEntries(entries) {
384
+ if (entries.length <= MAX_LEDGER_ENTRIES) return entries;
385
+ const distilled = entries.filter((e) => e.distilled);
386
+ // Keep the newest KEEP_DISTILLED distilled, preserving order.
387
+ const keptDistilledIds = new Set(
388
+ (distilled.length > KEEP_DISTILLED ? distilled.slice(distilled.length - KEEP_DISTILLED) : distilled)
389
+ .map((e) => e.id),
390
+ );
391
+ let merged = entries.filter((e) => (e.distilled ? keptDistilledIds.has(e.id) : true));
392
+ if (merged.length > MAX_LEDGER_ENTRIES) {
393
+ // Still over — drop oldest pending (front of array).
394
+ const overflow = merged.length - MAX_LEDGER_ENTRIES;
395
+ let dropped = 0;
396
+ merged = merged.filter((e) => {
397
+ if (dropped < overflow && !e.distilled) { dropped++; return false; }
398
+ return true;
399
+ });
400
+ }
401
+ return merged;
402
+ }
403
+
404
+ /**
405
+ * Append new captures to the ledger, skipping ones whose lesson already exists
406
+ * (normalized) — the Stop scrape re-reads an overlapping tail each turn, so
407
+ * dedup-on-append prevents the same lesson landing twice. Dedup is best-effort
408
+ * (read-then-write, not a transaction): correct because Claude Code serialises a
409
+ * session's Stop hooks, so there is no concurrent writer to race against. The
410
+ * distill's separate semantic dedup (≥0.80) is the real backstop.
411
+ *
412
+ * @param {string} projectRoot
413
+ * @param {Array<{lesson: string, kind?: string, sessionId?: string|null}>} captures
414
+ * @param {number} [now]
415
+ * @returns {number} count actually appended
416
+ */
417
+ export function appendLedgerEntries(projectRoot, captures, now = Date.now()) {
418
+ const list = Array.isArray(captures) ? captures : [];
419
+ if (list.length === 0) return 0;
420
+ const ledger = readLedger(projectRoot);
421
+ const seen = new Set(ledger.entries.map((e) => normalizeLesson(e.lesson)));
422
+ let appended = 0;
423
+ for (const c of list) {
424
+ const lesson = String(c?.lesson || '').replace(/\s+/g, ' ').trim().slice(0, MAX_LESSON_CHARS);
425
+ if (lesson.length < 8) continue;
426
+ const norm = normalizeLesson(lesson);
427
+ if (seen.has(norm)) continue;
428
+ seen.add(norm);
429
+ ledger.entries.push({
430
+ id: `${now.toString(36)}-${randomBytes(3).toString('hex')}`,
431
+ lesson,
432
+ kind: c?.kind || null,
433
+ sessionId: c?.sessionId ?? null,
434
+ capturedAt: now,
435
+ distilled: false,
436
+ });
437
+ appended++;
438
+ }
439
+ if (appended === 0) return 0;
440
+ ledger.entries = capEntries(ledger.entries);
441
+ atomicWriteJson(ledgerFilePath(projectRoot), ledger);
442
+ return appended;
443
+ }
444
+
445
+ /**
446
+ * Mark the given entry ids distilled. New captures that arrived during the
447
+ * distill run keep `distilled:false` and survive for the next pass.
448
+ *
449
+ * @param {string} projectRoot
450
+ * @param {string[]} ids
451
+ * @returns {number} count marked
452
+ */
453
+ export function markLedgerDistilled(projectRoot, ids) {
454
+ const idSet = new Set(Array.isArray(ids) ? ids : []);
455
+ if (idSet.size === 0) return 0;
456
+ const ledger = readLedger(projectRoot);
457
+ let marked = 0;
458
+ for (const e of ledger.entries) {
459
+ if (e && idSet.has(e.id) && !e.distilled) { e.distilled = true; marked++; }
460
+ }
461
+ if (marked === 0) return 0;
462
+ ledger.entries = capEntries(ledger.entries);
463
+ atomicWriteJson(ledgerFilePath(projectRoot), ledger);
464
+ return marked;
465
+ }
466
+
467
+ // ── Stage 2: distill prompt (DRY with #1187 /meditate) ───────────────────────
468
+
469
+ /**
470
+ * Build the bounded headless-Haiku distillation prompt over the pending ledger
471
+ * entries. Reuses the `/meditate` protocol (search → dedup ≥0.80 → store) rather
472
+ * than forking it, and instructs writes through the MCP memory tools so they
473
+ * route via the daemon single-writer chokepoint (#981) — writer-safe.
474
+ *
475
+ * @param {Array<{lesson: string}>} entries
476
+ * @returns {string}
477
+ */
478
+ export function buildDistillPrompt(entries) {
479
+ const list = (Array.isArray(entries) ? entries : []).filter((e) => e && e.lesson);
480
+ const numbered = list.map((e, i) => `${i + 1}. ${e.lesson}`).join('\n');
481
+ return [
482
+ `You are running moflo's automatic /meditate distillation (#1198) headlessly. Below are raw candidate lessons captured during a recent session — one per line. Persist the durable keepers to long-term memory, deduped. Be terse; do not write files.`,
483
+ ``,
484
+ `Candidates:`,
485
+ numbered,
486
+ ``,
487
+ `Procedure — reuse the /meditate protocol, do not invent a new one:`,
488
+ `1. For EACH candidate, call mcp__moflo__memory_search { namespace: "learnings", query: <bare keywords>, threshold: 0.6, limit: 5 }.`,
489
+ `2. If the top hit is the SAME fact at similarity >= 0.80, call mcp__moflo__memory_store with that SAME key (upsert), merging any new nuance — do NOT create a near-duplicate.`,
490
+ `3. Otherwise call mcp__moflo__memory_store { namespace: "learnings", key: <stable descriptive slug>, value: "<lesson> — Why: <why it matters>. How to apply: <what to do next time>.", tags: [<topic>, <area>] }.`,
491
+ `4. Skip any candidate that does not clear the durability bar below, or that merely restates an existing entry.`,
492
+ ``,
493
+ DURABILITY_BAR,
494
+ ``,
495
+ `When finished, output one line only: "distilled <new>, updated <merged>, skipped <n>".`,
496
+ ].join('\n');
497
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Credential / secret scrubber for the session-continuity persist path (#1185).
3
+ *
4
+ * THREAT MODEL — this is deliberately NOT the transfer/anonymization pipeline
5
+ * (`src/cli/transfer/anonymization/index.ts`). That module makes a CFP safe to
6
+ * publish *externally* (aggressive, lossy, deterministic pseudonymisation of
7
+ * emails / home paths / IPs). This module has a narrower job: a session digest
8
+ * is written to the user's OWN local `.moflo/moflo.db`, so the only thing we
9
+ * must never persist is a literal *secret* that happened to appear in the
10
+ * session (an API key, a JWT, a private-key block). We intentionally KEEP
11
+ * benign context like file paths and branch names — they're the whole point of
12
+ * a "where you left off" digest and are not sensitive on the user's own disk.
13
+ *
14
+ * Two different purposes → two focused scrubbers, not one over-coupled one.
15
+ *
16
+ * Pure + synchronous + dependency-free so a bin/*.mjs hook can call it on the
17
+ * hot path without loading a model. Cross-platform (Rule #1): plain regex, no
18
+ * shell, no path assumptions.
19
+ */
20
+
21
+ /**
22
+ * Ordered list of { name, pattern, replace } secret shapes. Order matters:
23
+ * multi-line PEM blocks and specific vendor token formats are neutralised
24
+ * before the generic `key=value` assignment sweep so the specific redaction
25
+ * label wins.
26
+ *
27
+ * Each `pattern` carries the global flag so `String.replace` hits every match.
28
+ */
29
+ export const SECRET_PATTERNS = [
30
+ // PEM private-key blocks (RSA / EC / OPENSSH / generic) — match the whole block.
31
+ {
32
+ name: 'private-key',
33
+ pattern: /-----BEGIN (?:[A-Z]+ )?PRIVATE KEY-----[\s\S]*?-----END (?:[A-Z]+ )?PRIVATE KEY-----/g,
34
+ replace: '[REDACTED_PRIVATE_KEY]',
35
+ },
36
+ // JSON Web Tokens — header.payload.signature, both segments start with `eyJ`.
37
+ {
38
+ name: 'jwt',
39
+ pattern: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g,
40
+ replace: '[REDACTED_JWT]',
41
+ },
42
+ // OpenAI / Anthropic-style keys: sk-..., sk-ant-..., pk-...
43
+ {
44
+ name: 'openai-anthropic-key',
45
+ pattern: /\b(?:sk|pk)-(?:ant-)?[A-Za-z0-9_-]{20,}\b/g,
46
+ replace: '[REDACTED_API_KEY]',
47
+ },
48
+ // AWS access key id.
49
+ { name: 'aws-access-key', pattern: /\bAKIA[0-9A-Z]{16}\b/g, replace: '[REDACTED_AWS_KEY]' },
50
+ // GitHub tokens (classic ghp_/gho_/ghu_/ghs_/ghr_ + fine-grained github_pat_).
51
+ {
52
+ name: 'github-token',
53
+ pattern: /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,})\b/g,
54
+ replace: '[REDACTED_GITHUB_TOKEN]',
55
+ },
56
+ // Slack tokens.
57
+ { name: 'slack-token', pattern: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, replace: '[REDACTED_SLACK_TOKEN]' },
58
+ // Google API keys.
59
+ { name: 'google-api-key', pattern: /\bAIza[0-9A-Za-z_-]{35}\b/g, replace: '[REDACTED_GOOGLE_KEY]' },
60
+ // Bearer tokens in Authorization headers / curl snippets.
61
+ {
62
+ name: 'bearer-token',
63
+ pattern: /\b[Bb]earer\s+[A-Za-z0-9._-]{16,}/g,
64
+ replace: 'Bearer [REDACTED_TOKEN]',
65
+ },
66
+ // Credentials embedded in a URL: scheme://user:secret@host → drop the secret.
67
+ // The negative lookahead keeps the scrub idempotent — it won't re-match the
68
+ // `[REDACTED]` placeholder it just wrote (so containsSecret(scrubbed) is false).
69
+ {
70
+ name: 'url-credentials',
71
+ pattern: /\b([a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/\s:@]+):(?!\[REDACTED\]@)[^/\s@]+@/g,
72
+ replace: '$1:[REDACTED]@',
73
+ },
74
+ // Generic `secret=value` / `password: value` / `token = value` / `api_key=...`
75
+ // assignments. Quote-aware so it stops at the closing quote or whitespace.
76
+ // Runs LAST so the specific vendor formats above keep their precise labels.
77
+ // The negative lookahead skips the `[REDACTED]` placeholder so a re-scan of
78
+ // already-scrubbed text reports clean (idempotent detection).
79
+ {
80
+ name: 'assigned-secret',
81
+ pattern: /\b(api[_-]?key|secret|password|passwd|token|access[_-]?token|auth[_-]?token)(["']?\s*[:=]\s*["']?)(?!\[REDACTED)([^\s"']{6,})/gi,
82
+ replace: (_m, key, sep) => `${key}${sep}[REDACTED]`,
83
+ },
84
+ ];
85
+
86
+ /**
87
+ * Replace every recognised secret in `text` with a redaction label. Returns the
88
+ * scrubbed string. Non-string input is coerced to '' (capture must never throw).
89
+ *
90
+ * @param {string} text
91
+ * @returns {string}
92
+ */
93
+ export function scrubSecrets(text) {
94
+ if (typeof text !== 'string' || text.length === 0) return '';
95
+ let out = text;
96
+ for (const { pattern, replace } of SECRET_PATTERNS) {
97
+ // Fresh lastIndex each pass — these regexes are module-shared and carry /g,
98
+ // so a prior `.test()`/`.exec()` elsewhere must not leak state (the exact
99
+ // RegExp-lastIndex bug class from feedback_publish_catches_straggler_bugs).
100
+ pattern.lastIndex = 0;
101
+ out = out.replace(pattern, replace);
102
+ }
103
+ return out;
104
+ }
105
+
106
+ /**
107
+ * True if `text` contains at least one recognised secret. Used by tests and by
108
+ * the capture path's defensive "did we miss anything" assertion.
109
+ *
110
+ * @param {string} text
111
+ * @returns {boolean}
112
+ */
113
+ export function containsSecret(text) {
114
+ if (typeof text !== 'string' || text.length === 0) return false;
115
+ return SECRET_PATTERNS.some(({ pattern }) => {
116
+ pattern.lastIndex = 0;
117
+ return pattern.test(text);
118
+ });
119
+ }