moflo 4.9.10 → 4.9.12

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.
Files changed (37) hide show
  1. package/.claude/commands/simplify.md +78 -30
  2. package/.claude/guidance/shipped/moflo-cli-reference.md +201 -0
  3. package/.claude/guidance/shipped/moflo-core-guidance.md +30 -391
  4. package/.claude/guidance/shipped/moflo-cross-platform.md +20 -1
  5. package/.claude/guidance/shipped/moflo-guidance-rules.md +144 -0
  6. package/.claude/guidance/shipped/moflo-memory-strategy.md +1 -0
  7. package/.claude/guidance/shipped/moflo-memorydb-maintenance.md +33 -6
  8. package/.claude/guidance/shipped/moflo-session-start.md +154 -0
  9. package/.claude/guidance/shipped/moflo-settings-injection.md +124 -0
  10. package/.claude/guidance/shipped/moflo-source-hygiene.md +1 -1
  11. package/.claude/guidance/shipped/moflo-spell-custom-steps.md +126 -0
  12. package/.claude/guidance/shipped/moflo-spell-engine.md +4 -101
  13. package/.claude/guidance/shipped/moflo-subagents.md +10 -0
  14. package/.claude/guidance/shipped/moflo-task-icons.md +9 -0
  15. package/.claude/guidance/shipped/moflo-user-facing-language.md +8 -0
  16. package/.claude/guidance/shipped/moflo-yaml-reference.md +191 -0
  17. package/.claude/skills/connector-builder/SKILL.md +1 -1
  18. package/.claude/skills/guidance/SKILL.md +158 -0
  19. package/.claude/skills/publish/SKILL.md +16 -0
  20. package/.claude/skills/simplify/SKILL.md +90 -21
  21. package/.claude/skills/spell-builder/SKILL.md +2 -2
  22. package/.claude/skills/spell-builder/architecture.md +1 -1
  23. package/.claude/skills/spell-schedule/SKILL.md +167 -0
  24. package/bin/session-start-launcher.mjs +164 -11
  25. package/dist/src/cli/commands/doctor-checks-deep.js +62 -0
  26. package/dist/src/cli/commands/doctor.js +34 -1
  27. package/dist/src/cli/config/moflo-config.js +14 -3
  28. package/dist/src/cli/index.js +18 -0
  29. package/dist/src/cli/init/moflo-init.js +19 -4
  30. package/dist/src/cli/init/settings-generator.js +18 -3
  31. package/dist/src/cli/services/daemon-readiness.js +12 -0
  32. package/dist/src/cli/services/hook-block-hash.js +320 -0
  33. package/dist/src/cli/services/hook-wiring.js +54 -1
  34. package/dist/src/cli/services/index.js +2 -0
  35. package/dist/src/cli/services/process-registry.js +58 -0
  36. package/dist/src/cli/version.js +1 -1
  37. package/package.json +2 -2
@@ -552,4 +552,66 @@ export async function checkGateHealth() {
552
552
  message: `${caseCount} gate cases, ${hookCount} hook bindings, state file OK`,
553
553
  };
554
554
  }
555
+ /**
556
+ * Hash-based hook-block drift check (#881). Complements `checkGateHealth`'s
557
+ * required-pattern probe by detecting drift in *any* direction — missing
558
+ * events, modified commands, future hook events not yet covered by
559
+ * `REQUIRED_HOOK_WIRING`. Uses the self-contained `hook-block-hash` module so
560
+ * the same logic runs in `flo doctor`, the launcher, and unit tests.
561
+ *
562
+ * Reports `pass` when no drift, `warn` with a count summary when drift exists.
563
+ * Never `fail` — drift is informational; the user (or `regenerate` mode) is
564
+ * responsible for deciding what to do.
565
+ */
566
+ export async function checkHookBlockDrift() {
567
+ const projectDir = findConsumerProjectDir();
568
+ const settingsPath = join(projectDir, '.claude', 'settings.json');
569
+ if (!existsSync(settingsPath)) {
570
+ return {
571
+ name: 'Hook Block Drift',
572
+ status: 'warn',
573
+ message: '.claude/settings.json not found',
574
+ fix: 'npx moflo init',
575
+ };
576
+ }
577
+ let settings;
578
+ try {
579
+ settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
580
+ }
581
+ catch (e) {
582
+ return {
583
+ name: 'Hook Block Drift',
584
+ status: 'warn',
585
+ message: `cannot parse .claude/settings.json: ${errorDetail(e)}`,
586
+ };
587
+ }
588
+ const { computeHookBlockDrift, isHookBlockLocked } = await import('../services/hook-block-hash.js');
589
+ if (isHookBlockLocked(settings)) {
590
+ return {
591
+ name: 'Hook Block Drift',
592
+ status: 'pass',
593
+ message: 'drift check skipped — claudeFlow.hooks.locked: true',
594
+ };
595
+ }
596
+ const report = computeHookBlockDrift(settings.hooks ?? {});
597
+ if (!report.drifted) {
598
+ return {
599
+ name: 'Hook Block Drift',
600
+ status: 'pass',
601
+ message: `hook block matches reference (${report.consumerHash})`,
602
+ };
603
+ }
604
+ const parts = [];
605
+ parts.push(`drift ${report.consumerHash} vs ${report.referenceHash}`);
606
+ if (report.missing.length > 0)
607
+ parts.push(`${report.missing.length} missing`);
608
+ if (report.extra.length > 0)
609
+ parts.push(`${report.extra.length} custom`);
610
+ return {
611
+ name: 'Hook Block Drift',
612
+ status: 'warn',
613
+ message: parts.join(', '),
614
+ fix: 'set auto_update.hook_block_drift: regenerate in moflo.yaml, or claudeFlow.hooks.locked: true to suppress',
615
+ };
616
+ }
555
617
  //# sourceMappingURL=doctor-checks-deep.js.map
