moflo 4.12.7 → 4.12.8

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.
@@ -1,178 +1,75 @@
1
1
  /**
2
2
  * Helpers Generator
3
3
  * Creates utility scripts in .claude/helpers/
4
+ *
5
+ * Each generator below returns the CANONICAL helper file verbatim, embedded at
6
+ * build time by scripts/generate-embedded-helpers.mjs (#1443).
7
+ *
8
+ * These are used by `flo init`'s fallback path — `src/cli/init/executor.ts`
9
+ * reaches for them only when `findSourceHelpersDir()` cannot locate the
10
+ * package's own files. Each function used to carry a hand-maintained COPY of
11
+ * the helper it emits, and five of the seven had drifted; `gate.cjs` had fallen
12
+ * far enough behind to be missing #1348's credit-fingerprint invalidation
13
+ * entirely, so a project that received the fallback kept crediting tests that
14
+ * no longer matched the code. Nothing compared the copies, so nothing said so.
15
+ *
16
+ * Embedding, rather than reading at runtime: a fallback for "the package files
17
+ * are not findable" cannot read those files, and resolving them relative to
18
+ * `__dirname` is the dist-vs-source depth trap from #1126. See the generator
19
+ * script's header.
20
+ *
21
+ * To change what a consumer receives, edit the canonical file (`bin/gate.cjs`,
22
+ * `.claude/helpers/pre-commit`, …) and run `npm run generate:helpers`. Editing
23
+ * this file cannot change it — and tests/guards/embedded-helpers-parity.test.ts
24
+ * fails if the embed is stale.
4
25
  */
26
+ import { EMBEDDED_HELPERS_BASE64 } from './embedded-helpers.js';
27
+ /** Decoded helpers, memoised — the fallback can ask for the same one twice. */
28
+ const decodedCache = new Map();
5
29
  /**
6
- * Generate pre-commit hook script
30
+ * Decode an embedded helper, loudly.
31
+ *
32
+ * A missing key would otherwise return `undefined` and write the string
33
+ * "undefined" into a consumer's `.claude/helpers/gate.cjs` — a corrupt gate,
34
+ * which is worse than the degraded install the fallback exists to rescue.
35
+ * Throwing is the survivable outcome: `flo init` reports it and writes nothing.
36
+ *
37
+ * Note that this is NOT fallback-only. `executor.writeHelpers` builds its record
38
+ * by calling all seven of these unconditionally, on every init — and with
39
+ * `--force` it writes them over the files it just copied. That is precisely how
40
+ * the drift this replaced reached healthy installs, not only broken ones.
41
+ *
42
+ * Decoding is lazy and memoised, so an init that ends up writing none of them
43
+ * pays only for the base64 the module already holds — the ~180KB constant loads
44
+ * with the module either way.
7
45
  */
46
+ function embedded(name) {
47
+ const cached = decodedCache.get(name);
48
+ if (cached !== undefined)
49
+ return cached;
50
+ const encoded = EMBEDDED_HELPERS_BASE64[name];
51
+ if (typeof encoded !== 'string' || encoded.length === 0) {
52
+ throw new Error(`Embedded helper "${name}" is missing or empty. Run \`npm run generate:helpers\` ` +
53
+ '(scripts/generate-embedded-helpers.mjs) to rebuild src/cli/init/embedded-helpers.ts.');
54
+ }
55
+ const content = Buffer.from(encoded, 'base64').toString('utf-8');
56
+ if (content.length === 0) {
57
+ throw new Error(`Embedded helper "${name}" decoded to nothing — the embed is corrupt.`);
58
+ }
59
+ decodedCache.set(name, content);
60
+ return content;
61
+ }
62
+ /** Generate pre-commit hook script */
8
63
  export function generatePreCommitHook() {
9
- return `#!/bin/bash
10
- # moflo Pre-Commit Hook
11
- # Validates code quality before commit
12
-
13
- set -e
14
-
15
- echo "🔍 Running moflo pre-commit checks..."
16
-
17
- # Get staged files
18
- STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)
19
-
20
- # Run validation for each staged file
21
- for FILE in $STAGED_FILES; do
22
- if [[ "$FILE" =~ \\.(ts|js|tsx|jsx)$ ]]; then
23
- echo " Validating: $FILE"
24
- npx moflo hooks pre-edit --file "$FILE" --validate-syntax 2>/dev/null || true
25
- fi
26
- done
27
-
28
- # Run tests if available
29
- if [ -f "package.json" ] && grep -q '"test"' package.json; then
30
- echo "🧪 Running tests..."
31
- npm test --if-present 2>/dev/null || echo " Tests skipped or failed"
32
- fi
33
-
34
- echo "✅ Pre-commit checks complete"
35
- `;
64
+ return embedded('pre-commit');
36
65
  }
37
- /**
38
- * Generate post-commit hook script
39
- */
66
+ /** Generate post-commit hook script */
40
67
  export function generatePostCommitHook() {
41
- return `#!/bin/bash
42
- # moflo Post-Commit Hook
43
- # Records commit metrics and trains patterns
44
-
45
- COMMIT_HASH=$(git rev-parse HEAD)
46
- COMMIT_MSG=$(git log -1 --pretty=%B)
47
-
48
- echo "📊 Recording commit metrics..."
49
-
50
- # Notify flo of commit
51
- npx moflo hooks notify \\
52
- --message "Commit: $COMMIT_MSG" \\
53
- --level info \\
54
- --metadata '{"hash": "'$COMMIT_HASH'"}' 2>/dev/null || true
55
-
56
- echo "✅ Commit recorded"
57
- `;
68
+ return embedded('post-commit');
58
69
  }
59
- /**
60
- * Generate a minimal auto-memory-hook.mjs fallback for fresh installs.
61
- * This ESM script handles import/sync/status commands gracefully when
62
- * moflo/cli/memory is not installed. Gets overwritten when source copy succeeds.
63
- */
70
+ /** Generate the auto-memory bridge hook */
64
71
  export function generateAutoMemoryHook() {
65
- return `#!/usr/bin/env node
66
- /**
67
- * Auto Memory Bridge Hook (ADR-048/049) — Minimal Fallback
68
- * Full version is copied from package source when available.
69
- *
70
- * Usage:
71
- * node auto-memory-hook.mjs import # SessionStart
72
- * node auto-memory-hook.mjs sync # SessionEnd / Stop
73
- * node auto-memory-hook.mjs status # Show bridge status
74
- */
75
-
76
- import { existsSync, mkdirSync, writeFileSync } from 'fs';
77
- import { join, dirname } from 'path';
78
- import { fileURLToPath } from 'url';
79
-
80
- const __filename = fileURLToPath(import.meta.url);
81
- const __dirname = dirname(__filename);
82
- const PROJECT_ROOT = join(__dirname, '../..');
83
- const DATA_DIR = join(PROJECT_ROOT, '.moflo', 'data');
84
- const STORE_PATH = join(DATA_DIR, 'auto-memory-store.json');
85
-
86
- const DIM = '\\x1b[2m';
87
- const RESET = '\\x1b[0m';
88
- const dim = (msg) => console.log(\` \${DIM}\${msg}\${RESET}\`);
89
-
90
- // Ensure data dir
91
- if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
92
-
93
- async function loadMemoryPackage() {
94
- // Memory was inlined into moflo's cli package by the workspace-collapse epic
95
- // (#586 / story #598) — the bare \`@moflo/memory\` specifier no longer resolves.
96
- // After the final cli collapse (#602) the compiled module ships at
97
- // <moflo-pkg-root>/dist/src/cli/memory/index.js.
98
- const MEMORY_REL = join('dist', 'src', 'cli', 'memory', 'index.js');
99
- const { pathToFileURL } = await import('url');
100
-
101
- // Strategy 1: Resolve moflo's package.json directly from the consumer
102
- // project — its dirname IS the package root, no walk needed.
103
- try {
104
- const { createRequire } = await import('module');
105
- const require = createRequire(join(PROJECT_ROOT, 'package.json'));
106
- const pkgRoot = dirname(require.resolve('moflo/package.json'));
107
- const candidate = join(pkgRoot, MEMORY_REL);
108
- if (existsSync(candidate)) return await import(pathToFileURL(candidate).href);
109
- } catch { /* fall through */ }
110
-
111
- // Strategy 2: Walk up from PROJECT_ROOT looking for moflo in any node_modules.
112
- let searchDir = PROJECT_ROOT;
113
- const { parse } = await import('path');
114
- while (searchDir !== parse(searchDir).root) {
115
- const candidate = join(searchDir, 'node_modules', 'moflo', MEMORY_REL);
116
- if (existsSync(candidate)) {
117
- try { return await import(pathToFileURL(candidate).href); } catch { /* fall through */ }
118
- }
119
- searchDir = dirname(searchDir);
120
- }
121
-
122
- return null;
123
- }
124
-
125
- async function doImport() {
126
- const memPkg = await loadMemoryPackage();
127
-
128
- if (!memPkg || !memPkg.AutoMemoryBridge) {
129
- dim('Memory package not available — auto memory import skipped (non-critical)');
130
- return;
131
- }
132
-
133
- // Full implementation deferred to copied version
134
- dim('Auto memory import available — run init --upgrade for full support');
135
- }
136
-
137
- async function doSync() {
138
- if (!existsSync(STORE_PATH)) {
139
- dim('No entries to sync');
140
- return;
141
- }
142
-
143
- const memPkg = await loadMemoryPackage();
144
-
145
- if (!memPkg || !memPkg.AutoMemoryBridge) {
146
- dim('Memory package not available — sync skipped (non-critical)');
147
- return;
148
- }
149
-
150
- dim('Auto memory sync available — run init --upgrade for full support');
151
- }
152
-
153
- function doStatus() {
154
- console.log('\\n=== Auto Memory Bridge Status ===\\n');
155
- console.log(' Package: Fallback mode (run init --upgrade for full)');
156
- console.log(\` Store: \${existsSync(STORE_PATH) ? 'Initialized' : 'Not initialized'}\`);
157
- console.log('');
158
- }
159
-
160
- const command = process.argv[2] || 'status';
161
-
162
- try {
163
- switch (command) {
164
- case 'import': await doImport(); break;
165
- case 'sync': await doSync(); break;
166
- case 'status': doStatus(); break;
167
- default:
168
- console.log('Usage: auto-memory-hook.mjs <import|sync|status>');
169
- process.exit(1);
170
- }
171
- } catch (err) {
172
- // Hooks must never crash Claude Code - fail silently
173
- dim(\`Error (non-critical): \${err.message}\`);
174
- }
175
- `;
72
+ return embedded('auto-memory-hook.mjs');
176
73
  }
177
74
  /**
178
75
  * Generate all helper files
@@ -193,1396 +90,23 @@ export function generateHelpers(options) {
193
90
  return helpers;
194
91
  }
195
92
  /**
196
- * Generate lightweight gate.cjs — spell gates without CLI bootstrap.
197
- * Handles JSON state file read/write for memory-first and TaskCreate gates.
198
- * This replaces `npx flo gate <command>` to avoid spawning a full CLI process
199
- * on every tool call (~500ms npx overhead → ~20ms direct node).
93
+ * Lightweight gate.cjs — spell gates without CLI bootstrap. Replaces
94
+ * `npx flo gate <command>` to avoid spawning a full CLI process on every tool
95
+ * call (~500ms npx overhead -> ~20ms direct node).
200
96
  */
