moflo 4.9.11 → 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.
@@ -1,55 +1,103 @@
1
1
  ---
2
- description: Review changed code for reuse, quality, and efficiency, then fix any issues found.
2
+ description: Review changed code for reuse, quality, and efficiency, then fix any issues found. Sizes review effort to the diff and routes the cheapest model that fits.
3
3
  ---
4
4
 
5
- # /simplify — Gate-Compliant Code Review
5
+ # /simplify — Adaptive Gate-Compliant Code Review
6
6
 
7
- Review all changed files for reuse opportunities, code quality, and efficiency improvements.
7
+ Review changed code for reuse, quality, and efficiency. **Effort scales with diff size; model is routed for cost.** A 5-line comment trim does not get 3 Opus agents.
8
8
 
9
- **This command overrides any built-in simplify skill.** Follow these steps exactly.
9
+ ## Phase 0: Gate prerequisites
10
10
 
11
- ## Prerequisites (MANDATORY do these FIRST)
11
+ These satisfy the memory-first and task-create-first gates. Always do them before any Agent spawn.
12
12
 
13
- 1. **Memory search**: Search for relevant patterns before reviewing
14
- ```
15
- mcp__moflo__memory_search — query: "code quality patterns", namespace: "patterns"
16
- ```
13
+ 1. **Memory search** `mcp__moflo__memory_search query: "code quality patterns", namespace: "patterns"`
14
+ 2. **Task create** — `TaskCreate — subject: "🔍 [Reviewer] Simplify changed code"`
17
15
 
18
- 2. **Create task**: Track the simplification work
19
- ```
20
- TaskCreate — subject: "🔍 [Reviewer] Simplify changed code", description: "Review changed files for reuse, quality, and efficiency"
16
+ ## Phase 1: Identify the diff
17
+
18
+ ```bash
19
+ git diff HEAD # working tree
20
+ git diff main...HEAD # committed since branch base
21
21
  ```
22
22
 
23
- ## Execution
23
+ Treat the union as the diff. Note whether `/simplify` already ran on this branch in this session — if so, you are in a **validation pass** (Phase 4 below).
24
24
 
25
- After prerequisites are satisfied, get the list of changed files:
25
+ ## Phase 2: Classify the diff
26
26
 
27
- ```bash
28
- git diff --name-only HEAD~1
29
- ```
27
+ Pick the smallest tier the diff genuinely fits.
30
28
 
31
- Then launch 3 reviewer agents **in parallel** (single message, multiple Agent tool calls).
29
+ | Tier | Trigger | Action |
30
+ |------|---------|--------|
31
+ | **TRIVIAL** | ≤10 net LOC, single file, comments/formatting/local renames only | Self-review, zero agents |
32
+ | **SMALL** | ≤200 net LOC, ≤2 files, no API/dependency change | **One agent** (default for most diffs, including critical surface) |
33
+ | **NORMAL** | ≥3 files, OR >200 LOC, OR public API change, OR new/removed dependency, OR cross-cutting refactor | Three parallel agents |
32
34
 
33
- **CRITICAL**: Each agent prompt below includes a mandatory memory search step. This is required because subagents must satisfy the memory-first gate independently before using Glob, Grep, or Read tools. Do NOT remove the memory search from agent prompts.
35
+ Critical-surface files (launcher, hooks, MCP wiring) raise the *care* of the agent prompt sharper checklist, blast-radius framing they do **not** automatically escalate to NORMAL. Risk-weighted headcount-weighted.
36
+
37
+ ## Phase 3: Route the model (skip for TRIVIAL)
38
+
39
+ Before spawning any Agent, ask the moflo router which model to use:
34
40
 
35
- ### Agent 1: Reuse Reviewer
36
41
  ```
