moflo 4.12.9 → 4.12.10

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.
@@ -103,6 +103,7 @@ status_line:
103
103
  show_model: true # Current model name
104
104
  show_session: true # Session duration
105
105
  show_intelligence: true # Intelligence % indicator
106
+ show_context: true # Context-window % used; hides until Claude Code reports it
106
107
  show_swarm: true # Active swarm agents count
107
108
  show_hooks: true # Enabled hooks count
108
109
  show_mcp: true # MCP server count
@@ -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. Substring
244
- // match (case-insensitive) inside the gate.
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 classify;
1263
+ var mod;
1195
1264
  try {
1196
- classify = require('./simplify-classify.cjs').classifyDiff;
1265
+ mod = require('./simplify-classify.cjs');
1197
1266
  } catch (e) { return null; }
1198
- if (typeof classify !== 'function') return null;
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: 8 * 1024 * 1024
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
- if (snapDiff !== null) {
1236
- var combined = snapDiff + (workTreeA ? '\n' + workTreeA : '');
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
- return tryClassify(branchDiff + (workTreeB ? '\n' + workTreeB : ''), 'branch diff');
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.indexOf(DANGEROUS[i]) >= 0) {
2215
+ if (matchesDangerous(cmd, DANGEROUS[i])) {
2114
2216
  console.log('[BLOCKED] Dangerous command: ' + DANGEROUS[i]);
2115
2217
  process.exit(2);
2116
2218
  }
@@ -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
- function readDiffFromGit(base, cwd) {
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
- return committed + (working ? '\n' + working : '');
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
- * Pure decision function. Takes parsed stats, returns dispatch decision.
244
- * No I/O. Easy to unit-test with synthetic stats.
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 decide(stats) {
388
+ function decideMeasured(stats) {
247
389
  const reasoning = [];
248
390
  const totalChange = stats.added + stats.deleted;
249
391
 
250
- if (totalChange === 0) {
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
- return classifyDiff(readDiffFromGit(resolved, cwd));
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 = { parseDiff, decide, classifyDiff, classifyFromGit, detectDefaultBranch, _resetCacheForTest };
557
+ module.exports = {
558
+ parseDiff, decide, classifyDiff, classifyFromGit,
559
+ readUntrackedDiff, detectDefaultBranch, EXEC_MAX_BUFFER, _resetCacheForTest,
560
+ };
@@ -45,6 +45,7 @@ function loadStatusLineConfig() {
45
45
  show_model: true,
46
46
  show_session: true,
47
47
  show_intelligence: true,
48
+ show_context: true,
48
49
  show_swarm: true,
49
50
  show_hooks: true,
50
51
  show_mcp: true,
@@ -153,6 +154,23 @@ function readJSON(filePath) {
153
154
  return null;
154
155
  }
155
156
 
157
+ // Normalize Claude Code's `context_window.used_percentage` into a 0-100 integer.
158
+ // Returns null (never a stand-in number) for anything that isn't a finite number,
159
+ // so every renderer can self-hide rather than publish a value it can't stand
160
+ // behind (#1453).
161
+ function normalizeContextPct(raw) {
162
+ if (typeof raw !== 'number' || !Number.isFinite(raw)) return null;
163
+ return Math.max(0, Math.min(100, Math.round(raw)));
164
+ }
165
+
166
+ // Colour for a context gauge; lower is better. Matches the thresholds already
167
+ // used by the TypeScript statusline generator (src/cli/hooks/statusline/index.ts).
168
+ function contextColor(pct) {
169
+ if (pct >= 75) return c.brightRed;
170
+ if (pct >= 50) return c.brightYellow;
171
+ return c.brightGreen;
172
+ }
173
+
156
174
  // Safe file stat (returns null on failure)
157
175
  function safeStat(filePath) {
158
176
  try {
@@ -386,7 +404,6 @@ function getSystemMetrics() {
386
404
  // Intelligence from learning.json
387
405
  const learningData = readJSON(path.join(CWD, '.moflo', 'metrics', 'learning.json'));
388
406
  let intelligencePct = 0;
389
- let contextPct = 0;
390
407
 
391
408
  if (learningData?.intelligence?.score !== undefined) {
392
409
  intelligencePct = Math.min(100, Math.floor(learningData.intelligence.score));
@@ -409,11 +426,20 @@ function getSystemMetrics() {
409
426
  intelligencePct = Math.min(100, score);
410
427
  }
411
428
 
412
- if (learningData?.sessions?.total !== undefined) {
413
- contextPct = Math.min(100, learningData.sessions.total * 5);
414
- } else {
415
- contextPct = Math.min(100, Math.floor(learning.sessions * 5));
416
- }
429
+ // Context %: the real value, piped in by Claude Code on stdin (#1453).
430
+ //
431
+ // `context_window.used_percentage` is pre-calculated by Claude Code from INPUT
432
+ // tokens only (input_tokens + cache_creation_input_tokens + cache_read_input_tokens);
433
+ // it deliberately excludes output_tokens. It already accounts for
434
+ // `context_window_size`, so it stays correct on a 1M-context model. Do NOT
435
+ // "correct" this to `exceeds_200k_tokens`, which is a fixed 200k threshold and
436
+ // is meaningless on anything larger.
437
+ //
438
+ // It is null early in a session (before the first API response). Report null,
439
+ // never a substitute: this field used to be derived from the stored session
440
+ // count (`sessions * 5`), which pinned at 100% after 20 sessions and had nothing
441
+ // to do with the window. A blank beats a wrong number.
442
+ const contextPct = normalizeContextPct(STDIN_PAYLOAD?.context_window?.used_percentage);
417
443
 
418
444
  // Sub-agents from file metrics (no ps aux)
419
445
  let subAgents = 0;
@@ -797,6 +823,12 @@ function generateStatusline() {
797
823
  parts.push(`${c.cyan}\u23F1 ${session.duration}${c.reset}`);
798
824
  }
799
825
 
826
+ // Context % (#1453). Self-hides when Claude Code hasn't reported a window yet,
827
+ // rather than rendering a placeholder that reads as a real measurement.
828
+ if (SL_CONFIG.show_context && system.contextPct !== null) {
829
+ parts.push(`${contextColor(system.contextPct)}\uD83D\uDCC2 ${system.contextPct}%${c.reset}`);
830
+ }
831
+
800
832
  // Intelligence %
801
833
  if (SL_CONFIG.show_intelligence) {
802
834
  const intellColor = system.intelligencePct >= 80 ? c.brightGreen : system.intelligencePct >= 40 ? c.brightYellow : c.dim;
@@ -875,6 +907,14 @@ function generateDashboard() {
875
907
  );
876
908
  }
877
909
 
910
+ // Context % (#1453). Self-hides when the value is unknown.
911
+ if (SL_CONFIG.show_context && system.contextPct !== null) {
912
+ lines.push(
913
+ `${c.brightCyan}\uD83D\uDCC2 Context${c.reset} ${contextColor(system.contextPct)}${system.contextPct}%${c.reset} ` +
914
+ `${c.dim}used${c.reset}`
915
+ );
916
+ }
917
+
878
918
  // Embeddings line \u2014 vector store stats from .moflo/vector-stats.json.
879
919
  // Reuses `system.embeddings` (already computed by getSystemMetrics()) instead
880
920
  // of re-probing the cache file on every render.
@@ -952,8 +992,17 @@ function generateCompactDashboard() {
952
992
  pushUpgradeNoticeSegment(lines);
953
993
  lines.push(header);
954
994
 
955
- // Combined swarm + embeddings + mcp line
995
+ // Combined context + swarm + embeddings + mcp line
956
996
  const segments = [];
997
+ // Context % (#1453). Read straight off the stdin payload rather than via
998
+ // getSystemMetrics() — compact mode deliberately avoids that call so it stays
999
+ // probe-free, and this value costs nothing to derive.
1000
+ {
1001
+ const pct = normalizeContextPct(STDIN_PAYLOAD?.context_window?.used_percentage);
1002
+ if (SL_CONFIG.show_context && pct !== null) {
1003
+ segments.push(`${contextColor(pct)}\uD83D\uDCC2 ${pct}%${c.reset}`);
1004
+ }
1005
+ }
957
1006
  if (SL_CONFIG.show_swarm) {
958
1007
  const swarm = getSwarmStatus();
959
1008
  const swarmInd = swarm.coordinationActive ? `${c.brightGreen}\u25C9${c.reset}` : `${c.dim}\u25CB${c.reset}`;