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