moflo 4.10.14 → 4.10.16

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.
@@ -145,6 +145,10 @@ For the full `moflo.yaml` schema, gate toggles, model routing, and sandbox confi
145
145
  | Every 5+ file changes | `map` | Update codebase map |
146
146
  | Complex debugging | `deepdive` | Deep code analysis |
147
147
 
148
+ ### Worker Report Location
149
+
150
+ Headless workers (`optimize`, `testgaps`, `ultralearn`, `refactor`, `deepdive`) write the latest run's full output to `.moflo/reports/<workerType>.<ext>` (`.md` for markdown workers, `.json` for `ultralearn`). The path is overwritten each run; for history, see `.moflo/logs/headless/`. The directory is gitignored by `flo init`, so reports never reach a consumer's commit. When the user asks "what did testgaps find?" or "where's the optimize report?", read `.moflo/reports/<workerType>.md` directly — do NOT re-run the worker.
151
+
148
152
  ### Memory-Enhanced Development
149
153
 
150
154
  | Action | When |
package/README.md CHANGED
@@ -419,7 +419,7 @@ flo daemon status # shows whether the service is registered AND running
419
419
 
420
420
  `flo spell schedule create` warns when the daemon isn't installed so you don't quietly miss runs.
421
421
 
422
- **Monitoring.** **[The Luminarium](#the-luminarium)** — moflo's localhost daemon dashboard — surfaces live schedules, recent executions, and per-schedule controls (disable / re-enable / run now), alongside worker health, memory stats, and Claude Code session stats. Each project gets its own deterministic port (33000–33999) recorded in `.moflo/daemon.lock`; ask `/luminarium` in your Claude session and it'll print the link.
422
+ **Monitoring.** **[The Luminarium](#the-luminarium)** — moflo's localhost daemon dashboard — surfaces live schedules, recent executions, and per-schedule controls (disable / re-enable / run now), alongside worker health, memory stats, and Claude Code session stats. Ask `/luminarium` in your Claude session and it'll print the link.
423
423
 
424
424
  For full configuration (`scheduler:` block in `moflo.yaml`), event types, and the catch-up window after restarts, see [docs/SPELLS.md#scheduling](docs/SPELLS.md#scheduling).
425
425
 
@@ -465,13 +465,7 @@ The Luminarium is moflo's localhost daemon dashboard. It boots automatically wit
465
465
 
466
466
  ### Finding the URL
467
467
 
468
- Each project gets a deterministic port in the range 33000–33999, derived from a hash of the project root so two projects never collide on the same machine. The actual bound port is written to `.moflo/daemon.lock` when the daemon starts if the deterministic port is already taken the daemon scans forward, so the lock file is the source of truth, not the hash.
469
-
470
- Three ways to get the URL:
471
-
472
- - **`/luminarium`** — inside a Claude Code session in a moflo project, this skill reads `.moflo/daemon.lock` and prints `http://localhost:<port>`. Fastest path.
473
- - **`flo daemon status`** — prints the URL alongside the health summary.
474
- - **`cat .moflo/daemon.lock`** — read the JSON directly: `{ "pid": ..., "port": 33421, ... }`.
468
+ Inside a Claude Code session in a moflo project, ask **`/luminarium`** the skill prints the dashboard URL for the current project. `flo daemon status` prints the same URL alongside the health summary. Each project binds its own port so two projects on the same machine never collide.
475
469
 
476
470
  ### What it shows
477
471
 
@@ -486,7 +480,7 @@ Three ways to get the URL:
486
480
  ### Flags
487
481
 
488
482
  - `flo daemon start --no-dashboard` — disable the HTTP server entirely (the daemon itself still runs)
489
- - `flo daemon start --dashboard-port <N>` — pin to a specific port, overriding the deterministic resolver. Also accepts the `MOFLO_DAEMON_PORT` env var, which the rest of moflo respects when talking to the daemon
483
+ - `flo daemon start --dashboard-port <N>` — pin to a specific port, overriding the per-project default. Also accepts the `MOFLO_DAEMON_PORT` env var, which the rest of moflo respects when talking to the daemon
490
484
 
491
485
  ## Commands
492
486
 
@@ -90,7 +90,7 @@ Provide actionable suggestions with code examples.`,
90
90
  - Check for missing error handling tests
91
91
  - Identify integration test gaps
92
92
 
93
- For each gap, provide a test skeleton.`,
93
+ For each gap, include a test skeleton inline in the report as a fenced code block — DO NOT create separate test files; the consumer will copy the skeletons into their test tree by hand.`,
94
94
  sandbox: 'permissive',
95
95
  model: 'sonnet',
96
96
  outputFormat: 'markdown',
@@ -300,11 +300,13 @@ export class HeadlessWorkerExecutor extends EventEmitter {
300
300
  maxContextFiles: options?.maxContextFiles ?? 20,
301
301
  maxCharsPerFile: options?.maxCharsPerFile ?? 5000,
302
302
  logDir: options?.logDir ?? join(projectRoot, '.moflo', 'logs', 'headless'),
303
+ reportsDir: options?.reportsDir ?? join(projectRoot, '.moflo', 'reports'),
303
304
  cacheContext: options?.cacheContext ?? true,
304
305
  cacheTtlMs: options?.cacheTtlMs ?? 60000, // 1 minute default
305
306
  };
306
- // Ensure log directory exists
307
+ // Ensure log + reports directories exist
307
308
  this.ensureLogDir();
309
+ this.ensureReportsDir();
308
310
  // Register for process-exit cleanup via the shared listener.
309
311
  ensureExitListener();
310
312
  liveExecutors.add(this);
@@ -546,6 +548,29 @@ export class HeadlessWorkerExecutor extends EventEmitter {
546
548
  this.emit('warning', { message: 'Failed to create log directory', error });
547
549
  }
548
550
  }
551
+ /**
552
+ * Ensure the reports directory exists. Reports land under `.moflo/reports/`
553
+ * (gitignored by `flo init`); without this directory the post-spawn write
554
+ * would no-op and the consumer would lose the report entirely.
555
+ */
556
+ ensureReportsDir() {
557
+ try {
558
+ if (!existsSync(this.config.reportsDir)) {
559
+ mkdirSync(this.config.reportsDir, { recursive: true });
560
+ }
561
+ }
562
+ catch (error) {
563
+ this.emit('warning', { message: 'Failed to create reports directory', error });
564
+ }
565
+ }
566
+ /**
567
+ * Resolve the on-disk report path for a worker. Extension matches the
568
+ * declared outputFormat so tools reading the report don't have to sniff.
569
+ */
570
+ getReportPath(workerType, outputFormat) {
571
+ const ext = outputFormat === 'json' ? 'json' : outputFormat === 'text' ? 'txt' : 'md';
572
+ return join(this.config.reportsDir, `${workerType}.${ext}`);
573
+ }
549
574
  /**
550
575
  * Internal execution logic
551
576
  */
@@ -558,8 +583,11 @@ export class HeadlessWorkerExecutor extends EventEmitter {
558
583
  try {
559
584
  // Build context from file patterns
560
585
  const context = await this.buildContext(headless.contextPatterns || []);
586
+ // Resolve the on-disk report path before prompt assembly so the prompt
587
+ // can quote the exact absolute path Claude should write to.
588
+ const reportPath = this.getReportPath(workerType, headless.outputFormat);
561
589
  // Build the full prompt
562
- const fullPrompt = this.buildPrompt(headless.promptTemplate, context);
590
+ const fullPrompt = this.buildPrompt(headless.promptTemplate, context, reportPath);
563
591
  // Log prompt for debugging
564
592
  this.logExecution(executionId, 'prompt', fullPrompt);
565
593
  // Execute Claude Code headlessly
@@ -571,6 +599,13 @@ export class HeadlessWorkerExecutor extends EventEmitter {
571
599
  workerType,
572
600
  signal,
573
601
  });
602
+ // Persist the spawn output to the canonical report path. Belt-and-braces
603
+ // against Claude ignoring the in-prompt instruction — this guarantees
604
+ // the consumer ends up with a report at a deterministic location no
605
+ // matter what the model chose to do with its Write tool.
606
+ if (result.success && result.output && result.output.trim().length > 0) {
607
+ this.writeReport(reportPath, result.output);
608
+ }
574
609
  // Parse output based on format
575
610
  let parsedOutput;
576
611
  if (headless.outputFormat === 'json' && result.output) {
@@ -768,15 +803,23 @@ export class HeadlessWorkerExecutor extends EventEmitter {
768
803
  return name === pattern;
769
804
  }
770
805
  /**
771
- * Build full prompt with context
806
+ * Build full prompt with context. The report path is injected so Claude
807
+ * saves output to the canonical `.moflo/reports/` location instead of
808
+ * dropping `*-analysis.md` / `*-report.md` files at the project root — the
809
+ * behaviour consumers were seeing before this change.
772
810
  */
773
- buildPrompt(template, context) {
811
+ buildPrompt(template, context, reportPath) {
812
+ const ioInstructions = `## Output
813
+
814
+ Save the full report to \`${reportPath}\` using the Write tool. Overwrite any prior content at that path. DO NOT create any other files anywhere in the project; if you need to suggest test skeletons or code samples, include them inline in the report as fenced code blocks. Moflo persists the same output to that path after you finish, so the location is authoritative.`;
774
815
  if (!context) {
775
816
  return `${template}
776
817
 
777
818
  ## Instructions
778
819
 
779
- Analyze the codebase and provide your response following the format specified in the task.`;
820
+ Analyze the codebase and provide your response following the format specified in the task.
821
+
822
+ ${ioInstructions}`;
780
823
  }
781
824
  return `${template}
782
825
 
@@ -786,7 +829,9 @@ ${context}
786
829
 
787
830
  ## Instructions
788
831
 
789
- Analyze the above codebase context and provide your response following the format specified in the task.`;
832
+ Analyze the above codebase context and provide your response following the format specified in the task.
833
+
834
+ ${ioInstructions}`;
790
835
  }
791
836
  /**
792
837
  * Execute Claude Code in headless mode
@@ -1011,6 +1056,22 @@ Analyze the above codebase context and provide your response following the forma
1011
1056
  // Ignore log write errors
1012
1057
  }
1013
1058
  }
1059
+ /**
1060
+ * Persist a worker's report to the canonical `.moflo/reports/` location.
1061
+ * The directory was created at construction time; we recreate it here as a
1062
+ * safety net in case it was removed between construction and execution.
1063
+ */
1064
+ writeReport(reportPath, content) {
1065
+ try {
1066
+ if (!existsSync(this.config.reportsDir)) {
1067
+ mkdirSync(this.config.reportsDir, { recursive: true });
1068
+ }
1069
+ writeFileSync(reportPath, content);
1070
+ }
1071
+ catch (error) {
1072
+ this.emit('warning', { message: 'Failed to write worker report', reportPath, error });
1073
+ }
1074
+ }
1014
1075
  }
1015
1076
  // Export default
1016
1077
  export default HeadlessWorkerExecutor;
@@ -262,14 +262,26 @@ export function computeHookBlockDrift(consumerHooks, referenceHooks) {
262
262
  // Settings.json helpers — shared between launcher + doctor
263
263
  // ────────────────────────────────────────────────────────────────────────────
264
264
  /**
265
- * True when the user has set `claudeFlow.hooks.locked: true` in their
266
- * settings.json a sentinel that suppresses drift surfacing entirely.
265
+ * True when the user has set `moflo.hooks.locked: true` (canonical) or
266
+ * `claudeFlow.hooks.locked: true` (legacy alias) in their settings.json
267
+ * a sentinel that suppresses drift surfacing entirely.
268
+ *
269
+ * The `claudeFlow.*` settings tree is a pre-rebrand legacy name that survives
270
+ * in writers + readers across the codebase. Renaming the whole tree is a
271
+ * separate effort; this one reader accepts `moflo.hooks.locked` ahead of the
272
+ * legacy key so the #1180 escape hatch is documented under the canonical
273
+ * brand from day one and consumers never have to migrate the key after we
274
+ * tell them to set it.
267
275
  */
268
276
  export function isHookBlockLocked(settings) {
269
277
  const root = settings;
278
+ const moflo = root?.moflo;
279
+ const mofloHooks = moflo?.hooks;
280
+ if (mofloHooks?.locked === true)
281
+ return true;
270
282
  const cf = root?.claudeFlow;
271
- const hooks = cf?.hooks;
272
- return hooks?.locked === true;
283
+ const cfHooks = cf?.hooks;
284
+ return cfHooks?.locked === true;
273
285
  }
274
286
  /**
275
287
  * Additively repair drift: for every entry in `report.missing`, locate the
@@ -309,26 +321,135 @@ export function applyAdditiveRegeneration(settings, report) {
309
321
  settings.hooks = hooks;
310
322
  return { settings, added, removed: 0 };
311
323
  }
324
+ /**
325
+ * Set of helper-script basenames that moflo ships under `.claude/helpers/` and
326
+ * `.claude/scripts/`. Built once from `getReferenceHookBlock()` so it always
327
+ * tracks whatever the current reference block actually emits. Used by the
328
+ * wholesale-regen path to tell "stale moflo entry from a removed reference
329
+ * shape" (drop) apart from "consumer customisation we never owned" (preserve).
330
+ */
331
+ let MOFLO_HELPER_BASENAMES_CACHE = null;
332
+ function getMofloHelperBasenames() {
333
+ if (MOFLO_HELPER_BASENAMES_CACHE)
334
+ return MOFLO_HELPER_BASENAMES_CACHE;
335
+ const out = new Set();
336
+ const tree = getReferenceHookBlock();
337
+ for (const event of Object.keys(tree)) {
338
+ for (const block of tree[event]) {
339
+ for (const hook of block.hooks) {
340
+ const m = hook.command.match(/\.claude\/(?:helpers|scripts)\/([\w.\-]+)/);
341
+ if (m)
342
+ out.add(m[1]);
343
+ }
344
+ }
345
+ }
346
+ MOFLO_HELPER_BASENAMES_CACHE = out;
347
+ return out;
348
+ }
349
+ /**
350
+ * True when a hook command references a moflo-shipped helper basename.
351
+ *
352
+ * Design: moflo "owns" the slot for any command pointing at one of its shipped
353
+ * helpers, so wholesale regen is free to replace that slot with the current
354
+ * reference. Conversely, a command pointing at a non-moflo helper basename is
355
+ * a consumer-owned customisation that must survive (#1180).
356
+ *
357
+ * Trade-off — hand-edits to a moflo helper command (e.g. consumer adds
358
+ * `--my-flag` to `gate-hook.mjs check-dangerous-command`) are treated as
359
+ * moflo-owned and replaced with the current reference shape on regen. The
360
+ * alternative (preserve any non-exact match) would silently retain stale
361
+ * entries like `gate.cjs session-reset` (removed in #842), defeating the
362
+ * whole point of wholesale regen. Consumers who need a tweaked moflo command
363
+ * should either lock the hook block via `moflo.hooks.locked: true`
364
+ * (`claudeFlow.hooks.locked` is still honoured as a legacy alias) or route
365
+ * through their own helper basename.
366
+ *
367
+ * Cross-platform: regex matches forward slashes only — what moflo always
368
+ * emits (Claude Code expands `$CLAUDE_PROJECT_DIR` on every OS). A consumer
369
+ * who hand-edited `settings.json` with backslashes on Windows would fail to
370
+ * match here and be treated as a customisation (preserved). That's the safer
371
+ * default — we'd rather keep an unfamiliar entry than delete user work.
372
+ */
373
+ function isMofloOwnedHookEntry(command) {
374
+ const m = command.match(/\.claude\/(?:helpers|scripts)\/([\w.\-]+)/);
375
+ return m ? getMofloHelperBasenames().has(m[1]) : false;
376
+ }
312
377
  /**
313
378
  * Wholesale regeneration: replace `settings.hooks` with the canonical reference
314
- * block. Drops extras (stale entries from previous moflo versions, e.g. the
315
- * `gate.cjs session-reset` SessionStart hook removed in #842) AND adds missing
316
- * entries — the additive variant only does the latter.
379
+ * block, then graft consumer customisations back in. Drops stale moflo entries
380
+ * (e.g. the `gate.cjs session-reset` SessionStart hook removed in #842) AND
381
+ * adds missing entries — the additive variant only does the latter.
382
+ *
383
+ * #1180 — `report.extra` carries both stale moflo entries AND consumer-owned
384
+ * customisations (e.g. waxstak's `project-analysis-gate.cjs`). The wholesale
385
+ * path used to drop both; it now distinguishes them via the helper-basename
386
+ * discriminator: any extra command referencing a moflo-shipped helper under
387
+ * `.claude/helpers/` or `.claude/scripts/` is stale moflo and gets dropped;
388
+ * anything else is consumer-owned and gets grafted back into the fresh tree
389
+ * under the same `(event, matcher)`, with its original `HookEntry`
390
+ * (type/timeout) snapshotted before the replace.
317
391
  *
318
392
  * The caller MUST check `isHookBlockLocked(settings)` first; if locked, the
319
393
  * user has opted out and this function should not be called. Non-hooks fields
320
- * on `settings` (permissions, env, claudeFlow.*, etc.) are preserved.
394
+ * on `settings` (permissions, env, moflo.*, claudeFlow.*, etc.) are preserved.
321
395
  *
322
396
  * Mutates `settings` in place; caller is responsible for writing the file.
323
397
  */
324
398
  export function applyWholesaleRegeneration(settings, report) {
325
399
  if (!report.drifted)
326
400
  return { settings, added: 0, removed: 0 };
401
+ // Snapshot full `HookEntry` objects for consumer customisations BEFORE we
402
+ // overwrite settings.hooks — the drift report carries command strings only,
403
+ // not timeout/type. Walk the consumer's existing tree, match against each
404
+ // non-moflo `extra` on (event, matcher, command).
405
+ const customisations = [];
406
+ const consumerHooks = (settings.hooks ?? {});
407
+ for (const extra of report.extra) {
408
+ if (isMofloOwnedHookEntry(extra.command))
409
+ continue;
410
+ const evtArr = consumerHooks[extra.event];
411
+ if (!Array.isArray(evtArr))
412
+ continue;
413
+ for (const block of evtArr) {
414
+ if ((block?.matcher ?? '') !== extra.matcher)
415
+ continue;
416
+ const found = Array.isArray(block.hooks)
417
+ ? block.hooks.find((h) => h?.command === extra.command)
418
+ : undefined;
419
+ if (found) {
420
+ customisations.push({ event: extra.event, matcher: extra.matcher, hook: found });
421
+ break;
422
+ }
423
+ }
424
+ }
327
425
  // Clone the cached reference so a later mutation of settings.hooks (by the
328
426
  // launcher's settings.json migrations, doctor --fix, etc.) cannot corrupt
329
427
  // the cached tree shared across `computeHookBlockDrift` calls in this process.
330
- settings.hooks = structuredClone(getCachedReference().tree);
331
- return { settings, added: report.missing.length, removed: report.extra.length };
428
+ const fresh = structuredClone(getCachedReference().tree);
429
+ // Graft customisations into the fresh tree, slotting them under the same
430
+ // matcher block (created if absent).
431
+ for (const { event, matcher, hook } of customisations) {
432
+ let arr = fresh[event];
433
+ if (!Array.isArray(arr)) {
434
+ arr = [];
435
+ fresh[event] = arr;
436
+ }
437
+ let block = arr.find(b => (b?.matcher ?? '') === matcher);
438
+ if (!block) {
439
+ block = { hooks: [] };
440
+ if (matcher)
441
+ block.matcher = matcher;
442
+ arr.push(block);
443
+ }
444
+ if (!Array.isArray(block.hooks))
445
+ block.hooks = [];
446
+ if (!block.hooks.some((h) => h?.command === hook.command)) {
447
+ block.hooks.push(hook);
448
+ }
449
+ }
450
+ settings.hooks = fresh;
451
+ const removed = report.extra.length - customisations.length;
452
+ return { settings, added: report.missing.length, removed };
332
453
  }
333
454
  /**
334
455
  * Format a drift report for human-readable output (multi-line, no colour).
@@ -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.10.14';
5
+ export const VERSION = '4.10.16';
6
6
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moflo",
3
- "version": "4.10.14",
3
+ "version": "4.10.16",
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",
@@ -95,7 +95,7 @@
95
95
  "@typescript-eslint/eslint-plugin": "^7.18.0",
96
96
  "@typescript-eslint/parser": "^7.18.0",
97
97
  "eslint": "^8.0.0",
98
- "moflo": "^4.10.13",
98
+ "moflo": "^4.10.15",
99
99
  "tsx": "^4.21.0",
100
100
  "typescript": "^5.9.3",
101
101
  "vitest": "^4.0.0"