moflo 4.12.3 → 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 (98) 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/guidance/shipped/moflo-spell-engine.md +13 -10
  5. package/.claude/helpers/gate-hook.mjs +67 -2
  6. package/.claude/helpers/gate.cjs +173 -12
  7. package/.claude/helpers/hook-handler.cjs +0 -0
  8. package/.claude/helpers/prompt-hook.mjs +0 -0
  9. package/.claude/skills/connector-builder/SKILL.md +5 -4
  10. package/.claude/skills/fl/phases.md +1 -1
  11. package/.claude/skills/fl/sdd.md +1 -1
  12. package/.claude/skills/spell-builder/SKILL.md +5 -4
  13. package/.claude/skills/spell-builder/architecture.md +4 -4
  14. package/.claude/skills/spell-builder/connectors/github-cli/README.md +22 -17
  15. package/.claude/skills/spell-builder/connectors/http/README.md +18 -11
  16. package/.claude/skills/spell-builder/connectors/local-outlook/README.md +8 -7
  17. package/.claude/skills/spell-builder/connectors/playwright/README.md +10 -8
  18. package/.claude/skills/spell-builder/steps/agent/README.md +25 -21
  19. package/.claude/skills/verify/SKILL.md +24 -3
  20. package/bin/gate-hook.mjs +67 -2
  21. package/bin/gate.cjs +173 -12
  22. package/bin/generate-code-map.mjs +70 -17
  23. package/bin/index-all.mjs +38 -0
  24. package/bin/index-guidance.mjs +8 -41
  25. package/bin/index-patterns.mjs +2 -2
  26. package/bin/index-tests.mjs +6 -4
  27. package/bin/lib/guidance-config.mjs +119 -0
  28. package/bin/lib/index-fingerprint.mjs +0 -0
  29. package/dist/src/cli/commands/agent.js +1 -89
  30. package/dist/src/cli/commands/analyze.js +1 -1
  31. package/dist/src/cli/commands/benchmark.js +11 -8
  32. package/dist/src/cli/commands/claims.js +2 -2
  33. package/dist/src/cli/commands/completions.js +1 -1
  34. package/dist/src/cli/commands/config.js +256 -96
  35. package/dist/src/cli/commands/deployment.js +6 -6
  36. package/dist/src/cli/commands/diagnose.js +1 -1
  37. package/dist/src/cli/commands/doctor-checks-config.js +18 -22
  38. package/dist/src/cli/commands/doctor-checks-deep.js +1 -1
  39. package/dist/src/cli/commands/doctor-fixes.js +36 -11
  40. package/dist/src/cli/commands/doctor.js +1 -1
  41. package/dist/src/cli/commands/embeddings.js +64 -23
  42. package/dist/src/cli/commands/github.js +1 -1
  43. package/dist/src/cli/commands/guidance.js +10 -10
  44. package/dist/src/cli/commands/hive-mind.js +8 -70
  45. package/dist/src/cli/commands/hooks.js +32 -219
  46. package/dist/src/cli/commands/mcp.js +5 -2
  47. package/dist/src/cli/commands/memory.js +59 -46
  48. package/dist/src/cli/commands/neural.js +21 -21
  49. package/dist/src/cli/commands/performance.js +14 -7
  50. package/dist/src/cli/commands/plugins.js +2 -2
  51. package/dist/src/cli/commands/providers.js +2 -2
  52. package/dist/src/cli/commands/retire.js +1 -1
  53. package/dist/src/cli/commands/route.js +1 -1
  54. package/dist/src/cli/commands/security.js +4 -4
  55. package/dist/src/cli/commands/status.js +18 -13
  56. package/dist/src/cli/commands/task.js +8 -0
  57. package/dist/src/cli/config/cli-config-store.js +326 -0
  58. package/dist/src/cli/hooks/workers/index.js +15 -9
  59. package/dist/src/cli/index.js +43 -33
  60. package/dist/src/cli/init/claudemd-generator.js +3 -3
  61. package/dist/src/cli/init/helpers-generator.js +203 -20
  62. package/dist/src/cli/init/moflo-yaml-template.js +11 -3
  63. package/dist/src/cli/init/settings-generator.js +10 -2
  64. package/dist/src/cli/mcp-client.js +12 -2
  65. package/dist/src/cli/mcp-server.js +5 -23
  66. package/dist/src/cli/mcp-tools/analysis-tools.js +103 -0
  67. package/dist/src/cli/mcp-tools/coverage-tools.js +206 -0
  68. package/dist/src/cli/mcp-tools/hive-mind-tools.js +71 -0
  69. package/dist/src/cli/mcp-tools/hooks-tools.js +25 -9
  70. package/dist/src/cli/mcp-tools/memory-admin-tools.js +538 -0
  71. package/dist/src/cli/mcp-tools/memory-tools.js +26 -5
  72. package/dist/src/cli/mcp-tools/neural-tools.js +56 -87
  73. package/dist/src/cli/mcp-tools/performance-tools.js +76 -39
  74. package/dist/src/cli/mcp-tools/progress-tools.js +142 -0
  75. package/dist/src/cli/mcp-tools/security-tools.js +1 -1
  76. package/dist/src/cli/mcp-tools/session-tools.js +116 -0
  77. package/dist/src/cli/mcp-tools/synthetic.js +74 -0
  78. package/dist/src/cli/mcp-tools/system-tools.js +126 -78
  79. package/dist/src/cli/mcp-tools/task-tools.js +101 -1
  80. package/dist/src/cli/movector/coverage-router.js +48 -6
  81. package/dist/src/cli/movector/diff-classifier.js +8 -6
  82. package/dist/src/cli/movector/flash-attention.js +1 -1
  83. package/dist/src/cli/movector/vector-db.js +1 -1
  84. package/dist/src/cli/parser.js +81 -12
  85. package/dist/src/cli/services/daemon-lock.js +5 -2
  86. package/dist/src/cli/services/hook-block-hash.js +3 -1
  87. package/dist/src/cli/services/moflo-version.js +62 -0
  88. package/dist/src/cli/services/movector-training.js +1 -1
  89. package/dist/src/cli/services/spell-gate.js +6 -4
  90. package/dist/src/cli/services/worker-daemon.js +37 -7
  91. package/dist/src/cli/shared/utils/load-average.js +59 -0
  92. package/dist/src/cli/spells/commands/agent-command.js +55 -25
  93. package/dist/src/cli/suggest.js +1 -1
  94. package/dist/src/cli/swarm/swarm-persistence.js +112 -0
  95. package/dist/src/cli/swarm/unified-coordinator.js +118 -3
  96. package/dist/src/cli/version.js +1 -1
  97. package/package.json +5 -3
  98. package/dist/src/cli/mcp-tools/github-tools.js +0 -318