201
97
  export function generateGateScript() {
202
- return `#!/usr/bin/env node
203
- 'use strict';
204
- var fs = require('fs');
205
- var path = require('path');
206
- var os = require('os');
207
-
208
- var PROJECT_DIR = (process.env.CLAUDE_PROJECT_DIR || process.cwd()).replace(/^\\/([a-z])\\//i, '$1:/');
209
- var STATE_FILE = path.join(PROJECT_DIR, '.claude', 'workflow-state.json');
210
-
211
- var STATE_DEFAULTS = { tasksCreated: false, taskCount: 0, tasksAcknowledged: false, memorySearched: false, memorySearchedBy: {}, memoryRequired: true, learningsStored: false, testsRun: false, simplifyRun: false, verifyRun: false, verifyOutcome: null, interactionCount: 0, sessionStart: null, lastBlockedAt: null, lastNamespaceHint: '', lastNamespaceHintEmittedBy: {}, flMode: null, swarmInitialized: false, hiveInitialized: false, sddMode: false, activeSddSlug: null };
212
-
213
- function readState() {
214
- try {
215
- if (fs.existsSync(STATE_FILE)) {
216
- var parsed = JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8'));
217
- return Object.assign({}, STATE_DEFAULTS, parsed);
218
- }
219
- } catch (e) { /* reset on corruption */ }
220
- return Object.assign({}, STATE_DEFAULTS);
221
- }
222
-
223
- // Per-actor memory-search tracking (#838). When gate-hook.mjs forwards Claude
224
- // Code's stdin session_id as HOOK_SESSION_ID, prefer the per-session map so
225
- // each spawned subagent must search memory itself before its first
226
- // Glob/Grep/Read. Falls back to the legacy boolean otherwise.
227
- function isMemorySearchedFor(state) {
228
- var sid = process.env.HOOK_SESSION_ID || '';
229
- if (sid) {
230
- var map = state.memorySearchedBy || {};
231
- return map[sid] === true;
232
- }
233
- return state.memorySearched === true;
234
- }
235
-
236
- // Stamp the legacy bool plus (when HOOK_SESSION_ID is set) the per-actor map.
237
- // Returns true if anything actually changed — callers gate writeState() on it.
238
- function markMemorySearched(state) {
239
- var sid = process.env.HOOK_SESSION_ID || '';
240
- var changed = false;
241
- if (state.memorySearched !== true) { state.memorySearched = true; changed = true; }
242
- if (sid) {
243
- if (!state.memorySearchedBy) state.memorySearchedBy = {};
244
- if (state.memorySearchedBy[sid] !== true) { state.memorySearchedBy[sid] = true; changed = true; }
245
- }
246
- return changed;
247
- }
248
-
249
- function writeState(s) {
250
- try {
251
- var dir = path.dirname(STATE_FILE);
252
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
253
- fs.writeFileSync(STATE_FILE, JSON.stringify(s, null, 2));
254
- } catch (e) { /* non-fatal */ }
255
- }
256
-
257
- // Load moflo.yaml gate config (defaults: all enabled)
258
- function loadGateConfig() {
259
- // #1435 — task_status_gate is a MODE ('block' | 'warn' | 'off'), not a boolean;
260
- // boolean forms are accepted so it reads like its neighbours. Unrecognised
261
- // values keep the default: a typo must not become a stealth opt-out.
262
- var defaults = { memory_first: true, task_create_first: true, context_tracking: true, testing_gate: true, simplify_gate: true, learnings_gate: true, swarm_invocation_gate: true, verify_before_done: true, sdd_gate: true, task_status_gate: 'block' };
263
- var content = MOFLO_YAML;
264
- if (content) {
265
- var tsg = /task_status_gate:\\s*['"]?(block|warn|off|false|true)['"]?/i.exec(content);
266
- if (tsg) {
267
- var tsgMode = tsg[1].toLowerCase();
268
- defaults.task_status_gate = tsgMode === 'false' ? 'off' : tsgMode === 'true' ? 'block' : tsgMode;
269
- }
270
- if (/memory_first:\\s*false/i.test(content)) defaults.memory_first = false;
271
- if (/task_create_first:\\s*false/i.test(content)) defaults.task_create_first = false;
272
- if (/context_tracking:\\s*false/i.test(content)) defaults.context_tracking = false;
273
- if (/testing_gate:\\s*false/i.test(content)) defaults.testing_gate = false;
274
- if (/simplify_gate:\\s*false/i.test(content)) defaults.simplify_gate = false;
275
- if (/learnings_gate:\\s*false/i.test(content)) defaults.learnings_gate = false;
276
- if (/swarm_invocation_gate:\\s*false/i.test(content)) defaults.swarm_invocation_gate = false;
277
- // Opt-out: on by default (#1294); disable only when explicitly set false.
278
- if (/verify_before_done:\\s*false/i.test(content)) defaults.verify_before_done = false;
279
- // #1297 — check-before-implement backstop; opt-out. Only fires when armed.
280
- if (/sdd_gate:\\s*false/i.test(content)) defaults.sdd_gate = false;
281
- }
282
- return defaults;
283
- }
284
-
285
- // #1297 — parse the top-level sdd: block (default + specs_dir), scoped so we
286
- // never match a default: key from another section. Tolerates CRLF. SYNC: mirrors
287
- // bin/gate.cjs loadSddConfig.
288
- function loadSddConfig() {
289
- var out = { default: false, specsDir: '.moflo/specs' };
290
- var content = MOFLO_YAML;
291
- if (!content) return out;
292
- var block = content.match(/^sdd:[ \\t]*\\r?\\n((?:[ \\t]+.*(?:\\r?\\n|$))*)/m);
293
- if (!block) return out;
294
- var body = block[1];
295
- if (/^\\s*default:\\s*true\\b/im.test(body)) out.default = true;
296
- var sd = body.match(/^\\s*specs_dir:\\s*(.+?)\\s*$/im);
297
- if (sd) {
298
- var v = sd[1].replace(/\\s+#.*$/, '').replace(/^["']|["']$/g, '').trim();
299
- if (v) out.specsDir = v;
300
- }
301
- return out;
302
- }
303
-
304
- // #1285 — parse the top-level merge: block. Block-scoped like loadSddConfig.
305
- // SYNC: mirrors bin/gate.cjs loadMergeConfig.
306
- function loadMergeConfig() {
307
- var out = { auto: false };
308
- var content = MOFLO_YAML;
309
- if (!content) return out;
310
- var block = content.match(/^merge:[ \\t]*\\r?\\n((?:[ \\t]+.*(?:\\r?\\n|$))*)/m);
311
- if (!block) return out;
312
- if (/^\\s*auto:\\s*true\\b/im.test(block[1])) out.auto = true;
313
- return out;
314
- }
315
-
316
- // #1297 — read moflo.yaml once (both loaders parse it; gate fires on every
317
- // Write/Edit). SYNC: mirrors bin/gate.cjs readMofloYaml.
318
- function readMofloYaml() {
319
- try { return fs.readFileSync(path.join(PROJECT_DIR, 'moflo.yaml'), 'utf-8'); }
320
- catch (e) { return ''; }
321
- }
322
- var MOFLO_YAML = readMofloYaml();
323
-
324
- // #1394 — is the hook that transcribes /verify's verdict wired at all?
325
- // Distinguishes "agent skipped Step 5" from "nothing can record the verdict";
326
- // only the first is fixable by re-running /verify. Lazy (blocked path only);
327
- // unreadable settings → true so a parse failure keeps the generic message.
328
- // SYNC: mirrors bin/gate.cjs isVerifyOutcomeHookWired.
329
- function isVerifyOutcomeHookWired() {
330
- try {
331
- return fs.readFileSync(path.join(PROJECT_DIR, '.claude', 'settings.json'), 'utf-8')
332
- .indexOf('record-verify-outcome') >= 0;
333
- } catch (e) { return true; }
334
- }
335
-
336
- var config = loadGateConfig();
337
- var sddConf = loadSddConfig();
338
- var mergeConf = loadMergeConfig();
339
- var command = process.argv[2];
340
-
341
- var EXEMPT = ['.claude/', '.claude\\\\', 'CLAUDE.md', 'MEMORY.md', 'workflow-state', 'node_modules', 'moflo.yaml'];
342
-
343
- // Appended to every memory-first denial so a context audit doesn't read gate
344
- // enforcement as permission misconfiguration (#1307 finding 5). SYNC: mirrors
345
- // bin/gate.cjs GATE_ORIGIN_NOTE / GATE_DISABLE_NOTE.
346
- var GATE_ORIGIN_NOTE = 'This is a moflo hook, not a Claude Code permission rule — allow-rules cannot override it.';
347
- var GATE_DISABLE_NOTE = 'Disable per-gate via moflo.yaml: gates: memory_first: false';
348
- // #1338 — a session can outlive its moflo MCP connection (Claude Code spawns
349
- // stdio servers once, at session start). SYNC: mirrors bin/gate.cjs.
350
- var MCP_FALLBACK_NOTE = 'If mcp__moflo__* tools are unavailable this session (MCP server not connected), this credits the gate too: npx flo memory search --query "<topic>" --namespace <ns>';
351
- // #1338 follow-up — swarm/hive equivalent. SYNC: mirrors bin/gate.cjs.
352
- var COORD_FALLBACK_NOTE = 'If mcp__moflo__* tools are unavailable this session (MCP server not connected), this satisfies the gate too:';
353
- // #1348 — the pre-PR gates are order-dependent; naming only the missing one left
354
- // callers to rediscover the sequence by trial. SYNC: mirrors bin/gate.cjs.
355
- 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';
356
- // #1434 — the old text named the mechanism but no quality bar, so the cheapest
357
- // way past the gate was a summary of the run. The escape command is built from
358
- // __filename: the caller is the model typing into Bash, where $CLAUDE_PROJECT_DIR
359
- // is unset and a relative path breaks from any cwd but the project root.
360
- // SYNC: mirrors bin/gate.cjs.
361
- var LEARNINGS_MISSING =
362
- 'no durable lesson recorded. A lesson qualifies only if it would help a future session ' +
363
- 'working on a DIFFERENT task — a reusable pattern, a trap, a decision + rationale. ' +
364
- 'Store one with mcp__moflo__memory_store (namespace "learnings"; use "patterns" for a ' +
365
- 'reusable code shape). What THIS run changed is git history — it belongs in the PR body, ' +
366
- 'not in memory. If this run taught nothing new, say so instead of inventing one: ' +
367
- 'node "' + __filename + '" record-no-durable-lesson';
368
- // #1294 Finding 3 — exempt ephemeral reads/scans under the OS temp dir
369
- // (background-task output, scratchpads) from the memory-first gate. Mirrors
370
- // bin/gate.cjs isEphemeralPath. Cross-platform via os.tmpdir(); normalizes a
371
- // leading /private so macOS /var-vs-/private/var symlink pairs match.
372
- function stripPrivate(p) { return p.indexOf('/private/') === 0 ? p.slice('/private'.length) : p; }
373
- function isEphemeralPath(fp) {
374
- if (!fp) return false;
375
- var tmp;
376
- try { tmp = path.resolve(os.tmpdir()); } catch (e) { return false; }
377
- var t = stripPrivate(tmp);
378
- function under(p) { var n = stripPrivate(p); return n === t || n.indexOf(t + path.sep) === 0; }
379
- var resolved = path.resolve(fp);
380
- if (!under(resolved)) return false;
381
- // Symlink staged in tmp could deref to a real file — realpath both (Rule #2).
382
- try { return under(fs.realpathSync(resolved)); } catch (e) { return true; }
383
- }
384
- // #1171 — DANGEROUS gained PS additions to match the matcher widening that now
385
- // routes the PowerShell tool through check-dangerous-command. See bin/gate.cjs.
386
- var DANGEROUS = ['rm -rf /', 'format c:', 'del /s /q c:\\\\', ':(){:|:&};:', 'mkfs.', '> /dev/sda', 'remove-item -recurse -force c:\\\\', 'remove-item -recurse -force /', 'remove-item -recurse -force ~', 'format-volume', 'clear-disk'];
387
- // #1132 — Bash memory-first gate regexes. See bin/gate.cjs for documentation.
388
- // #1171 — READ_LIKE extended with PS-native exploration forms (Get-ChildItem -Recurse,
389
- // dir /s, Format-Hex). Plain Get-ChildItem stays uncovered (ls-equivalent).
390
- // #1338 — CREDIT requires a real memory-search INVOCATION, not any command that
391
- // merely contains the phrase. Matched by basename so flo, flo.cmd, npx.cmd flo
392
- // and node C:\\\\...\\\\cli.js all credit (Rule #1).
393
- // SYNC: duplicated verbatim in bin/gate.cjs — see there for full rationale.
394
- var CREDIT_RUNNER_RE = /^(?:npx|npm|pnpm|yarn|bun|bunx|deno|node|nodejs|tsx|ts-node)(?:\\.(?:cmd|exe|bat|ps1))?$/i;
395
- var CREDIT_RUNNER_SKIP_RE = /^(?:dlx|exec|run|-y|--yes|-q|--quiet|--silent|--no-install|--)$/i;
396
- var CREDIT_CLI_RE = /^(?:flo|moflo|claude-flow|cli\\.js|cli\\.mjs)(?:\\.(?:cmd|exe|bat|ps1))?$/i;
397
- var CREDIT_SEARCH_BIN_RE = /^(?:flo-search(?:\\.(?:cmd|exe|bat|ps1))?|semantic-search\\.mjs)$/i;
398
- var CREDIT_HINT_RE = /flo|cli\\.m?js|semantic-search/i;
399
- var CREDIT_MEMORY_VERB_RE = /^(?:search|retrieve)$/i;
400
- var CREDIT_MEMORY_COMPOUND_RE = /^memory[-_](?:search|retrieve)$/i;
401
- // Splits on BOTH separators — path.basename honours only the host's (Rule #1).
402
- function commandBasename(tok) {
403
- var t = tok.replace(/^["']+|["']+$/g, '');
404
- var cut = t.lastIndexOf('/');
405
- var bs = t.lastIndexOf('\\\\');
406
- if (bs > cut) cut = bs;
407
- return (cut >= 0 ? t.slice(cut + 1) : t).toLowerCase();
408
- }
409
- function mofloSubcommand(seg) {
410
- var tokens = seg.trim().split(/\\s+/).filter(Boolean);
411
- var i = 0;
412
- while (i < tokens.length) {
413
- var tok = tokens[i];
414
- if (tok === 'sudo' || /^[A-Za-z_][A-Za-z0-9_]*=/.test(tok)) { i++; continue; }
415
- if (CREDIT_RUNNER_SKIP_RE.test(tok)) { i++; continue; }
416
- if (CREDIT_RUNNER_RE.test(commandBasename(tok))) { i++; continue; }
417
- break;
418
- }
419
- if (i >= tokens.length) return null;
420
- var entry = commandBasename(tokens[i]);
421
- if (CREDIT_SEARCH_BIN_RE.test(entry)) return ['memory', 'search'];
422
- if (!CREDIT_CLI_RE.test(entry)) return null;
423
- var rest = [];
424
- for (var j = i + 1; j < tokens.length; j++) {
425
- if (tokens[j].charAt(0) !== '-') rest.push(tokens[j].toLowerCase());
426
- }
427
- return rest;
428
- }
429
- function mofloSegments(rawCmd) {
430
- return stripQuotedAndHeredocs(rawCmd || '').split(/[;|&\\n]+/);
431
- }
432
- function segmentCreditsMemorySearch(seg) {
433
- var sub = mofloSubcommand(seg);
434
- if (!sub || !sub.length) return false;
435
- if (CREDIT_MEMORY_COMPOUND_RE.test(sub[0])) return true;
436
- return sub[0] === 'memory' && sub.length > 1 && CREDIT_MEMORY_VERB_RE.test(sub[1]);
437
- }
438
- function creditsMemorySearch(rawCmd) {
439
- if (!CREDIT_HINT_RE.test(rawCmd || '')) return false;
440
- return mofloSegments(rawCmd).some(segmentCreditsMemorySearch);
441
- }
442
- // #1338 follow-up — the CLI runs the SAME in-process handler the MCP tool does
443
- // and persists the swarm, so it satisfies the #952 gate for real. Recorded only
444
- // on success (PostToolUse, #1322). SYNC: bin/gate.cjs has the full rationale.
445
- function bashCoordinationInit(rawCmd) {
446
- if (!CREDIT_HINT_RE.test(rawCmd || '') || !/\\binit\\b/i.test(rawCmd)) return null;
447
- var segments = mofloSegments(rawCmd);
448
- for (var i = 0; i < segments.length; i++) {
449
- var sub = mofloSubcommand(segments[i]);
450
- if (!sub || sub.length < 2 || sub[1] !== 'init') continue;
451
- if (sub[0] === 'swarm') return 'swarm';
452
- if (sub[0] === 'hive-mind' || sub[0] === 'hive') return 'hive';
453
- }
454
- return null;
455
- }
456
- var READ_LIKE_BASH_RE = /^\\s*(?:cat|head|tail|less|more|bat|xxd|od|hexdump)\\b|^\\s*(?:grep|rg|ag|fgrep|egrep|find|fd)\\b|^\\s*sed\\s+-n\\b|^\\s*awk\\s+(?!.*<<)|^\\s*type\\s+\\S*[\\\\/.]|^\\s*(?:Get-Content|gc|Select-String|sls)\\b|^\\s*(?:Get-ChildItem|gci)\\b[^|]*-Recurse\\b|^\\s*dir\\b[^|]*\\s\\/[sS]\\b|^\\s*Format-Hex\\b/i;
457
- var BASH_CARVE_OUT_RE = /^\\s*(npm|npx|pnpm|yarn|bun|node|deno|tsx|ts-node)\\s|^\\s*(git|gh|hub)\\s|^\\s*(docker|kubectl|helm|terraform)\\s|^\\s*(curl|wget|http|fetch)\\s|^\\s*(jq|yq|xq)\\s|^\\s*(echo|printf|true|false|sleep|test|\\[)\\s|^\\s*cat\\s+(<<|<<<)|^\\s*cat\\s+[^|]*\\s*>|^\\s*tee\\b|^\\s*find\\s+.+?-(delete|exec\\s+rm)\\b/;
458
- // #1171 follow-up — strip quoted bodies + heredocs before DANGEROUS substring
459
- // match so git commit messages with dangerous-shaped text in quoted bodies do
460
- // not trip the gate. See bin/gate.cjs for the full rationale. Command-sub
461
- // bodies are intentionally not stripped (those execute).
462
- function stripQuotedAndHeredocs(cmd) {
463
- var out = cmd;
464
- out = out.replace(/<<-?\\s*['"]?[\\w-]+['"]?[\\s\\S]*$/, '');
465
- out = out.replace(/<<<\\s*\\S+/g, '');
466
- out = out.replace(/'[^']*'/g, "''");
467
- out = out.replace(/"(?:[^"\\\\]|\\\\.)*"/g, '""');
468
- return out;
469
- }
470
-
471
- var DIRECTIVE_RE = /^(yes|no|yeah|yep|nope|sure|ok|okay|correct|right|exactly|perfect)\\b/i;
472
- var TASK_RE = /\\b(fix|bug|error|implement|add|create|build|write|refactor|debug|test|feature|issue|security|optimi)\\b/i;
473
-
474
- // Namespace classification (#931). Hint stored on workflow-state and emitted
475
- // once by check-before-agent at Agent-spawn time — was emitted on every prompt
476
- // before, costing ~40 tokens × every prompt × every consumer.
477
- //
478
- // SYNC: these regexes + classifyNamespaceHint + applyPromptStateReset are
479
- // duplicated verbatim in bin/gate.cjs (canonical, synced to consumer
480
- // .claude/helpers/gate.cjs by post-install-bootstrap). Any edit MUST be
481
- // applied to both — this template is the fallback for the flo-init path
482
- // where source helpers cannot be located, so it must keep parity.
483
- var NS_LEARNINGS_RE = /\\b(remember|recall|insight|lesson learned|gotcha|post.?mortem)\\b|we (decid|agree|chose|said)/;
484
- var NS_TEST_RE = /\\b(test|spec|coverage|tested|test case|test cases|tests for|spec for)\\b/;
485
- var NS_EXPLICIT = [
486
- { pattern: /\\b(pattern|convention|best practice|style|coding rule)\\b/, ns: 'patterns', label: 'code patterns and conventions' },
487
- { pattern: /\\b(code.?map|file structure|project structure|directory)\\b/, ns: 'code-map', label: 'codebase navigation' },
488
- ];
489
- var NS_PATTERN_RES = [/\\b(template|example|similar to|how do we|how should)\\b/];
490
- var NS_DOMAIN_RES = [
491
- /\\b(guidance|guide|docs|documentation|rules|how-to)\\b/,
492
- /\\b(architecture|design|domain|tenant|migrat|schema|deploy)/,
493
- /\\b(rule|requirement|constraint|compliance)\\b/,
494
- ];
495
- var NS_NAV_RES = [
496
- /\\b(find|where|which file|look up|locate|endpoint|route|url|path)\\b/,
497
- /\\b(class|function|method|component|service|entity|module)\\b/,
498
- ];
499
-
500
- // Detect whether the current prompt invoked /fl or /flo with a swarm/hive flag
501
- // (#952). When set, check-before-agent BLOCKS the Agent spawn until the matching
502
- // MCP init has been recorded — the user explicitly opted in to the protected
503
- // coordination surface, so falling back to raw Agent dispatch silently regresses
504
- // headline moflo product capability.
505
- //
506
- // SYNC: duplicated verbatim in bin/gate.cjs.
507
- function detectFlMode(promptText) {
508
- var p = promptText || '';
509
- if (!/^\\s*\\/(?:fl|flo)\\b/i.test(p)) return null;
510
- if (/(?:^|\\s)(?:-s|--swarm)\\b/.test(p)) return 'swarm';
511
- if (/(?:^|\\s)(?:-h|--hive)\\b/.test(p)) return 'hive';
512
- return null;
513
- }
514
-
515
- // Resolve ALL /flo run modifiers from the prompt + moflo.yaml. Single source of
516
- // truth for gate arming AND the authoritative announcement below — a second
517
- // implementation is how sdd.default got silently ignored. Precedence per key:
518
- // --no-X > -x/--X > moflo.yaml > built-in (sdd opt-in, verify opt-out, merge
519
- // opt-in). SYNC: mirrors bin/gate.cjs resolveFloRun.
520
- function resolveFloRun(promptText) {
521
- var p = promptText || '';
522
- var out = { isFlo: false, workflow: 'full', sdd: false, verify: false, merge: false,
523
- sddSrc: 'default', verifySrc: 'default', mergeSrc: 'default' };
524
- if (!/^\\s*\\/(?:fl|flo)\\b/i.test(p)) return out;
525
- out.isFlo = true;
526
-
527
- if (/(?:^|\\s)(?:-wf|--workflow)\\b/.test(p)) out.workflow = 'spell-engine';
528
- else if (/(?:^|\\s)(?:-r|--research)\\b/.test(p)) out.workflow = 'research';
529
- else if (/(?:^|\\s)(?:-t|--ticket)\\b/.test(p)) out.workflow = 'ticket';
530
- var epicBranch = /(?:^|\\s)--epic-branch\\b/.test(p);
531
-
532
- if (/(?:^|\\s)--no-sdd\\b/.test(p)) { out.sdd = false; out.sddSrc = 'flag'; }
533
- else if (/(?:^|\\s)(?:-sd|--sdd)\\b/.test(p)) { out.sdd = true; out.sddSrc = 'flag'; }
534
- else if (sddConf.default) { out.sdd = true; out.sddSrc = 'moflo.yaml sdd.default'; }
535
-
536
- if (/(?:^|\\s)--no-verify\\b/.test(p)) { out.verify = false; out.verifySrc = 'flag'; }
537
- else if (/(?:^|\\s)(?:-v|--verify)\\b/.test(p)) { out.verify = true; out.verifySrc = 'flag'; }
538
- else if (!config.verify_before_done) { out.verify = false; out.verifySrc = 'moflo.yaml gates.verify_before_done'; }
539
- else { out.verify = true; out.verifySrc = 'default'; }
540
- if (out.sdd && !out.verify && out.verifySrc !== 'flag') out.verify = true;
541
-
542
- if (/(?:^|\\s)--no-merge\\b/.test(p)) { out.merge = false; out.mergeSrc = 'flag'; }
543
- else if (/(?:^|\\s)(?:-m|--merge)\\b/.test(p)) { out.merge = true; out.mergeSrc = 'flag'; }
544
- else if (mergeConf.auto) { out.merge = true; out.mergeSrc = 'moflo.yaml merge.auto'; }
545
-
546
- // Re-attribute anything applicability turned off — a false must never carry
547
- // the source of the value it no longer has. SYNC: mirrors bin/gate.cjs.
548
- if (out.workflow === 'ticket' || out.workflow === 'research') {
549
- if (out.verify) out.verifySrc = out.workflow + ' mode does not implement';
550
- out.verify = false;
551
- }
552
- if (out.workflow === 'research' || out.workflow === 'spell-engine') {
553
- if (out.sdd) out.sddSrc = out.workflow + ' mode produces no spec artifacts';
554
- out.sdd = false;
555
- }
556
- if (out.workflow !== 'full' || epicBranch) {
557
- if (out.merge) out.mergeSrc = epicBranch ? '--epic-branch owns merging' : out.workflow + ' mode opens no PR';
558
- out.merge = false;
559
- }
560
- return out;
561
- }
562
-
563
- // #1297 — arm the SDD implement gate from a /flo prompt. Thin wrapper so the
564
- // armed decision and the announced decision can never disagree.
565
- function detectSddMode(promptText) {
566
- return resolveFloRun(promptText).sdd;
567
- }
568
-
569
- // SDD specs-root resolution + artifact helpers for check-before-implement.
570
- // SYNC: mirrors bin/gate.cjs. Rule #1: no separator hardcoded; CRLF-tolerant.
571
- var SOURCE_FILE_RE = /\\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|rb|java|kt|swift|c|cc|cpp|h|hpp|sh|bash|ps1)$/i;
572
- function sddSpecsRootAbs() {
573
- var configured = (sddConf.specsDir || '.moflo/specs');
574
- var segments = configured.split(/[\\\\/]+/).filter(Boolean);
575
- var escapes = segments.length === 0
576
- || segments.indexOf('..') >= 0
577
- || /^([a-zA-Z]:|~)$/.test(segments[0])
578
- || configured.charAt(0) === '/'
579
- || configured.charAt(0) === '\\\\';
580
- if (escapes) return path.join(PROJECT_DIR, '.moflo', 'specs');
581
- return path.join.apply(path, [PROJECT_DIR].concat(segments));
582
- }
583
- function isInsideSpecsDir(filePath) {
584
- try {
585
- var root = sddSpecsRootAbs();
586
- var abs = path.isAbsolute(filePath) ? filePath : path.resolve(PROJECT_DIR, filePath);
587
- var rel = path.relative(root, abs);
588
- return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
589
- } catch (e) { return false; }
590
- }
591
- function isPlanReviewed(slug) {
592
- try {
593
- var planPath = path.join(sddSpecsRootAbs(), slug, 'plan.md');
594
- if (!fs.existsSync(planPath)) return false;
595
- var content = fs.readFileSync(planPath, 'utf-8').replace(/\\r\\n/g, '\\n');
596
- var fm = content.match(/^---\\n([\\s\\S]*?)\\n---/);
597
- if (!fm) return false;
598
- return /^\\s*status:\\s*["']?reviewed["']?\\s*$/im.test(fm[1]);
599
- } catch (e) { return false; }
600
- }
601
-
602
- function classifyNamespaceHint(promptText) {
603
- var lower = (promptText || '').toLowerCase();
604
- if (NS_TEST_RE.test(lower)) return 'Memory namespace hint: use "tests" for test inventory and coverage lookups.';
605
- if (NS_LEARNINGS_RE.test(lower)) return 'Memory namespace hint: use "learnings" for user-directed decisions and distilled insights.';
606
- for (var i = 0; i < NS_EXPLICIT.length; i++) {
607
- if (NS_EXPLICIT[i].pattern.test(lower)) return 'Memory namespace hint: use "' + NS_EXPLICIT[i].ns + '" for ' + NS_EXPLICIT[i].label + '.';
608
- }
609
- for (var j = 0; j < NS_DOMAIN_RES.length; j++) {
610
- if (NS_DOMAIN_RES[j].test(lower)) return 'Memory namespace hint: search "guidance" and "learnings" for domain rules and project decisions.';
611
- }
612
- for (var k = 0; k < NS_PATTERN_RES.length; k++) {
613
- if (NS_PATTERN_RES[k].test(lower)) return 'Memory namespace hint: use "patterns" for code patterns and conventions.';
614
- }
615
- for (var m = 0; m < NS_NAV_RES.length; m++) {
616
- if (NS_NAV_RES[m].test(lower)) return 'Memory namespace hint: use "code-map" for codebase navigation.';
617
- }
618
- return '';
619
- }
620
-
621
- // #1132 — command-shape namespace classifier for the bash-BLOCK message.
622
- // SYNC: duplicated verbatim in bin/gate.cjs. See that file for rationale.
623
- function classifyBashNamespaceHint(cmd) {
624
- if (/^\\s*(?:grep|rg|ag|fgrep|egrep|find|fd|Select-String|sls)\\b/i.test(cmd)) {
625
- return 'Memory namespace hint: use "code-map" for codebase navigation.';
626
- }
627
- if (/^\\s*(?:cat|head|tail|less|more|bat|type|Get-Content|gc)\\b.*\\.(?:md|mdx|rst|txt)\\b/i.test(cmd)
628
- || /^\\s*(?:cat|head|tail|less|more|bat|type|Get-Content|gc)\\b.*\\b(?:README|CLAUDE|CHANGELOG|CONTRIBUTING|LICENSE)\\b/i.test(cmd)) {
629
- return 'Memory namespace hint: search "guidance" and "learnings" for project rules and decisions.';
630
- }
631
- return '';
632
- }
633
-
634
- function applyPromptStateReset(state, promptText) {
635
- state.memorySearched = false;
636
- state.memorySearchedBy = {};
637
- var DIRECTIVE_MAX_LEN = 20;
638
- var escaped = /^@@\\s*/.test(promptText || '');
639
- state.memoryRequired = !escaped && (promptText || '').length >= 4 && (TASK_RE.test(promptText || '') || (promptText || '').length > DIRECTIVE_MAX_LEN);
640
- state.lastNamespaceHint = classifyNamespaceHint(promptText);
641
- // Per-actor emission tracking — fresh window each prompt so subagents that
642
- // spawn their own agents still see the hint on their first check-before-agent.
643
- state.lastNamespaceHintEmittedBy = {};
644
- // #952 — derive flMode from the user prompt and reset the matching init
645
- // flag. Each /fl invocation must call its protected MCP init.
646
- state.flMode = detectFlMode(promptText);
647
- state.swarmInitialized = false;
648
- state.hiveInitialized = false;
649
- // #1297 — arm/disarm SDD implement gate per prompt; fresh run has no active slug.
650
- state.sddMode = detectSddMode(promptText);
651
- state.activeSddSlug = null;
652
- }
653
- 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;
654
- // #1322 — failure markers in a test runner's own OUTPUT.
655
- //
656
- // This is deliberately not an exit-code check: Claude Code's PostToolUse payload
657
- // carries no exit status, and PostToolUse does not fire at all when a command
658
- // exits non-zero — so an unmasked red suite already leaves testsRun false, by
659
- // accident of the hook lifecycle rather than by design. What DOES defeat the
660
- // gate is a masked exit (\`npm test | tail -20\`, \`npm test || true\`,
661
- // \`npm test 2>&1 | grep -i fail\`): the pipeline exits 0, PostToolUse fires with
662
- // a clean-looking response, and a red suite credits the gate. Output is the only
663
- // signal left, and it is genuinely weaker than a status — see the ticket.
664
- //
665
- // Every arm matches a SUMMARY shape a runner emits, never a bare "fail", which
666
- // occurs constantly in ordinary passing test names ("returns null when the
667
- // lookup failed"). The count arm excludes an explicit zero so jest's
668
- // \`0 failed, 12 passed\` cannot self-block.
669
- //
670
- // The count arm's trailing lookahead is what keeps a GREEN run from blocking
671
- // itself. Mocha's default spec reporter prints every passing test name, so
672
- // \`npm test | tail -20\` on a green suite legitimately contains lines like
673
- // \`✓ handles 2 failed retries\`. A real summary is followed by a delimiter or a
674
- // line end (\`3 failed | 40 passed\`, \`1 failed, 2 passed\`, \`1 failing\`), never by
675
- // more prose — so a lowercase word after the count means it is a sentence, not a
676
- // tally. \`tests\`/\`test\` is exempted because \`2 failed tests\` is a real summary.
677
- // Same-line whitespace only: at a line end there is nothing to disqualify.
678
- var TEST_FAILURE_RE = new RegExp([
679
- '\\\\b(?!0\\\\b)\\\\d+\\\\s+(?:tests?\\\\s+)?(?:failed|failing|failures?)\\\\b(?![^\\\\S\\\\n]+(?!tests?\\\\b)[a-z])', // vitest/jest/pytest/mocha counts
680
- '^\\\\s*(?:FAIL|FAILED)\\\\b', // vitest + jest per-file, pytest FAILED
681
- '^\\\\s*---\\\\s*FAIL:', // go test
682
- '\\\\btest result:\\\\s*FAILED\\\\b', // cargo
683
- '^npm ERR!', // npm wrapper around any of the above
684
- ].join('|'), 'im');
685
-
686
- /**
687
- * #1322 — why a just-fired record-test-run must NOT be credited, or null.
688
- *
689
- * Absent output is not evidence of failure: a quiet green \`npm test > /dev/null\`
690
- * and a silently-masked red one are indistinguishable, and treating the pair as
691
- * failures would block every consumer who redirects test output. Absent means
692
- * unknown, and unknown keeps the pre-#1322 behaviour.
693
- */
694
- function detectTestFailure() {
695
- if (process.env.TOOL_RESPONSE_interrupted === 'true') return 'the run was interrupted';
696
- var out = (process.env.TOOL_RESPONSE_stdout || '') + '\\n' + (process.env.TOOL_RESPONSE_stderr || '');
697
- if (!out.trim()) return null;
698
- var hit = out.match(TEST_FAILURE_RE);
699
- return hit ? 'output reports "' + hit[0].trim().slice(0, 40) + '"' : null;
98
+ return embedded('gate.cjs');
700
99
  }
701
- 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;
702
- // #1297 — path-inert dirs (.github/workflows etc.); SYNC: mirrors bin/gate.cjs EDIT_RESET_SKIP_PATH_RE.
703
- // #1348 — plus \`.moflo/\`, moflo's own gitignored state dir: nothing written
704
- // there can reach the branch diff, so it must not invalidate a gate.
705
- // #1395 — \`.claude/\` CONFIG (settings/skills/guidance/agents) joins them: it is
706
- // not the code under verification, and it is the directory a user edits because
707
- // a gate told them to. \`scripts/\`/\`helpers/\` stay OUT — they are executable.
708
- var EDIT_RESET_SKIP_PATH_RE = /(?:^|[\\\\\\/])\\.github[\\\\\\/](?:workflows|ISSUE_TEMPLATE|PULL_REQUEST_TEMPLATE)(?:[\\\\\\/.]|$)|(?:^|[\\\\\\/])\\.moflo[\\\\\\/]|(?:^|[\\\\\\/])\\.claude[\\\\\\/](?:settings(?:\\.local)?\\.json$|skills[\\\\\\/]|guidance[\\\\\\/]|agents[\\\\\\/])/i;
709
- // Test files: invalidate testsRun but preserve simplifyRun (#908) — /simplify
710
- // already reviewed the production code, touching tests/fixtures doesn't expose
711
- // new untested surface for code review.
712
- 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;
713
-
714
- // #1374/#1435 — count TaskCreate calls against terminal TaskUpdate calls in the
715
- // session transcript. SYNC: mirrors bin/gate.cjs readTaskLedger (see there for
716
- // why the transcript, and not a TaskUpdate observer or Claude Code's task store).
717
- //
718
- // This template variant omits RELAXATIONS the synced bin/gate.cjs carries (the
719
- // docs-only exemption, fingerprint expiry) — omitting those only makes the
720
- // fallback stricter. An ENFORCEMENT gate is the opposite: leaving it out would
721
- // make the fallback silently permissive, which is the exact failure #1435 is
722
- // about. So it is mirrored in full.
723
- var TRANSCRIPT_MAX_BYTES = 16 * 1024 * 1024;
724
- function readTaskLedger() {
725
- var tp = process.env.HOOK_TRANSCRIPT_PATH || '';
726
- if (!tp) return null;
727
- var raw;
728
- try {
729
- var tst = fs.statSync(tp);
730
- if (!tst.isFile() || tst.size > TRANSCRIPT_MAX_BYTES) return null;
731
- raw = fs.readFileSync(tp, 'utf-8');
732
- } catch (e) { return null; }
733
- var created = 0, createdIdCount = 0;
734
- var pendingCreates = {}, createdIds = {}, latest = {};
735
- var pos = 0;
736
- while (pos <= raw.length) {
737
- var nl = raw.indexOf('\\n', pos);
738
- var line = nl < 0 ? raw.slice(pos) : raw.slice(pos, nl);
739
- pos = nl < 0 ? raw.length + 1 : nl + 1;
740
- if (line.indexOf('TaskCreate') < 0 && line.indexOf('TaskUpdate') < 0
741
- && line.indexOf('created successfully') < 0) continue;
742
- var entry;
743
- try { entry = JSON.parse(line); } catch (e) { continue; }
744
- var content = entry && entry.message && entry.message.content;
745
- if (!Array.isArray(content)) continue;
746
- for (var ci = 0; ci < content.length; ci++) {
747
- var block = content[ci];
748
- if (!block) continue;
749
- if (block.type === 'tool_result') {
750
- if (!pendingCreates[block.tool_use_id]) continue;
751
- delete pendingCreates[block.tool_use_id];
752
- var text = typeof block.content === 'string' ? block.content : JSON.stringify(block.content);
753
- var m = /Task #(\\S+) created successfully/.exec(text || '');
754
- if (m && !createdIds[m[1]]) { createdIds[m[1]] = true; createdIdCount++; }
755
- continue;
756
- }
757
- if (block.type !== 'tool_use') continue;
758
- if (block.name === 'TaskCreate') {
759
- created++;
760
- if (block.id) pendingCreates[block.id] = true;
761
- continue;
762
- }
763
- if (block.name !== 'TaskUpdate') continue;
764
- var tinput = block.input || {};
765
- var tid = tinput.taskId != null ? tinput.taskId : tinput.task_id;
766
- if (tid == null || typeof tinput.status !== 'string' || !tinput.status) continue;
767
- latest[String(tid)] = tinput.status;
768
- }
769
- }
770
- if (created === 0) return null;
771
- var open = created - createdIdCount;
772
- Object.keys(createdIds).forEach(function(id) {
773
- if (latest[id] !== 'completed' && latest[id] !== 'deleted') open++;
774
- });
775
- if (open > created) open = created;
776
- return { created: created, closed: created - open, open: open };
777
- }
778
-
779
- switch (command) {
780
- case 'check-before-agent': {
781
- // Mostly advisory. The TaskCreate + memory reminders below go to stdout and
782
- // never block — their wording must not claim otherwise (#1326). The one
783
- // exception is the #952 swarm/hive check at the bottom of this case, which
784
- // writes to stderr and exits 2.
785
- // Memory-first enforcement otherwise happens at the scan/read gate layer.
786
- // SubagentStart hook injects guidance directive into subagent context.
787
- // #931 — TaskCreate REMINDER + namespace hint moved here from
788
- // prompt-reminder so they emit only when Claude is about to spawn an Agent.
789
- var s = readState();
790
- if (config.task_create_first && !s.tasksCreated) {
791
- process.stdout.write('REMINDER: Use TaskCreate before spawning agents.\\n');
792
- }
793
- if (config.memory_first && s.memoryRequired && !s.memorySearched) {
794
- process.stdout.write('REMINDER: Search memory (mcp__moflo__memory_search) before spawning agents.\\n');
795
- }
796
- if (s.lastNamespaceHint) {
797
- // Per-actor single-shot — each session_id emits the hint at most once
798
- // per prompt. Subagents that spawn their own agents still see it on
799
- // their first check-before-agent because their session_id is its own
800
- // bucket. Falls back to a _legacy_ bucket when HOOK_SESSION_ID is
801
- // missing (older Claude Code, direct CLI). The map clears on every
802
- // new prompt via applyPromptStateReset.
803
- var sid = process.env.HOOK_SESSION_ID || '';
804
- var emittedBy = s.lastNamespaceHintEmittedBy || {};
805
- var bucket = sid || '_legacy_';
806
- if (!emittedBy[bucket]) {
807
- process.stdout.write(s.lastNamespaceHint + '\\n');
808
- emittedBy[bucket] = true;
809
- s.lastNamespaceHintEmittedBy = emittedBy;
810
- writeState(s);
811
- }
812
- }
813
- // #952 — when /fl was invoked with -s/-h, the protected MCP init must run
814
- // BEFORE any Agent spawn. Hard block: the user explicitly opted in to
815
- // moflo's coordination surface, so silently dispatching Agent calls
816
- // without mcp__moflo__swarm_init / mcp__moflo__hive-mind_init is the
817
- // failure mode this gate exists to prevent (CLAUDE.md "⛔ Protected
818
- // functionality"). Other Agent uses remain advisory.
819
- if (config.swarm_invocation_gate) {
820
- if (s.flMode === 'swarm' && !s.swarmInitialized) {
821
- process.stderr.write('BLOCKED: /fl was invoked with -s/--swarm but mcp__moflo__swarm_init has not been called.\\n');
822
- process.stderr.write('Run mcp__moflo__swarm_init first, then mcp__moflo__agent_spawn for each role, then dispatch Agent.\\n');
823
- process.stderr.write(COORD_FALLBACK_NOTE + ' npx flo swarm init --topology hierarchical (then: npx flo agent spawn --type <role>)\\n');
824
- process.stderr.write('See .claude/skills/fl/execution-modes.md "SWARM mode" and CLAUDE.md "⛔ Protected functionality".\\n');
825
- process.stderr.write('Disable via moflo.yaml: gates: swarm_invocation_gate: false\\n');
826
- process.exit(2);
827
- }
828
- if (s.flMode === 'hive' && !s.hiveInitialized) {
829
- process.stderr.write('BLOCKED: /fl was invoked with -h/--hive but mcp__moflo__hive-mind_init has not been called.\\n');
830
- process.stderr.write('Run mcp__moflo__hive-mind_init first, then dispatch Agent or hive-mind workers.\\n');
831
- process.stderr.write(COORD_FALLBACK_NOTE + ' npx flo hive-mind init (then: npx flo hive-mind spawn)\\n');
832
- process.stderr.write('See .claude/skills/fl/execution-modes.md "HIVE-MIND mode" and CLAUDE.md "⛔ Protected functionality".\\n');
833
- process.stderr.write('Disable via moflo.yaml: gates: swarm_invocation_gate: false\\n');
834
- process.exit(2);
835
- }
836
- }
837
- break;
838
- }
839
- case 'record-bash-swarm-init': {
840
- // #1338 follow-up — CLI half of record-swarm-init/record-hive-init. Wired
841
- // PostToolUse so only a SUCCEEDED init credits (#1322). SYNC: bin/gate.cjs.
842
- var kind = bashCoordinationInit(process.env.TOOL_INPUT_command || '');
843
- if (kind) {
844
- var sc = readState();
845
- var flag = kind === 'swarm' ? 'swarmInitialized' : 'hiveInitialized';
846
- if (!sc[flag]) { sc[flag] = true; writeState(sc); }
847
- }
848
- break;
849
- }
850
- case 'record-swarm-init': {
851
- // #952 — wired to mcp__moflo__swarm_init PostToolUse.
852
- var s = readState();
853
- if (!s.swarmInitialized) {
854
- s.swarmInitialized = true;
855
- writeState(s);
856
- }
857
- break;
858
- }
859
- case 'record-hive-init': {
860
- // #952 — wired to mcp__moflo__hive-mind_init PostToolUse.
861
- var s = readState();
862
- if (!s.hiveInitialized) {
863
- s.hiveInitialized = true;
864
- writeState(s);
865
- }
866
- break;
867
- }
868
- case 'check-before-scan': {
869
- if (!config.memory_first) break;
870
- var s = readState();
871
- if (!s.memoryRequired || isMemorySearchedFor(s)) break;
872
- var target = (process.env.TOOL_INPUT_pattern || '') + ' ' + (process.env.TOOL_INPUT_path || '');
873
- if (isEphemeralPath(process.env.TOOL_INPUT_path)) break;
874
- if (EXEMPT.some(function(p) { return target.indexOf(p) >= 0; })) break;
875
- process.stderr.write('BLOCKED [moflo memory_first gate]: Search memory before exploring files. Use mcp__moflo__memory_search.\\n' + MCP_FALLBACK_NOTE + '\\n' + GATE_ORIGIN_NOTE + '\\n' + GATE_DISABLE_NOTE + '\\n');
876
- process.exit(2);
877
- }
878
- case 'check-before-read': {
879
- if (!config.memory_first) break;
880
- var s = readState();
881
- if (!s.memoryRequired || isMemorySearchedFor(s)) break;
882
- var fp = process.env.TOOL_INPUT_file_path || '';
883
- if (isEphemeralPath(fp)) break;
884
- if (fp.indexOf('.claude/guidance/') < 0 && fp.indexOf('.claude\\\\guidance\\\\') < 0) break;
885
- process.stderr.write('BLOCKED [moflo memory_first gate]: Search memory before reading guidance files. Use mcp__moflo__memory_search.\\n' + MCP_FALLBACK_NOTE + '\\n' + GATE_ORIGIN_NOTE + '\\n' + GATE_DISABLE_NOTE + '\\n');
886
- process.exit(2);
887
- }
888
- case 'record-task-created': {
889
- var s = readState();
890
- s.tasksCreated = true;
891
- s.taskCount = (s.taskCount || 0) + 1;
892
- writeState(s);
893
- break;
894
- }
895
- // #1435 — the escape from the task-status gate, for work deliberately left
896
- // open. Session-scoped via STATE_DEFAULTS; no prompt or edit reset touches it.
897
- case 'record-tasks-acknowledged': {
898
- var s = readState();
899
- if (!s.tasksAcknowledged) {
900
- s.tasksAcknowledged = true;
901
- writeState(s);
902
- }
903
- // writeState swallows its own errors so a gate never crashes its hook. This
904
- // is the ONLY escape from a BLOCKING gate, so an unconfirmed write would
905
- // report "satisfied" and block the next 'gh pr create' anyway — confirm it.
906
- if (!readState().tasksAcknowledged) {
907
- process.stderr.write('Task-status gate NOT satisfied: the acknowledgement could not be persisted to\\n' +
908
- STATE_FILE + '\\n' +
909
- 'Check that the file and its directory are writable, then run this again.\\n' +
910
- 'To proceed without it: set gates: task_status_gate: off in moflo.yaml.\\n');
911
- process.exit(1);
912
- }
913
- process.stdout.write('Task-status gate satisfied: open tasks acknowledged as deliberately deferred.\\n' +
914
- 'They stay visible in the task list — this records the decision, it does not close them.\\n');
915
- break;
916
- }
917
- case 'record-memory-searched': {
918
- var s = readState();
919
- if (markMemorySearched(s)) writeState(s);
920
- break;
921
- }
922
- case 'check-bash-memory': {
923
- // #1132 — credit + block. See bin/gate.cjs for full documentation.
924
- var cmd = process.env.TOOL_INPUT_command || '';
925
- if (creditsMemorySearch(cmd)) {
926
- var s = readState();
927
- if (markMemorySearched(s)) writeState(s);
928
- break;
929
- }
930
- if (!config.memory_first) break;
931
- if (!READ_LIKE_BASH_RE.test(cmd)) break;
932
- if (BASH_CARVE_OUT_RE.test(cmd)) break;
933
- var s2 = readState();
934
- if (!s2.memoryRequired || isMemorySearchedFor(s2)) break;
935
- // Hint precedence: prompt classification → command-shape classification.
936
- // See bin/gate.cjs check-bash-memory for full rationale.
937
- var hint = s2.lastNamespaceHint || classifyBashNamespaceHint(cmd) || '';
938
- process.stderr.write(
939
- 'BLOCKED [moflo memory_first gate]: Search memory before reading files via Bash.\\n' +
940
- 'Example: mcp__moflo__memory_search { query: "<topic>", namespace: "<one of: guidance | code-map | patterns | learnings | tests>" }\\n' +
941
- (hint ? hint + '\\n' : '') +
942
- 'On chunk hits, traverse via mcp__moflo__memory_get_neighbors — see .claude/guidance/moflo-memory-protocol.md\\n' +
943
- MCP_FALLBACK_NOTE + '\\n' +
944
- GATE_ORIGIN_NOTE + '\\n' +
945
- GATE_DISABLE_NOTE + '\\n'
946
- );
947
- process.exit(2);
948
- break;
949
- }
950
- case 'check-task-transition': {
951
- // Intentional no-op, retained for backwards compatibility only (#1331).
952
- // The ^TaskUpdate$ wiring was removed — see bin/gate.cjs for the full note
953
- // and applyPromptStateReset() for why the memory gate resets per-prompt
954
- // rather than per-task-transition.
955
- break;
956
- }
957
- // #1434 — a mandatory write with nothing to say produces filler that displaces
958
- // reusable lessons from every future bounded search. Both credits set the same
959
- // flag and differ only in whether the run has something to say, so they share
960
- // one case body. SYNC: mirrors bin/gate.cjs.
961
- case 'record-learnings-stored':
962
- case 'record-no-durable-lesson': {
963
- var s = readState();
964
- if (!s.learningsStored) {
965
- s.learningsStored = true;
966
- writeState(s);
967
- }
968
- if (command === 'record-no-durable-lesson') {
969
- // Same reasoning as record-tasks-acknowledged: this is the ONLY escape
970
- // from the BLOCKING learnings gate that needs no memory_store, so an
971
- // unconfirmed write would report "satisfied" and block anyway. Verified
972
- // only on this arm — record-learnings-stored fires automatically on every
973
- // memory_store, where a lost write still leaves the ordinary way through.
974
- if (!readState().learningsStored) {
975
- process.stderr.write('Learnings gate NOT satisfied: the declaration could not be persisted to\\n' +
976
- STATE_FILE + '\\n' +
977
- 'Check that the file and its directory are writable, then run this again.\\n' +
978
- 'To proceed without it: set gates: learnings_gate: false in moflo.yaml.\\n');
979
- process.exit(1);
980
- }
981
- process.stdout.write(
982
- 'Learnings gate satisfied: no durable lesson declared for this run.\\n' +
983
- 'What this run did belongs in the PR body, not in memory.\\n',
984
- );
985
- }
986
- break;
987
- }
988
- case 'record-test-run': {
989
- var cmd = process.env.TOOL_INPUT_command || '';
990
- if (TEST_RUNNER_RE.test(cmd)) {
991
- // #1322 — a red run is evidence AGAINST the gate, so it also clears a
992
- // flag an earlier green run earned.
993
- var failure = detectTestFailure();
994
- var s = readState();
995
- if (failure) {
996
- if (s.testsRun) { s.testsRun = false; writeState(s); }
997
- process.stderr.write('gate: record-test-run not credited — ' + failure + '\\n');
998
- } else if (!s.testsRun) {
999
- s.testsRun = true;
1000
- writeState(s);
1001
- }
1002
- }
1003
- break;
1004
- }
1005
- case 'record-skill-run': {
1006
- var skName = (process.env.TOOL_INPUT_skill || '');
1007
- if (skName === 'simplify' || skName === 'flo-simplify' || skName === 'distill') {
1008
- var s = readState();
1009
- if (!s.simplifyRun) {
1010
- s.simplifyRun = true;
1011
- writeState(s);
1012
- }
1013
- }
1014
- break;
1015
- }
1016
- case 'record-verify-run': {
1017
- // Story #1274 (Epic #1269) — credit the native /verify skill for the
1018
- // verify-before-done gate.
1019
- var vName = (process.env.TOOL_INPUT_skill || '');
1020
- // Only /verify satisfies the gate — /ward and /quicken are audits, not
1021
- // end-to-end verification (see fl/sdd.md).
1022
- if (vName === 'verify') {
1023
- var s = readState();
1024
- // #1332: starting a verification clears any prior verdict, so the run
1025
- // in progress cannot inherit a previous issue's PASS.
1026
- if (!s.verifyRun || s.verifyOutcome) {
1027
- s.verifyRun = true;
1028
- s.verifyOutcome = null;
1029
- writeState(s);
1030
- }
1031
- }
1032
- break;
1033
- }
1034
- case 'record-verify-outcome': {
1035
- // #1332 — record HOW the verification ended, from the structured record
1036
- // #1328 has /verify write to memory_store's \`metadata\`. Never parsed out
1037
- // of the prose \`value\`; gate-hook.mjs forwards the object as JSON.
1038
- var mKey = process.env.TOOL_INPUT_key || '';
1039
- if (mKey.indexOf('verify:') !== 0) break;
1040
- var rawMeta = process.env.TOOL_INPUT_metadata || '';
1041
- if (!rawMeta) break;
1042
- var parsedMeta = null;
1043
- try { parsedMeta = JSON.parse(rawMeta); } catch (e) { parsedMeta = null; }
1044
- if (!parsedMeta || typeof parsedMeta !== 'object' || parsedMeta.type !== 'verify-record') break;
1045
- var overall = typeof parsedMeta.overall === 'string' ? parsedMeta.overall.toUpperCase() : '';
1046
- if (overall !== 'PASS' && overall !== 'FAIL' && overall !== 'UNVERIFIED') overall = 'UNVERIFIED';
1047
- var vs = readState();
1048
- // #1348 — a verdict that arrives after a code edit cleared verifyRun
1049
- // describes pre-edit code; recording it leaves state self-contradictory.
1050
- if (!vs.verifyRun) break;
1051
- vs.verifyOutcome = overall;
1052
- writeState(vs);
1053
- break;
1054
- }
1055
- case 'reset-edit-gates': {
1056
- var fp = process.env.TOOL_INPUT_file_path || '';
1057
- // Inert files (markdown, lockfiles, CHANGELOG, .env.example) and inert paths
1058
- // (.github meta dirs, .moflo state): no gate reset.
1059
- if (fp && (EDIT_RESET_SKIP_BOTH_RE.test(fp) || EDIT_RESET_SKIP_PATH_RE.test(fp))) break;
1060
- // #1348 — a scratchpad write under the OS temp dir is transient tool I/O,
1061
- // never a code edit, so it must not reset tests/simplify/verify. SYNC:
1062
- // mirrors bin/gate.cjs, which carries the full rationale.
1063
- if (isEphemeralPath(fp)) break;
1064
- var s = readState();
1065
- // Test-only edits invalidate testsRun but preserve simplifyRun (#908).
1066
- var isTestOnly = fp && EDIT_RESET_SKIP_SIMPLIFY_ONLY_RE.test(fp);
1067
- var resetTests = s.testsRun;
1068
- // A code edit invalidates a prior verification (Story #1274), like tests.
1069
- // #1332: also fires on a lingering verdict, so no stale PASS survives.
1070
- var resetVerify = s.verifyRun || !!s.verifyOutcome;
1071
- var resetSimplify = s.simplifyRun && !isTestOnly;
1072
- if (!resetTests && !resetSimplify && !resetVerify) break;
1073
- var gates = [];
1074
- if (resetTests) { s.testsRun = false; gates.push('tests'); }
1075
- if (resetVerify) { s.verifyRun = false; s.verifyOutcome = null; gates.push('verify'); }
1076
- if (resetSimplify) { s.simplifyRun = false; gates.push('simplify'); }
1077
- if (fp) {
1078
- s.lastResetBy = { file: fp, at: new Date().toISOString(), gates: gates };
1079
- }
1080
- writeState(s);
1081
- break;
1082
- }
1083
- case 'check-before-implement': {
1084
- // #1297 — SDD front-half backstop. Block source Write/Edit until a spec
1085
- // exists and its plan is reviewed, when the run is armed for SDD. SYNC:
1086
- // mirrors bin/gate.cjs. Disarmed (non-SDD) runs pass instantly.
1087
- if (!config.sdd_gate) break;
1088
- var si = readState();
1089
- if (!si.sddMode) break;
1090
- var fpi = process.env.TOOL_INPUT_file_path || '';
1091
- if (!fpi) break;
1092
- if (EXEMPT.some(function (e) { return fpi.indexOf(e) >= 0; })) break;
1093
- if (!SOURCE_FILE_RE.test(fpi)) break;
1094
- if (EDIT_RESET_SKIP_PATH_RE.test(fpi)) break;
1095
- if (isInsideSpecsDir(fpi)) break;
1096
- if (!si.activeSddSlug) {
1097
- process.stderr.write('BLOCKED: SDD mode is on — author a spec before editing source.\\n' +
1098
- 'Run: flo sdd spec "<title>" (then review it, and plan)\\n' +
1099
- 'One-off skip: re-run with --no-sdd. Disable via moflo.yaml: gates: sdd_gate: false\\n');
1100
- process.exit(2);
1101
- }
1102
- if (!isPlanReviewed(si.activeSddSlug)) {
1103
- process.stderr.write('BLOCKED: SDD — the plan for "' + si.activeSddSlug + '" is not reviewed yet.\\n' +
1104
- ' flo sdd plan ' + si.activeSddSlug + '\\n' +
1105
- ' flo sdd review ' + si.activeSddSlug + ' plan\\n' +
1106
- 'One-off skip: re-run with --no-sdd. Disable via moflo.yaml: gates: sdd_gate: false\\n');
1107
- process.exit(2);
1108
- }
1109
- break;
1110
- }
1111
- case 'check-before-pr': {
1112
- var cmd = process.env.TOOL_INPUT_command || '';
1113
- if (!/(?:^|&&\\s*|\\|\\|\\s*|;\\s*)\\s*(?:[A-Z_][A-Z0-9_]*=\\S+\\s+)*gh\\s+pr\\s+create\\b/.test(cmd)) break;
1114
- // #1435 — task-status gate. Subordinate to task_create_first so both halves
1115
- // of the task nag are on or off together; fail-open when the ledger is null.
1116
- // State is read once for the whole case and reused below; reading it before
1117
- // the ledger keeps an acknowledged run off the transcript scan entirely.
1118
- var s = readState();
1119
- if (config.task_create_first && config.task_status_gate !== 'off' && !s.tasksAcknowledged) {
1120
- var ledger = readTaskLedger();
1121
- if (ledger && ledger.open > 0) {
1122
- var tally = ledger.created + ' task' + (ledger.created === 1 ? '' : 's') +
1123
- ' created this session, ' + ledger.open + ' still open.';
1124
- var closeIt = 'Close them with TaskUpdate (status: completed), or delete the ones ' +
1125
- 'that no longer apply, so the run does not report done over an unfinished list.\\n';
1126
- if (config.task_status_gate === 'warn') {
1127
- process.stdout.write('REMINDER: ' + tally + ' ' + closeIt);
1128
- } else {
1129
- process.stderr.write('BLOCKED: ' + tally + '\\n' + closeIt +
1130
- 'Deferring them on purpose is a legitimate outcome — declare it instead of\\n' +
1131
- 'closing tasks that are not done: node "' + __filename + '" record-tasks-acknowledged\\n' +
1132
- GATE_ORIGIN_NOTE + '\\n' +
1133
- 'Report instead of blocking via moflo.yaml: gates: task_status_gate: warn (or: off)\\n');
1134
- process.exit(2);
1135
- }
1136
- }
1137
- }
1138
- var missing = [];
1139
- 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)');
1140
- if (config.simplify_gate && !s.simplifyRun) missing.push('/flo-simplify (or /distill) has not run since the last code edit');
1141
- if (config.learnings_gate && !s.learningsStored) missing.push(LEARNINGS_MISSING);
1142
- if (missing.length === 0) break;
1143
- process.stderr.write('BLOCKED: gh pr create requires the following before opening a PR:\\n');
1144
- for (var i = 0; i < missing.length; i++) {
1145
- process.stderr.write(' - ' + missing[i] + '\\n');
1146
- }
1147
- if (s.lastResetBy && s.lastResetBy.file) {
1148
- process.stderr.write('Last gate reset: ' + s.lastResetBy.file + ' (' + (s.lastResetBy.gates || []).join(', ') + ')\\n');
1149
- }
1150
- process.stderr.write(ORDER_HINT);
1151
- process.stderr.write('Disable per-gate via moflo.yaml:\\n');
1152
- process.stderr.write(' gates:\\n testing_gate: false\\n simplify_gate: false\\n learnings_gate: false\\n');
1153
- process.exit(2);
1154
- }
1155
- case 'check-before-done': {
1156
- // Story #1274 (Epic #1269) + #1294 — verify-before-done. ON by default
1157
- // (#1294); disable via moflo.yaml gates.verify_before_done: false or per-run
1158
- // --no-verify. Same 'gh pr create' trigger as check-before-pr. This
1159
- // template variant intentionally omits the no-source (docs-only) exemption to
1160
- // stay consistent with THIS file's simpler check-before-pr; the full exemption
1161
- // lives in the source bin/gate.cjs that the launcher syncs over this fallback.
1162
- if (!config.verify_before_done) break;
1163
- var cmd = process.env.TOOL_INPUT_command || '';
1164
- if (!/(?:^|&&\\s*|\\|\\|\\s*|;\\s*)\\s*(?:[A-Z_][A-Z0-9_]*=\\S+\\s+)*gh\\s+pr\\s+create\\b/.test(cmd)) break;
1165
- var s = readState();
1166
- // #1332 — gate on the OUTCOME, not on attendance: a /verify returning FAIL
1167
- // is still a successful tool invocation, so verifyRun alone let it through.
1168
- if (s.verifyRun && s.verifyOutcome === 'PASS') break;
1169
- process.stderr.write('BLOCKED: gh pr create requires verification before done:\\n');
1170
- if (!s.verifyRun) {
1171
- process.stderr.write(' - the change has not been verified since the last code edit (run /verify)\\n');
1172
- } else if (s.verifyOutcome === 'FAIL' || s.verifyOutcome === 'UNVERIFIED') {
1173
- process.stderr.write(' - /verify ran and returned ' + s.verifyOutcome + ' — fix the failing criteria, then re-run /verify\\n');
1174
- // #1394 — two causes, opposite remedies. Re-running /verify cannot fix
1175
- // absent wiring, so never prescribe it when the transcriber is missing.
1176
- } else if (!isVerifyOutcomeHookWired()) {
1177
- process.stderr.write(' - \`record-verify-outcome\` is not wired in .claude/settings.json — the verdict cannot be recorded\\n');
1178
- process.stderr.write(' /verify may well have passed; nothing exists to transcribe its result, so re-running it will not help.\\n');
1179
- process.stderr.write(' Fix: run \`flo doctor --fix\`, restart the session, then re-run /verify.\\n');
1180
- } else {
1181
- process.stderr.write(' - /verify ran but recorded no verdict — re-run it so it stores a structured result\\n');
1182
- // #1348 — re-invoking /verify clears the prior verdict by design (#1332),
1183
- // so the obvious recovery lands back here unless Step 5 completes.
1184
- process.stderr.write(' Re-invoking /verify clears the prior verdict, so a re-run that skips Step 5 lands here again.\\n');
1185
- }
1186
- process.stderr.write(ORDER_HINT);
1187
- process.stderr.write('Disable via moflo.yaml:\\n');
1188
- process.stderr.write(' gates:\\n verify_before_done: false\\n');
1189
- process.exit(2);
1190
- }
1191
- case 'check-dangerous-command': {
1192
- // #1171 follow-up — strip quoted bodies + heredocs before substring match.
1193
- // See bin/gate.cjs for full rationale.
1194
- var raw = process.env.TOOL_INPUT_command || '';
1195
- var cmd = stripQuotedAndHeredocs(raw).toLowerCase();
1196
- for (var i = 0; i < DANGEROUS.length; i++) {
1197
- if (cmd.indexOf(DANGEROUS[i]) >= 0) {
1198
- console.log('[BLOCKED] Dangerous command: ' + DANGEROUS[i]);
1199
- process.exit(2);
1200
- }
1201
- }
1202
- break;
1203
- }
1204
- case 'prompt-reminder': {
1205
- // Full per-prompt reset (first UserPromptSubmit hook via prompt-hook.mjs).
1206
- // Owns interactionCount + Context warnings. TaskCreate REMINDER and
1207
- // namespace hint moved to check-before-agent (#931).
1208
- var s = readState();
1209
- var prompt = process.env.CLAUDE_USER_PROMPT || '';
1210
- applyPromptStateReset(s, prompt);
1211
- s.interactionCount = (s.interactionCount || 0) + 1;
1212
- writeState(s);
1213
- // Announce the resolved /flo run modifiers. moflo.yaml was already parsed in
1214
- // THIS process (fresh per prompt — a git pull or mid-session yaml edit is
1215
- // picked up automatically, no cache to invalidate), so this costs no extra
1216
- // read. SYNC: mirrors bin/gate.cjs prompt-reminder.
1217
- var floRun = resolveFloRun(prompt);
1218
- if (floRun.isFlo) {
1219
- console.log(
1220
- '[moflo] /flo run modes (AUTHORITATIVE — use verbatim; do NOT re-derive from the skill defaults): ' +
1221
- 'sdd=' + (floRun.sdd ? 'ON' : 'off') +
1222
- ' verify=' + (floRun.verify ? 'ON' : 'off') +
1223
- ' merge=' + (floRun.merge ? 'ON' : 'off') +
1224
- ' [workflow=' + floRun.workflow + ']'
1225
- );
1226
- if (floRun.sdd && floRun.sddSrc !== 'flag') {
1227
- console.log(
1228
- '[moflo] sdd is ON via ' + floRun.sddSrc + ' — the spec→plan→implement→verify cycle is ' +
1229
- 'MANDATORY this run. Author the spec before editing source (the sdd_gate blocks source ' +
1230
- 'Write/Edit until a reviewed plan exists). One-off opt out: re-run with --no-sdd.'
1231
- );
1232
- }
1233
- if (floRun.merge && floRun.mergeSrc !== 'flag') {
1234
- console.log('[moflo] merge is ON via ' + floRun.mergeSrc + ' — the PR will be auto-merged. Opt out: --no-merge.');
1235
- }
1236
- }
1237
- if (config.context_tracking) {
1238
- var ic = s.interactionCount;
1239
- if (ic > 30) console.log('Context: CRITICAL. Commit, store learnings, suggest new session.');
1240
- else if (ic > 20) console.log('Context: DEPLETED. Checkpoint progress. Recommend /compact or fresh session.');
1241
- else if (ic > 10) console.log('Context: MODERATE. Re-state goal before architectural decisions. Use agents for >300 LOC.');
1242
- }
1243
- break;
1244
- }
1245
- case 'prompt-state-reset': {
1246
- // Defensive safety-net (second UserPromptSubmit hook). Idempotent state
1247
- // reset only — no interactionCount increment, no emission. Ensures the
1248
- // per-prompt reset still happens if prompt-hook.mjs throws (#931). Skip
1249
- // the disk write when prompt-reminder already wrote the byte-identical
1250
- // post-reset state (the normal no-exception path).
1251
- var s = readState();
1252
- var prompt = process.env.CLAUDE_USER_PROMPT || '';
1253
- var before = JSON.stringify(s);
1254
- applyPromptStateReset(s, prompt);
1255
- if (JSON.stringify(s) !== before) writeState(s);
1256
- break;
1257
- }
1258
- case 'compact-guidance': {
1259
- console.log('Pre-Compact: Check CLAUDE.md for rules. Use memory search to recover context after compact.');
1260
- break;
1261
- }
1262
- case 'session-reset': {
1263
- // Derive from STATE_DEFAULTS so adding a new state field requires only one
1264
- // edit (the defaults object).
1265
- writeState(Object.assign({}, STATE_DEFAULTS, { sessionStart: new Date().toISOString() }));
1266
- break;
1267
- }
1268
- default:
1269
- break;
1270
- }
1271
- `;
1272
- }
1273
- /**
1274
- * Generate gate-hook.mjs — ESM wrapper that reads Claude Code stdin JSON
1275
- * and passes tool_name + tool_input + tool_response to gate.cjs via env vars.
1276
- *
1277
- * Claude Code hooks receive context as JSON on stdin but don't set env vars
1278
- * for tool input. This script bridges that gap. It also translates exit code 1
1279
- * from gate.cjs into exit code 2 (which Claude Code requires to block tools).
1280
- *
1281
- * **This must stay byte-identical to `bin/gate-hook.mjs`** — the launcher syncs
1282
- * that file into the same `.claude/helpers/gate-hook.mjs` this generator writes,
1283
- * so any divergence means `flo init` emits one bridge and the next session start
1284
- * silently swaps in another. It had drifted exactly that way before #1322: the
1285
- * generated copy never received #1332's structured-input forwarding and still
1286
- * shelled out via `execSync` string concatenation. Parity is pinned by
1287
- * `tests/guards/gate-hook-parity-guard.test.ts` — when you change one, copy the
1288
- * whole file across; do not hand-merge.
1289
- */
100
+ /** The gate bridge Claude Code's hooks invoke, which shells into gate.cjs */
1290
101
  export function generateGateHookScript() {
1291
- return `#!/usr/bin/env node
1292
- import { execFileSync } from 'child_process';
1293
- import { resolve } from 'path';
1294
-
1295
- var command = process.argv[2];
1296
- if (!command) process.exit(0);
1297
-
1298
- // Read stdin JSON from Claude Code
1299
- var stdinData = '';
1300
- try {
1301
- stdinData = await new Promise(function(res) {
1302
- var data = '';
1303
- var timeout = setTimeout(function() { res(data); }, 500);
1304
- process.stdin.setEncoding('utf-8');
1305
- process.stdin.on('data', function(chunk) { data += chunk; });
1306
- process.stdin.on('end', function() { clearTimeout(timeout); res(data); });
1307
- process.stdin.on('error', function() { clearTimeout(timeout); res(''); });
1308
- if (process.stdin.isTTY) { clearTimeout(timeout); res(''); }
1309
- });
1310
- } catch (e) { /* no stdin */ }
1311
-
1312
- var hookContext = {};
1313
- try { if (stdinData.trim()) hookContext = JSON.parse(stdinData); } catch (e) {}
1314
-
1315
- // Pass tool info as env vars for gate.cjs
1316
- var env = Object.assign({}, process.env);
1317
- if (hookContext.tool_name) env.TOOL_NAME = hookContext.tool_name;
1318
- // Forward Claude Code's session_id so gate.cjs can enforce memory-first
1319
- // per-actor (#838) — each spawned subagent gets its own session_id, so a
1320
- // shared workflow-state.json no longer lets one subagent's directive be
1321
- // silently satisfied by the parent's earlier search.
1322
- if (typeof hookContext.session_id === 'string' && hookContext.session_id) {
1323
- env.HOOK_SESSION_ID = hookContext.session_id;
1324
- }
1325
- // #1374 — forward the transcript path so a gate can read what the session
1326
- // actually DID, not only what workflow-state.json was told about it. Used by
1327
- // check-before-pr to count TaskCreate calls against terminal TaskUpdate calls;
1328
- // the alternative — a \`^TaskUpdate$\` PostToolUse observer — is the wiring #1331
1329
- // removed as pure hot-path overhead, and the transcript already holds the answer.
1330
- // Absent on hosts that don't send it: gates treat that as "unknown" and stay silent.
1331
- if (typeof hookContext.transcript_path === 'string' && hookContext.transcript_path) {
1332
- env.HOOK_TRANSCRIPT_PATH = hookContext.transcript_path;
1333
- }
1334
- // #1332: structured tool inputs are forwarded as JSON, not dropped.
1335
- //
1336
- // This previously forwarded ONLY string values, so any object-valued input was
1337
- // invisible to gate.cjs. That blocked the verify-before-done gate from reading
1338
- // \`/verify\`'s per-criterion verdict, which #1328 stores in memory_store's
1339
- // \`metadata\` — an object. Parsing the verdict out of the prose \`value\` string
1340
- // instead would re-create exactly the free-text dependency #1328 removed.
1341
- //
1342
- // Cross-platform (Rule #1): Windows caps a single environment variable at
1343
- // ~32KB and the whole block at ~32K wide chars, and exceeding it fails the
1344
- // spawn rather than truncating. Newly-forwarded values are therefore skipped
1345
- // when oversized, not clipped — a truncated JSON blob would parse as malformed
1346
- // on the far side and read as a corrupt record rather than an absent one.
1347
- // \`metadata\` is capped at 64KB by memory_store, so a real verdict never nears
1348
- // this. STRING values keep their previous uncapped behaviour byte-for-byte:
1349
- // gate.cjs reads TOOL_INPUT_command, and dropping an oversized heredoc command
1350
- // would silently stop check-dangerous-command from firing on the exact inputs
1351
- // most worth checking.
1352
- var MAX_STRUCTURED_LEN = 16384;
1353
- if (hookContext.tool_input && typeof hookContext.tool_input === 'object') {
1354
- Object.keys(hookContext.tool_input).forEach(function(key) {
1355
- var raw = hookContext.tool_input[key];
1356
- if (typeof raw === 'string') {
1357
- env['TOOL_INPUT_' + key] = raw;
1358
- return;
1359
- }
1360
- var val;
1361
- if (typeof raw === 'number' || typeof raw === 'boolean') {
1362
- val = String(raw);
1363
- } else if (raw && typeof raw === 'object') {
1364
- try { val = JSON.stringify(raw); } catch (e) { return; }
1365
- } else {
1366
- return; // null/undefined/function — nothing meaningful to forward
1367
- }
1368
- if (val.length > MAX_STRUCTURED_LEN) return;
1369
- env['TOOL_INPUT_' + key] = val;
1370
- });
1371
- }
1372
-
1373
- // #1322: forward the parts of tool_response that actually exist, so a gate can
1374
- // observe an OUTCOME rather than only the intent it was handed.
1375
- //
1376
- // Claude Code's PostToolUse payload carries NO exit status — probed on v2.1.220,
1377
- // tool_response for a Bash call is {stdout, stderr, interrupted, isImage,
1378
- // noOutputExpected}. PostToolUse also does not fire at all when the command
1379
- // exits non-zero, so the only case a gate can still be fooled by is an exit code
1380
- // MASKED by a pipe or \`|| true\`, where the response looks clean. The runner's
1381
- // own output is the sole remaining signal; record-test-run reads it in gate.cjs.
1382
- //
1383
- // Tail, not head. Every test runner prints its pass/fail summary LAST, so
1384
- // clipping the front of a long log would discard the exact lines this exists to
1385
- // read. Bounds are deliberately tight — Windows caps the whole environment
1386
- // block at ~32K wide chars and fails the spawn rather than truncating, and
1387
- // TOOL_INPUT_command is already forwarded uncapped alongside these.
1388
- var MAX_RESPONSE_STDOUT = 4096;
1389
- var MAX_RESPONSE_STDERR = 2048;
1390
- function tailOf(value, max) {
1391
- return value.length > max ? value.slice(value.length - max) : value;
102
+ return embedded('gate-hook.mjs');
1392
103
  }
1393
- if (hookContext.tool_response && typeof hookContext.tool_response === 'object') {
1394
- var resp = hookContext.tool_response;
1395
- if (typeof resp.stdout === 'string' && resp.stdout) {
1396
- env.TOOL_RESPONSE_stdout = tailOf(resp.stdout, MAX_RESPONSE_STDOUT);
1397
- }
1398
- if (typeof resp.stderr === 'string' && resp.stderr) {
1399
- env.TOOL_RESPONSE_stderr = tailOf(resp.stderr, MAX_RESPONSE_STDERR);
1400
- }
1401
- // Boolean — the string-typed forwarding above would drop it silently.
1402
- if (typeof resp.interrupted === 'boolean') {
1403
- env.TOOL_RESPONSE_interrupted = String(resp.interrupted);
1404
- }
1405
- }
1406
-
1407
- // #1435 — deliver a PASSING gate's advisory to Claude, not only to the transcript.
1408
- //
1409
- // Claude Code shows a PreToolUse/PostToolUse hook's stdout to the user in
1410
- // transcript mode and stops there; the model never sees it. So every advisory
1411
- // the gates emit on the exit-0 path was invisible on exactly the runs it was
1412
- // written for: #1374's open-task count, the pre-Agent TaskCreate reminder, the
1413
- // namespace hint, the docs-only and simplify-auto-pass notes. They surfaced only
1414
- // when some OTHER gate blocked, because the catch arm below re-routes err.stdout
1415
- // to stderr — i.e. only once the PR had already been stopped for another reason.
1416
- // A consumer shipped a PR over four untouched tasks with that reminder "working".
1417
- //
1418
- // \`hookSpecificOutput.additionalContext\` is the documented channel from a passing
1419
- // tool hook into the model's context. Wrap there and nowhere else: SessionStart
1420
- // and UserPromptSubmit already inject their stdout as context, so wrapping those
1421
- // would rewrite a working path for nothing. An unknown or absent hook_event_name
1422
- // falls back to raw stdout — byte-identical to the previous behaviour.
1423
- var ADVISORY_EVENTS = { PreToolUse: true, PostToolUse: true };
1424
- function emitAdvisory(text) {
1425
- var event = hookContext.hook_event_name;
1426
- if (!ADVISORY_EVENTS[event]) {
1427
- process.stdout.write(text);
1428
- return;
1429
- }
1430
- process.stdout.write(JSON.stringify({
1431
- hookSpecificOutput: { hookEventName: event, additionalContext: text },
1432
- }) + '\\n');
1433
- }
1434
-
1435
- // Run gate.cjs with the enriched environment
1436
- var projectDir = (env.CLAUDE_PROJECT_DIR || process.cwd()).replace(/^\\/([a-z])\\//i, '$1:/');
1437
- var gateScript = resolve(projectDir, '.claude/helpers/gate.cjs');
1438
- try {
1439
- var output = execFileSync('node', [gateScript, command], {
1440
- env: env, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true
1441
- });
1442
- if (output.trim()) emitAdvisory(output);
1443
- process.exit(0);
1444
- } catch (err) {
1445
- // gate.cjs exit(2) = block, exit(1) = also block attempt — translate both to exit(2)
1446
- if (err.stderr) process.stderr.write(err.stderr);
1447
- if (err.stdout) process.stderr.write(err.stdout);
1448
- process.exit(err.status === 2 || err.status === 1 ? 2 : 0);
1449
- }
1450
- `;
1451
- }
1452
- /**
1453
- * Generate prompt-hook.mjs — reads user prompt from Claude Code stdin JSON,
1454
- * runs prompt classification via gate.cjs, and appends namespace hints.
1455
- */
104
+ /** The UserPromptSubmit bridge */
1456
105
  export function generatePromptHookScript() {
1457
- return `#!/usr/bin/env node
1458
- import { execSync } from 'child_process';
1459
- import { resolve } from 'path';
1460
-
1461
- // Read stdin JSON from Claude Code
1462
- var stdinData = '';
1463
- try {
1464
- stdinData = await new Promise(function(res) {
1465
- var data = '';
1466
- var timeout = setTimeout(function() { res(data); }, 500);
1467
- process.stdin.setEncoding('utf-8');
1468
- process.stdin.on('data', function(chunk) { data += chunk; });
1469
- process.stdin.on('end', function() { clearTimeout(timeout); res(data); });
1470
- process.stdin.on('error', function() { clearTimeout(timeout); res(''); });
1471
- if (process.stdin.isTTY) { clearTimeout(timeout); res(''); }
1472
- });
1473
- } catch (e) { /* no stdin */ }
1474
-
1475
- var hookContext = {};
1476
- try { if (stdinData.trim()) hookContext = JSON.parse(stdinData); } catch (e) {}
1477
-
1478
- var userPrompt = hookContext.user_prompt || hookContext.prompt || '';
1479
- var env = Object.assign({}, process.env, { CLAUDE_USER_PROMPT: userPrompt });
1480
-
1481
- // #1397 — forward session_id so prompt-reminder can stamp it onto
1482
- // workflow-state.json; \`flo runs start\` has no other source for it.
1483
- if (typeof hookContext.session_id === 'string' && hookContext.session_id) {
1484
- env.HOOK_SESSION_ID = hookContext.session_id;
106
+ return embedded('prompt-hook.mjs');
1485
107
  }
1486
-
1487
- // Run prompt-reminder via gate.cjs
1488
- var projectDir = (env.CLAUDE_PROJECT_DIR || process.cwd()).replace(/^\\/([a-z])\\//i, '$1:/');
1489
- var gateScript = resolve(projectDir, '.claude/helpers/gate.cjs');
1490
- var output = '';
1491
- try {
1492
- output = execSync('node "' + gateScript + '" prompt-reminder', {
1493
- env: env, encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe']
1494
- });
1495
- } catch (err) { output = (err && err.stdout) || ''; }
1496
-
1497
- // #931 — Namespace hint classification moved into gate.cjs (computed by
1498
- // prompt-reminder, stored on workflow-state, emitted once by check-before-agent).
1499
- var parts = [output.trim()].filter(Boolean);
1500
- if (parts.length) process.stdout.write(parts.join('\\n') + '\\n');
1501
- process.exit(0);
1502
- `;
1503
- }
1504
- /**
1505
- * Generate lightweight hook-handler.cjs — hook dispatch without CLI bootstrap.
1506
- * Handles routing, edit/task tracking, session lifecycle, and notifications.
1507
- * This replaces `npx flo hooks <command>` to avoid spawning a full CLI process.
1508
- */
108
+ /** The PostToolUse / Stop / Notification handler */
1509
109
  export function generateHookHandlerScript() {
1510
- return `#!/usr/bin/env node
1511
- 'use strict';
1512
- var fs = require('fs');
1513
- var path = require('path');
1514
-
1515
- var PROJECT_DIR = process.env.CLAUDE_PROJECT_DIR || process.cwd();
1516
- var METRICS_FILE = path.join(PROJECT_DIR, '.moflo', 'metrics', 'learning.json');
1517
- var command = process.argv[2];
1518
-
1519
- // Read stdin (Claude Code sends hook data as JSON)
1520
- function readStdin() {
1521
- if (process.stdin.isTTY) return Promise.resolve('');
1522
- return new Promise(function(resolve) {
1523
- var data = '';
1524
- var timer = setTimeout(function() {
1525
- process.stdin.removeAllListeners();
1526
- process.stdin.pause();
1527
- resolve(data);
1528
- }, 500);
1529
- process.stdin.setEncoding('utf8');
1530
- process.stdin.on('data', function(chunk) { data += chunk; });
1531
- process.stdin.on('end', function() { clearTimeout(timer); resolve(data); });
1532
- process.stdin.on('error', function() { clearTimeout(timer); resolve(data); });
1533
- process.stdin.resume();
1534
- });
1535
- }
1536
-
1537
- function bumpMetric(key) {
1538
- try {
1539
- var metrics = {};
1540
- if (fs.existsSync(METRICS_FILE)) metrics = JSON.parse(fs.readFileSync(METRICS_FILE, 'utf-8'));
1541
- metrics[key] = (metrics[key] || 0) + 1;
1542
- metrics.lastUpdated = new Date().toISOString();
1543
- var dir = path.dirname(METRICS_FILE);
1544
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
1545
- fs.writeFileSync(METRICS_FILE, JSON.stringify(metrics, null, 2));
1546
- } catch (e) { /* non-fatal */ }
1547
- }
1548
-
1549
- readStdin().then(function(stdinData) {
1550
- var hookInput = {};
1551
- if (stdinData && stdinData.trim()) {
1552
- try { hookInput = JSON.parse(stdinData); } catch (e) { /* ignore */ }
1553
- }
1554
-
1555
- switch (command) {
1556
- case 'route': {
1557
- var prompt = hookInput.prompt || hookInput.command || process.env.PROMPT || '';
1558
- if (prompt) console.log('[INFO] Routing: ' + prompt.substring(0, 80));
1559
- else console.log('[INFO] Ready');
1560
- break;
1561
- }
1562
- case 'pre-edit':
1563
- case 'post-edit':
1564
- bumpMetric('edits');
1565
- console.log('[OK] Edit recorded');
1566
- break;
1567
- case 'pre-task':
1568
- bumpMetric('tasks');
1569
- console.log('[OK] Task started');
1570
- break;
1571
- case 'post-task':
1572
- bumpMetric('tasksCompleted');
1573
- console.log('[OK] Task completed');
1574
- break;
1575
- case 'session-end':
1576
- console.log('[OK] Session ended');
1577
- break;
1578
- case 'notification':
1579
- // Silent — just acknowledge
1580
- break;
1581
- default:
1582
- if (command) console.log('[OK] Hook: ' + command);
1583
- break;
1584
- }
1585
- });
1586
- `;
110
+ return embedded('hook-handler.cjs');
1587
111
  }
1588
112
  //# sourceMappingURL=helpers-generator.js.map