37
- Agentname: "reuse-reviewer", run_in_background: true, subagent_type: "reviewer", prompt: "FIRST ACTION: Run mcp__moflo__memory_search with query 'code reuse patterns' and namespace 'patterns'. You MUST do this before any Glob, Grep, or Read calls. THEN review these changed files for code reuse opportunities. Look for: duplicated logic that could use existing utilities, patterns already solved elsewhere in the codebase, opportunities to extract shared helpers. Files: $CHANGED_FILES"
42
+ mcp__moflo__hooks_model-route{
43
+ task: "Review N-line change in <files> for reuse, quality, efficiency",
44
+ preferCost: true
45
+ }
38
46
  ```
39
47
 
40
- ### Agent 2: Quality Reviewer
48
+ **Wording rules:** the router's complexity score is keyword-sensitive. Avoid `refactor`, `architect`, `audit`, `system`, `redesign`, `migrate` — those force opus even when scoring suggests sonnet. State LOC count, file count, and "review for reuse, quality, efficiency". Nothing more.
49
+
50
+ **Hard rule for `/simplify`: opus is never correct.** Code review never needs Opus reasoning, even on critical surface. If the router returns `opus`, downgrade to `sonnet`. On router failure, default to `sonnet`. Comment trims and pure formatting → `haiku`.
51
+
52
+ ## Phase 4: Validation pass (re-run after fixes from a prior simplify)
53
+
54
+ If `/simplify` already ran on this branch in this session AND the only edits since are fixes the prior pass surfaced, **default to TRIVIAL self-review** regardless of LOC count. The fan-out happened; the fix is small relative to the already-reviewed diff.
55
+
56
+ Escalate one tier (self-review → SMALL agent) only if the fix introduced a new file, a new exported symbol, a new dependency, or a control-flow change not covered by the original findings. Never escalate a validation pass to NORMAL.
57
+
58
+ ## Phase 5: Run the appropriate review
59
+
60
+ ### TRIVIAL / Validation
61
+ Run the three category checks (reuse / quality / efficiency) yourself in one pass against the diff. Most TRIVIAL diffs are clean — confirm and exit. Budget: ~30 seconds, no Agent.
62
+
63
+ ### SMALL — one agent
41
64
  ```
42
- Agent — name: "quality-reviewer", run_in_background: true, subagent_type: "reviewer", prompt: "FIRST ACTION: Run mcp__moflo__memory_search with query 'code quality patterns' and namespace 'patterns'. You MUST do this before any Glob, Grep, or Read calls. THEN review these changed files for code quality issues. Look for: unclear naming, overly complex logic, missing error handling at system boundaries, potential bugs, consistency with existing patterns. Files: $CHANGED_FILES"
65
+ Agent — {
66
+ subagent_type: "reviewer",
67
+ model: "<sonnet (or haiku for trivial-formatting tier from router)>",
68
+ prompt: "FIRST ACTION: Run mcp__moflo__memory_search with query 'code review patterns' and namespace 'patterns' to satisfy the memory-first gate. THEN review this diff for reuse, quality, and efficiency. <diff inline>. Flag specific issues as file:line + 1-line description. Max 5 file reads. Under 200 words. Skip cosmetic style. Don't suggest cross-cutting refactors of code outside this diff."
69
+ }
43
70
  ```
44
71
 
45
- ### Agent 3: Efficiency Reviewer
72
+ For critical-surface files, prepend a 1-line risk note (e.g., "This is `bin/session-start-launcher.mjs` — runs in every consumer's session-start hot path; cross-platform + blast-radius matter."). One careful agent, not three.
73
+
74
+ ### NORMAL — three parallel agents
75
+ Launch three agents in a single message, each at the routed model (typically sonnet). Each agent gets the SMALL-tier tool-budget cap.
76
+
77
+ - **Agent 1 (Reuse):** existing helpers/utilities that should be used; duplicated patterns; functions re-implementing something already in the codebase.
78
+ - **Agent 2 (Quality):** redundant state, parameter sprawl, copy-paste with variation, leaky abstractions, stringly-typed code, nested conditionals 3+ levels, unnecessary comments.
79
+ - **Agent 3 (Efficiency):** unnecessary work, missed concurrency, hot-path bloat, recurring no-op updates, TOCTOU existence checks, unbounded structures, over-broad reads.
80
+
81
+ Each agent prompt must start with `FIRST ACTION: mcp__moflo__memory_search ... namespace: "patterns"` — subagents must satisfy the memory-first gate independently before Glob/Grep/Read.
82
+
83
+ ## Phase 6: Fix or skip
84
+
85
+ Aggregate findings. Fix each issue directly that's worth fixing. False positives or out-of-scope: note and skip without arguing.
86
+
87
+ If fixes were made, re-run tests to confirm nothing broke. If tests fail after a fix, revert it.
88
+
89
+ After fixes: the next `/simplify` invocation is a **validation pass** (Phase 4). Bundle related fixes into one batch so a single validation pass covers them — don't re-fan-out for cosmetic micro-corrections.
90
+
91
+ ## Phase 7: Optional — record routing outcome
92
+
93
+ If you spawned an agent, feed back the outcome so the router learns:
94
+
46
95
  ```
