moflo 4.12.9 → 4.12.11
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-claude-swarm-cohesion.md +27 -4
- package/.claude/guidance/shipped/moflo-yaml-reference.md +1 -0
- package/.claude/helpers/gate.cjs +116 -14
- package/.claude/helpers/simplify-classify.cjs +194 -12
- package/.claude/helpers/statusline.cjs +56 -7
- package/bin/gate.cjs +116 -14
- package/bin/lib/session-continuity.mjs +109 -0
- package/bin/session-continuity.mjs +1 -22
- package/bin/simplify-classify.cjs +194 -12
- package/dist/src/cli/commands/hooks.js +8 -2
- package/dist/src/cli/config/moflo-config.js +3 -0
- package/dist/src/cli/hooks/statusline/index.js +15 -9
- package/dist/src/cli/init/embedded-helpers.js +1 -1
- package/dist/src/cli/init/moflo-yaml-template.js +1 -0
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -2
package/bin/gate.cjs
CHANGED
|
@@ -240,10 +240,11 @@ function isEphemeralPath(fp) {
|
|
|
240
240
|
}
|
|
241
241
|
// #1171 — DANGEROUS gained PowerShell additions to match the matcher widening
|
|
242
242
|
// that now routes the dedicated `PowerShell` tool through check-dangerous-command.
|
|
243
|
-
// POSIX entries still apply because PS will execute them when invoked.
|
|
244
|
-
//
|
|
243
|
+
// POSIX entries still apply because PS will execute them when invoked. Matched
|
|
244
|
+
// case-insensitively by `matchesDangerous` below — substring for most entries,
|
|
245
|
+
// root-anchored for the ones that end at a filesystem root.
|
|
245
246
|
var DANGEROUS = [
|
|
246
|
-
'rm -rf /', 'format c:', 'del /s /q c:\\', ':(){:|:&};:', 'mkfs.', '> /dev/sda',
|
|
247
|
+
'rm -rf /', 'rm -rf ~', 'format c:', 'del /s /q c:\\', ':(){:|:&};:', 'mkfs.', '> /dev/sda',
|
|
247
248
|
// PowerShell destructive patterns. Won't catch every adversarial spelling
|
|
248
249
|
// (PS aliases let `ri -r -force C:\` mean the same thing) but covers the
|
|
249
250
|
// common-typo destruction class — symmetric to the POSIX list's intent.
|
|
@@ -254,6 +255,74 @@ var DANGEROUS = [
|
|
|
254
255
|
'clear-disk',
|
|
255
256
|
];
|
|
256
257
|
|
|
258
|
+
// #1449 — the entries above that END at a filesystem root are also a PREFIX of
|
|
259
|
+
// every absolute path beneath it, so plain substring matching blocked routine
|
|
260
|
+
// cleanup: `rm -rf /tmp/scratch` reported as `rm -rf /`. Those entries must
|
|
261
|
+
// match ON the root rather than at the head of a longer path.
|
|
262
|
+
//
|
|
263
|
+
// WHICH entries those are is derived, not listed: a pattern is root-shaped iff
|
|
264
|
+
// it ends at a root — a separator or `~`. That selects `rm -rf /`, `rm -rf ~`,
|
|
265
|
+
// `del /s /q c:\` and the three `remove-item` forms, and leaves `format c:`,
|
|
266
|
+
// `mkfs.`, `> /dev/sda`, `format-volume` and `clear-disk` on plain substring
|
|
267
|
+
// matching, where trailing text is still the same dangerous command. Deriving
|
|
268
|
+
// it rather than keeping a parallel list means a future root-shaped addition to
|
|
269
|
+
// DANGEROUS is anchored automatically instead of silently falling back to the
|
|
270
|
+
// prefix bug this fixes.
|
|
271
|
+
//
|
|
272
|
+
// `rm -rf ~` joins DANGEROUS with this change: it was never listed, so wiping a
|
|
273
|
+
// home directory was allowed outright while cleaning a subdirectory of one was
|
|
274
|
+
// blocked. Anchoring is what makes the entry safe to add.
|
|
275
|
+
function endsAtRoot(pat) {
|
|
276
|
+
var last = pat.charAt(pat.length - 1);
|
|
277
|
+
return last === '/' || last === '\\' || last === '~';
|
|
278
|
+
}
|
|
279
|
+
// What may legally follow a root target: nothing, whitespace, a glob (`rm -rf /*`
|
|
280
|
+
// still blocks), a shell operator, or a closing quote/paren. A path character —
|
|
281
|
+
// letter, digit, `-`, `_` — means another segment follows, i.e. routine cleanup
|
|
282
|
+
// of something below root. Rule #1: pure string logic, no platform branch; both
|
|
283
|
+
// separators are handled for every OS's spelling.
|
|
284
|
+
var ROOT_BOUNDARY_RE = /[\s;&|)"'`*<>]/;
|
|
285
|
+
function isRootBoundary(cmd, i) {
|
|
286
|
+
if (i >= cmd.length) return true;
|
|
287
|
+
var c = cmd.charAt(i);
|
|
288
|
+
return c === '/' || c === '\\' || ROOT_BOUNDARY_RE.test(c);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Advance past text that does not move OFF the root: repeated separators, and
|
|
293
|
+
* `.` / `..` segments, which resolve back to where they started. `rm -rf //`,
|
|
294
|
+
* `rm -rf /.` and `rm -rf /..` all still mean `rm -rf /` and must block, while
|
|
295
|
+
* `rm -rf //tmp/x` and `rm -rf /.config` are real paths below root and must not.
|
|
296
|
+
* Without the dot handling the anchoring would OPEN a hole the substring match
|
|
297
|
+
* did not have — `.` is not a boundary character, so `rm -rf /.` would read as
|
|
298
|
+
* "a longer path follows" and pass.
|
|
299
|
+
*/
|
|
300
|
+
function skipToRootEnd(cmd, i) {
|
|
301
|
+
for (;;) {
|
|
302
|
+
var start = i;
|
|
303
|
+
while (i < cmd.length && (cmd.charAt(i) === '/' || cmd.charAt(i) === '\\')) i++;
|
|
304
|
+
var dots = 0;
|
|
305
|
+
while (cmd.charAt(i + dots) === '.') dots++;
|
|
306
|
+
// Only a BARE `.` or `..` segment is a no-op; `.config` and `..foo` are names.
|
|
307
|
+
if ((dots === 1 || dots === 2) && isRootBoundary(cmd, i + dots)) i += dots;
|
|
308
|
+
if (i === start) return i;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* True when `pat` occurs in `cmd` as a real dangerous command. Root-shaped
|
|
314
|
+
* patterns must land on the root; every occurrence is examined, so a safe
|
|
315
|
+
* leading match (`rm -rf /tmp/a && rm -rf /`) never masks a later real one.
|
|
316
|
+
*/
|
|
317
|
+
function matchesDangerous(cmd, pat) {
|
|
318
|
+
if (!endsAtRoot(pat)) return cmd.indexOf(pat) >= 0;
|
|
319
|
+
for (var at = cmd.indexOf(pat); at >= 0; at = cmd.indexOf(pat, at + 1)) {
|
|
320
|
+
var end = skipToRootEnd(cmd, at + pat.length);
|
|
321
|
+
if (end >= cmd.length || ROOT_BOUNDARY_RE.test(cmd.charAt(end))) return true;
|
|
322
|
+
}
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
|
|
257
326
|
// #1132 — Bash memory-first gate.
|
|
258
327
|
//
|
|
259
328
|
// CREDIT: marks the gate satisfied when Claude invokes a memory-search CLI
|
|
@@ -1191,11 +1260,32 @@ function isPrCreateCommand(cmd) {
|
|
|
1191
1260
|
// Fail-safe: any error (no classifier, no git, no merge-base) returns null,
|
|
1192
1261
|
// which forces /simplify to run as today.
|
|
1193
1262
|
function classifyForGateSkip(state) {
|
|
1194
|
-
var
|
|
1263
|
+
var mod;
|
|
1195
1264
|
try {
|
|
1196
|
-
|
|
1265
|
+
mod = require('./simplify-classify.cjs');
|
|
1197
1266
|
} catch (e) { return null; }
|
|
1198
|
-
|
|
1267
|
+
var classify = mod && mod.classifyDiff;
|
|
1268
|
+
var readUntracked = mod && mod.readUntrackedDiff;
|
|
1269
|
+
// EXEC_MAX_BUFFER is checked alongside the functions because falling back to
|
|
1270
|
+
// Node's 1 MiB default would silently reinstate the very cliff #1451 removed.
|
|
1271
|
+
if (typeof classify !== 'function' || typeof readUntracked !== 'function'
|
|
1272
|
+
|| typeof mod.EXEC_MAX_BUFFER !== 'number') return null;
|
|
1273
|
+
|
|
1274
|
+
// Untracked files show up in no `git diff` output, so without them the gate
|
|
1275
|
+
// could auto-pass a branch of brand-new unstaged files as TRIVIAL (#1451).
|
|
1276
|
+
// Reading every one of them is real work, so it is deferred until a path is
|
|
1277
|
+
// actually about to classify. Returns null if the read failed — the caller
|
|
1278
|
+
// must then fall through and force /simplify rather than classify a partial
|
|
1279
|
+
// diff.
|
|
1280
|
+
var untrackedText = null;
|
|
1281
|
+
function untrackedSuffix() {
|
|
1282
|
+
if (untrackedText !== null) return untrackedText;
|
|
1283
|
+
var u;
|
|
1284
|
+
try { u = readUntracked(PROJECT_DIR); } catch (e) { return null; }
|
|
1285
|
+
if (!u || u.unreadable) return null;
|
|
1286
|
+
untrackedText = u.text ? '\n' + u.text : '';
|
|
1287
|
+
return untrackedText;
|
|
1288
|
+
}
|
|
1199
1289
|
|
|
1200
1290
|
function tryClassify(diffText, label, allowSmallReviewFix) {
|
|
1201
1291
|
try {
|
|
@@ -1219,11 +1309,16 @@ function classifyForGateSkip(state) {
|
|
|
1219
1309
|
return null;
|
|
1220
1310
|
}
|
|
1221
1311
|
|
|
1312
|
+
// maxBuffer comes FROM the classifier (#1451) rather than being a matching
|
|
1313
|
+
// literal here, so the gate and the skill cannot drift on which diffs are
|
|
1314
|
+
// readable at all. Past it execFileSync throws ENOBUFS and gitDiff returns
|
|
1315
|
+
// null — which every caller below must treat as "unknown", never "empty".
|
|
1316
|
+
var maxBuffer = mod.EXEC_MAX_BUFFER;
|
|
1222
1317
|
function gitDiff(args) {
|
|
1223
1318
|
try {
|
|
1224
1319
|
return cp.execFileSync('git', args, {
|
|
1225
1320
|
cwd: PROJECT_DIR, encoding: 'utf-8', timeout: 5000, windowsHide: true,
|
|
1226
|
-
stdio: ['ignore', 'pipe', 'ignore'], maxBuffer:
|
|
1321
|
+
stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: maxBuffer
|
|
1227
1322
|
});
|
|
1228
1323
|
} catch (e) { return null; }
|
|
1229
1324
|
}
|
|
@@ -1231,9 +1326,14 @@ function classifyForGateSkip(state) {
|
|
|
1231
1326
|
// Snapshot path: classify everything since /simplify last ran.
|
|
1232
1327
|
if (state.simplifySnapshotSha) {
|
|
1233
1328
|
var snapDiff = gitDiff(['diff', state.simplifySnapshotSha + '...HEAD']);
|
|
1234
|
-
var workTreeA = gitDiff(['diff', 'HEAD'])
|
|
1235
|
-
|
|
1236
|
-
|
|
1329
|
+
var workTreeA = gitDiff(['diff', 'HEAD']);
|
|
1330
|
+
// BOTH reads must succeed. Coalescing a failed working-tree read to '' is
|
|
1331
|
+
// how an over-buffer working tree used to read as "no working-tree changes"
|
|
1332
|
+
// and let the gate skip review (#1451).
|
|
1333
|
+
if (snapDiff !== null && workTreeA !== null) {
|
|
1334
|
+
var suffixA = untrackedSuffix();
|
|
1335
|
+
if (suffixA === null) return null;
|
|
1336
|
+
var combined = snapDiff + (workTreeA ? '\n' + workTreeA : '') + suffixA;
|
|
1237
1337
|
// Snapshot path: allow SMALL review-fix shape because the original /simplify
|
|
1238
1338
|
// already covered the surface and only tiny no-decl-touching tweaks followed.
|
|
1239
1339
|
var hit = tryClassify(combined, 'delta since last /simplify', true);
|
|
@@ -1253,9 +1353,11 @@ function classifyForGateSkip(state) {
|
|
|
1253
1353
|
} catch (e) { continue; }
|
|
1254
1354
|
if (!base) continue;
|
|
1255
1355
|
var branchDiff = gitDiff(['diff', base + '...HEAD']);
|
|
1256
|
-
var workTreeB = gitDiff(['diff', 'HEAD'])
|
|
1257
|
-
if (branchDiff !== null) {
|
|
1258
|
-
|
|
1356
|
+
var workTreeB = gitDiff(['diff', 'HEAD']);
|
|
1357
|
+
if (branchDiff !== null && workTreeB !== null) {
|
|
1358
|
+
var suffixB = untrackedSuffix();
|
|
1359
|
+
if (suffixB === null) return null;
|
|
1360
|
+
return tryClassify(branchDiff + (workTreeB ? '\n' + workTreeB : '') + suffixB, 'branch diff');
|
|
1259
1361
|
}
|
|
1260
1362
|
break;
|
|
1261
1363
|
}
|
|
@@ -2110,7 +2212,7 @@ switch (command) {
|
|
|
2110
2212
|
var raw = process.env.TOOL_INPUT_command || '';
|
|
2111
2213
|
var cmd = stripQuotedAndHeredocs(raw).toLowerCase();
|
|
2112
2214
|
for (var i = 0; i < DANGEROUS.length; i++) {
|
|
2113
|
-
if (cmd
|
|
2215
|
+
if (matchesDangerous(cmd, DANGEROUS[i])) {
|
|
2114
2216
|
console.log('[BLOCKED] Dangerous command: ' + DANGEROUS[i]);
|
|
2115
2217
|
process.exit(2);
|
|
2116
2218
|
}
|
|
@@ -206,6 +206,115 @@ export function hasPrivateOptOut(text) {
|
|
|
206
206
|
return typeof text === 'string' && /<private>/i.test(text);
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
+
// ── Session goal extraction (pure) ──────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Claude Code stamps `isMeta: true` on user-role transcript entries it injected
|
|
213
|
+
* itself rather than ones the human typed — expanded skill preambles, the
|
|
214
|
+
* local-command caveat, "skill is already loaded" notes. An audit of every
|
|
215
|
+
* transcript on a dogfooding box found this marker on boilerplate ONLY, never on
|
|
216
|
+
* a real goal, which makes it the structural signal to filter on. The literal
|
|
217
|
+
* prefixes below are belt-and-braces for a Claude Code build that stops emitting
|
|
218
|
+
* the flag — a missing `isMeta` must not silently restore the #1452 bug.
|
|
219
|
+
*/
|
|
220
|
+
const BOILERPLATE_OPENERS = [
|
|
221
|
+
/^Base directory for this skill:/i,
|
|
222
|
+
/^Skill \S+ is already loaded/i,
|
|
223
|
+
/^Caveat: The messages below were generated by the user/i,
|
|
224
|
+
/^\[Request interrupted by user/i,
|
|
225
|
+
/^This session is being continued from a previous conversation/i,
|
|
226
|
+
];
|
|
227
|
+
|
|
228
|
+
/** Slash commands that steer the SESSION rather than state a goal. `/clear` is
|
|
229
|
+
* the near-universal opener in an agent-driven repo, so returning it as the
|
|
230
|
+
* Goal would reintroduce the same "boilerplate as intent" failure. */
|
|
231
|
+
const SESSION_CONTROL_COMMANDS = new Set(['clear', 'compact', 'resume', 'exit', 'quit', 'login', 'logout', 'help']);
|
|
232
|
+
|
|
233
|
+
const COMMAND_NAME_RE = /<command-name>\s*\/?\s*([\w:.-]+)\s*<\/command-name>/i;
|
|
234
|
+
const COMMAND_ARGS_RE = /<command-args>([\s\S]*?)<\/command-args>/i;
|
|
235
|
+
const BARE_COMMAND_RE = /^\/([\w:.-]+)(?:\s+([\s\S]*))?$/;
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Render a slash-command invocation as a goal string, e.g. `/flo 1452`.
|
|
239
|
+
*
|
|
240
|
+
* Claude Code writes the invocation two ways: an XML envelope
|
|
241
|
+
* (`<command-name>/flo</command-name><command-args>1452</command-args>`) and,
|
|
242
|
+
* for some built-ins, the bare text the user typed (`/compact`). Both are
|
|
243
|
+
* handled — the ARGUMENTS carry the actual intent (the issue number), which is
|
|
244
|
+
* exactly what the old filter threw away.
|
|
245
|
+
*
|
|
246
|
+
* `name` is lower-cased for the session-control lookup only; `goal` preserves
|
|
247
|
+
* the case the user actually typed, since it is a record of their invocation.
|
|
248
|
+
*
|
|
249
|
+
* Text after the closing `</command-args>` tag is intentionally dropped — the
|
|
250
|
+
* command plus its arguments IS the goal, and the surrounding envelope carries
|
|
251
|
+
* a `<command-message>` label that is never part of the ask.
|
|
252
|
+
*
|
|
253
|
+
* @param {string} flat - message content flattened to a single line
|
|
254
|
+
* @returns {{ name: string, goal: string } | null} null if not a slash command
|
|
255
|
+
*/
|
|
256
|
+
function parseSlashCommand(flat) {
|
|
257
|
+
let name = null;
|
|
258
|
+
let args = '';
|
|
259
|
+
const envelope = COMMAND_NAME_RE.exec(flat);
|
|
260
|
+
if (envelope) {
|
|
261
|
+
name = envelope[1];
|
|
262
|
+
const a = COMMAND_ARGS_RE.exec(flat);
|
|
263
|
+
args = a ? a[1].trim() : '';
|
|
264
|
+
} else if (!flat.startsWith('<')) {
|
|
265
|
+
const bare = BARE_COMMAND_RE.exec(flat);
|
|
266
|
+
if (!bare) return null;
|
|
267
|
+
name = bare[1];
|
|
268
|
+
args = (bare[2] || '').trim();
|
|
269
|
+
}
|
|
270
|
+
if (!name) return null;
|
|
271
|
+
return { name: name.toLowerCase(), goal: args ? `/${name} ${args}` : `/${name}` };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* First user message that actually states a goal, from a transcript JSONL head.
|
|
276
|
+
*
|
|
277
|
+
* Returns `null` rather than a boilerplate string when nothing qualifies: this
|
|
278
|
+
* value is re-injected at SessionStart after a compaction, to a model that has
|
|
279
|
+
* just lost its context and cannot tell a junk Goal from a real one. An absent
|
|
280
|
+
* Goal is strictly better than a misleading one. See issue #1452.
|
|
281
|
+
*
|
|
282
|
+
* @param {string} headText - raw JSONL text from the head of the transcript
|
|
283
|
+
* @returns {string|null} goal (≤200 chars), or null if none was stated
|
|
284
|
+
*/
|
|
285
|
+
export function extractFirstUserGoal(headText) {
|
|
286
|
+
if (typeof headText !== 'string') return null;
|
|
287
|
+
for (const line of headText.split('\n')) {
|
|
288
|
+
const t = line.trim();
|
|
289
|
+
if (!t) continue;
|
|
290
|
+
let obj;
|
|
291
|
+
try { obj = JSON.parse(t); } catch { continue; }
|
|
292
|
+
const role = obj?.message?.role ?? obj?.role;
|
|
293
|
+
if (role !== 'user') continue;
|
|
294
|
+
// Injected by Claude Code, not typed by the human — never a goal.
|
|
295
|
+
if (obj?.isMeta === true) continue;
|
|
296
|
+
let content = obj?.message?.content ?? obj?.content;
|
|
297
|
+
if (Array.isArray(content)) {
|
|
298
|
+
content = content.map((b) => (typeof b === 'string' ? b : b?.text || '')).join(' ');
|
|
299
|
+
}
|
|
300
|
+
if (typeof content !== 'string') continue;
|
|
301
|
+
const flat = content.replace(/\s+/g, ' ').trim();
|
|
302
|
+
if (!flat) continue;
|
|
303
|
+
|
|
304
|
+
const command = parseSlashCommand(flat);
|
|
305
|
+
if (command) {
|
|
306
|
+
// `/clear`, `/compact` — session plumbing; keep scanning for the real ask.
|
|
307
|
+
if (SESSION_CONTROL_COMMANDS.has(command.name)) continue;
|
|
308
|
+
return command.goal.slice(0, 200);
|
|
309
|
+
}
|
|
310
|
+
// Other hook/tool XML noise (`<system-reminder>`, `<task-notification>`, …).
|
|
311
|
+
if (flat.startsWith('<')) continue;
|
|
312
|
+
if (BOILERPLATE_OPENERS.some((re) => re.test(flat))) continue;
|
|
313
|
+
return flat.slice(0, 200);
|
|
314
|
+
}
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
|
|
209
318
|
// ── Digest assembly (pure) ──────────────────────────────────────────────────
|
|
210
319
|
|
|
211
320
|
/** statSync mtime that never throws (file may vanish between readdir and stat). */
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
readContinuityConfig,
|
|
36
36
|
readGitState,
|
|
37
37
|
hasPrivateOptOut,
|
|
38
|
+
extractFirstUserGoal,
|
|
38
39
|
assembleDigestContent,
|
|
39
40
|
buildDigestMetadata,
|
|
40
41
|
writeDigest,
|
|
@@ -48,28 +49,6 @@ function warn(msg) {
|
|
|
48
49
|
try { process.stderr.write(`moflo: session-continuity ${msg}\n`); } catch { /* never throw from a hook */ }
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
/** First user message (the session goal) from a transcript JSONL head. */
|
|
52
|
-
function extractFirstUserGoal(headText) {
|
|
53
|
-
for (const line of headText.split('\n')) {
|
|
54
|
-
const t = line.trim();
|
|
55
|
-
if (!t) continue;
|
|
56
|
-
let obj;
|
|
57
|
-
try { obj = JSON.parse(t); } catch { continue; }
|
|
58
|
-
const role = obj?.message?.role ?? obj?.role;
|
|
59
|
-
if (role !== 'user') continue;
|
|
60
|
-
let content = obj?.message?.content ?? obj?.content;
|
|
61
|
-
if (Array.isArray(content)) {
|
|
62
|
-
content = content.map((b) => (typeof b === 'string' ? b : b?.text || '')).join(' ');
|
|
63
|
-
}
|
|
64
|
-
if (typeof content !== 'string') continue;
|
|
65
|
-
const firstLine = content.replace(/\s+/g, ' ').trim();
|
|
66
|
-
// Skip slash-command / hook-noise openers — they aren't a goal statement.
|
|
67
|
-
if (!firstLine || firstLine.startsWith('<')) continue;
|
|
68
|
-
return firstLine.slice(0, 200);
|
|
69
|
-
}
|
|
70
|
-
return null;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
52
|
/** Bounded transcript read: goal from the head, `<private>` opt-out scan over
|
|
74
53
|
* head+tail. Large transcripts are read at the edges only. */
|
|
75
54
|
function readTranscriptInfo(path) {
|
|
@@ -31,6 +31,14 @@
|
|
|
31
31
|
* "stats": { added, deleted, fileCount, declAdded, declRemoved, tsjsLOC, tsjsNetDecls, otherNetAdded, ... }
|
|
32
32
|
* }
|
|
33
33
|
*
|
|
34
|
+
* The diff it measures spans committed-since-base, working-tree, AND untracked
|
|
35
|
+
* non-ignored files — an untracked file is a change the branch will carry, and
|
|
36
|
+
* omitting it undercounts the diff exactly as a swallowed read error does.
|
|
37
|
+
* When a read it needed did not happen, `stats.diffUnavailable` is set and the
|
|
38
|
+
* decision routes to a review tier: the classifier cannot distinguish "no
|
|
39
|
+
* changes" from "I could not read the changes", and only the first is safe to
|
|
40
|
+
* call TRIVIAL (#1451).
|
|
41
|
+
*
|
|
34
42
|
* Usage:
|
|
35
43
|
* node bin/simplify-classify.cjs # auto-detects default branch
|
|
36
44
|
* node bin/simplify-classify.cjs --base develop # explicit override
|
|
@@ -42,6 +50,27 @@
|
|
|
42
50
|
'use strict';
|
|
43
51
|
|
|
44
52
|
const { execSync } = require('child_process');
|
|
53
|
+
const fs = require('fs');
|
|
54
|
+
const path = require('path');
|
|
55
|
+
|
|
56
|
+
// execSync defaults to a 1 MiB stdout buffer and a real branch diff clears that
|
|
57
|
+
// routinely (#1451 measured 2,113,712 bytes). Overflow throws ENOBUFS, which
|
|
58
|
+
// used to be swallowed into an empty diff — "TRIVIAL, nothing to review" on a
|
|
59
|
+
// branch with plenty to review. 64 MiB puts the cliff well past any diff a
|
|
60
|
+
// human opens a PR for; past it, the classifier now says so instead of
|
|
61
|
+
// reporting zero.
|
|
62
|
+
const EXEC_MAX_BUFFER = 64 * 1024 * 1024;
|
|
63
|
+
|
|
64
|
+
// Cap on how much of an untracked file is slurped to synthesize its new-file
|
|
65
|
+
// diff. Well past any hand-written source file; anything larger is treated like
|
|
66
|
+
// a binary (counted as a new file with no added lines) rather than read.
|
|
67
|
+
const UNTRACKED_MAX_BYTES = 8 * 1024 * 1024;
|
|
68
|
+
|
|
69
|
+
// Total budget for synthesized untracked-file content. A repo with a huge
|
|
70
|
+
// un-ignored directory must not be slurped into memory wholesale — past this
|
|
71
|
+
// the remaining files are recorded as new files and the result is flagged
|
|
72
|
+
// unmeasurable, which forces review rather than quietly undercounting.
|
|
73
|
+
const UNTRACKED_TOTAL_BUDGET = 32 * 1024 * 1024;
|
|
45
74
|
|
|
46
75
|
// Paths where new logic warrants the 3-agent fan-out.
|
|
47
76
|
// Mechanical edits inside these paths are still SMALL; only adding/removing
|
|
@@ -98,14 +127,43 @@ function noEscalate() {
|
|
|
98
127
|
return { suggested: false, target: null, reason: null };
|
|
99
128
|
}
|
|
100
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Run a git command, returning its stdout — or `null` if the command failed.
|
|
132
|
+
*
|
|
133
|
+
* `null` rather than `''` is load-bearing: a caller that cannot tell "git said
|
|
134
|
+
* nothing" from "git never answered" will report an unreadable diff as an empty
|
|
135
|
+
* one, and an empty diff is the one thing it is safe to call TRIVIAL (#1451).
|
|
136
|
+
*/
|
|
101
137
|
function safeExec(cmd, opts) {
|
|
102
138
|
try {
|
|
103
139
|
return execSync(cmd, {
|
|
104
140
|
encoding: 'utf-8',
|
|
105
141
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
142
|
+
maxBuffer: EXEC_MAX_BUFFER,
|
|
106
143
|
...(opts && opts.cwd ? { cwd: opts.cwd } : {}),
|
|
107
144
|
});
|
|
108
|
-
} catch { return
|
|
145
|
+
} catch { return null; }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Is there a repo with at least one commit here? A git failure only means
|
|
150
|
+
* "there was something we could not measure" when there is history to read —
|
|
151
|
+
* outside a repo, or in a fresh `git init` before the first commit, there is
|
|
152
|
+
* genuinely no diff to miss, and forcing a review fan-out over nothing would be
|
|
153
|
+
* its own defect.
|
|
154
|
+
*/
|
|
155
|
+
function hasGitHistory(cwd) {
|
|
156
|
+
return safeExec('git rev-parse --verify HEAD', cwd ? { cwd } : undefined) !== null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* One `git rev-parse` per classification, shared by both diff readers. They
|
|
161
|
+
* consult it only on their failure paths — and a directory that is not a repo
|
|
162
|
+
* makes every read fail, so without sharing, the cheapest case pays twice.
|
|
163
|
+
*/
|
|
164
|
+
function makeHistoryProbe(cwd) {
|
|
165
|
+
let answer;
|
|
166
|
+
return () => (answer === undefined ? (answer = hasGitHistory(cwd)) : answer);
|
|
109
167
|
}
|
|
110
168
|
|
|
111
169
|
// Detect the consumer's default branch. Hardcoding 'main' silently miscalibrates
|
|
@@ -119,7 +177,7 @@ function detectDefaultBranch(cwd) {
|
|
|
119
177
|
const opts = cwd ? { cwd } : undefined;
|
|
120
178
|
|
|
121
179
|
// Preferred: origin/HEAD points to whatever the remote considers default.
|
|
122
|
-
const symbolic = safeExec('git symbolic-ref --short refs/remotes/origin/HEAD', opts).trim();
|
|
180
|
+
const symbolic = (safeExec('git symbolic-ref --short refs/remotes/origin/HEAD', opts) || '').trim();
|
|
123
181
|
if (symbolic.startsWith('origin/')) {
|
|
124
182
|
const v = symbolic.slice('origin/'.length);
|
|
125
183
|
if (cwd === undefined) _cachedDefaultBranch = v;
|
|
@@ -127,7 +185,7 @@ function detectDefaultBranch(cwd) {
|
|
|
127
185
|
}
|
|
128
186
|
|
|
129
187
|
// Fallback: local init.defaultBranch (set by `git init -b <name>` or config).
|
|
130
|
-
const configured = safeExec('git config --get init.defaultBranch', opts).trim();
|
|
188
|
+
const configured = (safeExec('git config --get init.defaultBranch', opts) || '').trim();
|
|
131
189
|
if (configured) {
|
|
132
190
|
if (cwd === undefined) _cachedDefaultBranch = configured;
|
|
133
191
|
return configured;
|
|
@@ -142,12 +200,95 @@ function _resetCacheForTest() {
|
|
|
142
200
|
_cachedDefaultBranch = null;
|
|
143
201
|
}
|
|
144
202
|
|
|
145
|
-
|
|
203
|
+
/**
|
|
204
|
+
* Read the tracked half of the diff: committed-since-base + working-tree.
|
|
205
|
+
* Returns `{ text, unreadable, reason }` — `unreadable` means a read we needed
|
|
206
|
+
* did not happen, so `text` is an undercount and must not be trusted as zero.
|
|
207
|
+
*/
|
|
208
|
+
function readDiffFromGit(base, cwd, historyProbe) {
|
|
146
209
|
const opts = cwd ? { cwd } : undefined;
|
|
147
|
-
// Combined diff: committed-since-base + working-tree
|
|
148
210
|
const committed = safeExec(`git diff ${base}...HEAD`, opts);
|
|
149
211
|
const working = safeExec('git diff HEAD', opts);
|
|
150
|
-
|
|
212
|
+
const text = (committed || '') + (working ? '\n' + working : '');
|
|
213
|
+
|
|
214
|
+
if (committed !== null && working !== null) return { text, unreadable: false };
|
|
215
|
+
if (!(historyProbe ? historyProbe() : hasGitHistory(cwd))) return { text, unreadable: false };
|
|
216
|
+
|
|
217
|
+
const failed = [];
|
|
218
|
+
if (committed === null) failed.push(`git diff ${base}...HEAD`);
|
|
219
|
+
if (working === null) failed.push('git diff HEAD');
|
|
220
|
+
return { text, unreadable: true, reason: `${failed.join(' and ')} failed` };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Read the untracked half of the diff.
|
|
225
|
+
*
|
|
226
|
+
* Untracked files appear in no `git diff` output at all, so a branch of
|
|
227
|
+
* brand-new files reads as a far smaller change than it is — 12 new CRUD files
|
|
228
|
+
* classified SMALL until someone staged them (#1451). Synthesize a new-file
|
|
229
|
+
* entry per untracked, non-ignored file so `parseDiff` counts it exactly as it
|
|
230
|
+
* would once staged.
|
|
231
|
+
*
|
|
232
|
+
* Built from Node file reads rather than `git diff --no-index` against a null
|
|
233
|
+
* device, which would need `/dev/null` vs `NUL` branching (Rule #1). The index
|
|
234
|
+
* is never touched.
|
|
235
|
+
*/
|
|
236
|
+
function readUntrackedDiff(cwd, historyProbe) {
|
|
237
|
+
const root = cwd || process.cwd();
|
|
238
|
+
const out = safeExec('git ls-files --others --exclude-standard -z', cwd ? { cwd } : undefined);
|
|
239
|
+
if (out === null) {
|
|
240
|
+
return (historyProbe ? historyProbe() : hasGitHistory(cwd))
|
|
241
|
+
? { text: '', unreadable: true, reason: 'git ls-files --others failed' }
|
|
242
|
+
: { text: '', unreadable: false };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const parts = [];
|
|
246
|
+
let budgetSpent = 0;
|
|
247
|
+
let overBudget = false;
|
|
248
|
+
// -z keeps paths raw (no shell quoting of unusual characters). git emits them
|
|
249
|
+
// POSIX-separated on every platform, so they need no separator translation —
|
|
250
|
+
// only path.resolve to reach the file on disk.
|
|
251
|
+
for (const rel of out.split('\0')) {
|
|
252
|
+
if (!rel) continue;
|
|
253
|
+
const header = `diff --git a/${rel} b/${rel}\nnew file mode 100644\n`;
|
|
254
|
+
|
|
255
|
+
let body = null;
|
|
256
|
+
try {
|
|
257
|
+
const abs = path.resolve(root, rel);
|
|
258
|
+
// lstat, not stat: following an untracked symlink would read and count
|
|
259
|
+
// the TARGET's content — mismeasuring the diff, and pulling bytes from
|
|
260
|
+
// wherever the link points, which may be outside the working tree.
|
|
261
|
+
const stat = fs.lstatSync(abs);
|
|
262
|
+
if (stat.isFile() && stat.size <= UNTRACKED_MAX_BYTES && budgetSpent + stat.size <= UNTRACKED_TOTAL_BUDGET) {
|
|
263
|
+
const buf = fs.readFileSync(abs);
|
|
264
|
+
budgetSpent += stat.size;
|
|
265
|
+
if (!buf.includes(0)) body = buf.toString('utf-8');
|
|
266
|
+
} else if (stat.isFile()) {
|
|
267
|
+
overBudget = overBudget || budgetSpent + stat.size > UNTRACKED_TOTAL_BUDGET;
|
|
268
|
+
}
|
|
269
|
+
} catch { /* vanished or unreadable — header only */ }
|
|
270
|
+
|
|
271
|
+
if (body === null) {
|
|
272
|
+
// Binary, symlink, oversized, or unreadable. git emits no `+` lines for
|
|
273
|
+
// these either, so the file still counts toward fileCount/newFiles with
|
|
274
|
+
// zero added lines — the honest measurement, not a swallowed one.
|
|
275
|
+
parts.push(`${header}Binary files /dev/null and b/${rel} differ\n`);
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Split on \n and drop the trailing empty element from a final newline;
|
|
280
|
+
// CRLF files keep their \r on each line, which parseDiff trims before
|
|
281
|
+
// testing for declarations.
|
|
282
|
+
const lines = body.split('\n');
|
|
283
|
+
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
|
|
284
|
+
parts.push(`${header}--- /dev/null\n+++ b/${rel}\n@@ -0,0 +1,${lines.length} @@\n`);
|
|
285
|
+
for (const ln of lines) parts.push(`+${ln}\n`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const text = parts.join('');
|
|
289
|
+
return overBudget
|
|
290
|
+
? { text, unreadable: true, reason: 'untracked files exceeded the diff-synthesis budget' }
|
|
291
|
+
: { text, unreadable: false };
|
|
151
292
|
}
|
|
152
293
|
|
|
153
294
|
/**
|
|
@@ -240,14 +381,19 @@ function parseDiff(diff) {
|
|
|
240
381
|
}
|
|
241
382
|
|
|
242
383
|
/**
|
|
243
|
-
*
|
|
244
|
-
*
|
|
384
|
+
* Route a diff we actually managed to measure. Pure — no I/O. Callers reach
|
|
385
|
+
* this through `decide`, which first handles the case where the measurement
|
|
386
|
+
* itself failed.
|
|
245
387
|
*/
|
|
246
|
-
function
|
|
388
|
+
function decideMeasured(stats) {
|
|
247
389
|
const reasoning = [];
|
|
248
390
|
const totalChange = stats.added + stats.deleted;
|
|
249
391
|
|
|
250
|
-
|
|
392
|
+
// Only a diff with no files at all is genuinely empty. A diff carrying files
|
|
393
|
+
// but no +/- lines — binary assets, pure renames, mode changes, an untracked
|
|
394
|
+
// binary — is a real change git simply does not express as lines, and calling
|
|
395
|
+
// it "nothing to review" is the same undercount as swallowing a read error.
|
|
396
|
+
if (totalChange === 0 && stats.fileCount === 0) {
|
|
251
397
|
return { tier: 'TRIVIAL', model: 'sonnet', agentCount: 0, escalate: noEscalate(), reasoning: ['empty diff — nothing to review'], stats };
|
|
252
398
|
}
|
|
253
399
|
|
|
@@ -345,13 +491,46 @@ function decide(stats) {
|
|
|
345
491
|
return { tier: 'SMALL', model: 'sonnet', agentCount: 1, escalate: noEscalate(), reasoning, stats };
|
|
346
492
|
}
|
|
347
493
|
|
|
494
|
+
/**
|
|
495
|
+
* Pure decision function. Takes parsed stats, returns dispatch decision.
|
|
496
|
+
* No I/O. Easy to unit-test with synthetic stats.
|
|
497
|
+
*
|
|
498
|
+
* `stats.diffUnavailable` marks a diff the reader could not fully measure. The
|
|
499
|
+
* classifier cannot tell "no changes" from "I could not read the changes", and
|
|
500
|
+
* only the first is safe to call TRIVIAL — so an unmeasurable diff routes to a
|
|
501
|
+
* review tier and says why, rather than stamping the gate clean (#1451).
|
|
502
|
+
*/
|
|
503
|
+
function decide(stats) {
|
|
504
|
+
if (!stats.diffUnavailable) return decideMeasured(stats);
|
|
505
|
+
|
|
506
|
+
const note = `diff could not be fully read (${stats.diffUnavailableReason || 'git read failed'})`
|
|
507
|
+
+ ' — a diff the classifier cannot measure is never TRIVIAL';
|
|
508
|
+
// Whatever DID parse may already warrant more than the forced NORMAL floor;
|
|
509
|
+
// an architectural diff whose working-tree half went missing stays DEEP.
|
|
510
|
+
const measured = decideMeasured(stats);
|
|
511
|
+
if (measured.agentCount >= 3) {
|
|
512
|
+
return { ...measured, reasoning: [note].concat(measured.reasoning) };
|
|
513
|
+
}
|
|
514
|
+
return { tier: 'NORMAL', model: 'sonnet', agentCount: 3, escalate: noEscalate(), reasoning: [note], stats };
|
|
515
|
+
}
|
|
516
|
+
|
|
348
517
|
function classifyDiff(diffText) {
|
|
349
518
|
return decide(parseDiff(diffText));
|
|
350
519
|
}
|
|
351
520
|
|
|
352
521
|
function classifyFromGit(base, cwd) {
|
|
353
522
|
const resolved = base || detectDefaultBranch(cwd);
|
|
354
|
-
|
|
523
|
+
const historyProbe = makeHistoryProbe(cwd);
|
|
524
|
+
const tracked = readDiffFromGit(resolved, cwd, historyProbe);
|
|
525
|
+
const untracked = readUntrackedDiff(cwd, historyProbe);
|
|
526
|
+
const stats = parseDiff(tracked.text + (untracked.text ? '\n' + untracked.text : ''));
|
|
527
|
+
if (tracked.unreadable || untracked.unreadable) {
|
|
528
|
+
stats.diffUnavailable = true;
|
|
529
|
+
// Both halves can fail independently; surface every reason, not just the
|
|
530
|
+
// first, so the printed decision explains the whole gap.
|
|
531
|
+
stats.diffUnavailableReason = [tracked.reason, untracked.reason].filter(Boolean).join('; ');
|
|
532
|
+
}
|
|
533
|
+
return decide(stats);
|
|
355
534
|
}
|
|
356
535
|
|
|
357
536
|
if (require.main === module) {
|
|
@@ -375,4 +554,7 @@ if (require.main === module) {
|
|
|
375
554
|
}
|
|
376
555
|
}
|
|
377
556
|
|
|
378
|
-
module.exports = {
|
|
557
|
+
module.exports = {
|
|
558
|
+
parseDiff, decide, classifyDiff, classifyFromGit,
|
|
559
|
+
readUntrackedDiff, detectDefaultBranch, EXEC_MAX_BUFFER, _resetCacheForTest,
|
|
560
|
+
};
|
|
@@ -2682,7 +2682,12 @@ const statuslineCommand = {
|
|
|
2682
2682
|
maturityScore += 10;
|
|
2683
2683
|
intelligencePct = Math.min(100, maturityScore);
|
|
2684
2684
|
}
|
|
2685
|
-
|
|
2685
|
+
// Context-window usage is only knowable from the session payload Claude Code
|
|
2686
|
+
// pipes to a statusline command; `flo hooks status` is a plain CLI invocation
|
|
2687
|
+
// and never receives one. Report null rather than a stand-in (#1453) — this
|
|
2688
|
+
// used to be `learning.sessions * 5`, an activity counter wearing a context
|
|
2689
|
+
// gauge's label, which pinned at 100% after 20 stored sessions.
|
|
2690
|
+
const contextPct = null;
|
|
2686
2691
|
return { memoryMB, contextPct, intelligencePct, subAgents };
|
|
2687
2692
|
}
|
|
2688
2693
|
// Get user info
|
|
@@ -2729,7 +2734,8 @@ const statuslineCommand = {
|
|
|
2729
2734
|
}
|
|
2730
2735
|
// Compact output
|
|
2731
2736
|
if (ctx.flags.compact) {
|
|
2732
|
-
const
|
|
2737
|
+
const ctxDisplay = system.contextPct === null ? '--' : `${system.contextPct}%`;
|
|
2738
|
+
const line = `DDD:${progress.domainsCompleted}/${progress.totalDomains} CVE:${security.cvesFixed}/${security.totalCves} Swarm:${swarm.activeAgents}/${swarm.maxAgents} Ctx:${ctxDisplay} Int:${system.intelligencePct}%`;
|
|
2733
2739
|
output.writeln(line);
|
|
2734
2740
|
return { success: true, data: statusData };
|
|
2735
2741
|
}
|
|
@@ -121,6 +121,7 @@ const DEFAULT_CONFIG = {
|
|
|
121
121
|
show_model: true,
|
|
122
122
|
show_session: true,
|
|
123
123
|
show_intelligence: true,
|
|
124
|
+
show_context: true,
|
|
124
125
|
show_swarm: true,
|
|
125
126
|
show_hooks: true,
|
|
126
127
|
show_mcp: true,
|
|
@@ -361,6 +362,7 @@ function mergeConfig(raw, root) {
|
|
|
361
362
|
show_model: raw.status_line?.show_model ?? raw.statusLine?.showModel ?? DEFAULT_CONFIG.status_line.show_model,
|
|
362
363
|
show_session: raw.status_line?.show_session ?? raw.statusLine?.showSession ?? DEFAULT_CONFIG.status_line.show_session,
|
|
363
364
|
show_intelligence: raw.status_line?.show_intelligence ?? raw.statusLine?.showIntelligence ?? DEFAULT_CONFIG.status_line.show_intelligence,
|
|
365
|
+
show_context: raw.status_line?.show_context ?? raw.statusLine?.showContext ?? DEFAULT_CONFIG.status_line.show_context,
|
|
364
366
|
show_swarm: raw.status_line?.show_swarm ?? raw.statusLine?.showSwarm ?? DEFAULT_CONFIG.status_line.show_swarm,
|
|
365
367
|
show_hooks: raw.status_line?.show_hooks ?? raw.statusLine?.showHooks ?? DEFAULT_CONFIG.status_line.show_hooks,
|
|
366
368
|
show_mcp: raw.status_line?.show_mcp ?? raw.statusLine?.showMcp ?? DEFAULT_CONFIG.status_line.show_mcp,
|
|
@@ -608,6 +610,7 @@ status_line:
|
|
|
608
610
|
show_model: true # Current model name
|
|
609
611
|
show_session: true # Session duration
|
|
610
612
|
show_intelligence: true # Intelligence % indicator
|
|
613
|
+
show_context: true # Context-window % used (from Claude Code's stdin payload)
|
|
611
614
|
show_swarm: true # Active swarm agents count
|
|
612
615
|
show_hooks: true # Enabled hooks count
|
|
613
616
|
show_mcp: true # MCP server count
|