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.
- package/.claude/commands/simplify.md +78 -30
- package/.claude/skills/simplify/SKILL.md +90 -21
- package/bin/session-start-launcher.mjs +144 -4
- package/dist/src/cli/commands/doctor-checks-deep.js +62 -0
- package/dist/src/cli/commands/doctor.js +4 -1
- package/dist/src/cli/config/moflo-config.js +14 -3
- package/dist/src/cli/init/moflo-init.js +5 -3
- package/dist/src/cli/services/hook-block-hash.js +320 -0
- package/dist/src/cli/services/index.js +2 -0
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -2
|
@@ -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
|
|
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
|
-
|
|
9
|
+
## Phase 0: Gate prerequisites
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
These satisfy the memory-first and task-create-first gates. Always do them before any Agent spawn.
|
|
12
12
|
|
|
13
|
-
1. **Memory search
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
25
|
+
## Phase 2: Classify the diff
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
git diff --name-only HEAD~1
|
|
29
|
-
```
|
|
27
|
+
Pick the smallest tier the diff genuinely fits.
|
|
30
28
|
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 —
|
|
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
|
-
|
|
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
|
-
|
|
96
|
+
mcp__moflo__hooks_model-outcome — { task: "...", model: "<chosen>", outcome: "success" | "failure" | "escalated" }
|
|
48
97
|
```
|
|
49
98
|
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
- ≤
|
|
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
|
-
|
|
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
|
|
40
|
-
|
|
41
|
-
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
|
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 —
|
|
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
|
-
|
|
62
|
-
|
|
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
|
|
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.
|
|
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: '
|
|
62
|
+
review: 'sonnet',
|
|
63
63
|
test: 'sonnet',
|
|
64
64
|
},
|
|
65
65
|
model_routing: {
|
|
66
|
-
enabled:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
package/dist/src/cli/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "moflo",
|
|
3
|
-
"version": "4.9.
|
|
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.
|
|
84
|
+
"moflo": "^4.9.11",
|
|
85
85
|
"tsx": "^4.21.0",
|
|
86
86
|
"typescript": "^5.9.3",
|
|
87
87
|
"vitest": "^4.0.0"
|