moflo 4.13.4 → 4.13.5

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.
@@ -25,7 +25,7 @@
25
25
  * flo-codemap # Via PATH
26
26
  */
27
27
 
28
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'fs';
28
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
29
29
  import { resolve, dirname, relative, basename, extname } from 'path';
30
30
  import { fileURLToPath } from 'url';
31
31
  import { execSync, execFileSync, spawn } from 'child_process';
@@ -33,6 +33,7 @@ import { memoryDbPath, MOFLO_DIR, findProjectRoot } from './lib/moflo-paths.mjs'
33
33
  import { openBackend } from './lib/get-backend.mjs';
34
34
  import { applyIncrementalChunks, schemeTaggedContentHash } from './lib/incremental-write.mjs';
35
35
  import { resolveMofloBin } from './lib/resolve-bin.mjs';
36
+ import { listScopedSourceFiles } from './lib/source-scope.mjs';
36
37
 
37
38
 
38
39
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -50,13 +51,10 @@ const HASH_CACHE_PATH = resolve(projectRoot, MOFLO_DIR, 'code-map-hash.txt');
50
51
  // delegates here rather than shipping a second, divergent generator.
51
52
  // v2: unified scheme — invalidates every pre-#1260 cache (bare hash or the
52
53
  // old directory-level `flo memory code-map` output) exactly once on pickup.
53
- const SCHEME_VERSION = 2;
54
-
55
- // Directories to exclude from indexing
56
- const EXCLUDE_DIRS = [
57
- 'node_modules', 'dist', 'build', '.next', 'coverage',
58
- '.claude', 'template', 'back-office-template',
59
- ];
54
+ // v3: #1497 — enumeration now honours code_map.directories/exclude on the git
55
+ // path and drops test files, so every consumer's code-map rebuilds once and
56
+ // the orphan sweep removes the out-of-scope rows.
57
+ const SCHEME_VERSION = 3;
60
58
 
61
59
  // Heuristic descriptions for well-known directory names
