moflo 4.13.3 → 4.13.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -144,6 +144,13 @@ function emitWarning(message) {
144
144
  process.stderr.write(`moflo: ${message}\n`);
145
145
  } catch { /* stderr write must not throw */ }
146
146
  }
147
+ // Advisory stdout line for a condition the user can act on but the launcher
148
+ // deliberately did not change. Not counted in `mutationCount` — nothing mutated.
149
+ function emitNotice(message) {
150
+ try {
151
+ process.stdout.write(`moflo: ${message}\n`);
152
+ } catch { /* writing must never throw */ }
153
+ }
147
154
  function errMessage(err) {
148
155
  return err && err.message ? err.message : String(err);
149
156
  }
@@ -2444,6 +2451,14 @@ try {
2444
2451
  `${plural(result.stripped, 'row')} of run state removed from semantic search`,
2445
2452
  );
2446
2453
  }
2454
+ // Report-only (#1495): key shape is a heuristic, so these are never moved
2455
+ // automatically — the audit nominates them for a model verdict.
2456
+ if (result?.runSummaries > 0) {
2457
+ emitNotice(
2458
+ `${result.runSummaries} learning${result.runSummaries === 1 ? ' looks' : 's look'} like per-ticket run summaries, not lessons ` +
2459
+ '— review with `flo memory audit-learnings`',
2460
+ );
2461
+ }
2447
2462
  }
2448
2463
  } catch (err) {
2449
2464
  // Non-fatal — leftover rows just sit until the next session retries.
@@ -2527,6 +2542,12 @@ try {
2527
2542
  `${plural(result.flushedToShared, 'durable entry')} pushed to the shared store`,
2528
2543
  );
2529
2544
  }
2545
+ if (result?.healedShared > 0) {
2546
+ emitMutation(
2547
+ 'removed verify records from the shared store',
2548
+ `${plural(result.healedShared, 'row')} that are not learnings`,
2549
+ );
2550
+ }
2530
2551
  }
