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.
@@ -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. Only escalate to a 3-agent
11
- * fan-out when diff signals genuinely warrant it. Opus is never selected
12
- * the existing skill already documents that.
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
- // Always Sonnet — Opus is never the right model for /simplify per skill rule.
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: 'set auto_update.hook_block_drift: regenerate in moflo.yaml, or moflo.hooks.locked: true to suppress',
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
- function generateHooks(root, force, answers) {
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
- let existing = {};
190
- if (fs.existsSync(settingsPath)) {
191
- try {
192
- existing = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
193
- }
194
- catch { /* start fresh */ }
195
- // Check if MoFlo hooks already set up
196
- const settingsStr = JSON.stringify(existing);
197
- const hasGateHooks = settingsStr.includes('flo gate') || settingsStr.includes('moflo gate');
198
- if (hasGateHooks && !force) {
199
- return { name: '.claude/settings.json', status: 'skipped', detail: 'MoFlo hooks already configured' };
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
- // Build hooks config all on by default (opinionated pit-of-success)
203
- // Uses direct node invocation via helper scripts (gate.cjs, gate-hook.mjs,
204
- // hook-handler.cjs) instead of `npx flo` to avoid 2-5s cold-start per hook.
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
- return { name: '.claude/settings.json', status: existing.hooks ? 'updated' : 'created', detail: '14 hooks configured (gates, lifecycle, routing, session)' };
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
- options = { ...options, tags: [...tags, 'source:manual'] };
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 SIGKILL on POSIX (Windows already used /F)
659
- if (process.platform !== 'win32') {
660
- try {
661
- process.kill(pid, 'SIGKILL');
662
- }
663
- catch { /* already dead */ }
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
- * Uses platform-aware shell selection.
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
- : (process.env.SHELL || 'bash');
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);
@@ -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.10.25';
5
+ export const VERSION = '4.10.27';
6
6
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moflo",
3
- "version": "4.10.25",
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.25-rc.1",
98
+ "moflo": "^4.10.26",
99
99
  "tsx": "^4.21.0",
100
100
  "typescript": "^5.9.3",
101
101
  "vitest": "^4.0.0"