47
- Agentname: "efficiency-reviewer", run_in_background: true, subagent_type: "reviewer", prompt: "FIRST ACTION: Run mcp__moflo__memory_search with query 'performance optimization patterns' and namespace 'patterns'. You MUST do this before any Glob, Grep, or Read calls. THEN review these changed files for efficiency improvements. Look for: unnecessary allocations, O(n^2) where O(n) is possible, redundant operations, opportunities to batch or cache. Files: $CHANGED_FILES"
96
+ mcp__moflo__hooks_model-outcome{ task: "...", model: "<chosen>", outcome: "success" | "failure" | "escalated" }
48
97
  ```
49
98
 
50
- ## Post-Review
99
+ `escalated` only when a real miss happened that a higher tier would have caught — never used to retroactively justify opus.
100
+
101
+ ## Briefly summarize
51
102
 
52
- 1. Collect findings from all 3 reviewers
53
- 2. Apply fixes that preserve ALL existing functionality — no behavior changes
54
- 3. If fixes were made, re-run tests to confirm nothing broke
55
- 4. If tests fail after fixes, revert the simplification changes
103
+ End with one or two sentences: which tier ran, which model, what was fixed (or "clean — no changes"). No headers, no bullets.
@@ -5,7 +5,7 @@ description: Review changed code for reuse, quality, and efficiency, then fix an
5
5
 
6
6
  # /simplify — Adaptive Code Review
7
7
 
8
- Review changed code for reuse opportunities, quality issues, and efficiency improvements. **Effort scales with diff size** — a 5-line comment trim doesn't get the same treatment as a 500-line refactor.
8
+ Review changed code for reuse opportunities, quality issues, and efficiency improvements. **Effort scales with diff size and reuses prior context** — a 5-line comment trim doesn't get the same treatment as a 500-line refactor, and a re-run after fixing pass-1 findings doesn't re-pay for a fresh fan-out.
9
9
 
10
10
  ## Phase 1: Identify changes
11
11
 
@@ -13,9 +13,11 @@ Run `git diff HEAD` (working tree) and `git diff main...HEAD` (committed) to get
13
13
 
14
14
  Treat the union of staged + unstaged + committed-since-base as the diff to review.
15
15
 
16
+ Also note: was `/simplify` already run on this branch in this session? If yes, you're in a **validation pass** (Phase 2.5 below) — most of the heavy lifting is done.
17
+
16
18
  ## Phase 2: Classify the diff
17
19
 
18
- Pick the **smallest tier** the diff genuinely fits. When in doubt, escalate.
20
+ Pick the **smallest tier** the diff genuinely fits. When in doubt, escalate one step (not two).
19
21
 
20
22
  ### TRIVIAL — self-review, no agent spawn
21
23
  ALL of these must hold:
@@ -28,38 +30,103 @@ ALL of these must hold:
28
30
  Examples that qualify: trimming a comment, fixing a typo in a log message, renaming a private helper, reformatting a single block.
29
31
  Examples that DON'T qualify: changing an `if` condition, reordering function args, deleting a try/catch.
30
32
 
31
- ### SMALL — single agent, all three categories
33
+ ### SMALL — single agent, all three categories (DEFAULT for most diffs)
32
34
  ALL of these must hold:
33
- - ≤50 net LOC changed
35
+ - ≤200 net LOC changed
34
36
  - ≤2 files
35
37
  - No structural changes (no new modules, no API additions/removals, no contract changes)
36
38
 
37
- Examples that qualify: extracting a constant, inlining a one-liner, swapping a `for` for a `forEach`, adding one early-return.
39
+ This is the default tier for **most real diffs**, including changes to critical surface (launcher, hooks, MCP wiring). Critical surface raises the *care* of the agent prompt (sharper checklist, blast-radius framing), not the *number* of agents.
40
+
41
+ Examples that qualify: extracting a constant, inlining a one-liner, swapping a `for` for a `forEach`, adding one early-return, refactoring a single function within a file, adding a cache fast-path inside an existing block.
38
42
 
39
- ### NORMAL — three parallel agents (the original flow)
40
- Anything that doesn't fit TRIVIAL or SMALL. Includes any diff that:
41
- - Spans 3+ files
43
+ ### NORMAL — three parallel agents
44
+ Reserved for **genuinely cross-cutting** changes. ANY of these triggers NORMAL:
45
+ - 3+ files changed
46
+ - >200 net LOC changed
42
47
  - Adds/removes/renames a public API
43
- - Changes control flow in a non-trivial way
44
48
  - Introduces or removes a dependency
45
- - Touches `bin/`, hooks, MCP tool handlers, or anything called out in `CLAUDE.md` as critical surface
49
+ - Cross-cutting refactor (touches the same pattern in multiple modules)
50
+
51
+ Three agents exist to cover orthogonal axes (Reuse / Quality / Efficiency) when the change is broad enough that one agent's tool-call budget can't survey it all. For single-file edits, one focused agent always covers all three axes — three is duplication, not coverage.
52
+
53
+ ## Phase 2.5: Validation pass (re-run after fixes)
54
+
55
+ If `/simplify` already ran on this branch in this session AND the only edits since are fixes driven by the prior pass's findings, default to **self-review tier** regardless of LOC count. The fan-out already happened; the fix is small relative to the diff that was already reviewed.
56
+
57
+ Escalate one tier (self-review → SMALL agent) only if the fix introduced any of:
58
+ - A new file
59
+ - A new exported symbol
60
+ - A new dependency or import from a previously-untouched module
61
+ - A change to control flow not covered in the original findings
62
+
63
+ Do **not** escalate to NORMAL on a validation pass. If the fix is so structural that NORMAL is warranted, treat it as a fresh diff and start over from Phase 1.
64
+
65
+ ## Phase 2.7: Route the model (before any Agent spawn)
66
+
67
+ For every tier that spawns an Agent (SMALL / NORMAL — TRIVIAL self-review skips this), call the moflo router to pick the cheapest model that fits the task **before** invoking Agent:
68
+
69
+ ```
70
+ mcp__moflo__hooks_model-route — {
71
+ task: "<diff summary — see wording rules below>",
72
+ preferCost: true
73
+ }
74
+ ```
75
+
76
+ ### Wording the task description
77
+
78
+ The router's complexity score is keyword-sensitive. Words like `refactor`, `architect`, `audit`, `system`, `redesign`, `migrate` flip a high-complexity flag and force opus *even when scoring suggests sonnet*. For `/simplify` you are **always doing code review**, never genuine architecture, so frame the task accordingly:
79
+
80
+ - ✅ Good: `"Review 110-line single-file change in bin/session-start-launcher.mjs for reuse, quality, efficiency."`
81
+ - ❌ Bad: `"Review refactor that adds mtime-cache fast-path and architects new caching layer."`
46
82
 
47
- When CLAUDE.md flags a file as critical surface (SessionStart, launcher, hooks, MCP coordinator wiring, swarm/hive-mind), **always escalate to NORMAL** regardless of LOC count. Risk-weighted, not size-weighted.
83
+ Drop the trigger words. State LOC count, file count, and "review for reuse, quality, efficiency". That's enough signal.
84
+
85
+ ### Applying the result
86
+
87
+ The router returns `{ model: 'haiku' | 'sonnet' | 'opus', complexity, reasoning, alternatives, ... }`.
88
+
89
+ **Hard rule for `/simplify`: opus is never correct.** Code review does not require Opus-tier reasoning even on critical surface. If the router returns `opus`:
90
+
91
+ 1. Look at `alternatives` — if `sonnet` scores higher than the selected model's confidence, downgrade to sonnet.
92
+ 2. Otherwise, downgrade to sonnet anyway (treat opus as "router was uncertain — pick the safer middle").
93
+
94
+ Pass the final model verbatim to the Agent's `model` parameter (Agent accepts `'haiku' | 'sonnet' | 'opus'`). On router failure (MCP call errors), default to `'sonnet'`.
95
+
96
+ In practice: comment trims and pure formatting → haiku; everything else for `/simplify` → sonnet.
97
+
98
+ ### Feed back the outcome
99
+
100
+ After the agent completes, record the outcome so the router learns:
101
+
102
+ ```
103
+ mcp__moflo__hooks_model-outcome — { task: "<same wording as route call>", model: "<chosen>", outcome: "success" | "failure" | "escalated" }
104
+ ```
105
+
106
+ `escalated` = the agent missed something a higher-tier pass would have caught. That signal teaches the router to bias similar tasks upward next time. Don't fake `escalated` to retroactively justify opus — only record it when a *real* miss happened.
48
107
 
49
108
  ## Phase 3: Run the appropriate review
50
109
 
51
- ### TRIVIAL: self-review
52
- Run the same three category checks (reuse / quality / efficiency) yourself, in one pass, against the diff. Most TRIVIAL diffs will be clean — the goal is to confirm, not to fan out. If you find an issue, fix it; otherwise stamp clean. Total budget: ~30 seconds, no Agent calls.
110
+ ### TRIVIAL / Validation: self-review
111
+ Run the same three category checks (reuse / quality / efficiency) yourself, in one pass, against the diff. Most TRIVIAL and validation diffs will be clean — the goal is to confirm, not to fan out. If you find an issue, fix it; otherwise stamp clean. Total budget: ~30 seconds, no Agent calls. No router call needed.
53
112
 
54
- ### SMALL: one agent
55
- Launch a SINGLE Agent with subagent_type `reviewer` covering all three categories in one prompt. Pass the diff inline. Budget: ~1 minute.
113
+ ### SMALL: one agent (model from router)
114
+ Launch a SINGLE Agent with subagent_type `reviewer`, passing the model returned by Phase 2.7's router call. Cap the agent's tool budget by being explicit:
56
115
 
57
116
  ```
