moflo 4.12.4-rc.1 → 4.12.4-rc.2

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 (52) hide show
  1. package/.claude/agents/core/coder.md +2 -3
  2. package/.claude/agents/core/researcher.md +3 -4
  3. package/.claude/agents/core/reviewer.md +6 -8
  4. package/.claude/helpers/gate-hook.mjs +34 -0
  5. package/.claude/helpers/gate.cjs +110 -6
  6. package/.claude/helpers/hook-handler.cjs +0 -0
  7. package/.claude/helpers/prompt-hook.mjs +0 -0
  8. package/bin/gate-hook.mjs +34 -0
  9. package/bin/gate.cjs +110 -6
  10. package/dist/src/cli/commands/agent.js +1 -89
  11. package/dist/src/cli/commands/benchmark.js +11 -8
  12. package/dist/src/cli/commands/config.js +256 -96
  13. package/dist/src/cli/commands/deployment.js +4 -4
  14. package/dist/src/cli/commands/doctor-checks-config.js +18 -22
  15. package/dist/src/cli/commands/doctor-fixes.js +36 -11
  16. package/dist/src/cli/commands/embeddings.js +62 -21
  17. package/dist/src/cli/commands/guidance.js +10 -10
  18. package/dist/src/cli/commands/hive-mind.js +8 -70
  19. package/dist/src/cli/commands/hooks.js +32 -219
  20. package/dist/src/cli/commands/mcp.js +5 -2
  21. package/dist/src/cli/commands/memory.js +59 -46
  22. package/dist/src/cli/commands/neural.js +19 -19
  23. package/dist/src/cli/commands/performance.js +12 -5
  24. package/dist/src/cli/commands/security.js +2 -2
  25. package/dist/src/cli/commands/status.js +18 -13
  26. package/dist/src/cli/commands/task.js +8 -0
  27. package/dist/src/cli/config/cli-config-store.js +326 -0
  28. package/dist/src/cli/hooks/workers/index.js +15 -9
  29. package/dist/src/cli/index.js +41 -31
  30. package/dist/src/cli/init/claudemd-generator.js +3 -3
  31. package/dist/src/cli/init/helpers-generator.js +165 -15
  32. package/dist/src/cli/mcp-client.js +12 -2
  33. package/dist/src/cli/mcp-tools/analysis-tools.js +103 -0
  34. package/dist/src/cli/mcp-tools/coverage-tools.js +206 -0
  35. package/dist/src/cli/mcp-tools/hive-mind-tools.js +71 -0
  36. package/dist/src/cli/mcp-tools/memory-admin-tools.js +538 -0
  37. package/dist/src/cli/mcp-tools/memory-tools.js +1 -1
  38. package/dist/src/cli/mcp-tools/neural-tools.js +53 -105
  39. package/dist/src/cli/mcp-tools/performance-tools.js +72 -53
  40. package/dist/src/cli/mcp-tools/progress-tools.js +142 -0
  41. package/dist/src/cli/mcp-tools/session-tools.js +116 -0
  42. package/dist/src/cli/mcp-tools/system-tools.js +127 -94
  43. package/dist/src/cli/mcp-tools/task-tools.js +101 -1
  44. package/dist/src/cli/movector/coverage-router.js +48 -6
  45. package/dist/src/cli/movector/diff-classifier.js +8 -6
  46. package/dist/src/cli/parser.js +81 -12
  47. package/dist/src/cli/services/spell-gate.js +5 -3
  48. package/dist/src/cli/services/worker-daemon.js +37 -7
  49. package/dist/src/cli/shared/utils/load-average.js +59 -0
  50. package/dist/src/cli/version.js +1 -1
  51. package/package.json +2 -2
  52. package/dist/src/cli/mcp-tools/github-tools.js +0 -331
@@ -236,10 +236,9 @@ mcp__moflo__performance_benchmark {
236
236
  iterations: 10
237
237
  }
238
238
 
239
- // Analyze bottlenecks
239
+ // Read this process's CPU / memory / heap usage
240
240
  mcp__moflo__performance_report {
241
- component: "api-endpoint",
242
- metrics: ["response-time", "memory-usage"]
241
+ format: "detailed"
243
242
  }
244
243
  ```
245
244
 
@@ -155,10 +155,9 @@ mcp__moflo__memory_search {
155
155
 
156
156
  ### Analysis Tools
157
157
  ```javascript
158
- // Analyze codebase
159
- mcp__moflo__github_repo_analyze {
160
- repo: "current",
161
- analysis_type: "code_quality"
158
+ // Understand what a change touches and how risky it is
159
+ mcp__moflo__analyze_diff {
160
+ ref: "main"
162
161
  }
163
162
 
164
163
  // Track research metrics
@@ -304,16 +304,14 @@ mcp__moflo__memory_retrieve {
304
304
 
305
305
  ### Code Analysis
306
306
  ```javascript
307
- // Analyze code quality
308
- mcp__moflo__github_repo_analyze {
309
- repo: "current",
310
- analysis_type: "code_quality"
307
+ // Assess the risk and shape of the change under review
308
+ mcp__moflo__analyze_diff {
309
+ ref: "main"
311
310
  }
312
311
 
313
- // Run security scan
314
- mcp__moflo__github_repo_analyze {
315
- repo: "current",
316
- analysis_type: "security"
312
+ // Scan the diff for prompt-injection and unsafe content
313
+ mcp__moflo__aidefence_scan {
314
+ content: "<the diff or file under review>"
317
315
  }
318
316
  ```
319
317
 
@@ -71,6 +71,40 @@ if (hookContext.tool_input && typeof hookContext.tool_input === 'object') {
71
71
  });
72
72
  }
73
73
 