62
60
  const DIR_DESCRIPTIONS = {
@@ -163,53 +161,6 @@ function countMissingEmbeddings(db) {
163
161
  // Source file enumeration — git ls-files with filesystem fallback
164
162
  // ---------------------------------------------------------------------------
165
163
 
166
- /** Read code_map config from moflo.yaml (directories, extensions, exclude). */
167
- function readCodeMapConfig() {
168
- const defaults = {
169
- directories: ['src'],
170
- extensions: [
171
- '.ts', '.tsx', '.js', '.mjs', '.cjs', '.jsx', // JS/TS
172
- '.py', '.pyi', // Python
173
- '.go', // Go
174
- '.java', '.kt', '.kts', // JVM
175
- '.cs', // C#
176
- '.rs', // Rust
177
- '.rb', // Ruby
178
- '.swift', // Swift
179
- '.php', // PHP
180
- '.c', '.h', '.cpp', '.hpp', '.cc', // C/C++
181
- ],
182
- exclude: [...EXCLUDE_DIRS],
183
- };
184
- try {
185
- const yamlPath = resolve(projectRoot, 'moflo.yaml');
186
- if (!existsSync(yamlPath)) return defaults;
187
- const content = readFileSync(yamlPath, 'utf-8');
188
- // Simple YAML parsing for code_map block
189
- const block = content.match(/code_map:\s*\n((?:\s+\w+:.*\n?|\s+- .*\n?)+)/);
190
- if (!block) return defaults;
191
- const lines = block[1].split('\n');
192
- let currentKey = null;
193
- const result = { ...defaults };
194
- for (const line of lines) {
195
- const keyMatch = line.match(/^\s+(\w+):/);
196
- const itemMatch = line.match(/^\s+- (.+)/);
197
- if (keyMatch) {
198
- currentKey = keyMatch[1];
199
- // Inline array: extensions: [".ts", ".tsx"]
200
- const inlineArray = line.match(/\[([^\]]+)\]/);
201
- if (inlineArray && (currentKey === 'extensions' || currentKey === 'exclude' || currentKey === 'directories')) {
202
- result[currentKey] = inlineArray[1].split(',').map(s => s.trim().replace(/^["']|["']$/g, ''));
203
- }
204
- } else if (itemMatch && currentKey) {
205
- if (!Array.isArray(result[currentKey])) result[currentKey] = [];
206
- result[currentKey].push(itemMatch[1].trim().replace(/^["']|["']$/g, ''));
207
- }
208
- }
209
- return result;
210
- } catch { return defaults; }
211
- }
212
-
213
164
  /**
214
165
  * Extension of `p`, lowercased.
215
166
  *
@@ -222,75 +173,14 @@ function normExt(p) {
222
173
  return extname(p).toLowerCase();
223
174
  }
224
175
 
225
- /** Walk a directory tree collecting source files (filesystem fallback). */
226
- function walkDir(dir, extensions, excludeSet, maxDepth = 8, depth = 0) {
227
- if (depth > maxDepth) return [];
228
- const results = [];
229
- let entries;
230
- try {
231
- entries = readdirSync(resolve(projectRoot, dir), { withFileTypes: true });
232
- } catch { return []; }
233
- for (const entry of entries) {
234
- if (excludeSet.has(entry.name)) continue;
235
- // Use forward slashes for consistent cross-platform paths
236
- const rel = dir ? `${dir}/${entry.name}` : entry.name;
237
- if (entry.isDirectory()) {
238
- results.push(...walkDir(rel, extensions, excludeSet, maxDepth, depth + 1));
239
- } else if (entry.isFile()) {
240
- if (extensions.has(normExt(entry.name))) results.push(rel);
241
- }
242
- }
243
- return results;
244
- }
245
-
176
+ /**
177
+ * In-scope source files (#1497): `code_map.directories` minus `code_map.exclude`,
178
+ * never gitignored, never a test file — on the git path and the fallback alike.
179
+ */
246
180
  function getSourceFiles() {
247
- const config = readCodeMapConfig();
248
- // Lowercase the configured list too, so `extensions: [".MJS"]` in a consumer's
249
- // moflo.yaml still matches the normalised extension of a scanned file.
250
- const extSet = new Set(config.extensions.map(e => e.toLowerCase()));
251
- const excludeSet = new Set(config.exclude);
252
-
253
- // Build git glob patterns from configured extensions.
254
- // `:(icase)` is required, not cosmetic: git pathspec globs are case-sensitive
255
- // regardless of `core.ignorecase` (verified on git 2.43 with the setting both
256
- // on and off), so a bare `*.mjs` misses a committed `Foo.MJS` on every
257
- // platform. This is the primary enumeration path — normalising `extname()` in
258
- // the filesystem fallback alone would leave it broken. (#1337)
259
- const gitGlobArgs = config.extensions.map(ext => `:(icase)*${ext}`);
260
-
261
- // Try git ls-files first (fast, respects .gitignore)
262
- try {
263
- const raw = execFileSync(
264
- 'git', ['ls-files', '--', ...gitGlobArgs],
265
- { cwd: projectRoot, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024, windowsHide: true }
266
- ).trim();
267
-
268
- if (raw) {
269
- const files = raw.split('\n')
270
- .map(f => f.replace(/\\/g, '/')) // normalize separators
271
- .filter(f => {
272
- for (const ex of EXCLUDE_DIRS) {
273
- if (f.startsWith(ex + '/')) return false;
274
- }
275
- return true;
276
- });
277
- if (files.length > 0) return files;
278
- }
279
- } catch {
280
- // git not available or not a git repo — fall through
281
- }
282
-
283
- // Fallback: walk configured directories from moflo.yaml
284
- log('git ls-files returned no files — falling back to filesystem walk');
285
- const files = [];
286
-
287
- for (const dir of config.directories) {
288
- if (existsSync(resolve(projectRoot, dir))) {
289
- files.push(...walkDir(dir, extSet, excludeSet));
290
- }
291
- }
292
-
293
- return files;
181
+ return listScopedSourceFiles(projectRoot, {
182
+ onFallback: () => log('git ls-files returned no files — falling back to filesystem walk'),
183
+ });
294
184
  }
295
185
 
296
186
  function isUnchanged(currentHash) {
@@ -119,7 +119,18 @@ function loadGuidanceDirs() {
119
119
  resolve(bundledGuidanceDir) !== resolve(projectGuidanceDir) &&
120
120
  resolve(bundledGuidanceDir) !== resolve(projectGuidanceDir, 'shipped')
121
121
  ) {
122
- dirs.push({ path: bundledGuidanceDir, prefix: 'moflo-bundled', absolute: true });
122
+ // #1497 — session-start syncs every shipped doc into `.claude/guidance/`,
123
+ // so once that copy exists the bundled one is a byte-identical twin that
124
+ // halves the distinct docs per search. Shadow it — but only when the
125
+ // project's `.claude/guidance` is itself a configured directory; otherwise
126
+ // the synced copy is never indexed and the bundled one is the only copy.
127
+ const projectGuidanceIndexed = userDirs.some(d => resolve(projectRoot, d) === projectGuidanceDir);
128
+ dirs.push({
129
+ path: bundledGuidanceDir,
130
+ prefix: 'moflo-bundled',
131
+ absolute: true,
132
+ shadowDir: projectGuidanceIndexed ? projectGuidanceDir : null,
133
+ });
123
134
  }
124
135
 
125
136
  // 3. CLAUDE.md files are NOT indexed — Claude loads them into context automatically.
@@ -134,7 +145,17 @@ function loadGuidanceDirs() {
134
145
  // 5. Bundled moflo skills — gated by isSelfRef to prevent double-indexing
135
146
  const bundledSkillsDir = resolve(mofloRoot, '.claude/skills');
136
147
  if (!isSelfRef && existsSync(bundledSkillsDir) && resolve(bundledSkillsDir) !== resolve(projectSkillsDir)) {
137
- dirs.push({ path: bundledSkillsDir, prefix: 'skill-bundled', fileFilter: ['SKILL.md'], kind: 'skill', absolute: true });
148
+ // #1497 — skills synced into `.claude/skills/<name>/` are indexed by step 4;
149
+ // index a bundled skill only when the project holds no copy of it (e.g. a
150
+ // skill excluded by a `skills: categories` selection).
151
+ dirs.push({
152
+ path: bundledSkillsDir,
153
+ prefix: 'skill-bundled',
154
+ fileFilter: ['SKILL.md'],
155
+ kind: 'skill',
156
+ absolute: true,
157
+ shadowDir: projectSkillsDir,
158
+ });
138
159
  }
139
160
 
140
161
  // 6. SDD spec/plan artifacts are NOT indexed.
@@ -683,6 +704,17 @@ function walkMdFiles(dir, excludeRoots = []) {
683
704
  return files;
684
705
  }
685
706
 
707
+ /**
708
+ * True when a bundled file's project-side copy (same relative path under
709
+ * `dirConfig.shadowDir`) exists and is therefore indexed by another entry.
710
+ * Skipping it here also drops it from the run's live prefix set, so the stale
711
+ * sweep removes rows a previous run wrote for the bundled twin.
712
+ */
713
+ function isShadowed(dirConfig, dirPath, filePath) {
714
+ if (!dirConfig.shadowDir) return false;
715
+ return existsSync(resolve(dirConfig.shadowDir, relative(dirPath, filePath)));
716
+ }
717
+
686
718
  function indexDirectory(db, dirConfig) {
687
719
  const dirPath = dirConfig.absolute ? dirConfig.path : resolve(projectRoot, dirConfig.path);
688
720
  const results = [];
@@ -693,9 +725,10 @@ function indexDirectory(db, dirConfig) {
693
725
  }
694
726
 
695
727
  const allMdFiles = walkMdFiles(dirPath, EXCLUDE_ROOTS);
696
- const filtered = dirConfig.fileFilter
728
+ const filtered = (dirConfig.fileFilter
697
729
  ? allMdFiles.filter(f => dirConfig.fileFilter.includes(basename(f)))
698
- : allMdFiles;
730
+ : allMdFiles
731
+ ).filter(f => !isShadowed(dirConfig, dirPath, f));
699
732
 
700
733
  for (const filePath of filtered) {
701
734
  let options = {};
@@ -7,7 +7,6 @@
7
7
  * - API route definitions and middleware usage
8
8
  * - Error handling strategies per file
9
9
  * - Export conventions per module
10
- * - Test patterns (describe/it structure)
11
10
  * - Configuration patterns
12
11
  *
13
12
  * Chunk types:
@@ -24,14 +23,15 @@
24
23
  * flo-patterns # Via PATH
25
24
  */
26
25
 
27
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'fs';
28
- import { resolve, dirname, relative, basename, extname } from 'path';
26
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
27
+ import { resolve, dirname, relative } from 'path';
29
28
  import { fileURLToPath } from 'url';
30
29
  import { resolveMofloBin } from './lib/resolve-bin.mjs';
31
30
  import { memoryDbPath, MOFLO_DIR, findProjectRoot } from './lib/moflo-paths.mjs';
32
31
  import { openBackend } from './lib/get-backend.mjs';
33
32
  import { applyIncrementalChunks, computeContentListHash } from './lib/incremental-write.mjs';
34
33
  import { createProcessManager } from './lib/process-manager.mjs';
34
+ import { listScopedSourceFiles } from './lib/source-scope.mjs';
35
35
 
36
36
  const __dirname = dirname(fileURLToPath(import.meta.url));
37
37
 
@@ -48,11 +48,7 @@ const statsOnly = args.includes('--stats');
48
48
  function log(msg) { console.log(`[index-patterns] ${msg}`); }
49
49
  function debug(msg) { if (verbose) console.log(`[index-patterns] ${msg}`); }
50
50
 
51
- const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py', '.go', '.rs']);
52
- const EXCLUDE_DIRS = new Set([
53
- 'node_modules', 'dist', 'build', '.next', 'coverage',
54
- '.claude', '.swarm', '.moflo', '.git', 'template',
55
- ]);
51
+ const SOURCE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py', '.go', '.rs'];
56
52
 
57
53
  // ---------------------------------------------------------------------------
58
54
  // Database helpers
@@ -113,21 +109,15 @@ function countNamespace(db) {
113
109
  // File collection
114
110
  // ---------------------------------------------------------------------------
115
111
 
116
- function collectSourceFiles(dir, maxDepth = 8, depth = 0) {
117
- if (depth > maxDepth) return [];
118
- const files = [];
119
- let entries;
120
- try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return []; }
121
- for (const entry of entries) {
122
- if (EXCLUDE_DIRS.has(entry.name)) continue;
123
- const fullPath = resolve(dir, entry.name);
124
- if (entry.isDirectory()) {
125
- files.push(...collectSourceFiles(fullPath, maxDepth, depth + 1));
126
- } else if (entry.isFile() && SOURCE_EXTENSIONS.has(extname(entry.name).toLowerCase())) {
127
- files.push(fullPath);
128
- }
129
- }
130
- return files;
112
+ /**
113
+ * Absolute paths of in-scope source files (#1497). Shares code-map's scope —
114
+ * `code_map.directories` minus `code_map.exclude`, never gitignored, never a
115
+ * test file — so a raw repo walk can no longer pull in build output such as
116
+ * `cdk.out/asset.<hash>/index.mjs` or re-index what the tests namespace owns.
117
+ */
118
+ function collectSourceFiles() {
119
+ return listScopedSourceFiles(projectRoot, { extensions: SOURCE_EXTENSIONS })
120
+ .map(rel => resolve(projectRoot, rel));
131
121
  }
132
122
 
133
123
  // ---------------------------------------------------------------------------
@@ -259,14 +249,14 @@ async function main() {
259
249
  if (statsOnly) {
260
250
  const db = await getDb();
261
251
  const count = countNamespace(db);
262
- const files = collectSourceFiles(projectRoot);
252
+ const files = collectSourceFiles();
263
253
  log(`${files.length} source files, ${count} chunks in patterns namespace`);
264
254
  db.close();
265
255
  return;
266
256
  }
267
257
 
268
258
  // Collect files
269
- const files = collectSourceFiles(projectRoot);
259
+ const files = collectSourceFiles();
270
260
  log(`Found ${files.length} source files`);
271
261
 
272
262
  if (files.length === 0) {
@@ -189,6 +189,14 @@ function newestGuidanceMtime(projectRoot) {
189
189
  return newest;
190
190
  }
191
191
 
192
+ function sourceScopeFingerprint(projectRoot) {
193
+ return {
194
+ sourceList: gitFileListHash(projectRoot, SOURCE_GLOBS),
195
+ config: safeMtime(resolve(projectRoot, 'moflo.yaml')),
196
+ mofloPkg: safeMtime(resolve(projectRoot, 'node_modules/moflo/package.json')),
197
+ };
198
+ }
199
+
192
200
  /**
193
201
  * Per-step fingerprint computers. Each takes `projectRoot` and returns a flat
194
202
  * `{ input → value }` object. All MUST be sync, cheap (no sql.js loads), and
@@ -212,9 +220,14 @@ const STEP_FINGERPRINT_COMPUTERS = {
212
220
  mofloPkg: safeMtime(resolve(projectRoot, 'node_modules/moflo/package.json')),
213
221
  }),
214
222
 
215
- 'code-map': (projectRoot) => ({ sourceList: gitFileListHash(projectRoot, SOURCE_GLOBS) }),
223
+ // Source edits, plus the two inputs that change WHICH files are in scope
224
+ // (#1497): `moflo.yaml` (code_map.directories / exclude) and a moflo upgrade
225
+ // (the scope rules ship with the package). Without them a scope fix or a
226
+ // config edit leaves out-of-scope rows in place until some unrelated source
227
+ // edit happens to invalidate the gate.
228
+ 'code-map': (projectRoot) => sourceScopeFingerprint(projectRoot),
216
229
  'test-index': (projectRoot) => ({ testList: gitFileListHash(projectRoot, TEST_GLOBS) }),
217
- 'patterns-index': (projectRoot) => ({ sourceList: gitFileListHash(projectRoot, SOURCE_GLOBS) }),
230
+ 'patterns-index': (projectRoot) => sourceScopeFingerprint(projectRoot),
218
231
 
219
232
  // Library-docs grounding (#1184). Re-index when installed dependency versions
220
233
  // change — which rewrites the lockfile (npm/yarn/pnpm/bun) — or when the dep
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Source-file scope shared by the code-map and patterns indexers (#1497).
3
+ *
4
+ * Both indexers used to enumerate the whole repository: code-map ran
5
+ * `git ls-files` over every tracked file and applied `code_map.directories` /
6
+ * `code_map.exclude` only in its filesystem fallback, and patterns walked the
7
+ * raw filesystem with no `.gitignore` handling at all — so gitignored build
8
+ * output (e.g. `cdk.out/asset.<hash>/index.mjs`) and every test file landed in
9
+ * semantic search. One scope, applied on every enumeration path, keeps them in
10
+ * step with each other and with what `moflo.yaml` says.
11
+ *
12
+ * Scope rules:
13
+ * - Only files under a configured `code_map.directories` entry (`.` = repo).
14
+ * - Never a gitignored file: enumeration is `git ls-files --cached --others
15
+ * --exclude-standard`, i.e. tracked files plus untracked-but-not-ignored.
16
+ * - Never a path matched by `code_map.exclude` — a bare name matches any
17
+ * path segment, a name containing `/` matches a repo-relative path prefix.
18
+ * - Never a test file — the `tests` namespace owns those.
19
+ *
20
+ * All returned paths are repo-relative with forward slashes on every platform.
21
+ */
22
+
23
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
24
+ import { resolve, extname } from 'node:path';
25
+ import { execFileSync } from 'node:child_process';
26
+
27
+ /**
28
+ * Top-level directories never indexed, whatever the config says. Kept as
29
+ * top-level prefixes (not segment matches) so a legitimate nested
30
+ * `src/template/` is not swallowed by the `template` entry.
31
+ */
32
+ export const BASELINE_EXCLUDE_DIRS = [
33
+ 'node_modules', 'dist', 'build', '.next', 'coverage',
34
+ '.claude', '.swarm', '.moflo', '.git', 'template', 'back-office-template',
35
+ ];
36
+
37
+ /** Same shapes `bin/index-tests.mjs` and `moflo.yaml tests.patterns` treat as tests. */
38
+ const TEST_FILE_PATTERNS = [/\.test\.\w+$/i, /\.spec\.\w+$/i, /\.test-\w+\.\w+$/i];
39
+
40
+ const DEFAULT_EXTENSIONS = [
41
+ '.ts', '.tsx', '.js', '.mjs', '.cjs', '.jsx', // JS/TS
42
+ '.py', '.pyi', // Python
43
+ '.go', // Go
44
+ '.java', '.kt', '.kts', // JVM
45
+ '.cs', // C#
46
+ '.rs', // Rust
47
+ '.rb', // Ruby
48
+ '.swift', // Swift
49
+ '.php', // PHP
50
+ '.c', '.h', '.cpp', '.hpp', '.cc', // C/C++
51
+ ];
52
+
53
+ /** Read the `code_map` block from moflo.yaml (directories, extensions, exclude). */
54
+ export function readCodeMapConfig(projectRoot) {
55
+ // Unconfigured `directories` means the whole repo — the scope code-map's git
56
+ // path and the patterns walk both had before #1497. A consumer whose
57
+ // moflo.yaml predates `code_map` (or omits `directories`) must not silently
58
+ // drop to `src/` only, or to nothing at all when there is no `src/` (Rule #2).
59
+ const defaults = {
60
+ directories: ['.'],
61
+ extensions: [...DEFAULT_EXTENSIONS],
62
+ exclude: [...BASELINE_EXCLUDE_DIRS],
63
+ };
64
+ try {
65
+ const yamlPath = resolve(projectRoot, 'moflo.yaml');
66
+ if (!existsSync(yamlPath)) return defaults;
67
+ const content = readFileSync(yamlPath, 'utf-8');
68
+ // Simple YAML parsing for code_map block
69
+ // Indented `#` lines are allowed inside the block so a commented-out list
70
+ // entry does not end it early and drop every entry after it.
71
+ const block = content.match(/code_map:\s*\n((?:\s+\w+:.*\n?|\s+- .*\n?|[ \t]+#.*\n?)+)/);
72
+ if (!block) return defaults;
73
+ const lines = block[1].split(/\r?\n/);
74
+ let currentKey = null;
75
+ const result = { ...defaults };
76
+ for (const line of lines) {
77
+ const keyMatch = line.match(/^\s+(\w+):/);
78
+ const itemMatch = line.match(/^\s+- (.+)/);
79
+ if (keyMatch) {
80
+ currentKey = keyMatch[1];
81
+ // Inline array: extensions: [".ts", ".tsx"]
82
+ const inlineArray = line.match(/\[([^\]]+)\]/);
83
+ if (inlineArray && (currentKey === 'extensions' || currentKey === 'exclude' || currentKey === 'directories')) {
84
+ result[currentKey] = inlineArray[1].split(',').map(s => s.trim().replace(/^["']|["']$/g, ''));
85
+ } else if (currentKey === 'directories' || currentKey === 'exclude' || currentKey === 'extensions') {
86
+ // Block list follows — start from empty rather than appending to the defaults.
87
+ result[currentKey] = [];
88
+ }
89
+ } else if (itemMatch && currentKey) {
90
+ if (!Array.isArray(result[currentKey])) result[currentKey] = [];
91
+ result[currentKey].push(itemMatch[1].trim().replace(/^["']|["']$/g, ''));
92
+ }
93
+ }
94
+ return result;
95
+ } catch { return defaults; }
96
+ }
97
+
98
+ /** Normalise a config path to repo-relative forward-slash form ('' = repo root). */
99
+ function normRel(p) {
100
+ return String(p).replace(/\\/g, '/').replace(/^\.\/+/, '').replace(/\/+$/, '').replace(/^\.$/, '');
101
+ }
102
+
103
+ // Rule #1: NTFS and APFS are case-insensitive by default, so `Src/` in config
104
+ // must still match git's `src/...` there. Linux compares exactly.
105
+ const foldCase = process.platform === 'win32' || process.platform === 'darwin'
106
+ ? (s) => s.toLowerCase()
107
+ : (s) => s;
108
+
109
+ const underPrefix = (path, prefix) => prefix === '' || path === prefix || path.startsWith(prefix + '/');
110
+
111
+ export function isTestFile(relPath) {
112
+ const name = relPath.slice(relPath.lastIndexOf('/') + 1);
113
+ return TEST_FILE_PATTERNS.some(p => p.test(name));
114
+ }
115
+
116
+ /**
117
+ * Build the in-scope predicate for a repo-relative forward-slash path.
118
+ * Exported for tests; indexers should call {@link listScopedSourceFiles}.
119
+ */
120
+ export function createScopeFilter(config) {
121
+ const dirs = (config.directories || []).map(d => foldCase(normRel(d)));
122
+ const exts = new Set((config.extensions || []).map(e => e.toLowerCase()));
123
+ const excludeNames = new Set();
124
+ const excludePrefixes = [];
125
+ for (const raw of config.exclude || []) {
126
+ const e = foldCase(normRel(raw));
127
+ if (!e) continue;
128
+ if (e.includes('/')) excludePrefixes.push(e);
129
+ else excludeNames.add(e);
130
+ }
131
+ const baseline = BASELINE_EXCLUDE_DIRS.map(foldCase);
132
+
133
+ return (relPath) => {
134
+ const p = foldCase(relPath);
135
+ if (!exts.has(extname(p).toLowerCase())) return false;
136
+ if (!dirs.some(d => underPrefix(p, d))) return false;
137
+ if (baseline.some(b => p.startsWith(b + '/'))) return false;
138
+ if (excludePrefixes.some(e => underPrefix(p, e))) return false;
139
+ const segments = p.split('/');
140
+ if (segments.some(s => excludeNames.has(s))) return false;
141
+ return !isTestFile(p);
142
+ };
143
+ }
144
+
145
+ /**
146
+ * Git enumeration: tracked files plus untracked-but-not-ignored files, so a
147
+ * gitignored build artefact is never returned and a brand-new source file is.
148
+ * Returns null when git is unavailable or this is not a work tree.
149
+ */
150
+ function gitListFiles(projectRoot, extensions) {
151
+ // `:(icase)` — git pathspec globs are case-sensitive regardless of
152
+ // `core.ignorecase`, so a bare `*.mjs` would miss a committed `Foo.MJS` (#1337).
153
+ const pathspecGlobs = extensions.map(ext => `:(icase)*${ext}`);
154
+ try {
155
+ const raw = execFileSync(
156
+ 'git', ['ls-files', '--cached', '--others', '--exclude-standard', '--', ...pathspecGlobs],
157
+ // stderr ignored: "not a git repository" is the expected fallback signal,
158
+ // not something to print into a consumer's session-start output.
159
+ {
160
+ cwd: projectRoot, encoding: 'utf-8', maxBuffer: 64 * 1024 * 1024, windowsHide: true,
161
+ stdio: ['ignore', 'pipe', 'ignore'],
162
+ },
163
+ ).trim();
164
+ if (!raw) return null;
165
+ // --cached + --others can list a path twice (e.g. mid-merge); the caller dedupes.
166
+ return raw.split(/\r?\n/).map(f => f.replace(/\\/g, '/'));
167
+ } catch {
168
+ return null;
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Filesystem fallback: walk each configured directory. Only the two trees that
174
+ * are never source are pruned here (for speed); the scope filter decides the rest.
175
+ */
176
+ const WALK_PRUNE = new Set(['node_modules', '.git']);
177
+
178
+ function walkFiles(projectRoot, rel, maxDepth = 8, depth = 0, out = []) {
179
+ if (depth > maxDepth) return out;
180
+ let entries;
181
+ try {
182
+ entries = readdirSync(resolve(projectRoot, rel || '.'), { withFileTypes: true });
183
+ } catch { return out; }
184
+ for (const entry of entries) {
185
+ const child = rel ? `${rel}/${entry.name}` : entry.name;
186
+ if (entry.isDirectory()) {
187
+ if (WALK_PRUNE.has(entry.name)) continue;
188
+ walkFiles(projectRoot, child, maxDepth, depth + 1, out);
189
+ } else if (entry.isFile()) {
190
+ out.push(child);
191
+ }
192
+ }
193
+ return out;
194
+ }
195
+
196
+ /**
197
+ * Every in-scope source file, repo-relative with forward slashes, sorted.
198
+ *
199
+ * @param {string} projectRoot
200
+ * @param {{ extensions?: string[], config?: object, onFallback?: () => void }} [opts]
201
+ * `extensions` overrides `code_map.extensions` (patterns has its own list);
202
+ * `config` bypasses the moflo.yaml read (tests).
203
+ */
204
+ export function listScopedSourceFiles(projectRoot, opts = {}) {
205
+ const config = { ...(opts.config || readCodeMapConfig(projectRoot)) };
206
+ if (opts.extensions) config.extensions = opts.extensions;
207
+ const inScope = createScopeFilter(config);
208
+
209
+ let candidates = gitListFiles(projectRoot, config.extensions);
210
+ if (!candidates) {
211
+ opts.onFallback?.();
212
+ candidates = [];
213
+ for (const dir of config.directories) {
214
+ const rel = normRel(dir);
215
+ if (existsSync(resolve(projectRoot, rel || '.'))) walkFiles(projectRoot, rel, 8, 0, candidates);
216
+ }
217
+ }
218
+ return [...new Set(candidates.filter(inScope))].sort();
219
+ }
@@ -76,6 +76,11 @@ export const EMBEDDING_MODEL_LEGACY_DEFAULT = 'local';
76
76
  * `tasklist`, because the dashboard's Flo Runs tab
77
77
  * (`daemon-dashboard.ts handleSpells`) reads tasklist; purging it on every
78
78
  * session would empty the tab between sessions (#968).
79
+ *
80
+ * `verify` is deliberately NOT here (decided in #1497): `/verify` Step 0 runs a
81
+ * semantic `memory_search` over that namespace to surface prior verdicts for
82
+ * the area being changed, which needs embeddings. Its size is bounded by
83
+ * {@link VERIFY_RETENTION_CAP} instead.
79
84
  */
80
85
  export const EPHEMERAL_NAMESPACES = new Set([
81
86
  'hive-mind',
@@ -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.4';
5
+ export const VERSION = '4.13.5';
6
6
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moflo",
3
- "version": "4.13.4",
3
+ "version": "4.13.5",
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.3",
102
+ "moflo": "^4.13.4",
103
103
  "tsx": "^4.21.0",
104
104
  "typescript": "^5.9.3",
105
105
  "vitest": "^4.0.0"