moflo 4.9.11 → 4.9.13

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.
@@ -0,0 +1,341 @@
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, removed: 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, removed: 0 };
293
+ }
294
+ /**
295
+ * Wholesale regeneration: replace `settings.hooks` with the canonical reference
296
+ * block. Drops extras (stale entries from previous moflo versions, e.g. the
297
+ * `gate.cjs session-reset` SessionStart hook removed in #842) AND adds missing
298
+ * entries — the additive variant only does the latter.
299
+ *
300
+ * The caller MUST check `isHookBlockLocked(settings)` first; if locked, the
301
+ * user has opted out and this function should not be called. Non-hooks fields
302
+ * on `settings` (permissions, env, claudeFlow.*, etc.) are preserved.
303
+ *
304
+ * Mutates `settings` in place; caller is responsible for writing the file.
305
+ */
306
+ export function applyWholesaleRegeneration(settings, report) {
307
+ if (!report.drifted)
308
+ return { settings, added: 0, removed: 0 };
309
+ // Clone the cached reference so a later mutation of settings.hooks (by the
310
+ // launcher's settings.json migrations, doctor --fix, etc.) cannot corrupt
311
+ // the cached tree shared across `computeHookBlockDrift` calls in this process.
312
+ settings.hooks = structuredClone(getCachedReference().tree);
313
+ return { settings, added: report.missing.length, removed: report.extra.length };
314
+ }
315
+ /**
316
+ * Format a drift report for human-readable output (multi-line, no colour).
317
+ * Used by `flo doctor` and the session-start launcher's stdout summary.
318
+ */
319
+ export function formatDriftReport(report) {
320
+ if (!report.drifted) {
321
+ return `hook block matches reference (${report.consumerHash})`;
322
+ }
323
+ const lines = [];
324
+ lines.push(`hook block drift detected (consumer ${report.consumerHash} vs reference ${report.referenceHash})`);
325
+ if (report.missing.length > 0) {
326
+ lines.push(` ${report.missing.length} missing:`);
327
+ for (const m of report.missing) {
328
+ const m2 = m.matcher ? ` ${m.matcher}` : '';
329
+ lines.push(` - ${m.event}${m2}: ${m.command}`);
330
+ }
331
+ }
332
+ if (report.extra.length > 0) {
333
+ lines.push(` ${report.extra.length} extra (likely customisations):`);
334
+ for (const e of report.extra) {
335
+ const m2 = e.matcher ? ` ${e.matcher}` : '';
336
+ lines.push(` + ${e.event}${m2}: ${e.command}`);
337
+ }
338
+ }
339
+ return lines.join('\n');
340
+ }
341
+ //# 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.13';
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.13",
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.12",
85
85
  "tsx": "^4.21.0",
86
86
  "typescript": "^5.9.3",
87
87
  "vitest": "^4.0.0"