@@ -1,22 +1,29 @@
1
1
  # `http` — HTTP Requests
2
2
 
3
- **Purpose:** Use this connector to make HTTP requests to any URL from within agent steps. Choose this when you need to call REST APIs, fetch data, or post payloads during spell execution.
3
+ **Purpose:** Use this connector to make HTTP requests to any URL. Choose this when you need to call REST APIs, fetch data, or post payloads during spell execution.
4
4
 
5
5
  ## Usage
6
6
 
7
+ Unlike `playwright`/`github-cli`/`local-outlook`, this connector has **no dedicated step type**. Reach it from a composite (YAML) step's `tool` action:
8
+
7
9
  ```yaml
8
- - id: fetch-status
9
- type: agent
10
- config:
11
- prompt: |
12
- Use the http connector to check the deployment status.
13
- Call context.tools.execute('http', 'request', {
14
- method: 'GET',
15
- url: 'https://api.myapp.com/deploy/status',
16
- headers: { 'Authorization': 'Bearer {credentials.API_TOKEN}' }
17
- })
10
+ name: fetch-status
11
+ inputs:
12
+ token: { type: string }
13
+ actions:
14
+ - tool: http
15
+ action: request
16
+ params:
17
+ method: GET
18
+ url: "https://api.myapp.com/deploy/status"
19
+ headers:
20
+ Authorization: "Bearer ${inputs.token}"
18
21
  ```
19
22
 
23
+ …or from a custom step command via the Direct Usage API below. See `.claude/guidance/moflo-spell-custom-steps.md`.
24
+
25
+ > Earlier revisions of this file showed an `agent` step calling `context.tools.execute(...)` from a prompt. The `agent` step type has never been executable.
26
+
20
27
  ## Actions
21
28
 
22
29
  | Action | Parameters | Description |
