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
@@ -19,10 +19,21 @@
19
19
  * every durable write, so a new learning propagates to sibling workspaces
20
20
  * by their next session-start.
21
21
  *
22
- * Both directions are a single call to {@link cherryPickLearningsFromLegacy}
23
- * with source/target swapped — it already copies durable rows with
24
- * `INSERT OR IGNORE` on `UNIQUE(namespace, key)` (conflict-free, idempotent,
25
- * embeddings carried forward verbatim). No new SQL lives here.
22
+ * Both directions are a single call to {@link reconcileDurableStores} with
23
+ * source/target swapped — the same {@link planReconcile} rule the team artifact
24
+ * uses, so a correction and a deletion cross between worktrees instead of being
25
+ * dropped.
26
+ *
27
+ * Until #1463 both directions ran `INSERT OR IGNORE`, making the result the
28
+ * union of durable rows on both sides: an entry purged in one worktree came
29
+ * straight back at the next session-start seed. That mattered more here than
30
+ * for the team artifact, because worktree sharing needs no configuration —
31
+ * `worktree_sharing` defaults on — so every user with a linked worktree had it.
32
+ *
33
+ * `cherryPickLearningsFromLegacy` is deliberately NOT used any more for these
34
+ * two directions, and deliberately still used for the legacy upgrade path it
35
+ * was written for: reconciling against a legacy DB could archive rows on the
36
+ * strength of a store that predates tombstones entirely.
26
37
  *
27
38
  * Safety: the shared store is a plain transfer DB — moflo never *searches* it
28
39
  * directly, so it has no HNSW sidecar and no index-divergence concern. Row
@@ -39,7 +50,10 @@ import { findProjectRoot } from './project-root.js';
39
50
  import { stableAbsolute, pickConfiguredPath } from './configured-path.js';
40
51
  import { memoryDbPath } from './moflo-paths.js';
41
52
  import { loadMofloConfig } from '../config/moflo-config.js';
42
- import { cherryPickLearningsFromLegacy, DURABLE_NAMESPACES, isDurableNamespace, } from './cherry-pick-learnings.js';
53
+ import { openDaemonDatabase } from '../memory/daemon-backend.js';
54
+ import { CHERRY_PICK_SKIP_REASONS, DURABLE_NAMESPACES, isDurableNamespace, } from './cherry-pick-learnings.js';
55
+ import { planReconcile, TOMBSTONE_TTL_MS } from './durable-reconcile.js';
56
+ import { readDurableSnapshot, applyDurableActions, pruneExpiredArchives } from './durable-store-io.js';
43
57
  export { isDurableNamespace };
