moflo 4.10.25 → 4.10.27
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/guidance/shipped/moflo-yaml-reference.md +3 -2
- package/.claude/helpers/simplify-classify.cjs +172 -29
- package/.claude/skills/flo-simplify/SKILL.md +26 -5
- package/LICENSE +21 -21
- package/README.md +8 -0
- package/bin/build-embeddings.mjs +0 -0
- package/bin/gate-hook.mjs +0 -0
- package/bin/gate.cjs +0 -0
- package/bin/generate-code-map.mjs +0 -0
- package/bin/hook-handler.cjs +0 -0
- package/bin/hooks.mjs +0 -0
- package/bin/index-all.mjs +0 -0
- package/bin/index-guidance.mjs +0 -0
- package/bin/index-patterns.mjs +0 -0
- package/bin/index-tests.mjs +0 -0
- package/bin/lib/meditate.mjs +54 -6
- package/bin/lib/moflo-resolve.mjs +0 -0
- package/bin/lib/process-manager.mjs +0 -0
- package/bin/lib/registry-cleanup.cjs +0 -0
- package/bin/meditate-distill.mjs +4 -0
- package/bin/npx-repair.js +0 -0
- package/bin/npx-safe-launch.js +0 -0
- package/bin/prompt-hook.mjs +0 -0
- package/bin/semantic-search.mjs +0 -0
- package/bin/session-start-launcher.mjs +16 -2
- package/bin/setup-project.mjs +0 -0
- package/bin/simplify-classify.cjs +134 -12
- package/dist/src/cli/commands/doctor-checks-deep.js +1 -1
- package/dist/src/cli/init/moflo-init.js +95 -161
- package/dist/src/cli/memory/entries-write.js +11 -1
- package/dist/src/cli/services/daemon-lock.js +25 -8
- package/dist/src/cli/spells/commands/composite-command.js +13 -2
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -2
|
@@ -7,17 +7,28 @@
|
|
|
7
7
|
* deterministic and unit-testable instead of a prose decision Claude makes
|
|
8
8
|
* over and over per run.
|
|
9
9
|
*
|
|
10
|
-
* Rule: default to single-agent Sonnet review.
|
|
11
|
-
* fan-out when diff signals
|
|
12
|
-
*
|
|
10
|
+
* Rule: default to single-agent Sonnet review. Escalate to a 3-agent Sonnet
|
|
11
|
+
* fan-out (NORMAL) when diff signals warrant it, and to a 3-agent Opus fan-out
|
|
12
|
+
* (DEEP) only for genuinely architectural diffs — ordinary review is
|
|
13
|
+
* breadth-bound (Sonnet wins), but architectural review is depth-bound (Opus
|
|
14
|
+
* earns its cost). The most extreme diffs additionally suggest handing off to
|
|
15
|
+
* Claude Code's built-in /simplify via escalate.suggested. (#1222 follow-up)
|
|
16
|
+
*
|
|
17
|
+
* Opus escalation is gated on genuine new-logic evidence, NEVER raw volume:
|
|
18
|
+
* TS/JS uses net-new declarations; other languages use net-new lines
|
|
19
|
+
* (added − deleted, aggregate → relocation/churn cancels out). Noise
|
|
20
|
+
* (lockfiles, snapshots, generated/vendored) and docs/data never count toward
|
|
21
|
+
* the opus bar. So a lockfile bump, a reformatting sweep, or a big rename can
|
|
22
|
+
* never reach Opus.
|
|
13
23
|
*
|
|
14
24
|
* Outputs JSON:
|
|
15
25
|
* {
|
|
16
|
-
* "tier": "TRIVIAL" | "SMALL" | "NORMAL",
|
|
17
|
-
* "model": "sonnet",
|
|
26
|
+
* "tier": "TRIVIAL" | "SMALL" | "NORMAL" | "DEEP",
|
|
27
|
+
* "model": "sonnet" | "haiku" | "opus",
|
|
18
28
|
* "agentCount": 0 | 1 | 3,
|
|
29
|
+
* "escalate": { suggested: bool, target: "builtin-simplify"|null, reason: string|null },
|
|
19
30
|
* "reasoning": [string, ...],
|
|
20
|
-
* "stats": { added, deleted, fileCount, declAdded, declRemoved, ... }
|
|
31
|
+
* "stats": { added, deleted, fileCount, declAdded, declRemoved, tsjsLOC, tsjsNetDecls, otherNetAdded, ... }
|
|
21
32
|
* }
|
|
22
33
|
*
|
|
23
34
|
* Usage:
|
|
@@ -45,6 +56,48 @@ const SECURITY_PATHS = [
|
|
|
45
56
|
/(?:^|[\\\/])\.claude[\\\/]helpers[\\\/]gate/i,
|
|
46
57
|
];
|
|
47
58
|
|
|
59
|
+
// ── File-family classification for the opus-escalation gate (#1222) ───────────
|
|
60
|
+
// Opus is gated on genuine new-logic evidence, never raw volume — so generated
|
|
61
|
+
// noise and docs/data are stripped before measuring, and TS/JS (where the decl
|
|
62
|
+
// parser is accurate) is measured by net-new declarations while other languages
|
|
63
|
+
// fall back to net-new lines.
|
|
64
|
+
|
|
65
|
+
// Generated / vendored noise — inflates LOC without adding reviewable logic.
|
|
66
|
+
const NOISE_FILE = [
|
|
67
|
+
/(?:^|[\\\/])(?:package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb?|composer\.lock|Cargo\.lock|poetry\.lock|Gemfile\.lock|go\.sum)$/i,
|
|
68
|
+
/\.snap$/i,
|
|
69
|
+
/(?:^|[\\\/])__snapshots__[\\\/]/i,
|
|
70
|
+
/(?:^|[\\\/])(?:dist|build|out|coverage|node_modules)[\\\/]/i,
|
|
71
|
+
/\.min\.(?:js|css)$/i,
|
|
72
|
+
/\.(?:map|bundle\.js)$/i,
|
|
73
|
+
/(?:^|[\\\/])vendor[\\\/]/i,
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
// Docs / data — reviewed at normal tiers, but never counted toward the opus bar.
|
|
77
|
+
const DOCDATA_FILE = /\.(?:md|mdx|markdown|txt|rst|json|json5|ya?ml|toml|ini|cfg|conf|xml|csv|tsv|svg|properties|env)$/i;
|
|
78
|
+
|
|
79
|
+
// TS/JS source — the declaration parser is accurate here, so the opus gate uses
|
|
80
|
+
// net-new declarations for these files.
|
|
81
|
+
const TSJS_FILE = /\.(?:ts|tsx|js|jsx|mjs|cjs|mts|cts)$/i;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Bucket a file path into the family the opus-escalation gate cares about:
|
|
85
|
+
* 'noise' / 'docdata' (both excluded from the opus bar), 'tsjs' (decl-gated),
|
|
86
|
+
* or 'othercode' (net-line-gated). Pure function over the path string.
|
|
87
|
+
*/
|
|
88
|
+
function fileFamily(filename) {
|
|
89
|
+
if (NOISE_FILE.some((rx) => rx.test(filename))) return 'noise';
|
|
90
|
+
if (TSJS_FILE.test(filename)) return 'tsjs';
|
|
91
|
+
if (DOCDATA_FILE.test(filename)) return 'docdata';
|
|
92
|
+
return 'othercode';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Default "no escalation" marker attached to every non-DEEP decision so the
|
|
96
|
+
// output shape (decision.escalate) is stable for every consumer.
|
|
97
|
+
function noEscalate() {
|
|
98
|
+
return { suggested: false, target: null, reason: null };
|
|
99
|
+
}
|
|
100
|
+
|
|
48
101
|
function safeExec(cmd, opts) {
|
|
49
102
|
try {
|
|
50
103
|
return execSync(cmd, {
|
|
@@ -146,6 +199,11 @@ function parseDiff(diff) {
|
|
|
146
199
|
let added = 0, deleted = 0, declAdded = 0, declRemoved = 0;
|
|
147
200
|
let newFiles = 0, renamedFiles = 0;
|
|
148
201
|
let securityHit = false;
|
|
202
|
+
// Family-segregated signals for the opus-escalation gate (#1222). Noise +
|
|
203
|
+
// docs/data still contribute to the global totals (so existing
|
|
204
|
+
// TRIVIAL/SMALL/NORMAL routing is unchanged) but never to the opus bar.
|
|
205
|
+
let tsjsAdded = 0, tsjsDeleted = 0, tsjsDeclAdded = 0, tsjsDeclRemoved = 0;
|
|
206
|
+
let otherAdded = 0, otherDeleted = 0;
|
|
149
207
|
for (const f of files.values()) {
|
|
150
208
|
added += f.added;
|
|
151
209
|
deleted += f.deleted;
|
|
@@ -154,6 +212,14 @@ function parseDiff(diff) {
|
|
|
154
212
|
if (f.isNew) newFiles++;
|
|
155
213
|
if (f.isRenamed) renamedFiles++;
|
|
156
214
|
if (SECURITY_PATHS.some(rx => rx.test(f.filename))) securityHit = true;
|
|
215
|
+
|
|
216
|
+
const fam = fileFamily(f.filename);
|
|
217
|
+
if (fam === 'tsjs') {
|
|
218
|
+
tsjsAdded += f.added; tsjsDeleted += f.deleted;
|
|
219
|
+
tsjsDeclAdded += f.declAdded; tsjsDeclRemoved += f.declRemoved;
|
|
220
|
+
} else if (fam === 'othercode') {
|
|
221
|
+
otherAdded += f.added; otherDeleted += f.deleted;
|
|
222
|
+
}
|
|
157
223
|
}
|
|
158
224
|
|
|
159
225
|
return {
|
|
@@ -162,6 +228,13 @@ function parseDiff(diff) {
|
|
|
162
228
|
fileCount: files.size,
|
|
163
229
|
newFiles, renamedFiles,
|
|
164
230
|
securityHit,
|
|
231
|
+
// Opus-gate signals (#1222): net-new declarations for TS/JS, net-new lines
|
|
232
|
+
// for other code. Aggregate net → relocation/churn cancels to ~0.
|
|
233
|
+
tsjsLOC: tsjsAdded + tsjsDeleted,
|
|
234
|
+
tsjsNetDecls: tsjsDeclAdded - tsjsDeclRemoved,
|
|
235
|
+
tsjsDeclAdded,
|
|
236
|
+
otherNetAdded: otherAdded - otherDeleted,
|
|
237
|
+
otherLOC: otherAdded + otherDeleted,
|
|
165
238
|
files: [...files.keys()],
|
|
166
239
|
};
|
|
167
240
|
}
|
|
@@ -175,13 +248,13 @@ function decide(stats) {
|
|
|
175
248
|
const totalChange = stats.added + stats.deleted;
|
|
176
249
|
|
|
177
250
|
if (totalChange === 0) {
|
|
178
|
-
return { tier: 'TRIVIAL', model: 'sonnet', agentCount: 0, reasoning: ['empty diff — nothing to review'], stats };
|
|
251
|
+
return { tier: 'TRIVIAL', model: 'sonnet', agentCount: 0, escalate: noEscalate(), reasoning: ['empty diff — nothing to review'], stats };
|
|
179
252
|
}
|
|
180
253
|
|
|
181
254
|
// TRIVIAL: tiny diff, no declarations changed
|
|
182
255
|
if (totalChange <= 10 && stats.fileCount <= 1 && stats.netDecls === 0 && stats.declAdded === 0 && stats.declRemoved === 0) {
|
|
183
256
|
reasoning.push(`≤10 LOC in 1 file with no declaration changes`);
|
|
184
|
-
return { tier: 'TRIVIAL', model: 'sonnet', agentCount: 0, reasoning, stats };
|
|
257
|
+
return { tier: 'TRIVIAL', model: 'sonnet', agentCount: 0, escalate: noEscalate(), reasoning, stats };
|
|
185
258
|
}
|
|
186
259
|
|
|
187
260
|
// Mechanical relocation detection — the #906 case.
|
|
@@ -203,11 +276,60 @@ function decide(stats) {
|
|
|
203
276
|
// Haiku is sufficient for mechanical moves: code already existed and worked,
|
|
204
277
|
// so review reduces to copy-paste-divergence / dead-after-move pattern checks
|
|
205
278
|
// — exactly haiku's strength. ~5x cheaper than sonnet on relocation-shape diffs.
|
|
206
|
-
return { tier: 'SMALL', model: 'haiku', agentCount: 1, reasoning, stats };
|
|
279
|
+
return { tier: 'SMALL', model: 'haiku', agentCount: 1, escalate: noEscalate(), reasoning, stats };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ── Architectural escalation to Opus (#1222) ────────────────────────────────
|
|
283
|
+
// Two rungs above NORMAL, BOTH gated on genuine new-logic evidence so volume
|
|
284
|
+
// alone never escalates: TS/JS by net-new declarations, other languages by
|
|
285
|
+
// net-new lines. The relocation guard above already returned SMALL, so
|
|
286
|
+
// mechanical moves never reach here, and noise/docs/data were stripped from
|
|
287
|
+
// these signals in parseDiff.
|
|
288
|
+
//
|
|
289
|
+
// • DEEP → runs a 3-agent Opus pass automatically (depth-bound
|
|
290
|
+
// architectural review; ordinary review stays Sonnet).
|
|
291
|
+
// • DEEP + handoff → also suggests Claude Code's built-in /simplify for the
|
|
292
|
+
// most extreme diffs (escalate.suggested = true). The
|
|
293
|
+
// Opus pass still runs as the floor; the handoff is a
|
|
294
|
+
// prompt, not an auto-switch.
|
|
295
|
+
const tsjsLOC = stats.tsjsLOC || 0;
|
|
296
|
+
const tsjsNetDecls = stats.tsjsNetDecls || 0;
|
|
297
|
+
const tsjsDeclAdded = stats.tsjsDeclAdded || 0;
|
|
298
|
+
const otherNetAdded = stats.otherNetAdded || 0;
|
|
299
|
+
|
|
300
|
+
// The new-subsystem triggers count TS/JS declarations only (tsjs-scoped, not
|
|
301
|
+
// global) so a docs/data file with a fenced `export function` code sample
|
|
302
|
+
// can't leak into the opus gate — consistent with the net-new-logic contract.
|
|
303
|
+
const handoffTriggers = [];
|
|
304
|
+
if (tsjsLOC > 4000 && tsjsNetDecls >= 25) handoffTriggers.push(`${tsjsLOC} LOC of TS/JS with ${tsjsNetDecls} net-new declarations`);
|
|
305
|
+
if (otherNetAdded > 3000) handoffTriggers.push(`${otherNetAdded} net-new lines of non-TS/JS source`);
|
|
306
|
+
if (stats.newFiles >= 10 && tsjsDeclAdded >= 30 && tsjsNetDecls >= 20) handoffTriggers.push(`${stats.newFiles} new files with ${tsjsDeclAdded} new TS/JS declarations`);
|
|
307
|
+
|
|
308
|
+
if (handoffTriggers.length > 0) {
|
|
309
|
+
return {
|
|
310
|
+
tier: 'DEEP', model: 'opus', agentCount: 3,
|
|
311
|
+
escalate: { suggested: true, target: 'builtin-simplify', reason: handoffTriggers.join('; ') },
|
|
312
|
+
reasoning: handoffTriggers, stats,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const deepTriggers = [];
|
|
317
|
+
if (tsjsLOC > 1500 && tsjsNetDecls >= 10) deepTriggers.push(`${tsjsLOC} LOC of TS/JS with ${tsjsNetDecls} net-new declarations`);
|
|
318
|
+
if (otherNetAdded > 1200) deepTriggers.push(`${otherNetAdded} net-new lines of non-TS/JS source`);
|
|
319
|
+
if (stats.newFiles >= 5 && tsjsDeclAdded >= 15 && tsjsNetDecls >= 10) deepTriggers.push(`${stats.newFiles} new files with ${tsjsDeclAdded} new TS/JS declarations`);
|
|
320
|
+
if (stats.securityHit && stats.netDecls >= 8) deepTriggers.push(`security-sensitive path with ${stats.netDecls} net-new declarations`);
|
|
321
|
+
|
|
322
|
+
if (deepTriggers.length > 0) {
|
|
323
|
+
return {
|
|
324
|
+
tier: 'DEEP', model: 'opus', agentCount: 3,
|
|
325
|
+
escalate: noEscalate(),
|
|
326
|
+
reasoning: deepTriggers, stats,
|
|
327
|
+
};
|
|
207
328
|
}
|
|
208
329
|
|
|
209
330
|
// Escalation triggers — any one trips NORMAL (3 agents).
|
|
210
|
-
//
|
|
331
|
+
// Sonnet — ordinary cross-cutting review is breadth-bound, so 3 Sonnet agents
|
|
332
|
+
// are the right tool; Opus is reserved for the DEEP (architectural) tier above.
|
|
211
333
|
const triggers = [];
|
|
212
334
|
if (totalChange > 500) triggers.push(`>500 LOC changed (${totalChange})`);
|
|
213
335
|
if (stats.fileCount >= 5 && stats.netDecls >= 3) triggers.push(`${stats.fileCount} files with ${stats.netDecls} net new declarations`);
|
|
@@ -215,12 +337,12 @@ function decide(stats) {
|
|
|
215
337
|
if (stats.newFiles >= 3 && stats.declAdded >= 5) triggers.push(`${stats.newFiles} new files with ${stats.declAdded} new declarations`);
|
|
216
338
|
|
|
217
339
|
if (triggers.length > 0) {
|
|
218
|
-
return { tier: 'NORMAL', model: 'sonnet', agentCount: 3, reasoning: triggers, stats };
|
|
340
|
+
return { tier: 'NORMAL', model: 'sonnet', agentCount: 3, escalate: noEscalate(), reasoning: triggers, stats };
|
|
219
341
|
}
|
|
220
342
|
|
|
221
343
|
// Default: SMALL — single sonnet agent
|
|
222
344
|
reasoning.push(`small/medium diff: ${totalChange} LOC across ${stats.fileCount} file(s), +${stats.declAdded}/-${stats.declRemoved} decls`);
|
|
223
|
-
return { tier: 'SMALL', model: 'sonnet', agentCount: 1, reasoning, stats };
|
|
345
|
+
return { tier: 'SMALL', model: 'sonnet', agentCount: 1, escalate: noEscalate(), reasoning, stats };
|
|
224
346
|
}
|
|
225
347
|
|
|
226
348
|
function classifyDiff(diffText) {
|
|
@@ -667,7 +667,7 @@ export async function checkHookBlockDrift() {
|
|
|
667
667
|
name: 'Hook Block Drift',
|
|
668
668
|
status: 'warn',
|
|
669
669
|
message: parts.join(', '),
|
|
670
|
-
fix: '
|
|
670
|
+
fix: 'session-start auto-regenerates by default (#1227); next Claude Code start should heal this. If it persists, ensure `auto_update.hook_block_drift` is not set to `warn`/`off` in moflo.yaml, or set `moflo.hooks.locked: true` to suppress.',
|
|
671
671
|
};
|
|
672
672
|
}
|
|
673
673
|
// ============================================================================
|
|
@@ -19,6 +19,9 @@ import { generateClaudeMd as generateMofloSection } from './claudemd-generator.j
|
|
|
19
19
|
import { applyInjectionReplacement } from '../services/claudemd-injection.js';
|
|
20
20
|
import { loadShippedScripts } from './shipped-scripts.js';
|
|
21
21
|
import { DEFAULT_INIT_OPTIONS } from './types.js';
|
|
22
|
+
import { generateSettings } from './settings-generator.js';
|
|
23
|
+
import { applyWholesaleRegeneration, computeHookBlockDrift, isHookBlockLocked, } from '../services/hook-block-hash.js';
|
|
24
|
+
import { rewriteIncorrectHookWiring } from '../services/hook-wiring.js';
|
|
22
25
|
export { discoverTestDirs };
|
|
23
26
|
// ============================================================================
|
|
24
27
|
// Init
|
|
@@ -180,176 +183,107 @@ function generateConfig(root, force, answers) {
|
|
|
180
183
|
// ============================================================================
|
|
181
184
|
// Step 2: .claude/settings.json hooks
|
|
182
185
|
// ============================================================================
|
|
183
|
-
|
|
186
|
+
// #1227 — `flo init` was the surgical patcher that silently nuked user-owned
|
|
187
|
+
// hooks (project-analysis-gate.cjs, e2e-gate.cjs) AND moflo entries in legacy
|
|
188
|
+
// slots (swarm_init/hive-mind_init in PreToolUse, auto-memory-hook in SessionEnd).
|
|
189
|
+
// The old generateHooks had three structural defects:
|
|
190
|
+
// (1) Its "already configured" guard scanned for the literal substring
|
|
191
|
+
// `'flo gate'` / `'moflo gate'` — modern moflo commands are
|
|
192
|
+
// `node ".../helpers/gate.cjs ..."` so the substring NEVER matched and
|
|
193
|
+
// the guard always fell through to the wipe path.
|
|
194
|
+
// (2) `existing.hooks = hooks` was a wholesale overwrite — the comment claimed
|
|
195
|
+
// "preserve existing non-MoFlo hooks" but the code did the opposite.
|
|
196
|
+
// (3) Its inlined canonical block had drifted from settings-generator.ts /
|
|
197
|
+
// hook-block-hash.ts (no swarm_init, no hive-mind_init, no Stop
|
|
198
|
+
// auto-memory-hook, SessionStart launcher timeout 3000 not 5000).
|
|
199
|
+
//
|
|
200
|
+
// New shape: one canonical source. For missing settings.json, write
|
|
201
|
+
// generateSettings(DEFAULT_INIT_OPTIONS). For existing, run the same wholesale
|
|
202
|
+
// regen the session-start launcher uses — applyWholesaleRegeneration preserves
|
|
203
|
+
// user-owned entries via the #1180 basename guard AND relocates moflo entries
|
|
204
|
+
// from legacy slots (SessionEnd → Stop, PreToolUse swarm_init → PostToolUse,
|
|
205
|
+
// etc.). rewriteIncorrectHookWiring runs first so command-string rewrites
|
|
206
|
+
// (#879 / #931) are healed before the structural pass hashes the block.
|
|
207
|
+
function generateHooks(root, force, _answers) {
|
|
184
208
|
const settingsPath = path.join(root, '.claude', 'settings.json');
|
|
185
209
|
const settingsDir = path.dirname(settingsPath);
|
|
186
210
|
if (!fs.existsSync(settingsDir)) {
|
|
187
211
|
fs.mkdirSync(settingsDir, { recursive: true });
|
|
188
212
|
}
|
|
189
|
-
|
|
190
|
-
if (fs.existsSync(settingsPath)) {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
213
|
+
// No settings.json yet — write the canonical default and return.
|
|
214
|
+
if (!fs.existsSync(settingsPath)) {
|
|
215
|
+
const fresh = generateSettings({ ...DEFAULT_INIT_OPTIONS, targetDir: root, force: true });
|
|
216
|
+
fs.writeFileSync(settingsPath, JSON.stringify(fresh, null, 2), 'utf-8');
|
|
217
|
+
return { name: '.claude/settings.json', status: 'created', detail: 'canonical hooks block written' };
|
|
218
|
+
}
|
|
219
|
+
let existing;
|
|
220
|
+
try {
|
|
221
|
+
existing = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
// Corrupt — rewrite from canonical (force-overwrites; user can revert).
|
|
225
|
+
const fresh = generateSettings({ ...DEFAULT_INIT_OPTIONS, targetDir: root, force: true });
|
|
226
|
+
fs.writeFileSync(settingsPath, JSON.stringify(fresh, null, 2), 'utf-8');
|
|
227
|
+
return { name: '.claude/settings.json', status: 'updated', detail: 'rewrote unparseable settings.json from canonical' };
|
|
228
|
+
}
|
|
229
|
+
// Respect the explicit opt-out — same sentinel the launcher honours.
|
|
230
|
+
if (isHookBlockLocked(existing) && !force) {
|
|
231
|
+
return { name: '.claude/settings.json', status: 'skipped', detail: 'moflo.hooks.locked=true (explicit opt-out)' };
|
|
232
|
+
}
|
|
233
|
+
// Pass 1: in-place command + matcher rewrites (#879, #929, #931, #1171,
|
|
234
|
+
// auto-meditate rebrand). These never delete anything; they fix commands
|
|
235
|
+
// that exist but point at the wrong helper/subcommand.
|
|
236
|
+
const { rewrites } = rewriteIncorrectHookWiring(existing);
|
|
237
|
+
const rewroteCommands = rewrites.reduce((n, r) => n + r.count, 0);
|
|
238
|
+
// Pass 2: structural wholesale regen. Preserves user-owned entries via the
|
|
239
|
+
// #1180 basename guard (any command not pointing at a moflo-shipped helper
|
|
240
|
+
// is grafted back in); relocates moflo entries from legacy event/matcher
|
|
241
|
+
// slots to the current canonical shape.
|
|
242
|
+
const report = computeHookBlockDrift((existing.hooks ?? {}));
|
|
243
|
+
let added = 0;
|
|
244
|
+
let removed = 0;
|
|
245
|
+
let preserved = 0;
|
|
246
|
+
if (report.drifted) {
|
|
247
|
+
const extraCount = report.extra.length;
|
|
248
|
+
const result = applyWholesaleRegeneration(existing, report);
|
|
249
|
+
added = result.added;
|
|
250
|
+
removed = result.removed;
|
|
251
|
+
// applyWholesaleRegeneration computes `removed = extra - customisations`,
|
|
252
|
+
// so `preserved` is the complement — the number of user-owned entries
|
|
253
|
+
// grafted back into the fresh tree.
|
|
254
|
+
preserved = extraCount - removed;
|
|
255
|
+
}
|
|
256
|
+
// Ensure statusLine + permissions/env/attribution scaffold is present —
|
|
257
|
+
// mirrors the existing moflo-init.ts UX but no longer overwrites user
|
|
258
|
+
// values that are already set.
|
|
259
|
+
const canonical = generateSettings({ ...DEFAULT_INIT_OPTIONS, targetDir: root, force: true });
|
|
260
|
+
const scaffoldKeys = ['statusLine', 'permissions', 'env', 'attribution'];
|
|
261
|
+
const scaffoldAdded = [];
|
|
262
|
+
for (const key of scaffoldKeys) {
|
|
263
|
+
if (existing[key] == null && canonical[key] != null) {
|
|
264
|
+
existing[key] = canonical[key];
|
|
265
|
+
scaffoldAdded.push(key);
|
|
200
266
|
}
|
|
201
267
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
const gateHook = (sub) => `node "$CLAUDE_PROJECT_DIR/.claude/helpers/gate-hook.mjs" ${sub}`;
|
|
206
|
-
const gate = (sub) => `node "$CLAUDE_PROJECT_DIR/.claude/helpers/gate.cjs" ${sub}`;
|
|
207
|
-
const handler = (sub) => `node "$CLAUDE_PROJECT_DIR/.claude/helpers/hook-handler.cjs" ${sub}`;
|
|
208
|
-
const hooks = {
|
|
209
|
-
"PreToolUse": [
|
|
210
|
-
{
|
|
211
|
-
"matcher": "^(Write|Edit|MultiEdit)$",
|
|
212
|
-
"hooks": [{ "type": "command", "command": handler('post-edit'), "timeout": 5000 }]
|
|
213
|
-
},
|
|
214
|
-
{
|
|
215
|
-
"matcher": "^(Glob|Grep)$",
|
|
216
|
-
"hooks": [{ "type": "command", "command": gateHook('check-before-scan'), "timeout": 3000 }]
|
|
217
|
-
},
|
|
218
|
-
{
|
|
219
|
-
"matcher": "^Read$",
|
|
220
|
-
"hooks": [{ "type": "command", "command": gateHook('check-before-read'), "timeout": 3000 }]
|
|
221
|
-
},
|
|
222
|
-
{
|
|
223
|
-
// #1171 — widened to cover the dedicated `PowerShell` tool.
|
|
224
|
-
"matcher": "^(Bash|PowerShell)$",
|
|
225
|
-
"hooks": [
|
|
226
|
-
{ "type": "command", "command": gateHook('check-dangerous-command'), "timeout": 2000 },
|
|
227
|
-
{ "type": "command", "command": gateHook('check-before-pr'), "timeout": 2000 }
|
|
228
|
-
]
|
|
229
|
-
},
|
|
230
|
-
{
|
|
231
|
-
// #931 — Advisory only; never blocks. TaskCreate REMINDER and the
|
|
232
|
-
// namespace hint moved here from UserPromptSubmit so they emit only
|
|
233
|
-
// when Claude is about to spawn an Agent — saves ~90 tokens × every
|
|
234
|
-
// prompt × every consumer. Routed via gate-hook.mjs so Claude Code's
|
|
235
|
-
// session_id is forwarded as HOOK_SESSION_ID, enabling per-actor
|
|
236
|
-
// single-shot emission (mirror of #879's record-memory-searched fix).
|
|
237
|
-
"matcher": "^Agent$",
|
|
238
|
-
"hooks": [{ "type": "command", "command": gateHook('check-before-agent'), "timeout": 2000 }]
|
|
239
|
-
}
|
|
240
|
-
],
|
|
241
|
-
"PostToolUse": [
|
|
242
|
-
{
|
|
243
|
-
"matcher": "^(Write|Edit|MultiEdit)$",
|
|
244
|
-
"hooks": [
|
|
245
|
-
{ "type": "command", "command": handler('post-edit'), "timeout": 5000 },
|
|
246
|
-
{ "type": "command", "command": gateHook('reset-edit-gates'), "timeout": 2000 }
|
|
247
|
-
]
|
|
248
|
-
},
|
|
249
|
-
{
|
|
250
|
-
"matcher": "^Agent$",
|
|
251
|
-
"hooks": [{ "type": "command", "command": handler('post-task'), "timeout": 5000 }]
|
|
252
|
-
},
|
|
253
|
-
{
|
|
254
|
-
"matcher": "^TaskCreate$",
|
|
255
|
-
"hooks": [{ "type": "command", "command": gate('record-task-created'), "timeout": 2000 }]
|
|
256
|
-
},
|
|
257
|
-
{
|
|
258
|
-
// #1171 — widened to cover the dedicated `PowerShell` tool.
|
|
259
|
-
"matcher": "^(Bash|PowerShell)$",
|
|
260
|
-
"hooks": [
|
|
261
|
-
{ "type": "command", "command": gateHook('check-bash-memory'), "timeout": 2000 },
|
|
262
|
-
{ "type": "command", "command": gateHook('record-test-run'), "timeout": 2000 }
|
|
263
|
-
]
|
|
264
|
-
},
|
|
265
|
-
{
|
|
266
|
-
"matcher": "^Skill$",
|
|
267
|
-
"hooks": [{ "type": "command", "command": gateHook('record-skill-run'), "timeout": 2000 }]
|
|
268
|
-
},
|
|
269
|
-
{
|
|
270
|
-
// Anchored alternation — Claude Code anchors hook matchers (`^…$` semantics),
|
|
271
|
-
// so a bare `mcp__moflo__memory_` never matches any real MCP tool name and the
|
|
272
|
-
// hook silently no-ops (#929 regression). The explicit suffix list keeps the
|
|
273
|
-
// matcher narrow while catching every memory_* tool we ship.
|
|
274
|
-
// Use gateHook (not gate) 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).
|
|
279
|
-
"matcher": "^mcp__moflo__memory_(search|retrieve|list|stats|store)$",
|
|
280
|
-
"hooks": [{ "type": "command", "command": gateHook('record-memory-searched'), "timeout": 3000 }]
|
|
281
|
-
},
|
|
282
|
-
{
|
|
283
|
-
"matcher": "^mcp__moflo__memory_store$",
|
|
284
|
-
"hooks": [{ "type": "command", "command": gate('record-learnings-stored'), "timeout": 2000 }]
|
|
285
|
-
}
|
|
286
|
-
],
|
|
287
|
-
"UserPromptSubmit": [
|
|
288
|
-
{
|
|
289
|
-
"hooks": [
|
|
290
|
-
{ "type": "command", "command": `node "$CLAUDE_PROJECT_DIR/.claude/helpers/prompt-hook.mjs"`, "timeout": 3000 }
|
|
291
|
-
]
|
|
292
|
-
},
|
|
293
|
-
{
|
|
294
|
-
// prompt-state-reset is REQUIRED to reset memorySearched/memorySearchedBy on
|
|
295
|
-
// each new prompt and reclassify memoryRequired. Without it, gate state leaks
|
|
296
|
-
// across prompts. Separate hook entry so a prompt-hook.mjs exception doesn't
|
|
297
|
-
// skip the reset. Idempotent state reset only — no emission, no
|
|
298
|
-
// interactionCount increment (#931 dedupe).
|
|
299
|
-
"hooks": [
|
|
300
|
-
{ "type": "command", "command": gateHook('prompt-state-reset'), "timeout": 3000 }
|
|
301
|
-
]
|
|
302
|
-
}
|
|
303
|
-
],
|
|
304
|
-
"SubagentStart": [
|
|
305
|
-
{
|
|
306
|
-
"hooks": [{
|
|
307
|
-
"type": "command",
|
|
308
|
-
"command": "node \"$CLAUDE_PROJECT_DIR/.claude/helpers/subagent-start.cjs\"",
|
|
309
|
-
"timeout": 2000
|
|
310
|
-
}]
|
|
311
|
-
}
|
|
312
|
-
],
|
|
313
|
-
"SessionStart": [
|
|
314
|
-
{
|
|
315
|
-
"hooks": [
|
|
316
|
-
{
|
|
317
|
-
"type": "command",
|
|
318
|
-
"command": "node \"$CLAUDE_PROJECT_DIR/.claude/scripts/session-start-launcher.mjs\"",
|
|
319
|
-
"timeout": 3000
|
|
320
|
-
}
|
|
321
|
-
]
|
|
322
|
-
}
|
|
323
|
-
],
|
|
324
|
-
"Stop": [
|
|
325
|
-
{
|
|
326
|
-
"hooks": [{ "type": "command", "command": handler('session-end'), "timeout": 5000 }]
|
|
327
|
-
}
|
|
328
|
-
],
|
|
329
|
-
"PreCompact": [
|
|
330
|
-
{
|
|
331
|
-
"hooks": [{ "type": "command", "command": gate('compact-guidance'), "timeout": 3000 }]
|
|
332
|
-
}
|
|
333
|
-
],
|
|
334
|
-
"Notification": [
|
|
335
|
-
{
|
|
336
|
-
"hooks": [{ "type": "command", "command": handler('notification'), "timeout": 3000 }]
|
|
337
|
-
}
|
|
338
|
-
]
|
|
339
|
-
};
|
|
340
|
-
// Merge: preserve existing non-MoFlo hooks, add MoFlo hooks
|
|
341
|
-
existing.hooks = hooks;
|
|
342
|
-
// Ensure statusLine is always present (required for dashboard display).
|
|
343
|
-
// The executor.ts / settings-generator.ts code path adds this, but
|
|
344
|
-
// moflo-init.ts uses its own generateHooks() which was missing it.
|
|
345
|
-
if (!existing.statusLine) {
|
|
346
|
-
existing.statusLine = {
|
|
347
|
-
type: 'command',
|
|
348
|
-
command: 'node "$CLAUDE_PROJECT_DIR/.claude/helpers/statusline.cjs"',
|
|
349
|
-
};
|
|
268
|
+
const dirty = rewroteCommands > 0 || added > 0 || removed > 0 || scaffoldAdded.length > 0;
|
|
269
|
+
if (!dirty) {
|
|
270
|
+
return { name: '.claude/settings.json', status: 'skipped', detail: 'already at canonical reference' };
|
|
350
271
|
}
|
|
351
272
|
fs.writeFileSync(settingsPath, JSON.stringify(existing, null, 2), 'utf-8');
|
|
352
|
-
|
|
273
|
+
// Surface deletions, preserved customisations, and rewrites so nothing is
|
|
274
|
+
// silent — direct response to #1227's "no notice was printed" complaint.
|
|
275
|
+
const parts = [];
|
|
276
|
+
if (added > 0)
|
|
277
|
+
parts.push(`+${added} canonical`);
|
|
278
|
+
if (removed > 0)
|
|
279
|
+
parts.push(`-${removed} stale moflo`);
|
|
280
|
+
if (preserved > 0)
|
|
281
|
+
parts.push(`✓${preserved} preserved`);
|
|
282
|
+
if (rewroteCommands > 0)
|
|
283
|
+
parts.push(`↻${rewroteCommands} rewrites`);
|
|
284
|
+
if (scaffoldAdded.length > 0)
|
|
285
|
+
parts.push(`+scaffold (${scaffoldAdded.join(',')})`);
|
|
286
|
+
return { name: '.claude/settings.json', status: 'updated', detail: parts.join(', ') };
|
|
353
287
|
}
|
|
354
288
|
// ============================================================================
|
|
355
289
|
// Step 3: .claude/skills/flo/ skill (with /fl alias)
|
|
@@ -50,7 +50,17 @@ export async function storeEntry(options) {
|
|
|
50
50
|
if (options.namespace === 'learnings') {
|
|
51
51
|
const tags = options.tags ?? [];
|
|
52
52
|
if (!tags.some((t) => typeof t === 'string' && t.startsWith('source:'))) {
|
|
53
|
-
|
|
53
|
+
// Default provenance is `source:manual`, but a writer running in a known
|
|
54
|
+
// context can declare its own origin deterministically via the
|
|
55
|
+
// MOFLO_LEARNINGS_SOURCE env (the auto-meditate distill sets it to
|
|
56
|
+
// `auto-meditate`) so attribution never depends on a headless model
|
|
57
|
+
// remembering to pass the tag. Validated to a bare slug so a stray env
|
|
58
|
+
// value can't inject arbitrary tag text. (#1203 follow-up)
|
|
59
|
+
const envSource = process.env.MOFLO_LEARNINGS_SOURCE;
|
|
60
|
+
const source = envSource && /^[a-z0-9][a-z0-9-]*$/i.test(envSource)
|
|
61
|
+
? `source:${envSource}`
|
|
62
|
+
: 'source:manual';
|
|
63
|
+
options = { ...options, tags: [...tags, source] };
|
|
54
64
|
}
|
|
55
65
|
}
|
|
56
66
|
// #981 — single-writer routing. When an external daemon is reachable AND
|
|
@@ -65,7 +65,16 @@ export function readOwnMofloVersion() {
|
|
|
65
65
|
* @returns `{ acquired: true }` on success,
|
|
66
66
|
* `{ acquired: false, holder: pid }` if another daemon owns the lock.
|
|
67
67
|
*/
|
|
68
|
-
export function acquireDaemonLock(projectRoot, pid = process.pid
|
|
68
|
+
export function acquireDaemonLock(projectRoot, pid = process.pid,
|
|
69
|
+
/**
|
|
70
|
+
* Pre-computed project-daemon PIDs for the pre-acquire reap. When supplied,
|
|
71
|
+
* the reap skips the OS process scan (`findProjectDaemonPids`) and operates
|
|
72
|
+
* on exactly these PIDs. A caller that already enumerated project daemons
|
|
73
|
+
* passes them here to avoid a redundant scan; tests use it to make the reap
|
|
74
|
+
* deterministic instead of depending on the contention-sensitive cold-shell
|
|
75
|
+
* scan (mirrors the `pidsHint` seam on `reapSameProjectOrphans`).
|
|
76
|
+
*/
|
|
77
|
+
opts = {}) {
|
|
69
78
|
const lock = lockPath(projectRoot);
|
|
70
79
|
const stateDir = join(projectRoot, '.moflo');
|
|
71
80
|
// Ensure state directory exists
|
|
@@ -79,7 +88,7 @@ export function acquireDaemonLock(projectRoot, pid = process.pid) {
|
|
|
79
88
|
// the failure mode that produced two-daemons-per-project in #1145's
|
|
80
89
|
// waxstack audit.
|
|
81
90
|
const lockHolderPid = readLockPayload(lock)?.pid;
|
|
82
|
-
reapSameProjectOrphans(projectRoot, pid, lockHolderPid);
|
|
91
|
+
reapSameProjectOrphans(projectRoot, pid, lockHolderPid, opts.pidsHint);
|
|
83
92
|
const payload = {
|
|
84
93
|
pid,
|
|
85
94
|
startedAt: Date.now(),
|
|
@@ -655,13 +664,21 @@ function terminateOrphan(pid) {
|
|
|
655
664
|
}
|
|
656
665
|
if (!isProcessAlive(pid))
|
|
657
666
|
return true;
|
|
658
|
-
// Escalate to
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
667
|
+
// Escalate. The first kill can fail to land when spawning the OS helper is
|
|
668
|
+
// starved under heavy fork contention — a cold `taskkill.exe` load on Windows
|
|
669
|
+
// exceeding its exec timeout leaves the process alive with no second attempt.
|
|
670
|
+
// Escalate with an IN-PROCESS kill that spawns nothing and is therefore immune
|
|
671
|
+
// to that contention:
|
|
672
|
+
// - POSIX: SIGKILL.
|
|
673
|
+
// - Windows: process.kill maps to TerminateProcess (libuv) for any same-user
|
|
674
|
+
// PID — no subprocess, unlike taskkill. The earlier taskkill /T already
|
|
675
|
+
// best-effort-killed the process tree; this guarantees the main PID dies.
|
|
676
|
+
// (Production value: a transiently-failed reap is the exact #1150 failure mode
|
|
677
|
+
// — a surviving orphan a fresh daemon then spawns alongside.)
|
|
678
|
+
try {
|
|
679
|
+
process.kill(pid, 'SIGKILL');
|
|
664
680
|
}
|
|
681
|
+
catch { /* already dead */ }
|
|
665
682
|
const killDeadline = Date.now() + 1000;
|
|
666
683
|
while (Date.now() < killDeadline && isProcessAlive(pid)) {
|
|
667
684
|
sleepSyncMs(100);
|
|
@@ -217,13 +217,24 @@ function interpolateCommandString(command, config) {
|
|
|
217
217
|
}
|
|
218
218
|
/**
|
|
219
219
|
* Run a shell command and capture output.
|
|
220
|
-
*
|
|
220
|
+
*
|
|
221
|
+
* On POSIX we deliberately use `/bin/sh` (NOT `process.env.SHELL`). The
|
|
222
|
+
* interpolation pipeline uses POSIX single-quote escaping (`shellEscapeValue`),
|
|
223
|
+
* which is safe under sh/bash but breaks under zsh: zsh's stricter globbing
|
|
224
|
+
* treats unmatched `[...]` patterns as a hard error ("zsh:1: no matches found"),
|
|
225
|
+
* whereas POSIX sh and bash leave unmatched globs literal. Honouring the user's
|
|
226
|
+
* interactive `$SHELL` made CI (Ubuntu, /bin/bash) pass while macOS/Linux dev
|
|
227
|
+
* machines on zsh silently failed every composite step containing bracket
|
|
228
|
+
* characters in interpolated values.
|
|
229
|
+
*
|
|
230
|
+
* On Windows we use ComSpec (cmd.exe) — POSIX shell quoting is meaningless
|
|
231
|
+
* there; the rest of the pipeline already special-cases Windows shell-out.
|
|
221
232
|
*/
|
|
222
233
|
function runShellCommand(command, context) {
|
|
223
234
|
const timeout = 30000;
|
|
224
235
|
const shell = process.platform === 'win32'
|
|
225
236
|
? (process.env.ComSpec || 'cmd.exe')
|
|
226
|
-
:
|
|
237
|
+
: '/bin/sh';
|
|
227
238
|
return new Promise((resolve) => {
|
|
228
239
|
const child = exec(command, { timeout, shell }, (error, stdout, stderr) => {
|
|
229
240
|
context.abortSignal?.removeEventListener('abort', onAbort);
|
package/dist/src/cli/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "moflo",
|
|
3
|
-
"version": "4.10.
|
|
3
|
+
"version": "4.10.27",
|
|
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",
|
|
@@ -95,7 +95,7 @@
|
|
|
95
95
|
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
|
96
96
|
"@typescript-eslint/parser": "^7.18.0",
|
|
97
97
|
"eslint": "^8.0.0",
|
|
98
|
-
"moflo": "^4.10.
|
|
98
|
+
"moflo": "^4.10.26",
|
|
99
99
|
"tsx": "^4.21.0",
|
|
100
100
|
"typescript": "^5.9.3",
|
|
101
101
|
"vitest": "^4.0.0"
|