@@ -12,7 +12,7 @@ import { execSync, exec } from 'child_process';
12
12
  import { promisify } from 'util';
13
13
  import os from 'os';
14
14
  import { getDaemonLockHolder, releaseDaemonLock, isDaemonProcess } from '../services/daemon-lock.js';
15
- import { checkSubagentHealth, checkSpellExecution, checkMcpToolInvocation, checkHookExecution, checkMcpSpellIntegration, checkGateHealth, checkMofloDbBridge, getMofloRoot, } from './doctor-checks-deep.js';
15
+ import { checkSubagentHealth, checkSpellExecution, checkMcpToolInvocation, checkHookExecution, checkMcpSpellIntegration, checkGateHealth, checkHookBlockDrift, checkMofloDbBridge, getMofloRoot, } from './doctor-checks-deep.js';
16
16
  import { checkEmbeddingHygiene } from './doctor-embedding-hygiene.js';
17
17
  import { checkSwarmFunctional, checkHiveMindFunctional, } from './doctor-checks-swarm.js';
18
18
  import { checkMemoryAccessFunctional } from './doctor-checks-memory-access.js';
@@ -1114,12 +1114,37 @@ function killTrackedProcesses() {
1114
1114
  catch { /* ok */ }
1115
1115
  return killed;
1116
1116
  }
1117
+ // Read the set of moflo background PIDs registered with the shared
1118
+ // ProcessManager (.moflo/background-pids.json). These are legitimate tracked
1119
+ // background tasks (sequential indexer chain, daemon, MCP servers spawned by
1120
+ // session-start) — they are detached:true by design so their parents have
1121
+ // already exited, but they are NOT orphans. Without this allow-set,
1122
+ // findZombieProcesses() flags every running indexer step as a zombie.
1123
+ function readTrackedBackgroundPids() {
1124
+ const result = new Set();
1125
+ const registryFile = join(process.cwd(), '.moflo', 'background-pids.json');
1126
+ try {
1127
+ if (!existsSync(registryFile))
1128
+ return result;
1129
+ const entries = JSON.parse(readFileSync(registryFile, 'utf-8'));
1130
+ if (!Array.isArray(entries))
1131
+ return result;
1132
+ for (const entry of entries) {
1133
+ if (entry && typeof entry.pid === 'number' && entry.pid > 0) {
1134
+ result.add(entry.pid);
1135
+ }
1136
+ }
1137
+ }
1138
+ catch { /* malformed or unreadable — treat as empty */ }
1139
+ return result;
1140
+ }
1117
1141
  // Find and optionally kill orphaned moflo/claude-flow node processes.
1118
1142
  // A process is only "orphaned" if its parent is no longer alive — meaning
1119
1143
  // nothing will clean it up. MCP servers spawned by a live Claude Code session
1120
1144
  // have a live parent (claude.exe) and must not be flagged.