74
+ // #1322: forward the parts of tool_response that actually exist, so a gate can
75
+ // observe an OUTCOME rather than only the intent it was handed.
76
+ //
77
+ // Claude Code's PostToolUse payload carries NO exit status — probed on v2.1.220,
78
+ // tool_response for a Bash call is {stdout, stderr, interrupted, isImage,
79
+ // noOutputExpected}. PostToolUse also does not fire at all when the command
80
+ // exits non-zero, so the only case a gate can still be fooled by is an exit code
81
+ // MASKED by a pipe or `|| true`, where the response looks clean. The runner's
82
+ // own output is the sole remaining signal; record-test-run reads it in gate.cjs.
83
+ //
84
+ // Tail, not head. Every test runner prints its pass/fail summary LAST, so
85
+ // clipping the front of a long log would discard the exact lines this exists to
86
+ // read. Bounds are deliberately tight — Windows caps the whole environment
87
+ // block at ~32K wide chars and fails the spawn rather than truncating, and
88
+ // TOOL_INPUT_command is already forwarded uncapped alongside these.
89
+ var MAX_RESPONSE_STDOUT = 4096;
90
+ var MAX_RESPONSE_STDERR = 2048;
91
+ function tailOf(value, max) {
92
+ return value.length > max ? value.slice(value.length - max) : value;
93
+ }
94
+ if (hookContext.tool_response && typeof hookContext.tool_response === 'object') {
95
+ var resp = hookContext.tool_response;
96
+ if (typeof resp.stdout === 'string' && resp.stdout) {
97
+ env.TOOL_RESPONSE_stdout = tailOf(resp.stdout, MAX_RESPONSE_STDOUT);
98
+ }
99
+ if (typeof resp.stderr === 'string' && resp.stderr) {
100
+ env.TOOL_RESPONSE_stderr = tailOf(resp.stderr, MAX_RESPONSE_STDERR);
101
+ }
102
+ // Boolean — the string-typed forwarding above would drop it silently.
103
+ if (typeof resp.interrupted === 'boolean') {
104
+ env.TOOL_RESPONSE_interrupted = String(resp.interrupted);
105
+ }
106
+ }
107
+
74
108
  // Run gate.cjs with the enriched environment
