moflo 4.12.11 → 4.13.0

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 (43) hide show
  1. package/.claude/guidance/shipped/moflo-cli-reference.md +45 -1
  2. package/.claude/guidance/shipped/moflo-cross-install-memory-sharing.md +7 -2
  3. package/.claude/guidance/shipped/moflo-skills-reference.md +2 -0
  4. package/.claude/skills/fl/phases.md +51 -17
  5. package/.claude/skills/optimize-learnings/SKILL.md +220 -0
  6. package/README.md +95 -1
  7. package/bin/lib/get-backend.mjs +150 -12
  8. package/bin/lib/skill-categories.mjs +1 -0
  9. package/bin/session-start-launcher.mjs +13 -5
  10. package/dist/src/cli/commands/daemon.js +5 -2
  11. package/dist/src/cli/commands/epic.js +5 -1
  12. package/dist/src/cli/commands/hive-mind.js +6 -4
  13. package/dist/src/cli/commands/hooks.js +8 -8
  14. package/dist/src/cli/commands/index.js +5 -0
  15. package/dist/src/cli/commands/memory-audit-learnings.js +587 -0
  16. package/dist/src/cli/commands/memory.js +71 -10
  17. package/dist/src/cli/commands/spell-schedule.js +5 -3
  18. package/dist/src/cli/commands/worktree.js +408 -0
  19. package/dist/src/cli/config/moflo-config.js +57 -0
  20. package/dist/src/cli/index.js +4 -2
  21. package/dist/src/cli/init/executor.js +1 -0
  22. package/dist/src/cli/mcp-tools/memory-admin-tools.js +46 -8
  23. package/dist/src/cli/mcp-tools/moflodb-tools.js +30 -6
  24. package/dist/src/cli/memory/bridge-entries.js +157 -9
  25. package/dist/src/cli/memory/controllers/batch-operations.js +7 -2
  26. package/dist/src/cli/memory/daemon-backend.js +152 -11
  27. package/dist/src/cli/memory/entries-read.js +47 -2
  28. package/dist/src/cli/memory/entries-write.js +73 -10
  29. package/dist/src/cli/memory/hnsw-singleton.js +112 -9
  30. package/dist/src/cli/memory/learnings-audit.js +420 -0
  31. package/dist/src/cli/memory/learnings-dead-paths.js +202 -0
  32. package/dist/src/cli/memory/learnings-tree.js +187 -0
  33. package/dist/src/cli/memory/memory-bridge.js +37 -27
  34. package/dist/src/cli/memory/tool-call-markup.js +218 -0
  35. package/dist/src/cli/parser.js +7 -3
  36. package/dist/src/cli/services/cherry-pick-learnings.js +9 -3
  37. package/dist/src/cli/services/durable-reconcile.js +161 -0
  38. package/dist/src/cli/services/durable-store-io.js +291 -0
  39. package/dist/src/cli/services/durable-sync.js +159 -24
  40. package/dist/src/cli/services/team-artifact-sync.js +462 -163
  41. package/dist/src/cli/services/worktree-provision.js +400 -0
  42. package/dist/src/cli/version.js +1 -1
  43. package/package.json +2 -2
