moflo 4.9.6 → 4.9.8

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.
package/bin/index-all.mjs CHANGED
@@ -2,9 +2,13 @@
2
2
  /**
3
3
  * Sequential indexer chain for session-start.
4
4
  *
5
- * Runs all DB-writing indexers one at a time to avoid sql.js last-write-wins
6
- * concurrency issues (#78), then triggers HNSW rebuild once everything is
7
- * committed (#81).
5
+ * Each step is gated independently see `lib/index-fingerprint.mjs`. The
6
+ * orchestrator just walks the plan, asks the gate per step, runs the
7
+ * survivors, and saves the post-run fingerprint when each succeeds.
8
+ *
9
+ * Steps run sequentially (DB-writing) to avoid sql.js last-write-wins
10
+ * concurrency issues (#78). HNSW rebuild is last, after every other step
11
+ * has committed (#81).
8
12
  *
9
13
  * Spawned as a single detached background process by hooks.mjs session-start.
10
14
  */
@@ -15,6 +19,21 @@ import { fileURLToPath } from 'url';
15
19
  import { spawn, spawnSync } from 'child_process';
16
20
  import { platform } from 'os';
17
21
  import { hnswIndexPath } from './lib/moflo-paths.mjs';
22
+ import {
23
+ decideStepGate,
24
+ computeStepFingerprint,
25
+ saveStepFingerprint,
26
+ cleanupLegacyFingerprint,
27
+ } from './lib/index-fingerprint.mjs';
28
+
29
+ // Cap fastembed/ONNX thread count when spawning the heavy steps. Without
30
+ // this, ONNX defaults to one thread per CPU core (22+ on a modern dev box),
31
+ // pegging the entire machine while the indexer runs. 2 threads keeps
32
+ // re-embedding throughput acceptable while leaving the box usable.
33
+ const ONNX_THREAD_CAP = {
34
+ OMP_NUM_THREADS: '2',
35
+ ONNXRUNTIME_INTRA_OP_NUM_THREADS: '2',
36
+ };
18
37
 
19
38
  const __dirname = dirname(fileURLToPath(import.meta.url));
20
39
 
