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.
- package/.claude/guidance/shipped/moflo-cli-reference.md +45 -1
- package/.claude/guidance/shipped/moflo-cross-install-memory-sharing.md +7 -2
- package/.claude/guidance/shipped/moflo-skills-reference.md +2 -0
- package/.claude/skills/fl/phases.md +51 -17
- package/.claude/skills/optimize-learnings/SKILL.md +220 -0
- package/README.md +95 -1
- package/bin/lib/get-backend.mjs +150 -12
- package/bin/lib/skill-categories.mjs +1 -0
- package/bin/session-start-launcher.mjs +13 -5
- package/dist/src/cli/commands/daemon.js +5 -2
- package/dist/src/cli/commands/epic.js +5 -1
- package/dist/src/cli/commands/hive-mind.js +6 -4
- package/dist/src/cli/commands/hooks.js +8 -8
- package/dist/src/cli/commands/index.js +5 -0
- package/dist/src/cli/commands/memory-audit-learnings.js +587 -0
- package/dist/src/cli/commands/memory.js +71 -10
- package/dist/src/cli/commands/spell-schedule.js +5 -3
- package/dist/src/cli/commands/worktree.js +408 -0
- package/dist/src/cli/config/moflo-config.js +57 -0
- package/dist/src/cli/index.js +4 -2
- package/dist/src/cli/init/executor.js +1 -0
- package/dist/src/cli/mcp-tools/memory-admin-tools.js +46 -8
- package/dist/src/cli/mcp-tools/moflodb-tools.js +30 -6
- package/dist/src/cli/memory/bridge-entries.js +157 -9
- package/dist/src/cli/memory/controllers/batch-operations.js +7 -2
- package/dist/src/cli/memory/daemon-backend.js +152 -11
- package/dist/src/cli/memory/entries-read.js +47 -2
- package/dist/src/cli/memory/entries-write.js +73 -10
- package/dist/src/cli/memory/hnsw-singleton.js +112 -9
- package/dist/src/cli/memory/learnings-audit.js +420 -0
- package/dist/src/cli/memory/learnings-dead-paths.js +202 -0
- package/dist/src/cli/memory/learnings-tree.js +187 -0
- package/dist/src/cli/memory/memory-bridge.js +37 -27
- package/dist/src/cli/memory/tool-call-markup.js +218 -0
- package/dist/src/cli/parser.js +7 -3
- package/dist/src/cli/services/cherry-pick-learnings.js +9 -3
- package/dist/src/cli/services/durable-reconcile.js +161 -0
- package/dist/src/cli/services/durable-store-io.js +291 -0
- package/dist/src/cli/services/durable-sync.js +159 -24
- package/dist/src/cli/services/team-artifact-sync.js +462 -163
- package/dist/src/cli/services/worktree-provision.js +400 -0
- package/dist/src/cli/version.js +1 -1
- 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
|