moflo 4.12.4-rc.9 → 4.12.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.
@@ -27,16 +27,31 @@ Specs and plans persist as Markdown, one directory per unit of work, under the c
27
27
  <specs_dir>/<slug>/plan.md # the "steps" + how each criterion is verified
28
28
  ```
29
29
 
30
- They are indexed into memory on session start, so `mcp__moflo__memory_search` surfaces prior specs across sessions. **Always create and mutate them through the `flo sdd` CLI** — never hand-write the path in a skill step (cross-platform, Rule #1: the CLI builds every path with `path.join`).
30
+ **Always create and mutate them through the `flo sdd` CLI** — never hand-write the path in a skill step (cross-platform, Rule #1: the CLI builds every path with `path.join`).
31
31
 
32
- **Where they live is configurable (`sdd.specs_dir`, #1294).** The default `.moflo/specs` is **gitignored** by `flo init` — specs stay local and do not bloat source control, but they also do **not** appear in PRs. To make specs reviewable, point `sdd.specs_dir` at a **tracked** path and commit them:
32
+ ### Specs are NOT indexed into memory
33
+
34
+ Earlier versions indexed `spec.md` / `plan.md` into the `guidance` namespace. They no longer are, and the specs directory is excluded from the guidance walk even when it sits inside a `guidance.directories` entry.
35
+
36
+ A spec is pre-implementation intent for **one** unit of work, not a project rule. Once implemented it is stale-by-construction, and specs accumulate without bound — so a superseded approach kept surfacing at high similarity alongside real guidance, and the namespace degraded as the project aged. Nothing was gained in exchange: the active spec's path is already known (the `flo sdd` CLI just returned it), so **read it from disk** rather than searching for it.
37
+
38
+ | To… | Use |
39
+ |---|---|
40
+ | Read the spec/plan you are working on | `Read` the path `flo sdd` returned |
41
+ | Find prior specs across sessions | `flo sdd list` / `flo sdd status <slug>` |
42
+ | Recall what an implementation actually taught you | `memory_search` namespace `learnings` |
43
+ | Recall a past verify verdict | `memory_search` namespace `verify` |
44
+
45
+ Existing spec rows are removed by the `purge-spec-chunks` migration on the next session start.
46
+
47
+ **Where specs live is configurable (`sdd.specs_dir`, #1294).** The default `.moflo/specs` is **gitignored** by `flo init`. To make specs reviewable in the PR, point `sdd.specs_dir` at a **tracked** path and commit them:
33
48
 
34
49
  | `sdd.specs_dir` | Committed? | Use when |
35
50
  |-----------------|------------|----------|
36
- | `.moflo/specs` (default) | No (gitignored) | You want the SDD workflow but not spec artifacts in history |
37
- | `docs/specs`, `.specs`, … (tracked) | Yes | You want specs reviewed in the PR alongside the code |
51
+ | `.moflo/specs` (default) | No (gitignored) | Specs are scratch — the PR body carries the acceptance criteria. Best at high spec volume. |
52
+ | `docs/specs`, `.specs`, … (tracked) | Yes | You want the spec diffed and reviewed alongside the code |
38
53
 
39
- Set it once in `moflo.yaml`; the `flo sdd` CLI and the session-start indexer both honor it. If the path sits inside a `guidance.directories` entry, specs are indexed once (as guidance), not twice.
54
+ Set it once in `moflo.yaml`; the `flo sdd` CLI and the session-start indexer both honor it the CLI to write specs there, the indexer to exclude them.
40
55
 
41
56
  Each artifact carries a `status` of `draft` or `reviewed` in its frontmatter. The constitution layer (`CLAUDE.md` + `.claude/guidance/`) is referenced by every stage — never restate its invariants inside a spec.
42
57
 
@@ -47,6 +47,28 @@ const DB_PATH = memoryDbPath(projectRoot);
47
47
  // Load guidance directories from moflo.yaml, falling back to defaults
48
48
  // ============================================================================
49
49
 
50
+ /**
51
+ * Absolute path to the project's SDD specs directory (default `.moflo/specs`).
52
+ *
53
+ * Validation MUST match `specsRoot()` in `src/cli/sdd/artifacts.ts` exactly, or
54
+ * the indexer and the CLI would disagree on where specs live: reject absolute /
55
+ * drive-letter / parent-escape values and fall back to the default.
56
+ *
57
+ * Cross-platform (Rule #1): split the /-written config value and re-join with
58
+ * `path.resolve`, never hardcode a separator.
59
+ */
60
+ function resolveSpecsDir(specsDirConfig) {
61
+ const raw = specsDirConfig || '.moflo/specs';
62
+ let rel = raw.split(/[\\/]+/).filter(Boolean);
63
+ const escapes = rel.length === 0
64
+ || rel.includes('..')
65
+ || /^([a-zA-Z]:|~)$/.test(rel[0])
66
+ || raw.startsWith('/')
67
+ || raw.startsWith('\\');
68
+ if (escapes) rel = ['.moflo', 'specs'];
69
+ return resolve(projectRoot, ...rel);
70
+ }
71
+
50
72
  function loadGuidanceDirs() {
51
73
  const dirs = [];
52
74
 
@@ -115,38 +137,34 @@ function loadGuidanceDirs() {
115
137
  dirs.push({ path: bundledSkillsDir, prefix: 'skill-bundled', fileFilter: ['SKILL.md'], kind: 'skill', absolute: true });
116
138
  }
117
139
 
118
- // 6. SDD spec/plan artifacts (Epic #1269) — index <specs_dir>/<slug>/{spec,plan}.md
119
- // so prior specs/plans are searchable across sessions. kind: 'spec' keys each
120
- // file by <slug>-<spec|plan> to avoid collisions between per-slug spec.md files.
121
- // #1294 the location is configurable (default .moflo/specs). Cross-platform
122
- // (Rule #1): split the /-written value and re-join, never hardcode a separator.
123
- // Validation MUST match specsRoot() in src/cli/sdd/artifacts.ts exactly, or
124
- // the indexer and the CLI would disagree on where specs live: reject
125
- // absolute / drive-letter / parent-escape values and fall back to the default.
126
- const rawSpecs = specsDirConfig || '.moflo/specs';
127
- let specsRel = rawSpecs.split(/[\\/]+/).filter(Boolean);
128
- const specsEscapes = specsRel.length === 0
129
- || specsRel.includes('..')
130
- || /^([a-zA-Z]:|~)$/.test(specsRel[0])
131
- || rawSpecs.startsWith('/')
132
- || rawSpecs.startsWith('\\');
133
- if (specsEscapes) specsRel = ['.moflo', 'specs'];
134
- const projectSpecsDir = resolve(projectRoot, ...specsRel);
135
- // Double-index guard: if specs_dir sits inside a guidance dir, the guidance
136
- // scan (step 1) already indexes those .md files skip the 'spec' entry so
137
- // they aren't indexed twice under two prefixes.
138
- const insideGuidance = userDirs.some(d => {
139
- const gd = resolve(projectRoot, ...d.split(/[\\/]+/).filter(Boolean));
140
- return projectSpecsDir === gd || projectSpecsDir.startsWith(gd + sep);
141
- });
142
- if (existsSync(projectSpecsDir) && !insideGuidance) {
143
- dirs.push({ path: specsRel.join('/'), prefix: 'spec', fileFilter: ['spec.md', 'plan.md'], kind: 'spec' });
144
- }
145
-
146
- return dirs;
140
+ // 6. SDD spec/plan artifacts are NOT indexed.
141
+ //
142
+ // They were (Epic #1269, kind: 'spec'), into this same `guidance` namespace.
143
+ // That was wrong on both axes:
144
+ //
145
+ // - Signal: a spec is PRE-implementation intent for one unit of work, not a
146
+ // project rule. Once implemented it is stale-by-construction, and a
147
+ // superseded approach surfacing at high similarity alongside real guidance
148
+ // is worse than absent. Specs accumulate without bound, so the guidance
149
+ // namespace degraded monotonically with project age.
150
+ // - Value: the active spec's path is already known (the `flo sdd` CLI just
151
+ // returned it) — reading it beats chunked retrieval of a doc you hold the
152
+ // path to. Cross-session discovery is served by `flo sdd list`, and the
153
+ // durable post-implementation signal already lands in `learnings` /
154
+ // `verify`.
155
+ //
156
+ // So the specs directory is EXCLUDED from the walk rather than merely
157
+ // skipped. Exclusion (not just dropping the step-6 entry) is what makes this
158
+ // correct for the config `moflo-sdd.md` recommends for reviewable specs
159
+ // a tracked `specs_dir` INSIDE a guidance dir, where the step-1 scan would
160
+ // otherwise pick spec.md/plan.md up as ordinary guidance markdown and
161
+ // reintroduce the pollution under a guidance prefix.
162
+ const specsDir = resolveSpecsDir(specsDirConfig);
163
+
164
+ return { dirs, excludeRoots: [specsDir] };
147
165
  }
148
166
 
149
- const GUIDANCE_DIRS = loadGuidanceDirs();
167
+ const { dirs: GUIDANCE_DIRS, excludeRoots: EXCLUDE_ROOTS } = loadGuidanceDirs();
150
168
 
151
169
  // Chunking config - optimized for Claude's retrieval
152
170
  const MIN_CHUNK_SIZE = 50; // Lower minimum to avoid mega-chunks
@@ -612,8 +630,15 @@ function indexFile(db, filePath, keyPrefix, options = {}) {
612
630
  };
613
631
  });
614
632
 
633
+ // keyPattern is load-bearing, not belt-and-braces: `${chunkPrefix}-%` as a
634
+ // bare LIKE also matches every chunk of any sibling doc whose name extends
635
+ // this one's (indexing `flo` matches `chunk-skill-flo-simplify-0`). Those
636
+ // rows aren't in chunkRows, so the orphan sweep deleted the sibling's whole
637
+ // index whenever THIS doc changed. Anchoring on the numeric chunk suffix
638
+ // confines the sweep to the keys this file actually owns.
615
639
  const counts = applyIncrementalChunks(db, NAMESPACE, chunkRows, {
616
640
  keyPrefix: `${chunkPrefix}-`,
641
+ keyPattern: new RegExp(`^${chunkPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}-\\d+$`),
617
642
  });
618
643
  if (verbose) {
619
644
  debug(` Doc ${docKey}: inserted=${counts.inserted} updated=${counts.updated} unchanged=${counts.unchanged} removed=${counts.removed}`);
@@ -628,20 +653,28 @@ function indexFile(db, filePath, keyPrefix, options = {}) {
628
653
  /**
629
654
  * Recursively collect all .md files under a directory.
630
655
  * Skips node_modules, .git, and other non-content directories.
656
+ *
657
+ * `excludeRoots` (absolute paths) prunes whole subtrees — used to keep the SDD
658
+ * specs directory out of the index even when it sits inside a guidance dir.
659
+ * Compares resolved absolute paths, never raw strings, and matches on a
660
+ * `path.sep` boundary so `docs/specs` cannot also prune `docs/specs-guide`.
631
661
  */
632
- function walkMdFiles(dir) {
662
+ function walkMdFiles(dir, excludeRoots = []) {
633
663
  const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.next', '.reports']);
634
664
  // CLAUDE.md is loaded into context by Claude automatically — skip to avoid duplicate vectors
635
665
  const SKIP_FILES = new Set(['CLAUDE.md']);
636
666
  const files = [];
637
667
 
668
+ const isExcluded = (p) => excludeRoots.some(root => p === root || p.startsWith(root + sep));
669
+
638
670
  function walk(current) {
639
671
  if (!existsSync(current)) return;
640
672
  for (const entry of readdirSync(current, { withFileTypes: true })) {
673
+ const full = resolve(current, entry.name);
641
674
  if (entry.isDirectory()) {
642
- if (!SKIP_DIRS.has(entry.name)) walk(resolve(current, entry.name));
675
+ if (!SKIP_DIRS.has(entry.name) && !isExcluded(full)) walk(full);
643
676
  } else if (entry.isFile() && entry.name.endsWith('.md') && !SKIP_FILES.has(entry.name)) {
644
- files.push(resolve(current, entry.name));
677
+ if (!isExcluded(full)) files.push(full);
645
678
  }
646
679
  }
647
680
  }
@@ -659,7 +692,7 @@ function indexDirectory(db, dirConfig) {
659
692
  return results;
660
693
  }
661
694
 
662
- const allMdFiles = walkMdFiles(dirPath);
695
+ const allMdFiles = walkMdFiles(dirPath, EXCLUDE_ROOTS);
663
696
  const filtered = dirConfig.fileFilter
664
697
  ? allMdFiles.filter(f => dirConfig.fileFilter.includes(basename(f)))
665
698
  : allMdFiles;
@@ -674,17 +707,6 @@ function indexDirectory(db, dirConfig) {
674
707
  extraMetadata: { kind: 'skill', skill_name: skillName },
675
708
  extraTags: ['skill', `skill-${skillName}`],
676
709
  };
677
- } else if (dirConfig.kind === 'spec') {
678
- // kind: 'spec' (Epic #1269) — key by <slug>-<spec|plan> so a spec.md and
679
- // plan.md under the same slug, and identically-named files across slugs,
680
- // never collide on the doc key.
681
- const slug = basename(dirname(filePath));
682
- const artifact = basename(filePath, extname(filePath)); // 'spec' | 'plan'
683
- options = {
684
- nameOverride: `${slug}-${artifact}`,
685
- extraMetadata: { kind: 'spec', spec_slug: slug, artifact },
686
- extraTags: ['spec', `spec-${slug}`, artifact],
687
- };
688
710
  }
689
711
  const result = indexFile(db, filePath, dirConfig.prefix, options);
690
712
  results.push(result);
@@ -693,37 +715,77 @@ function indexDirectory(db, dirConfig) {
693
715
  return results;
694
716
  }
695
717
 
718
+ /**
719
+ * Derive a chunk row's owning doc prefix from its key.
720
+ *
721
+ * Chunk keys are `${chunkPrefix}-${i}`, so stripping the FINAL `-<digits>`
722
+ * recovers the prefix. Only the last segment is stripped, which keeps
723
+ * docs whose filename itself ends in digits intact:
724
+ * `chunk-guidance-issue-1402-0` → `chunk-guidance-issue-1402`, not
725
+ * `chunk-guidance-issue`.
726
+ *
727
+ * Returns null for a key with no numeric suffix — those are not chunk rows
728
+ * this indexer wrote, and the caller leaves them alone rather than guessing.
729
+ */
730
+ function chunkPrefixOf(key) {
731
+ const m = key.match(/^(.*)-\d+$/);
732
+ return m ? m[1] : null;
733
+ }
734
+
696
735
  /**
697
736
  * Remove stale entries for files that no longer exist on disk.
698
- * Uses the set of docKeys seen during the current indexing run to determine
699
- * which entries are stale, rather than reconstructing file paths from keys
700
- * (which breaks for files in subdirectories).
737
+ *
738
+ * Keyed on the chunk prefixes seen during the current run, NOT on `doc-*` rows.
739
+ * The original implementation enumerated `key LIKE 'doc-%'` and treated any doc
740
+ * key absent from the run as a deleted file — but #1053 S4 retired doc rows
741
+ * (the chunker stopped writing them and `purge-doc-entries` deleted the rest),
742
+ * so on any current install that query returns zero rows and the sweep was a
743
+ * silent no-op. Deleting a guidance file left its chunks — embeddings and all —
744
+ * in the namespace permanently, with nothing downstream to detect it. Specs made
745
+ * that visible because they accumulate fastest, but it stranded chunks for every
746
+ * deleted guidance file, skill, and doc.
747
+ *
748
+ * Safety: an empty live set means the run indexed nothing (I/O error, config
749
+ * pointing at a missing tree). Sweeping then would wipe the namespace, so bail
750
+ * and leave the rows for a later healthy run to reconcile.
701
751
  */
702
- function cleanStaleEntries(db, currentDocKeys) {
703
- const docsStmt = db.prepare(
704
- `SELECT DISTINCT key FROM memory_entries WHERE namespace = ? AND key LIKE 'doc-%'`
752
+ function cleanStaleEntries(db, currentChunkPrefixes) {
753
+ if (currentChunkPrefixes.size === 0) {
754
+ log(' Skipped: this run indexed no files (refusing to sweep on an empty live set)');
755
+ return 0;
756
+ }
757
+
758
+ const chunkStmt = db.prepare(
759
+ `SELECT DISTINCT key FROM memory_entries WHERE namespace = ? AND key LIKE 'chunk-%'`
705
760
  );
706
- docsStmt.bind([NAMESPACE]);
707
- const docs = [];
708
- while (docsStmt.step()) docs.push(docsStmt.getAsObject());
709
- docsStmt.free();
761
+ chunkStmt.bind([NAMESPACE]);
762
+ const chunkKeys = [];
763
+ while (chunkStmt.step()) chunkKeys.push(chunkStmt.getAsObject().key);
764
+ chunkStmt.free();
765
+
766
+ // Group stale chunk keys by prefix so the log reports one line per deleted
767
+ // file rather than one per chunk.
768
+ const stalePrefixes = new Map();
769
+ for (const key of chunkKeys) {
770
+ const prefix = chunkPrefixOf(key);
771
+ if (!prefix || currentChunkPrefixes.has(prefix)) continue;
772
+ stalePrefixes.set(prefix, (stalePrefixes.get(prefix) ?? 0) + 1);
773
+ }
710
774
 
711
775
  let staleCount = 0;
712
-
713
- for (const { key } of docs) {
714
- // If this doc key was seen during the current indexing run, it's not stale
715
- if (currentDocKeys.has(key)) continue;
716
-
717
- const chunkPrefix = key.replace('doc-', 'chunk-');
718
- const countBefore = db.exec(`SELECT COUNT(*) as cnt FROM memory_entries WHERE namespace = '${NAMESPACE}'`)[0]?.values[0][0] || 0;
719
- db.run(`DELETE FROM memory_entries WHERE namespace = ? AND key LIKE ?`, [NAMESPACE, `${chunkPrefix}%`]);
720
- db.run(`DELETE FROM memory_entries WHERE namespace = ? AND key = ?`, [NAMESPACE, key]);
721
- const countAfter = db.exec(`SELECT COUNT(*) as cnt FROM memory_entries WHERE namespace = '${NAMESPACE}'`)[0]?.values[0][0] || 0;
722
- const removed = countBefore - countAfter;
723
- if (removed > 0) {
724
- log(` Removed ${removed} stale entries for deleted file: ${key}`);
725
- staleCount += removed;
776
+ const del = db.prepare(`DELETE FROM memory_entries WHERE namespace = ? AND key = ?`);
777
+ try {
778
+ for (const key of chunkKeys) {
779
+ const prefix = chunkPrefixOf(key);
780
+ if (!prefix || currentChunkPrefixes.has(prefix)) continue;
781
+ del.run([NAMESPACE, key]);
782
+ staleCount++;
726
783
  }
784
+ } finally {
785
+ del.free();
786
+ }
787
+ for (const [prefix, count] of stalePrefixes) {
788
+ log(` Removed ${count} stale entries for deleted file: ${prefix}`);
727
789
  }
728
790
 
729
791
  // Also clean any orphaned entries not matching doc-/chunk- patterns
@@ -740,6 +802,22 @@ function cleanStaleEntries(db, currentDocKeys) {
740
802
  log(` Removed orphan entry: ${key}`);
741
803
  }
742
804
 
805
+ // Legacy `doc-*` rows from a pre-#1053-S4 install that never ran the
806
+ // purge-doc-entries migration. Unconditional — the chunker has not written
807
+ // one since S4, so any survivor is stale by definition.
808
+ const docStmt = db.prepare(
809
+ `SELECT key FROM memory_entries WHERE namespace = ? AND key LIKE 'doc-%'`
810
+ );
811
+ docStmt.bind([NAMESPACE]);
812
+ const legacyDocs = [];
813
+ while (docStmt.step()) legacyDocs.push(docStmt.getAsObject().key);
814
+ docStmt.free();
815
+ for (const key of legacyDocs) {
816
+ db.run(`DELETE FROM memory_entries WHERE namespace = ? AND key = ?`, [NAMESPACE, key]);
817
+ staleCount++;
818
+ log(` Removed legacy doc entry: ${key}`);
819
+ }
820
+
743
821
  return staleCount;
744
822
  }
745
823
 
@@ -760,7 +838,10 @@ let docsIndexed = 0;
760
838
  let chunksIndexed = 0;
761
839
  let unchanged = 0;
762
840
  let errors = 0;
763
- const currentDocKeys = new Set();
841
+ // Chunk prefixes written by this run — the live set the stale sweep diffs
842
+ // against. Populated from every file that indexed OR was skipped as unchanged;
843
+ // an unchanged file is very much still on disk.
844
+ const currentChunkPrefixes = new Set();
764
845
 
765
846
  if (specificFile) {
766
847
  // Index single file
@@ -794,7 +875,7 @@ if (specificFile) {
794
875
 
795
876
  for (const result of results) {
796
877
  if (result.status === 'indexed' || result.status === 'unchanged') {
797
- currentDocKeys.add(result.docKey);
878
+ currentChunkPrefixes.add(result.docKey.replace(/^doc-/, 'chunk-'));
798
879
  }
799
880
  if (result.status === 'indexed') {
800
881
  log(` ✅ ${result.docKey} (${result.chunks} chunks)`);
@@ -814,7 +895,7 @@ if (specificFile) {
814
895
  let staleRemoved = 0;
815
896
  if (!specificFile) {
816
897
  log('Cleaning stale entries for deleted files...');
817
- staleRemoved = cleanStaleEntries(db, currentDocKeys);
898
+ staleRemoved = cleanStaleEntries(db, currentChunkPrefixes);
818
899
  if (staleRemoved === 0) {
819
900
  log(' No stale entries found');
820
901
  }
@@ -98,9 +98,12 @@ export function schemeTaggedContentHash(files, schemeVersion) {
98
98
  * @param {string} namespace
99
99
  * @param {string} [keyPrefix] — when set, restricts the scan to `key LIKE '<prefix>%'`.
100
100
  * The same prefix scopes the orphan sweep in {@link applyIncrementalChunks}.
101
+ * @param {RegExp} [keyPattern] — optional second filter applied in JS after the
102
+ * SQL `LIKE`. Required whenever one caller's `keyPrefix` can be a string
103
+ * prefix of another's — see {@link applyIncrementalChunks}.
101
104
  * @returns {Map<string,string>}
102
105
  */
103
- export function loadExistingContent(db, namespace, keyPrefix) {
106
+ export function loadExistingContent(db, namespace, keyPrefix, keyPattern) {
104
107
  const stmt = keyPrefix
105
108
  ? db.prepare(
106
109
  `SELECT key, content FROM memory_entries WHERE namespace = ? AND key LIKE ? AND status = 'active'`,
@@ -116,7 +119,9 @@ export function loadExistingContent(db, namespace, keyPrefix) {
116
119
  const map = new Map();
117
120
  while (stmt.step()) {
118
121
  const row = stmt.getAsObject();
119
- map.set(String(row.key), String(row.content ?? ''));
122
+ const key = String(row.key);
123
+ if (keyPattern && !keyPattern.test(key)) continue;
124
+ map.set(key, String(row.content ?? ''));
120
125
  }
121
126
  stmt.free();
122
127
  return map;
@@ -137,12 +142,21 @@ export function loadExistingContent(db, namespace, keyPrefix) {
137
142
  * when processing a single file's chunks at a time (e.g. index-guidance.mjs
138
143
  * iterates files independently) — without it the sweep would delete every
139
144
  * chunk from every OTHER file as an orphan on each call.
145
+ * @param {RegExp} [opts.keyPattern] — narrows `keyPrefix` beyond what SQL `LIKE`
146
+ * can express. REQUIRED when one file's prefix can be a string prefix of
147
+ * another's, which is exactly the case for per-file chunk keys: indexing
148
+ * `flo` scopes to `chunk-skill-flo-%`, and that LIKE also matches every chunk
149
+ * of `flo-simplify`. Those rows are absent from the caller's `chunks`, so the
150
+ * orphan sweep deleted a sibling document's entire index — it reappeared only
151
+ * on the next run, re-inserted with a NULL embedding and re-vectorised from
152
+ * scratch. Passing `/^chunk-skill-flo-\d+$/` confines the sweep to the chunk
153
+ * keys the caller actually owns.
140
154
  * @returns {{inserted:number, updated:number, unchanged:number, removed:number}}
141
155
  */
142
156
  export function applyIncrementalChunks(db, namespace, chunks, opts = {}) {
143
157
  const serialize = opts.serialize !== false;
144
158
  const keyPrefix = opts.keyPrefix;
145
- const existing = loadExistingContent(db, namespace, keyPrefix);
159
+ const existing = loadExistingContent(db, namespace, keyPrefix, opts.keyPattern);
146
160
  const newKeys = new Set();
147
161
  let inserted = 0;
148
162
  let updated = 0;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Migration: hard-delete SDD spec/plan chunks from the `guidance` namespace.
3
+ *
4
+ * `bin/index-guidance.mjs` used to index `<specs_dir>/<slug>/{spec,plan}.md`
5
+ * into `guidance` alongside real project guidance (Epic #1269, `kind: 'spec'`).
6
+ * A spec is pre-implementation intent for one unit of work, not a project rule:
7
+ * once implemented it is stale-by-construction, and specs accumulate without
8
+ * bound, so the namespace degraded monotonically with project age. The indexer
9
+ * now excludes the specs directory outright.
10
+ *
11
+ * This clears what earlier versions already wrote. Two shapes existed:
12
+ *
13
+ * 1. `chunk-spec-*` keys with `metadata.kind === 'spec'` — the dedicated
14
+ * step-6 path, used when `specs_dir` sat OUTSIDE every guidance directory.
15
+ * 2. Ordinary guidance chunks under a guidance prefix — produced when
16
+ * `specs_dir` sat INSIDE a guidance dir (the config `moflo-sdd.md`
17
+ * recommends for reviewable specs). These carry no spec marker at all and
18
+ * are indistinguishable from real guidance by key or metadata.
19
+ *
20
+ * Only shape 1 is purged here, because it is the only one that can be
21
+ * identified without guessing. Shape 2 is handled by the repaired stale sweep
22
+ * in `bin/index-guidance.mjs`: the specs directory is now pruned from the walk,
23
+ * so those chunk prefixes fall out of the live set on the next index run and
24
+ * are swept as deleted files. Deleting them here by path-matching would risk
25
+ * taking real guidance with them.
26
+ *
27
+ * Idempotent: re-runs find no matching rows.
28
+ *
29
+ * @module bin/migrations/purge-spec-chunks
30
+ */
31
+
32
+ import { existsSync } from 'fs';
33
+ import { memoryDbPath } from '../lib/moflo-paths.mjs';
34
+ import { openBackend } from '../lib/get-backend.mjs';
35
+
36
+ export const name = 'purge-spec-chunks';
37
+ // After purge-doc-entries (0) and strip-context-preambles (20) so this operates
38
+ // on an already-normalised chunk table.
39
+ export const order = 30;
40
+
41
+ /**
42
+ * @param {string} projectRoot
43
+ * @returns {Promise<{purged:number}>}
44
+ */
45
+ export async function run(projectRoot) {
46
+ const dbPath = memoryDbPath(projectRoot);
47
+ if (!existsSync(dbPath)) return { purged: 0 };
48
+
49
+ const db = await openBackend(projectRoot, { create: false });
50
+
51
+ // Two independent markers, OR'd, because they were written by the same code
52
+ // path and either alone would leave rows behind on a partial index:
53
+ // - key prefix `chunk-spec-` (dirConfig.prefix === 'spec')
54
+ // - metadata.kind === 'spec' (survives even if the prefix ever changed)
55
+ // Scoped to `guidance` — the only namespace bin/index-guidance.mjs writes —
56
+ // so a user-stored entry elsewhere that happens to match is never touched.
57
+ const WHERE = `namespace = 'guidance'
58
+ AND (key LIKE 'chunk-spec-%' OR metadata LIKE '%"kind":"spec"%')`;
59
+
60
+ const countStmt = db.prepare(`SELECT COUNT(*) AS cnt FROM memory_entries WHERE ${WHERE}`);
61
+ countStmt.step();
62
+ const beforeCount = Number(countStmt.getAsObject().cnt ?? 0);
63
+ countStmt.free();
64
+
65
+ if (beforeCount === 0) {
66
+ db.close();
67
+ return { purged: 0 };
68
+ }
69
+
70
+ db.run(`DELETE FROM memory_entries WHERE ${WHERE}`);
71
+ const purged = db.getRowsModified?.() ?? beforeCount;
72
+
73
+ // No explicit HNSW invalidation needed: the delete moves the DB/WAL mtime,
74
+ // which is exactly what the `hnsw-rebuild` step gates on, so the sidecar
75
+ // reconciles on the next session-start indexer pass.
76
+ if (purged > 0) db.save();
77
+ db.close();
78
+ return { purged };
79
+ }
@@ -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.12.4-rc.9';
5
+ export const VERSION = '4.12.4';
6
6
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moflo",
3
- "version": "4.12.4-rc.9",
3
+ "version": "4.12.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",
@@ -98,7 +98,7 @@
98
98
  "@typescript-eslint/parser": "^8.65.0",
99
99
  "eslint": "^10.8.0",
100
100
  "glob": "^11.1.0",
101
- "moflo": "^4.12.4-rc.8",
101
+ "moflo": "^4.12.4-rc.10",
102
102
  "tsx": "^4.21.0",
103
103
  "typescript": "^5.9.3",
104
104
  "vitest": "^4.0.0"