moflo 4.10.15 → 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.
@@ -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.15';
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.15",
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.14",
98
+ "moflo": "^4.10.15",
99
99
  "tsx": "^4.21.0",
100
100
  "typescript": "^5.9.3",
101
101
  "vitest": "^4.0.0"