58
- Agent — subagent_type: "reviewer", prompt: "Review this diff for reuse, quality, and efficiency. <diff inline>. Flag specific issues with file:line; skip generic advice. Under 200 words."
117
+ Agent — {
118
+ subagent_type: "reviewer",
119
+ model: "<from router, typically 'sonnet'>",
120
+ prompt: "Review this diff for reuse, quality, and efficiency. <diff inline>. Flag specific issues as file:line + 1-line description. Max 5 file reads. Under 200 words. Skip cosmetic style. Don't suggest cross-cutting refactors of code outside this diff."
121
+ }
59
122
  ```
60
123
 
61
- ### NORMAL: three parallel agents (original flow)
62
- Launch three agents in a single message — Reuse, Quality, Efficiency — passing the full diff to each. Use the original flow's category checklists.
124
+ For critical-surface files, prepend a 1-line risk note to the prompt (e.g., "This is `bin/session-start-launcher.mjs` — runs in every consumer's session-start hot path; cross-platform + blast-radius matter."). One careful agent, not three.
125
+
126
+ Budget: ~1 minute.
127
+
128
+ ### NORMAL: three parallel agents (model from router, applied to all)
129
+ Launch three agents in a single message — Reuse, Quality, Efficiency — passing the full diff and the same routed `model` to each. Each agent gets the same tool-budget cap as SMALL.
63
130
 
64
131
  **Reuse**: existing helpers/utilities that should be used instead; duplicated patterns; new functions that re-implement something already in the codebase.
65
132
 
@@ -69,14 +136,16 @@ Launch three agents in a single message — Reuse, Quality, Efficiency — passi
69
136
 
70
137
  ## Phase 4: Fix or skip
71
138
 
72
- Aggregate findings. Fix each one directly. False positives or not-worth-fixing — note and skip without arguing. If TRIVIAL self-review found nothing, just confirm clean and exit.
139
+ Aggregate findings. Fix each one directly. False positives or not-worth-fixing — note and skip without arguing. If self-review found nothing, just confirm clean and exit.
73
140
 
74
141
  If fixes were made, re-run tests to confirm nothing broke. If tests fail after a fix, revert it.
75
142
 
143
+ After fixes: the next `/simplify` invocation is a **validation pass** (Phase 2.5). Do not re-fan-out unless the fix added genuinely new concerns — bundle related fixes into one batch so a single validation pass covers them.
144
+
76
145
  ## Phase 5: Stamp the gate
77
146
 
78
- Whatever tier ran, the gate (`check-before-pr`) registers /simplify as having executed. The skill is satisfied.
147
+ Whatever tier ran, the gate (`check-before-pr`) registers /simplify as having executed. The skill is satisfied. Self-review counts.
79
148
 
80
149
  ## Briefly summarize
81
150
 
82
- End with one or two sentences: which tier, what was fixed (or "clean — no changes"). No headers, no bullets unless needed.
151
+ End with one or two sentences: which tier ran, what was fixed (or "clean — no changes"). No headers, no bullets unless needed.
@@ -10,7 +10,7 @@
10
10
  import { spawn, execFileSync } from 'child_process';
11
11
  import { existsSync, readFileSync, writeFileSync, copyFileSync, unlinkSync, readdirSync, mkdirSync, statSync } from 'fs';
12
12
  import { resolve, dirname, join } from 'path';
13
- import { fileURLToPath } from 'url';
13
+ import { fileURLToPath, pathToFileURL } from 'url';
14
14
  import { mofloDir } from './lib/moflo-paths.mjs';
15
15
  import { repairMemoryDbIfCorrupt } from './lib/db-repair.mjs';
16
16
  import { resolveMofloBin } from './lib/resolve-bin.mjs';
@@ -218,11 +218,17 @@ try {
218
218
  // own errors if the DB is still broken.
219
219
  }
220
220
 
221
- // ── 0d. Clear post-install restart notice when version is current (#867) ───
221
+ // ── 0d. Silently clear post-install restart notice when version is current (#867, #887)
222
222
  // scripts/post-install-notice.mjs drops `.moflo/restart-pending.json` on every
223
223
  // `npm install moflo`. The UserPromptSubmit hook surfaces it on every prompt
224
224
  // until cleared, so this session only sees the message between install and
225
225
  // the FIRST restart that actually picks up the new bits.
226
+ //
227
+ // Cleanup is silent (#887): the user already saw + acted on the restart prompt
228
+ // — surfacing a "cleared notice" line on the very next session reads like an
229
+ // error in additionalContext and inflates mutationCount, which would also fire
230
+ // the closing "starting background tasks" framing. Both are noise on a
231
+ // successful post-restart session.
226
232
  try {
227
233
  const pendingPath = join(mofloDir(projectRoot), 'restart-pending.json');
228
234
  const pkgPath = resolve(projectRoot, 'node_modules/moflo/package.json');
@@ -231,7 +237,6 @@ try {
231
237
  if (pending && typeof pending.version === 'string' && pending.version === installedVersion) {
232
238
  unlinkSync(pendingPath);
233
239
  try { unlinkSync(join(mofloDir(projectRoot), 'last-install-banner.json')); } catch { /* tracker may not exist */ }
234
- emitMutation('cleared post-install restart notice', `${installedVersion} now running`);
235
240
  }
236
241
  } catch { /* file missing or malformed — silent fast-path */ }
237
242
 
@@ -304,7 +309,7 @@ try {
304
309
  // Controlled by `auto_update.enabled` in moflo.yaml (default: true).
305
310
  // When moflo is upgraded (npm install), scripts and helpers may be stale.
306
311
  // Detect version change and sync from source before running hooks.
307
- let autoUpdateConfig = { enabled: true, scripts: true, helpers: true };
312
+ let autoUpdateConfig = { enabled: true, scripts: true, helpers: true, hookBlockDrift: 'warn' };
308
313
  try {
309
314
  const mofloYaml = resolve(projectRoot, 'moflo.yaml');
310
315
  if (existsSync(mofloYaml)) {
@@ -313,9 +318,12 @@ try {
313
318
  const enabledMatch = yamlContent.match(/auto_update:\s*\n\s+enabled:\s*(true|false)/);
314
319
  const scriptsMatch = yamlContent.match(/auto_update:\s*\n(?:\s+\w+:.*\n)*?\s+scripts:\s*(true|false)/);
315
320
  const helpersMatch = yamlContent.match(/auto_update:\s*\n(?:\s+\w+:.*\n)*?\s+helpers:\s*(true|false)/);
321
+ // #881: hook-block drift detector (warn | regenerate | off; default warn)
322
+ const driftMatch = yamlContent.match(/auto_update:\s*\n(?:\s+\w+:.*\n)*?\s+hook_block_drift:\s*(warn|regenerate|off)/);
316
323
  if (enabledMatch) autoUpdateConfig.enabled = enabledMatch[1] === 'true';
317
324
  if (scriptsMatch) autoUpdateConfig.scripts = scriptsMatch[1] === 'true';
318
325
  if (helpersMatch) autoUpdateConfig.helpers = helpersMatch[1] === 'true';
326
+ if (driftMatch) autoUpdateConfig.hookBlockDrift = driftMatch[1];
319
327
  }
320
328
  } catch (err) {
321
329
  // Defaults (all true) keep the upgrade flow alive but the user should
@@ -881,6 +889,138 @@ try {
881
889
  emitWarning(`settings.json migration failed (${errMessage(err)})`);
882
890
  }
883
891
 
892
+ // ── 3a-vi. Hook-block drift detection (#881) ───────────────────────────────
893
+ // Hash the consumer's settings.json hook block against the reference block
894
+ // `generateHooksConfig()` would produce for this moflo version. Catches
895
+ // drift the per-bug `repairHookWiring` / `rewriteIncorrectHookWiring` rules
896
+ // don't cover (future hook events, partial migrations, hand-edited commands).
897
+ // Runs every session under `auto_update.enabled`, not only on version change.
898
+ //
899
+ // Modes (`auto_update.hook_block_drift` in moflo.yaml):
900
+ // warn — print a one-line summary + diff to stdout (default)
901
+ // regenerate — additively add missing hooks; falls back to warn when the
902
+ // consumer has extra (custom) hooks, to avoid clobbering
903
+ // off — skip entirely
904
+ //
905
+ // Also respects a `claudeFlow.hooks.locked: true` sentinel in settings.json
906
+ // — if set, the user has explicitly opted out of drift surfacing.
907
+ // Fast-path: `.moflo/hook-drift-cache.json` records the last clean run. If
908
+ // settings.json + the dist module both still match the cached mtimes and the
909
+ // cached check was clean (consumerHash === referenceHash), skip readFile +
910
+ // JSON.parse + dynamic import entirely. This block runs every session; the
911
+ // cache makes it ~free in the steady state.
912
+ //
913
+ // Returns the values to persist on the slow path, or null when skipped
914
+ // (cache hit, no settings.json, no dist module, locked, etc.). Pulled out
915
+ // to keep the guard chain flat — the original inline form was 9 levels deep.
916
+ async function runHookBlockDriftCheck() {
917
+ const settingsPath = resolve(projectRoot, '.claude', 'settings.json');
918
+ let settingsStat;
919
+ try { settingsStat = statSync(settingsPath); } catch { return null; }
920
+
921
+ // statSync each candidate doubles as existence check + provides the mtime
922
+ // we need for the cache key, avoiding the existsSync→import TOCTOU pattern.
923
+ const hbhCandidates = [
924
+ resolve(projectRoot, 'node_modules/moflo/dist/src/cli/services/hook-block-hash.js'),
925
+ resolve(projectRoot, 'dist/src/cli/services/hook-block-hash.js'),
926
+ ];
927
+ let hbhPath = null;
928
+ let hbhStat = null;
929
+ for (const p of hbhCandidates) {
930
+ try { hbhStat = statSync(p); hbhPath = p; break; } catch { /* try next */ }
931
+ }
932
+ if (!hbhPath) return null;
933
+
934
+ // Fast-path requires consumerHash === referenceHash (a previously *clean*
935
+ // run). A drifted-but-cached state still needs to re-emit the warning each
936
+ // session, so we always re-do the work in that case.
937
+ const cachePath = join(mofloDir(projectRoot), 'hook-drift-cache.json');
938
+ let cached = null;
939
+ try { cached = JSON.parse(readFileSync(cachePath, 'utf-8')); } catch { /* missing or corrupt */ }
940
+ if (
941
+ cached &&
942
+ cached.settingsMtimeMs === settingsStat.mtimeMs &&
943
+ cached.moduleMtimeMs === hbhStat.mtimeMs &&
944
+ cached.consumerHash === cached.referenceHash
945
+ ) return null;
946
+
947
+ // Try-catch around the dynamic import handles the file disappearing
948
+ // between statSync and import (TOCTOU); module-load errors fall through.
949
+ let mod = null;
950
+ try { mod = await import(pathToFileURL(hbhPath).href); } catch { /* TOCTOU or load error — skip */ return null; }
951
+ if (typeof mod.computeHookBlockDrift !== 'function') return null;
952
+
953
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
954
+ if (typeof mod.isHookBlockLocked === 'function' && mod.isHookBlockLocked(settings)) return null;
955
+
956
+ const report = mod.computeHookBlockDrift(settings.hooks || {});
957
+ let regenerated = false;
958
+
959
+ if (report.drifted) {
960
+ const wantRegenerate = autoUpdateConfig.hookBlockDrift === 'regenerate';
961
+ const safeToRegenerate = wantRegenerate && report.extra.length === 0;
962
+ if (safeToRegenerate && typeof mod.applyAdditiveRegeneration === 'function') {
963
+ const { added } = mod.applyAdditiveRegeneration(settings, report);
964
+ if (added > 0) {
965
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
966
+ regenerated = true;
967
+ emitMutation(
968
+ 'regenerated hook block',
969
+ `added ${plural(added, 'missing hook entry')} (drift ${report.consumerHash} → ${report.referenceHash})`,
970
+ );
971
+ }
972
+ } else {
973
+ const parts = [];
974
+ if (report.missing.length > 0) parts.push(plural(report.missing.length, 'missing entry'));
975
+ if (report.extra.length > 0) parts.push(`${plural(report.extra.length, 'custom hook')} preserved`);
976
+ const reason = parts.join(', ') || 'reordered';
977
+ // stdout (not stderr) so Claude sees this in `additionalContext` and
978
+ // surfaces it to the user — not a mutation since we didn't change anything.
979
+ try {
980
+ process.stdout.write(
981
+ `moflo: hook block drift (${reason}); run \`flo doctor hook-drift\` or set auto_update.hook_block_drift: regenerate in moflo.yaml\n`,
982
+ );
983
+ } catch { /* broken stdout — non-fatal */ }
984
+ }
985
+ }
986
+
987
+ // Regeneration mutated settings.json — re-stat for the fresh mtime so next
988
+ // session's fast-path matches; otherwise reuse the stat we already have.
989
+ let finalSettingsMtime = settingsStat.mtimeMs;
990
+ if (regenerated) {
991
+ try { finalSettingsMtime = statSync(settingsPath).mtimeMs; } catch { /* keep prior */ }
992
+ }
993
+ // After successful regeneration consumerHash matches referenceHash by construction.
994
+ const finalConsumerHash = regenerated ? report.referenceHash : report.consumerHash;
995
+
996
+ return {
997
+ cachePath,
998
+ settingsMtimeMs: finalSettingsMtime,
999
+ moduleMtimeMs: hbhStat.mtimeMs,
1000
+ consumerHash: finalConsumerHash,
1001
+ referenceHash: report.referenceHash,
1002
+ };
1003
+ }
1004
+
1005
+ try {
1006
+ if (autoUpdateConfig.enabled && autoUpdateConfig.hookBlockDrift !== 'off') {
1007
+ const result = await runHookBlockDriftCheck();
1008
+ if (result) {
1009
+ try {
1010
+ mkdirSync(mofloDir(projectRoot), { recursive: true });
1011
+ writeFileSync(result.cachePath, JSON.stringify({
1012
+ settingsMtimeMs: result.settingsMtimeMs,
1013
+ moduleMtimeMs: result.moduleMtimeMs,
1014
+ consumerHash: result.consumerHash,
1015
+ referenceHash: result.referenceHash,
1016
+ }));
1017
+ } catch { /* cache is opportunistic — non-fatal */ }
1018
+ }
1019
+ }
1020
+ } catch (err) {
1021
+ emitWarning(`hook-block drift check skipped (${errMessage(err)})`);
1022
+ }
1023
+
884
1024
  // ── 3b. Ensure shipped guidance files exist (even without version change) ──