@@ -4,18 +4,19 @@
4
4
 
5
5
  ## Usage
6
6
 
7
+ From a spell, reach this connector through the `outlook` step, which delegates to it:
8
+
7
9
  ```yaml
8
10
  - id: check-for-invoices
9
- type: agent
11
+ type: outlook
10
12
  config:
11
- prompt: |
12
- Use the local-outlook connector to search for recent invoices.
13
- Call context.tools.execute('local-outlook', 'search', {
14
- query: 'invoice from:billing@vendor.com',
15
- limit: 5
16
- })
13
+ action: search
14
+ query: "invoice from:billing@vendor.com"
15
+ limit: 5
17
16
  ```
18
17
 
18
+ > Earlier revisions of this file showed an `agent` step calling `context.tools.execute(...)` from a prompt. The `agent` step type has never been executable — use the `outlook` step, a composite step's `tool` action, or a custom step command.
19
+
19
20
  ## Actions
20
21
 
21
22
  | Action | Parameters | Description |
@@ -1,21 +1,23 @@
1
1
  # `playwright` — Browser Automation Connector
2
2
 
3
- **Purpose:** Use this connector for low-level browser automation via Playwright. Choose this when you need direct browser control from agent steps, or when building browser-based connectors like `local-outlook`.
3
+ **Purpose:** Use this connector for low-level browser automation via Playwright. Choose this when you need direct browser control from a step command, or when building browser-based connectors like `local-outlook`.
4
4
 
5
5
  ## Usage
6
6
 
7
+ From a spell, reach this connector through the `browser` step, which delegates to it:
8
+
7
9
  ```yaml
8
10
  - id: take-screenshot
9
- type: agent
11
+ type: browser
10
12
  config:
11
- prompt: |
12
- Use the playwright connector to screenshot the homepage.
13
- Call context.tools.execute('playwright', 'navigate', {
14
- url: 'https://myapp.com'
15
- })
16
- Then call context.tools.execute('playwright', 'screenshot', {})
13
+ actions:
14
+ - action: navigate
15
+ url: "https://myapp.com"
16
+ - action: screenshot
17
17
  ```
18
18
 
19
+ > Earlier revisions of this file showed an `agent` step calling `context.tools.execute(...)` from a prompt. The `agent` step type has never been executable — use the `browser` step, a composite step's `tool` action, or a custom step command.
20
+
19
21
  ## Actions
20
22
 
21
23
  | Action | Parameters | Description |
@@ -1,36 +1,40 @@
1
- # `agent` — Spawn a Claude Subagent
1
+ # `agent` — NOT EXECUTABLE
2
2
 
3
- **Purpose:** Use this step to delegate a task to a Claude subagent and capture its response. Choose this over `bash` when the task requires reasoning, code generation, or research rather than a deterministic command.
3
+ **Purpose:** Document that this step type does not work, so it is not offered when authoring a spell. It is registered but has never spawned a subagent — moflo has no agent spawner in the spell runner. Casting it always fails.
4
4
 
5
- ## Usage
5
+ **Do not add `agent` steps to a spell.** Previously the step returned `success: true` with `result: "Agent task prepared: <type>"`, so spells containing one completed green and downstream steps consumed that string as though it were agent output. It now fails with an explanatory error instead.
6
+
7
+ The type stays registered on purpose: removing it would turn a silently-useless step into a hard parse error for any existing spell YAML that contains one.
8
+
9
+ ## Use this instead
10
+
11
+ Run the Claude CLI from a `bash` step:
6
12
 
7
13
  ```yaml
8
14
  - id: research
9
- type: agent
15
+ type: bash
10
16
  config:
11
- agentType: researcher
12
- prompt: "Find all REST API endpoints in src/ and list their HTTP methods and paths"
13
- model: claude-sonnet-5
14
- background: false
17
+ command: 'claude -p "Find all REST API endpoints in src/ and list their HTTP methods and paths"'
18
+ timeout: 300000
15
19
  ```
16
20
 
17
- ## Config
21
+ This is what moflo's own shipped spells do — see `src/cli/spells/definitions/epic-auto-merge.yaml`.
22
+
23
+ **Note on cost:** a `bash` step invoking `claude -p` has no spend ceiling, which matters most for daemon-scheduled spells. Set a `timeout` and prefer `failOnError: true`.
24
+
25
+ ## If you are reading an older spell
26
+
27
+ | Old `agent` config | Replacement |
28
+ |---|---|
29
+ | `prompt` | the prompt text inside `claude -p "..."` |
30
+ | `agentType` | no equivalent — describe the role in the prompt |
31
+ | `background` | no equivalent — the bash step waits |
18
32
 
