moflo 4.12.6 → 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.
- package/.claude/guidance/shipped/moflo-agent-rules.md +15 -0
- package/.claude/guidance/shipped/moflo-claude-swarm-cohesion.md +10 -2
- package/.claude/helpers/gate-hook.mjs +29 -1
- package/.claude/helpers/gate.cjs +143 -18
- package/.claude/skills/fl/SKILL.md +6 -4
- package/.claude/skills/fl/phases.md +59 -7
- package/README.md +4 -1
- package/bin/gate-hook.mjs +29 -1
- package/bin/gate.cjs +143 -18
- package/bin/lib/hook-io.mjs +19 -2
- package/bin/session-start-launcher.mjs +145 -12
- package/dist/src/cli/init/embedded-helpers.js +25 -0
- package/dist/src/cli/init/helpers-generator.js +71 -1366
- package/dist/src/cli/init/moflo-yaml-template.js +1 -0
- package/dist/src/cli/services/hook-wiring.js +6 -2
- package/dist/src/cli/version.js +1 -1
- package/package.json +4 -3
|
@@ -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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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,1215 +90,23 @@ export function generateHelpers(options) {
|
|
|
193
90
|
return helpers;
|
|
194
91
|
}
|
|
195
92
|
/**
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
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
|
|
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, 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 */ }
|
|
98
|
+
return embedded('gate.cjs');
|
|
255
99
|
}
|
|
256
|
-
|
|
257
|
-
// Load moflo.yaml gate config (defaults: all enabled)
|
|
258
|
-
function loadGateConfig() {
|
|
259
|
-
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 };
|
|
260
|
-
var content = MOFLO_YAML;
|
|
261
|
-
if (content) {
|
|
262
|
-
if (/memory_first:\\s*false/i.test(content)) defaults.memory_first = false;
|
|
263
|
-
if (/task_create_first:\\s*false/i.test(content)) defaults.task_create_first = false;
|
|
264
|
-
if (/context_tracking:\\s*false/i.test(content)) defaults.context_tracking = false;
|
|
265
|
-
if (/testing_gate:\\s*false/i.test(content)) defaults.testing_gate = false;
|
|
266
|
-
if (/simplify_gate:\\s*false/i.test(content)) defaults.simplify_gate = false;
|
|
267
|
-
if (/learnings_gate:\\s*false/i.test(content)) defaults.learnings_gate = false;
|
|
268
|
-
if (/swarm_invocation_gate:\\s*false/i.test(content)) defaults.swarm_invocation_gate = false;
|
|
269
|
-
// Opt-out: on by default (#1294); disable only when explicitly set false.
|
|
270
|
-
if (/verify_before_done:\\s*false/i.test(content)) defaults.verify_before_done = false;
|
|
271
|
-
// #1297 — check-before-implement backstop; opt-out. Only fires when armed.
|
|
272
|
-
if (/sdd_gate:\\s*false/i.test(content)) defaults.sdd_gate = false;
|
|
273
|
-
}
|
|
274
|
-
return defaults;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
// #1297 — parse the top-level sdd: block (default + specs_dir), scoped so we
|
|
278
|
-
// never match a default: key from another section. Tolerates CRLF. SYNC: mirrors
|
|
279
|
-
// bin/gate.cjs loadSddConfig.
|
|
280
|
-
function loadSddConfig() {
|
|
281
|
-
var out = { default: false, specsDir: '.moflo/specs' };
|
|
282
|
-
var content = MOFLO_YAML;
|
|
283
|
-
if (!content) return out;
|
|
284
|
-
var block = content.match(/^sdd:[ \\t]*\\r?\\n((?:[ \\t]+.*(?:\\r?\\n|$))*)/m);
|
|
285
|
-
if (!block) return out;
|
|
286
|
-
var body = block[1];
|
|
287
|
-
if (/^\\s*default:\\s*true\\b/im.test(body)) out.default = true;
|
|
288
|
-
var sd = body.match(/^\\s*specs_dir:\\s*(.+?)\\s*$/im);
|
|
289
|
-
if (sd) {
|
|
290
|
-
var v = sd[1].replace(/\\s+#.*$/, '').replace(/^["']|["']$/g, '').trim();
|
|
291
|
-
if (v) out.specsDir = v;
|
|
292
|
-
}
|
|
293
|
-
return out;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
// #1285 — parse the top-level merge: block. Block-scoped like loadSddConfig.
|
|
297
|
-
// SYNC: mirrors bin/gate.cjs loadMergeConfig.
|
|
298
|
-
function loadMergeConfig() {
|
|
299
|
-
var out = { auto: false };
|
|
300
|
-
var content = MOFLO_YAML;
|
|
301
|
-
if (!content) return out;
|
|
302
|
-
var block = content.match(/^merge:[ \\t]*\\r?\\n((?:[ \\t]+.*(?:\\r?\\n|$))*)/m);
|
|
303
|
-
if (!block) return out;
|
|
304
|
-
if (/^\\s*auto:\\s*true\\b/im.test(block[1])) out.auto = true;
|
|
305
|
-
return out;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
// #1297 — read moflo.yaml once (both loaders parse it; gate fires on every
|
|
309
|
-
// Write/Edit). SYNC: mirrors bin/gate.cjs readMofloYaml.
|
|
310
|
-
function readMofloYaml() {
|
|
311
|
-
try { return fs.readFileSync(path.join(PROJECT_DIR, 'moflo.yaml'), 'utf-8'); }
|
|
312
|
-
catch (e) { return ''; }
|
|
313
|
-
}
|
|
314
|
-
var MOFLO_YAML = readMofloYaml();
|
|
315
|
-
|
|
316
|
-
// #1394 — is the hook that transcribes /verify's verdict wired at all?
|
|
317
|
-
// Distinguishes "agent skipped Step 5" from "nothing can record the verdict";
|
|
318
|
-
// only the first is fixable by re-running /verify. Lazy (blocked path only);
|
|
319
|
-
// unreadable settings → true so a parse failure keeps the generic message.
|
|
320
|
-
// SYNC: mirrors bin/gate.cjs isVerifyOutcomeHookWired.
|
|
321
|
-
function isVerifyOutcomeHookWired() {
|
|
322
|
-
try {
|
|
323
|
-
return fs.readFileSync(path.join(PROJECT_DIR, '.claude', 'settings.json'), 'utf-8')
|
|
324
|
-
.indexOf('record-verify-outcome') >= 0;
|
|
325
|
-
} catch (e) { return true; }
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
var config = loadGateConfig();
|
|
329
|
-
var sddConf = loadSddConfig();
|
|
330
|
-
var mergeConf = loadMergeConfig();
|
|
331
|
-
var command = process.argv[2];
|
|
332
|
-
|
|
333
|
-
var EXEMPT = ['.claude/', '.claude\\\\', 'CLAUDE.md', 'MEMORY.md', 'workflow-state', 'node_modules', 'moflo.yaml'];
|
|
334
|
-
|
|
335
|
-
// Appended to every memory-first denial so a context audit doesn't read gate
|
|
336
|
-
// enforcement as permission misconfiguration (#1307 finding 5). SYNC: mirrors
|
|
337
|
-
// bin/gate.cjs GATE_ORIGIN_NOTE / GATE_DISABLE_NOTE.
|
|
338
|
-
var GATE_ORIGIN_NOTE = 'This is a moflo hook, not a Claude Code permission rule — allow-rules cannot override it.';
|
|
339
|
-
var GATE_DISABLE_NOTE = 'Disable per-gate via moflo.yaml: gates: memory_first: false';
|
|
340
|
-
// #1338 — a session can outlive its moflo MCP connection (Claude Code spawns
|
|
341
|
-
// stdio servers once, at session start). SYNC: mirrors bin/gate.cjs.
|
|
342
|
-
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>';
|
|
343
|
-
// #1338 follow-up — swarm/hive equivalent. SYNC: mirrors bin/gate.cjs.
|
|
344
|
-
var COORD_FALLBACK_NOTE = 'If mcp__moflo__* tools are unavailable this session (MCP server not connected), this satisfies the gate too:';
|
|
345
|
-
// #1348 — the pre-PR gates are order-dependent; naming only the missing one left
|
|
346
|
-
// callers to rediscover the sequence by trial. SYNC: mirrors bin/gate.cjs.
|
|
347
|
-
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';
|
|
348
|
-
// #1294 Finding 3 — exempt ephemeral reads/scans under the OS temp dir
|
|
349
|
-
// (background-task output, scratchpads) from the memory-first gate. Mirrors
|
|
350
|
-
// bin/gate.cjs isEphemeralPath. Cross-platform via os.tmpdir(); normalizes a
|
|
351
|
-
// leading /private so macOS /var-vs-/private/var symlink pairs match.
|
|
352
|
-
function stripPrivate(p) { return p.indexOf('/private/') === 0 ? p.slice('/private'.length) : p; }
|
|
353
|
-
function isEphemeralPath(fp) {
|
|
354
|
-
if (!fp) return false;
|
|
355
|
-
var tmp;
|
|
356
|
-
try { tmp = path.resolve(os.tmpdir()); } catch (e) { return false; }
|
|
357
|
-
var t = stripPrivate(tmp);
|
|
358
|
-
function under(p) { var n = stripPrivate(p); return n === t || n.indexOf(t + path.sep) === 0; }
|
|
359
|
-
var resolved = path.resolve(fp);
|
|
360
|
-
if (!under(resolved)) return false;
|
|
361
|
-
// Symlink staged in tmp could deref to a real file — realpath both (Rule #2).
|
|
362
|
-
try { return under(fs.realpathSync(resolved)); } catch (e) { return true; }
|
|
363
|
-
}
|
|
364
|
-
// #1171 — DANGEROUS gained PS additions to match the matcher widening that now
|
|
365
|
-
// routes the PowerShell tool through check-dangerous-command. See bin/gate.cjs.
|
|
366
|
-
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'];
|
|
367
|
-
// #1132 — Bash memory-first gate regexes. See bin/gate.cjs for documentation.
|
|
368
|
-
// #1171 — READ_LIKE extended with PS-native exploration forms (Get-ChildItem -Recurse,
|
|
369
|
-
// dir /s, Format-Hex). Plain Get-ChildItem stays uncovered (ls-equivalent).
|
|
370
|
-
// #1338 — CREDIT requires a real memory-search INVOCATION, not any command that
|
|
371
|
-
// merely contains the phrase. Matched by basename so flo, flo.cmd, npx.cmd flo
|
|
372
|
-
// and node C:\\\\...\\\\cli.js all credit (Rule #1).
|
|
373
|
-
// SYNC: duplicated verbatim in bin/gate.cjs — see there for full rationale.
|
|
374
|
-
var CREDIT_RUNNER_RE = /^(?:npx|npm|pnpm|yarn|bun|bunx|deno|node|nodejs|tsx|ts-node)(?:\\.(?:cmd|exe|bat|ps1))?$/i;
|
|
375
|
-
var CREDIT_RUNNER_SKIP_RE = /^(?:dlx|exec|run|-y|--yes|-q|--quiet|--silent|--no-install|--)$/i;
|
|
376
|
-
var CREDIT_CLI_RE = /^(?:flo|moflo|claude-flow|cli\\.js|cli\\.mjs)(?:\\.(?:cmd|exe|bat|ps1))?$/i;
|
|
377
|
-
var CREDIT_SEARCH_BIN_RE = /^(?:flo-search(?:\\.(?:cmd|exe|bat|ps1))?|semantic-search\\.mjs)$/i;
|
|
378
|
-
var CREDIT_HINT_RE = /flo|cli\\.m?js|semantic-search/i;
|
|
379
|
-
var CREDIT_MEMORY_VERB_RE = /^(?:search|retrieve)$/i;
|
|
380
|
-
var CREDIT_MEMORY_COMPOUND_RE = /^memory[-_](?:search|retrieve)$/i;
|
|
381
|
-
// Splits on BOTH separators — path.basename honours only the host's (Rule #1).
|
|
382
|
-
function commandBasename(tok) {
|
|
383
|
-
var t = tok.replace(/^["']+|["']+$/g, '');
|
|
384
|
-
var cut = t.lastIndexOf('/');
|
|
385
|
-
var bs = t.lastIndexOf('\\\\');
|
|
386
|
-
if (bs > cut) cut = bs;
|
|
387
|
-
return (cut >= 0 ? t.slice(cut + 1) : t).toLowerCase();
|
|
388
|
-
}
|
|
389
|
-
function mofloSubcommand(seg) {
|
|
390
|
-
var tokens = seg.trim().split(/\\s+/).filter(Boolean);
|
|
391
|
-
var i = 0;
|
|
392
|
-
while (i < tokens.length) {
|
|
393
|
-
var tok = tokens[i];
|
|
394
|
-
if (tok === 'sudo' || /^[A-Za-z_][A-Za-z0-9_]*=/.test(tok)) { i++; continue; }
|
|
395
|
-
if (CREDIT_RUNNER_SKIP_RE.test(tok)) { i++; continue; }
|
|
396
|
-
if (CREDIT_RUNNER_RE.test(commandBasename(tok))) { i++; continue; }
|
|
397
|
-
break;
|
|
398
|
-
}
|
|
399
|
-
if (i >= tokens.length) return null;
|
|
400
|
-
var entry = commandBasename(tokens[i]);
|
|
401
|
-
if (CREDIT_SEARCH_BIN_RE.test(entry)) return ['memory', 'search'];
|
|
402
|
-
if (!CREDIT_CLI_RE.test(entry)) return null;
|
|
403
|
-
var rest = [];
|
|
404
|
-
for (var j = i + 1; j < tokens.length; j++) {
|
|
405
|
-
if (tokens[j].charAt(0) !== '-') rest.push(tokens[j].toLowerCase());
|
|
406
|
-
}
|
|
407
|
-
return rest;
|
|
408
|
-
}
|
|
409
|
-
function mofloSegments(rawCmd) {
|
|
410
|
-
return stripQuotedAndHeredocs(rawCmd || '').split(/[;|&\\n]+/);
|
|
411
|
-
}
|
|
412
|
-
function segmentCreditsMemorySearch(seg) {
|
|
413
|
-
var sub = mofloSubcommand(seg);
|
|
414
|
-
if (!sub || !sub.length) return false;
|
|
415
|
-
if (CREDIT_MEMORY_COMPOUND_RE.test(sub[0])) return true;
|
|
416
|
-
return sub[0] === 'memory' && sub.length > 1 && CREDIT_MEMORY_VERB_RE.test(sub[1]);
|
|
417
|
-
}
|
|
418
|
-
function creditsMemorySearch(rawCmd) {
|
|
419
|
-
if (!CREDIT_HINT_RE.test(rawCmd || '')) return false;
|
|
420
|
-
return mofloSegments(rawCmd).some(segmentCreditsMemorySearch);
|
|
421
|
-
}
|
|
422
|
-
// #1338 follow-up — the CLI runs the SAME in-process handler the MCP tool does
|
|
423
|
-
// and persists the swarm, so it satisfies the #952 gate for real. Recorded only
|
|
424
|
-
// on success (PostToolUse, #1322). SYNC: bin/gate.cjs has the full rationale.
|
|
425
|
-
function bashCoordinationInit(rawCmd) {
|
|
426
|
-
if (!CREDIT_HINT_RE.test(rawCmd || '') || !/\\binit\\b/i.test(rawCmd)) return null;
|
|
427
|
-
var segments = mofloSegments(rawCmd);
|
|
428
|
-
for (var i = 0; i < segments.length; i++) {
|
|
429
|
-
var sub = mofloSubcommand(segments[i]);
|
|
430
|
-
if (!sub || sub.length < 2 || sub[1] !== 'init') continue;
|
|
431
|
-
if (sub[0] === 'swarm') return 'swarm';
|
|
432
|
-
if (sub[0] === 'hive-mind' || sub[0] === 'hive') return 'hive';
|
|
433
|
-
}
|
|
434
|
-
return null;
|
|
435
|
-
}
|
|
436
|
-
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;
|
|
437
|
-
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/;
|
|
438
|
-
// #1171 follow-up — strip quoted bodies + heredocs before DANGEROUS substring
|
|
439
|
-
// match so git commit messages with dangerous-shaped text in quoted bodies do
|
|
440
|
-
// not trip the gate. See bin/gate.cjs for the full rationale. Command-sub
|
|
441
|
-
// bodies are intentionally not stripped (those execute).
|
|
442
|
-
function stripQuotedAndHeredocs(cmd) {
|
|
443
|
-
var out = cmd;
|
|
444
|
-
out = out.replace(/<<-?\\s*['"]?[\\w-]+['"]?[\\s\\S]*$/, '');
|
|
445
|
-
out = out.replace(/<<<\\s*\\S+/g, '');
|
|
446
|
-
out = out.replace(/'[^']*'/g, "''");
|
|
447
|
-
out = out.replace(/"(?:[^"\\\\]|\\\\.)*"/g, '""');
|
|
448
|
-
return out;
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
var DIRECTIVE_RE = /^(yes|no|yeah|yep|nope|sure|ok|okay|correct|right|exactly|perfect)\\b/i;
|
|
452
|
-
var TASK_RE = /\\b(fix|bug|error|implement|add|create|build|write|refactor|debug|test|feature|issue|security|optimi)\\b/i;
|
|
453
|
-
|
|
454
|
-
// Namespace classification (#931). Hint stored on workflow-state and emitted
|
|
455
|
-
// once by check-before-agent at Agent-spawn time — was emitted on every prompt
|
|
456
|
-
// before, costing ~40 tokens × every prompt × every consumer.
|
|
457
|
-
//
|
|
458
|
-
// SYNC: these regexes + classifyNamespaceHint + applyPromptStateReset are
|
|
459
|
-
// duplicated verbatim in bin/gate.cjs (canonical, synced to consumer
|
|
460
|
-
// .claude/helpers/gate.cjs by post-install-bootstrap). Any edit MUST be
|
|
461
|
-
// applied to both — this template is the fallback for the flo-init path
|
|
462
|
-
// where source helpers cannot be located, so it must keep parity.
|
|
463
|
-
var NS_LEARNINGS_RE = /\\b(remember|recall|insight|lesson learned|gotcha|post.?mortem)\\b|we (decid|agree|chose|said)/;
|
|
464
|
-
var NS_TEST_RE = /\\b(test|spec|coverage|tested|test case|test cases|tests for|spec for)\\b/;
|
|
465
|
-
var NS_EXPLICIT = [
|
|
466
|
-
{ pattern: /\\b(pattern|convention|best practice|style|coding rule)\\b/, ns: 'patterns', label: 'code patterns and conventions' },
|
|
467
|
-
{ pattern: /\\b(code.?map|file structure|project structure|directory)\\b/, ns: 'code-map', label: 'codebase navigation' },
|
|
468
|
-
];
|
|
469
|
-
var NS_PATTERN_RES = [/\\b(template|example|similar to|how do we|how should)\\b/];
|
|
470
|
-
var NS_DOMAIN_RES = [
|
|
471
|
-
/\\b(guidance|guide|docs|documentation|rules|how-to)\\b/,
|
|
472
|
-
/\\b(architecture|design|domain|tenant|migrat|schema|deploy)/,
|
|
473
|
-
/\\b(rule|requirement|constraint|compliance)\\b/,
|
|
474
|
-
];
|
|
475
|
-
var NS_NAV_RES = [
|
|
476
|
-
/\\b(find|where|which file|look up|locate|endpoint|route|url|path)\\b/,
|
|
477
|
-
/\\b(class|function|method|component|service|entity|module)\\b/,
|
|
478
|
-
];
|
|
479
|
-
|
|
480
|
-
// Detect whether the current prompt invoked /fl or /flo with a swarm/hive flag
|
|
481
|
-
// (#952). When set, check-before-agent BLOCKS the Agent spawn until the matching
|
|
482
|
-
// MCP init has been recorded — the user explicitly opted in to the protected
|
|
483
|
-
// coordination surface, so falling back to raw Agent dispatch silently regresses
|
|
484
|
-
// headline moflo product capability.
|
|
485
|
-
//
|
|
486
|
-
// SYNC: duplicated verbatim in bin/gate.cjs.
|
|
487
|
-
function detectFlMode(promptText) {
|
|
488
|
-
var p = promptText || '';
|
|
489
|
-
if (!/^\\s*\\/(?:fl|flo)\\b/i.test(p)) return null;
|
|
490
|
-
if (/(?:^|\\s)(?:-s|--swarm)\\b/.test(p)) return 'swarm';
|
|
491
|
-
if (/(?:^|\\s)(?:-h|--hive)\\b/.test(p)) return 'hive';
|
|
492
|
-
return null;
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
// Resolve ALL /flo run modifiers from the prompt + moflo.yaml. Single source of
|
|
496
|
-
// truth for gate arming AND the authoritative announcement below — a second
|
|
497
|
-
// implementation is how sdd.default got silently ignored. Precedence per key:
|
|
498
|
-
// --no-X > -x/--X > moflo.yaml > built-in (sdd opt-in, verify opt-out, merge
|
|
499
|
-
// opt-in). SYNC: mirrors bin/gate.cjs resolveFloRun.
|
|
500
|
-
function resolveFloRun(promptText) {
|
|
501
|
-
var p = promptText || '';
|
|
502
|
-
var out = { isFlo: false, workflow: 'full', sdd: false, verify: false, merge: false,
|
|
503
|
-
sddSrc: 'default', verifySrc: 'default', mergeSrc: 'default' };
|
|
504
|
-
if (!/^\\s*\\/(?:fl|flo)\\b/i.test(p)) return out;
|
|
505
|
-
out.isFlo = true;
|
|
506
|
-
|
|
507
|
-
if (/(?:^|\\s)(?:-wf|--workflow)\\b/.test(p)) out.workflow = 'spell-engine';
|
|
508
|
-
else if (/(?:^|\\s)(?:-r|--research)\\b/.test(p)) out.workflow = 'research';
|
|
509
|
-
else if (/(?:^|\\s)(?:-t|--ticket)\\b/.test(p)) out.workflow = 'ticket';
|
|
510
|
-
var epicBranch = /(?:^|\\s)--epic-branch\\b/.test(p);
|
|
511
|
-
|
|
512
|
-
if (/(?:^|\\s)--no-sdd\\b/.test(p)) { out.sdd = false; out.sddSrc = 'flag'; }
|
|
513
|
-
else if (/(?:^|\\s)(?:-sd|--sdd)\\b/.test(p)) { out.sdd = true; out.sddSrc = 'flag'; }
|
|
514
|
-
else if (sddConf.default) { out.sdd = true; out.sddSrc = 'moflo.yaml sdd.default'; }
|
|
515
|
-
|
|
516
|
-
if (/(?:^|\\s)--no-verify\\b/.test(p)) { out.verify = false; out.verifySrc = 'flag'; }
|
|
517
|
-
else if (/(?:^|\\s)(?:-v|--verify)\\b/.test(p)) { out.verify = true; out.verifySrc = 'flag'; }
|
|
518
|
-
else if (!config.verify_before_done) { out.verify = false; out.verifySrc = 'moflo.yaml gates.verify_before_done'; }
|
|
519
|
-
else { out.verify = true; out.verifySrc = 'default'; }
|
|
520
|
-
if (out.sdd && !out.verify && out.verifySrc !== 'flag') out.verify = true;
|
|
521
|
-
|
|
522
|
-
if (/(?:^|\\s)--no-merge\\b/.test(p)) { out.merge = false; out.mergeSrc = 'flag'; }
|
|
523
|
-
else if (/(?:^|\\s)(?:-m|--merge)\\b/.test(p)) { out.merge = true; out.mergeSrc = 'flag'; }
|
|
524
|
-
else if (mergeConf.auto) { out.merge = true; out.mergeSrc = 'moflo.yaml merge.auto'; }
|
|
525
|
-
|
|
526
|
-
// Re-attribute anything applicability turned off — a false must never carry
|
|
527
|
-
// the source of the value it no longer has. SYNC: mirrors bin/gate.cjs.
|
|
528
|
-
if (out.workflow === 'ticket' || out.workflow === 'research') {
|
|
529
|
-
if (out.verify) out.verifySrc = out.workflow + ' mode does not implement';
|
|
530
|
-
out.verify = false;
|
|
531
|
-
}
|
|
532
|
-
if (out.workflow === 'research' || out.workflow === 'spell-engine') {
|
|
533
|
-
if (out.sdd) out.sddSrc = out.workflow + ' mode produces no spec artifacts';
|
|
534
|
-
out.sdd = false;
|
|
535
|
-
}
|
|
536
|
-
if (out.workflow !== 'full' || epicBranch) {
|
|
537
|
-
if (out.merge) out.mergeSrc = epicBranch ? '--epic-branch owns merging' : out.workflow + ' mode opens no PR';
|
|
538
|
-
out.merge = false;
|
|
539
|
-
}
|
|
540
|
-
return out;
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
// #1297 — arm the SDD implement gate from a /flo prompt. Thin wrapper so the
|
|
544
|
-
// armed decision and the announced decision can never disagree.
|
|
545
|
-
function detectSddMode(promptText) {
|
|
546
|
-
return resolveFloRun(promptText).sdd;
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
// SDD specs-root resolution + artifact helpers for check-before-implement.
|
|
550
|
-
// SYNC: mirrors bin/gate.cjs. Rule #1: no separator hardcoded; CRLF-tolerant.
|
|
551
|
-
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;
|
|
552
|
-
function sddSpecsRootAbs() {
|
|
553
|
-
var configured = (sddConf.specsDir || '.moflo/specs');
|
|
554
|
-
var segments = configured.split(/[\\\\/]+/).filter(Boolean);
|
|
555
|
-
var escapes = segments.length === 0
|
|
556
|
-
|| segments.indexOf('..') >= 0
|
|
557
|
-
|| /^([a-zA-Z]:|~)$/.test(segments[0])
|
|
558
|
-
|| configured.charAt(0) === '/'
|
|
559
|
-
|| configured.charAt(0) === '\\\\';
|
|
560
|
-
if (escapes) return path.join(PROJECT_DIR, '.moflo', 'specs');
|
|
561
|
-
return path.join.apply(path, [PROJECT_DIR].concat(segments));
|
|
562
|
-
}
|
|
563
|
-
function isInsideSpecsDir(filePath) {
|
|
564
|
-
try {
|
|
565
|
-
var root = sddSpecsRootAbs();
|
|
566
|
-
var abs = path.isAbsolute(filePath) ? filePath : path.resolve(PROJECT_DIR, filePath);
|
|
567
|
-
var rel = path.relative(root, abs);
|
|
568
|
-
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
569
|
-
} catch (e) { return false; }
|
|
570
|
-
}
|
|
571
|
-
function isPlanReviewed(slug) {
|
|
572
|
-
try {
|
|
573
|
-
var planPath = path.join(sddSpecsRootAbs(), slug, 'plan.md');
|
|
574
|
-
if (!fs.existsSync(planPath)) return false;
|
|
575
|
-
var content = fs.readFileSync(planPath, 'utf-8').replace(/\\r\\n/g, '\\n');
|
|
576
|
-
var fm = content.match(/^---\\n([\\s\\S]*?)\\n---/);
|
|
577
|
-
if (!fm) return false;
|
|
578
|
-
return /^\\s*status:\\s*["']?reviewed["']?\\s*$/im.test(fm[1]);
|
|
579
|
-
} catch (e) { return false; }
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
function classifyNamespaceHint(promptText) {
|
|
583
|
-
var lower = (promptText || '').toLowerCase();
|
|
584
|
-
if (NS_TEST_RE.test(lower)) return 'Memory namespace hint: use "tests" for test inventory and coverage lookups.';
|
|
585
|
-
if (NS_LEARNINGS_RE.test(lower)) return 'Memory namespace hint: use "learnings" for user-directed decisions and distilled insights.';
|
|
586
|
-
for (var i = 0; i < NS_EXPLICIT.length; i++) {
|
|
587
|
-
if (NS_EXPLICIT[i].pattern.test(lower)) return 'Memory namespace hint: use "' + NS_EXPLICIT[i].ns + '" for ' + NS_EXPLICIT[i].label + '.';
|
|
588
|
-
}
|
|
589
|
-
for (var j = 0; j < NS_DOMAIN_RES.length; j++) {
|
|
590
|
-
if (NS_DOMAIN_RES[j].test(lower)) return 'Memory namespace hint: search "guidance" and "learnings" for domain rules and project decisions.';
|
|
591
|
-
}
|
|
592
|
-
for (var k = 0; k < NS_PATTERN_RES.length; k++) {
|
|
593
|
-
if (NS_PATTERN_RES[k].test(lower)) return 'Memory namespace hint: use "patterns" for code patterns and conventions.';
|
|
594
|
-
}
|
|
595
|
-
for (var m = 0; m < NS_NAV_RES.length; m++) {
|
|
596
|
-
if (NS_NAV_RES[m].test(lower)) return 'Memory namespace hint: use "code-map" for codebase navigation.';
|
|
597
|
-
}
|
|
598
|
-
return '';
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
// #1132 — command-shape namespace classifier for the bash-BLOCK message.
|
|
602
|
-
// SYNC: duplicated verbatim in bin/gate.cjs. See that file for rationale.
|
|
603
|
-
function classifyBashNamespaceHint(cmd) {
|
|
604
|
-
if (/^\\s*(?:grep|rg|ag|fgrep|egrep|find|fd|Select-String|sls)\\b/i.test(cmd)) {
|
|
605
|
-
return 'Memory namespace hint: use "code-map" for codebase navigation.';
|
|
606
|
-
}
|
|
607
|
-
if (/^\\s*(?:cat|head|tail|less|more|bat|type|Get-Content|gc)\\b.*\\.(?:md|mdx|rst|txt)\\b/i.test(cmd)
|
|
608
|
-
|| /^\\s*(?:cat|head|tail|less|more|bat|type|Get-Content|gc)\\b.*\\b(?:README|CLAUDE|CHANGELOG|CONTRIBUTING|LICENSE)\\b/i.test(cmd)) {
|
|
609
|
-
return 'Memory namespace hint: search "guidance" and "learnings" for project rules and decisions.';
|
|
610
|
-
}
|
|
611
|
-
return '';
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
function applyPromptStateReset(state, promptText) {
|
|
615
|
-
state.memorySearched = false;
|
|
616
|
-
state.memorySearchedBy = {};
|
|
617
|
-
var DIRECTIVE_MAX_LEN = 20;
|
|
618
|
-
var escaped = /^@@\\s*/.test(promptText || '');
|
|
619
|
-
state.memoryRequired = !escaped && (promptText || '').length >= 4 && (TASK_RE.test(promptText || '') || (promptText || '').length > DIRECTIVE_MAX_LEN);
|
|
620
|
-
state.lastNamespaceHint = classifyNamespaceHint(promptText);
|
|
621
|
-
// Per-actor emission tracking — fresh window each prompt so subagents that
|
|
622
|
-
// spawn their own agents still see the hint on their first check-before-agent.
|
|
623
|
-
state.lastNamespaceHintEmittedBy = {};
|
|
624
|
-
// #952 — derive flMode from the user prompt and reset the matching init
|
|
625
|
-
// flag. Each /fl invocation must call its protected MCP init.
|
|
626
|
-
state.flMode = detectFlMode(promptText);
|
|
627
|
-
state.swarmInitialized = false;
|
|
628
|
-
state.hiveInitialized = false;
|
|
629
|
-
// #1297 — arm/disarm SDD implement gate per prompt; fresh run has no active slug.
|
|
630
|
-
state.sddMode = detectSddMode(promptText);
|
|
631
|
-
state.activeSddSlug = null;
|
|
632
|
-
}
|
|
633
|
-
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;
|
|
634
|
-
// #1322 — failure markers in a test runner's own OUTPUT.
|
|
635
|
-
//
|
|
636
|
-
// This is deliberately not an exit-code check: Claude Code's PostToolUse payload
|
|
637
|
-
// carries no exit status, and PostToolUse does not fire at all when a command
|
|
638
|
-
// exits non-zero — so an unmasked red suite already leaves testsRun false, by
|
|
639
|
-
// accident of the hook lifecycle rather than by design. What DOES defeat the
|
|
640
|
-
// gate is a masked exit (\`npm test | tail -20\`, \`npm test || true\`,
|
|
641
|
-
// \`npm test 2>&1 | grep -i fail\`): the pipeline exits 0, PostToolUse fires with
|
|
642
|
-
// a clean-looking response, and a red suite credits the gate. Output is the only
|
|
643
|
-
// signal left, and it is genuinely weaker than a status — see the ticket.
|
|
644
|
-
//
|
|
645
|
-
// Every arm matches a SUMMARY shape a runner emits, never a bare "fail", which
|
|
646
|
-
// occurs constantly in ordinary passing test names ("returns null when the
|
|
647
|
-
// lookup failed"). The count arm excludes an explicit zero so jest's
|
|
648
|
-
// \`0 failed, 12 passed\` cannot self-block.
|
|
649
|
-
//
|
|
650
|
-
// The count arm's trailing lookahead is what keeps a GREEN run from blocking
|
|
651
|
-
// itself. Mocha's default spec reporter prints every passing test name, so
|
|
652
|
-
// \`npm test | tail -20\` on a green suite legitimately contains lines like
|
|
653
|
-
// \`✓ handles 2 failed retries\`. A real summary is followed by a delimiter or a
|
|
654
|
-
// line end (\`3 failed | 40 passed\`, \`1 failed, 2 passed\`, \`1 failing\`), never by
|
|
655
|
-
// more prose — so a lowercase word after the count means it is a sentence, not a
|
|
656
|
-
// tally. \`tests\`/\`test\` is exempted because \`2 failed tests\` is a real summary.
|
|
657
|
-
// Same-line whitespace only: at a line end there is nothing to disqualify.
|
|
658
|
-
var TEST_FAILURE_RE = new RegExp([
|
|
659
|
-
'\\\\b(?!0\\\\b)\\\\d+\\\\s+(?:tests?\\\\s+)?(?:failed|failing|failures?)\\\\b(?![^\\\\S\\\\n]+(?!tests?\\\\b)[a-z])', // vitest/jest/pytest/mocha counts
|
|
660
|
-
'^\\\\s*(?:FAIL|FAILED)\\\\b', // vitest + jest per-file, pytest FAILED
|
|
661
|
-
'^\\\\s*---\\\\s*FAIL:', // go test
|
|
662
|
-
'\\\\btest result:\\\\s*FAILED\\\\b', // cargo
|
|
663
|
-
'^npm ERR!', // npm wrapper around any of the above
|
|
664
|
-
].join('|'), 'im');
|
|
665
|
-
|
|
666
|
-
/**
|
|
667
|
-
* #1322 — why a just-fired record-test-run must NOT be credited, or null.
|
|
668
|
-
*
|
|
669
|
-
* Absent output is not evidence of failure: a quiet green \`npm test > /dev/null\`
|
|
670
|
-
* and a silently-masked red one are indistinguishable, and treating the pair as
|
|
671
|
-
* failures would block every consumer who redirects test output. Absent means
|
|
672
|
-
* unknown, and unknown keeps the pre-#1322 behaviour.
|
|
673
|
-
*/
|
|
674
|
-
function detectTestFailure() {
|
|
675
|
-
if (process.env.TOOL_RESPONSE_interrupted === 'true') return 'the run was interrupted';
|
|
676
|
-
var out = (process.env.TOOL_RESPONSE_stdout || '') + '\\n' + (process.env.TOOL_RESPONSE_stderr || '');
|
|
677
|
-
if (!out.trim()) return null;
|
|
678
|
-
var hit = out.match(TEST_FAILURE_RE);
|
|
679
|
-
return hit ? 'output reports "' + hit[0].trim().slice(0, 40) + '"' : null;
|
|
680
|
-
}
|
|
681
|
-
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;
|
|
682
|
-
// #1297 — path-inert dirs (.github/workflows etc.); SYNC: mirrors bin/gate.cjs EDIT_RESET_SKIP_PATH_RE.
|
|
683
|
-
// #1348 — plus \`.moflo/\`, moflo's own gitignored state dir: nothing written
|
|
684
|
-
// there can reach the branch diff, so it must not invalidate a gate.
|
|
685
|
-
// #1395 — \`.claude/\` CONFIG (settings/skills/guidance/agents) joins them: it is
|
|
686
|
-
// not the code under verification, and it is the directory a user edits because
|
|
687
|
-
// a gate told them to. \`scripts/\`/\`helpers/\` stay OUT — they are executable.
|
|
688
|
-
var EDIT_RESET_SKIP_PATH_RE = /(?:^|[\\\\\\/])\\.github[\\\\\\/](?:workflows|ISSUE_TEMPLATE|PULL_REQUEST_TEMPLATE)(?:[\\\\\\/.]|$)|(?:^|[\\\\\\/])\\.moflo[\\\\\\/]|(?:^|[\\\\\\/])\\.claude[\\\\\\/](?:settings(?:\\.local)?\\.json$|skills[\\\\\\/]|guidance[\\\\\\/]|agents[\\\\\\/])/i;
|
|
689
|
-
// Test files: invalidate testsRun but preserve simplifyRun (#908) — /simplify
|
|
690
|
-
// already reviewed the production code, touching tests/fixtures doesn't expose
|
|
691
|
-
// new untested surface for code review.
|
|
692
|
-
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;
|
|
693
|
-
|
|
694
|
-
switch (command) {
|
|
695
|
-
case 'check-before-agent': {
|
|
696
|
-
// Mostly advisory. The TaskCreate + memory reminders below go to stdout and
|
|
697
|
-
// never block — their wording must not claim otherwise (#1326). The one
|
|
698
|
-
// exception is the #952 swarm/hive check at the bottom of this case, which
|
|
699
|
-
// writes to stderr and exits 2.
|
|
700
|
-
// Memory-first enforcement otherwise happens at the scan/read gate layer.
|
|
701
|
-
// SubagentStart hook injects guidance directive into subagent context.
|
|
702
|
-
// #931 — TaskCreate REMINDER + namespace hint moved here from
|
|
703
|
-
// prompt-reminder so they emit only when Claude is about to spawn an Agent.
|
|
704
|
-
var s = readState();
|
|
705
|
-
if (config.task_create_first && !s.tasksCreated) {
|
|
706
|
-
process.stdout.write('REMINDER: Use TaskCreate before spawning agents.\\n');
|
|
707
|
-
}
|
|
708
|
-
if (config.memory_first && s.memoryRequired && !s.memorySearched) {
|
|
709
|
-
process.stdout.write('REMINDER: Search memory (mcp__moflo__memory_search) before spawning agents.\\n');
|
|
710
|
-
}
|
|
711
|
-
if (s.lastNamespaceHint) {
|
|
712
|
-
// Per-actor single-shot — each session_id emits the hint at most once
|
|
713
|
-
// per prompt. Subagents that spawn their own agents still see it on
|
|
714
|
-
// their first check-before-agent because their session_id is its own
|
|
715
|
-
// bucket. Falls back to a _legacy_ bucket when HOOK_SESSION_ID is
|
|
716
|
-
// missing (older Claude Code, direct CLI). The map clears on every
|
|
717
|
-
// new prompt via applyPromptStateReset.
|
|
718
|
-
var sid = process.env.HOOK_SESSION_ID || '';
|
|
719
|
-
var emittedBy = s.lastNamespaceHintEmittedBy || {};
|
|
720
|
-
var bucket = sid || '_legacy_';
|
|
721
|
-
if (!emittedBy[bucket]) {
|
|
722
|
-
process.stdout.write(s.lastNamespaceHint + '\\n');
|
|
723
|
-
emittedBy[bucket] = true;
|
|
724
|
-
s.lastNamespaceHintEmittedBy = emittedBy;
|
|
725
|
-
writeState(s);
|
|
726
|
-
}
|
|
727
|
-
}
|
|
728
|
-
// #952 — when /fl was invoked with -s/-h, the protected MCP init must run
|
|
729
|
-
// BEFORE any Agent spawn. Hard block: the user explicitly opted in to
|
|
730
|
-
// moflo's coordination surface, so silently dispatching Agent calls
|
|
731
|
-
// without mcp__moflo__swarm_init / mcp__moflo__hive-mind_init is the
|
|
732
|
-
// failure mode this gate exists to prevent (CLAUDE.md "⛔ Protected
|
|
733
|
-
// functionality"). Other Agent uses remain advisory.
|
|
734
|
-
if (config.swarm_invocation_gate) {
|
|
735
|
-
if (s.flMode === 'swarm' && !s.swarmInitialized) {
|
|
736
|
-
process.stderr.write('BLOCKED: /fl was invoked with -s/--swarm but mcp__moflo__swarm_init has not been called.\\n');
|
|
737
|
-
process.stderr.write('Run mcp__moflo__swarm_init first, then mcp__moflo__agent_spawn for each role, then dispatch Agent.\\n');
|
|
738
|
-
process.stderr.write(COORD_FALLBACK_NOTE + ' npx flo swarm init --topology hierarchical (then: npx flo agent spawn --type <role>)\\n');
|
|
739
|
-
process.stderr.write('See .claude/skills/fl/execution-modes.md "SWARM mode" and CLAUDE.md "⛔ Protected functionality".\\n');
|
|
740
|
-
process.stderr.write('Disable via moflo.yaml: gates: swarm_invocation_gate: false\\n');
|
|
741
|
-
process.exit(2);
|
|
742
|
-
}
|
|
743
|
-
if (s.flMode === 'hive' && !s.hiveInitialized) {
|
|
744
|
-
process.stderr.write('BLOCKED: /fl was invoked with -h/--hive but mcp__moflo__hive-mind_init has not been called.\\n');
|
|
745
|
-
process.stderr.write('Run mcp__moflo__hive-mind_init first, then dispatch Agent or hive-mind workers.\\n');
|
|
746
|
-
process.stderr.write(COORD_FALLBACK_NOTE + ' npx flo hive-mind init (then: npx flo hive-mind spawn)\\n');
|
|
747
|
-
process.stderr.write('See .claude/skills/fl/execution-modes.md "HIVE-MIND mode" and CLAUDE.md "⛔ Protected functionality".\\n');
|
|
748
|
-
process.stderr.write('Disable via moflo.yaml: gates: swarm_invocation_gate: false\\n');
|
|
749
|
-
process.exit(2);
|
|
750
|
-
}
|
|
751
|
-
}
|
|
752
|
-
break;
|
|
753
|
-
}
|
|
754
|
-
case 'record-bash-swarm-init': {
|
|
755
|
-
// #1338 follow-up — CLI half of record-swarm-init/record-hive-init. Wired
|
|
756
|
-
// PostToolUse so only a SUCCEEDED init credits (#1322). SYNC: bin/gate.cjs.
|
|
757
|
-
var kind = bashCoordinationInit(process.env.TOOL_INPUT_command || '');
|
|
758
|
-
if (kind) {
|
|
759
|
-
var sc = readState();
|
|
760
|
-
var flag = kind === 'swarm' ? 'swarmInitialized' : 'hiveInitialized';
|
|
761
|
-
if (!sc[flag]) { sc[flag] = true; writeState(sc); }
|
|
762
|
-
}
|
|
763
|
-
break;
|
|
764
|
-
}
|
|
765
|
-
case 'record-swarm-init': {
|
|
766
|
-
// #952 — wired to mcp__moflo__swarm_init PostToolUse.
|
|
767
|
-
var s = readState();
|
|
768
|
-
if (!s.swarmInitialized) {
|
|
769
|
-
s.swarmInitialized = true;
|
|
770
|
-
writeState(s);
|
|
771
|
-
}
|
|
772
|
-
break;
|
|
773
|
-
}
|
|
774
|
-
case 'record-hive-init': {
|
|
775
|
-
// #952 — wired to mcp__moflo__hive-mind_init PostToolUse.
|
|
776
|
-
var s = readState();
|
|
777
|
-
if (!s.hiveInitialized) {
|
|
778
|
-
s.hiveInitialized = true;
|
|
779
|
-
writeState(s);
|
|
780
|
-
}
|
|
781
|
-
break;
|
|
782
|
-
}
|
|
783
|
-
case 'check-before-scan': {
|
|
784
|
-
if (!config.memory_first) break;
|
|
785
|
-
var s = readState();
|
|
786
|
-
if (!s.memoryRequired || isMemorySearchedFor(s)) break;
|
|
787
|
-
var target = (process.env.TOOL_INPUT_pattern || '') + ' ' + (process.env.TOOL_INPUT_path || '');
|
|
788
|
-
if (isEphemeralPath(process.env.TOOL_INPUT_path)) break;
|
|
789
|
-
if (EXEMPT.some(function(p) { return target.indexOf(p) >= 0; })) break;
|
|
790
|
-
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');
|
|
791
|
-
process.exit(2);
|
|
792
|
-
}
|
|
793
|
-
case 'check-before-read': {
|
|
794
|
-
if (!config.memory_first) break;
|
|
795
|
-
var s = readState();
|
|
796
|
-
if (!s.memoryRequired || isMemorySearchedFor(s)) break;
|
|
797
|
-
var fp = process.env.TOOL_INPUT_file_path || '';
|
|
798
|
-
if (isEphemeralPath(fp)) break;
|
|
799
|
-
if (fp.indexOf('.claude/guidance/') < 0 && fp.indexOf('.claude\\\\guidance\\\\') < 0) break;
|
|
800
|
-
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');
|
|
801
|
-
process.exit(2);
|
|
802
|
-
}
|
|
803
|
-
case 'record-task-created': {
|
|
804
|
-
var s = readState();
|
|
805
|
-
s.tasksCreated = true;
|
|
806
|
-
s.taskCount = (s.taskCount || 0) + 1;
|
|
807
|
-
writeState(s);
|
|
808
|
-
break;
|
|
809
|
-
}
|
|
810
|
-
case 'record-memory-searched': {
|
|
811
|
-
var s = readState();
|
|
812
|
-
if (markMemorySearched(s)) writeState(s);
|
|
813
|
-
break;
|
|
814
|
-
}
|
|
815
|
-
case 'check-bash-memory': {
|
|
816
|
-
// #1132 — credit + block. See bin/gate.cjs for full documentation.
|
|
817
|
-
var cmd = process.env.TOOL_INPUT_command || '';
|
|
818
|
-
if (creditsMemorySearch(cmd)) {
|
|
819
|
-
var s = readState();
|
|
820
|
-
if (markMemorySearched(s)) writeState(s);
|
|
821
|
-
break;
|
|
822
|
-
}
|
|
823
|
-
if (!config.memory_first) break;
|
|
824
|
-
if (!READ_LIKE_BASH_RE.test(cmd)) break;
|
|
825
|
-
if (BASH_CARVE_OUT_RE.test(cmd)) break;
|
|
826
|
-
var s2 = readState();
|
|
827
|
-
if (!s2.memoryRequired || isMemorySearchedFor(s2)) break;
|
|
828
|
-
// Hint precedence: prompt classification → command-shape classification.
|
|
829
|
-
// See bin/gate.cjs check-bash-memory for full rationale.
|
|
830
|
-
var hint = s2.lastNamespaceHint || classifyBashNamespaceHint(cmd) || '';
|
|
831
|
-
process.stderr.write(
|
|
832
|
-
'BLOCKED [moflo memory_first gate]: Search memory before reading files via Bash.\\n' +
|
|
833
|
-
'Example: mcp__moflo__memory_search { query: "<topic>", namespace: "<one of: guidance | code-map | patterns | learnings | tests>" }\\n' +
|
|
834
|
-
(hint ? hint + '\\n' : '') +
|
|
835
|
-
'On chunk hits, traverse via mcp__moflo__memory_get_neighbors — see .claude/guidance/moflo-memory-protocol.md\\n' +
|
|
836
|
-
MCP_FALLBACK_NOTE + '\\n' +
|
|
837
|
-
GATE_ORIGIN_NOTE + '\\n' +
|
|
838
|
-
GATE_DISABLE_NOTE + '\\n'
|
|
839
|
-
);
|
|
840
|
-
process.exit(2);
|
|
841
|
-
break;
|
|
842
|
-
}
|
|
843
|
-
case 'check-task-transition': {
|
|
844
|
-
// Intentional no-op, retained for backwards compatibility only (#1331).
|
|
845
|
-
// The ^TaskUpdate$ wiring was removed — see bin/gate.cjs for the full note
|
|
846
|
-
// and applyPromptStateReset() for why the memory gate resets per-prompt
|
|
847
|
-
// rather than per-task-transition.
|
|
848
|
-
break;
|
|
849
|
-
}
|
|
850
|
-
case 'record-learnings-stored': {
|
|
851
|
-
var s = readState();
|
|
852
|
-
if (!s.learningsStored) {
|
|
853
|
-
s.learningsStored = true;
|
|
854
|
-
writeState(s);
|
|
855
|
-
}
|
|
856
|
-
break;
|
|
857
|
-
}
|
|
858
|
-
case 'record-test-run': {
|
|
859
|
-
var cmd = process.env.TOOL_INPUT_command || '';
|
|
860
|
-
if (TEST_RUNNER_RE.test(cmd)) {
|
|
861
|
-
// #1322 — a red run is evidence AGAINST the gate, so it also clears a
|
|
862
|
-
// flag an earlier green run earned.
|
|
863
|
-
var failure = detectTestFailure();
|
|
864
|
-
var s = readState();
|
|
865
|
-
if (failure) {
|
|
866
|
-
if (s.testsRun) { s.testsRun = false; writeState(s); }
|
|
867
|
-
process.stderr.write('gate: record-test-run not credited — ' + failure + '\\n');
|
|
868
|
-
} else if (!s.testsRun) {
|
|
869
|
-
s.testsRun = true;
|
|
870
|
-
writeState(s);
|
|
871
|
-
}
|
|
872
|
-
}
|
|
873
|
-
break;
|
|
874
|
-
}
|
|
875
|
-
case 'record-skill-run': {
|
|
876
|
-
var skName = (process.env.TOOL_INPUT_skill || '');
|
|
877
|
-
if (skName === 'simplify' || skName === 'flo-simplify' || skName === 'distill') {
|
|
878
|
-
var s = readState();
|
|
879
|
-
if (!s.simplifyRun) {
|
|
880
|
-
s.simplifyRun = true;
|
|
881
|
-
writeState(s);
|
|
882
|
-
}
|
|
883
|
-
}
|
|
884
|
-
break;
|
|
885
|
-
}
|
|
886
|
-
case 'record-verify-run': {
|
|
887
|
-
// Story #1274 (Epic #1269) — credit the native /verify skill for the
|
|
888
|
-
// verify-before-done gate.
|
|
889
|
-
var vName = (process.env.TOOL_INPUT_skill || '');
|
|
890
|
-
// Only /verify satisfies the gate — /ward and /quicken are audits, not
|
|
891
|
-
// end-to-end verification (see fl/sdd.md).
|
|
892
|
-
if (vName === 'verify') {
|
|
893
|
-
var s = readState();
|
|
894
|
-
// #1332: starting a verification clears any prior verdict, so the run
|
|
895
|
-
// in progress cannot inherit a previous issue's PASS.
|
|
896
|
-
if (!s.verifyRun || s.verifyOutcome) {
|
|
897
|
-
s.verifyRun = true;
|
|
898
|
-
s.verifyOutcome = null;
|
|
899
|
-
writeState(s);
|
|
900
|
-
}
|
|
901
|
-
}
|
|
902
|
-
break;
|
|
903
|
-
}
|
|
904
|
-
case 'record-verify-outcome': {
|
|
905
|
-
// #1332 — record HOW the verification ended, from the structured record
|
|
906
|
-
// #1328 has /verify write to memory_store's \`metadata\`. Never parsed out
|
|
907
|
-
// of the prose \`value\`; gate-hook.mjs forwards the object as JSON.
|
|
908
|
-
var mKey = process.env.TOOL_INPUT_key || '';
|
|
909
|
-
if (mKey.indexOf('verify:') !== 0) break;
|
|
910
|
-
var rawMeta = process.env.TOOL_INPUT_metadata || '';
|
|
911
|
-
if (!rawMeta) break;
|
|
912
|
-
var parsedMeta = null;
|
|
913
|
-
try { parsedMeta = JSON.parse(rawMeta); } catch (e) { parsedMeta = null; }
|
|
914
|
-
if (!parsedMeta || typeof parsedMeta !== 'object' || parsedMeta.type !== 'verify-record') break;
|
|
915
|
-
var overall = typeof parsedMeta.overall === 'string' ? parsedMeta.overall.toUpperCase() : '';
|
|
916
|
-
if (overall !== 'PASS' && overall !== 'FAIL' && overall !== 'UNVERIFIED') overall = 'UNVERIFIED';
|
|
917
|
-
var vs = readState();
|
|
918
|
-
// #1348 — a verdict that arrives after a code edit cleared verifyRun
|
|
919
|
-
// describes pre-edit code; recording it leaves state self-contradictory.
|
|
920
|
-
if (!vs.verifyRun) break;
|
|
921
|
-
vs.verifyOutcome = overall;
|
|
922
|
-
writeState(vs);
|
|
923
|
-
break;
|
|
924
|
-
}
|
|
925
|
-
case 'reset-edit-gates': {
|
|
926
|
-
var fp = process.env.TOOL_INPUT_file_path || '';
|
|
927
|
-
// Inert files (markdown, lockfiles, CHANGELOG, .env.example) and inert paths
|
|
928
|
-
// (.github meta dirs, .moflo state): no gate reset.
|
|
929
|
-
if (fp && (EDIT_RESET_SKIP_BOTH_RE.test(fp) || EDIT_RESET_SKIP_PATH_RE.test(fp))) break;
|
|
930
|
-
// #1348 — a scratchpad write under the OS temp dir is transient tool I/O,
|
|
931
|
-
// never a code edit, so it must not reset tests/simplify/verify. SYNC:
|
|
932
|
-
// mirrors bin/gate.cjs, which carries the full rationale.
|
|
933
|
-
if (isEphemeralPath(fp)) break;
|
|
934
|
-
var s = readState();
|
|
935
|
-
// Test-only edits invalidate testsRun but preserve simplifyRun (#908).
|
|
936
|
-
var isTestOnly = fp && EDIT_RESET_SKIP_SIMPLIFY_ONLY_RE.test(fp);
|
|
937
|
-
var resetTests = s.testsRun;
|
|
938
|
-
// A code edit invalidates a prior verification (Story #1274), like tests.
|
|
939
|
-
// #1332: also fires on a lingering verdict, so no stale PASS survives.
|
|
940
|
-
var resetVerify = s.verifyRun || !!s.verifyOutcome;
|
|
941
|
-
var resetSimplify = s.simplifyRun && !isTestOnly;
|
|
942
|
-
if (!resetTests && !resetSimplify && !resetVerify) break;
|
|
943
|
-
var gates = [];
|
|
944
|
-
if (resetTests) { s.testsRun = false; gates.push('tests'); }
|
|
945
|
-
if (resetVerify) { s.verifyRun = false; s.verifyOutcome = null; gates.push('verify'); }
|
|
946
|
-
if (resetSimplify) { s.simplifyRun = false; gates.push('simplify'); }
|
|
947
|
-
if (fp) {
|
|
948
|
-
s.lastResetBy = { file: fp, at: new Date().toISOString(), gates: gates };
|
|
949
|
-
}
|
|
950
|
-
writeState(s);
|
|
951
|
-
break;
|
|
952
|
-
}
|
|
953
|
-
case 'check-before-implement': {
|
|
954
|
-
// #1297 — SDD front-half backstop. Block source Write/Edit until a spec
|
|
955
|
-
// exists and its plan is reviewed, when the run is armed for SDD. SYNC:
|
|
956
|
-
// mirrors bin/gate.cjs. Disarmed (non-SDD) runs pass instantly.
|
|
957
|
-
if (!config.sdd_gate) break;
|
|
958
|
-
var si = readState();
|
|
959
|
-
if (!si.sddMode) break;
|
|
960
|
-
var fpi = process.env.TOOL_INPUT_file_path || '';
|
|
961
|
-
if (!fpi) break;
|
|
962
|
-
if (EXEMPT.some(function (e) { return fpi.indexOf(e) >= 0; })) break;
|
|
963
|
-
if (!SOURCE_FILE_RE.test(fpi)) break;
|
|
964
|
-
if (EDIT_RESET_SKIP_PATH_RE.test(fpi)) break;
|
|
965
|
-
if (isInsideSpecsDir(fpi)) break;
|
|
966
|
-
if (!si.activeSddSlug) {
|
|
967
|
-
process.stderr.write('BLOCKED: SDD mode is on — author a spec before editing source.\\n' +
|
|
968
|
-
'Run: flo sdd spec "<title>" (then review it, and plan)\\n' +
|
|
969
|
-
'One-off skip: re-run with --no-sdd. Disable via moflo.yaml: gates: sdd_gate: false\\n');
|
|
970
|
-
process.exit(2);
|
|
971
|
-
}
|
|
972
|
-
if (!isPlanReviewed(si.activeSddSlug)) {
|
|
973
|
-
process.stderr.write('BLOCKED: SDD — the plan for "' + si.activeSddSlug + '" is not reviewed yet.\\n' +
|
|
974
|
-
' flo sdd plan ' + si.activeSddSlug + '\\n' +
|
|
975
|
-
' flo sdd review ' + si.activeSddSlug + ' plan\\n' +
|
|
976
|
-
'One-off skip: re-run with --no-sdd. Disable via moflo.yaml: gates: sdd_gate: false\\n');
|
|
977
|
-
process.exit(2);
|
|
978
|
-
}
|
|
979
|
-
break;
|
|
980
|
-
}
|
|
981
|
-
case 'check-before-pr': {
|
|
982
|
-
var cmd = process.env.TOOL_INPUT_command || '';
|
|
983
|
-
if (!/(?:^|&&\\s*|\\|\\|\\s*|;\\s*)\\s*(?:[A-Z_][A-Z0-9_]*=\\S+\\s+)*gh\\s+pr\\s+create\\b/.test(cmd)) break;
|
|
984
|
-
var s = readState();
|
|
985
|
-
var missing = [];
|
|
986
|
-
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)');
|
|
987
|
-
if (config.simplify_gate && !s.simplifyRun) missing.push('/flo-simplify (or /distill) has not run since the last code edit');
|
|
988
|
-
if (config.learnings_gate && !s.learningsStored) missing.push('learnings have not been stored (call mcp__moflo__memory_store)');
|
|
989
|
-
if (missing.length === 0) break;
|
|
990
|
-
process.stderr.write('BLOCKED: gh pr create requires the following before opening a PR:\\n');
|
|
991
|
-
for (var i = 0; i < missing.length; i++) {
|
|
992
|
-
process.stderr.write(' - ' + missing[i] + '\\n');
|
|
993
|
-
}
|
|
994
|
-
if (s.lastResetBy && s.lastResetBy.file) {
|
|
995
|
-
process.stderr.write('Last gate reset: ' + s.lastResetBy.file + ' (' + (s.lastResetBy.gates || []).join(', ') + ')\\n');
|
|
996
|
-
}
|
|
997
|
-
process.stderr.write(ORDER_HINT);
|
|
998
|
-
process.stderr.write('Disable per-gate via moflo.yaml:\\n');
|
|
999
|
-
process.stderr.write(' gates:\\n testing_gate: false\\n simplify_gate: false\\n learnings_gate: false\\n');
|
|
1000
|
-
process.exit(2);
|
|
1001
|
-
}
|
|
1002
|
-
case 'check-before-done': {
|
|
1003
|
-
// Story #1274 (Epic #1269) + #1294 — verify-before-done. ON by default
|
|
1004
|
-
// (#1294); disable via moflo.yaml gates.verify_before_done: false or per-run
|
|
1005
|
-
// --no-verify. Same 'gh pr create' trigger as check-before-pr. This
|
|
1006
|
-
// template variant intentionally omits the no-source (docs-only) exemption to
|
|
1007
|
-
// stay consistent with THIS file's simpler check-before-pr; the full exemption
|
|
1008
|
-
// lives in the source bin/gate.cjs that the launcher syncs over this fallback.
|
|
1009
|
-
if (!config.verify_before_done) break;
|
|
1010
|
-
var cmd = process.env.TOOL_INPUT_command || '';
|
|
1011
|
-
if (!/(?:^|&&\\s*|\\|\\|\\s*|;\\s*)\\s*(?:[A-Z_][A-Z0-9_]*=\\S+\\s+)*gh\\s+pr\\s+create\\b/.test(cmd)) break;
|
|
1012
|
-
var s = readState();
|
|
1013
|
-
// #1332 — gate on the OUTCOME, not on attendance: a /verify returning FAIL
|
|
1014
|
-
// is still a successful tool invocation, so verifyRun alone let it through.
|
|
1015
|
-
if (s.verifyRun && s.verifyOutcome === 'PASS') break;
|
|
1016
|
-
process.stderr.write('BLOCKED: gh pr create requires verification before done:\\n');
|
|
1017
|
-
if (!s.verifyRun) {
|
|
1018
|
-
process.stderr.write(' - the change has not been verified since the last code edit (run /verify)\\n');
|
|
1019
|
-
} else if (s.verifyOutcome === 'FAIL' || s.verifyOutcome === 'UNVERIFIED') {
|
|
1020
|
-
process.stderr.write(' - /verify ran and returned ' + s.verifyOutcome + ' — fix the failing criteria, then re-run /verify\\n');
|
|
1021
|
-
// #1394 — two causes, opposite remedies. Re-running /verify cannot fix
|
|
1022
|
-
// absent wiring, so never prescribe it when the transcriber is missing.
|
|
1023
|
-
} else if (!isVerifyOutcomeHookWired()) {
|
|
1024
|
-
process.stderr.write(' - \`record-verify-outcome\` is not wired in .claude/settings.json — the verdict cannot be recorded\\n');
|
|
1025
|
-
process.stderr.write(' /verify may well have passed; nothing exists to transcribe its result, so re-running it will not help.\\n');
|
|
1026
|
-
process.stderr.write(' Fix: run \`flo doctor --fix\`, restart the session, then re-run /verify.\\n');
|
|
1027
|
-
} else {
|
|
1028
|
-
process.stderr.write(' - /verify ran but recorded no verdict — re-run it so it stores a structured result\\n');
|
|
1029
|
-
// #1348 — re-invoking /verify clears the prior verdict by design (#1332),
|
|
1030
|
-
// so the obvious recovery lands back here unless Step 5 completes.
|
|
1031
|
-
process.stderr.write(' Re-invoking /verify clears the prior verdict, so a re-run that skips Step 5 lands here again.\\n');
|
|
1032
|
-
}
|
|
1033
|
-
process.stderr.write(ORDER_HINT);
|
|
1034
|
-
process.stderr.write('Disable via moflo.yaml:\\n');
|
|
1035
|
-
process.stderr.write(' gates:\\n verify_before_done: false\\n');
|
|
1036
|
-
process.exit(2);
|
|
1037
|
-
}
|
|
1038
|
-
case 'check-dangerous-command': {
|
|
1039
|
-
// #1171 follow-up — strip quoted bodies + heredocs before substring match.
|
|
1040
|
-
// See bin/gate.cjs for full rationale.
|
|
1041
|
-
var raw = process.env.TOOL_INPUT_command || '';
|
|
1042
|
-
var cmd = stripQuotedAndHeredocs(raw).toLowerCase();
|
|
1043
|
-
for (var i = 0; i < DANGEROUS.length; i++) {
|
|
1044
|
-
if (cmd.indexOf(DANGEROUS[i]) >= 0) {
|
|
1045
|
-
console.log('[BLOCKED] Dangerous command: ' + DANGEROUS[i]);
|
|
1046
|
-
process.exit(2);
|
|
1047
|
-
}
|
|
1048
|
-
}
|
|
1049
|
-
break;
|
|
1050
|
-
}
|
|
1051
|
-
case 'prompt-reminder': {
|
|
1052
|
-
// Full per-prompt reset (first UserPromptSubmit hook via prompt-hook.mjs).
|
|
1053
|
-
// Owns interactionCount + Context warnings. TaskCreate REMINDER and
|
|
1054
|
-
// namespace hint moved to check-before-agent (#931).
|
|
1055
|
-
var s = readState();
|
|
1056
|
-
var prompt = process.env.CLAUDE_USER_PROMPT || '';
|
|
1057
|
-
applyPromptStateReset(s, prompt);
|
|
1058
|
-
s.interactionCount = (s.interactionCount || 0) + 1;
|
|
1059
|
-
writeState(s);
|
|
1060
|
-
// Announce the resolved /flo run modifiers. moflo.yaml was already parsed in
|
|
1061
|
-
// THIS process (fresh per prompt — a git pull or mid-session yaml edit is
|
|
1062
|
-
// picked up automatically, no cache to invalidate), so this costs no extra
|
|
1063
|
-
// read. SYNC: mirrors bin/gate.cjs prompt-reminder.
|
|
1064
|
-
var floRun = resolveFloRun(prompt);
|
|
1065
|
-
if (floRun.isFlo) {
|
|
1066
|
-
console.log(
|
|
1067
|
-
'[moflo] /flo run modes (AUTHORITATIVE — use verbatim; do NOT re-derive from the skill defaults): ' +
|
|
1068
|
-
'sdd=' + (floRun.sdd ? 'ON' : 'off') +
|
|
1069
|
-
' verify=' + (floRun.verify ? 'ON' : 'off') +
|
|
1070
|
-
' merge=' + (floRun.merge ? 'ON' : 'off') +
|
|
1071
|
-
' [workflow=' + floRun.workflow + ']'
|
|
1072
|
-
);
|
|
1073
|
-
if (floRun.sdd && floRun.sddSrc !== 'flag') {
|
|
1074
|
-
console.log(
|
|
1075
|
-
'[moflo] sdd is ON via ' + floRun.sddSrc + ' — the spec→plan→implement→verify cycle is ' +
|
|
1076
|
-
'MANDATORY this run. Author the spec before editing source (the sdd_gate blocks source ' +
|
|
1077
|
-
'Write/Edit until a reviewed plan exists). One-off opt out: re-run with --no-sdd.'
|
|
1078
|
-
);
|
|
1079
|
-
}
|
|
1080
|
-
if (floRun.merge && floRun.mergeSrc !== 'flag') {
|
|
1081
|
-
console.log('[moflo] merge is ON via ' + floRun.mergeSrc + ' — the PR will be auto-merged. Opt out: --no-merge.');
|
|
1082
|
-
}
|
|
1083
|
-
}
|
|
1084
|
-
if (config.context_tracking) {
|
|
1085
|
-
var ic = s.interactionCount;
|
|
1086
|
-
if (ic > 30) console.log('Context: CRITICAL. Commit, store learnings, suggest new session.');
|
|
1087
|
-
else if (ic > 20) console.log('Context: DEPLETED. Checkpoint progress. Recommend /compact or fresh session.');
|
|
1088
|
-
else if (ic > 10) console.log('Context: MODERATE. Re-state goal before architectural decisions. Use agents for >300 LOC.');
|
|
1089
|
-
}
|
|
1090
|
-
break;
|
|
1091
|
-
}
|
|
1092
|
-
case 'prompt-state-reset': {
|
|
1093
|
-
// Defensive safety-net (second UserPromptSubmit hook). Idempotent state
|
|
1094
|
-
// reset only — no interactionCount increment, no emission. Ensures the
|
|
1095
|
-
// per-prompt reset still happens if prompt-hook.mjs throws (#931). Skip
|
|
1096
|
-
// the disk write when prompt-reminder already wrote the byte-identical
|
|
1097
|
-
// post-reset state (the normal no-exception path).
|
|
1098
|
-
var s = readState();
|
|
1099
|
-
var prompt = process.env.CLAUDE_USER_PROMPT || '';
|
|
1100
|
-
var before = JSON.stringify(s);
|
|
1101
|
-
applyPromptStateReset(s, prompt);
|
|
1102
|
-
if (JSON.stringify(s) !== before) writeState(s);
|
|
1103
|
-
break;
|
|
1104
|
-
}
|
|
1105
|
-
case 'compact-guidance': {
|
|
1106
|
-
console.log('Pre-Compact: Check CLAUDE.md for rules. Use memory search to recover context after compact.');
|
|
1107
|
-
break;
|
|
1108
|
-
}
|
|
1109
|
-
case 'session-reset': {
|
|
1110
|
-
// Derive from STATE_DEFAULTS so adding a new state field requires only one
|
|
1111
|
-
// edit (the defaults object).
|
|
1112
|
-
writeState(Object.assign({}, STATE_DEFAULTS, { sessionStart: new Date().toISOString() }));
|
|
1113
|
-
break;
|
|
1114
|
-
}
|
|
1115
|
-
default:
|
|
1116
|
-
break;
|
|
1117
|
-
}
|
|
1118
|
-
`;
|
|
1119
|
-
}
|
|
1120
|
-
/**
|
|
1121
|
-
* Generate gate-hook.mjs — ESM wrapper that reads Claude Code stdin JSON
|
|
1122
|
-
* and passes tool_name + tool_input + tool_response to gate.cjs via env vars.
|
|
1123
|
-
*
|
|
1124
|
-
* Claude Code hooks receive context as JSON on stdin but don't set env vars
|
|
1125
|
-
* for tool input. This script bridges that gap. It also translates exit code 1
|
|
1126
|
-
* from gate.cjs into exit code 2 (which Claude Code requires to block tools).
|
|
1127
|
-
*
|
|
1128
|
-
* **This must stay byte-identical to `bin/gate-hook.mjs`** — the launcher syncs
|
|
1129
|
-
* that file into the same `.claude/helpers/gate-hook.mjs` this generator writes,
|
|
1130
|
-
* so any divergence means `flo init` emits one bridge and the next session start
|
|
1131
|
-
* silently swaps in another. It had drifted exactly that way before #1322: the
|
|
1132
|
-
* generated copy never received #1332's structured-input forwarding and still
|
|
1133
|
-
* shelled out via `execSync` string concatenation. Parity is pinned by
|
|
1134
|
-
* `tests/guards/gate-hook-parity-guard.test.ts` — when you change one, copy the
|
|
1135
|
-
* whole file across; do not hand-merge.
|
|
1136
|
-
*/
|
|
100
|
+
/** The gate bridge Claude Code's hooks invoke, which shells into gate.cjs */
|
|
1137
101
|
export function generateGateHookScript() {
|
|
1138
|
-
return
|
|
1139
|
-
import { execFileSync } from 'child_process';
|
|
1140
|
-
import { resolve } from 'path';
|
|
1141
|
-
|
|
1142
|
-
var command = process.argv[2];
|
|
1143
|
-
if (!command) process.exit(0);
|
|
1144
|
-
|
|
1145
|
-
// Read stdin JSON from Claude Code
|
|
1146
|
-
var stdinData = '';
|
|
1147
|
-
try {
|
|
1148
|
-
stdinData = await new Promise(function(res) {
|
|
1149
|
-
var data = '';
|
|
1150
|
-
var timeout = setTimeout(function() { res(data); }, 500);
|
|
1151
|
-
process.stdin.setEncoding('utf-8');
|
|
1152
|
-
process.stdin.on('data', function(chunk) { data += chunk; });
|
|
1153
|
-
process.stdin.on('end', function() { clearTimeout(timeout); res(data); });
|
|
1154
|
-
process.stdin.on('error', function() { clearTimeout(timeout); res(''); });
|
|
1155
|
-
if (process.stdin.isTTY) { clearTimeout(timeout); res(''); }
|
|
1156
|
-
});
|
|
1157
|
-
} catch (e) { /* no stdin */ }
|
|
1158
|
-
|
|
1159
|
-
var hookContext = {};
|
|
1160
|
-
try { if (stdinData.trim()) hookContext = JSON.parse(stdinData); } catch (e) {}
|
|
1161
|
-
|
|
1162
|
-
// Pass tool info as env vars for gate.cjs
|
|
1163
|
-
var env = Object.assign({}, process.env);
|
|
1164
|
-
if (hookContext.tool_name) env.TOOL_NAME = hookContext.tool_name;
|
|
1165
|
-
// Forward Claude Code's session_id so gate.cjs can enforce memory-first
|
|
1166
|
-
// per-actor (#838) — each spawned subagent gets its own session_id, so a
|
|
1167
|
-
// shared workflow-state.json no longer lets one subagent's directive be
|
|
1168
|
-
// silently satisfied by the parent's earlier search.
|
|
1169
|
-
if (typeof hookContext.session_id === 'string' && hookContext.session_id) {
|
|
1170
|
-
env.HOOK_SESSION_ID = hookContext.session_id;
|
|
1171
|
-
}
|
|
1172
|
-
// #1374 — forward the transcript path so a gate can read what the session
|
|
1173
|
-
// actually DID, not only what workflow-state.json was told about it. Used by
|
|
1174
|
-
// check-before-pr to count TaskCreate calls against terminal TaskUpdate calls;
|
|
1175
|
-
// the alternative — a \`^TaskUpdate$\` PostToolUse observer — is the wiring #1331
|
|
1176
|
-
// removed as pure hot-path overhead, and the transcript already holds the answer.
|
|
1177
|
-
// Absent on hosts that don't send it: gates treat that as "unknown" and stay silent.
|
|
1178
|
-
if (typeof hookContext.transcript_path === 'string' && hookContext.transcript_path) {
|
|
1179
|
-
env.HOOK_TRANSCRIPT_PATH = hookContext.transcript_path;
|
|
1180
|
-
}
|
|
1181
|
-
// #1332: structured tool inputs are forwarded as JSON, not dropped.
|
|
1182
|
-
//
|
|
1183
|
-
// This previously forwarded ONLY string values, so any object-valued input was
|
|
1184
|
-
// invisible to gate.cjs. That blocked the verify-before-done gate from reading
|
|
1185
|
-
// \`/verify\`'s per-criterion verdict, which #1328 stores in memory_store's
|
|
1186
|
-
// \`metadata\` — an object. Parsing the verdict out of the prose \`value\` string
|
|
1187
|
-
// instead would re-create exactly the free-text dependency #1328 removed.
|
|
1188
|
-
//
|
|
1189
|
-
// Cross-platform (Rule #1): Windows caps a single environment variable at
|
|
1190
|
-
// ~32KB and the whole block at ~32K wide chars, and exceeding it fails the
|
|
1191
|
-
// spawn rather than truncating. Newly-forwarded values are therefore skipped
|
|
1192
|
-
// when oversized, not clipped — a truncated JSON blob would parse as malformed
|
|
1193
|
-
// on the far side and read as a corrupt record rather than an absent one.
|
|
1194
|
-
// \`metadata\` is capped at 64KB by memory_store, so a real verdict never nears
|
|
1195
|
-
// this. STRING values keep their previous uncapped behaviour byte-for-byte:
|
|
1196
|
-
// gate.cjs reads TOOL_INPUT_command, and dropping an oversized heredoc command
|
|
1197
|
-
// would silently stop check-dangerous-command from firing on the exact inputs
|
|
1198
|
-
// most worth checking.
|
|
1199
|
-
var MAX_STRUCTURED_LEN = 16384;
|
|
1200
|
-
if (hookContext.tool_input && typeof hookContext.tool_input === 'object') {
|
|
1201
|
-
Object.keys(hookContext.tool_input).forEach(function(key) {
|
|
1202
|
-
var raw = hookContext.tool_input[key];
|
|
1203
|
-
if (typeof raw === 'string') {
|
|
1204
|
-
env['TOOL_INPUT_' + key] = raw;
|
|
1205
|
-
return;
|
|
1206
|
-
}
|
|
1207
|
-
var val;
|
|
1208
|
-
if (typeof raw === 'number' || typeof raw === 'boolean') {
|
|
1209
|
-
val = String(raw);
|
|
1210
|
-
} else if (raw && typeof raw === 'object') {
|
|
1211
|
-
try { val = JSON.stringify(raw); } catch (e) { return; }
|
|
1212
|
-
} else {
|
|
1213
|
-
return; // null/undefined/function — nothing meaningful to forward
|
|
1214
|
-
}
|
|
1215
|
-
if (val.length > MAX_STRUCTURED_LEN) return;
|
|
1216
|
-
env['TOOL_INPUT_' + key] = val;
|
|
1217
|
-
});
|
|
1218
|
-
}
|
|
1219
|
-
|
|
1220
|
-
// #1322: forward the parts of tool_response that actually exist, so a gate can
|
|
1221
|
-
// observe an OUTCOME rather than only the intent it was handed.
|
|
1222
|
-
//
|
|
1223
|
-
// Claude Code's PostToolUse payload carries NO exit status — probed on v2.1.220,
|
|
1224
|
-
// tool_response for a Bash call is {stdout, stderr, interrupted, isImage,
|
|
1225
|
-
// noOutputExpected}. PostToolUse also does not fire at all when the command
|
|
1226
|
-
// exits non-zero, so the only case a gate can still be fooled by is an exit code
|
|
1227
|
-
// MASKED by a pipe or \`|| true\`, where the response looks clean. The runner's
|
|
1228
|
-
// own output is the sole remaining signal; record-test-run reads it in gate.cjs.
|
|
1229
|
-
//
|
|
1230
|
-
// Tail, not head. Every test runner prints its pass/fail summary LAST, so
|
|
1231
|
-
// clipping the front of a long log would discard the exact lines this exists to
|
|
1232
|
-
// read. Bounds are deliberately tight — Windows caps the whole environment
|
|
1233
|
-
// block at ~32K wide chars and fails the spawn rather than truncating, and
|
|
1234
|
-
// TOOL_INPUT_command is already forwarded uncapped alongside these.
|
|
1235
|
-
var MAX_RESPONSE_STDOUT = 4096;
|
|
1236
|
-
var MAX_RESPONSE_STDERR = 2048;
|
|
1237
|
-
function tailOf(value, max) {
|
|
1238
|
-
return value.length > max ? value.slice(value.length - max) : value;
|
|
102
|
+
return embedded('gate-hook.mjs');
|
|
1239
103
|
}
|
|
1240
|
-
|
|
1241
|
-
var resp = hookContext.tool_response;
|
|
1242
|
-
if (typeof resp.stdout === 'string' && resp.stdout) {
|
|
1243
|
-
env.TOOL_RESPONSE_stdout = tailOf(resp.stdout, MAX_RESPONSE_STDOUT);
|
|
1244
|
-
}
|
|
1245
|
-
if (typeof resp.stderr === 'string' && resp.stderr) {
|
|
1246
|
-
env.TOOL_RESPONSE_stderr = tailOf(resp.stderr, MAX_RESPONSE_STDERR);
|
|
1247
|
-
}
|
|
1248
|
-
// Boolean — the string-typed forwarding above would drop it silently.
|
|
1249
|
-
if (typeof resp.interrupted === 'boolean') {
|
|
1250
|
-
env.TOOL_RESPONSE_interrupted = String(resp.interrupted);
|
|
1251
|
-
}
|
|
1252
|
-
}
|
|
1253
|
-
|
|
1254
|
-
// Run gate.cjs with the enriched environment
|
|
1255
|
-
var projectDir = (env.CLAUDE_PROJECT_DIR || process.cwd()).replace(/^\\/([a-z])\\//i, '$1:/');
|
|
1256
|
-
var gateScript = resolve(projectDir, '.claude/helpers/gate.cjs');
|
|
1257
|
-
try {
|
|
1258
|
-
var output = execFileSync('node', [gateScript, command], {
|
|
1259
|
-
env: env, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true
|
|
1260
|
-
});
|
|
1261
|
-
if (output.trim()) process.stdout.write(output);
|
|
1262
|
-
process.exit(0);
|
|
1263
|
-
} catch (err) {
|
|
1264
|
-
// gate.cjs exit(2) = block, exit(1) = also block attempt — translate both to exit(2)
|
|
1265
|
-
if (err.stderr) process.stderr.write(err.stderr);
|
|
1266
|
-
if (err.stdout) process.stderr.write(err.stdout);
|
|
1267
|
-
process.exit(err.status === 2 || err.status === 1 ? 2 : 0);
|
|
1268
|
-
}
|
|
1269
|
-
`;
|
|
1270
|
-
}
|
|
1271
|
-
/**
|
|
1272
|
-
* Generate prompt-hook.mjs — reads user prompt from Claude Code stdin JSON,
|
|
1273
|
-
* runs prompt classification via gate.cjs, and appends namespace hints.
|
|
1274
|
-
*/
|
|
104
|
+
/** The UserPromptSubmit bridge */
|
|
1275
105
|
export function generatePromptHookScript() {
|
|
1276
|
-
return
|
|
1277
|
-
import { execSync } from 'child_process';
|
|
1278
|
-
import { resolve } from 'path';
|
|
1279
|
-
|
|
1280
|
-
// Read stdin JSON from Claude Code
|
|
1281
|
-
var stdinData = '';
|
|
1282
|
-
try {
|
|
1283
|
-
stdinData = await new Promise(function(res) {
|
|
1284
|
-
var data = '';
|
|
1285
|
-
var timeout = setTimeout(function() { res(data); }, 500);
|
|
1286
|
-
process.stdin.setEncoding('utf-8');
|
|
1287
|
-
process.stdin.on('data', function(chunk) { data += chunk; });
|
|
1288
|
-
process.stdin.on('end', function() { clearTimeout(timeout); res(data); });
|
|
1289
|
-
process.stdin.on('error', function() { clearTimeout(timeout); res(''); });
|
|
1290
|
-
if (process.stdin.isTTY) { clearTimeout(timeout); res(''); }
|
|
1291
|
-
});
|
|
1292
|
-
} catch (e) { /* no stdin */ }
|
|
1293
|
-
|
|
1294
|
-
var hookContext = {};
|
|
1295
|
-
try { if (stdinData.trim()) hookContext = JSON.parse(stdinData); } catch (e) {}
|
|
1296
|
-
|
|
1297
|
-
var userPrompt = hookContext.user_prompt || hookContext.prompt || '';
|
|
1298
|
-
var env = Object.assign({}, process.env, { CLAUDE_USER_PROMPT: userPrompt });
|
|
1299
|
-
|
|
1300
|
-
// #1397 — forward session_id so prompt-reminder can stamp it onto
|
|
1301
|
-
// workflow-state.json; \`flo runs start\` has no other source for it.
|
|
1302
|
-
if (typeof hookContext.session_id === 'string' && hookContext.session_id) {
|
|
1303
|
-
env.HOOK_SESSION_ID = hookContext.session_id;
|
|
1304
|
-
}
|
|
1305
|
-
|
|
1306
|
-
// Run prompt-reminder via gate.cjs
|
|
1307
|
-
var projectDir = (env.CLAUDE_PROJECT_DIR || process.cwd()).replace(/^\\/([a-z])\\//i, '$1:/');
|
|
1308
|
-
var gateScript = resolve(projectDir, '.claude/helpers/gate.cjs');
|
|
1309
|
-
var output = '';
|
|
1310
|
-
try {
|
|
1311
|
-
output = execSync('node "' + gateScript + '" prompt-reminder', {
|
|
1312
|
-
env: env, encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe']
|
|
1313
|
-
});
|
|
1314
|
-
} catch (err) { output = (err && err.stdout) || ''; }
|
|
1315
|
-
|
|
1316
|
-
// #931 — Namespace hint classification moved into gate.cjs (computed by
|
|
1317
|
-
// prompt-reminder, stored on workflow-state, emitted once by check-before-agent).
|
|
1318
|
-
var parts = [output.trim()].filter(Boolean);
|
|
1319
|
-
if (parts.length) process.stdout.write(parts.join('\\n') + '\\n');
|
|
1320
|
-
process.exit(0);
|
|
1321
|
-
`;
|
|
106
|
+
return embedded('prompt-hook.mjs');
|
|
1322
107
|
}
|
|
1323
|
-
/**
|
|
1324
|
-
* Generate lightweight hook-handler.cjs — hook dispatch without CLI bootstrap.
|
|
1325
|
-
* Handles routing, edit/task tracking, session lifecycle, and notifications.
|
|
1326
|
-
* This replaces `npx flo hooks <command>` to avoid spawning a full CLI process.
|
|
1327
|
-
*/
|
|
108
|
+
/** The PostToolUse / Stop / Notification handler */
|
|
1328
109
|
export function generateHookHandlerScript() {
|
|
1329
|
-
return
|
|
1330
|
-
'use strict';
|
|
1331
|
-
var fs = require('fs');
|
|
1332
|
-
var path = require('path');
|
|
1333
|
-
|
|
1334
|
-
var PROJECT_DIR = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
1335
|
-
var METRICS_FILE = path.join(PROJECT_DIR, '.moflo', 'metrics', 'learning.json');
|
|
1336
|
-
var command = process.argv[2];
|
|
1337
|
-
|
|
1338
|
-
// Read stdin (Claude Code sends hook data as JSON)
|
|
1339
|
-
function readStdin() {
|
|
1340
|
-
if (process.stdin.isTTY) return Promise.resolve('');
|
|
1341
|
-
return new Promise(function(resolve) {
|
|
1342
|
-
var data = '';
|
|
1343
|
-
var timer = setTimeout(function() {
|
|
1344
|
-
process.stdin.removeAllListeners();
|
|
1345
|
-
process.stdin.pause();
|
|
1346
|
-
resolve(data);
|
|
1347
|
-
}, 500);
|
|
1348
|
-
process.stdin.setEncoding('utf8');
|
|
1349
|
-
process.stdin.on('data', function(chunk) { data += chunk; });
|
|
1350
|
-
process.stdin.on('end', function() { clearTimeout(timer); resolve(data); });
|
|
1351
|
-
process.stdin.on('error', function() { clearTimeout(timer); resolve(data); });
|
|
1352
|
-
process.stdin.resume();
|
|
1353
|
-
});
|
|
1354
|
-
}
|
|
1355
|
-
|
|
1356
|
-
function bumpMetric(key) {
|
|
1357
|
-
try {
|
|
1358
|
-
var metrics = {};
|
|
1359
|
-
if (fs.existsSync(METRICS_FILE)) metrics = JSON.parse(fs.readFileSync(METRICS_FILE, 'utf-8'));
|
|
1360
|
-
metrics[key] = (metrics[key] || 0) + 1;
|
|
1361
|
-
metrics.lastUpdated = new Date().toISOString();
|
|
1362
|
-
var dir = path.dirname(METRICS_FILE);
|
|
1363
|
-
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
1364
|
-
fs.writeFileSync(METRICS_FILE, JSON.stringify(metrics, null, 2));
|
|
1365
|
-
} catch (e) { /* non-fatal */ }
|
|
1366
|
-
}
|
|
1367
|
-
|
|
1368
|
-
readStdin().then(function(stdinData) {
|
|
1369
|
-
var hookInput = {};
|
|
1370
|
-
if (stdinData && stdinData.trim()) {
|
|
1371
|
-
try { hookInput = JSON.parse(stdinData); } catch (e) { /* ignore */ }
|
|
1372
|
-
}
|
|
1373
|
-
|
|
1374
|
-
switch (command) {
|
|
1375
|
-
case 'route': {
|
|
1376
|
-
var prompt = hookInput.prompt || hookInput.command || process.env.PROMPT || '';
|
|
1377
|
-
if (prompt) console.log('[INFO] Routing: ' + prompt.substring(0, 80));
|
|
1378
|
-
else console.log('[INFO] Ready');
|
|
1379
|
-
break;
|
|
1380
|
-
}
|
|
1381
|
-
case 'pre-edit':
|
|
1382
|
-
case 'post-edit':
|
|
1383
|
-
bumpMetric('edits');
|
|
1384
|
-
console.log('[OK] Edit recorded');
|
|
1385
|
-
break;
|
|
1386
|
-
case 'pre-task':
|
|
1387
|
-
bumpMetric('tasks');
|
|
1388
|
-
console.log('[OK] Task started');
|
|
1389
|
-
break;
|
|
1390
|
-
case 'post-task':
|
|
1391
|
-
bumpMetric('tasksCompleted');
|
|
1392
|
-
console.log('[OK] Task completed');
|
|
1393
|
-
break;
|
|
1394
|
-
case 'session-end':
|
|
1395
|
-
console.log('[OK] Session ended');
|
|
1396
|
-
break;
|
|
1397
|
-
case 'notification':
|
|
1398
|
-
// Silent — just acknowledge
|
|
1399
|
-
break;
|
|
1400
|
-
default:
|
|
1401
|
-
if (command) console.log('[OK] Hook: ' + command);
|
|
1402
|
-
break;
|
|
1403
|
-
}
|
|
1404
|
-
});
|
|
1405
|
-
`;
|
|
110
|
+
return embedded('hook-handler.cjs');
|
|
1406
111
|
}
|
|
1407
112
|
//# sourceMappingURL=helpers-generator.js.map
|