885
1025
  // Subagents need these files on disk for direct reads without memory search.
886
1026
  // Also prunes top-level mirrors whose source no longer exists in shipped/
@@ -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';
@@ -1548,6 +1548,7 @@ export const doctorCommand = {
1548
1548
  checkMcpSpellIntegration,
1549
1549
  checkHookExecution,
1550
1550
  checkGateHealth,
1551
+ checkHookBlockDrift,
1551
1552
  checkMofloDbBridge,
1552
1553
  // Issue #818 / epic #798 — coordinator-path tripwires. They share the
1553
1554
  // singleton coordinator with checkSubagentHealth above and assert by
@@ -1595,6 +1596,8 @@ export const doctorCommand = {
1595
1596
  'hooks': checkHookExecution,
1596
1597
  'gates': checkGateHealth,
1597
1598
  'gate': checkGateHealth,
1599
+ 'hook-drift': checkHookBlockDrift,
1600
+ 'drift': checkHookBlockDrift,
1598
1601
  'sandbox': checkSandboxTier,
1599
1602
  'sandbox-tier': checkSandboxTier,
1600
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
@@ -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
@@ -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
@@ -15,6 +15,8 @@ export { AgentRouter, getAgentRouter, routeTask, AGENT_CAPABILITIES, } from './a
15
15
  export { startDashboard, createDashboardMemoryAccessor, DEFAULT_DASHBOARD_PORT, } from './daemon-dashboard.js';
16
16
  // Hook Wiring (shared between doctor, upgrade, and session-start)
17
17
  export { repairHookWiring, HOOK_ENTRY_MAP, REQUIRED_HOOK_WIRING, } from './hook-wiring.js';
18
+ // Hook Block Drift Detection (#881 — hash-based reconciliation)
19
+ export { computeHookBlockHash, computeHookBlockDrift, formatDriftReport, getReferenceHookBlock, normaliseHooks, isHookBlockLocked, applyAdditiveRegeneration, DRIFT_MODES, } from './hook-block-hash.js';
18
20
  // Subagent Bootstrap Directive (single-source for SubagentStart + agent_spawn surfaces)
19
21
  export { SUBAGENT_BOOTSTRAP_DIRECTIVE } from './subagent-bootstrap.js';
20
22
  //# sourceMappingURL=index.js.map
@@ -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.11';
5
+ export const VERSION = '4.9.12';
6
6
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moflo",
3
- "version": "4.9.11",
3
+ "version": "4.9.12",
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.10",
84
+ "moflo": "^4.9.11",
85
85
  "tsx": "^4.21.0",
86
86
  "typescript": "^5.9.3",
87
87
  "vitest": "^4.0.0"