19
- | Field | Required | Default | Description |
20
- |-------|----------|---------|-------------|
21
- | `prompt` | Yes | — | Task prompt for the agent |
22
- | `agentType` | No | `coder` | Agent specialization: `researcher`, `coder`, `tester`, `reviewer` |
23
- | `model` | No | system default | Model override (e.g. `claude-sonnet-5`) |
24
- | `systemPrompt` | No | — | Custom system prompt replacing the default |
25
- | `background` | No | `false` | Run without waiting for the result |
33
+ `model` and `systemPrompt` were documented here previously but were never implemented — the step's `configSchema` only ever accepted `prompt`, `agentType`, and `background`.
26
34
 
27
35
  ## Outputs
28
36
 
29
- | Field | Type | Description |
30
- |-------|------|-------------|
31
- | `result` | string | Agent response text |
32
- | `agentType` | string | Agent type that was used |
33
- | `prompt` | string | Prompt that was sent |
37
+ None. The step always fails. `agentType` and `prompt` are echoed in the failure data for diagnosis only; there is no `result`.
34
38
 
35
39
  ## Source
36
40
 
@@ -14,7 +14,9 @@ Prove the current change **actually does what it was supposed to** before it shi
14
14
 
15
15
  ## What satisfies the gate
16
16
 