@@ -0,0 +1,587 @@
1
+ /**
2
+ * `flo memory audit-learnings` (#1466) — curation pass over durable learnings.
3
+ *
4
+ * Every side effect the audit needs lives here: reading the store, the
5
+ * verdict-state file, the bounded headless model call, and the archive write.
6
+ * The judgement itself is pure and lives in `memory/learnings-audit.ts`, which
7
+ * is what makes the clustering and ranking testable without a database.
8
+ *
9
+ * **Dry by default.** A run with no flags reads, nominates, judges, and prints;
10
+ * it never writes to the store. `--apply` archives (`status = 'archived'`) via
11
+ * the same `archiveDurableRow` primitive `flo memory delete` uses, so the
12
+ * deletion carries a tombstone the #1463 reconciler can propagate instead of
13
+ * being silently re-imported on the next session start.
14
+ *
15
+ * The dead-path pass (#1479) follows the same split: the detector is pure and
16
+ * takes a `resolves` predicate, and `memory/learnings-tree.ts` supplies it from
17
+ * a real checkout. This file only decides whether the pass runs at all.
18
+ *
19
+ * Cross-platform (Rule #1): `path.join` throughout, `child_process.spawn` with
20
+ * an argument array (no shell), `windowsHide` on the child, and no POSIX-only
21
+ * utilities.
22
+ *
23
+ * @module commands/memory-audit-learnings
24
+ */
25
+ import * as fs from 'fs';
26
+ import * as pathModule from 'path';
27
+ import { spawn } from 'child_process';
28
+ import { output } from '../output.js';
29
+ import { confirm } from '../prompt.js';
30
+ import { errorDetail } from '../shared/utils/error-detail.js';
31
+ import { deleteEntry } from '../memory/entries-write.js';
32
+ import { openDaemonDatabase } from '../memory/daemon-backend.js';
33
+ import { resolveBridgeDbPath } from '../memory/bridge-core.js';
34
+ import { findProjectRoot } from '../services/project-root.js';
35
+ import { hasMemoryEntriesTable } from '../services/cherry-pick-learnings.js';
36
+ import { atomicWriteFileSync } from '../shared/utils/atomic-file-write.js';
37
+ import { hashContent } from '../memory/auto-memory-bridge.js';
38
+ import { listWorkspacePrefixes, makeTreeResolver } from '../memory/learnings-tree.js';
39
+ import { LEARNINGS_NAMESPACE, buildAuditPlan, buildJudgePrompt, parseVerdicts, selectArchivable, selectManualActions, DEFAULT_DUPLICATE_THRESHOLD, DEFAULT_JUDGE_LIMIT, DEFAULT_UNUSED_LIMIT, DEFAULT_UNUSED_MIN_AGE_MS, } from '../memory/learnings-audit.js';
40
+ /** Where recorded verdicts live. Local-only — never part of the shared artifact. */
41
+ export const AUDIT_STATE_FILE = 'learnings-audit.json';
42
+ /** Bump when the record shape changes; an older file is discarded, not migrated. */
43
+ const AUDIT_STATE_VERSION = 1;
44
+ /** Cheap formatter/judge model — same tier the auto-meditate distill runs on. */
45
+ const JUDGE_MODEL_ID = 'claude-haiku-4-5-20251001';
46
+ /** Hard ceiling on the headless judge; killed past this. */
47
+ const JUDGE_TIMEOUT_MS = 180_000;
48
+ /** Narrowest read-only tool grant for the judge child. See `runJudge`. */
49
+ const JUDGE_ALLOWED_TOOLS = 'Read';
50
+ /**
51
+ * Test seam mirroring `bin/meditate-distill.mjs`: when set to a script path the
52
+ * judge runs as `node <stub> --print <prompt>` instead of `claude --print
53
+ * <prompt>`, so the spawn is exercisable on all three platforms without a real
54
+ * Claude CLI on PATH.
55
+ */
56
+ export const JUDGE_STUB_ENV = 'MOFLO_AUDIT_LEARNINGS_NODE_STUB';
57
+ function stateFilePath(projectRoot) {
58
+ return pathModule.join(projectRoot, '.moflo', AUDIT_STATE_FILE);
59
+ }
60
+ /** Read recorded verdicts. Any unreadable or stale-version file reads as empty. */
61
+ export function readAuditState(projectRoot) {
62
+ try {
63
+ const raw = fs.readFileSync(stateFilePath(projectRoot), 'utf-8');
64
+ const parsed = JSON.parse(raw);
65
+ if (parsed?.version !== AUDIT_STATE_VERSION || !parsed.decided)
66
+ return new Map();
67
+ return new Map(Object.entries(parsed.decided));
68
+ }
69
+ catch {
70
+ // Absent, truncated, or hand-edited into invalid JSON. Losing the record
71
+ // costs one re-judgement; refusing to run over it costs the command.
72
+ return new Map();
73
+ }
74
+ }
75
+ /**
76
+ * Write the verdict record back.
77
+ *
78
+ * Read-modify-write with no lock: two `--apply` runs racing on the same project
79
+ * would lose one run's verdicts. Not worth a lock — this is a hand-invoked
80
+ * curation command, the loss costs one re-judgement, and `atomicWriteFileSync`
81
+ * already rules out a torn file (its temp name is pid- and random-suffixed, so
82
+ * concurrent writers cannot clobber each other's staging file either).
83
+ */
84
+ export function writeAuditState(projectRoot, decided) {
85
+ const file = stateFilePath(projectRoot);
86
+ fs.mkdirSync(pathModule.dirname(file), { recursive: true });
87
+ const payload = { version: AUDIT_STATE_VERSION, decided: Object.fromEntries(decided) };
88
+ atomicWriteFileSync(file, `${JSON.stringify(payload, null, 2)}\n`);
89
+ }
90
+ /**
91
+ * Parse a stored embedding. A malformed vector reads as absent rather than
92
+ * throwing — one bad row must not take the whole audit down.
93
+ */
94
+ function parseEmbedding(raw) {
95
+ if (typeof raw !== 'string' || raw.length === 0)
96
+ return null;
97
+ try {
98
+ const parsed = JSON.parse(raw);
99
+ return Array.isArray(parsed) && parsed.length > 0 ? parsed : null;
100
+ }
101
+ catch {
102
+ return null;
103
+ }
104
+ }
105
+ /**
106
+ * Load the active learnings rows the audit operates on.
107
+ *
108
+ * The missing-table case is PROBED rather than caught. A blanket try/catch here
109
+ * would turn a corrupt or locked database into a cheerful "0 active learnings
110
+ * examined", which is the same lie #1149 exists to prevent — a real read failure
111
+ * must reach the caller.
112
+ */
113
+ export function readLearningsRows(db) {
114
+ if (!hasMemoryEntriesTable(db))
115
+ return [];
116
+ const result = db.exec(`SELECT id, key, content, embedding, created_at, updated_at, access_count
117
+ FROM memory_entries
118
+ WHERE status = 'active' AND namespace = ?`, [LEARNINGS_NAMESPACE]);
119
+ return (result[0]?.values ?? []).map((row) => {
120
+ const [id, key, content, embedding, createdAt, updatedAt, accessCount] = row;
121
+ return {
122
+ id: String(id ?? ''),
123
+ key: String(key ?? ''),
124
+ content: String(content ?? ''),
125
+ embedding: parseEmbedding(embedding),
126
+ createdAt: Number(createdAt) || 0,
127
+ updatedAt: Number(updatedAt) || 0,
128
+ accessCount: Number(accessCount) || 0,
129
+ };
130
+ });
131
+ }
132
+ /**
133
+ * Run one bounded headless judgement. Never throws — a failure is a result.
134
+ *
135
+ * The prompt goes over **stdin**, not argv. `bin/meditate-distill.mjs` passes
136
+ * its prompt as an argument, which is safe there because it sends 25 one-line
137
+ * lessons; this one sends up to 60 entries with 400-char bodies — roughly 36 KB,
138
+ * comfortably past Windows' 32,767-character `CreateProcess` command-line limit,
139
+ * so an argv prompt would fail on Windows at the DEFAULT settings (Rule #1).
140
+ * `claude --print` with no positional prompt reads stdin, which has no such cap
141
+ * on any platform.
142
+ */
143
+ function runJudge(projectRoot, prompt) {
144
+ return new Promise((resolve) => {
145
+ const stub = process.env[JUDGE_STUB_ENV];
146
+ const cmd = stub ? process.execPath : 'claude';
147
+ // The judge reads a prompt and writes verdict lines — it needs no tools at
148
+ // all. The flag wants a value, so grant the narrowest read-only one rather
149
+ // than leaving the child with an unrestricted default (Write/Edit/Bash) in
150
+ // somebody else's repository.
151
+ const args = stub ? [stub, '--print'] : ['--print', '--allowedTools', JUDGE_ALLOWED_TOOLS];
152
+ let child;
153
+ try {
154
+ child = spawn(cmd, args, {
155
+ cwd: projectRoot,
156
+ env: {
157
+ ...process.env,
158
+ // Mark the child so its own hooks no-op (#860) — without this the
159
+ // judge would trip the session-start indexer chain in every consumer.
160
+ CLAUDE_CODE_HEADLESS: 'true',
161
+ ANTHROPIC_MODEL: JUDGE_MODEL_ID,
162
+ },
163
+ stdio: ['pipe', 'pipe', 'pipe'],
164
+ windowsHide: true,
165
+ });
166
+ }
167
+ catch (err) {
168
+ resolve({ ok: false, output: '', error: err instanceof Error ? err.message : String(err) });
169
+ return;
170
+ }
171
+ let text = '';
172
+ let settled = false;
173
+ const finish = (r) => {
174
+ if (settled)
175
+ return;
176
+ settled = true;
177
+ clearTimeout(timer);
178
+ resolve(r);
179
+ };
180
+ const timer = setTimeout(() => {
181
+ try {
182
+ child.kill('SIGTERM');
183
+ }
184
+ catch { /* already gone */ }
185
+ finish({ ok: false, output: text, error: `timed out after ${JUDGE_TIMEOUT_MS}ms` });
186
+ }, JUDGE_TIMEOUT_MS);
187
+ child.stdout?.on('data', (d) => { text += String(d); });
188
+ child.stderr?.on('data', (d) => { text += String(d); });
189
+ child.on('error', (err) => finish({ ok: false, output: text, error: err.message }));
190
+ child.on('close', (code) => finish({ ok: code === 0, output: text }));
191
+ // A child that exits before reading the prompt makes this write EPIPE.
192
+ // That is the same failure the `close` handler is about to report, so
193
+ // swallow it here rather than letting it reach the process as unhandled.
194
+ child.stdin?.on('error', () => { });
195
+ try {
196
+ child.stdin?.end(prompt, 'utf-8');
197
+ }
198
+ catch {
199
+ /* same — the exit path carries the diagnosis */
200
+ }
201
+ });
202
+ }
203
+ function toPositiveNumber(value, fallback) {
204
+ const n = Number(value);
205
+ return Number.isFinite(n) && n > 0 ? n : fallback;
206
+ }
207
+ /** `--unused-min-age-days` in ms, falling back to the module default. */
208
+ function unusedMinAgeMs(flag) {
209
+ const days = Number(flag);
210
+ return Number.isFinite(days) && days > 0 ? days * 24 * 60 * 60 * 1000 : DEFAULT_UNUSED_MIN_AGE_MS;
211
+ }
212
+ function printPlan(plan, deadPathsScanned) {
213
+ output.writeln();
214
+ output.writeln(output.bold('Nominations'));
215
+ output.printTable({
216
+ columns: [
217
+ { key: 'bucket', header: 'Bucket', width: 24 },
218
+ { key: 'count', header: 'Count', width: 10, align: 'right' },
219
+ ],
220
+ data: [
221
+ { bucket: 'Near-duplicate', count: plan.counts.duplicate },
222
+ { bucket: 'Unused and old', count: plan.counts.unused },
223
+ { bucket: 'Superseded vocabulary', count: plan.counts.superseded },
224
+ { bucket: 'Dead path reference', count: plan.counts.deadPath },
225
+ { bucket: output.bold('To judge'), count: output.bold(String(plan.candidates.length)) },
226
+ ],
227
+ });
228
+ const notes = [
229
+ `${plan.examined} active learning${plan.examined === 1 ? '' : 's'} examined`,
230
+ ];
231
+ if (plan.unusedCoverage.matched > plan.unusedCoverage.nominated) {
232
+ // Never let a cap read as full coverage.
233
+ notes.push(`${plan.unusedCoverage.matched} entries are unused and old; the ${plan.unusedCoverage.nominated} `
234
+ + 'least-recently-updated were nominated (--unused-limit to widen)');
235
+ }
236
+ if (plan.alreadyDecided > 0) {
237
+ notes.push(`${plan.alreadyDecided} already carry a recorded verdict (--recheck to re-examine)`);
238
+ }
239
+ if (plan.withoutEmbedding > 0) {
240
+ notes.push(`${plan.withoutEmbedding} have no stored vector — invisible to the duplicate pass`);
241
+ }
242
+ if (!deadPathsScanned) {
243
+ // A zero in the table has to be distinguishable from a pass that never ran.
244
+ notes.push('dead-path resolution skipped (--no-dead-paths) — that bucket reads 0 regardless');
245
+ }
246
+ if (plan.overflow > 0) {
247
+ notes.push(`${plan.overflow} nomination(s) over the judge limit — they resurface next run`);
248
+ }
249
+ output.printList(notes);
250
+ }
251
+ /**
252
+ * Name the unresolved paths that nominated an entry.
253
+ *
254
+ * The verdict table gets the counts; this gets the evidence, because a reader
255
+ * cannot tell a moved file from a deleted one without seeing the path — and
256
+ * that distinction is the whole difference between COMPRESS and RETIRE.
257
+ */
258
+ function printDeadPaths(plan) {
259
+ const cited = plan.candidates.filter((c) => (c.deadPaths?.length ?? 0) > 0);
260
+ if (cited.length === 0)
261
+ return;
262
+ output.writeln();
263
+ output.printInfo(`${cited.length} entr${cited.length === 1 ? 'y cites a path' : 'ies cite paths'} that resolve nowhere in the tree. `
264
+ + 'A moved file reads exactly like a deleted one here — check `git log --diff-filter=D -- <path>` '
265
+ + 'before treating any of these as stale:');
266
+ output.printList(cited.map((c) => `${c.key} — ${(c.deadPaths ?? []).join(', ')}`));
267
+ }
268
+ function printVerdicts(candidates, verdicts) {
269
+ output.writeln();
270
+ output.writeln(output.bold('Verdicts'));
271
+ output.printTable({
272
+ columns: [
273
+ { key: 'key', header: 'Entry', width: 44 },
274
+ { key: 'verdict', header: 'Verdict', width: 10 },
275
+ { key: 'reason', header: 'Reason', width: 46 },
276
+ ],
277
+ data: candidates
278
+ .filter((c) => verdicts.has(c.key))
279
+ .map((c) => {
280
+ const v = verdicts.get(c.key);
281
+ return { key: c.key, verdict: v.verdict, reason: v.reason };
282
+ }),
283
+ });
284
+ const unanswered = candidates.length - verdicts.size;
285
+ if (unanswered > 0) {
286
+ output.printWarning(`${unanswered} entr${unanswered === 1 ? 'y' : 'ies'} received no verdict — left untouched`);
287
+ }
288
+ }
289
+ /** Say what a non-archiving verdict is asking for, so it never just evaporates. */
290
+ function printManualActions(manual) {
291
+ if (manual.length === 0)
292
+ return;
293
+ output.writeln();
294
+ output.printInfo(`${manual.length} entr${manual.length === 1 ? 'y needs' : 'ies need'} an author, not an archive — `
295
+ + '--apply never removes these, because both verdicts mean the content still has to survive:');
296
+ output.printList(manual.map(({ candidate, verdict }) => verdict === 'MERGE'
297
+ ? `${candidate.key} — MERGE into ${candidate.duplicateOf ?? 'its near-duplicate'}, then retire it`
298
+ : `${candidate.key} — COMPRESS: rewrite to 1-3 sentences`));
299
+ }
300
+ export const auditLearningsCommand = {
301
+ name: 'audit-learnings',
302
+ description: 'Evaluate durable learnings for staleness (dry by default)',
303
+ options: [
304
+ {
305
+ name: 'apply',
306
+ description: 'Archive entries the audit judged RETIRE or MERGE',
307
+ type: 'boolean',
308
+ default: false,
309
+ },
310
+ {
311
+ // Declared positively. The parser turns `--no-<x>` into `<x> = false`
312
+ // (parser.ts § long flag), so an option NAMED `no-judge` would be
313
+ // unreachable: typing `--no-judge` sets `judge`, and nothing reads it.
314
+ name: 'judge',
315
+ description: 'Request a model verdict for nominated entries (--no-judge to skip)',
316
+ type: 'boolean',
317
+ default: true,
318
+ },
319
+ {
320
+ // Declared positively for the same reason as `judge` above: the parser
321
+ // turns `--no-<x>` into `<x> = false`, so an option named `no-dead-paths`
322
+ // would be unreachable.
323
+ name: 'dead-paths',
324
+ description: 'Nominate entries citing paths that no longer resolve (--no-dead-paths to skip)',
325
+ type: 'boolean',
326
+ default: true,
327
+ },
328
+ {
329
+ name: 'recheck',
330
+ description: 'Re-examine entries that already carry a recorded verdict',
331
+ type: 'boolean',
332
+ default: false,
333
+ },
334
+ {
335
+ name: 'duplicate-threshold',
336
+ description: `Cosine similarity for near-duplicates (default ${DEFAULT_DUPLICATE_THRESHOLD})`,
337
+ type: 'number',
338
+ },
339
+ {
340
+ name: 'unused-min-age-days',
341
+ description: `Age floor before an unused entry is nominated (default ${DEFAULT_UNUSED_MIN_AGE_MS / 86_400_000})`,
342
+ type: 'number',
343
+ },
344
+ {
345
+ name: 'unused-limit',
346
+ description: `Max unused entries nominated (default ${DEFAULT_UNUSED_LIMIT})`,
347
+ type: 'number',
348
+ },
349
+ {
350
+ name: 'judge-limit',
351
+ description: `Max entries sent for a verdict (default ${DEFAULT_JUDGE_LIMIT})`,
352
+ type: 'number',
353
+ },
354
+ {
355
+ name: 'force',
356
+ short: 'f',
357
+ description: 'Skip the confirmation prompt on --apply',
358
+ type: 'boolean',
359
+ default: false,
360
+ },
361
+ ],
362
+ examples: [
363
+ { command: 'flo memory audit-learnings', description: 'Dry run — nominate, judge, and report' },
364
+ { command: 'flo memory audit-learnings --no-judge', description: 'Mechanical nominations only, no model call' },
365
+ { command: 'flo memory audit-learnings --no-dead-paths', description: 'Skip the path-resolution pass' },
366
+ { command: 'flo memory audit-learnings --apply', description: 'Archive the entries judged RETIRE' },
367
+ ],
368
+ action: async (ctx) => {
369
+ try {
370
+ return await runAudit(ctx);
371
+ }
372
+ catch (error) {
373
+ // Opening the store, reading it, or writing the verdict record can all
374
+ // throw. Report them as a failed command rather than an unhandled
375
+ // rejection that prints a stack trace over the plan the user just read.
376
+ output.printError(`audit-learnings failed: ${errorDetail(error)}`);
377
+ return { success: false, exitCode: 1 };
378
+ }
379
+ },
380
+ };
381
+ async function runAudit(ctx) {
382
+ const apply = ctx.flags.apply === true;
383
+ const judge = ctx.flags.judge !== false;
384
+ const recheck = ctx.flags.recheck === true;
385
+ const scanDeadPaths = ctx.flags.deadPaths !== false;
386
+ const force = ctx.flags.force === true;
387
+ const asJson = ctx.flags.format === 'json';
388
+ const projectRoot = findProjectRoot({ cwd: process.cwd() });
389
+ const dbPath = resolveBridgeDbPath(projectRoot);
390
+ if (!fs.existsSync(dbPath)) {
391
+ output.printError(`No memory store found at ${dbPath}. Run: flo memory init`);
392
+ return { success: false, exitCode: 1 };
393
+ }
394
+ // Always load the record: `--recheck` bypasses it for NOMINATION only.
395
+ // Starting from an empty map and writing that back would erase every prior
396
+ // verdict, making the next ordinary run re-nominate and re-pay for the whole
397
+ // judged set — the opposite of what a recheck is asking for.
398
+ const decided = readAuditState(projectRoot);
399
+ const decidedForPlan = recheck ? new Map() : decided;
400
+ let rows;
401
+ const db = openDaemonDatabase(dbPath);
402
+ try {
403
+ rows = readLearningsRows(db);
404
+ }
405
+ finally {
406
+ db.close();
407
+ }
408
+ const plan = buildAuditPlan(rows, {
409
+ duplicateThreshold: toPositiveNumber(ctx.flags.duplicateThreshold, DEFAULT_DUPLICATE_THRESHOLD),
410
+ unusedLimit: toPositiveNumber(ctx.flags.unusedLimit, DEFAULT_UNUSED_LIMIT),
411
+ judgeLimit: toPositiveNumber(ctx.flags.judgeLimit, DEFAULT_JUDGE_LIMIT),
412
+ decided: decidedForPlan,
413
+ unusedMinAgeMs: unusedMinAgeMs(ctx.flags.unusedMinAgeDays),
414
+ // Injected rather than reached for: the detector is pure, so the tree it
415
+ // resolves against is this layer's to supply. Omitted under
416
+ // `--no-dead-paths`, which is what makes the pass not run at all.
417
+ deadPaths: scanDeadPaths
418
+ ? { resolves: makeTreeResolver(projectRoot), workspacePrefixes: listWorkspacePrefixes(projectRoot) }
419
+ : undefined,
420
+ hashContent,
421
+ });
422
+ if (!asJson) {
423
+ if (!apply)
424
+ output.writeln(output.warning('DRY RUN - No changes will be made'));
425
+ printPlan(plan, scanDeadPaths);
426
+ printDeadPaths(plan);
427
+ }
428
+ // Report the number sent for a verdict on every path, including the paths
429
+ // that send none — "0 judged" and "judging skipped" are different answers
430
+ // and a reader has to be able to tell them apart.
431
+ let verdicts = new Map();
432
+ let judged = 0;
433
+ let judgeError;
434
+ if (!judge) {
435
+ judgeError = 'skipped (--no-judge)';
436
+ }
437
+ else if (plan.candidates.length === 0) {
438
+ judgeError = undefined;
439
+ }
440
+ else {
441
+ judged = plan.candidates.length;
442
+ if (!asJson) {
443
+ output.printInfo(`Requesting a verdict for ${judged} nominated entr${judged === 1 ? 'y' : 'ies'}…`);
444
+ }
445
+ const result = await runJudge(projectRoot, buildJudgePrompt(plan.candidates));
446
+ if (result.ok) {
447
+ verdicts = parseVerdicts(result.output, plan.candidates.map((c) => c.key));
448
+ }
449
+ else {
450
+ judgeError = result.error ?? 'the judge exited non-zero';
451
+ }
452
+ }
453
+ if (judgeError && judge && !asJson) {
454
+ output.printWarning(`No verdicts: ${judgeError}. Nominations above stand; nothing was archived.`);
455
+ }
456
+ if (!asJson && verdicts.size > 0)
457
+ printVerdicts(plan.candidates, verdicts);
458
+ const archivable = selectArchivable(plan.candidates, verdicts);
459
+ const manual = selectManualActions(plan.candidates, verdicts);
460
+ if (!asJson)
461
+ printManualActions(manual);
462
+ const summary = {
463
+ dryRun: !apply,
464
+ examined: plan.examined,
465
+ alreadyDecided: plan.alreadyDecided,
466
+ counts: plan.counts,
467
+ nominated: plan.candidates.length,
468
+ overflow: plan.overflow,
469
+ deadPathsScanned: scanDeadPaths,
470
+ deadPaths: plan.candidates
471
+ .filter((c) => (c.deadPaths?.length ?? 0) > 0)
472
+ .map((c) => ({ key: c.key, paths: c.deadPaths ?? [] })),
473
+ judged,
474
+ judgeSkipped: !judge,
475
+ judgeError,
476
+ verdicts: Object.fromEntries([...verdicts].map(([k, v]) => [k, v.verdict])),
477
+ archivable: archivable.map((c) => c.key),
478
+ archiveFailures: [],
479
+ manualActions: manual.map(({ candidate, verdict }) => ({ key: candidate.key, verdict })),
480
+ archived: 0,
481
+ };
482
+ if (!apply) {
483
+ if (asJson)
484
+ output.printJson(summary);
485
+ else if (archivable.length > 0) {
486
+ output.writeln();
487
+ output.printInfo(`Re-run with --apply to archive ${archivable.length} entr${archivable.length === 1 ? 'y' : 'ies'}.`);
488
+ }
489
+ return { success: true, data: summary };
490
+ }
491
+ if (verdicts.size === 0) {
492
+ // Without verdicts there is nothing to apply. Nominations are evidence,
493
+ // not decisions — archiving on them alone is exactly the age-based purge
494
+ // this command exists to replace.
495
+ const reason = 'No verdicts to apply — nominations alone are not a decision.';
496
+ if (asJson)
497
+ output.printJson({ ...summary, applied: false, reason });
498
+ else
499
+ output.printWarning(reason);
500
+ return { success: true, data: summary };
501
+ }
502
+ if (archivable.length > 0 && !force) {
503
+ // A prompt needs somewhere to read the answer from. Under `--format json`,
504
+ // in CI, or from a cron entry there is no terminal, and `confirm()` would
505
+ // block forever on a pipe that never sends a line — so say what is needed
506
+ // and decline instead of hanging.
507
+ const canPrompt = ctx.interactive && process.stdin.isTTY === true && !asJson;
508
+ if (!canPrompt) {
509
+ const pending = { ...summary, applied: false, reason: 'Confirmation required — re-run with --force.' };
510
+ if (asJson)
511
+ output.printJson(pending);
512
+ else
513
+ output.printWarning(`${pending.reason} (no interactive terminal to confirm on)`);
514
+ return { success: true, data: pending };
515
+ }
516
+ const confirmed = await confirm({
517
+ message: `Archive ${archivable.length} learning${archivable.length === 1 ? '' : 's'}?`,
518
+ default: false,
519
+ });
520
+ if (!confirmed) {
521
+ output.printInfo('Audit cancelled — nothing was archived');
522
+ return { success: true, data: summary };
523
+ }
524
+ }
525
+ // Every mutation goes through `deleteEntry`, never a direct handle on the
526
+ // store. This command runs in the foreground while the user's daemon is very
527
+ // likely holding `.moflo/moflo.db`, and a raw cross-process write there is
528
+ // exactly the single-writer violation epic #1054 exists to prevent — the
529
+ // whitelist classifies `durable-store-io` as `daemon-offline` for that
530
+ // reason, and this caller is not offline. Routing also means the archive
531
+ // semantics are inherited rather than restated: `deleteEntry` and
532
+ // `bridgeDeleteEntry` both send a durable namespace to `archiveDurableRow`,
533
+ // so a `learnings` delete already archives, keeps the row, and leaves the
534
+ // tombstone #1463's reconciler propagates.
535
+ //
536
+ // No `dbPath` is passed on purpose: supplying one is how a caller opts OUT
537
+ // of that routing, which would put the raw write straight back.
538
+ let archived = 0;
539
+ const archiveFailures = [];
540
+ for (const candidate of archivable) {
541
+ try {
542
+ const result = await deleteEntry({ key: candidate.key, namespace: LEARNINGS_NAMESPACE });
543
+ if (result?.deleted === true)
544
+ archived++;
545
+ else
546
+ archiveFailures.push(`${candidate.key}: ${result?.error ?? 'not deleted'}`);
547
+ }
548
+ catch (err) {
549
+ archiveFailures.push(`${candidate.key}: ${errorDetail(err)}`);
550
+ }
551
+ }
552
+ // Record every verdict, not just the archived ones. A KEEP that is not
553
+ // recorded is re-nominated by the same mechanical pass on the next run,
554
+ // which would make the audit permanently noisy instead of idempotent.
555
+ const now = Date.now();
556
+ const nextState = new Map(decided);
557
+ for (const candidate of plan.candidates) {
558
+ const v = verdicts.get(candidate.key);
559
+ if (!v)
560
+ continue;
561
+ nextState.set(candidate.key, { verdict: v.verdict, hash: hashContent(candidate.content), at: now });
562
+ }
563
+ writeAuditState(projectRoot, nextState);
564
+ summary.dryRun = false;
565
+ summary.archived = archived;
566
+ summary.archiveFailures = archiveFailures;
567
+ if (archiveFailures.length > 0 && !asJson) {
568
+ // Never let a partial apply read as a clean one: the verdict record below
569
+ // still marks these decided, so a silent failure would retire them from
570
+ // the audit's attention without ever removing them.
571
+ output.printWarning(`${archiveFailures.length} entr${archiveFailures.length === 1 ? 'y' : 'ies'} could not be archived:`);
572
+ output.printList(archiveFailures);
573
+ }
574
+ if (asJson) {
575
+ output.printJson({ ...summary, applied: true });
576
+ }
577
+ else {
578
+ output.writeln();
579
+ output.printSuccess(`Archived ${archived} learning${archived === 1 ? '' : 's'}`);
580
+ output.printList([
581
+ `Verdicts recorded: ${verdicts.size}`,
582
+ 'Archived entries are excluded from memory_search, flo memory list, and memory_stats.',
583
+ ]);
584
+ }
585
+ return { success: true, data: { ...summary, applied: true } };
586
+ }
587
+ //# sourceMappingURL=memory-audit-learnings.js.map