moflo 4.10.26 → 4.10.28

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 |
@@ -229,9 +229,15 @@ Confirm the published version matches what we just built.
229
229
 
230
230
  ```bash
231
231
  npm install moflo@<new-version> --save-dev
232
+ npm install --package-lock-only --force
233
+ npm ci --dry-run --legacy-peer-deps
232
234
  ```
233
235
 
234
- This updates `package.json` and `package-lock.json` to use the newly published version as a devDependency.
236
+ The first command updates `package.json` and `package-lock.json` to use the newly published version as a devDependency.
237
+
238
+ The second command (`--package-lock-only --force`) backfills cross-platform optional-dependency entries that npm omits from the lockfile when `npm install` runs on a single host platform. Without it, transitive optional deps like the nested `@rolldown/binding-wasm32-wasi/node_modules/@emnapi/*` entries get dropped when publishing from Windows, and the resulting lockfile fails `npm ci` on Ubuntu/macOS CI legs. This produced the green-`release-smoke` → red-`ci.yml` divergence in run `26487580745` (moflo@4.10.27 install commit).
239
+
240
+ The final `npm ci --dry-run --legacy-peer-deps` proves the resulting lockfile is in sync with `package.json` before we commit it. If this exits non-zero, stop and investigate — do not commit a broken lockfile.
235
241
 
236
242
  ### Step 13: Final Commit
237
243
 
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
@@ -673,6 +673,58 @@ export async function checkMofloYamlCompliance(cwd = process.cwd()) {
673
673
  fix: 'Restart Claude Code (yaml-upgrader auto-appends) or `npx moflo init --force`',
674
674
  };
675
675
  }
