moflo 4.10.26 → 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.
@@ -117,7 +117,7 @@ auto_update:
117
117
  enabled: true # Master toggle for version-change auto-sync
118
118
  scripts: true # Sync .claude/scripts/ from moflo bin/
119
119
  helpers: true # Sync .claude/helpers/ from moflo source
120
- hook_block_drift: warn # warn | regenerate | off
120
+ hook_block_drift: regenerate # warn | regenerate | off (default: regenerate since #1227)
121
121
  claudemd_injection_drift: regenerate # warn | regenerate | off
122
122
  ```
123
123
 
@@ -150,7 +150,8 @@ If your `moflo.yaml` predates the `sandbox:` or `auto_update:` blocks, they are
150
150
  | `sandbox.tier: full` | Require OS sandbox; throw at runtime if the platform tool is unavailable |
151
151
  | `sandbox.tier: denylist-only` | Keep Layer 1 denylist only; skip OS isolation even when enabled |
152
152
  | `auto_update.enabled: false` | Disable all on-session auto-sync (scripts, helpers, drift checks) |
153
- | `auto_update.hook_block_drift: regenerate` | Auto-repair drift in `.claude/settings.json` hook block on session start (#881) |
153
+ | `auto_update.hook_block_drift: regenerate` | Auto-repair drift in `.claude/settings.json` hook block on session start (#881, default since #1227 — basename guard from #1180 keeps user-owned hooks safe). |
154
+ | `auto_update.hook_block_drift: warn` | Print drift notice but leave settings.json unchanged. Opt-out from auto-regen. |
154
155
  | `auto_update.hook_block_drift: off` | Skip hook-block drift detection entirely |
155
156
  | `auto_update.claudemd_injection_drift: regenerate` | Auto-refresh the MoFlo block in `CLAUDE.md` when it drifts from the current generator (#1142, default) |
156
157
  | `auto_update.claudemd_injection_drift: warn` | Print a drift notice on session start but leave `CLAUDE.md` unchanged |
package/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2024-2026 ruvnet
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2024-2026 ruvnet
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -120,6 +120,14 @@ To force a clean re-initialization over an existing setup:
120
120
  flo init --force
121
121
  ```
122
122
 
123
+ #### First-run heads-up: brief CPU spike during initial indexing
124
+
125
+ The **very first time** MoFlo runs in your project — typically right after `flo init` and the next session start — it builds the initial guidance, code map, and test indexes from scratch and generates 384-dim embeddings for every chunk. On a medium-sized project this takes **one to a few minutes** and you may see elevated CPU during that window. It runs in the background, so you can start working immediately; you just might hear your fans for a bit.
126
+
127
+ After that first pass, indexing is **incremental and lazy** — every subsequent session start only re-processes files that actually changed since the last run, which typically finishes in under a second with no perceptible CPU activity. The big one-time cost is a deliberate trade: pay it once, then every future session opens with your project already searchable by meaning.
128
+
129
+ > **Tip:** Let that initial indexing finish before running **`/healer`** (or `flo healer`). Healer's embeddings and semantic-quality checks expect the indexes to exist — if you run it mid-build it may flag warnings that resolve themselves the moment indexing completes.
130
+
123
131
  ### 2. Review your guidance and code settings
124
132
 
125
133
  Open `moflo.yaml` to see what init detected. The two key sections:
File without changes
package/bin/gate-hook.mjs CHANGED
File without changes
package/bin/gate.cjs CHANGED
File without changes
File without changes
File without changes
package/bin/hooks.mjs CHANGED
File without changes
package/bin/index-all.mjs CHANGED
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
package/bin/npx-repair.js CHANGED
File without changes
File without changes
File without changes
File without changes
@@ -812,7 +812,18 @@ let autoUpdateConfig = {
812
812
  enabled: true,
813
813
  scripts: true,
814
814
  helpers: true,
815
- hookBlockDrift: 'warn',
815
+ // #1227 — default flipped from 'warn' → 'regenerate'. After #1180 added the
816
+ // basename guard in applyWholesaleRegeneration, the wholesale path stopped
817
+ // clobbering user-owned entries (commands not pointing at moflo helpers are
818
+ // grafted back into the fresh tree). The only thing left to "lose" is stale
819
+ // moflo entries from prior versions — exactly what consumers WANT migrated.
820
+ // The 'warn' default was a holdover from the pre-#1180 era when the wipe
821
+ // wasn't safe; that rationale no longer applies, and the launcher silently
822
+ // leaving legacy shapes in place was the root of #1227's surprise deletions
823
+ // (`flo init`'s wholesale-wipe path tripped where the launcher's safe wipe
824
+ // hadn't run because of the warn default). Consumers who explicitly want
825
+ // warn-only can still set `auto_update.hook_block_drift: warn` in moflo.yaml.
826
+ hookBlockDrift: 'regenerate',
816
827
  // #1142 — CLAUDE.md injection drift refresh mode (warn | regenerate | off,
817
828
  // default regenerate). Defaults to regenerate because the consumer cannot
818
829
  // refresh CLAUDE.md on their own — there is no other auto-refresh path.
@@ -826,7 +837,10 @@ try {
826
837
  const enabledMatch = yamlContent.match(/auto_update:\s*\n\s+enabled:\s*(true|false)/);
827
838
  const scriptsMatch = yamlContent.match(/auto_update:\s*\n(?:\s+\w+:.*\n)*?\s+scripts:\s*(true|false)/);
828
839
  const helpersMatch = yamlContent.match(/auto_update:\s*\n(?:\s+\w+:.*\n)*?\s+helpers:\s*(true|false)/);
829
- // #881: hook-block drift detector (warn | regenerate | off; default warn)
840
+ // #881: hook-block drift detector (warn | regenerate | off; default
841
+ // regenerate post-#1227 — the basename guard from #1180 made wholesale
842
+ // regen safe for user-owned hooks, so the prior 'warn' default just left
843
+ // legacy shapes in place for `flo init` to wipe non-safely.)
830
844
  const driftMatch = yamlContent.match(/auto_update:\s*\n(?:\s+\w+:.*\n)*?\s+hook_block_drift:\s*(warn|regenerate|off)/);
831
845
  // #1142: CLAUDE.md injection drift detector (warn | regenerate | off; default regenerate)
832
846
  const claudemdMatch = yamlContent.match(/auto_update:\s*\n(?:\s+\w+:.*\n)*?\s+claudemd_injection_drift:\s*(warn|regenerate|off)/);
File without changes
@@ -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)
@@ -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.26';
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.26",
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",
98
+ "moflo": "^4.10.26",
99
99
  "tsx": "^4.21.0",
100
100
  "typescript": "^5.9.3",
101
101
  "vitest": "^4.0.0"