moflo 4.9.17 → 4.9.19

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.
@@ -34,20 +34,15 @@ Pick the smallest tier the diff genuinely fits.
34
34
 
35
35
  Critical-surface files (launcher, hooks, MCP wiring) raise the *care* of the agent prompt — sharper checklist, blast-radius framing — they do **not** automatically escalate to NORMAL. Risk-weighted ≠ headcount-weighted.
36
36
 
37
- ## Phase 3: Route the model (skip for TRIVIAL)
37
+ ## Phase 3: Use the classifier's model (skip for TRIVIAL)
38
38
 
39
- Before spawning any Agent, ask the moflo router which model to use:
39
+ The classifier returns the right model for the tier no separate router call needed:
40
40
 
41
- ```
42
- mcp__moflo__hooks_model-route{
43
- task: "Review N-line change in <files> for reuse, quality, efficiency",
44
- preferCost: true
45
- }
46
- ```
41
+ - `sonnet` (default) — real logic edits, single agent or 3-agent fan-out.
42
+ - `haiku` mostly-relocation diffs (mechanical moves; pattern-matching beats deep reasoning).
43
+ - `opus` never returned. Code review is breadth-bound, not depth-bound; sonnet 3-way IS the high-effort tier.
47
44
 
48
- **Wording rules:** the router's complexity score is keyword-sensitive. Avoid `refactor`, `architect`, `audit`, `system`, `redesign`, `migrate` those force opus even when scoring suggests sonnet. State LOC count, file count, and "review for reuse, quality, efficiency". Nothing more.
49
-
50
- **Hard rule for `/simplify`: opus is never correct.** Code review never needs Opus reasoning, even on critical surface. If the router returns `opus`, downgrade to `sonnet`. On router failure, default to `sonnet`. Comment trims and pure formatting → `haiku`.
45
+ Pass the classifier's `model` field verbatim to Agent's `model` parameter. If you fell back to prose rules in Phase 2 (no classifier), default to `sonnet`.
51
46
 
52
47
  ## Phase 4: Validation pass (re-run after fixes from a prior simplify)
53
48
 
@@ -57,8 +52,11 @@ Escalate one tier (self-review → SMALL agent) only if the fix introduced a new
57
52
 
58
53
  ## Phase 5: Run the appropriate review
59
54
 
60
- ### TRIVIAL / Validation
61
- Run the three category checks (reuse / quality / efficiency) yourself in one pass against the diff. Most TRIVIAL diffs are clean confirm and exit. Budget: ~30 seconds, no Agent.
55
+ ### TRIVIAL
56
+ Print one confirmation line (`simplify: TRIVIAL N LOC, 1 file — stamped`) and exit. **Do not** walk the three-category check; the classifier already concluded the diff is below the review-value threshold. Budget: <5 seconds, no Agent.
57
+
58
+ ### Validation pass
59
+ Run the three category checks against the post-fix diff in one pass. Most are clean — confirm and exit. Budget: ~30 seconds, no Agent.
62
60
 
63
61
  ### SMALL — one agent