676
+ /**
677
+ * #1229 — standing tripwire for a silently-disabled memory_first gate.
678
+ *
679
+ * `gates.memory_first` is the only enforcement of the memory-search-first
680
+ * protocol. When it is `false` in moflo.yaml the protocol is off repo-wide
681
+ * with no per-prompt signal — and because the disabled gate is itself what
682
+ * surfaces the stored "never disable memory_first" learning, the situation
683
+ * self-conceals. Historically a daemon-spawned headless analysis worker could
684
+ * write this value unprompted to unblock itself (the `optimize` worker editing
685
+ * `moflo.yaml` to `memory_first: false # Temporarily disabled for performance
686
+ * analysis`); that root cause is fixed by making those workers read-only, but
687
+ * this check is the loud, standing detector so the state never goes unnoticed
688
+ * again — whatever writes it (worker, agent, or a deliberate consumer edit).
689
+ *
690
+ * Warn rather than fail: disabling the gate is a legitimate (if rare) choice;
691
+ * the point is that it can never be silent. Matches only an *active* (un-
692
+ * commented) `memory_first: false` line and is EOL-agnostic so it behaves
693
+ * identically on CRLF (Windows) and LF (POSIX) checkouts.
694
+ *
695
+ * Exported with a cwd param so tests can target a temp root without touching
696
+ * process.cwd().
697
+ */
698
+ export async function checkMemoryFirstGate(cwd = process.cwd()) {
699
+ const yamlPath = join(cwd, 'moflo.yaml');
700
+ // Absence is covered by checkMofloYamlCompliance; with no file the gate
701
+ // defaults to enabled, so there is nothing to warn about here.
702
+ if (!existsSync(yamlPath)) {
703
+ return { name: 'Memory-First Gate', status: 'pass', message: 'Enabled (default — no moflo.yaml override)' };
704
+ }
705
+ let content;
706
+ try {
707
+ content = readFileSync(yamlPath, 'utf-8');
708
+ }
709
+ catch (e) {
710
+ return { name: 'Memory-First Gate', status: 'warn', message: `Unable to read moflo.yaml: ${errorDetail(e)}` };
711
+ }
712
+ // Active value only: a line whose first non-whitespace token is
713
+ // `memory_first: false`. A commented `# memory_first: false` is ignored
714
+ // because the leading `#` defeats the `^\s*memory_first` anchor.
715
+ const disabled = content
716
+ .split(/\r?\n/)
717
+ .some((line) => /^\s*memory_first:\s*false\b/i.test(line));
718
+ if (disabled) {
719
+ return {
720
+ name: 'Memory-First Gate',
721
+ status: 'warn',
722
+ message: 'DISABLED in moflo.yaml — memory-search-first protocol is off repo-wide and silent per-prompt',
723
+ fix: 'Restore `gates.memory_first: true` (e.g. `git checkout -- moflo.yaml`) unless you disabled it deliberately',
724
+ };
725
+ }
726
+ return { name: 'Memory-First Gate', status: 'pass', message: 'Enabled' };
727
+ }
676
728
  /**
677
729
  * #981 / #987 — surfaces the single-writer-architecture safety net.
678
730
  *
@@ -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
  // ============================================================================
@@ -12,7 +12,7 @@ import { checkWritersAudit } from './doctor-checks-writers-audit.js';
12
12
  import { checkSwarmFunctional, checkHiveMindFunctional, } from './doctor-checks-swarm.js';
13
13
  import { checkMemoryAccessFunctional } from './doctor-checks-memory-access.js';
14
14
  import { checkBuildTools, checkClaudeCode, checkDiskSpace, checkGit, checkGitRepo, checkNodeVersion, checkNpmVersion, } from './doctor-checks-runtime.js';
15
- import { checkConfigFile, checkDaemonIdentity, checkDaemonOrphan, checkDaemonStatus, checkDaemonWriteRouting, checkMcpServers, checkMemoryDatabase, checkMemoryDbIntegrity, checkMofloYamlCompliance, checkNestedMofloIslands, checkStatusLine, checkSwarmResidue, checkTestDirs, } from './doctor-checks-config.js';
15
+ import { checkConfigFile, checkDaemonIdentity, checkDaemonOrphan, checkDaemonStatus, checkDaemonWriteRouting, checkMcpServers, checkMemoryDatabase, checkMemoryDbIntegrity, checkMemoryFirstGate, checkMofloYamlCompliance, checkNestedMofloIslands, checkStatusLine, checkSwarmResidue, checkTestDirs, } from './doctor-checks-config.js';
16
16
  import { checkSpellEngine, checkSandboxTier } from './doctor-checks-platform.js';
17
17
  import { checkEmbeddings, checkSemanticQuality, } from './doctor-checks-memory.js';
18
18
  import { checkIntelligence } from './doctor-checks-intelligence.js';
@@ -34,6 +34,8 @@ export const allChecks = [
34
34
  checkGitRepo,
35
35
  checkConfigFile,
36
36
  checkMofloYamlCompliance,
37
+ // #1229 — loud tripwire for a silently-disabled memory_first gate.
38
+ checkMemoryFirstGate,
37
39
  checkStatusLine,
38
40
  checkDaemonStatus,
39
41
  checkDaemonVersionSkew,
@@ -97,6 +99,8 @@ export const componentMap = {
97
99
  'config': checkConfigFile,
98
100
  'yaml': checkMofloYamlCompliance,
99
101
  'moflo-yaml': checkMofloYamlCompliance,
102
+ 'memory-first': checkMemoryFirstGate,
103
+ 'memory-gate': checkMemoryFirstGate,
100
104
  'statusline': checkStatusLine,
101
105
  'status-line': checkStatusLine,
102
106
  'daemon': checkDaemonStatus,
@@ -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)
@@ -46,6 +46,28 @@ const MODEL_IDS = {
46
46
  opus: 'claude-opus-4-6',
47
47
  haiku: 'claude-haiku-4-5-20251001',
48
48
  };
49
+ /**
50
+ * Tool allowlist for every headless worker. These workers are READ-ONLY by
51
+ * design: they analyse the codebase and produce a report. Moflo persists their
52
+ * stdout to `.moflo/reports/<type>.<ext>` itself (see `executeInternal`), so
53
+ * they never need write capability to deliver their output.
54
+ *
55
+ * Restricting tools here is a GATE-INTEGRITY guarantee, not a tidy-up. The
56
+ * spawn previously ran `claude --print <prompt>` with no tool restriction, so
57
+ * a daemon-spawned worker inherited the consumer's permissions and could edit
58
+ * any file in their tree. The `optimize` ("Performance optimization") worker
59
+ * is `enabled: true` by default and runs unattended; given write access and a
60
+ * "analyze this codebase for performance optimizations" prompt, it reasons its
61
+ * way into editing `moflo.yaml` to `memory_first: false`
62
+ * (`# Temporarily disabled for performance analysis`) to unblock its own
63
+ * non-memory-first tool calls against the gate — silently disabling the
64
+ * memory-first protocol repo-wide and dirtying the working tree every run.
65
+ * Worse, the gate it disables is what surfaces the "never disable memory_first"
66
+ * learning, so the loop self-conceals. A read-only allowlist makes that edit
67
+ * physically impossible while leaving full analysis capability (Read/Glob/Grep)
68
+ * intact. See issue #1229.
69
+ */
70
+ const HEADLESS_WORKER_ALLOWED_TOOLS = 'Read,Glob,Grep';
49
71
  /**
50
72
  * Default headless worker configurations based on ADR-020 (the
51
73
  * `audit`/`document`/`predict` entries from the original ADR were dropped
@@ -811,7 +833,7 @@ export class HeadlessWorkerExecutor extends EventEmitter {
811
833
  buildPrompt(template, context, reportPath) {
812
834
  const ioInstructions = `## Output
813
835
 
814
- Save the full report to \`${reportPath}\` using the Write tool. Overwrite any prior content at that path. DO NOT create any other files anywhere in the project; if you need to suggest test skeletons or code samples, include them inline in the report as fenced code blocks. Moflo persists the same output to that path after you finish, so the location is authoritative.`;
836
+ Save the full report to \`${reportPath}\` by emitting it as your response moflo writes your output to that path after you finish, so that location is authoritative. You are a READ-ONLY analysis worker and have no write tools: do not attempt to create or modify any file in the project, and in particular never edit \`moflo.yaml\`, anything under \`.claude/\`, or any gate/config (the memory-first gate stays on comply with it, never disable it). Include any test skeletons or code samples inline in the report as fenced code blocks.`;
815
837
  if (!context) {
816
838
  return `${template}
817
839
 
@@ -845,8 +867,11 @@ ${ioInstructions}`;
845
867
  };
846
868
  // Set model
847
869
  env.ANTHROPIC_MODEL = MODEL_IDS[options.model];
848
- // Spawn claude CLI process
849
- const child = spawn('claude', ['--print', prompt], {
870
+ // Spawn claude CLI process. Keep `--print` first and the prompt second
871
+ // (callers/tests rely on argv[0]/argv[1]); the read-only allowlist is
872
+ // appended so these analysis workers cannot mutate the consumer's tree
873
+ // (e.g. disable a gate in moflo.yaml). See HEADLESS_WORKER_ALLOWED_TOOLS.
874
+ const child = spawn('claude', ['--print', prompt, '--allowedTools', HEADLESS_WORKER_ALLOWED_TOOLS], {
850
875
  cwd: this.projectRoot,
851
876
  env,
852
877
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -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.28';
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.28",
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.27",
99
99
  "tsx": "^4.21.0",
100
100
  "typescript": "^5.9.3",
101
101
  "vitest": "^4.0.0"