17
- Invoking this skill (name `verify`) trips the `record-verify-run` hook, which flips the `verifyRun` state the `check-before-done` gate reads. **Only `/verify` satisfies it** — `/ward` and `/quicken` are targeted audits, not an end-to-end verification. A source edit *after* verifying invalidates the result (the edit gate resets `verifyRun`), so run `/verify` as the last step before the PR.
17
+ Invoking this skill (name `verify`) trips the `record-verify-run` hook, which flips the `verifyRun` state. **That alone does not open the gate** (#1332): `check-before-done` also requires the recorded verdict to be `PASS`, which reaches it from the `metadata.overall` your Step 5 store writes. So a run returning FAIL leaves `gh pr create` blocked — as it should, since the change did not meet its criteria — and a run that stores prose without `metadata` counts as *no verdict* and blocks too.
18
+
19
+ **Only `/verify` satisfies it** — `/ward` and `/quicken` are targeted audits, not an end-to-end verification. A source edit *after* verifying invalidates both the flag and the verdict, so run `/verify` as the last step before the PR.
18
20
 
19
21
  ## Step 0 — Memory first
20
22
 
@@ -71,17 +73,36 @@ Overall verdict is PASS iff no row is FAIL or UNVERIFIED.
71
73
 
72
74
  ## Step 5 — Record the outcome to memory
73
75
 
74
- Always store the result (feeds routing/learning and the SDD trail):
76
+ Always store the result (feeds routing/learning and the SDD trail). Store it **twice over**: the prose summary in `value`, and the Step 4 table itself — unflattened — in `metadata`.
75
77
 
76
78
  ```
77
79
  mcp__moflo__memory_store {
78
80
  namespace: "learnings",
79
81
  key: "verify:<slug-or-issue>",
80
82
  value: "<overall PASS/FAIL> — per-criterion: <criterion → evidence → verdict>; commit <sha>",
81
- tags: ["verify", "sdd"]
83
+ tags: ["verify", "sdd"],
84
+ metadata: {
85
+ type: "verify-record",
86
+ issue: "<slug-or-issue>",
87
+ commit: "<sha>",
88
+ overall: "PASS" | "FAIL" | "UNVERIFIED",
89
+ verifiedAt: "<ISO-8601>",
90
+ criteria: [
91
+ { id: 1, statement: "<criterion>", verdict: "PASS" | "FAIL" | "UNVERIFIED",
92
+ evidence: "<test name / observed output>", freshlyExecuted: true }
93
+ ]
94
+ }
82
95
  }
83
96
  ```
84
97
 
98
+ **Why both.** `value` is what gets embedded for semantic search, so it stays prose — a JSON blob there would degrade every future `learnings` search. `metadata` is stored verbatim and not embedded, so the structure survives without that cost. `memory_retrieve` returns the parsed `metadata` object for non-chunk entries, which is what makes the record readable rather than write-only.
99
+
100
+ **`freshlyExecuted` is required on every criterion, not optional.** Step 3 deliberately permits *citing* an earlier green run instead of re-executing. That is a sound cost optimisation, but it means evidence may be a citation rather than a fresh result — set `freshlyExecuted: false` when you cited. Without that flag a later reader silently inherits stale evidence and cannot tell a re-verified criterion from a re-cited one.
101
+
102
+ **Never record an exit code.** Claude Code's `tool_response` for Bash carries `stdout`/`stderr` but **no exit status**, and PostToolUse does not fire at all when a command exits non-zero (#1322). `evidence` is therefore descriptive by necessity; a field named `exitCode` would be agent-narrated fiction, which is the problem this record exists to remove.
103
+
104
+ `overall` MUST agree with the Step 4 rule — PASS iff no criterion is FAIL or UNVERIFIED. Record a FAIL as a FAIL; the store is the audit trail, and a verdict that only ever reads PASS is worth nothing.
105
+
85
106
  ## Step 6 — Report
86
107
 
87
108
  One concise summary: overall verdict, the per-criterion table, and — on FAIL/UNVERIFIED — exactly which criteria are unproven and what evidence is missing. Do **not** open the PR or edit code from here; hand the verdict back to the caller.
package/bin/gate-hook.mjs CHANGED
@@ -32,14 +32,79 @@ if (hookContext.tool_name) env.TOOL_NAME = hookContext.tool_name;
32
32
  if (typeof hookContext.session_id === 'string' && hookContext.session_id) {
33
33
  env.HOOK_SESSION_ID = hookContext.session_id;
34
34
  }
35
+ // #1332: structured tool inputs are forwarded as JSON, not dropped.
36
+ //
37
+ // This previously forwarded ONLY string values, so any object-valued input was
38
+ // invisible to gate.cjs. That blocked the verify-before-done gate from reading
39
+ // `/verify`'s per-criterion verdict, which #1328 stores in memory_store's
40
+ // `metadata` — an object. Parsing the verdict out of the prose `value` string
41
+ // instead would re-create exactly the free-text dependency #1328 removed.
42
+ //
43
+ // Cross-platform (Rule #1): Windows caps a single environment variable at
44
+ // ~32KB and the whole block at ~32K wide chars, and exceeding it fails the
45
+ // spawn rather than truncating. Newly-forwarded values are therefore skipped
46
+ // when oversized, not clipped — a truncated JSON blob would parse as malformed
47
+ // on the far side and read as a corrupt record rather than an absent one.
48
+ // `metadata` is capped at 64KB by memory_store, so a real verdict never nears
49
+ // this. STRING values keep their previous uncapped behaviour byte-for-byte:
50
+ // gate.cjs reads TOOL_INPUT_command, and dropping an oversized heredoc command
51
+ // would silently stop check-dangerous-command from firing on the exact inputs
52
+ // most worth checking.
53
+ var MAX_STRUCTURED_LEN = 16384;
35
54
  if (hookContext.tool_input && typeof hookContext.tool_input === 'object') {
36
55
  Object.keys(hookContext.tool_input).forEach(function(key) {
37
- if (typeof hookContext.tool_input[key] === 'string') {
38
- env['TOOL_INPUT_' + key] = hookContext.tool_input[key];
56
+ var raw = hookContext.tool_input[key];
57
+ if (typeof raw === 'string') {
58
+ env['TOOL_INPUT_' + key] = raw;
59
+ return;
39
60
  }
61
+ var val;
62
+ if (typeof raw === 'number' || typeof raw === 'boolean') {
63
+ val = String(raw);
64
+ } else if (raw && typeof raw === 'object') {
65
+ try { val = JSON.stringify(raw); } catch (e) { return; }
66
+ } else {
67
+ return; // null/undefined/function — nothing meaningful to forward
68
+ }
69
+ if (val.length > MAX_STRUCTURED_LEN) return;
70
+ env['TOOL_INPUT_' + key] = val;
40
71
  });
41
72
  }
42
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
+
43
108
  // Run gate.cjs with the enriched environment
44
109
  var projectDir = (env.CLAUDE_PROJECT_DIR || process.cwd()).replace(/^\/([a-z])\//i, '$1:/');
45
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
  }
@@ -865,30 +938,92 @@ switch (command) {
865
938
  // crediting them would let the gate pass without an end-to-end verify.
866
939
  if (vName === 'verify') {
867
940
  var s = readState();
868
- if (!s.verifyRun) { s.verifyRun = true; writeState(s); }
941
+ // #1332: invoking /verify starts a verification; it does not conclude
942
+ // one. Clear any prior verdict so the run in progress cannot inherit the
943
+ // PASS from a previous issue and satisfy check-before-done on its own.
944
+ if (!s.verifyRun || s.verifyOutcome) {
945
+ s.verifyRun = true;
946
+ s.verifyOutcome = null;
947
+ writeState(s);
948
+ }
869
949
  } else if (vName) {
870
950
  process.stderr.write('gate: record-verify-run no-op — TOOL_INPUT_skill="' + vName + '" is not verify\n');
871
951
  }
872
952
  break;
873
953
  }
954
+ case 'record-verify-outcome': {
955
+ // #1332. Fires PostToolUse on mcp__moflo__memory_store. `record-verify-run`
956
+ // above proves a verification was ATTEMPTED; this proves how it ENDED.
957
+ //
958
+ // The verdict is read from the structured record #1328 made /verify write
959
+ // to memory_store's `metadata` — never parsed out of the prose `value`,
960
+ // which is precisely the free-text dependency #1328 removed. gate-hook.mjs
961
+ // forwards the object as JSON (see its MAX_STRUCTURED_LEN note).
962
+ var mKey = process.env.TOOL_INPUT_key || '';
963
+ if (mKey.indexOf('verify:') !== 0) break;
964
+ var rawMeta = process.env.TOOL_INPUT_metadata || '';
965
+ if (!rawMeta) {
966
+ process.stderr.write('gate: record-verify-outcome — "' + mKey + '" carries no metadata; verdict not recorded\n');
967
+ break;
968
+ }
969
+ var parsedMeta = null;
970
+ try { parsedMeta = JSON.parse(rawMeta); } catch (e) { parsedMeta = null; }
971
+ if (!parsedMeta || typeof parsedMeta !== 'object' || parsedMeta.type !== 'verify-record') break;
972
+ var overall = typeof parsedMeta.overall === 'string' ? parsedMeta.overall.toUpperCase() : '';
973
+ if (overall !== 'PASS' && overall !== 'FAIL' && overall !== 'UNVERIFIED') {
974
+ process.stderr.write('gate: record-verify-outcome — unrecognised overall="' + parsedMeta.overall + '"; treating as not-passing\n');
975
+ overall = 'UNVERIFIED';
976
+ }
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
+ }
989
+ vs.verifyOutcome = overall;
990
+ writeState(vs);
991
+ break;
992
+ }
874
993
  case 'reset-edit-gates': {
875
994
  var fp = process.env.TOOL_INPUT_file_path || '';
876
995
  // Inert files (markdown, lockfiles, CHANGELOG, .env.example) AND inert paths
877
996
  // (.github/workflows/, .github/ISSUE_TEMPLATE/, .github/PULL_REQUEST_TEMPLATE/, #1176):
878
997
  // no gate reset — editing these doesn't expose new runtime surface.
879
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;
880
1010
  var s = readState();
881
1011
  // Test-only edits invalidate testsRun but preserve simplifyRun (#908).
882
1012
  var isTestOnly = fp && EDIT_RESET_SKIP_SIMPLIFY_ONLY_RE.test(fp);
883
1013
  var resetTests = s.testsRun;
884
1014
  // A code edit invalidates a prior verification (Story #1274) — same as tests,
885
1015
  // including test-only edits (the criteria being verified may have moved).
886
- var resetVerify = s.verifyRun;
1016
+ // #1332: also fires when a verdict lingers without the flag, so no path
1017
+ // can leave a recorded outcome behind after a source edit.
1018
+ var resetVerify = s.verifyRun || !!s.verifyOutcome;
887
1019
  var resetSimplify = s.simplifyRun && !isTestOnly;
888
1020
  if (!resetTests && !resetSimplify && !resetVerify) break;
889
1021
  var gates = [];
890
1022
  if (resetTests) { s.testsRun = false; gates.push('tests'); }
891
- if (resetVerify) { s.verifyRun = false; gates.push('verify'); }
1023
+ // #1332: drop the recorded verdict with the flag. Leaving a stale PASS
1024
+ // behind would let the next check-before-done pass on a verdict that
1025
+ // describes pre-edit code.
1026
+ if (resetVerify) { s.verifyRun = false; s.verifyOutcome = null; gates.push('verify'); }
892
1027
  if (resetSimplify) { s.simplifyRun = false; gates.push('simplify'); }
893
1028
  if (fp) {
894
1029
  s.lastResetBy = { file: fp, at: new Date().toISOString(), gates: gates };
@@ -978,7 +1113,7 @@ switch (command) {
978
1113
  }
979
1114
  }
980
1115
  var missing = [];
981
- 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)');
982
1117
  if (config.simplify_gate && !s.simplifyRun) missing.push('/flo-simplify (or /distill) has not run since the last code edit');
983
1118
  if (config.learnings_gate && !s.learningsStored) missing.push('learnings have not been stored (call mcp__moflo__memory_store)');
984
1119
  if (missing.length === 0) break;
@@ -989,6 +1124,10 @@ switch (command) {
989
1124
  if (s.lastResetBy && s.lastResetBy.file) {
990
1125
  process.stderr.write('Last gate reset: ' + s.lastResetBy.file + ' (' + (s.lastResetBy.gates || []).join(', ') + ')\n');
991
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);
992
1131
  process.stderr.write('Disable per-gate via moflo.yaml:\n');
993
1132
  process.stderr.write(' gates:\n testing_gate: false\n simplify_gate: false\n learnings_gate: false\n');
994
1133
  process.exit(2);
@@ -1017,12 +1156,34 @@ switch (command) {
1017
1156
  }
1018
1157
  }
1019
1158
  var sd = readState();
1020
- if (sd.verifyRun) break;
1159
+ // #1332: gate on the OUTCOME, not on attendance. Before this, `verifyRun`
1160
+ // alone opened the gate, so a /verify returning FAIL satisfied it exactly
1161
+ // as a PASS did — a failing verdict is still a successful tool invocation.
1162
+ if (sd.verifyRun && sd.verifyOutcome === 'PASS') break;
1021
1163
  process.stderr.write('BLOCKED: gh pr create requires verification before done:\n');
1022
- process.stderr.write(' - the change has not been verified since the last code edit (run /verify)\n');
1023
- if (sd.lastResetBy && sd.lastResetBy.file && (sd.lastResetBy.gates || []).indexOf('verify') >= 0) {
1164
+ // The four states need different remedies, so name which one applies
1165
+ // rather than emitting one message that fits none of them.
1166
+ var invalidated = sd.lastResetBy && sd.lastResetBy.file
1167
+ && (sd.lastResetBy.gates || []).indexOf('verify') >= 0;
1168
+ if (!sd.verifyRun && invalidated) {
1169
+ process.stderr.write(' - a code edit invalidated the previous verification — re-run /verify\n');
1024
1170
  process.stderr.write('Last gate reset: ' + sd.lastResetBy.file + ' (verify)\n');
1171
+ } else if (!sd.verifyRun) {
1172
+ process.stderr.write(' - the change has not been verified since the last code edit (run /verify)\n');
1173
+ } else if (sd.verifyOutcome === 'FAIL' || sd.verifyOutcome === 'UNVERIFIED') {
1174
+ process.stderr.write(' - /verify ran and returned ' + sd.verifyOutcome + ' — fix the failing criteria, then re-run /verify\n');
1175
+ process.stderr.write(' (a FAIL is a real result, not a gate error; the PR is blocked because the change did not meet its acceptance criteria)\n');
1176
+ } else {
1177
+ // Ran, but no verdict reached the gate: /verify was invoked and never
1178
+ // recorded a structured outcome (interrupted, or it stored prose only).
1179
+ process.stderr.write(' - /verify ran but recorded no verdict — re-run it so it stores a structured result\n');
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');
1025
1185
  }
1186
+ process.stderr.write(ORDER_HINT);
1026
1187
  process.stderr.write('Disable via moflo.yaml:\n');
1027
1188
  process.stderr.write(' gates:\n verify_before_done: false\n');
1028
1189
  process.exit(2);