64
62
  ```
@@ -2,11 +2,12 @@
2
2
  'use strict';
3
3
  var fs = require('fs');
4
4
  var path = require('path');
5
+ var cp = require('child_process');
5
6
 
6
7
  var PROJECT_DIR = (process.env.CLAUDE_PROJECT_DIR || process.cwd()).replace(/^\/([a-z])\//i, '$1:/');
7
8
  var STATE_FILE = path.join(PROJECT_DIR, '.claude', 'workflow-state.json');
8
9
 
9
- var STATE_DEFAULTS = { tasksCreated: false, taskCount: 0, memorySearched: false, memorySearchedBy: {}, memoryRequired: true, learningsStored: false, testsRun: false, simplifyRun: false, interactionCount: 0, sessionStart: null, lastBlockedAt: null };
10
+ var STATE_DEFAULTS = { tasksCreated: false, taskCount: 0, memorySearched: false, memorySearchedBy: {}, memoryRequired: true, learningsStored: false, testsRun: false, simplifyRun: false, simplifySnapshotSha: null, interactionCount: 0, sessionStart: null, lastBlockedAt: null };
10
11
 
11
12
  // Per-actor memory-search tracking (#838). The legacy `memorySearched` boolean
12
13
  // is session-wide, so once the parent searches memory, every spawned subagent
@@ -93,6 +94,109 @@ var EDIT_RESET_SKIP_BOTH_RE = /\.(md|markdown|txt|rst|adoc|lock|gitignore)$|(?:^
93
94
  // but NOT the simplify gate — /simplify already reviewed the production code; touching
94
95
  // a test file or fixture doesn't expose new untested surface for code review (#908).
95
96
  var EDIT_RESET_SKIP_SIMPLIFY_ONLY_RE = /(?:^|[\\\/])(__tests__|__mocks__|tests?|spec|specs|cypress|e2e|fixtures?)[\\\/]|\.(test|spec)\.[mc]?[jt]sx?$|\.fixture\.[mc]?[jt]sx?$/i;
97
+ // Docs-only PR exemption: text/markup/image extensions that cannot change runtime behaviour.
98
+ // If EVERY file in the PR diff matches this, skip testing/simplify/learnings gates.
99
+ // Anchored to end-of-path so e.g. `foo.md.js` does not match. Excludes lock files / configs
100
+ // on purpose — those are inert for edit-reset (above) but not "documentation".
101
+ var DOCS_ONLY_RE = /\.(md|markdown|txt|rst|adoc|html?|pdf|png|jpe?g|gif|svg|webp|ico|bmp)$/i;
102
+
103
+ // Classifier-aware simplify gate skip. Returns a string reason if the gate
104
+ // can be auto-passed, or null if /simplify must run. Uses simplify-classify.cjs
105
+ // so the gate's "trivial" definition matches the skill's exactly.
106
+ //
107
+ // Two paths:
108
+ // 1. snapshot path — /simplify ran earlier on this branch. Classify the diff
109
+ // between simplifySnapshotSha and current HEAD/working-tree. If TRIVIAL,
110
+ // the prior review still covers the branch — no re-run needed.
111
+ // 2. baseline path — no snapshot (first time). Classify the entire branch
112
+ // diff vs merge-base. If TRIVIAL, the whole PR is below the threshold
113
+ // where /simplify provides value — auto-pass without ever invoking it.
114
+ //
115
+ // Fail-safe: any error (no classifier, no git, no merge-base) returns null,
116
+ // which forces /simplify to run as today.
117
+ function classifyForGateSkip(state) {
118
+ var classify;
119
+ try {
120
+ classify = require('./simplify-classify.cjs').classifyDiff;
121
+ } catch (e) { return null; }
122
+ if (typeof classify !== 'function') return null;
123
+
124
+ function tryClassify(diffText, label) {
125
+ try {
126
+ var dec = classify(diffText);
127
+ if (dec.tier === 'TRIVIAL') {
128
+ var loc = (dec.stats.added || 0) + (dec.stats.deleted || 0);
129
+ return label + ' is TRIVIAL (' + loc + ' LOC, ' + (dec.stats.fileCount || 0) + ' file(s))';
130
+ }
131
+ } catch (e) { /* fall through */ }
132
+ return null;
133
+ }
134
+
135
+ function gitDiff(args) {
136
+ try {
137
+ return cp.execFileSync('git', args, {
138
+ cwd: PROJECT_DIR, encoding: 'utf-8', timeout: 5000, windowsHide: true,
139
+ stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 8 * 1024 * 1024
140
+ });
141
+ } catch (e) { return null; }
142
+ }
143
+
144
+ // Snapshot path: classify everything since /simplify last ran.
145
+ if (state.simplifySnapshotSha) {
146
+ var snapDiff = gitDiff(['diff', state.simplifySnapshotSha + '...HEAD']);
147
+ var workTreeA = gitDiff(['diff', 'HEAD']) || '';
148
+ if (snapDiff !== null) {
149
+ var combined = snapDiff + (workTreeA ? '\n' + workTreeA : '');
150
+ var hit = tryClassify(combined, 'delta since last /simplify');
151
+ if (hit) return hit;
152
+ }
153
+ }
154
+
155
+ // Baseline path: classify the whole branch vs merge-base.
156
+ var bases = ['origin/main', 'main', 'origin/master', 'master'];
157
+ for (var i = 0; i < bases.length; i++) {
158
+ var base;
159
+ try {
160
+ base = cp.execFileSync('git', ['merge-base', 'HEAD', bases[i]], {
161
+ cwd: PROJECT_DIR, encoding: 'utf-8', timeout: 2000, windowsHide: true,
162
+ stdio: ['ignore', 'pipe', 'ignore']
163
+ }).trim();
164
+ } catch (e) { continue; }
165
+ if (!base) continue;
166
+ var branchDiff = gitDiff(['diff', base + '...HEAD']);
167
+ var workTreeB = gitDiff(['diff', 'HEAD']) || '';
168
+ if (branchDiff !== null) {
169
+ return tryClassify(branchDiff + (workTreeB ? '\n' + workTreeB : ''), 'branch diff');
170
+ }
171
+ break;
172
+ }
173
+ return null;
174
+ }
175
+
176
+ // Get the file list changed on the current branch vs the merge-base with origin/main
177
+ // (falling back to local main). Returns an array of repo-relative paths, or null on
178
+ // failure — in which case callers MUST fall through to the standard gate (fail-safe).
179
+ function getChangedFilesVsBase() {
180
+ var bases = ['origin/main', 'main', 'origin/master', 'master'];
181
+ var base = null;
182
+ for (var i = 0; i < bases.length; i++) {
183
+ try {
184
+ base = cp.execFileSync('git', ['merge-base', 'HEAD', bases[i]], {
185
+ cwd: PROJECT_DIR, encoding: 'utf-8', timeout: 2000, windowsHide: true,
186
+ stdio: ['ignore', 'pipe', 'ignore']
187
+ }).trim();
188
+ if (base) break;
189
+ } catch (e) { /* try next */ }
190
+ }
191
+ if (!base) return null;
192
+ try {
193
+ var out = cp.execFileSync('git', ['diff', '--name-only', base + '...HEAD'], {
194
+ cwd: PROJECT_DIR, encoding: 'utf-8', timeout: 2000, windowsHide: true,
195
+ stdio: ['ignore', 'pipe', 'ignore']
196
+ });
197
+ return out.split('\n').map(function(l) { return l.trim(); }).filter(Boolean);
198
+ } catch (e) { return null; }
199
+ }
96
200
 
97
201
  switch (command) {
98
202
  case 'check-before-agent': {
@@ -175,10 +279,19 @@ switch (command) {
175
279
  case 'record-skill-run': {
176
280
  if ((process.env.TOOL_INPUT_skill || '') === 'simplify') {
177
281
  var s = readState();
178
- if (!s.simplifyRun) {
179
- s.simplifyRun = true;
180
- writeState(s);
181
- }
282
+ var changed = false;
283
+ if (!s.simplifyRun) { s.simplifyRun = true; changed = true; }
284
+ // Snapshot HEAD so check-before-pr can classify delta-since-simplify and
285
+ // skip a redundant /simplify re-run when only trivial fixes followed.
286
+ // Non-fatal — gate falls through to current behaviour without the snapshot.
287
+ try {
288
+ var sha = cp.execFileSync('git', ['rev-parse', 'HEAD'], {
289
+ cwd: PROJECT_DIR, encoding: 'utf-8', timeout: 2000, windowsHide: true,
290
+ stdio: ['ignore', 'pipe', 'ignore']
291
+ }).trim();
292
+ if (sha && s.simplifySnapshotSha !== sha) { s.simplifySnapshotSha = sha; changed = true; }
293
+ } catch (e) { /* no git or detached state — skip snapshot, gate still works */ }
294
+ if (changed) writeState(s);
182
295
  }
183
296
  break;
184
297
  }
@@ -208,7 +321,29 @@ switch (command) {
208
321
  // optional ENV=val prefix segment catches `GH_TOKEN=x gh pr create`.
209
322
  var cmd = process.env.TOOL_INPUT_command || '';
210
323
  if (!/(?:^|&&\s*|\|\|\s*|;\s*)\s*(?:[A-Z_][A-Z0-9_]*=\S+\s+)*gh\s+pr\s+create\b/.test(cmd)) break;
324
+ // Docs-only exemption: if every file changed vs the merge-base is a docs/image
325
+ // file (no runtime-behaviour surface), skip the testing/simplify/learnings gates
326
+ // and surface a one-line transparency note. Falls through to the standard gate
327
+ // on any failure (no base, no diff, exec error) — fail-safe by design.
328
+ var changed = getChangedFilesVsBase();
329
+ if (changed && changed.length > 0 && changed.every(function(f) { return DOCS_ONLY_RE.test(f); })) {
330
+ process.stdout.write('Docs-only PR (' + changed.length + ' file' + (changed.length === 1 ? '' : 's') + ') — skipping testing/simplify/learnings gates.\n');
331
+ break;
332
+ }
211
333
  var s = readState();
334
+ // Classifier-aware skip: if delta-since-snapshot or whole-branch diff is
335
+ // TRIVIAL, satisfy the simplify gate silently. Reuses the same classifier
336
+ // the skill uses — same "trivial" definition, no drift. Same threshold that
337
+ // already maps to TRIVIAL=0 agents inside /simplify, so trusting it at the
338
+ // gate level is the same trust profile, just one decision earlier.
339
+ if (config.simplify_gate && !s.simplifyRun) {
340
+ var skipReason = classifyForGateSkip(s);
341
+ if (skipReason) {
342
+ s.simplifyRun = true;
343
+ writeState(s);
344
+ process.stdout.write('Simplify gate auto-passed: ' + skipReason + '\n');
345
+ }
346
+ }
212
347
  var missing = [];
213
348
  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)');
214
349
  if (config.simplify_gate && !s.simplifyRun) missing.push('/simplify has not run since the last code edit');
@@ -159,7 +159,10 @@ function decide(stats) {
159
159
  reasoning.push(
160
160
  `mostly relocation: ${stats.declAdded} decls added, ${stats.declRemoved} removed, net ${stats.netDecls >= 0 ? '+' : ''}${stats.netDecls}`,
161
161
  );
162
- return { tier: 'SMALL', model: 'sonnet', agentCount: 1, reasoning, stats };
162
+ // Haiku is sufficient for mechanical moves: code already existed and worked,
163
+ // so review reduces to copy-paste-divergence / dead-after-move pattern checks
164
+ // — exactly haiku's strength. ~5x cheaper than sonnet on relocation-shape diffs.
165
+ return { tier: 'SMALL', model: 'haiku', agentCount: 1, reasoning, stats };
163
166
  }
164
167
 
165
168
  // Escalation triggers — any one trips NORMAL (3 agents).
@@ -42,13 +42,8 @@ Tier definitions the classifier encodes (for reference, not for re-derivation):
42
42
 
43
43
  Pick the **smallest tier** the diff genuinely fits. When in doubt, escalate one step (not two).
44
44
 
45
- ### TRIVIAL — self-review, no agent spawn
46
- ALL of these must hold:
47
- - ≤10 net LOC changed (insertions + deletions, excluding pure whitespace)
48
- - Single file
49
- - No logic changes — only comments, formatting, renames of local vars, JSDoc, or string-literal edits
50
- - No new imports, no new exports, no new function/class declarations
51
- - No removed safety checks, error handlers, or guards
45
+ ### TRIVIAL — gate stamp, no review
46
+ The classifier already proved the diff is below the threshold where review provides value (≤10 net LOC, single file, no declaration/import/export changes, no removed guards). **Stamp the gate with one line of confirmation and exit immediately.** Do not walk the three-category check yourself — the classifier IS the review at this tier, and at the gate level the classifier-aware skip (`bin/gate.cjs:classifyForGateSkip`) often satisfies the gate without invoking this skill at all.
52
47
 
53
48
  Examples that qualify: trimming a comment, fixing a typo in a log message, renaming a private helper, reformatting a single block.
54
49
  Examples that DON'T qualify: changing an `if` condition, reordering function args, deleting a try/catch.
@@ -88,14 +83,21 @@ Do **not** escalate to NORMAL on a validation pass. If the fix is so structural
88
83
 
89
84
  ## Phase 2.7: Model selection
90
85
 
91
- **Use the model the classifier returned** always `sonnet` for `/simplify`. Opus is never correct here; the classifier enforces this. No router call needed; the classifier IS the router for this skill.
86
+ **Use the model the classifier returned.** The classifier IS the router for this skill no separate router call needed. Possible outputs:
87
+
88
+ - `sonnet` (default) — real logic changes, single agent or three-agent fan-out.
89
+ - `haiku` — mostly-relocation diffs (mechanical moves where pattern-matching beats deep reasoning, ~5x cheaper).
90
+ - `opus` — never. Code review is breadth-bound, not depth-bound; the three-agent fan-out at sonnet is the high-effort tier.
92
91
 
93
- If you fell back to prose rules in Phase 2 (no classifier available), use `sonnet` unconditionally. Pass the model verbatim to Agent's `model` parameter.
92
+ If you fell back to prose rules in Phase 2 (no classifier available), use `sonnet` unconditionally. Pass the classifier's `model` field verbatim to Agent's `model` parameter.
94
93
 
95
94
  ## Phase 3: Run the appropriate review
96
95
 
97
- ### TRIVIAL / Validation: self-review
98
- Run the same three category checks (reuse / quality / efficiency) yourself, in one pass, against the diff. Most TRIVIAL and validation diffs will be clean the goal is to confirm, not to fan out. If you find an issue, fix it; otherwise stamp clean. Total budget: ~30 seconds, no Agent calls. No router call needed.
96
+ ### TRIVIAL: stamp and exit
97
+ The classifier proved the diff is below the review-value threshold. Print one confirmation line (e.g. `simplify: TRIVIAL N LOC, 1 filestamped`) and exit. **Do not** walk the three-category check; the classifier already concluded the answer is "clean." Budget: <5 seconds, no Agent calls.
98
+
99
+ ### Validation pass: confirm clean
100
+ Run the three category checks (reuse / quality / efficiency) yourself, in one pass, against the post-fix diff. Most validation passes are clean — the goal is to confirm fixes didn't introduce new concerns, not to fan out. Budget: ~30 seconds, no Agent calls.
99
101
 
100
102
  ### SMALL: one agent (model from router)
101
103
  Launch a SINGLE Agent with subagent_type `reviewer`, passing the model returned by Phase 2.7's router call. Cap the agent's tool budget by being explicit:
package/bin/gate.cjs CHANGED
@@ -2,11 +2,12 @@
2
2
  'use strict';
3
3
  var fs = require('fs');
4
4
  var path = require('path');
5
+ var cp = require('child_process');
5
6
 
6
7
  var PROJECT_DIR = (process.env.CLAUDE_PROJECT_DIR || process.cwd()).replace(/^\/([a-z])\//i, '$1:/');
7
8
  var STATE_FILE = path.join(PROJECT_DIR, '.claude', 'workflow-state.json');
8
9
 
9
- var STATE_DEFAULTS = { tasksCreated: false, taskCount: 0, memorySearched: false, memorySearchedBy: {}, memoryRequired: true, learningsStored: false, testsRun: false, simplifyRun: false, interactionCount: 0, sessionStart: null, lastBlockedAt: null };
10
+ var STATE_DEFAULTS = { tasksCreated: false, taskCount: 0, memorySearched: false, memorySearchedBy: {}, memoryRequired: true, learningsStored: false, testsRun: false, simplifyRun: false, simplifySnapshotSha: null, interactionCount: 0, sessionStart: null, lastBlockedAt: null };
10
11
 
11
12
  // Per-actor memory-search tracking (#838). The legacy `memorySearched` boolean
12
13
  // is session-wide, so once the parent searches memory, every spawned subagent
@@ -93,6 +94,109 @@ var EDIT_RESET_SKIP_BOTH_RE = /\.(md|markdown|txt|rst|adoc|lock|gitignore)$|(?:^
93
94
  // but NOT the simplify gate — /simplify already reviewed the production code; touching
94
95
  // a test file or fixture doesn't expose new untested surface for code review (#908).
95
96
  var EDIT_RESET_SKIP_SIMPLIFY_ONLY_RE = /(?:^|[\\\/])(__tests__|__mocks__|tests?|spec|specs|cypress|e2e|fixtures?)[\\\/]|\.(test|spec)\.[mc]?[jt]sx?$|\.fixture\.[mc]?[jt]sx?$/i;
97
+ // Docs-only PR exemption: text/markup/image extensions that cannot change runtime behaviour.
98
+ // If EVERY file in the PR diff matches this, skip testing/simplify/learnings gates.
99
+ // Anchored to end-of-path so e.g. `foo.md.js` does not match. Excludes lock files / configs
100
+ // on purpose — those are inert for edit-reset (above) but not "documentation".
101
+ var DOCS_ONLY_RE = /\.(md|markdown|txt|rst|adoc|html?|pdf|png|jpe?g|gif|svg|webp|ico|bmp)$/i;
102
+
103
+ // Classifier-aware simplify gate skip. Returns a string reason if the gate
104
+ // can be auto-passed, or null if /simplify must run. Uses simplify-classify.cjs
105
+ // so the gate's "trivial" definition matches the skill's exactly.
106
+ //
107
+ // Two paths:
108
+ // 1. snapshot path — /simplify ran earlier on this branch. Classify the diff
109
+ // between simplifySnapshotSha and current HEAD/working-tree. If TRIVIAL,
110
+ // the prior review still covers the branch — no re-run needed.
111
+ // 2. baseline path — no snapshot (first time). Classify the entire branch
112
+ // diff vs merge-base. If TRIVIAL, the whole PR is below the threshold
113
+ // where /simplify provides value — auto-pass without ever invoking it.
114
+ //
115
+ // Fail-safe: any error (no classifier, no git, no merge-base) returns null,
116
+ // which forces /simplify to run as today.
117
+ function classifyForGateSkip(state) {
118
+ var classify;
119
+ try {
120
+ classify = require('./simplify-classify.cjs').classifyDiff;
121
+ } catch (e) { return null; }
122
+ if (typeof classify !== 'function') return null;
123
+
124
+ function tryClassify(diffText, label) {
125
+ try {
126
+ var dec = classify(diffText);
127
+ if (dec.tier === 'TRIVIAL') {
128
+ var loc = (dec.stats.added || 0) + (dec.stats.deleted || 0);
129
+ return label + ' is TRIVIAL (' + loc + ' LOC, ' + (dec.stats.fileCount || 0) + ' file(s))';
130
+ }
131
+ } catch (e) { /* fall through */ }
132
+ return null;
133
+ }
134
+
135
+ function gitDiff(args) {
136
+ try {
137
+ return cp.execFileSync('git', args, {
138
+ cwd: PROJECT_DIR, encoding: 'utf-8', timeout: 5000, windowsHide: true,
139
+ stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 8 * 1024 * 1024
140
+ });
141
+ } catch (e) { return null; }
142
+ }
143
+
144
+ // Snapshot path: classify everything since /simplify last ran.
145
+ if (state.simplifySnapshotSha) {
146
+ var snapDiff = gitDiff(['diff', state.simplifySnapshotSha + '...HEAD']);
147
+ var workTreeA = gitDiff(['diff', 'HEAD']) || '';
148
+ if (snapDiff !== null) {
149
+ var combined = snapDiff + (workTreeA ? '\n' + workTreeA : '');
150
+ var hit = tryClassify(combined, 'delta since last /simplify');
151
+ if (hit) return hit;
152
+ }
153
+ }
154
+
155
+ // Baseline path: classify the whole branch vs merge-base.
156
+ var bases = ['origin/main', 'main', 'origin/master', 'master'];
157
+ for (var i = 0; i < bases.length; i++) {
158
+ var base;
159
+ try {
160
+ base = cp.execFileSync('git', ['merge-base', 'HEAD', bases[i]], {
161
+ cwd: PROJECT_DIR, encoding: 'utf-8', timeout: 2000, windowsHide: true,
162
+ stdio: ['ignore', 'pipe', 'ignore']
163
+ }).trim();
164
+ } catch (e) { continue; }
165
+ if (!base) continue;
166
+ var branchDiff = gitDiff(['diff', base + '...HEAD']);
167
+ var workTreeB = gitDiff(['diff', 'HEAD']) || '';
168
+ if (branchDiff !== null) {
169
+ return tryClassify(branchDiff + (workTreeB ? '\n' + workTreeB : ''), 'branch diff');
170
+ }
171
+ break;
172
+ }
173
+ return null;
174
+ }
175
+
176
+ // Get the file list changed on the current branch vs the merge-base with origin/main
177
+ // (falling back to local main). Returns an array of repo-relative paths, or null on
178
+ // failure — in which case callers MUST fall through to the standard gate (fail-safe).
179
+ function getChangedFilesVsBase() {
180
+ var bases = ['origin/main', 'main', 'origin/master', 'master'];
181
+ var base = null;
182
+ for (var i = 0; i < bases.length; i++) {
183
+ try {
184
+ base = cp.execFileSync('git', ['merge-base', 'HEAD', bases[i]], {
185
+ cwd: PROJECT_DIR, encoding: 'utf-8', timeout: 2000, windowsHide: true,
186
+ stdio: ['ignore', 'pipe', 'ignore']
187
+ }).trim();
188
+ if (base) break;
189
+ } catch (e) { /* try next */ }
190
+ }
191
+ if (!base) return null;
192
+ try {
193
+ var out = cp.execFileSync('git', ['diff', '--name-only', base + '...HEAD'], {
194
+ cwd: PROJECT_DIR, encoding: 'utf-8', timeout: 2000, windowsHide: true,
195
+ stdio: ['ignore', 'pipe', 'ignore']
196
+ });
197
+ return out.split('\n').map(function(l) { return l.trim(); }).filter(Boolean);
198
+ } catch (e) { return null; }
199
+ }
96
200
 
97
201
  switch (command) {
98
202
  case 'check-before-agent': {
@@ -175,10 +279,19 @@ switch (command) {
175
279
  case 'record-skill-run': {
176
280
  if ((process.env.TOOL_INPUT_skill || '') === 'simplify') {
177
281
  var s = readState();
178
- if (!s.simplifyRun) {
179
- s.simplifyRun = true;
180
- writeState(s);
181
- }
282
+ var changed = false;
283
+ if (!s.simplifyRun) { s.simplifyRun = true; changed = true; }
284
+ // Snapshot HEAD so check-before-pr can classify delta-since-simplify and
285
+ // skip a redundant /simplify re-run when only trivial fixes followed.
286
+ // Non-fatal — gate falls through to current behaviour without the snapshot.
287
+ try {
288
+ var sha = cp.execFileSync('git', ['rev-parse', 'HEAD'], {
289
+ cwd: PROJECT_DIR, encoding: 'utf-8', timeout: 2000, windowsHide: true,
290
+ stdio: ['ignore', 'pipe', 'ignore']
291
+ }).trim();
292
+ if (sha && s.simplifySnapshotSha !== sha) { s.simplifySnapshotSha = sha; changed = true; }
293
+ } catch (e) { /* no git or detached state — skip snapshot, gate still works */ }
294
+ if (changed) writeState(s);
182
295
  }
183
296
  break;
184
297
  }
@@ -208,7 +321,29 @@ switch (command) {
208
321
  // optional ENV=val prefix segment catches `GH_TOKEN=x gh pr create`.
209
322
  var cmd = process.env.TOOL_INPUT_command || '';
210
323
  if (!/(?:^|&&\s*|\|\|\s*|;\s*)\s*(?:[A-Z_][A-Z0-9_]*=\S+\s+)*gh\s+pr\s+create\b/.test(cmd)) break;
324
+ // Docs-only exemption: if every file changed vs the merge-base is a docs/image
325
+ // file (no runtime-behaviour surface), skip the testing/simplify/learnings gates
326
+ // and surface a one-line transparency note. Falls through to the standard gate
327
+ // on any failure (no base, no diff, exec error) — fail-safe by design.
328
+ var changed = getChangedFilesVsBase();
329
+ if (changed && changed.length > 0 && changed.every(function(f) { return DOCS_ONLY_RE.test(f); })) {
330
+ process.stdout.write('Docs-only PR (' + changed.length + ' file' + (changed.length === 1 ? '' : 's') + ') — skipping testing/simplify/learnings gates.\n');
331
+ break;
332
+ }
211
333
  var s = readState();
334
+ // Classifier-aware skip: if delta-since-snapshot or whole-branch diff is
335
+ // TRIVIAL, satisfy the simplify gate silently. Reuses the same classifier
336
+ // the skill uses — same "trivial" definition, no drift. Same threshold that
337
+ // already maps to TRIVIAL=0 agents inside /simplify, so trusting it at the
338
+ // gate level is the same trust profile, just one decision earlier.
339
+ if (config.simplify_gate && !s.simplifyRun) {
340
+ var skipReason = classifyForGateSkip(s);
341
+ if (skipReason) {
342
+ s.simplifyRun = true;
343
+ writeState(s);
344
+ process.stdout.write('Simplify gate auto-passed: ' + skipReason + '\n');
345
+ }
346
+ }
212
347
  var missing = [];
213
348
  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)');
214
349
  if (config.simplify_gate && !s.simplifyRun) missing.push('/simplify has not run since the last code edit');
@@ -159,7 +159,10 @@ function decide(stats) {
159
159
  reasoning.push(
160
160
  `mostly relocation: ${stats.declAdded} decls added, ${stats.declRemoved} removed, net ${stats.netDecls >= 0 ? '+' : ''}${stats.netDecls}`,
161
161
  );
162
- return { tier: 'SMALL', model: 'sonnet', agentCount: 1, reasoning, stats };
162
+ // Haiku is sufficient for mechanical moves: code already existed and worked,
163
+ // so review reduces to copy-paste-divergence / dead-after-move pattern checks
164
+ // — exactly haiku's strength. ~5x cheaper than sonnet on relocation-shape diffs.
165
+ return { tier: 'SMALL', model: 'haiku', agentCount: 1, reasoning, stats };
163
166
  }
164
167
 
165
168
  // Escalation triggers — any one trips NORMAL (3 agents).
@@ -23,26 +23,72 @@ async function runFixCommand(cmd) {
23
23
  }
24
24
  }
25
25
  /**
26
- * Fix missing hook wiring in settings.json by patching in entries for any
27
- * REQUIRED_HOOK_WIRING patterns that aren't present. Delegates to shared
28
- * repairHookWiring() to stay DRY with the upgrade path.
26
+ * Fix Gate Health failures: bin/.claude-helpers gate.cjs drift AND missing
27
+ * settings.json hook wiring. The check has three independent failure modes
28
+ * and the prior fix only handled hook wiring — leaving bin/helper drift
29
+ * unresolved while still claiming success (the "Auto-fixed 1 issue" lie that
30
+ * surfaced when #920 mirrored the docs-only PR exemption into only one of
31
+ * the two gate.cjs files).
32
+ *
33
+ * Sync direction is decided by which source file is "ahead" of its installed
34
+ * counterpart in `node_modules/moflo/`:
35
+ * - If only source `bin/gate.cjs` differs from installed bin → mirror bin → helper.
36
+ * - If only source `.claude/helpers/gate.cjs` differs from installed helper → mirror helper → bin.
37
+ * - If both are ahead with different content (genuine ambiguity) → bail
38
+ * and let the caller report failure; refuse to silently pick a side.
39
+ * - If `node_modules/moflo/` is missing entirely (consumer never installed,
40
+ * unusual layout) → bail.
29
41
  */
30
42
  async function fixGateHealthHooks() {
31
- const settingsPath = join(process.cwd(), '.claude', 'settings.json');
32
- if (!existsSync(settingsPath))
33
- return false;
34
- try {
35
- const raw = readFileSync(settingsPath, 'utf8');
36
- const settings = JSON.parse(raw);
37
- const { repaired } = repairHookWiring(settings);
38
- if (repaired.length === 0)
39
- return true; // nothing to fix
40
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
41
- return true;
43
+ const cwd = process.cwd();
44
+ let driftFixed = true; // true means "no drift to fix or drift resolved"
45
+ const binGate = join(cwd, 'bin', 'gate.cjs');
46
+ const helperGate = join(cwd, '.claude', 'helpers', 'gate.cjs');
47
+ const installedBin = join(cwd, 'node_modules', 'moflo', 'bin', 'gate.cjs');
48
+ const installedHelper = join(cwd, 'node_modules', 'moflo', '.claude', 'helpers', 'gate.cjs');
49
+ if (existsSync(binGate) && existsSync(helperGate)) {
50
+ try {
51
+ const binContent = readFileSync(binGate, 'utf8');
52
+ const helperContent = readFileSync(helperGate, 'utf8');
53
+ if (binContent !== helperContent) {
54
+ const installedBinContent = existsSync(installedBin) ? readFileSync(installedBin, 'utf8') : null;
55
+ const installedHelperContent = existsSync(installedHelper) ? readFileSync(installedHelper, 'utf8') : null;
56
+ const binAhead = installedBinContent !== null && binContent !== installedBinContent;
57
+ const helperAhead = installedHelperContent !== null && helperContent !== installedHelperContent;
58
+ if (binAhead && !helperAhead) {
59
+ writeFileSync(helperGate, binContent, 'utf-8');
60
+ }
61
+ else if (helperAhead && !binAhead) {
62
+ writeFileSync(binGate, helperContent, 'utf-8');
63
+ }
64
+ else {
65
+ // Both ahead with different content, OR neither ahead (no install
66
+ // to anchor on). Refuse to pick a side — surface the failure.
67
+ driftFixed = false;
68
+ }
69
+ }
70
+ }
71
+ catch {
72
+ driftFixed = false;
73
+ }
42
74
  }
43
- catch {
44
- return false;
75
+ // Hook-wiring repair (separate failure mode that this fixer also owns).
76
+ const settingsPath = join(cwd, '.claude', 'settings.json');
77
+ let wiringFixed = true;
78
+ if (existsSync(settingsPath)) {
79
+ try {
80
+ const raw = readFileSync(settingsPath, 'utf8');
81
+ const settings = JSON.parse(raw);
82
+ const { repaired } = repairHookWiring(settings);
83
+ if (repaired.length > 0) {
84
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
85
+ }
86
+ }
87
+ catch {
88
+ wiringFixed = false;
89
+ }
45
90
  }
91
+ return driftFixed && wiringFixed;
46
92
  }
47
93
  /**
48
94
  * Execute the fix for a failed/warned health check.