2531
2552
  } catch (err) {
2532
2553
  // Non-fatal — durable sync reconciles on the next session start.
@@ -23,7 +23,6 @@
23
23
  * @module commands/memory-audit-learnings
24
24
  */
25
25
  import * as fs from 'fs';
26
- import * as pathModule from 'path';
27
26
  import { spawn } from 'child_process';
28
27
  import { output } from '../output.js';
29
28
  import { confirm } from '../prompt.js';
@@ -33,14 +32,13 @@ import { openDaemonDatabase } from '../memory/daemon-backend.js';
33
32
  import { resolveBridgeDbPath } from '../memory/bridge-core.js';
34
33
  import { findProjectRoot } from '../services/project-root.js';
35
34
  import { hasMemoryEntriesTable } from '../services/cherry-pick-learnings.js';
36
- import { atomicWriteFileSync } from '../shared/utils/atomic-file-write.js';
37
35
  import { hashContent } from '../memory/auto-memory-bridge.js';
38
36
  import { listWorkspacePrefixes, makeTreeResolver } from '../memory/learnings-tree.js';
39
37
  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;
38
+ // The verdict record moved to `memory/learnings-audit-state.ts` so the
39
+ // session-start purge can read it (#1495); re-exported for existing callers.
40
+ export { AUDIT_STATE_FILE, readAuditState, writeAuditState } from '../memory/learnings-audit-state.js';
41
+ import { readAuditState, writeAuditState } from '../memory/learnings-audit-state.js';
44
42
  /** Cheap formatter/judge model — same tier the auto-meditate distill runs on. */
45
43
  const JUDGE_MODEL_ID = 'claude-haiku-4-5-20251001';
46
44
  /** Hard ceiling on the headless judge; killed past this. */
@@ -54,39 +52,6 @@ const JUDGE_ALLOWED_TOOLS = 'Read';
54
52
  * Claude CLI on PATH.
55
53
  */
56
54
  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
55
  /**
91
56
  * Parse a stored embedding. A malformed vector reads as absent rather than
92
57
  * throwing — one bad row must not take the whole audit down.
@@ -222,6 +187,7 @@ function printPlan(plan, deadPathsScanned) {
222
187
  { bucket: 'Unused and old', count: plan.counts.unused },
223
188
  { bucket: 'Superseded vocabulary', count: plan.counts.superseded },
224
189
  { bucket: 'Dead path reference', count: plan.counts.deadPath },
190
+ { bucket: 'Run summary (ticket-shaped key)', count: plan.counts.runSummary },
225
191
  { bucket: output.bold('To judge'), count: output.bold(String(plan.candidates.length)) },
226
192
  ],
227
193
  });
@@ -2541,6 +2541,8 @@ const teamExportCommand = {
2541
2541
  changes.push(`${report.deleted} retired`);
2542
2542
  if (report.resurrected > 0)
2543
2543
  changes.push(`${report.resurrected} restored`);
2544
+ if (report.droppedMisfiled > 0)
2545
+ changes.push(`${report.droppedMisfiled} verify record${report.droppedMisfiled === 1 ? '' : 's'} removed (not learnings, #1495)`);
2544
2546
  if (changes.length > 0)
2545
2547
  output.printInfo(`Also propagated: ${changes.join(', ')}.`);
2546
2548
  if (report.keptRemote > 0) {
@@ -2616,6 +2618,9 @@ const teamImportCommand = {
2616
2618
  if (report.skippedCorrupt > 0) {
2617
2619
  output.printWarning(`${report.skippedCorrupt} artifact line${report.skippedCorrupt === 1 ? '' : 's'} NOT imported — captured tool-call markup in the content (#1467).`);
2618
2620
  }
2621
+ if (report.skippedMisfiled > 0) {
2622
+ output.printInfo(`${report.skippedMisfiled} verify record${report.skippedMisfiled === 1 ? '' : 's'} in the artifact ignored (not learnings, #1495) — \`flo memory team-export\` removes them.`);
2623
+ }
2619
2624
  if (report.skippedNonDurable > 0) {
2620
2625
  output.printWarning(`${report.skippedNonDurable} non-durable entr${report.skippedNonDurable === 1 ? 'y' : 'ies'} skipped (only learnings/knowledge are shared).`);
2621
2626
  }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The verdict record `flo memory audit-learnings --apply` keeps between runs
3
+ * (#1466), at `.moflo/learnings-audit.json`.
4
+ *
5
+ * Carved out of the command so the session-start purge can read it without
6
+ * importing CLI command machinery: the purge's run-summary count (#1495) skips
7
+ * keys a human already judged, so the notice clears once the store is curated.
8
+ *
9
+ * Local-only — never part of the shared artifact.
10
+ *
11
+ * @module memory/learnings-audit-state
12
+ */
13
+ import * as fs from 'fs';
14
+ import * as path from 'path';
15
+ import { atomicWriteFileSync } from '../shared/utils/atomic-file-write.js';
16
+ /** Where recorded verdicts live, under `.moflo/`. */
17
+ export const AUDIT_STATE_FILE = 'learnings-audit.json';
18
+ /** Bump when the record shape changes; an older file is discarded, not migrated. */
19
+ const AUDIT_STATE_VERSION = 1;
20
+ function stateFilePath(projectRoot) {
21
+ return path.join(projectRoot, '.moflo', AUDIT_STATE_FILE);
22
+ }
23
+ /** Read recorded verdicts. Any unreadable or stale-version file reads as empty. */
24
+ export function readAuditState(projectRoot) {
25
+ try {
26
+ const raw = fs.readFileSync(stateFilePath(projectRoot), 'utf-8');
27
+ const parsed = JSON.parse(raw);
28
+ if (parsed?.version !== AUDIT_STATE_VERSION || !parsed.decided)
29
+ return new Map();
30
+ return new Map(Object.entries(parsed.decided));
31
+ }
32
+ catch {
33
+ // Absent, truncated, or hand-edited into invalid JSON. Losing the record
34
+ // costs one re-judgement; refusing to run over it costs the command.
35
+ return new Map();
36
+ }
37
+ }
38
+ /**
39
+ * Write the verdict record back.
40
+ *
41
+ * Read-modify-write with no lock: two `--apply` runs racing on the same project
42
+ * would lose one run's verdicts. Not worth a lock — this is a hand-invoked
43
+ * curation command, the loss costs one re-judgement, and `atomicWriteFileSync`
44
+ * already rules out a torn file (its temp name is pid- and random-suffixed, so
45
+ * concurrent writers cannot clobber each other's staging file either).
46
+ */
47
+ export function writeAuditState(projectRoot, decided) {
48
+ const file = stateFilePath(projectRoot);
49
+ fs.mkdirSync(path.dirname(file), { recursive: true });
50
+ const payload = { version: AUDIT_STATE_VERSION, decided: Object.fromEntries(decided) };
51
+ atomicWriteFileSync(file, `${JSON.stringify(payload, null, 2)}\n`);
52
+ }
53
+ //# sourceMappingURL=learnings-audit-state.js.map
@@ -33,9 +33,10 @@
33
33
  // audit's public surface — a caller configuring the pass should not have to
34
34
  // know which file the detector was carved into.
35
35
  import { findDeadPaths } from './learnings-dead-paths.js';
36
+ import { isRunSummaryKey, LEARNINGS_NAMESPACE } from '../services/durable-key-rules.js';
36
37
  export { DEFAULT_DEAD_PATHS_PER_ENTRY, extractCandidatePaths, findDeadPaths, resolvesInTree, } from './learnings-dead-paths.js';
37
38
  /** The namespace this audit is scoped to. */
38
- export const LEARNINGS_NAMESPACE = 'learnings';
39
+ export { LEARNINGS_NAMESPACE };
39
40
  export const AUDIT_VERDICTS = ['KEEP', 'RETIRE', 'COMPRESS', 'MERGE'];
40
41
  /**
41
42
  * Retired vocabulary. **Ships empty, and a guard test keeps it that way.**
@@ -238,11 +239,18 @@ export function buildAuditPlan(rows, options = {}) {
238
239
  const candidate = nominate(hit.row, 'dead-path');
239
240
  candidate.deadPaths = hit.deadPaths;
240
241
  }
242
+ // A run summary is git history stored as a learning (#1495). The key shape
243
+ // only nominates — a lesson can carry its ticket number as provenance, which
244
+ // is the case the model verdict tells apart.
245
+ const runSummaries = pending.filter((row) => isRunSummaryKey(row.key));
246
+ for (const row of runSummaries)
247
+ nominate(row, 'run-summary');
241
248
  const counts = {
242
249
  duplicate: duplicates.length,
243
250
  unused: unused.length,
244
251
  superseded: superseded.length,
245
252
  deadPath: deadPaths.length,
253
+ runSummary: runSummaries.length,
246
254
  };
247
255
  // Most-nominated first, then oldest — an entry three passes agree on is the
248
256
  // one a bounded prompt should spend its budget on.
@@ -273,6 +281,9 @@ function describeBuckets(candidate) {
273
281
  else if (bucket === 'dead-path') {
274
282
  parts.push(`cites path(s) that resolve nowhere in the tree: ${(candidate.deadPaths ?? []).join(', ')}`);
275
283
  }
284
+ else if (bucket === 'run-summary') {
285
+ parts.push('key is shaped like a per-ticket run summary');
286
+ }
276
287
  else {
277
288
  const terms = (candidate.supersededTerms ?? [])
278
289
  .map((t) => `"${t.from}" → "${t.to}"`)
@@ -339,6 +350,10 @@ export function buildJudgePrompt(candidates, now = Date.now()) {
339
350
  '| Deleted, but the lesson generalises past it | COMPRESS — drop the path, keep the rule |',
340
351
  '| The entry is a historical record, correct as written | KEEP |',
341
352
  '',
353
+ 'A run-summary flag means the key looks like a ticket record ("flo-123-...", "...-done").',
354
+ 'What one ticket changed is git history, not a lesson: RETIRE it unless the content states a',
355
+ 'rule that would help a DIFFERENT task, in which case COMPRESS it down to that rule.',
356
+ '',
342
357
  `Answer with exactly ${candidates.length} line(s), nothing else. One line per entry:`,
343
358
  '',
344
359
  '<key><TAB><VERDICT><TAB><reason in under 15 words>',
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Rows that must never live in a durable namespace, whichever store they are in
3
+ * (#1495).
4
+ *
5
+ * #1375 moved `verify:*` verdict records out of `learnings`, but only in the
6
+ * local `.moflo/moflo.db`. The same rows still sat in the two durable copies —
7
+ * the worktree shared store (`<git-common-dir>/moflo/durable.db`) and the team
8
+ * JSONL artifact — and the session-start seed and import wrote them straight
9
+ * back. The cleanup ran on every session and never converged, because each
10
+ * store was healed by its own code path and none of the sync paths knew the
11
+ * rule.
12
+ *
13
+ * This module is the ONE statement of the rule. The sync boundary applies it
14
+ * (`readDurableSnapshot` never returns a misfiled row, so no flush, seed,
15
+ * import or export can carry one), the shared store and artifact drop what they
16
+ * already hold, and the local purge relocates or drops the local copy. A new
17
+ * relocation rule is a new entry in {@link MISFILED_DURABLE_RULES} — never a
18
+ * new clause in one store's cleanup, which is the shape that failed.
19
+ *
20
+ * Pure: no fs, no sqlite.
21
+ *
22
+ * @module cli/services/durable-key-rules
23
+ */
24
+ import { VERIFY_RECORD_NAMESPACE } from '../memory/bridge-embedder.js';
25
+ /** The durable namespace user-authored lessons live in. */
26
+ export const LEARNINGS_NAMESPACE = 'learnings';
27
+ /** Every rule. `/verify` verdicts used to be written to `learnings` (#1375). */
28
+ export const MISFILED_DURABLE_RULES = [
29
+ { namespace: LEARNINGS_NAMESPACE, keyPrefix: `${VERIFY_RECORD_NAMESPACE}:`, relocateTo: VERIFY_RECORD_NAMESPACE },
30
+ ];
31
+ /** True when `(namespace, key)` is a row no durable store should hold. */
32
+ export function isMisfiledDurable(namespace, key) {
33
+ return MISFILED_DURABLE_RULES.some((r) => r.namespace === namespace && key.startsWith(r.keyPrefix));
34
+ }
35
+ /** SQL predicate matching the rows of ONE rule — the only place its shape is spelled. */
36
+ export function misfiledRuleSql(rule) {
37
+ return { sql: '(namespace = ? AND key GLOB ?)', params: [rule.namespace, `${rule.keyPrefix}*`] };
38
+ }
39
+ /**
40
+ * SQL predicate matching every misfiled row, with its bindings. Callers negate
41
+ * it (`NOT (...)`) to read a clean durable slice, or use it as-is to delete.
42
+ */
43
+ export function misfiledDurableSql() {
44
+ if (MISFILED_DURABLE_RULES.length === 0)
45
+ return { sql: '0', params: [] };
46
+ const parts = MISFILED_DURABLE_RULES.map(misfiledRuleSql);
47
+ return { sql: `(${parts.map((p) => p.sql).join(' OR ')})`, params: parts.flatMap((p) => p.params) };
48
+ }
49
+ /**
50
+ * Key shapes of a per-ticket run summary stored as a learning: `flo-2595-...`,
51
+ * `2679-...`, or a `-done`/`-complete`/`-shipped`/`-merged` suffix.
52
+ *
53
+ * A heuristic on key shape only, which is why nothing moves on it
54
+ * automatically: the session start COUNTS matches, and
55
+ * `flo memory audit-learnings` nominates them for a verdict. Content is not
56
+ * consulted — a real lesson can open with a ticket number as provenance
57
+ * (`1145-daemon-port-collision-fix`), and that is exactly the case a model
58
+ * verdict exists to tell apart.
59
+ */
60
+ const TICKET_PREFIX = /^(?:flo-)?\d{3,5}-/;
61
+ const STATUS_SUFFIX = /-(?:done|complete|completed|shipped|merged)$/;
62
+ /** A date-prefixed key (`2026-09-28-...`) is a dated note, not a ticket number. */
63
+ const DATE_PREFIX = /^\d{4}-\d{2}-\d{2}(?:\D|$)/;
64
+ /** True when a `learnings` key looks like a per-ticket run summary. */
65
+ export function isRunSummaryKey(key) {
66
+ if (STATUS_SUFFIX.test(key))
67
+ return true;
68
+ return TICKET_PREFIX.test(key) && !DATE_PREFIX.test(key);
69
+ }
70
+ //# sourceMappingURL=durable-key-rules.js.map
@@ -37,13 +37,20 @@ import { DURABLE_NAMESPACES, DURABLE_INSERT_OR_IGNORE_SQL, DURABLE_ROW_COLUMNS,
37
37
  // rather than each assembling its own copy of the retire-a-durable-row rule.
38
38
  export { isDurableNamespace };
39
39
  import { reconcileId, } from './durable-reconcile.js';
40
+ import { misfiledDurableSql } from './durable-key-rules.js';
40
41
  /** The `status` value that marks a durable row as deleted-but-propagatable. */
41
42
  export const ARCHIVED_STATUS = 'archived';
42
43
  // Same column list as the shared INSERT, so a future column can never be added
43
44
  // to one half of the round trip only. The status filter is what differs from
44
45
  // the legacy cherry-pick read: archived rows are the deletions we must carry.
46
+ //
47
+ // Misfiled rows (#1495) are excluded HERE, at the one read every sync direction
48
+ // shares, so none of them — flush, seed, artifact import, artifact export — can
49
+ // carry a `verify:*` record in either state. Filtering per call site is what let
50
+ // the local cleanup be undone by the next seed.
45
51
  const selectDurableSql = (placeholders, columns, byKey) => `SELECT ${columns} FROM memory_entries ` +
46
- `WHERE namespace IN (${placeholders}) AND status IN ('active', '${ARCHIVED_STATUS}')` +
52
+ `WHERE namespace IN (${placeholders}) AND status IN ('active', '${ARCHIVED_STATUS}') ` +
53
+ `AND NOT ${misfiledDurableSql().sql}` +
47
54
  (byKey ? ` AND key = ?` : '');
48
55
  /**
49
56
  * The columns the merge rule alone needs. A comparison never looks at the
@@ -69,7 +76,8 @@ export function readDurableSnapshot(db, namespaces = DURABLE_NAMESPACES, opts =
69
76
  const placeholders = namespaces.map(() => '?').join(',');
70
77
  const stmt = db.prepare(selectDurableSql(placeholders, withPayloads ? DURABLE_ROW_COLUMNS : RECORD_ONLY_COLUMNS, opts.key != null));
71
78
  try {
72
- stmt.bind(opts.key != null ? [...namespaces, opts.key] : namespaces.slice());
79
+ const misfiled = misfiledDurableSql().params;
80
+ stmt.bind(opts.key != null ? [...namespaces, ...misfiled, opts.key] : [...namespaces, ...misfiled]);
73
81
  while (stmt.step()) {
74
82
  const row = stmt.getAsObject();
75
83
  const namespace = String(row.namespace);
@@ -288,4 +296,26 @@ export function pruneExpiredArchives(db, now, ttlMs, namespaces = DURABLE_NAMESP
288
296
  `AND namespace IN (${placeholders}) AND updated_at < ?`, [...namespaces, now - ttlMs]);
289
297
  return db.getRowsModified();
290
298
  }
299
+ /**
300
+ * Hard-delete every misfiled row (#1495) from a DURABLE TRANSFER store — the
301
+ * worktree shared store, never a project's own `.moflo/moflo.db` (the local
302
+ * purge relocates those instead, since the local copy is the one worth keeping).
303
+ *
304
+ * A shared store holds only durable namespaces, so a `verify:*` record there has
305
+ * no home to move to, and a tombstone for it would be pointless: the snapshot
306
+ * read already ignores the key in every store. Deleting is what makes the store
307
+ * shrink rather than carry the rows forever. Returns rows removed.
308
+ */
309
+ export function deleteMisfiledDurableRows(db) {
310
+ if (!hasMemoryEntriesTable(db))
311
+ return 0;
312
+ const { sql, params } = misfiledDurableSql();
313
+ // Probe first: the steady state is a clean store, and a DELETE would open a
314
+ // write transaction on every session start for nothing.
315
+ const probe = db.exec(`SELECT 1 FROM memory_entries WHERE ${sql} LIMIT 1`, params);
316
+ if (!probe[0]?.values?.[0])
317
+ return 0;
318
+ db.run(`DELETE FROM memory_entries WHERE ${sql}`, params);
319
+ return db.getRowsModified();
320
+ }
291
321
  //# sourceMappingURL=durable-store-io.js.map
@@ -53,7 +53,7 @@ import { loadMofloConfig } from '../config/moflo-config.js';
53
53
  import { openDaemonDatabase } from '../memory/daemon-backend.js';
54
54
  import { CHERRY_PICK_SKIP_REASONS, DURABLE_NAMESPACES, isDurableNamespace, } from './cherry-pick-learnings.js';
55
55
  import { planReconcile, TOMBSTONE_TTL_MS } from './durable-reconcile.js';
56
- import { readDurableSnapshot, applyDurableActions, pruneExpiredArchives } from './durable-store-io.js';
56
+ import { readDurableSnapshot, applyDurableActions, pruneExpiredArchives, deleteMisfiledDurableRows, } from './durable-store-io.js';
57
57
  export { isDurableNamespace };
58
58
  /**
59
59
  * Read `<projectRoot>/.git` ONCE and classify the checkout for worktree sharing,
@@ -249,10 +249,10 @@ export function reconcileDurableStores(sourcePath, targetPath, opts = {}) {
249
249
  return result;
250
250
  }
251
251
  /**
252
- * Apply the archive retention window to one store. Best-effort: a missing or
253
- * unopenable store is "nothing to prune", never an error that fails a sync.
252
+ * Run one best-effort maintenance write against a store. A missing or
253
+ * unopenable store is "nothing to do", never an error that fails a sync.
254
254
  */
255
- function pruneStore(dbPath) {
255
+ function maintainStore(dbPath, op) {
256
256
  if (!fs.existsSync(dbPath))
257
257
  return 0;
258
258
  let db;
@@ -263,7 +263,7 @@ function pruneStore(dbPath) {
263
263
  return 0;
264
264
  }
265
265
  try {
266
- return pruneExpiredArchives(db, Date.now(), TOMBSTONE_TTL_MS);
266
+ return op(db);
267
267
  }
268
268
  catch {
269
269
  return 0;
@@ -272,6 +272,8 @@ function pruneStore(dbPath) {
272
272
  db.close();
273
273
  }
274
274
  }
275
+ /** Apply the archive retention window to one store. */
276
+ const pruneStore = (dbPath) => maintainStore(dbPath, (db) => pruneExpiredArchives(db, Date.now(), TOMBSTONE_TTL_MS));
275
277
  /** Total rows a direction actually changed — what the launcher reports. */
276
278
  export function changedRows(result) {
277
279
  return result.copied + result.updated + result.archived + result.resurrected;
@@ -331,6 +333,7 @@ export async function syncDurableAtSessionStart(opts = {}) {
331
333
  skipped,
332
334
  flushedToShared: 0,
333
335
  seededToLocal: 0,
336
+ healedShared: 0,
334
337
  prunedArchives: pruneStore(memoryDbPath(projectRoot)),
335
338
  };
336
339
  }
@@ -345,12 +348,24 @@ export async function syncDurableAtSessionStart(opts = {}) {
345
348
  // flush re-inserts the purged entry into every workspace. Running the seed
346
349
  // first means this store has applied the deletion before anything drops the
347
350
  // evidence for it.
348
- const pruned = pruneStore(memoryDbPath(projectRoot)) + pruneStore(durablePath);
351
+ //
352
+ // The shared store also sheds misfiled rows (#1495) in the same open — it is
353
+ // the store sibling worktrees contend on, so a second open is a second lock
354
+ // wait. Neither direction above can move such a row (the snapshot read
355
+ // excludes them), so this only shrinks the store. The local copy is the
356
+ // purge's job, which relocates rather than deletes.
357
+ let healedShared = 0;
358
+ const pruned = pruneStore(memoryDbPath(projectRoot)) +
359
+ maintainStore(durablePath, (db) => {
360
+ healedShared = deleteMisfiledDurableRows(db);
361
+ return pruneExpiredArchives(db, Date.now(), TOMBSTONE_TTL_MS);
362
+ });
349
363
  return {
350
364
  durablePath,
351
365
  autoWorktree: autoWorktree ?? false,
352
366
  flushedToShared: changedRows(flush),
353
367
  seededToLocal: changedRows(seed),
368
+ healedShared,
354
369
  prunedArchives: pruned,
355
370
  };
356
371
  }
@@ -36,6 +36,16 @@
36
36
  * shape the writer produced. The next index chain drops them from the HNSW
37
37
  * sidecar (the DB write invalidates the `hnsw-rebuild` fingerprint).
38
38
  *
39
+ * 5. **Count run summaries** (#1495) — active `learnings` rows whose key is
40
+ * shaped like a per-ticket run summary ({@link isRunSummaryKey}). Report
41
+ * only: key shape is a heuristic, so nothing moves on it here.
42
+ * `flo memory audit-learnings` nominates them for a verdict instead.
43
+ *
44
+ * The relocation in pass 2 is one of three halves of the #1495 heal: the
45
+ * durable sync never reads a misfiled row (so the shared store and the team
46
+ * artifact cannot seed it back), and each of those stores drops its own copy.
47
+ * Healing this DB alone is what #1375 did, and the next seed undid it.
48
+ *
39
49
  * All passes share the file open + final VACUUM + atomic write, so disk I/O
40
50
  * is the same as before. Writes back to disk only when something changed.
41
51
  *
@@ -50,10 +60,10 @@
50
60
  /* eslint-disable @typescript-eslint/no-explicit-any */
51
61
  import { ephemeralNamespaceSql, namespaceMatchSql, PURGE_ON_SESSION_START_NAMESPACES, PURGE_ON_SESSION_START_PREFIXES, TASKLIST_RETENTION_CAP, VERIFY_RECORD_NAMESPACE, VERIFY_RETENTION_CAP, } from '../memory/bridge-embedder.js';
52
62
  import { memoryDbPath } from './moflo-paths.js';
63
+ import { isRunSummaryKey, LEARNINGS_NAMESPACE, MISFILED_DURABLE_RULES, misfiledDurableSql, misfiledRuleSql, } from './durable-key-rules.js';
64
+ import { readAuditState } from '../memory/learnings-audit-state.js';
53
65
  import { openDaemonDatabase } from '../memory/daemon-backend.js';
54
66
  import { resolveStateRoot } from './project-root.js';
55
- /** Namespace stray verdict records are re-filed OUT of (#1375). */
56
- const LEARNINGS_NAMESPACE = 'learnings';
57
67
  /**
58
68
  * Hard-delete rows in {@link PURGE_ON_SESSION_START_NAMESPACES}, relocate
59
69
  * stray `verify:*` records out of `learnings`, and trim the retention-capped
@@ -66,9 +76,10 @@ export async function purgeEphemeralNamespaces(options = {}) {
66
76
  const fs = await import('fs');
67
77
  const path = await import('path');
68
78
  const nothingToDo = {
69
- purged: 0, trimmed: 0, relocated: 0, superseded: 0, stripped: 0,
79
+ purged: 0, trimmed: 0, relocated: 0, superseded: 0, stripped: 0, runSummaries: 0,
70
80
  };
71
- const dbPath = path.resolve(options.dbPath ?? memoryDbPath(resolveStateRoot()));
81
+ const stateRoot = options.dbPath ? options.projectRoot : (options.projectRoot ?? resolveStateRoot());
82
+ const dbPath = path.resolve(options.dbPath ?? memoryDbPath(options.projectRoot ?? resolveStateRoot()));
72
83
  if (!fs.existsSync(dbPath))
73
84
  return nothingToDo;
74
85
  // node:sqlite via the unified factory (Phase 5 / #1084). WAL persists each
@@ -92,15 +103,13 @@ export async function purgeEphemeralNamespaces(options = {}) {
92
103
  [VERIFY_RECORD_NAMESPACE, options.verifyRetentionCap ?? VERIFY_RETENTION_CAP],
93
104
  ];
94
105
  const { sql: purgeWhere, params: purgeBindings } = namespaceMatchSql(PURGE_ON_SESSION_START_NAMESPACES, PURGE_ON_SESSION_START_PREFIXES);
106
+ // The misfiled-row rule is shared with the durable sync (#1495), so this
107
+ // pass and the stores it cannot reach agree on exactly which rows move.
95
108
  // GLOB, not LIKE: SQLite's LIKE is case-INSENSITIVE for ASCII, so
96
109
  // `key LIKE 'verify:%'` would also sweep a `Verify:...` row that
97
110
  // `gate.cjs`'s `record-verify-outcome` — which tests
98
- // `key.indexOf('verify:') !== 0` — would never have credited. GLOB is
99
- // case-sensitive, so this matches exactly the keys /verify writes and the
100
- // gate recognises. Neither `*`, `?` nor `[` appears in the literal prefix,
101
- // so the only wildcard in the pattern is the trailing `*`.
102
- const strayVerifyWhere = 'namespace = ? AND key GLOB ?';
103
- const strayVerifyBindings = [LEARNINGS_NAMESPACE, `${VERIFY_RECORD_NAMESPACE}:*`];
111
+ // `key.indexOf('verify:') !== 0` — would never have credited.
112
+ const misfiled = misfiledDurableSql();
104
113
  const ephemeral = ephemeralNamespaceSql();
105
114
  const embeddedEphemeralWhere = `${ephemeral.sql} AND embedding IS NOT NULL`;
106
115
  // EVERY column needs a distinct alias. `exec` maps each row to an object
@@ -111,9 +120,9 @@ export async function purgeEphemeralNamespaces(options = {}) {
111
120
  // trims then read a total that isn't theirs.
112
121
  const countRows = db.exec(`SELECT
113
122
  (SELECT COUNT(*) FROM memory_entries WHERE ${purgeWhere}) AS purgeable,
114
- (SELECT COUNT(*) FROM memory_entries WHERE ${strayVerifyWhere}) AS relocatable,
123
+ (SELECT COUNT(*) FROM memory_entries WHERE ${misfiled.sql}) AS relocatable,
115
124
  (SELECT COUNT(*) FROM memory_entries WHERE ${embeddedEphemeralWhere}) AS strippable,
116
- ${caps.map((_, i) => `(SELECT COUNT(*) FROM memory_entries WHERE namespace = ?) AS capTotal${i}`).join(',\n ')}`, [...purgeBindings, ...strayVerifyBindings, ...ephemeral.params, ...caps.map(([ns]) => ns)]);
125
+ ${caps.map((_, i) => `(SELECT COUNT(*) FROM memory_entries WHERE namespace = ?) AS capTotal${i}`).join(',\n ')}`, [...purgeBindings, ...misfiled.params, ...ephemeral.params, ...caps.map(([ns]) => ns)]);
117
126
  const counts = countRows[0]?.values?.[0] ?? [];
118
127
  const purgeable = Number(counts[0] ?? 0);
119
128
  const relocatable = Number(counts[1] ?? 0);
@@ -126,22 +135,27 @@ export async function purgeEphemeralNamespaces(options = {}) {
126
135
  }
127
136
  let relocated = 0;
128
137
  let superseded = 0;
129
- if (relocatable > 0) {
138
+ let relocatedIntoVerify = 0;
139
+ for (const rule of relocatable > 0 ? MISFILED_DURABLE_RULES : []) {
140
+ const { sql: ruleWhere, params: ruleBindings } = misfiledRuleSql(rule);
130
141
  // UNIQUE(namespace, key): a stray row whose key already exists in the
131
142
  // target cannot simply move — the UPDATE would violate the constraint,
132
143
  // and `UPDATE OR REPLACE` would clobber the newer record with the older.
133
- // Drop the `learnings` copy instead, and COUNT it: this is the one place
134
- // the pass destroys a row rather than re-filing it, so it must surface in
135
- // the result rather than hide inside `relocated`.
144
+ // Drop the stray copy instead, and COUNT it: this is the one place the
145
+ // pass destroys a row rather than re-filing it, so it must surface in the
146
+ // result rather than hide inside `relocated`.
136
147
  db.run(`DELETE FROM memory_entries
137
- WHERE ${strayVerifyWhere}
138
- AND key IN (SELECT key FROM memory_entries WHERE namespace = ?)`, [...strayVerifyBindings, VERIFY_RECORD_NAMESPACE]);
139
- superseded = db.getRowsModified?.() ?? 0;
148
+ WHERE ${ruleWhere}
149
+ AND key IN (SELECT key FROM memory_entries WHERE namespace = ?)`, [...ruleBindings, rule.relocateTo]);
150
+ superseded += db.getRowsModified?.() ?? 0;
140
151
  // Content is unchanged, so the row's existing embedding stays valid: the
141
152
  // HNSW sidecar is keyed by row id and re-reads `namespace` from SQL on
142
153
  // every index build, so this is a re-filing, not a re-index.
143
- db.run(`UPDATE memory_entries SET namespace = ? WHERE ${strayVerifyWhere}`, [VERIFY_RECORD_NAMESPACE, ...strayVerifyBindings]);
144
- relocated = db.getRowsModified?.() ?? 0;
154
+ db.run(`UPDATE memory_entries SET namespace = ? WHERE ${ruleWhere}`, [rule.relocateTo, ...ruleBindings]);
155
+ const moved = db.getRowsModified?.() ?? 0;
156
+ relocated += moved;
157
+ if (rule.relocateTo === VERIFY_RECORD_NAMESPACE)
158
+ relocatedIntoVerify += moved;
145
159
  }
146
160
  // After the purge, so rows it already deleted are not counted twice. Model
147
161
  // goes back to NULL — the exact shape the ephemeral writers produce.
@@ -155,7 +169,7 @@ export async function purgeEphemeralNamespaces(options = {}) {
155
169
  let trimmed = 0;
156
170
  for (const [i, [ns, cap]] of caps.entries()) {
157
171
  // Rows just relocated into `verify` count toward its cap in this same run.
158
- const total = capTotals[i] + (ns === VERIFY_RECORD_NAMESPACE ? relocated : 0);
172
+ const total = capTotals[i] + (ns === VERIFY_RECORD_NAMESPACE ? relocatedIntoVerify : 0);
159
173
  if (total <= cap)
160
174
  continue;
161
175
  // Keep the newest `cap` rows by created_at, falling back to `id DESC`
@@ -170,8 +184,9 @@ export async function purgeEphemeralNamespaces(options = {}) {
170
184
  )`, [ns, ns, cap]);
171
185
  trimmed += db.getRowsModified?.() ?? 0;
172
186
  }
187
+ const runSummaries = countRunSummaries(db, stateRoot);
173
188
  if (purged === 0 && trimmed === 0 && relocated === 0 && superseded === 0 && stripped === 0) {
174
- return nothingToDo;
189
+ return { ...nothingToDo, runSummaries };
175
190
  }
176
191
  // VACUUM only after a DELETE actually freed pages. A relocation is an
177
192
  // UPDATE that reclaims nothing; a vector strip frees some pages, but later
@@ -181,12 +196,34 @@ export async function purgeEphemeralNamespaces(options = {}) {
181
196
  // both auto-commit each `db.run`, so this is safe to chain.
182
197
  if (purged > 0 || trimmed > 0 || superseded > 0)
183
198
  db.run('VACUUM');
184
- return { purged, trimmed, relocated, superseded, stripped };
199
+ return { purged, trimmed, relocated, superseded, stripped, runSummaries };
185
200
  }
186
201
  finally {
187
202
  db.close();
188
203
  }
189
204
  }
205
+ /**
206
+ * Count active `learnings` rows keyed like a per-ticket run summary. Keys only
207
+ * — a single indexed read of short strings, cheap on a store of thousands.
208
+ *
209
+ * Keys `flo memory audit-learnings` already judged are skipped: a KEEP is a
210
+ * human saying "this one is a lesson", and a notice that ignored it would print
211
+ * on every session forever.
212
+ */
213
+ function countRunSummaries(db, projectRoot) {
214
+ const rows = db.exec(`SELECT key FROM memory_entries WHERE namespace = ? AND status = 'active'`, [LEARNINGS_NAMESPACE]);
215
+ let decided = null;
216
+ let count = 0;
217
+ for (const [raw] of rows[0]?.values ?? []) {
218
+ const key = String(raw);
219
+ if (!isRunSummaryKey(key))
220
+ continue;
221
+ decided ??= projectRoot ? readAuditState(projectRoot) : new Map();
222
+ if (!decided.has(key))
223
+ count++;
224
+ }
225
+ return count;
226
+ }
190
227
  /**
191
228
  * Hard-delete rows whose namespace matches one of
192
229
  * {@link PURGE_ON_SESSION_START_PREFIXES} — currently `doctor-memprobe-*`
@@ -269,7 +269,7 @@ export async function restoreSnapshot(options) {
269
269
  let purged = 0;
270
270
  try {
271
271
  const { purgeEphemeralNamespaces } = await import('./ephemeral-namespace-purge.js');
272
- const result = await purgeEphemeralNamespaces({ dbPath: target });
272
+ const result = await purgeEphemeralNamespaces({ dbPath: target, projectRoot });
273
273
  purged = (result?.purged ?? 0) + (result?.trimmed ?? 0);
274
274
  }
275
275
  catch {
@@ -72,6 +72,7 @@ import { isDurableNamespace } from './cherry-pick-learnings.js';
72
72
  import { planReconcile, reconcileId, splitReconcileId, isPrunableTombstone, recordStamp, TOMBSTONE_TTL_MS, } from './durable-reconcile.js';
73
73
  import { readDurableSnapshot, applyDurableActions, } from './durable-store-io.js';
74
74
  import { detectToolCallMarkup } from '../memory/tool-call-markup.js';
75
+ import { isMisfiledDurable } from './durable-key-rules.js';
75
76
  /** Allowed `type` values — the schema CHECK set. An out-of-set value would make
76
77
  * INSERT OR IGNORE silently drop a hand-edited artifact row, so we coerce. */
77
78
  const VALID_TYPES = new Set([
@@ -208,8 +209,9 @@ function readArtifact(artifactPath) {
208
209
  const lines = new Map();
209
210
  const records = new Map();
210
211
  let malformed = 0;
212
+ let misfiled = 0;
211
213
  if (!fs.existsSync(artifactPath))
212
- return { lines, records, malformed, existed: false };
214
+ return { lines, records, malformed, misfiled, existed: false };
213
215
  const raw = fs.readFileSync(artifactPath, 'utf-8');
214
216
  for (const line of raw.split(/\r?\n/)) {
215
217
  const trimmed = line.trim();
@@ -222,6 +224,13 @@ function readArtifact(artifactPath) {
222
224
  }
223
225
  const id = lineId(parsed);
224
226
  const record = lineToRecord(parsed);
227
+ // Dropped at the read both directions share, so import never applies one
228
+ // and export rewrites the file without it — the artifact shrinks instead of
229
+ // re-seeding every teammate's `learnings` on their next session (#1495).
230
+ if (isMisfiledDurable(record.namespace, record.key)) {
231
+ misfiled++;
232
+ continue;
233
+ }
225
234
  const existing = records.get(id);
226
235
  // Newest wins, ties keep the first. Built on the merge rule's own
227
236
  // comparison basis so the two cannot disagree about which of a live line
@@ -231,7 +240,7 @@ function readArtifact(artifactPath) {
231
240
  lines.set(id, parsed);
232
241
  records.set(id, record);
233
242
  }
234
- return { lines, records, malformed, existed: true };
243
+ return { lines, records, malformed, misfiled, existed: true };
235
244
  }
236
245
  /**
237
246
  * Serialise lines to JSONL, sorted by the (namespace, key) each line is about —
@@ -296,8 +305,15 @@ function payloadFromLine(id, line) {
296
305
  tags: line.tags ? JSON.stringify(line.tags) : null,
297
306
  metadata: JSON.stringify({ provenance: line.provenance, sharedFrom: 'team-artifact' }),
298
307
  ownerId: line.provenance?.author || null,
299
- // created_at/updated_at are INTEGER NOT NULL — never bind null.
300
- createdAt: typeof line.created_at === 'number' ? line.created_at : Date.now(),
308
+ // created_at/updated_at are INTEGER NOT NULL — never bind null. A line with
309
+ // no created_at falls back to its edit time, never the import time: stamping
310
+ // now makes a re-imported old entry read as a new learning, which is what
311
+ // inflated growth metrics after every re-seed (#1495).
312
+ createdAt: typeof line.created_at === 'number'
313
+ ? line.created_at
314
+ : typeof line.updated_at === 'number'
315
+ ? line.updated_at
316
+ : Date.now(),
301
317
  // The row gets the best timestamp available even when the RECORD compared
302
318
  // as 0 (a pre-#1463 line): 0 governs only who wins the merge, while the row
303
319
  // itself should carry the most accurate time we have.
@@ -326,7 +342,7 @@ export function exportTeamArtifact(opts) {
326
342
  const parsedSharedAt = Date.parse(opts.sharedAt);
327
343
  const now = opts.now ?? (Number.isNaN(parsedSharedAt) ? Date.now() : parsedSharedAt);
328
344
  const ttlMs = opts.tombstoneTtlMs ?? TOMBSTONE_TTL_MS;
329
- const { lines, records: target, malformed, existed } = readArtifact(opts.artifactPath);
345
+ const { lines, records: target, malformed, misfiled, existed } = readArtifact(opts.artifactPath);
330
346
  const provenance = {
331
347
  author: resolveAuthor(projectRoot),
332
348
  source: resolveSource(),
@@ -418,7 +434,7 @@ export function exportTeamArtifact(opts) {
418
434
  }
419
435
  // Skip the write when the merge changed nothing AND the file already exists:
420
436
  // a git-tracked artifact should not show up as modified after a no-op run.
421
- const changed = actions.length > 0 || prunedTombstones > 0 || backfilled > 0;
437
+ const changed = actions.length > 0 || prunedTombstones > 0 || backfilled > 0 || misfiled > 0;
422
438
  const wrote = changed || !existed;
423
439
  if (wrote) {
424
440
  fs.mkdirSync(path.dirname(opts.artifactPath), { recursive: true });
@@ -436,6 +452,7 @@ export function exportTeamArtifact(opts) {
436
452
  backfilled,
437
453
  skippedMalformed: malformed,
438
454
  skippedCorrupt,
455
+ droppedMisfiled: misfiled,
439
456
  total: live,
440
457
  tombstones,
441
458
  wrote,
@@ -467,9 +484,11 @@ export function importTeamArtifact(opts) {
467
484
  skippedMalformed: 0,
468
485
  skippedNonDurable: 0,
469
486
  skippedCorrupt: 0,
487
+ skippedMisfiled: 0,
470
488
  };
471
- const { lines, records: parsed, malformed } = readArtifact(opts.artifactPath);
489
+ const { lines, records: parsed, malformed, misfiled } = readArtifact(opts.artifactPath);
472
490
  report.skippedMalformed = malformed;
491
+ report.skippedMisfiled = misfiled;
473
492
  if (lines.size === 0)
474
493
  return report;
475
494
  const source = new Map();
@@ -2,5 +2,5 @@
2
2
  * Auto-generated by build. Do not edit manually.
3
3
  * Source of truth: root package.json → scripts/sync-version.mjs
4
4
  */
5
- export const VERSION = '4.13.3';
5
+ export const VERSION = '4.13.4';
6
6
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moflo",
3
- "version": "4.13.3",
3
+ "version": "4.13.4",
4
4
  "description": "MoFlo — AI agent orchestration for Claude Code. A standalone, opinionated toolkit with semantic memory, learned routing, gates, spells, and the /flo issue-execution skill.",
5
5
  "main": "dist/src/cli/index.js",
6
6
  "type": "module",
@@ -99,7 +99,7 @@
99
99
  "@typescript-eslint/parser": "^8.65.0",
100
100
  "eslint": "^10.8.0",
101
101
  "glob": "^11.1.0",
102
- "moflo": "^4.13.2",
102
+ "moflo": "^4.13.3",
103
103
  "tsx": "^4.21.0",
104
104
  "typescript": "^5.9.3",
105
105
  "vitest": "^4.0.0"