@@ -56,7 +75,6 @@ function resolveBin(binName, localScript) {
56
75
 
57
76
  function getLocalCliPath() {
58
77
  const paths = [
59
- resolve(projectRoot, 'node_modules/moflo/bin/cli.js'),
60
78
  resolve(projectRoot, 'node_modules/moflo/bin/cli.js'),
61
79
  resolve(projectRoot, 'node_modules/.bin/flo'),
62
80
  // Development: local CLI
@@ -110,7 +128,7 @@ function killProcessTree(child) {
110
128
  }
111
129
  }
112
130
 
113
- function runStep(label, cmd, args, timeoutMs = 120_000) {
131
+ function runStep(label, cmd, args, timeoutMs = 120_000, extraEnv = null) {
114
132
  return new Promise((resolveStep) => {
115
133
  const start = Date.now();
116
134
  log(`START ${label}`);
@@ -119,6 +137,7 @@ function runStep(label, cmd, args, timeoutMs = 120_000) {
119
137
  stdio: 'ignore',
120
138
  windowsHide: true,
121
139
  detached: platform() !== 'win32', // POSIX: own process group for tree-kill
140
+ env: extraEnv ? { ...process.env, ...extraEnv } : process.env,
122
141
  });
123
142
  let timedOut = false;
124
143
  const timer = setTimeout(() => {
@@ -148,101 +167,140 @@ function runStep(label, cmd, args, timeoutMs = 120_000) {
148
167
  });
149
168
  }
150
169
 
151
- async function main() {
152
- const startTime = Date.now();
153
- log('Sequential indexing chain started');
154
-
155
- // 1. Guidance indexer
156
- if (isIndexEnabled('guidance')) {
157
- const guidanceScript = resolveBin('flo-index', 'index-guidance.mjs');
158
- if (guidanceScript) {
159
- await runStep('guidance-index', 'node', [guidanceScript, '--no-embeddings']);
160
- } else {
161
- log('SKIP guidance-index (script not found)');
162
- }
163
- } else {
164
- log('SKIP guidance-index (disabled in moflo.yaml)');
165
- }
170
+ /**
171
+ * Build the ordered step plan. Each entry is `{ name, cmd, args, timeoutMs, env? }`.
172
+ * Steps disabled in moflo.yaml or whose script can't be located are filtered
173
+ * out here so the run loop only sees runnable steps.
174
+ */
175
+ function buildStepPlan() {
176
+ const plan = [];
177
+ const localCli = getLocalCliPath();
166
178
 
167
- // 2. Code map generator (the big one ~22s)
168
- if (isIndexEnabled('code_map')) {
169
- const codeMapScript = resolveBin('flo-codemap', 'generate-code-map.mjs');
170
- if (codeMapScript) {
171
- await runStep('code-map', 'node', [codeMapScript, '--no-embeddings'], 180_000);
172
- } else {
173
- log('SKIP code-map (script not found)');
179
+ const consider = (name, cfgKey, scriptName, binName, args, timeoutMs = 120_000, env = null) => {
180
+ if (cfgKey && !isIndexEnabled(cfgKey)) {
181
+ log(`SKIP ${name} (disabled in moflo.yaml)`);
182
+ return;
174
183
  }
175
- } else {
176
- log('SKIP code-map (disabled in moflo.yaml)');
177
- }
178
-
179
- // 3. Test indexer
180
- if (isIndexEnabled('tests')) {
181
- const testScript = resolveBin('flo-testmap', 'index-tests.mjs');
182
- if (testScript) {
183
- await runStep('test-index', 'node', [testScript, '--no-embeddings']);
184
- } else {
185
- log('SKIP test-index (script not found)');
184
+ const script = scriptName ? resolveBin(binName, scriptName) : null;
185
+ if (scriptName && !script) {
186
+ log(`SKIP ${name} (script not found)`);
187
+ return;
186
188
  }
187
- } else {
188
- log('SKIP test-index (disabled in moflo.yaml)');
189
- }
189
+ plan.push({
190
+ name,
191
+ cmd: 'node',
192
+ args: scriptName ? [script, ...args] : args,
193
+ timeoutMs,
194
+ env,
195
+ });
196
+ };
190
197
 
191
- // 4. Patterns indexer
192
- if (isIndexEnabled('patterns')) {
193
- const patternsScript = resolveBin('flo-patterns', 'index-patterns.mjs');
194
- if (patternsScript) {
195
- await runStep('patterns-index', 'node', [patternsScript]);
196
- } else {
197
- log('SKIP patterns-index (script not found)');
198
- }
199
- } else {
200
- log('SKIP patterns-index (disabled in moflo.yaml)');
201
- }
198
+ consider('guidance-index', 'guidance', 'index-guidance.mjs', 'flo-index', ['--no-embeddings']);
199
+ consider('code-map', 'code_map', 'generate-code-map.mjs', 'flo-codemap', ['--no-embeddings'], 180_000);
200
+ consider('test-index', 'tests', 'index-tests.mjs', 'flo-testmap', ['--no-embeddings']);
201
+ consider('patterns-index', 'patterns', 'index-patterns.mjs', 'flo-patterns', []);
202
202
 
203
- // 5. Pretrain (extracts patterns from repository)
204
- const localCli = getLocalCliPath();
203
+ // Pretrain extracts patterns from the repo via the CLI subcommand. No
204
+ // direct script — invoke through the local flo binary.
205
205
  if (localCli) {
206
- await runStep('pretrain', 'node', [localCli, 'hooks', 'pretrain']);
206
+ plan.push({
207
+ name: 'pretrain',
208
+ cmd: 'node',
209
+ args: [localCli, 'hooks', 'pretrain'],
210
+ timeoutMs: 120_000,
211
+ });
207
212
  } else {
208
213
  log('SKIP pretrain (CLI not found)');
209
214
  }
210
215
 
211
- // 6. Build embeddings single pass for ALL namespaces, after all indexers finish.
212
- // Individual indexers are called with --no-embeddings to prevent background
213
- // embedding spawns that race with this chain (sql.js last-write-wins).
214
- const embeddingsScript = resolveBin('flo-embeddings', 'build-embeddings.mjs');
215
- if (embeddingsScript) {
216
- await runStep('build-embeddings', 'node', [embeddingsScript], 300_000);
216
+ // build-embeddings runs fastembed thread-capped to keep CPU usable.
217
+ consider('build-embeddings', null, 'build-embeddings.mjs', 'flo-embeddings', [], 300_000, ONNX_THREAD_CAP);
218
+
219
+ // HNSW MUST run last (after all DB writes are committed, #81). Same thread
220
+ // cap — rebuild-index loads fastembed for stats lookups.
221
+ //
222
+ // No `--force`: the embeddings-migration service (run by the launcher
223
+ // before this chain) handles model bumps, and `build-embeddings` above
224
+ // fills any rows that lack embeddings. So `rebuild-index` finds nothing
225
+ // to embed in steady state and takes the no-work path, which still
226
+ // refreshes the HNSW sidecar via `writeSidecarOrFail` and is followed by
227
+ // the existsSync post-check below. `--force` only added a 4000-row
228
+ // re-embed that the fingerprint gate (#858) is specifically trying to
229
+ // avoid (#859).
230
+ if (localCli) {
231
+ plan.push({
232
+ name: 'hnsw-rebuild',
233
+ cmd: 'node',
234
+ args: [localCli, 'memory', 'rebuild-index'],
235
+ timeoutMs: 300_000,
236
+ env: ONNX_THREAD_CAP,
237
+ });
217
238
  } else {
218
- log('SKIP build-embeddings (script not found)');
239
+ log('SKIP hnsw-rebuild (CLI not found)');
219
240
  }
220
241
 
221
- // 7. HNSW rebuild — MUST run last, after all writes are committed (#81).
222
- // rebuild-index now also writes the binary HNSW sidecar at
223
- // .moflo/hnsw.index, which can take longer than the default 120s on a
224
- // populated consumer DB — match build-embeddings' 300s budget.
242
+ return plan;
243
+ }
244
+
245
+ async function main() {
246
+ const startTime = Date.now();
247
+ const plan = buildStepPlan();
248
+
249
+ let ranAny = false;
250
+ let hnswAttempted = false;
225
251
  let hnswOk = true;
226
- if (localCli) {
227
- const ok = await runStep('hnsw-rebuild', 'node', [localCli, 'memory', 'rebuild-index', '--force'], 300_000);
252
+
253
+ for (const step of plan) {
254
+ const gate = decideStepGate(step.name, projectRoot);
255
+ if (gate.skip) {
256
+ log(`SKIP ${step.name} (${gate.reason})`);
257
+ continue;
258
+ }
259
+ log(`RUN ${step.name} (${gate.reason})`);
260
+ if (step.name === 'hnsw-rebuild') hnswAttempted = true;
261
+ const ok = await runStep(step.name, step.cmd, step.args, step.timeoutMs, step.env || null);
228
262
  if (ok) {
229
- const sidecar = hnswIndexPath(projectRoot);
230
- if (!existsSync(sidecar)) {
231
- // Loud failure: missing sidecar means cold-start memory search
232
- // silently rebuilds from SQL on every consumer process — the exact
233
- // regression this guard exists to surface.
234
- log(`FAIL hnsw-rebuild post-check: sidecar missing at ${sidecar}`);
235
- hnswOk = false;
263
+ // POST-run fingerprint: re-compute to capture any state mutated by
264
+ // this step (e.g. build-embeddings bumping memory.db mtime). Saving
265
+ // the POST value lets next session correctly compare against the
266
+ // stable post-step state.
267
+ try {
268
+ const post = computeStepFingerprint(step.name, projectRoot);
269
+ if (!saveStepFingerprint(step.name, projectRoot, post)) {
270
+ log(`WARN ${step.name} fingerprint save failed (next session will re-run)`);
271
+ }
272
+ } catch (err) {
273
+ const msg = (err && err.message ? err.message.split('\n')[0] : 'unknown');
274
+ log(`WARN ${step.name} fingerprint compute failed: ${msg}`);
236
275
  }
237
- } else {
276
+ ranAny = true;
277
+ } else if (step.name === 'hnsw-rebuild') {
238
278
  hnswOk = false;
239
279
  }
240
- } else {
241
- log('SKIP hnsw-rebuild (CLI not found)');
242
280
  }
243
281
 
282
+ // hnsw-rebuild post-check: sidecar must physically exist after the step
283
+ // ran successfully. Missing sidecar means cold-start memory search will
284
+ // silently rebuild from SQL on every consumer process — the regression
285
+ // this guard exists to surface (#854). Only meaningful when we actually
286
+ // tried to rebuild.
287
+ if (hnswAttempted && hnswOk) {
288
+ const sidecar = hnswIndexPath(projectRoot);
289
+ if (!existsSync(sidecar)) {
290
+ log(`FAIL hnsw-rebuild post-check: sidecar missing at ${sidecar}`);
291
+ hnswOk = false;
292
+ }
293
+ }
294
+
295
+ // Always tidy up the v1 fingerprint file from 4.9.7 — even on all-skip
296
+ // sessions, otherwise the orphan survives indefinitely.
297
+ cleanupLegacyFingerprint(projectRoot);
298
+
244
299
  const totalElapsed = ((Date.now() - startTime) / 1000).toFixed(1);
245
- log(`Sequential indexing chain complete (${totalElapsed}s)`);
300
+ log(ranAny
301
+ ? `Sequential indexing chain complete (${totalElapsed}s)`
302
+ : `Sequential indexing chain skipped — all steps gated unchanged (${totalElapsed}s)`);
303
+
246
304
  if (!hnswOk) process.exit(1);
247
305
  }
248
306
 
Binary file
@@ -14,6 +14,17 @@ import { fileURLToPath } from 'url';
14
14
  import { mofloDir } from './lib/moflo-paths.mjs';
15
15
  import { repairMemoryDbIfCorrupt } from './lib/db-repair.mjs';
16
16
 
17
+ // Headless skip (#860). The daemon's headless workers spawn `claude --print`
18
+ // with CLAUDE_CODE_HEADLESS=true (see src/cli/services/headless-worker-
19
+ // executor.ts). Each spawned Claude inherits SessionStart hooks, which
20
+ // would re-enter this launcher and fork the indexer chain — bumping
21
+ // memory.db mtime, invalidating the 4.9.7 fingerprint gate, and pegging
22
+ // CPU on the daemon's 15-min worker cycle.
23
+ if (process.env.CLAUDE_CODE_HEADLESS === 'true' || process.env.CLAUDE_CODE_HEADLESS === '1') {
24
+ emitWarning('session-start-launcher skipped (CLAUDE_CODE_HEADLESS=true)');
25
+ process.exit(0);
26
+ }
27
+
17
28
  const __dirname = dirname(fileURLToPath(import.meta.url));
18
29
 
19
30
  // Single source of truth for the launcher's guidance-mirror header. Section 3
@@ -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.9.6';
5
+ export const VERSION = '4.9.8';
6
6
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moflo",
3
- "version": "4.9.6",
3
+ "version": "4.9.8",
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",
@@ -81,7 +81,7 @@
81
81
  "@typescript-eslint/eslint-plugin": "^7.18.0",
82
82
  "@typescript-eslint/parser": "^7.18.0",
83
83
  "eslint": "^8.0.0",
84
- "moflo": "^4.9.5",
84
+ "moflo": "^4.9.7",
85
85
  "tsx": "^4.21.0",
86
86
  "typescript": "^5.9.3",
87
87
  "vitest": "^4.0.0"