moflo 4.10.5 → 4.10.6

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.
Files changed (34) hide show
  1. package/.claude/agents/analysis/analyze-code-quality.md +14 -0
  2. package/.claude/agents/analysis/code-analyzer.md +14 -0
  3. package/.claude/agents/architecture/system-design/arch-system-design.md +14 -0
  4. package/.claude/agents/base-template-generator.md +14 -0
  5. package/.claude/agents/core/coder.md +14 -0
  6. package/.claude/agents/core/planner.md +14 -0
  7. package/.claude/agents/core/researcher.md +14 -0
  8. package/.claude/agents/core/reviewer.md +14 -0
  9. package/.claude/agents/core/tester.md +14 -0
  10. package/.claude/agents/custom/test-long-runner.md +14 -0
  11. package/.claude/agents/development/dev-backend-api.md +14 -0
  12. package/.claude/agents/development/dev-database.md +13 -0
  13. package/.claude/agents/development/dev-frontend.md +13 -0
  14. package/.claude/agents/devops/ci-cd/ops-cicd-github.md +14 -0
  15. package/.claude/agents/documentation/api-docs/docs-api-openapi.md +14 -0
  16. package/.claude/agents/security/security-auditor.md +13 -0
  17. package/.claude/guidance/shipped/moflo-claude-swarm-cohesion.md +5 -3
  18. package/.claude/guidance/shipped/moflo-cli-reference.md +17 -31
  19. package/.claude/guidance/shipped/moflo-task-icons.md +10 -6
  20. package/.claude/guidance/shipped/moflo-yaml-reference.md +1 -1
  21. package/.claude/helpers/gate.cjs +101 -1
  22. package/.claude/helpers/subagent-bootstrap.json +1 -1
  23. package/.claude/helpers/subagent-start.cjs +1 -1
  24. package/bin/gate.cjs +101 -1
  25. package/bin/session-start-launcher.mjs +16 -1
  26. package/dist/src/cli/commands/daemon.js +31 -10
  27. package/dist/src/cli/commands/retire.js +22 -17
  28. package/dist/src/cli/init/helpers-generator.js +36 -1
  29. package/dist/src/cli/init/settings-generator.js +4 -1
  30. package/dist/src/cli/services/hook-block-hash.js +8 -2
  31. package/dist/src/cli/services/subagent-bootstrap.js +1 -1
  32. package/dist/src/cli/version.js +1 -1
  33. package/package.json +2 -2
  34. package/retired-files.json +305 -112
package/bin/gate.cjs CHANGED
@@ -82,6 +82,53 @@ var command = process.argv[2];
82
82
 
83
83
  var EXEMPT = ['.claude/', '.claude\\', 'CLAUDE.md', 'MEMORY.md', 'workflow-state', 'node_modules', 'moflo.yaml'];
84
84
  var DANGEROUS = ['rm -rf /', 'format c:', 'del /s /q c:\\', ':(){:|:&};:', 'mkfs.', '> /dev/sda'];