44
58
  /**
45
59
  * Read `<projectRoot>/.git` ONCE and classify the checkout for worktree sharing,
@@ -164,18 +178,111 @@ export function resolveDurablePath(projectRoot = findProjectRoot(), config) {
164
178
  }
165
179
  return { path: null, skipped: 'not-configured' };
166
180
  }
181
+ const emptyDirection = (target) => ({
182
+ copied: 0,
183
+ considered: 0,
184
+ sources: [],
185
+ target,
186
+ updated: 0,
187
+ archived: 0,
188
+ resurrected: 0,
189
+ keptTarget: 0,
190
+ });
191
+ /**
192
+ * Reconcile the durable slice of `sourcePath` into `targetPath`. Both stores
193
+ * are real memory DBs; the merge rule is the shared one, so a key present only
194
+ * in the target is never touched and only an explicit archive deletes.
195
+ *
196
+ * Missing source → a zero result rather than a throw: "the sibling worktree has
197
+ * not written anything yet" is a normal state, not an error.
198
+ */
199
+ export function reconcileDurableStores(sourcePath, targetPath, opts = {}) {
200
+ const result = emptyDirection(targetPath);
201
+ if (stableAbsolute(sourcePath) === stableAbsolute(targetPath)) {
202
+ result.sources.push({ path: sourcePath, rowsRead: 0, rowsInserted: 0, reason: CHERRY_PICK_SKIP_REASONS.SELF_REFERENCE });
203
+ return result;
204
+ }
205
+ if (!fs.existsSync(sourcePath)) {
206
+ result.sources.push({ path: sourcePath, rowsRead: 0, rowsInserted: 0, reason: CHERRY_PICK_SKIP_REASONS.NO_ROWS });
207
+ return result;
208
+ }
209
+ let sourceDb;
210
+ try {
211
+ sourceDb = openDaemonDatabase(sourcePath);
212
+ }
213
+ catch {
214
+ result.sources.push({ path: sourcePath, rowsRead: 0, rowsInserted: 0, reason: CHERRY_PICK_SKIP_REASONS.OPEN_FAILED });
215
+ return result;
216
+ }
217
+ let snapshot;
218
+ try {
219
+ snapshot = readDurableSnapshot(sourceDb, DURABLE_NAMESPACES, { withPayloads: true, key: opts.key });
220
+ }
221
+ finally {
222
+ sourceDb.close();
223
+ }
224
+ result.considered = snapshot.records.size;
225
+ if (snapshot.records.size === 0) {
226
+ result.sources.push({ path: sourcePath, rowsRead: 0, rowsInserted: 0, reason: CHERRY_PICK_SKIP_REASONS.NO_ROWS });
227
+ return result;
228
+ }
229
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
230
+ const targetDb = openDaemonDatabase(targetPath);
231
+ try {
232
+ const { records: target } = readDurableSnapshot(targetDb, DURABLE_NAMESPACES, { key: opts.key });
233
+ const { actions, summary } = planReconcile(snapshot.records, target);
234
+ const applied = applyDurableActions(targetDb, actions, snapshot.payloads);
235
+ result.copied = applied.inserted;
236
+ result.updated = applied.updated;
237
+ result.archived = applied.archived;
238
+ result.resurrected = applied.resurrected;
239
+ result.keptTarget = summary.keptTargetNewer;
240
+ }
241
+ finally {
242
+ targetDb.close();
243
+ }
244
+ result.sources.push({
245
+ path: sourcePath,
246
+ rowsRead: result.considered,
247
+ rowsInserted: result.copied,
248
+ });
249
+ return result;
250
+ }
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.
254
+ */
255
+ function pruneStore(dbPath) {
256
+ if (!fs.existsSync(dbPath))
257
+ return 0;
258
+ let db;
259
+ try {
260
+ db = openDaemonDatabase(dbPath);
261
+ }
262
+ catch {
263
+ return 0;
264
+ }
265
+ try {
266
+ return pruneExpiredArchives(db, Date.now(), TOMBSTONE_TTL_MS);
267
+ }
268
+ catch {
269
+ return 0;
270
+ }
271
+ finally {
272
+ db.close();
273
+ }
274
+ }
275
+ /** Total rows a direction actually changed — what the launcher reports. */
276
+ export function changedRows(result) {
277
+ return result.copied + result.updated + result.archived + result.resurrected;
278
+ }
167
279
  /**
168
280
  * Seed durable rows from the shared store into this project's local DB.
169
281
  * Shared is the source, local `.moflo/moflo.db` the target. No-op when the
170
282
  * shared store doesn't exist yet (nothing to seed).
171
283
  */
172
284
  export async function seedDurableFromShared(projectRoot, durablePath) {
173
- return cherryPickLearningsFromLegacy({
174
- projectRoot,
175
- legacyPaths: [durablePath],
176
- toPath: memoryDbPath(projectRoot),
177
- namespaces: DURABLE_NAMESPACES,
178
- });
285
+ return reconcileDurableStores(durablePath, memoryDbPath(projectRoot));
179
286
  }
180
287
  /**
181
288
  * Flush durable rows from this project's local DB into the shared store.
@@ -190,19 +297,23 @@ export async function flushDurableToShared(projectRoot, durablePath) {
190
297
  catch {
191
298
  /* a real open failure surfaces from cherry-pick below */
192
299
  }
193
- return cherryPickLearningsFromLegacy({
194
- projectRoot,
195
- legacyPaths: [memoryDbPath(projectRoot)],
196
- toPath: durablePath,
197
- namespaces: DURABLE_NAMESPACES,
198
- });
300
+ return reconcileDurableStores(memoryDbPath(projectRoot), durablePath);
199
301
  }
