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,161 @@
1
+ /**
2
+ * The durable-learning reconciliation plan (#1463).
3
+ *
4
+ * Before this module, every path that synced durable learnings between two
5
+ * stores was additive on keys: team export skipped any key already in the
6
+ * artifact, team import ran `INSERT OR IGNORE`, and the worktree durable sync
7
+ * ran both directions through the same ignore-on-conflict copy. A correction
8
+ * and a deletion were dropped in every direction, so a purge could not be made
9
+ * to stick — the entry came back at the next session-start import.
10
+ *
11
+ * That was three independent copies of the same defect. This module is the ONE
12
+ * implementation of the merge rule; the four call sites differ only in how they
13
+ * read records out of their store and how they apply the resulting actions.
14
+ * Adding a fifth sync path means reading records into {@link ReconcileRecord}
15
+ * and applying {@link ReconcileAction} — never re-deriving the rule.
16
+ *
17
+ * The module is deliberately pure: no fs, no sqlite, no clock. Everything it
18
+ * decides is a function of the two maps handed to it, which is what makes the
19
+ * conflict matrix directly testable in both orderings.
20
+ *
21
+ * ## The rule
22
+ *
23
+ * A record is either **live** (has `content`) or a **tombstone** (has
24
+ * `deletedAt`). For each key present in the SOURCE:
25
+ *
26
+ * | source | target | outcome |
27
+ * |---|---|---|
28
+ * | live | absent | `insert` |
29
+ * | live | live, same content | unchanged |
30
+ * | live | live, source newer | `update` |
31
+ * | live | live, target newer | kept (target wins) |
32
+ * | live | tombstone, source newer | `resurrect` |
33
+ * | live | tombstone, tombstone newer | kept (the purge stands) |
34
+ * | tombstone | absent | nothing — never shared, nothing to delete |
35
+ * | tombstone | live, tombstone newer | `delete` |
36
+ * | tombstone | live, target newer | kept (re-created after the purge) |
37
+ * | tombstone | tombstone | unchanged |
38
+ *
39
+ * And the rule that matters most, covering every key present in the TARGET but
40
+ * absent from the source: **leave it alone.** The naive alternative — delete
41
+ * anything the source lacks — cannot tell a remote deletion from local work
42
+ * that was never exported, and would destroy the latter. Only an explicit
43
+ * tombstone ever deletes.
44
+ *
45
+ * Comparisons are strict: equal timestamps produce no action. A tie means two
46
+ * stores disagree with no evidence about which is later, and the safe reading
47
+ * of "no evidence" is "change nothing".
48
+ *
49
+ * @module cli/services/durable-reconcile
50
+ */
51
+ /** Separator for the composite (namespace, key) identity. NUL can't occur in either. */
52
+ const ID_SEP = String.fromCharCode(0);
53
+ /**
54
+ * Composite identity for a durable row. Namespaces share keys — the
55
+ * `knowledge` → `learnings` migration guarantees overlap — so a key alone is
56
+ * not an identity and never was.
57
+ */
58
+ export function reconcileId(namespace, key) {
59
+ return `${namespace}${ID_SEP}${key}`;
60
+ }
61
+ /** Split a {@link reconcileId} back into its parts. */
62
+ export function splitReconcileId(id) {
63
+ const at = id.indexOf(ID_SEP);
64
+ return at < 0
65
+ ? { namespace: '', key: id }
66
+ : { namespace: id.slice(0, at), key: id.slice(at + 1) };
67
+ }
68
+ /** True when a record represents a deletion rather than a live entry. */
69
+ export function isTombstone(record) {
70
+ return typeof record.deletedAt === 'number';
71
+ }
72
+ /**
73
+ * The instant a record last changed, whichever kind it is. Used for every
74
+ * cross-kind comparison (live vs tombstone) so the matrix stays one rule —
75
+ * exported because callers settling duplicates within one store need the same
76
+ * basis, and a second copy of it is a second policy waiting to diverge.
77
+ */
78
+ export function recordStamp(record) {
79
+ return isTombstone(record) ? record.deletedAt : record.updatedAt;
80
+ }
81
+ /** True when the plan would change nothing. Callers use it to skip a write. */
82
+ export function isNoOpPlan(plan) {
83
+ return plan.actions.length === 0;
84
+ }
85
+ /**
86
+ * Compute what the target must do to match the source. Pure — the caller
87
+ * applies the actions and owns every side effect.
88
+ *
89
+ * Direction is entirely the caller's: pass (DB, artifact) to export and
90
+ * (artifact, DB) to import. The rule is symmetric, which is the point.
91
+ */
92
+ export function planReconcile(source, target) {
93
+ const actions = [];
94
+ const summary = {
95
+ inserted: 0,
96
+ updated: 0,
97
+ deleted: 0,
98
+ resurrected: 0,
99
+ unchanged: 0,
100
+ keptTargetNewer: 0,
101
+ targetOnly: 0,
102
+ };
103
+ for (const [id, src] of source) {
104
+ const dst = target.get(id);
105
+ if (!dst) {
106
+ // A tombstone for a key the target never had is not a deletion to
107
+ // propagate — the entry was never shared there. Dropping it here is also
108
+ // what keeps a tombstone from resurrecting as a row on a fresh install.
109
+ if (isTombstone(src)) {
110
+ summary.unchanged++;
111
+ continue;
112
+ }
113
+ actions.push({ id, op: 'insert', record: src });
114
+ summary.inserted++;
115
+ continue;
116
+ }
117
+ const srcDead = isTombstone(src);
118
+ const dstDead = isTombstone(dst);
119
+ if (srcDead && dstDead) {
120
+ // Both sides already agree the entry is gone. Refreshing the target's
121
+ // timestamp would churn the artifact diff for no semantic gain.
122
+ summary.unchanged++;
123
+ continue;
124
+ }
125
+ if (!srcDead && !dstDead && src.content === dst.content) {
126
+ summary.unchanged++;
127
+ continue;
128
+ }
129
+ // Strict: a tie changes nothing. See the module header.
130
+ if (recordStamp(src) <= recordStamp(dst)) {
131
+ summary.keptTargetNewer++;
132
+ continue;
133
+ }
134
+ const op = srcDead ? 'delete' : dstDead ? 'resurrect' : 'update';
135
+ actions.push({ id, op, record: src });
136
+ if (op === 'delete')
137
+ summary.deleted++;
138
+ else if (op === 'resurrect')
139
+ summary.resurrected++;
140
+ else
141
+ summary.updated++;
142
+ }
143
+ for (const id of target.keys()) {
144
+ if (!source.has(id))
145
+ summary.targetOnly++;
146
+ }
147
+ return { actions, summary };
148
+ }
149
+ /** Default age past which a tombstone is dropped from an artifact on export. */
150
+ export const TOMBSTONE_TTL_MS = 90 * 24 * 60 * 60 * 1000;
151
+ /**
152
+ * True when a tombstone is old enough to drop. Every store that was going to
153
+ * see the deletion has seen it long before 90 days, and keeping them forever
154
+ * would grow the artifact without bound.
155
+ *
156
+ * Live records are never prunable — the caller passes the whole map through.
157
+ */
158
+ export function isPrunableTombstone(record, now, ttlMs = TOMBSTONE_TTL_MS) {
159
+ return isTombstone(record) && now - record.deletedAt > ttlMs;
160
+ }
161
+ //# sourceMappingURL=durable-reconcile.js.map
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Reading durable rows out of a memory DB and applying a reconciliation plan
3
+ * back into one (#1463).
4
+ *
5
+ * The merge rule itself lives in {@link module:cli/services/durable-reconcile}
6
+ * and knows nothing about SQLite. This module is the other half: the SQL that
7
+ * turns `memory_entries` into {@link ReconcileRecord}s and the SQL that applies
8
+ * the resulting {@link ReconcileAction}s. Both the team JSONL artifact
9
+ * (`team-artifact-sync.ts`) and the worktree/cross-install durable sync
10
+ * (`durable-sync.ts`) use it, so the DB half of the sync exists once.
11
+ *
12
+ * ## Deletions are archives, not DELETEs
13
+ *
14
+ * A hard-deleted row is indistinguishable from a row that never existed, so it
15
+ * can never propagate — and "delete anything the other side lacks" is the rule
16
+ * we must not adopt (see the reconcile module header). Durable deletions
17
+ * therefore set `status = 'archived'` and stamp `updated_at` with the deletion
18
+ * time. Nothing else has to change for that to be invisible: every read path in
19
+ * moflo already filters `status = 'active'` — search, list, stats, retrieve,
20
+ * cleanup, and the HNSW index build. The archived row survives only as the
21
+ * evidence that lets the deletion cross to another store, and as the timestamp
22
+ * that lets a legitimate re-creation win later.
23
+ *
24
+ * ## Writer classification
25
+ *
26
+ * `daemon-offline`, same shape as `cherry-pick-learnings.ts` and the existing
27
+ * team-artifact import: these run pre-boot at session-start or from a CLI
28
+ * command, not concurrently with a live daemon writer. Registered in
29
+ * `tests/system/fixtures/writer-audit-whitelist.json` (#1054).
30
+ *
31
+ * @module cli/services/durable-store-io
32
+ */
33
+ import { MEMORY_SCHEMA_V3 } from '../memory/schema.js';
34
+ import { DURABLE_NAMESPACES, DURABLE_INSERT_OR_IGNORE_SQL, DURABLE_ROW_COLUMNS, hasMemoryEntriesTable, isDurableNamespace, } from './cherry-pick-learnings.js';
35
+ // Re-exported so the two delete paths (`entries-write`, `bridge-entries`) can
36
+ // reach the durable check and the archive statement through ONE import,
37
+ // rather than each assembling its own copy of the retire-a-durable-row rule.
38
+ export { isDurableNamespace };
39
+ import { reconcileId, } from './durable-reconcile.js';
40
+ /** The `status` value that marks a durable row as deleted-but-propagatable. */
41
+ export const ARCHIVED_STATUS = 'archived';
42
+ // Same column list as the shared INSERT, so a future column can never be added
43
+ // to one half of the round trip only. The status filter is what differs from
44
+ // the legacy cherry-pick read: archived rows are the deletions we must carry.
45
+ const selectDurableSql = (placeholders, columns, byKey) => `SELECT ${columns} FROM memory_entries ` +
46
+ `WHERE namespace IN (${placeholders}) AND status IN ('active', '${ARCHIVED_STATUS}')` +
47
+ (byKey ? ` AND key = ?` : '');
48
+ /**
49
+ * The columns the merge rule alone needs. A comparison never looks at the
50
+ * embedding, and on a 1,600-row store those JSON vectors are tens of MB of
51
+ * string allocation — so the side of a sync that only supplies records (the
52
+ * TARGET, always) must not pay for them.
53
+ */
54
+ const RECORD_ONLY_COLUMNS = `key, namespace, content, updated_at, status`;
55
+ /**
56
+ * Read the durable slice of a DB into comparable records plus full payloads.
57
+ * An archived row becomes a tombstone stamped with its `updated_at` — which is
58
+ * the deletion time, because that is what the archive write sets.
59
+ *
60
+ * Returns empty maps for a DB with no `memory_entries` table rather than
61
+ * throwing: a not-yet-initialised store is "nothing to sync", not an error.
62
+ */
63
+ export function readDurableSnapshot(db, namespaces = DURABLE_NAMESPACES, opts = {}) {
64
+ const records = new Map();
65
+ const payloads = new Map();
66
+ if (!hasMemoryEntriesTable(db))
67
+ return { records, payloads };
68
+ const withPayloads = opts.withPayloads === true;
69
+ const placeholders = namespaces.map(() => '?').join(',');
70
+ const stmt = db.prepare(selectDurableSql(placeholders, withPayloads ? DURABLE_ROW_COLUMNS : RECORD_ONLY_COLUMNS, opts.key != null));
71
+ try {
72
+ stmt.bind(opts.key != null ? [...namespaces, opts.key] : namespaces.slice());
73
+ while (stmt.step()) {
74
+ const row = stmt.getAsObject();
75
+ const namespace = String(row.namespace);
76
+ const key = String(row.key);
77
+ const id = reconcileId(namespace, key);
78
+ const createdAt = row.created_at == null ? 0 : Number(row.created_at);
79
+ // A row predating the updated_at backfill falls back to created_at: for a
80
+ // DB row that IS the last-known edit time, unlike an artifact line where
81
+ // substituting created_at would let a stale line win (see the reconcile
82
+ // module's note on passing 0).
83
+ const updatedAt = row.updated_at == null ? createdAt : Number(row.updated_at);
84
+ const archived = String(row.status ?? 'active') === ARCHIVED_STATUS;
85
+ records.set(id, archived
86
+ ? { namespace, key, updatedAt, deletedAt: updatedAt }
87
+ : { namespace, key, updatedAt, content: row.content == null ? '' : String(row.content) });
88
+ if (!withPayloads)
89
+ continue;
90
+ payloads.set(id, {
91
+ id: String(row.id),
92
+ namespace,
93
+ key,
94
+ content: row.content == null ? '' : String(row.content),
95
+ type: row.type == null ? 'semantic' : String(row.type),
96
+ tags: row.tags == null ? null : String(row.tags),
97
+ metadata: row.metadata == null ? null : String(row.metadata),
98
+ ownerId: row.owner_id == null ? null : String(row.owner_id),
99
+ createdAt,
100
+ updatedAt,
101
+ embedding: row.embedding == null ? null : String(row.embedding),
102
+ embeddingModel: row.embedding_model == null ? null : String(row.embedding_model),
103
+ embeddingDimensions: row.embedding_dimensions == null ? null : Number(row.embedding_dimensions),
104
+ });
105
+ }
106
+ }
107
+ finally {
108
+ try {
109
+ stmt.free();
110
+ }
111
+ catch {
112
+ /* best-effort cleanup */
113
+ }
114
+ }
115
+ return { records, payloads };
116
+ }
117
+ const UPDATE_ROW_SQL = `UPDATE memory_entries SET content = ?, type = ?, tags = ?, metadata = ?, ` +
118
+ `embedding = ?, embedding_model = ?, embedding_dimensions = ?, ` +
119
+ `updated_at = ?, status = 'active' WHERE namespace = ? AND key = ?`;
120
+ const ARCHIVE_DURABLE_ROW_SQL = `UPDATE memory_entries SET status = '${ARCHIVED_STATUS}', updated_at = ?, ` +
121
+ // The vector goes with the deletion: an archived row is excluded from index
122
+ // builds anyway, and dropping it means a later resurrect cannot reuse a
123
+ // vector that no longer matches whatever content wins.
124
+ `embedding = NULL, embedding_model = NULL, embedding_dimensions = NULL ` +
125
+ // `status = 'active'` guard: re-archiving an already-archived row would move
126
+ // its deletion timestamp forward and could beat a legitimate re-creation on
127
+ // the other side that had already won.
128
+ `WHERE namespace = ? AND key = ? AND status = 'active'`;
129
+ /**
130
+ * Apply a reconciliation plan to `db`. The whole batch runs in ONE transaction
131
+ * — a half-applied merge would leave the store in a state no later run could
132
+ * reason about, and a single commit is also one fsync instead of one per row on
133
+ * the session-start hot path.
134
+ *
135
+ * `payloads` supplies the full row for every insert/update/resurrect action;
136
+ * `delete` actions need only the timestamp the plan already carries.
137
+ */
138
+ export function applyDurableActions(db, actions, payloads) {
139
+ const report = {
140
+ inserted: 0,
141
+ updated: 0,
142
+ archived: 0,
143
+ resurrected: 0,
144
+ skippedMissingPayload: 0,
145
+ skippedConflict: 0,
146
+ };
147
+ if (actions.length === 0)
148
+ return report;
149
+ db.run(MEMORY_SCHEMA_V3);
150
+ let insertStmt = null;
151
+ let updateStmt = null;
152
+ let archiveStmt = null;
153
+ try {
154
+ insertStmt = db.prepare(DURABLE_INSERT_OR_IGNORE_SQL);
155
+ updateStmt = db.prepare(UPDATE_ROW_SQL);
156
+ archiveStmt = db.prepare(ARCHIVE_DURABLE_ROW_SQL);
157
+ db.run('BEGIN');
158
+ try {
159
+ for (const action of actions) {
160
+ if (action.op === 'delete') {
161
+ const { namespace, key } = action.record;
162
+ archiveStmt.bind([action.record.deletedAt ?? Date.now(), namespace, key]);
163
+ archiveStmt.step();
164
+ if (db.getRowsModified() > 0)
165
+ report.archived++;
166
+ archiveStmt.reset();
167
+ continue;
168
+ }
169
+ const payload = payloads.get(action.id);
170
+ if (!payload) {
171
+ report.skippedMissingPayload++;
172
+ continue;
173
+ }
174
+ if (action.op === 'insert') {
175
+ insertStmt.bind([
176
+ payload.id,
177
+ payload.key,
178
+ payload.namespace,
179
+ payload.content,
180
+ payload.type,
181
+ payload.embedding,
182
+ payload.embeddingModel,
183
+ payload.embeddingDimensions,
184
+ payload.tags,
185
+ payload.metadata,
186
+ payload.ownerId,
187
+ payload.createdAt,
188
+ payload.updatedAt,
189
+ 'active',
190
+ ]);
191
+ insertStmt.step();
192
+ if (db.getRowsModified() > 0)
193
+ report.inserted++;
194
+ else
195
+ report.skippedConflict++;
196
+ insertStmt.reset();
197
+ continue;
198
+ }
199
+ // update | resurrect — the same write. They differ only in what the
200
+ // target was before (a live row vs an archived one), which the plan has
201
+ // already decided; `status = 'active'` covers both.
202
+ updateStmt.bind([
203
+ payload.content,
204
+ payload.type,
205
+ payload.tags,
206
+ payload.metadata,
207
+ payload.embedding,
208
+ payload.embeddingModel,
209
+ payload.embeddingDimensions,
210
+ payload.updatedAt,
211
+ payload.namespace,
212
+ payload.key,
213
+ ]);
214
+ updateStmt.step();
215
+ if (db.getRowsModified() > 0) {
216
+ if (action.op === 'resurrect')
217
+ report.resurrected++;
218
+ else
219
+ report.updated++;
220
+ }
221
+ updateStmt.reset();
222
+ }
223
+ db.run('COMMIT');
224
+ }
225
+ catch (e) {
226
+ try {
227
+ db.run('ROLLBACK');
228
+ }
229
+ catch {
230
+ /* best-effort — close() also discards an open transaction */
231
+ }
232
+ throw e;
233
+ }
234
+ }
235
+ finally {
236
+ for (const stmt of [insertStmt, updateStmt, archiveStmt]) {
237
+ if (!stmt)
238
+ continue;
239
+ try {
240
+ stmt.free();
241
+ }
242
+ catch {
243
+ /* best-effort cleanup */
244
+ }
245
+ }
246
+ }
247
+ return report;
248
+ }
249
+ /**
250
+ * Archive one durable row so the deletion can propagate, returning whether a
251
+ * row was actually affected.
252
+ *
253
+ * The single archive implementation: both `flo memory delete` paths (offline
254
+ * `entries-write.deleteEntry` and daemon `bridgeDeleteEntry`) call this rather
255
+ * than binding the statement themselves, so the bind order and the
256
+ * `status = 'active'` guard exist once. A hard delete in a durable namespace
257
+ * would silently un-share nothing — the entry returns on the next import,
258
+ * which is the #1463 failure mode in miniature.
259
+ */
260
+ export function archiveDurableRow(db, namespace, key, deletedAt) {
261
+ if (!hasMemoryEntriesTable(db))
262
+ return false;
263
+ db.run(ARCHIVE_DURABLE_ROW_SQL, [deletedAt, namespace, key]);
264
+ return db.getRowsModified() > 0;
265
+ }
266
+ /**
267
+ * Drop archived durable rows whose deletion is older than `ttlMs`.
268
+ *
269
+ * Story #728 retired the previous soft-delete because tombstones were
270
+ * write-only and grew without bound. Both halves of that are answered here: the
271
+ * rows are read (by every sync direction, and by a re-creation that must beat
272
+ * them), and this prune bounds them on the same 90-day window the artifact uses
273
+ * — by which point every store has long since seen the deletion.
274
+ *
275
+ * Returns the number of rows removed.
276
+ */
277
+ export function pruneExpiredArchives(db, now, ttlMs, namespaces = DURABLE_NAMESPACES) {
278
+ if (!hasMemoryEntriesTable(db))
279
+ return 0;
280
+ // Probe before writing. `idx_memory_status` makes this a cheap index hit, and
281
+ // most stores hold no archived rows at all — running the DELETE regardless
282
+ // would open a write transaction on every session start for nothing.
283
+ const probe = db.exec(`SELECT 1 FROM memory_entries WHERE status = '${ARCHIVED_STATUS}' AND updated_at < ? LIMIT 1`, [now - ttlMs]);
284
+ if (!probe[0]?.values?.[0])
285
+ return 0;
286
+ const placeholders = namespaces.map(() => '?').join(',');
287
+ db.run(`DELETE FROM memory_entries WHERE status = '${ARCHIVED_STATUS}' ` +
288
+ `AND namespace IN (${placeholders}) AND updated_at < ?`, [...namespaces, now - ttlMs]);
289
+ return db.getRowsModified();
290
+ }
291
+ //# sourceMappingURL=durable-store-io.js.map