85
+
86
+ // #1132 — Bash memory-first gate.
87
+ //
88
+ // CREDIT: the legacy detector that marks the gate satisfied when Claude
89
+ // manually invokes a memory-search CLI (flo-search, the moflo MCP search via
90
+ // shell, etc.). Preserved verbatim from the pre-#1132 behaviour so existing
91
+ // recipes keep crediting the gate.
92
+ var CREDIT_MEMORY_SEARCH_RE = /semantic-search|memory search|memory retrieve|memory-search/;
93
+ // BLOCK: read-like Bash commands that bypass the existing check-before-read /
94
+ // check-before-scan gates by going through the shell. Anchored to the start of
95
+ // the line so subcommands inside pipelines or `npm install grep` don't trip.
96
+ // Covers POSIX read/search tools, Windows cmd `type`, and PowerShell readers.
97
+ var READ_LIKE_BASH_RE = new RegExp([
98
+ '^\\s*(?:cat|head|tail|less|more|bat|xxd|od|hexdump)\\b',
99
+ '^\\s*(?:grep|rg|ag|fgrep|egrep|find|fd)\\b',
100
+ '^\\s*sed\\s+-n\\b',
101
+ '^\\s*awk\\s+(?!.*<<)',
102
+ // `type <path>` on Windows. No `$` anchor so a piped form
103
+ // (`type src\foo.ts | grep x`) still matches and gets blocked. The argument
104
+ // must contain a slash, backslash, or dot — otherwise it's the shell-builtin
105
+ // command-lookup form (`type ls`, `type cd`) which the gate has no business
106
+ // blocking. False-negative trade: extension-less filenames like `type Makefile`
107
+ // pass through. Acceptable — source files all have extensions, and the
108
+ // primary risk pattern is leaking past the gate via `type src\foo.ts`.
109
+ '^\\s*type\\s+\\S*[\\\\/.]',
110
+ '^\\s*(?:Get-Content|gc|Select-String|sls)\\b',
111
+ ].join('|'), 'i');
112
+ // CARVE-OUT: commands that LOOK read-like but are operational. Anchored to the
113
+ // LEADING command — the pipe-filter case (`npm test | grep FAIL`) is already
114
+ // handled by READ_LIKE's `^\s*` anchor never matching the leading `npm`, so
115
+ // there is intentionally no pipe arm here: catching the leading command lets
116
+ // `grep -r TODO src/ | head -5` reach the BLOCK exit (which it must, that's
117
+ // the gap the ticket exists to close). #1132.
118
+ var BASH_CARVE_OUT_RE = new RegExp([
119
+ '^\\s*(npm|npx|pnpm|yarn|bun|node|deno|tsx|ts-node)\\s',
120
+ '^\\s*(git|gh|hub)\\s',
121
+ '^\\s*(docker|kubectl|helm|terraform)\\s',
122
+ '^\\s*(curl|wget|http|fetch)\\s',
123
+ '^\\s*(jq|yq|xq)\\s',
124
+ '^\\s*(echo|printf|true|false|sleep|test|\\[)\\s',
125
+ '^\\s*cat\\s+(<<|<<<)',
126
+ '^\\s*cat\\s+[^|]*\\s*>',
127
+ '^\\s*tee\\b',
128
+ // Lazy `.+?` instead of `.+\s` to avoid catastrophic backtracking on long
129
+ // `find` commands that lack a `-delete` / `-exec rm` suffix.
130
+ '^\\s*find\\s+.+?-(delete|exec\\s+rm)\\b',
131
+ ].join('|'));
85
132
  var DIRECTIVE_RE = /^(yes|no|yeah|yep|nope|sure|ok|okay|correct|right|exactly|perfect)\b/i;
86
133
  var TASK_RE = /\b(fix|bug|error|implement|add|create|build|write|refactor|debug|test|feature|issue|security|optimi)\b/i;
87
134
 
@@ -146,6 +193,29 @@ function classifyNamespaceHint(promptText) {
146
193
  return '';
147
194
  }
148
195
 
196
+ // #1132 — command-shape namespace classifier for the bash-BLOCK message.
197
+ // Used when the prompt-derived `lastNamespaceHint` is empty (e.g. subagents,
198
+ // which never see the user prompt) so the block message still routes to a
199
+ // useful namespace rather than the generic "pick one of five" list. Returns a
200
+ // full sentence in the same shape as classifyNamespaceHint so the BLOCK arm
201
+ // can write either source's hint without branching on format.
202
+ //
203
+ // SYNC: duplicated verbatim in src/cli/init/helpers-generator.ts.
204
+ function classifyBashNamespaceHint(cmd) {
205
+ // Search-like tools — the user is hunting for a symbol/file, code-map wins.
206
+ if (/^\s*(?:grep|rg|ag|fgrep|egrep|find|fd|Select-String|sls)\b/i.test(cmd)) {
207
+ return 'Memory namespace hint: use "code-map" for codebase navigation.';
208
+ }
209
+ // Reading a .md / RST / TXT, or a well-known doc file — guidance/learnings win.
210
+ // `.*` (not `\S*`) so flag-prefixed forms like `head -50 README.md` match.
211
+ // Anchored on the leading reader so a piped `cmd | grep foo.md` doesn't trip.
212
+ if (/^\s*(?:cat|head|tail|less|more|bat|type|Get-Content|gc)\b.*\.(?:md|mdx|rst|txt)\b/i.test(cmd)
213
+ || /^\s*(?:cat|head|tail|less|more|bat|type|Get-Content|gc)\b.*\b(?:README|CLAUDE|CHANGELOG|CONTRIBUTING|LICENSE)\b/i.test(cmd)) {
214
+ return 'Memory namespace hint: search "guidance" and "learnings" for project rules and decisions.';
215
+ }
216
+ return '';
217
+ }
218
+
149
219
  // Apply per-prompt state reset shared by `prompt-reminder` (full) and