200
302
  /**
201
303
  * Bidirectional durable sync run once at session-start: flush local → shared
202
304
  * (bootstraps pre-existing local learnings into the shared store), then seed
203
- * shared → local (pulls in sibling workspaces' learnings). Both directions are
204
- * `INSERT OR IGNORE`, so the result is the union of durable rows on both sides
205
- * with no conflicts. Safe to call unconditionally — a no-op when unconfigured.
305
+ * shared → local (pulls in sibling workspaces' learnings). Both directions run
306
+ * the shared reconciliation rule, so corrections and deletions converge across
307
+ * worktrees instead of accumulating as the union of both sides.
308
+ *
309
+ * Flush-then-seed order is load-bearing for deletions, and so is doing the
310
+ * archive pruning only once BOTH have run — see the prune comment in the body.
311
+ *
312
+ * Safe to call unconditionally, but no longer a true no-op when unconfigured:
313
+ * durable deletes archive rather than drop rows, so the local store still needs
314
+ * its retention window applied with sharing off. That costs one DB open and one
315
+ * indexed probe per session start, and nothing further unless expired archives
316
+ * actually exist.
206
317
  *
207
318
  * Intended to run BEFORE the daemon starts so the freshly-seeded rows are
208
319
  * present when the daemon builds its in-memory HNSW index.
@@ -211,17 +322,36 @@ export async function syncDurableAtSessionStart(opts = {}) {
211
322
  const projectRoot = opts.projectRoot ?? findProjectRoot();
212
323
  const { path: durablePath, skipped, autoWorktree } = resolveDurablePath(projectRoot, opts.config);
213
324
  if (!durablePath) {
214
- return { durablePath: null, skipped, flushedToShared: 0, seededToLocal: 0 };
325
+ // Sharing off, but deletes still archive — so the local store still needs
326
+ // its retention window applied. This is the majority case (one checkout,
327
+ // no team artifact); skipping it here is what would let archived rows grow
328
+ // without bound for most users.
329
+ return {
330
+ durablePath: null,
331
+ skipped,
332
+ flushedToShared: 0,
333
+ seededToLocal: 0,
334
+ prunedArchives: pruneStore(memoryDbPath(projectRoot)),
335
+ };
215
336
  }
216
337
  // Flush first so this worktree's existing learnings land in the shared store
217
338
  // before we seed — keeps the very first opt-in symmetric across workspaces.
218
339
  const flush = await flushDurableToShared(projectRoot, durablePath);
219
340
  const seed = await seedDurableFromShared(projectRoot, durablePath);
341
+ // Prune AFTER both directions, never inside one. Pruning during the flush
342
+ // deletes tombstones the seed has not applied yet: a worktree dormant past
343
+ // the retention window flushes its stale live row first, the shared
344
+ // tombstone correctly wins but is then pruned as expired, and the next
345
+ // flush re-inserts the purged entry into every workspace. Running the seed
346
+ // first means this store has applied the deletion before anything drops the
347
+ // evidence for it.
348
+ const pruned = pruneStore(memoryDbPath(projectRoot)) + pruneStore(durablePath);
220
349
  return {
221
350
  durablePath,
222
351
  autoWorktree: autoWorktree ?? false,
223
- flushedToShared: flush.copied,
224
- seededToLocal: seed.copied,
352
+ flushedToShared: changedRows(flush),
353
+ seededToLocal: changedRows(seed),
354
+ prunedArchives: pruned,
225
355
  };
226
356
  }
227
357
  /**
@@ -246,7 +376,12 @@ export async function writeThroughDurable(namespace, opts = {}) {
246
376
  const { path: durablePath } = resolveDurablePath(projectRoot, opts.config);
247
377
  if (!durablePath)
248
378
  return;
249
- await flushDurableToShared(projectRoot, durablePath);
379
+ // Flush ONLY the key that just changed. A full reconciliation here would
380
+ // read both stores end to end after every single learning written — the
381
+ // caller already knows exactly what moved, so a one-row plan is enough.
382
+ // The session-start sync remains the full-store reconciliation.
383
+ fs.mkdirSync(path.dirname(durablePath), { recursive: true });
384
+ reconcileDurableStores(memoryDbPath(projectRoot), durablePath, { key: opts.key });
250
385
  }
251
386
  catch {
252
387
  // Swallow entirely — see the rationale above.