75
109
  var projectDir = (env.CLAUDE_PROJECT_DIR || process.cwd()).replace(/^\/([a-z])\//i, '$1:/');
76
110
  var gateScript = resolve(projectDir, '.claude/helpers/gate.cjs');
@@ -139,6 +139,14 @@ var EXEMPT = ['.claude/', '.claude\\', 'CLAUDE.md', 'MEMORY.md', 'workflow-state
139
139
  // failures and "fixes" them with allow rules — which cannot override a hook
140
140
  // block at all (#1307 finding 5). Naming the gate makes the cause self-evident.
141
141
  var GATE_ORIGIN_NOTE = 'This is a moflo hook, not a Claude Code permission rule — allow-rules cannot override it.';
142
+
143
+ // #1348 — the pre-PR gates are order-dependent and each block message named only
144
+ // its own missing gate, so a caller could satisfy them one at a time forever:
145
+ // /flo-simplify may edit code (which resets tests and verify), and /verify must
146
+ // not, so simplify strictly precedes verify. Both blocking gates print this.
147
+ // /verify's Step 5 memory_store carries the verdict AND stamps learnings, which
148
+ // is why learnings has no separate step here.
149
+ var ORDER_HINT = 'Order that satisfies all of them: tests green -> /flo-simplify (re-run tests if it edits) -> /verify -> its memory_store verdict -> gh pr create\n';
142
150
  var GATE_DISABLE_NOTE = 'Disable per-gate via moflo.yaml: gates: memory_first: false';
143
151
 
144
152
  // #1294 Finding 3 — reads/scans of EPHEMERAL files under the OS temp dir
@@ -498,6 +506,53 @@ function applyPromptStateReset(state, promptText) {
498
506
  // and language-native test commands. The bare-runner arm is anchored so that
499
507
  // `npm install jest`, `grep -r vitest src/`, and similar don't false-positive.
500
508
  var TEST_RUNNER_RE = /(?:^|[^a-z])(?:npm|yarn|pnpm|bun)\s+(?:run\s+)?(?:test|t)(?:[:\s]|$)|\b(?:npx|pnpx)\s+(?:vitest|jest|mocha|ava|tap|jasmine|pytest)\b|(?:^|;|&&|\|\|)\s*(?:vitest|jest|pytest|mocha|jasmine|tap|ava)\s|\b(?:cargo|go|deno|dotnet|mvn)\s+test\b|\bgradle\w*\s+test\b/i;
509
+ // #1322 — failure markers in a test runner's own OUTPUT.
510
+ //
511
+ // This is deliberately not an exit-code check: Claude Code's PostToolUse payload
512
+ // carries no exit status, and PostToolUse does not fire at all when a command
513
+ // exits non-zero — so an unmasked red suite already leaves testsRun false, by
514
+ // accident of the hook lifecycle rather than by design. What DOES defeat the
515
+ // gate is a masked exit (`npm test | tail -20`, `npm test || true`,
516
+ // `npm test 2>&1 | grep -i fail`): the pipeline exits 0, PostToolUse fires with
517
+ // a clean-looking response, and a red suite credits the gate. Output is the only
518
+ // signal left, and it is genuinely weaker than a status — see the ticket.
519
+ //
520
+ // Every arm matches a SUMMARY shape a runner emits, never a bare "fail", which
521
+ // occurs constantly in ordinary passing test names ("returns null when the
522
+ // lookup failed"). The count arm excludes an explicit zero so jest's
523
+ // `0 failed, 12 passed` cannot self-block.
524
+ //
525
+ // The count arm's trailing lookahead is what keeps a GREEN run from blocking
526
+ // itself. Mocha's default spec reporter prints every passing test name, so
527
+ // `npm test | tail -20` on a green suite legitimately contains lines like
528
+ // `✓ handles 2 failed retries`. A real summary is followed by a delimiter or a
529
+ // line end (`3 failed | 40 passed`, `1 failed, 2 passed`, `1 failing`), never by
530
+ // more prose — so a lowercase word after the count means it is a sentence, not a
531
+ // tally. `tests`/`test` is exempted because `2 failed tests` is a real summary.
532
+ // Same-line whitespace only: at a line end there is nothing to disqualify.
533
+ var TEST_FAILURE_RE = new RegExp([
534
+ '\\b(?!0\\b)\\d+\\s+(?:tests?\\s+)?(?:failed|failing|failures?)\\b(?![^\\S\\n]+(?!tests?\\b)[a-z])', // vitest/jest/pytest/mocha counts
535
+ '^\\s*(?:FAIL|FAILED)\\b', // vitest + jest per-file, pytest FAILED
536
+ '^\\s*---\\s*FAIL:', // go test
537
+ '\\btest result:\\s*FAILED\\b', // cargo
538
+ '^npm ERR!', // npm wrapper around any of the above
539
+ ].join('|'), 'im');
540
+
541
+ /**
542
+ * #1322 — why a just-fired record-test-run must NOT be credited, or null.
543
+ *
544
+ * Absent output is not evidence of failure: a quiet green `npm test > /dev/null`
545
+ * and a silently-masked red one are indistinguishable, and treating the pair as
546
+ * failures would block every consumer who redirects test output. Absent means
547
+ * unknown, and unknown keeps the pre-#1322 behaviour.
548
+ */
549
+ function detectTestFailure() {
550
+ if (process.env.TOOL_RESPONSE_interrupted === 'true') return 'the run was interrupted';
551
+ var out = (process.env.TOOL_RESPONSE_stdout || '') + '\n' + (process.env.TOOL_RESPONSE_stderr || '');
552
+ if (!out.trim()) return null;
553
+ var hit = out.match(TEST_FAILURE_RE);
554
+ return hit ? 'output reports "' + hit[0].trim().slice(0, 40) + '"' : null;
555
+ }
501
556
  // Edits to these don't change runtime behaviour, so they don't invalidate prior test/simplify runs.
502
557
  // Lock files and .gitignore are tracked but inert; package.json/*.yaml ARE source — they reset.
503
558
  var EDIT_RESET_SKIP_BOTH_RE = /\.(md|markdown|txt|rst|adoc|lock|gitignore)$|(?:^|[\\\/])(CHANGELOG(?:\.md)?|\.env\.example|package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb)$/i;
@@ -508,7 +563,13 @@ var EDIT_RESET_SKIP_BOTH_RE = /\.(md|markdown|txt|rst|adoc|lock|gitignore)$|(?:^
508
563
  // they shouldn't reset testsRun/simplifyRun the way a real source edit does.
509
564
  // Trailing terminator includes `.` so the single-file template form
510
565
  // `.github/PULL_REQUEST_TEMPLATE.md` matches alongside the directory form.
511
- var EDIT_RESET_SKIP_PATH_RE = /(?:^|[\\\/])\.github[\\\/](?:workflows|ISSUE_TEMPLATE|PULL_REQUEST_TEMPLATE)(?:[\\\/.]|$)/i;
566
+ // #1348 — `.moflo/` joins them. It is moflo's own gitignored state directory
567
+ // (daemon locks, memory db, SDD spec/plan artifacts), so nothing written there
568
+ // can appear in the branch diff, and a spec edit invalidating the test run is
569
+ // pure noise. Scoped to the reset only — deliberately NOT added to EXEMPT,
570
+ // which would also un-gate reads of `.moflo/specs/**`, and those are indexed
571
+ // guidance that memory-first should still route through a search.
572
+ var EDIT_RESET_SKIP_PATH_RE = /(?:^|[\\\/])\.github[\\\/](?:workflows|ISSUE_TEMPLATE|PULL_REQUEST_TEMPLATE)(?:[\\\/.]|$)|(?:^|[\\\/])\.moflo[\\\/]/i;
512
573
  // Test files: invalidate the testing gate (tests are stale once test code changes)
513
574
  // but NOT the simplify gate — /simplify already reviewed the production code; touching
514
575
  // a test file or fixture doesn't expose new untested surface for code review (#908).
@@ -637,8 +698,11 @@ function getChangedFilesVsBase() {
637
698
 
638
699
  switch (command) {
639
700
  case 'check-before-agent': {
640
- // Advisory only — agent spawning is never blocked.
641
- // Memory-first enforcement happens at the scan/read gate layer.
701
+ // Mostly advisory. The TaskCreate + memory reminders below go to stdout and
702
+ // never block — their wording must not claim otherwise (#1326). The one
703
+ // exception is the #952 swarm/hive check at the bottom of this case, which
704
+ // writes to stderr and exits 2.
705
+ // Memory-first enforcement otherwise happens at the scan/read gate layer.
642
706
  // SubagentStart hook injects guidance directive into subagent context.
643
707
  //
644
708
  // #931 — TaskCreate REMINDER and the namespace hint moved here from
@@ -647,7 +711,7 @@ switch (command) {
647
711
  // consumer.
648
712
  var s = readState();
649
713
  if (config.task_create_first && !s.tasksCreated) {
650
- process.stdout.write('REMINDER: Use TaskCreate before spawning agents. Task tool is blocked until then.\n');
714
+ process.stdout.write('REMINDER: Use TaskCreate before spawning agents.\n');
651
715
  }
652
716
  if (config.memory_first && s.memoryRequired && !s.memorySearched) {
653
717
  process.stdout.write('REMINDER: Search memory (mcp__moflo__memory_search) before spawning agents. On chunk hits, traverse via mcp__moflo__memory_get_neighbors — see .claude/guidance/moflo-memory-protocol.md\n');
@@ -809,8 +873,17 @@ switch (command) {
809
873
  case 'record-test-run': {
810
874
  var cmd = process.env.TOOL_INPUT_command || '';
811
875
  if (TEST_RUNNER_RE.test(cmd)) {
876
+ // #1322 — a red run is evidence AGAINST the gate, so it also clears a
877
+ // flag an earlier green run earned. Without the reset, `npm test` (green)
878
+ // followed by an edit and `npm test | tail -20` (red) would leave the
879
+ // gate satisfied by the stale first run — the edit resets testsRun, but
880
+ // the masked red run would immediately set it back.
881
+ var failure = detectTestFailure();
812
882
  var s = readState();
813
- if (!s.testsRun) {
883
+ if (failure) {
884
+ if (s.testsRun) { s.testsRun = false; writeState(s); }
885
+ process.stderr.write('gate: record-test-run not credited — ' + failure + '\n');
886
+ } else if (!s.testsRun) {
814
887
  s.testsRun = true;
815
888
  writeState(s);
816
889
  }
@@ -902,6 +975,17 @@ switch (command) {
902
975
  overall = 'UNVERIFIED';
903
976
  }
904
977
  var vs = readState();
978
+ // #1348 — refuse a verdict for a run that is no longer live. `verifyRun`
979
+ // false here means a code edit fired reset-edit-gates between the /verify
980
+ // invocation and this store, so the verdict describes pre-edit code.
981
+ // Recording it anyway produced the contradictory `verifyRun:false,
982
+ // verifyOutcome:'PASS'` state that check-before-done's four-way message
983
+ // chain has no branch for — it reports "a code edit invalidated the previous
984
+ // verification" while a PASS sits in state, which reads as a gate bug.
985
+ if (!vs.verifyRun) {
986
+ process.stderr.write('gate: record-verify-outcome — "' + mKey + '" arrived after a code edit invalidated the run; verdict not recorded (re-run /verify)\n');
987
+ break;
988
+ }
905
989
  vs.verifyOutcome = overall;
906
990
  writeState(vs);
907
991
  break;
@@ -912,6 +996,17 @@ switch (command) {
912
996
  // (.github/workflows/, .github/ISSUE_TEMPLATE/, .github/PULL_REQUEST_TEMPLATE/, #1176):
913
997
  // no gate reset — editing these doesn't expose new runtime surface.
914
998
  if (fp && (EDIT_RESET_SKIP_BOTH_RE.test(fp) || EDIT_RESET_SKIP_PATH_RE.test(fp))) break;
999
+ // #1348 — a scratchpad write under the OS temp dir is transient tool I/O and
1000
+ // can never reach the branch diff, but it used to reset tests + simplify +
1001
+ // verify like any source change: a /verify run that jotted a probe cleared
1002
+ // the /flo-simplify stamp, and the two gates invalidated each other with no
1003
+ // ordering that satisfied both. Reuses the predicate the memory-first gate
1004
+ // already trusts for these paths (#1294) rather than a second one.
1005
+ // Scope: tmp-only, NOT project-root containment — so a project rooted under
1006
+ // tmp has its own edits skipped too (pinned in the #1348 tests). Containment
1007
+ // is the more general rule, but it fails OPEN when the root can't be
1008
+ // resolved, and a gate that silently stops resetting is the worse failure.
1009
+ if (isEphemeralPath(fp)) break;
915
1010
  var s = readState();
916
1011
  // Test-only edits invalidate testsRun but preserve simplifyRun (#908).
917
1012
  var isTestOnly = fp && EDIT_RESET_SKIP_SIMPLIFY_ONLY_RE.test(fp);
@@ -1018,7 +1113,7 @@ switch (command) {
1018
1113
  }
1019
1114
  }
1020
1115
  var missing = [];
1021
- if (config.testing_gate && !s.testsRun) missing.push('tests have not run since the last code edit (run npm test, vitest, jest, pytest, or similar)');
1116
+ if (config.testing_gate && !s.testsRun) missing.push('tests have not run green since the last code edit (run npm test, vitest, jest, pytest, or similar — a run whose output reports failures does not count, #1322)');
1022
1117
  if (config.simplify_gate && !s.simplifyRun) missing.push('/flo-simplify (or /distill) has not run since the last code edit');
1023
1118
  if (config.learnings_gate && !s.learningsStored) missing.push('learnings have not been stored (call mcp__moflo__memory_store)');
1024
1119
  if (missing.length === 0) break;
@@ -1029,6 +1124,10 @@ switch (command) {
1029
1124
  if (s.lastResetBy && s.lastResetBy.file) {
1030
1125
  process.stderr.write('Last gate reset: ' + s.lastResetBy.file + ' (' + (s.lastResetBy.gates || []).join(', ') + ')\n');
1031
1126
  }
1127
+ // #1348 — name the order, not just the missing gate. Satisfying one gate can
1128
+ // invalidate another (/flo-simplify edits code, which resets tests + verify),
1129
+ // so "what is missing" alone left callers rediscovering the sequence by trial.
1130
+ process.stderr.write(ORDER_HINT);
1032
1131
  process.stderr.write('Disable per-gate via moflo.yaml:\n');
1033
1132
  process.stderr.write(' gates:\n testing_gate: false\n simplify_gate: false\n learnings_gate: false\n');
1034
1133
  process.exit(2);
@@ -1079,7 +1178,12 @@ switch (command) {
1079
1178
  // recorded a structured outcome (interrupted, or it stored prose only).
1080
1179
  process.stderr.write(' - /verify ran but recorded no verdict — re-run it so it stores a structured result\n');
1081
1180
  process.stderr.write(' (Step 5 of the verify skill must pass metadata.overall to memory_store)\n');
1181
+ // #1348 — the trap this state sets: re-invoking /verify CLEARS any prior
1182
+ // verdict by design (#1332), so the obvious recovery lands right back here
1183
+ // unless Step 5 completes. Say so, rather than letting it be rediscovered.
1184
+ process.stderr.write(' Re-invoking /verify clears the prior verdict, so a re-run that skips Step 5 lands here again.\n');
1082
1185
  }
1186
+ process.stderr.write(ORDER_HINT);
1083
1187
  process.stderr.write('Disable via moflo.yaml:\n');
1084
1188
  process.stderr.write(' gates:\n verify_before_done: false\n');
1085
1189
  process.exit(2);
File without changes
File without changes
package/bin/gate-hook.mjs CHANGED
@@ -71,6 +71,40 @@ if (hookContext.tool_input && typeof hookContext.tool_input === 'object') {
71
71
  });
72
72
  }
73
73
 
74
+ // #1322: forward the parts of tool_response that actually exist, so a gate can
75
+ // observe an OUTCOME rather than only the intent it was handed.
76
+ //
77
+ // Claude Code's PostToolUse payload carries NO exit status — probed on v2.1.220,
78
+ // tool_response for a Bash call is {stdout, stderr, interrupted, isImage,
79
+ // noOutputExpected}. PostToolUse also does not fire at all when the command
80
+ // exits non-zero, so the only case a gate can still be fooled by is an exit code
81
+ // MASKED by a pipe or `|| true`, where the response looks clean. The runner's
82
+ // own output is the sole remaining signal; record-test-run reads it in gate.cjs.
83
+ //
84
+ // Tail, not head. Every test runner prints its pass/fail summary LAST, so
85
+ // clipping the front of a long log would discard the exact lines this exists to
86
+ // read. Bounds are deliberately tight — Windows caps the whole environment
87
+ // block at ~32K wide chars and fails the spawn rather than truncating, and
88
+ // TOOL_INPUT_command is already forwarded uncapped alongside these.
89
+ var MAX_RESPONSE_STDOUT = 4096;
90
+ var MAX_RESPONSE_STDERR = 2048;
91
+ function tailOf(value, max) {
92
+ return value.length > max ? value.slice(value.length - max) : value;
93
+ }
94
+ if (hookContext.tool_response && typeof hookContext.tool_response === 'object') {
95
+ var resp = hookContext.tool_response;
96
+ if (typeof resp.stdout === 'string' && resp.stdout) {
97
+ env.TOOL_RESPONSE_stdout = tailOf(resp.stdout, MAX_RESPONSE_STDOUT);
98
+ }
99
+ if (typeof resp.stderr === 'string' && resp.stderr) {
100
+ env.TOOL_RESPONSE_stderr = tailOf(resp.stderr, MAX_RESPONSE_STDERR);
101
+ }
102
+ // Boolean — the string-typed forwarding above would drop it silently.
103
+ if (typeof resp.interrupted === 'boolean') {
104
+ env.TOOL_RESPONSE_interrupted = String(resp.interrupted);
105
+ }
106
+ }
107
+
74
108
  // Run gate.cjs with the enriched environment
75
109
  var projectDir = (env.CLAUDE_PROJECT_DIR || process.cwd()).replace(/^\/([a-z])\//i, '$1:/');
76
110
  var gateScript = resolve(projectDir, '.claude/helpers/gate.cjs');
package/bin/gate.cjs CHANGED
@@ -139,6 +139,14 @@ var EXEMPT = ['.claude/', '.claude\\', 'CLAUDE.md', 'MEMORY.md', 'workflow-state
139
139
  // failures and "fixes" them with allow rules — which cannot override a hook
140
140
  // block at all (#1307 finding 5). Naming the gate makes the cause self-evident.
141
141
  var GATE_ORIGIN_NOTE = 'This is a moflo hook, not a Claude Code permission rule — allow-rules cannot override it.';
142
+
143
+ // #1348 — the pre-PR gates are order-dependent and each block message named only
144
+ // its own missing gate, so a caller could satisfy them one at a time forever:
145
+ // /flo-simplify may edit code (which resets tests and verify), and /verify must
146
+ // not, so simplify strictly precedes verify. Both blocking gates print this.
147
+ // /verify's Step 5 memory_store carries the verdict AND stamps learnings, which
148
+ // is why learnings has no separate step here.
149
+ var ORDER_HINT = 'Order that satisfies all of them: tests green -> /flo-simplify (re-run tests if it edits) -> /verify -> its memory_store verdict -> gh pr create\n';
142
150
  var GATE_DISABLE_NOTE = 'Disable per-gate via moflo.yaml: gates: memory_first: false';
143
151
 
144
152
  // #1294 Finding 3 — reads/scans of EPHEMERAL files under the OS temp dir
@@ -498,6 +506,53 @@ function applyPromptStateReset(state, promptText) {
498
506
  // and language-native test commands. The bare-runner arm is anchored so that
499
507
  // `npm install jest`, `grep -r vitest src/`, and similar don't false-positive.
500
508
  var TEST_RUNNER_RE = /(?:^|[^a-z])(?:npm|yarn|pnpm|bun)\s+(?:run\s+)?(?:test|t)(?:[:\s]|$)|\b(?:npx|pnpx)\s+(?:vitest|jest|mocha|ava|tap|jasmine|pytest)\b|(?:^|;|&&|\|\|)\s*(?:vitest|jest|pytest|mocha|jasmine|tap|ava)\s|\b(?:cargo|go|deno|dotnet|mvn)\s+test\b|\bgradle\w*\s+test\b/i;
509
+ // #1322 — failure markers in a test runner's own OUTPUT.
510
+ //
511
+ // This is deliberately not an exit-code check: Claude Code's PostToolUse payload
512
+ // carries no exit status, and PostToolUse does not fire at all when a command
513
+ // exits non-zero — so an unmasked red suite already leaves testsRun false, by
514
+ // accident of the hook lifecycle rather than by design. What DOES defeat the
515
+ // gate is a masked exit (`npm test | tail -20`, `npm test || true`,
516
+ // `npm test 2>&1 | grep -i fail`): the pipeline exits 0, PostToolUse fires with
517
+ // a clean-looking response, and a red suite credits the gate. Output is the only
518
+ // signal left, and it is genuinely weaker than a status — see the ticket.
519
+ //
520
+ // Every arm matches a SUMMARY shape a runner emits, never a bare "fail", which
521
+ // occurs constantly in ordinary passing test names ("returns null when the
522
+ // lookup failed"). The count arm excludes an explicit zero so jest's
523
+ // `0 failed, 12 passed` cannot self-block.
524
+ //
525
+ // The count arm's trailing lookahead is what keeps a GREEN run from blocking
526
+ // itself. Mocha's default spec reporter prints every passing test name, so
527
+ // `npm test | tail -20` on a green suite legitimately contains lines like
528
+ // `✓ handles 2 failed retries`. A real summary is followed by a delimiter or a
529
+ // line end (`3 failed | 40 passed`, `1 failed, 2 passed`, `1 failing`), never by
530
+ // more prose — so a lowercase word after the count means it is a sentence, not a
531
+ // tally. `tests`/`test` is exempted because `2 failed tests` is a real summary.
532
+ // Same-line whitespace only: at a line end there is nothing to disqualify.
533
+ var TEST_FAILURE_RE = new RegExp([
534
+ '\\b(?!0\\b)\\d+\\s+(?:tests?\\s+)?(?:failed|failing|failures?)\\b(?![^\\S\\n]+(?!tests?\\b)[a-z])', // vitest/jest/pytest/mocha counts
535
+ '^\\s*(?:FAIL|FAILED)\\b', // vitest + jest per-file, pytest FAILED
536
+ '^\\s*---\\s*FAIL:', // go test
537
+ '\\btest result:\\s*FAILED\\b', // cargo
538
+ '^npm ERR!', // npm wrapper around any of the above
539
+ ].join('|'), 'im');
540
+
541
+ /**
542
+ * #1322 — why a just-fired record-test-run must NOT be credited, or null.
543
+ *
544
+ * Absent output is not evidence of failure: a quiet green `npm test > /dev/null`
545
+ * and a silently-masked red one are indistinguishable, and treating the pair as
546
+ * failures would block every consumer who redirects test output. Absent means
547
+ * unknown, and unknown keeps the pre-#1322 behaviour.
548
+ */
549
+ function detectTestFailure() {
550
+ if (process.env.TOOL_RESPONSE_interrupted === 'true') return 'the run was interrupted';
551
+ var out = (process.env.TOOL_RESPONSE_stdout || '') + '\n' + (process.env.TOOL_RESPONSE_stderr || '');
552
+ if (!out.trim()) return null;
553
+ var hit = out.match(TEST_FAILURE_RE);
554
+ return hit ? 'output reports "' + hit[0].trim().slice(0, 40) + '"' : null;
555
+ }
501
556
  // Edits to these don't change runtime behaviour, so they don't invalidate prior test/simplify runs.
502
557
  // Lock files and .gitignore are tracked but inert; package.json/*.yaml ARE source — they reset.
503
558
  var EDIT_RESET_SKIP_BOTH_RE = /\.(md|markdown|txt|rst|adoc|lock|gitignore)$|(?:^|[\\\/])(CHANGELOG(?:\.md)?|\.env\.example|package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb)$/i;
@@ -508,7 +563,13 @@ var EDIT_RESET_SKIP_BOTH_RE = /\.(md|markdown|txt|rst|adoc|lock|gitignore)$|(?:^
508
563
  // they shouldn't reset testsRun/simplifyRun the way a real source edit does.
509
564
  // Trailing terminator includes `.` so the single-file template form
510
565
  // `.github/PULL_REQUEST_TEMPLATE.md` matches alongside the directory form.
511
- var EDIT_RESET_SKIP_PATH_RE = /(?:^|[\\\/])\.github[\\\/](?:workflows|ISSUE_TEMPLATE|PULL_REQUEST_TEMPLATE)(?:[\\\/.]|$)/i;
566
+ // #1348 — `.moflo/` joins them. It is moflo's own gitignored state directory
567
+ // (daemon locks, memory db, SDD spec/plan artifacts), so nothing written there
568
+ // can appear in the branch diff, and a spec edit invalidating the test run is
569
+ // pure noise. Scoped to the reset only — deliberately NOT added to EXEMPT,
570
+ // which would also un-gate reads of `.moflo/specs/**`, and those are indexed
571
+ // guidance that memory-first should still route through a search.
572
+ var EDIT_RESET_SKIP_PATH_RE = /(?:^|[\\\/])\.github[\\\/](?:workflows|ISSUE_TEMPLATE|PULL_REQUEST_TEMPLATE)(?:[\\\/.]|$)|(?:^|[\\\/])\.moflo[\\\/]/i;
512
573
  // Test files: invalidate the testing gate (tests are stale once test code changes)
513
574
  // but NOT the simplify gate — /simplify already reviewed the production code; touching
514
575
  // a test file or fixture doesn't expose new untested surface for code review (#908).
@@ -637,8 +698,11 @@ function getChangedFilesVsBase() {
637
698
 
638
699
  switch (command) {
639
700
  case 'check-before-agent': {
640
- // Advisory only — agent spawning is never blocked.
641
- // Memory-first enforcement happens at the scan/read gate layer.
701
+ // Mostly advisory. The TaskCreate + memory reminders below go to stdout and
702
+ // never block — their wording must not claim otherwise (#1326). The one
703
+ // exception is the #952 swarm/hive check at the bottom of this case, which
704
+ // writes to stderr and exits 2.
705
+ // Memory-first enforcement otherwise happens at the scan/read gate layer.
642
706
  // SubagentStart hook injects guidance directive into subagent context.
643
707
  //
644
708
  // #931 — TaskCreate REMINDER and the namespace hint moved here from
@@ -647,7 +711,7 @@ switch (command) {
647
711
  // consumer.
648
712
  var s = readState();
649
713
  if (config.task_create_first && !s.tasksCreated) {
650
- process.stdout.write('REMINDER: Use TaskCreate before spawning agents. Task tool is blocked until then.\n');
714
+ process.stdout.write('REMINDER: Use TaskCreate before spawning agents.\n');
651
715
  }
652
716
  if (config.memory_first && s.memoryRequired && !s.memorySearched) {
653
717
  process.stdout.write('REMINDER: Search memory (mcp__moflo__memory_search) before spawning agents. On chunk hits, traverse via mcp__moflo__memory_get_neighbors — see .claude/guidance/moflo-memory-protocol.md\n');
@@ -809,8 +873,17 @@ switch (command) {
809
873
  case 'record-test-run': {
810
874
  var cmd = process.env.TOOL_INPUT_command || '';
811
875
  if (TEST_RUNNER_RE.test(cmd)) {
876
+ // #1322 — a red run is evidence AGAINST the gate, so it also clears a
877
+ // flag an earlier green run earned. Without the reset, `npm test` (green)
878
+ // followed by an edit and `npm test | tail -20` (red) would leave the
879
+ // gate satisfied by the stale first run — the edit resets testsRun, but
880
+ // the masked red run would immediately set it back.
881
+ var failure = detectTestFailure();
812
882
  var s = readState();
813
- if (!s.testsRun) {
883
+ if (failure) {
884
+ if (s.testsRun) { s.testsRun = false; writeState(s); }
885
+ process.stderr.write('gate: record-test-run not credited — ' + failure + '\n');
886
+ } else if (!s.testsRun) {
814
887
  s.testsRun = true;
815
888
  writeState(s);
816
889
  }
@@ -902,6 +975,17 @@ switch (command) {
902
975
  overall = 'UNVERIFIED';
903
976
  }
904
977
  var vs = readState();
978
+ // #1348 — refuse a verdict for a run that is no longer live. `verifyRun`
979
+ // false here means a code edit fired reset-edit-gates between the /verify
980
+ // invocation and this store, so the verdict describes pre-edit code.
981
+ // Recording it anyway produced the contradictory `verifyRun:false,
982
+ // verifyOutcome:'PASS'` state that check-before-done's four-way message
983
+ // chain has no branch for — it reports "a code edit invalidated the previous
984
+ // verification" while a PASS sits in state, which reads as a gate bug.
985
+ if (!vs.verifyRun) {
986
+ process.stderr.write('gate: record-verify-outcome — "' + mKey + '" arrived after a code edit invalidated the run; verdict not recorded (re-run /verify)\n');
987
+ break;
988
+ }
905
989
  vs.verifyOutcome = overall;
906
990
  writeState(vs);
907
991
  break;
@@ -912,6 +996,17 @@ switch (command) {
912
996
  // (.github/workflows/, .github/ISSUE_TEMPLATE/, .github/PULL_REQUEST_TEMPLATE/, #1176):
913
997
  // no gate reset — editing these doesn't expose new runtime surface.
914
998
  if (fp && (EDIT_RESET_SKIP_BOTH_RE.test(fp) || EDIT_RESET_SKIP_PATH_RE.test(fp))) break;
999
+ // #1348 — a scratchpad write under the OS temp dir is transient tool I/O and
1000
+ // can never reach the branch diff, but it used to reset tests + simplify +
1001
+ // verify like any source change: a /verify run that jotted a probe cleared
1002
+ // the /flo-simplify stamp, and the two gates invalidated each other with no
1003
+ // ordering that satisfied both. Reuses the predicate the memory-first gate
1004
+ // already trusts for these paths (#1294) rather than a second one.
1005
+ // Scope: tmp-only, NOT project-root containment — so a project rooted under
1006
+ // tmp has its own edits skipped too (pinned in the #1348 tests). Containment
1007
+ // is the more general rule, but it fails OPEN when the root can't be
1008
+ // resolved, and a gate that silently stops resetting is the worse failure.
1009
+ if (isEphemeralPath(fp)) break;
915
1010
  var s = readState();
916
1011
  // Test-only edits invalidate testsRun but preserve simplifyRun (#908).
917
1012
  var isTestOnly = fp && EDIT_RESET_SKIP_SIMPLIFY_ONLY_RE.test(fp);
@@ -1018,7 +1113,7 @@ switch (command) {
1018
1113
  }
1019
1114
  }
1020
1115
  var missing = [];
1021
- if (config.testing_gate && !s.testsRun) missing.push('tests have not run since the last code edit (run npm test, vitest, jest, pytest, or similar)');
1116
+ if (config.testing_gate && !s.testsRun) missing.push('tests have not run green since the last code edit (run npm test, vitest, jest, pytest, or similar — a run whose output reports failures does not count, #1322)');
1022
1117
  if (config.simplify_gate && !s.simplifyRun) missing.push('/flo-simplify (or /distill) has not run since the last code edit');
1023
1118
  if (config.learnings_gate && !s.learningsStored) missing.push('learnings have not been stored (call mcp__moflo__memory_store)');
1024
1119
  if (missing.length === 0) break;
@@ -1029,6 +1124,10 @@ switch (command) {
1029
1124
  if (s.lastResetBy && s.lastResetBy.file) {
1030
1125
  process.stderr.write('Last gate reset: ' + s.lastResetBy.file + ' (' + (s.lastResetBy.gates || []).join(', ') + ')\n');
1031
1126
  }
1127
+ // #1348 — name the order, not just the missing gate. Satisfying one gate can
1128
+ // invalidate another (/flo-simplify edits code, which resets tests + verify),
1129
+ // so "what is missing" alone left callers rediscovering the sequence by trial.
1130
+ process.stderr.write(ORDER_HINT);
1032
1131
  process.stderr.write('Disable per-gate via moflo.yaml:\n');
1033
1132
  process.stderr.write(' gates:\n testing_gate: false\n simplify_gate: false\n learnings_gate: false\n');
1034
1133
  process.exit(2);
@@ -1079,7 +1178,12 @@ switch (command) {
1079
1178
  // recorded a structured outcome (interrupted, or it stored prose only).
1080
1179
  process.stderr.write(' - /verify ran but recorded no verdict — re-run it so it stores a structured result\n');
1081
1180
  process.stderr.write(' (Step 5 of the verify skill must pass metadata.overall to memory_store)\n');
1181
+ // #1348 — the trap this state sets: re-invoking /verify CLEARS any prior
1182
+ // verdict by design (#1332), so the obvious recovery lands right back here
1183
+ // unless Step 5 completes. Say so, rather than letting it be rediscovered.
1184
+ process.stderr.write(' Re-invoking /verify clears the prior verdict, so a re-run that skips Step 5 lands here again.\n');
1082
1185
  }
1186
+ process.stderr.write(ORDER_HINT);
1083
1187
  process.stderr.write('Disable via moflo.yaml:\n');
1084
1188
  process.stderr.write(' gates:\n verify_before_done: false\n');
1085
1189
  process.exit(2);
@@ -690,94 +690,6 @@ const healthCommand = {
690
690
  }
691
691
  }
692
692
  };
693
- // Agent logs subcommand
694
- const logsCommand = {
695
- name: 'logs',
696
- description: 'Show agent activity logs',
697
- options: [
698
- {
699
- name: 'id',
700
- short: 'i',
701
- description: 'Agent ID',
702
- type: 'string'
703
- },
704
- {
705
- name: 'tail',
706
- short: 'n',
707
- description: 'Number of recent entries',
708
- type: 'number',
709
- default: 50
710
- },
711
- {
712
- name: 'level',
713
- short: 'l',
714
- description: 'Minimum log level',
715
- type: 'string',
716
- choices: ['debug', 'info', 'warn', 'error'],
717
- default: 'info'
718
- },
719
- {
720
- name: 'follow',
721
- short: 'f',
722
- description: 'Follow log output',
723
- type: 'boolean',
724
- default: false
725
- },
726
- {
727
- name: 'since',
728
- description: 'Show logs since (e.g., "1h", "30m")',
729
- type: 'string'
730
- }
731
- ],
732
- examples: [
733
- { command: 'flo agent logs -i agent-001', description: 'Show agent logs' },
734
- { command: 'flo agent logs -i agent-001 -f', description: 'Follow agent logs' },
735
- { command: 'flo agent logs -l error --since 1h', description: 'Show errors from last hour' }
736
- ],
737
- action: async (ctx) => {
738
- const agentId = ctx.args[0] || ctx.flags.id;
739
- const tail = ctx.flags.tail;
740
- const level = ctx.flags.level;
741
- if (!agentId) {
742
- output.printError('Agent ID is required. Use --id or -i');
743
- return { success: false, exitCode: 1 };
744
- }
745
- try {
746
- const result = await callMCPTool('agent_logs', {
747
- agentId,
748
- tail,
749
- level,
750
- since: ctx.flags.since,
751
- });
752
- if (ctx.flags.format === 'json') {
753
- output.printJson(result);
754
- return { success: true, data: result };
755
- }
756
- output.writeln();
757
- output.writeln(output.bold(`Logs for ${agentId}`));
758
- output.writeln(output.dim(`Showing ${result.entries.length} of ${result.total} entries`));
759
- output.writeln();
760
- for (const entry of result.entries) {
761
- const time = new Date(entry.timestamp).toLocaleTimeString();
762
- const levelStr = formatLogLevel(entry.level);
763
- output.writeln(`${output.dim(time)} ${levelStr} ${entry.message}`);
764
- if (entry.context && Object.keys(entry.context).length > 0) {
765
- output.writeln(output.dim(` ${JSON.stringify(entry.context)}`));
766
- }
767
- }
768
- return { success: true, data: result };
769
- }
770
- catch (error) {
771
- if (error instanceof MCPClientError) {
772
- output.printError(`Logs error: ${error.message}`);
773
- }
774
- else {
775
- output.printError(`Unexpected error: ${String(error)}`);
776
- }
777
- return { success: false, exitCode: 1 };
778
- }
779
- }
780
- };
781
693
  function formatLogLevel(level) {
782
694
  switch (level) {
783
695
  case 'debug':
@@ -796,7 +708,7 @@ function formatLogLevel(level) {
796
708
  export const agentCommand = {
797
709
  name: 'agent',
798
710
  description: 'Agent management commands',
799
- subcommands: [spawnCommand, listCommand, statusCommand, stopCommand, metricsCommand, poolCommand, healthCommand, logsCommand],
711
+ subcommands: [spawnCommand, listCommand, statusCommand, stopCommand, metricsCommand, poolCommand, healthCommand],
800
712
  options: [],
801
713
  examples: [
802
714
  { command: 'flo agent spawn -t coder', description: 'Spawn a coder agent' },