150
220
  // `prompt-state-reset` (defensive safety-net, no emission). Idempotent — both
151
221
  // UserPromptSubmit hooks can run it without compounding any field. Caller
@@ -402,11 +472,41 @@ switch (command) {
402
472
  break;
403
473
  }
404
474
  case 'check-bash-memory': {
475
+ // #1132 — preserve CREDIT side-effect AND add a BLOCK arm for read-like
476
+ // Bash commands. Wired as PreToolUse[Bash] (was PostToolUse before #1132)
477
+ // so process.exit(2) actually prevents the read from reaching the shell.
405
478
  var cmd = process.env.TOOL_INPUT_command || '';
406
- if (/semantic-search|memory search|memory retrieve|memory-search/.test(cmd)) {
479
+
480
+ // 1) CREDIT — preserved behavior. A real memory-search invocation flips
481
+ // the gate flag so subsequent Read/Grep/Glob within this prompt pass.
482
+ if (CREDIT_MEMORY_SEARCH_RE.test(cmd)) {
407
483
  var s = readState();
408
484
  if (markMemorySearched(s)) writeState(s);
485
+ break;
409
486
  }
487
+
488
+ // 2) BLOCK — new behavior. Cheap regex checks come BEFORE readState() so
489
+ // the overwhelming majority of Bash invocations (git/npm/curl/echo/etc.)
490
+ // never touch the filesystem. Order: config flag → command-shape regexes
491
+ // → state read → memory gate.
492
+ if (!config.memory_first) break;
493
+ if (!READ_LIKE_BASH_RE.test(cmd)) break;
494
+ if (BASH_CARVE_OUT_RE.test(cmd)) break;
495
+ var s2 = readState();
496
+ if (!s2.memoryRequired || isMemorySearchedFor(s2)) break;
497
+ // Hint precedence: prompt-derived classification (set by applyPromptStateReset
498
+ // from the user prompt text) → command-shape classification (works for
499
+ // subagents that never saw the user prompt). Either source returns a full
500
+ // "Memory namespace hint: ..." sentence so the BLOCK message stays uniform.
501
+ var hint = s2.lastNamespaceHint || classifyBashNamespaceHint(cmd) || '';
502
+ process.stderr.write(
503
+ 'BLOCKED: Search memory before reading files via Bash.\n' +
504
+ 'Example: mcp__moflo__memory_search { query: "<topic>", namespace: "<one of: guidance | code-map | patterns | learnings | tests>" }\n' +
505
+ (hint ? hint + '\n' : '') +
506
+ 'On chunk hits, traverse via mcp__moflo__memory_get_neighbors — see .claude/guidance/moflo-memory-protocol.md\n' +
507
+ 'Disable per-gate via moflo.yaml: gates: memory_first: false\n'
508
+ );
509
+ process.exit(2);
410
510
  break;
411
511
  }
412
512
  case 'check-task-transition': {
@@ -1053,12 +1053,14 @@ try {
1053
1053
  // prune when the consumer's file matches a known-shipped hash —
1054
1054
  // customized files (different hash) get preserved with a one-line
1055
1055
  // notice the user can act on.
1056
+ let prunedRetiredPaths = new Set();
1056
1057
  try {
1057
1058
  const retiredManifestPath = resolve(
1058
1059
  projectRoot,
1059
1060
  'node_modules/moflo/retired-files.json',
1060
1061
  );
1061
1062
  const report = applyRetiredPrune(projectRoot, retiredManifestPath);
1063
+ prunedRetiredPaths = new Set(report.pruned);
1062
1064
  if (report.pruned.length > 0) {
1063
1065
  emitMutation(
1064
1066
  'pruned retired shipped files',
@@ -1110,10 +1112,23 @@ try {
1110
1112
 
1111
1113
  // Manifest reflects synced files immediately; version stamp is deferred
1112
1114
  // to 3g so an aborted launcher re-runs upgrade detection (#730).
1115
+ //
1116
+ // Exclude paths that `applyRetiredPrune` just deleted from disk —
1117
+ // recording a non-existent file in `installed-files.json` triggers
1118
+ // false drift detection on the next launcher run (`manifestDrifted`
1119
+ // flips true because the recorded path doesn't exist), which spuriously
1120
+ // re-fires the cherry-pick + manifest-sync pipeline. Widening
1121
+ // `retired-files.json`'s hash window in #1133 exposed this — more
1122
+ // legitimate matches → more pruned files → guaranteed false drift on
1123
+ // the next launcher and re-imported legacy rows (knowledge namespace
1124
+ // came back even though the migration deleted it).
1125
+ const persistedManifest = prunedRetiredPaths.size > 0
1126
+ ? currentManifest.filter((e) => !prunedRetiredPaths.has(e.path))
1127
+ : currentManifest;
1113
1128
  try {
1114
1129
  const cfDir = resolve(projectRoot, '.moflo');
1115
1130
  if (!existsSync(cfDir)) mkdirSync(cfDir, { recursive: true });
1116
- writeFileSync(manifestPath, JSON.stringify(currentManifest, null, 2));
1131
+ writeFileSync(manifestPath, JSON.stringify(persistedManifest, null, 2));
1117
1132
  pendingVersionStampWrite = { path: versionStampPath, version: installedVersion };
1118
1133
  } catch (err) {
1119
1134
  // #854: manifest write must surface — without it the next launcher
@@ -472,7 +472,7 @@ export async function killBackgroundDaemon(projectRoot) {
472
472
  try {
473
473
  // Platform-split shutdown. On Linux/macOS we try SIGTERM first so the
474
474
  // daemon's shutdown handlers (sql.js flush, lock release) can run; force-
475
- // kill only if it doesn't exit within ~1s.
475
+ // kill only if it doesn't exit within the graceful window.
476
476
  //
477
477
  // On Windows there is no SIGTERM equivalent for our headless detached
478
478
  // Node daemon — `taskkill /PID` (no /F) sends a window-close message
@@ -481,25 +481,46 @@ export async function killBackgroundDaemon(projectRoot) {
481
481
  // implementation invoked it anyway, ate the error in a bare catch, then
482
482
  // slept 1s before escalating to /F. Skip the dead step: go straight to
483
483
  // /F /T (tree-kill, in case a worker child outlived its parent) on Win.
484
+ //
485
+ // #1136: previously this used `setTimeout(1000)` after SIGTERM and
486
+ // skipped post-SIGKILL verification. Under load on a populated DB the
487
+ // daemon's shutdown handler (worker-daemon.ts:582 → scheduler.stop +
488
+ // saveState) can exceed 1s — SIGKILL hit mid-sql.js-dump and left
489
+ // torn pages in `.moflo/moflo.db` (the page-ref / rowid-order
490
+ // signature seen in the populated-consumer smoke). Bring the kill
491
+ // window in line with the launcher's stopDaemon
492
+ // (bin/session-start-launcher.mjs:425): 3s graceful poll + 1s force
493
+ // poll, both verifying liveness before returning.
484
494
  if (process.platform === 'win32') {
485
495
  try {
486
496
  execFileSync('taskkill', ['/F', '/T', '/PID', String(holderPid)], { windowsHide: true });
487
497
  }
488
498
  catch {
489
- // Already exiting / unreachable — process.kill(pid, 0) below verifies.
499
+ // Already exiting / unreachable — verified via poll below.
500
+ }
501
+ const forceDeadline = Date.now() + 1000;
502
+ while (Date.now() < forceDeadline && isProcessRunning(holderPid)) {
503
+ await new Promise(resolve => setTimeout(resolve, 100));
490
504
  }
491
505
  }
492
506
  else {
493
- process.kill(holderPid, 'SIGTERM');
494
- // Wait briefly so SIGTERM has a chance to land before checking liveness.
495
- await new Promise(resolve => setTimeout(resolve, 1000));
496
507
  try {
497
- process.kill(holderPid, 0);
498
- // Still alive — force kill.
499
- process.kill(holderPid, 'SIGKILL');
508
+ process.kill(holderPid, 'SIGTERM');
500
509
  }
501
- catch {
502
- // Process terminated
510
+ catch { /* already dead */ }
511
+ const gracefulDeadline = Date.now() + 3000;
512
+ while (Date.now() < gracefulDeadline && isProcessRunning(holderPid)) {
513
+ await new Promise(resolve => setTimeout(resolve, 100));
514
+ }
515
+ if (isProcessRunning(holderPid)) {
516
+ try {
517
+ process.kill(holderPid, 'SIGKILL');
518
+ }
519
+ catch { /* already dead */ }
520
+ const forceDeadline = Date.now() + 1000;
521
+ while (Date.now() < forceDeadline && isProcessRunning(holderPid)) {
522
+ await new Promise(resolve => setTimeout(resolve, 100));
523
+ }
503
524
  }
504
525
  }
505
526
  // Release lock
@@ -42,7 +42,7 @@ function findMofloRepoRoot(start) {
42
42
  }
43
43
  export const retireCommand = {
44
44
  name: 'retire',
45
- description: 'Record a retired shipped file in retired-files.json (moflo dev only) — usage: flo retire <path> [--retired-by #nnn]',
45
+ description: 'Record a retired shipped file in retired-files.json (moflo dev only) — usage: flo retire <path> [--retired-by #nnn] | flo retire --rebuild-hashes',
46
46
  hidden: true,
47
47
  options: [
48
48
  {
@@ -56,15 +56,16 @@ export const retireCommand = {
56
56
  type: 'string',
57
57
  },
58
58
  {
59
- name: 'hashes',
60
- description: 'Maximum number of historical content hashes to record (default 3)',
61
- type: 'number',
62
- default: 3,
59
+ name: 'rebuild-hashes',
60
+ description: 'Recompute knownContentHashes[] for every existing entry from full git history (#1133 backfill)',
61
+ type: 'boolean',
62
+ default: false,
63
63
  },
64
64
  ],
65
65
  examples: [
66
66
  { command: 'flo retire .claude/agents/v3/performance-engineer.md --retired-by #932', description: 'Record a retirement' },
67
67
  { command: 'flo retire .claude/skills/skill-builder/SKILL.md --retired-by #945 --retired-in 4.9.21', description: 'Pin retiredIn' },
68
+ { command: 'flo retire --rebuild-hashes', description: 'Backfill every entry from full git history' },
68
69
  ],
69
70
  action: async (ctx) => {
70
71
  const repoRoot = findMofloRepoRoot(__filename) || findMofloRepoRoot(ctx.cwd);
@@ -73,11 +74,6 @@ export const retireCommand = {
73
74
  output.printInfo('retired-files.json lives at the moflo package root and does not ship to consumer projects');
74
75
  return { success: false, message: 'not in moflo repo', exitCode: 1 };
75
76
  }
76
- const path = ctx.args[0];
77
- if (!path) {
78
- output.printError('Missing required argument: <path>');
79
- return { success: false, message: 'missing path', exitCode: 2 };
80
- }
81
77
  const scriptPath = resolve(repoRoot, 'scripts', 'build-retired-files.mjs');
82
78
  if (!existsSync(scriptPath)) {
83
79
  output.printError(`scripts/build-retired-files.mjs not found at ${scriptPath}`);
@@ -86,13 +82,22 @@ export const retireCommand = {
86
82
  // Parser normalises kebab-case flag names to camelCase before storing
87
83
  // (#787). Read as ctx.flags.<camelCase> — bracket-with-kebab is always
88
84
  // undefined and ESLint blocks that pattern.
89
- const args = ['--add', path];
90
- if (ctx.flags.retiredBy)
91
- args.push('--retired-by', String(ctx.flags.retiredBy));
92
- if (ctx.flags.retiredIn)
93
- args.push('--retired-in', String(ctx.flags.retiredIn));
94
- if (ctx.flags.hashes)
95
- args.push('--hashes', String(ctx.flags.hashes));
85
+ let args;
86
+ if (ctx.flags.rebuildHashes) {
87
+ args = ['--rebuild-hashes'];
88
+ }
89
+ else {
90
+ const path = ctx.args[0];
91
+ if (!path) {
92
+ output.printError('Missing required argument: <path> (or pass --rebuild-hashes)');
93
+ return { success: false, message: 'missing path', exitCode: 2 };
94
+ }
95
+ args = ['--add', path];
96
+ if (ctx.flags.retiredBy)
97
+ args.push('--retired-by', String(ctx.flags.retiredBy));
98
+ if (ctx.flags.retiredIn)
99
+ args.push('--retired-in', String(ctx.flags.retiredIn));
100
+ }
96
101
  const result = spawnSync('node', [scriptPath, ...args], {
97
102
  cwd: repoRoot,
98
103
  stdio: 'inherit',
@@ -277,6 +277,10 @@ var command = process.argv[2];
277
277
 
278
278
  var EXEMPT = ['.claude/', '.claude\\\\', 'CLAUDE.md', 'MEMORY.md', 'workflow-state', 'node_modules', 'moflo.yaml'];
279
279
  var DANGEROUS = ['rm -rf /', 'format c:', 'del /s /q c:\\\\', ':(){:|:&};:', 'mkfs.', '> /dev/sda'];
280
+ // #1132 — Bash memory-first gate regexes. See bin/gate.cjs for documentation.
281
+ var CREDIT_MEMORY_SEARCH_RE = /semantic-search|memory search|memory retrieve|memory-search/;
282
+ var READ_LIKE_BASH_RE = /^\\s*(?:cat|head|tail|less|more|bat|xxd|od|hexdump)\\b|^\\s*(?:grep|rg|ag|fgrep|egrep|find|fd)\\b|^\\s*sed\\s+-n\\b|^\\s*awk\\s+(?!.*<<)|^\\s*type\\s+\\S*[\\\\/.]|^\\s*(?:Get-Content|gc|Select-String|sls)\\b/i;
283
+ var BASH_CARVE_OUT_RE = /^\\s*(npm|npx|pnpm|yarn|bun|node|deno|tsx|ts-node)\\s|^\\s*(git|gh|hub)\\s|^\\s*(docker|kubectl|helm|terraform)\\s|^\\s*(curl|wget|http|fetch)\\s|^\\s*(jq|yq|xq)\\s|^\\s*(echo|printf|true|false|sleep|test|\\[)\\s|^\\s*cat\\s+(<<|<<<)|^\\s*cat\\s+[^|]*\\s*>|^\\s*tee\\b|^\\s*find\\s+.+?-(delete|exec\\s+rm)\\b/;
280
284
  var DIRECTIVE_RE = /^(yes|no|yeah|yep|nope|sure|ok|okay|correct|right|exactly|perfect)\\b/i;
281
285
  var TASK_RE = /\\b(fix|bug|error|implement|add|create|build|write|refactor|debug|test|feature|issue|security|optimi)\\b/i;
282
286
 
@@ -340,6 +344,19 @@ function classifyNamespaceHint(promptText) {
340
344
  return '';
341
345
  }
342
346
 
347
+ // #1132 — command-shape namespace classifier for the bash-BLOCK message.
348
+ // SYNC: duplicated verbatim in bin/gate.cjs. See that file for rationale.
349
+ function classifyBashNamespaceHint(cmd) {
350
+ if (/^\\s*(?:grep|rg|ag|fgrep|egrep|find|fd|Select-String|sls)\\b/i.test(cmd)) {
351
+ return 'Memory namespace hint: use "code-map" for codebase navigation.';
352
+ }
353
+ if (/^\\s*(?:cat|head|tail|less|more|bat|type|Get-Content|gc)\\b.*\\.(?:md|mdx|rst|txt)\\b/i.test(cmd)
354
+ || /^\\s*(?:cat|head|tail|less|more|bat|type|Get-Content|gc)\\b.*\\b(?:README|CLAUDE|CHANGELOG|CONTRIBUTING|LICENSE)\\b/i.test(cmd)) {
355
+ return 'Memory namespace hint: search "guidance" and "learnings" for project rules and decisions.';
356
+ }
357
+ return '';
358
+ }
359
+
343
360
  function applyPromptStateReset(state, promptText) {
344
361
  state.memorySearched = false;
345
362
  state.memorySearchedBy = {};
@@ -467,11 +484,29 @@ switch (command) {
467
484
  break;
468
485
  }
469
486
  case 'check-bash-memory': {
487
+ // #1132 — credit + block. See bin/gate.cjs for full documentation.
470
488
  var cmd = process.env.TOOL_INPUT_command || '';
471
- if (/semantic-search|memory search|memory retrieve|memory-search/.test(cmd)) {
489
+ if (CREDIT_MEMORY_SEARCH_RE.test(cmd)) {
472
490
  var s = readState();
473
491
  if (markMemorySearched(s)) writeState(s);
492
+ break;
474
493
  }
494
+ if (!config.memory_first) break;
495
+ if (!READ_LIKE_BASH_RE.test(cmd)) break;
496
+ if (BASH_CARVE_OUT_RE.test(cmd)) break;
497
+ var s2 = readState();
498
+ if (!s2.memoryRequired || isMemorySearchedFor(s2)) break;
499
+ // Hint precedence: prompt classification → command-shape classification.
500
+ // See bin/gate.cjs check-bash-memory for full rationale.
501
+ var hint = s2.lastNamespaceHint || classifyBashNamespaceHint(cmd) || '';
502
+ process.stderr.write(
503
+ 'BLOCKED: Search memory before reading files via Bash.\\n' +
504
+ 'Example: mcp__moflo__memory_search { query: "<topic>", namespace: "<one of: guidance | code-map | patterns | learnings | tests>" }\\n' +
505
+ (hint ? hint + '\\n' : '') +
506
+ 'On chunk hits, traverse via mcp__moflo__memory_get_neighbors — see .claude/guidance/moflo-memory-protocol.md\\n' +
507
+ 'Disable per-gate via moflo.yaml: gates: memory_first: false\\n'
508
+ );
509
+ process.exit(2);
475
510
  break;
476
511
  }
477
512
  case 'check-task-transition': {
@@ -233,6 +233,9 @@ function generateHooksConfig(config) {
233
233
  hooks: [
234
234
  { type: 'command', command: gateHookCmd('check-dangerous-command'), timeout: 2000 },
235
235
  { type: 'command', command: gateHookCmd('check-before-pr'), timeout: 2000 },
236
+ // #1132 — moved from PostToolUse so process.exit(2) actually blocks
237
+ // read-like Bash that bypasses the Read/Glob/Grep gates via the shell.
238
+ { type: 'command', command: gateHookCmd('check-bash-memory'), timeout: 2000 },
236
239
  ],
237
240
  },
238
241
  // #931 — TaskCreate REMINDER + namespace hint moved here from
@@ -272,7 +275,7 @@ function generateHooksConfig(config) {
272
275
  {
273
276
  matcher: '^Bash$',
274
277
  hooks: [
275
- { type: 'command', command: gateHookCmd('check-bash-memory'), timeout: 2000 },
278
+ // #1132 check-bash-memory moved to PreToolUse (above).
276
279
  { type: 'command', command: gateHookCmd('record-test-run'), timeout: 2000 },
277
280
  ],
278
281
  },
@@ -57,7 +57,12 @@ export function getReferenceHookBlock() {
57
57
  { matcher: '^Read$', hooks: [gateHook('check-before-read', 3000)] },
58
58
  {
59
59
  matcher: '^Bash$',
60
- hooks: [gateHook('check-dangerous-command', 2000), gateHook('check-before-pr', 2000)],
60
+ hooks: [
61
+ gateHook('check-dangerous-command', 2000),
62
+ gateHook('check-before-pr', 2000),
63
+ // #1132 — moved from PostToolUse so process.exit(2) actually blocks.
64
+ gateHook('check-bash-memory', 2000),
65
+ ],
61
66
  },
62
67
  // #931 — TaskCreate REMINDER + namespace hint advisory at Agent-spawn time.
63
68
  // Routed via gate-hook.mjs so HOOK_SESSION_ID is forwarded for per-actor
@@ -72,8 +77,9 @@ export function getReferenceHookBlock() {
72
77
  { matcher: '^Agent$', hooks: [handler('post-task', 5000)] },
73
78
  { matcher: '^TaskCreate$', hooks: [gateCjs('record-task-created', 2000)] },
74
79
  {
80
+ // #1132 — check-bash-memory moved to PreToolUse (above).
75
81
  matcher: '^Bash$',
76
- hooks: [gateHook('check-bash-memory', 2000), gateHook('record-test-run', 2000)],
82
+ hooks: [gateHook('record-test-run', 2000)],
77
83
  },
78
84
  { matcher: '^Skill$', hooks: [gateHook('record-skill-run', 2000)] },
79
85
  { matcher: '^mcp__moflo__memory_(search|retrieve|list|stats|store)$', hooks: [gateHook('record-memory-searched', 3000)] },
@@ -21,7 +21,7 @@ const BOOTSTRAP_JSON_REL = '.claude/helpers/subagent-bootstrap.json';
21
21
  // Defense-in-depth copy of the canonical directive in subagent-bootstrap.json.
22
22
  // Kept as a single-line literal so the parity test can verify it matches the
23
23
  // JSON via plain substring containment.
24
- const FALLBACK_DIRECTIVE = 'MANDATORY FIRST ACTION: Your very first tool call MUST be mcp__moflo__memory_search (any query, any namespace). The memory-first gate WILL BLOCK all Glob, Grep, and Read calls until you do this. After memory search, follow `.claude/guidance/moflo-subagents.md` protocol. When a search hit carries `navigation`, you MUST call mcp__moflo__memory_get_neighbors to traverse — calling mcp__moflo__memory_retrieve on every hit is a protocol violation. See `.claude/guidance/moflo-memory-protocol.md`.';
24
+ const FALLBACK_DIRECTIVE = 'MANDATORY FIRST ACTION: Your very first tool call MUST be mcp__moflo__memory_search (any query, any namespace). The memory-first gate WILL BLOCK all Glob, Grep, Read, and read-like Bash (cat/head/tail/grep/find/sed/awk and the Windows/PowerShell equivalents) calls until you do this. Pick the namespace by task: `guidance` for rules and conventions, `code-map` for file structure, `patterns` for proven solutions, `learnings` for past corrections, `tests` for test inventory. After memory search, follow `.claude/guidance/moflo-subagents.md` protocol. When a search hit carries `navigation`, you MUST call mcp__moflo__memory_get_neighbors to traverse — calling mcp__moflo__memory_retrieve on every hit is a protocol violation. See `.claude/guidance/moflo-memory-protocol.md`.';
25
25
  function loadDirective() {
26
26
  const jsonPath = locateMofloRootPath(BOOTSTRAP_JSON_REL);
27
27
  if (!jsonPath) {
@@ -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.5';
5
+ export const VERSION = '4.10.6';
6
6
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moflo",
3
- "version": "4.10.5",
3
+ "version": "4.10.6",
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.4",
98
+ "moflo": "^4.10.5",
99
99
  "tsx": "^4.21.0",
100
100
  "typescript": "^5.9.3",
101
101
  "vitest": "^4.0.0"