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
|
-
|
|
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
|
-
|
|
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) |
|
|
37
|
-
| `docs/specs`, `.specs`, … (tracked) | Yes | You want
|
|
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
|
|
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
|
|
package/bin/index-guidance.mjs
CHANGED
|
@@ -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
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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(
|
|
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(
|
|
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
|
-
*
|
|
699
|
-
*
|
|
700
|
-
*
|
|
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,
|
|
703
|
-
|
|
704
|
-
|
|
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
|
-
|
|
707
|
-
const
|
|
708
|
-
while (
|
|
709
|
-
|
|
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
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/src/cli/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "moflo",
|
|
3
|
-
"version": "4.12.4
|
|
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.
|
|
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"
|