1121
1145
  async function findZombieProcesses(kill = false) {
1122
1146
  const legitimatePid = getDaemonLockHolder(process.cwd());
1147
+ const trackedPids = readTrackedBackgroundPids();
1123
1148
  const currentPid = process.pid;
1124
1149
  const parentPid = process.ppid;
1125
1150
  const found = [];
@@ -1161,6 +1186,11 @@ async function findZombieProcesses(kill = false) {
1161
1186
  for (const { pid, ppid } of candidates) {
1162
1187
  if (pid === currentPid || pid === parentPid || pid === legitimatePid)
1163
1188
  continue;
1189
+ // Tracked background tasks (indexer chain, etc.) are detached:true so their
1190
+ // parent is dead by design. The ProcessManager registry tells us they are
1191
+ // legitimate, not orphaned.
1192
+ if (trackedPids.has(pid))
1193
+ continue;
1164
1194
  if (isProcessAlive(ppid))
1165
1195
  continue;
1166
1196
  // Defense-in-depth: detached daemons have dead parents by design.
@@ -1518,6 +1548,7 @@ export const doctorCommand = {
1518
1548
  checkMcpSpellIntegration,
1519
1549
  checkHookExecution,
1520
1550
  checkGateHealth,
1551
+ checkHookBlockDrift,
1521
1552
  checkMofloDbBridge,
1522
1553
  // Issue #818 / epic #798 — coordinator-path tripwires. They share the
1523
1554
  // singleton coordinator with checkSubagentHealth above and assert by
@@ -1565,6 +1596,8 @@ export const doctorCommand = {
1565
1596
  'hooks': checkHookExecution,
1566
1597
  'gates': checkGateHealth,
1567
1598
  'gate': checkGateHealth,
1599
+ 'hook-drift': checkHookBlockDrift,
1600
+ 'drift': checkHookBlockDrift,
1568
1601
  'sandbox': checkSandboxTier,
1569
1602
  'sandbox-tier': checkSandboxTier,
1570
1603
  'moflodb': checkMofloDbBridge,
@@ -59,11 +59,11 @@ const DEFAULT_CONFIG = {
59
59
  models: {
60
60
  default: 'opus',
61
61
  research: 'sonnet',
62
- review: 'opus',
62
+ review: 'sonnet',
63
63
  test: 'sonnet',
64
64
  },
65
65
  model_routing: {
66
- enabled: false,
66
+ enabled: true,
67
67
  confidence_threshold: 0.85,
68
68
  cost_optimization: true,
69
69
  circuit_breaker: true,
@@ -82,6 +82,7 @@ const DEFAULT_CONFIG = {
82
82
  enabled: true,
83
83
  scripts: true,
84
84
  helpers: true,
85
+ hook_block_drift: 'warn',
85
86
  },
86
87
  sandbox: {
87
88
  enabled: false,
@@ -205,6 +206,12 @@ function mergeConfig(raw, root) {
205
206
  enabled: raw.auto_update?.enabled ?? raw.autoUpdate?.enabled ?? DEFAULT_CONFIG.auto_update.enabled,
206
207
  scripts: raw.auto_update?.scripts ?? raw.autoUpdate?.scripts ?? DEFAULT_CONFIG.auto_update.scripts,
207
208
  helpers: raw.auto_update?.helpers ?? raw.autoUpdate?.helpers ?? DEFAULT_CONFIG.auto_update.helpers,
209
+ hook_block_drift: (() => {
210
+ const v = raw.auto_update?.hook_block_drift ?? raw.autoUpdate?.hookBlockDrift;
211
+ return v === 'regenerate' || v === 'off' || v === 'warn'
212
+ ? v
213
+ : DEFAULT_CONFIG.auto_update.hook_block_drift;
214
+ })(),
208
215
  },
209
216
  sandbox: {
210
217
  enabled: raw.sandbox?.enabled ?? DEFAULT_CONFIG.sandbox.enabled,
@@ -385,7 +392,7 @@ models:
385
392
  # When enabled, overrides the static model preferences above
386
393
  # by analyzing task complexity and routing to the cheapest capable model.
387
394
  model_routing:
388
- enabled: false # Set to true to enable dynamic routing
395
+ enabled: true # Set to false to pin to the static models above
389
396
  confidence_threshold: 0.85 # Min confidence before escalating to a more capable model
390
397
  cost_optimization: true # Prefer cheaper models when confidence is high
391
398
  circuit_breaker: true # Penalize models that fail repeatedly
@@ -398,6 +405,10 @@ auto_update:
398
405
  enabled: true # Master toggle for version-change auto-sync
399
406
  scripts: true # Sync .claude/scripts/ from moflo bin/
400
407
  helpers: true # Sync .claude/helpers/ from moflo source
408
+ hook_block_drift: warn # warn | regenerate | off
409
+ # warn = print drift summary on session start (default)
410
+ # regenerate = auto-add missing hooks (only when no customisations)
411
+ # off = skip detection entirely
401
412
 
402
413
  # OS-level sandbox for spell bash steps
403
414
  # Denylist always runs regardless of this setting
@@ -11,6 +11,7 @@ import { suggestCommand } from './suggest.js';
11
11
  import { runStartupUpdateCheck } from './update/index.js';
12
12
  import { loadMofloConfig } from './config/moflo-config.js';
13
13
  import { getDaemonLockHolder } from './services/daemon-lock.js';
14
+ import { registerBackgroundPid } from './services/process-registry.js';
14
15
  import { VERSION } from './version.js';
15
16
  export { VERSION };
16
17
  const LONG_RUNNING_COMMANDS = ['mcp', 'daemon'];
@@ -461,6 +462,23 @@ export class CLI {
461
462
  env: { ...process.env, CLAUDE_FLOW_DAEMON: '1' },
462
463
  });
463
464
  child.unref();
465
+ // Register the spawned daemon PID with the shared ProcessManager so that
466
+ // pm.killAll() (called by the session-end hook) can reap it, AND doctor's
467
+ // zombie scan recognises it as a tracked process rather than an orphan.
468
+ // Without this, every consumer's first `flo doctor` after a CLI command
469
+ // sees the auto-started daemon as a "zombie" because its parent (this
470
+ // CLI process) has already exited. SYNCHRONOUS — must complete before any
471
+ // concurrent doctor scan runs the registry read.
472
+ if (child.pid) {
473
+ try {
474
+ registerBackgroundPid(projectRoot, child.pid, 'daemon', spawnArgs.slice(1).join(' '));
475
+ }
476
+ catch {
477
+ // Registration is non-essential — daemon still works, just not visible
478
+ // to PM-aware tooling. Stay silent to keep maybeAutoStartDaemon a
479
+ // fire-and-forget helper.
480
+ }
481
+ }
464
482
  }
465
483
  /**
466
484
  * Load configuration file
@@ -382,17 +382,19 @@ status_line:
382
382
  show_mcp: true
383
383
 
384
384
  # Model preferences (haiku, sonnet, opus)
385
+ # These are static fallbacks. When model_routing.enabled is true (default),
386
+ # the dynamic router takes precedence based on task complexity.
385
387
  models:
386
- default: opus # Model for general tasks
388
+ default: opus # Model for general tasks (kept high for unknowns)
387
389
  research: sonnet # Model for research/exploration agents
388
- review: opus # Model for code review agents
390
+ review: sonnet # Code review never needs opus reasoning
389
391
  test: sonnet # Model for test-writing agents
390
392
 
391
393
  # Intelligent model routing (auto-selects haiku/sonnet/opus per task)
392
394
  # When enabled, overrides the static model preferences above
393
395
  # by analyzing task complexity and routing to the cheapest capable model.
394
396
  model_routing:
395
- enabled: false # Set to true to enable dynamic routing
397
+ enabled: true # Set to false to pin to the static models above
396
398
  confidence_threshold: 0.85 # Min confidence before escalating to a more capable model
397
399
  cost_optimization: true # Prefer cheaper models when confidence is high
398
400
  circuit_breaker: true # Penalize models that fail repeatedly
@@ -498,8 +500,13 @@ function generateHooks(root, force, answers) {
498
500
  "hooks": [{ "type": "command", "command": gateHook('record-skill-run'), "timeout": 2000 }]
499
501
  },
500
502
  {
503
+ // Use gateHook (not gate) so the wrapper forwards Claude Code's session_id as
504
+ // HOOK_SESSION_ID — record-memory-searched needs this to mark the per-actor map
505
+ // (memorySearchedBy[sid]) that check-before-read consults under #838's per-actor gating.
506
+ // Without it, the legacy boolean is set but the per-actor map stays empty, and the gate
507
+ // blocks every Read forever within the turn (issue #879).
501
508
  "matcher": "mcp__moflo__memory_",
502
- "hooks": [{ "type": "command", "command": gate('record-memory-searched'), "timeout": 3000 }]
509
+ "hooks": [{ "type": "command", "command": gateHook('record-memory-searched'), "timeout": 3000 }]
503
510
  },
504
511
  {
505
512
  "matcher": "^mcp__moflo__memory_store$",
@@ -511,6 +518,14 @@ function generateHooks(root, force, answers) {
511
518
  "hooks": [
512
519
  { "type": "command", "command": `node "$CLAUDE_PROJECT_DIR/.claude/helpers/prompt-hook.mjs"`, "timeout": 3000 }
513
520
  ]
521
+ },
522
+ {
523
+ // prompt-reminder is REQUIRED to reset memorySearched/memorySearchedBy on each
524
+ // new prompt and reclassify memoryRequired. Without it, gate state leaks across
525
+ // prompts. Separate hook entry so a prompt-hook.mjs exception doesn't skip the reset.
526
+ "hooks": [
527
+ { "type": "command", "command": gateHook('prompt-reminder'), "timeout": 3000 }
528
+ ]
514
529
  }
515
530
  ],
516
531
  "SubagentStart": [
@@ -270,9 +270,14 @@ function generateHooksConfig(config) {
270
270
  hooks: [{ type: 'command', command: gateHookCmd('record-skill-run'), timeout: 2000 }],
271
271
  },
272
272
  {
273
- // Simplified matcher — anchored regex with parens doesn't match MCP tool names reliably
273
+ // Simplified matcher — anchored regex with parens doesn't match MCP tool names reliably.
274
+ // Use gateHookCmd (not gateCmd) so the wrapper forwards Claude Code's session_id as
275
+ // HOOK_SESSION_ID — record-memory-searched needs this to mark the per-actor map
276
+ // (memorySearchedBy[sid]) that check-before-read consults under #838's per-actor gating.
277
+ // Without it, the legacy boolean is set but the per-actor map stays empty, and the gate
278
+ // blocks every Read forever within the turn (issue #879).
274
279
  matcher: 'mcp__moflo__memory_',
275
- hooks: [{ type: 'command', command: gateCmd('record-memory-searched'), timeout: 3000 }],
280
+ hooks: [{ type: 'command', command: gateHookCmd('record-memory-searched'), timeout: 3000 }],
276
281
  },
277
282
  {
278
283
  matcher: '^TaskUpdate$',
@@ -284,7 +289,12 @@ function generateHooksConfig(config) {
284
289
  },
285
290
  ];
286
291
  }
287
- // UserPromptSubmit — gate reminders + intelligent task routing
292
+ // UserPromptSubmit — gate reminders + intelligent task routing.
293
+ // The prompt-reminder hook is REQUIRED — it resets memorySearched / memorySearchedBy
294
+ // and reclassifies memoryRequired from the new prompt. Without it, gate state leaks
295
+ // across prompts: a previous turn's "satisfied" state survives, OR a previous turn's
296
+ // "armed" state never clears. Two separate hook entries (not chained) so an exception
297
+ // in prompt-hook.mjs doesn't skip the reset.
288
298
  if (config.userPromptSubmit) {
289
299
  hooks.UserPromptSubmit = [
290
300
  {
@@ -292,6 +302,11 @@ function generateHooksConfig(config) {
292
302
  { type: 'command', command: promptHookCmd(), timeout: 3000 },
293
303
  ],
294
304
  },
305
+ {
306
+ hooks: [
307
+ { type: 'command', command: gateHookCmd('prompt-reminder'), timeout: 3000 },
308
+ ],
309
+ },
295
310
  ];
296
311
  }
297
312
  // SubagentStart — inject directive for subagents to read guidance protocol
@@ -12,6 +12,7 @@ import { spawn } from 'child_process';
12
12
  import { getDaemonLockHolder } from './daemon-lock.js';
13
13
  import { isDaemonInstalled, installDaemonService } from './daemon-service.js';
14
14
  import { locateMofloCliBin } from './moflo-require.js';
15
+ import { registerBackgroundPid } from './process-registry.js';
15
16
  /**
16
17
  * Ensure the daemon is ready for scheduled spell execution.
17
18
  *
@@ -115,6 +116,17 @@ async function defaultStartDaemon(projectRoot) {
115
116
  env: daemonEnv,
116
117
  });
117
118
  child.unref();
119
+ // Register the spawned daemon PID with the shared ProcessManager (parity
120
+ // with src/cli/index.ts maybeAutoStartDaemon). Without this, doctor's
121
+ // zombie scan flags this detached process as orphaned because its parent
122
+ // (this CLI invocation) exits as soon as ensureDaemonForScheduling
123
+ // resolves.
124
+ if (child.pid) {
125
+ try {
126
+ registerBackgroundPid(projectRoot, child.pid, 'daemon', spawnArgs.slice(1).join(' '));
127
+ }
128
+ catch { /* registration is non-essential */ }
129
+ }
118
130
  // Poll for daemon lock acquisition (up to 2s, checking every 200ms)
119
131
  for (let i = 0; i < 10; i++) {
120
132
  await new Promise(r => setTimeout(r, 200));
@@ -0,0 +1,320 @@
1
+ /**
2
+ * Settings.json hook-block drift detection (#881).
3
+ *
4
+ * Hashes the consumer's `.claude/settings.json` `hooks` block and the
5
+ * reference hook block that `generateHooksConfig()` would produce for the
6
+ * current moflo version. When the hashes differ, the session-start launcher
7
+ * surfaces the diff (or, in `regenerate` mode, adds purely-additive missing
8
+ * hooks). This is the broader complement to the per-bug `repairHookWiring`
9
+ * and `rewriteIncorrectHookWiring` rules — it catches drift in any direction,
10
+ * including future hook events we haven't shipped yet.
11
+ *
12
+ * IMPORTANT: This module must remain self-contained with ZERO imports from
13
+ * other moflo modules (mirrors the constraint on `services/hook-wiring.ts`).
14
+ * It is dynamically imported at runtime by `bin/session-start-launcher.mjs`
15
+ * in consumer projects, where transitive dependencies may not resolve.
16
+ *
17
+ * The reference hook block is duplicated from `init/settings-generator.ts`
18
+ * on purpose — the launcher cannot pull in `init/types.js` at runtime, and a
19
+ * unit test (`hook-block-hash.test.ts`) asserts the two stay in sync.
20
+ */
21
+ import { createHash } from 'crypto';
22
+ export const DRIFT_MODES = ['warn', 'regenerate', 'off'];
23
+ // ────────────────────────────────────────────────────────────────────────────
24
+ // Reference hook block — kept in sync with init/settings-generator.ts
25
+ // ────────────────────────────────────────────────────────────────────────────
26
+ const HELPERS_PREFIX = '$CLAUDE_PROJECT_DIR/.claude/helpers';
27
+ const SCRIPTS_PREFIX = '$CLAUDE_PROJECT_DIR/.claude/scripts';
28
+ /** Build a `node "<helper> <subcommand>"` hook entry. */
29
+ const helperHook = (helper, sub, timeout) => ({
30
+ type: 'command',
31
+ command: `node "${HELPERS_PREFIX}/${helper}"${sub ? ` ${sub}` : ''}`,
32
+ timeout,
33
+ });
34
+ /** Build a `node "<scripts/file>"` hook entry (no subcommand). */
35
+ const scriptHook = (file, timeout) => ({
36
+ type: 'command',
37
+ command: `node "${SCRIPTS_PREFIX}/${file}"`,
38
+ timeout,
39
+ });
40
+ const gateHook = (sub, timeout) => helperHook('gate-hook.mjs', sub, timeout);
41
+ const gateCjs = (sub, timeout) => helperHook('gate.cjs', sub, timeout);
42
+ const handler = (sub, timeout) => helperHook('hook-handler.cjs', sub, timeout);
43
+ const autoMemory = (sub, timeout) => helperHook('auto-memory-hook.mjs', sub, timeout);
44
+ /**
45
+ * Build the reference hook block — the canonical block `generateHooksConfig()`
46
+ * produces with all hook flags enabled (the default for `flo init`).
47
+ *
48
+ * If you change `generateHooksConfig()` in `init/settings-generator.ts`, also
49
+ * change this function — and the unit test `getReferenceHookBlock matches
50
+ * generateHooksConfig` will fail until the two agree.
51
+ */
52
+ export function getReferenceHookBlock() {
53
+ return {
54
+ PreToolUse: [
55
+ { matcher: '^(Write|Edit|MultiEdit)$', hooks: [handler('post-edit', 5000)] },
56
+ { matcher: '^(Glob|Grep)$', hooks: [gateHook('check-before-scan', 3000)] },
57
+ { matcher: '^Read$', hooks: [gateHook('check-before-read', 3000)] },
58
+ {
59
+ matcher: '^Bash$',
60
+ hooks: [gateHook('check-dangerous-command', 2000), gateHook('check-before-pr', 2000)],
61
+ },
62
+ ],
63
+ PostToolUse: [
64
+ {
65
+ matcher: '^(Write|Edit|MultiEdit)$',
66
+ hooks: [handler('post-edit', 5000), gateHook('reset-edit-gates', 2000)],
67
+ },
68
+ { matcher: '^Agent$', hooks: [handler('post-task', 5000)] },
69
+ { matcher: '^TaskCreate$', hooks: [gateCjs('record-task-created', 2000)] },
70
+ {
71
+ matcher: '^Bash$',
72
+ hooks: [gateHook('check-bash-memory', 2000), gateHook('record-test-run', 2000)],
73
+ },
74
+ { matcher: '^Skill$', hooks: [gateHook('record-skill-run', 2000)] },
75
+ { matcher: 'mcp__moflo__memory_', hooks: [gateHook('record-memory-searched', 3000)] },
76
+ { matcher: '^TaskUpdate$', hooks: [gateCjs('check-task-transition', 2000)] },
77
+ { matcher: '^mcp__moflo__memory_store$', hooks: [gateCjs('record-learnings-stored', 2000)] },
78
+ ],
79
+ UserPromptSubmit: [
80
+ { hooks: [helperHook('prompt-hook.mjs', '', 3000)] },
81
+ { hooks: [gateHook('prompt-reminder', 3000)] },
82
+ ],
83
+ SubagentStart: [
84
+ { hooks: [helperHook('subagent-start.cjs', '', 2000)] },
85
+ ],
86
+ SessionStart: [
87
+ {
88
+ hooks: [scriptHook('session-start-launcher.mjs', 3000), autoMemory('import', 8000)],
89
+ },
90
+ ],
91
+ Stop: [
92
+ { hooks: [handler('session-end', 5000), autoMemory('sync', 10000)] },
93
+ ],
94
+ PreCompact: [
95
+ { hooks: [gateCjs('compact-guidance', 3000)] },
96
+ ],
97
+ Notification: [
98
+ { hooks: [handler('notification', 3000)] },
99
+ ],
100
+ };
101
+ }
102
+ // ────────────────────────────────────────────────────────────────────────────
103
+ // Normalisation + hashing
104
+ // ────────────────────────────────────────────────────────────────────────────
105
+ function normaliseHookEntry(raw) {
106
+ if (!raw || typeof raw !== 'object')
107
+ return null;
108
+ const r = raw;
109
+ if (typeof r.command !== 'string')
110
+ return null;
111
+ return {
112
+ type: typeof r.type === 'string' ? r.type : 'command',
113
+ command: r.command.replace(/\s+/g, ' ').trim(),
114
+ timeout: typeof r.timeout === 'number' && isFinite(r.timeout) ? r.timeout : 0,
115
+ };
116
+ }
117
+ function normaliseHookBlock(raw) {
118
+ if (!raw || typeof raw !== 'object')
119
+ return null;
120
+ const r = raw;
121
+ const hooksIn = Array.isArray(r.hooks) ? r.hooks : [];
122
+ const hooks = hooksIn.map(normaliseHookEntry).filter((h) => h !== null);
123
+ if (hooks.length === 0)
124
+ return null;
125
+ hooks.sort((a, b) => a.command.localeCompare(b.command));
126
+ const out = { hooks };
127
+ if (typeof r.matcher === 'string' && r.matcher.length > 0)
128
+ out.matcher = r.matcher;
129
+ return out;
130
+ }
131
+ /**
132
+ * Produce a stable, sorted view of a hook tree suitable for hashing or diffing.
133
+ * Drops unknown keys, coerces missing fields to defaults, and sorts events,
134
+ * matchers, and commands so semantically-equal trees compare equal.
135
+ */
136
+ export function normaliseHooks(raw) {
137
+ if (!raw || typeof raw !== 'object')
138
+ return {};
139
+ const events = raw;
140
+ const out = {};
141
+ const eventNames = Object.keys(events).sort();
142
+ for (const event of eventNames) {
143
+ const arr = events[event];
144
+ if (!Array.isArray(arr))
145
+ continue;
146
+ const blocks = arr
147
+ .map(normaliseHookBlock)
148
+ .filter((b) => b !== null);
149
+ if (blocks.length === 0)
150
+ continue;
151
+ blocks.sort((a, b) => {
152
+ const am = a.matcher ?? '';
153
+ const bm = b.matcher ?? '';
154
+ if (am !== bm)
155
+ return am.localeCompare(bm);
156
+ return (a.hooks[0]?.command ?? '').localeCompare(b.hooks[0]?.command ?? '');
157
+ });
158
+ out[event] = blocks;
159
+ }
160
+ return out;
161
+ }
162
+ function hashNormalised(tree) {
163
+ return createHash('sha256').update(JSON.stringify(tree)).digest('hex').slice(0, 16);
164
+ }
165
+ /**
166
+ * Hash a hook tree. Stable across runs (deterministic normalisation), and
167
+ * insensitive to key order, whitespace inside commands, or matcher block
168
+ * grouping. Returns a 16-char hex prefix of sha256 — long enough to make
169
+ * collisions a non-concern for the small space of valid hook trees while
170
+ * staying readable in launcher output.
171
+ */
172
+ export function computeHookBlockHash(raw) {
173
+ return hashNormalised(normaliseHooks(raw));
174
+ }
175
+ // ────────────────────────────────────────────────────────────────────────────
176
+ // Diff
177
+ // ────────────────────────────────────────────────────────────────────────────
178
+ function entryKey(event, matcher, command) {
179
+ return `${event} ${matcher} ${command}`;
180
+ }
181
+ function flatten(tree) {
182
+ const out = new Map();
183
+ for (const event of Object.keys(tree)) {
184
+ for (const block of tree[event]) {
185
+ const matcher = block.matcher ?? '';
186
+ for (const hook of block.hooks) {
187
+ const entry = { event, matcher, command: hook.command };
188
+ out.set(entryKey(event, matcher, hook.command), entry);
189
+ }
190
+ }
191
+ }
192
+ return out;
193
+ }
194
+ let cachedReference = null;
195
+ function getCachedReference() {
196
+ if (!cachedReference) {
197
+ const tree = getReferenceHookBlock();
198
+ const normalised = normaliseHooks(tree);
199
+ cachedReference = { tree, normalised, hash: hashNormalised(normalised), flat: flatten(normalised) };
200
+ }
201
+ return cachedReference;
202
+ }
203
+ /**
204
+ * Compare a consumer hook block against the reference and report what's
205
+ * missing / extra. Pass an explicit `referenceHooks` to test against a
206
+ * frozen reference (used by tests); omit it to use the current moflo
207
+ * reference from `getReferenceHookBlock()` (memoised — built once per process).
208
+ */
209
+ export function computeHookBlockDrift(consumerHooks, referenceHooks) {
210
+ const consumerNormalised = normaliseHooks(consumerHooks);
211
+ const consumerHash = hashNormalised(consumerNormalised);
212
+ const consumerFlat = flatten(consumerNormalised);
213
+ let referenceHash;
214
+ let referenceFlat;
215
+ if (referenceHooks === undefined) {
216
+ const ref = getCachedReference();
217
+ referenceHash = ref.hash;
218
+ referenceFlat = ref.flat;
219
+ }
220
+ else {
221
+ const refNormalised = normaliseHooks(referenceHooks);
222
+ referenceHash = hashNormalised(refNormalised);
223
+ referenceFlat = flatten(refNormalised);
224
+ }
225
+ const missing = [];
226
+ for (const [k, v] of referenceFlat) {
227
+ if (!consumerFlat.has(k))
228
+ missing.push(v);
229
+ }
230
+ const extra = [];
231
+ for (const [k, v] of consumerFlat) {
232
+ if (!referenceFlat.has(k))
233
+ extra.push(v);
234
+ }
235
+ return {
236
+ consumerHash,
237
+ referenceHash,
238
+ drifted: consumerHash !== referenceHash,
239
+ missing,
240
+ extra,
241
+ };
242
+ }
243
+ // ────────────────────────────────────────────────────────────────────────────
244
+ // Settings.json helpers — shared between launcher + doctor
245
+ // ────────────────────────────────────────────────────────────────────────────
246
+ /**
247
+ * True when the user has set `claudeFlow.hooks.locked: true` in their
248
+ * settings.json — a sentinel that suppresses drift surfacing entirely.
249
+ */
250
+ export function isHookBlockLocked(settings) {
251
+ const root = settings;
252
+ const cf = root?.claudeFlow;
253
+ const hooks = cf?.hooks;
254
+ return hooks?.locked === true;
255
+ }
256
+ /**
257
+ * Additively repair drift: for every entry in `report.missing`, locate the
258
+ * corresponding hook in the reference block and graft it into the consumer's
259
+ * settings. Only safe when `report.extra.length === 0` — otherwise the
260
+ * caller should fall back to `warn` mode to avoid clobbering customisations.
261
+ *
262
+ * Mutates `settings` in place; caller is responsible for writing the file.
263
+ */
264
+ export function applyAdditiveRegeneration(settings, report) {
265
+ if (report.missing.length === 0)
266
+ return { settings, added: 0 };
267
+ const ref = getCachedReference().tree;
268
+ const hooks = (settings.hooks ?? {});
269
+ let added = 0;
270
+ for (const miss of report.missing) {
271
+ const arr = Array.isArray(hooks[miss.event]) ? hooks[miss.event] : [];
272
+ let block = arr.find(b => (b?.matcher ?? '') === miss.matcher);
273
+ if (!block) {
274
+ block = { hooks: [] };
275
+ if (miss.matcher)
276
+ block.matcher = miss.matcher;
277
+ arr.push(block);
278
+ }
279
+ if (!Array.isArray(block.hooks))
280
+ block.hooks = [];
281
+ const refArr = ref[miss.event] ?? [];
282
+ const refBlock = refArr.find(b => (b?.matcher ?? '') === miss.matcher);
283
+ const refHook = refBlock?.hooks.find(h => h.command === miss.command);
284
+ if (refHook && !block.hooks.some(h => h?.command === miss.command)) {
285
+ block.hooks.push(refHook);
286
+ added++;
287
+ }
288
+ hooks[miss.event] = arr;
289
+ }
290
+ if (added > 0)
291
+ settings.hooks = hooks;
292
+ return { settings, added };
293
+ }
294
+ /**
295
+ * Format a drift report for human-readable output (multi-line, no colour).
296
+ * Used by `flo doctor` and the session-start launcher's stdout summary.
297
+ */
298
+ export function formatDriftReport(report) {
299
+ if (!report.drifted) {
300
+ return `hook block matches reference (${report.consumerHash})`;
301
+ }
302
+ const lines = [];
303
+ lines.push(`hook block drift detected (consumer ${report.consumerHash} vs reference ${report.referenceHash})`);
304
+ if (report.missing.length > 0) {
305
+ lines.push(` ${report.missing.length} missing:`);
306
+ for (const m of report.missing) {
307
+ const m2 = m.matcher ? ` ${m.matcher}` : '';
308
+ lines.push(` - ${m.event}${m2}: ${m.command}`);
309
+ }
310
+ }
311
+ if (report.extra.length > 0) {
312
+ lines.push(` ${report.extra.length} extra (likely customisations):`);
313
+ for (const e of report.extra) {
314
+ const m2 = e.matcher ? ` ${e.matcher}` : '';
315
+ lines.push(` + ${e.event}${m2}: ${e.command}`);
316
+ }
317
+ }
318
+ return lines.join('\n');
319
+ }
320
+ //# sourceMappingURL=hook-block-hash.js.map