moflo 4.13.1 → 4.13.2

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.
@@ -38,7 +38,8 @@ code_map:
38
38
  gates:
39
39
  memory_first: true # Block Glob/Grep/Read until memory is searched
40
40
  task_create_first: true # Advisory reminder before Agent tool (not blocking)
41
- context_tracking: true # Track context bracket (FRESH/MODERATE/DEPLETED/CRITICAL)
41
+ context_tracking: true # Report measured context usage, once per band crossing
42
+ # context_limit: 200k # Optional window size (200000 / 200k / 1m); unset = raw token counts
42
43
 
43
44
  # Auto-index on session start
44
45
  auto_index:
@@ -230,7 +231,8 @@ skills:
230
231
  | `session_continuity.inject: false` | Keep capturing digests but never auto-inject them at session start |
231
232
  | `gates.memory_first: true` | Block Glob/Grep/Read until memory is searched first |
232
233
  | `gates.task_create_first: true` | Advisory reminder before Agent tool (not blocking) |
233
- | `gates.context_tracking: true` | Show FRESH/MODERATE/DEPLETED/CRITICAL context bracket |
234
+ | `gates.context_tracking: true` | Report measured context usage, once per band crossing |
235
+ | `gates.context_limit: 200k` | Your model's context window, so the notice quotes a percentage. Unset by default — a hook cannot observe the window, and moflo will not guess one |
234
236
  | `hooks.pre_edit: false` | Disable file-edit tracking (skips pre-edit hook) |
235
237
  | `hooks.post_edit: false` | Disable edit outcome recording and neural training |
236
238
  | `hooks.pre_task: false` | Disable agent routing recommendations before spawn |
@@ -13,7 +13,7 @@ var STATE_FILE = path.join(PROJECT_DIR, '.claude', 'workflow-state.json');
13
13
  // the code it describes, so a change made outside Write/Edit/MultiEdit (a Bash
14
14
  // write, a branch switch, the next issue in the same session) invalidates it.
15
15
  // See creditFingerprint() for why the boolean flags alone cannot.
16
- var STATE_DEFAULTS = { tasksCreated: false, taskCount: 0, tasksAcknowledged: false, memorySearched: false, memorySearchedBy: {}, memoryRequired: true, learningsStored: false, testsRun: false, testsFingerprint: null, simplifyRun: false, simplifySnapshotSha: null, simplifyFingerprint: null, verifyRun: false, verifyOutcome: null, verifyFingerprint: null, interactionCount: 0, sessionStart: null, lastBlockedAt: null, lastNamespaceHint: '', lastNamespaceHintEmittedBy: {}, flMode: null, swarmInitialized: false, hiveInitialized: false, sddMode: false, activeSddSlug: null };
16
+ var STATE_DEFAULTS = { tasksCreated: false, taskCount: 0, tasksAcknowledged: false, memorySearched: false, memorySearchedBy: {}, memoryRequired: true, learningsStored: false, testsRun: false, testsFingerprint: null, simplifyRun: false, simplifySnapshotSha: null, simplifyFingerprint: null, verifyRun: false, verifyOutcome: null, verifyFingerprint: null, interactionCount: 0, contextBand: null, compactedAt: null, sessionStart: null, lastBlockedAt: null, lastNamespaceHint: '', lastNamespaceHintEmittedBy: {}, flMode: null, swarmInitialized: false, hiveInitialized: false, sddMode: false, activeSddSlug: null };
17
17
 
18
18
  // Per-actor memory-search tracking (#838). The legacy `memorySearched` boolean
19
19
  // is session-wide, so once the parent searches memory, every spawned subagent
@@ -76,7 +76,7 @@ function loadGateConfig() {
76
76
  // consecutive ignores in one session is not a control. Blocking is the default
77
77
  // because the honest "these stay open on purpose" outcome is one command away
78
78
  // (record-tasks-acknowledged), so nothing here can deadlock a run.
79
- var defaults = { memory_first: true, task_create_first: true, context_tracking: true, testing_gate: true, simplify_gate: true, learnings_gate: true, swarm_invocation_gate: true, verify_before_done: true, sdd_gate: true, task_status_gate: 'block' };
79
+ var defaults = { memory_first: true, task_create_first: true, context_tracking: true, context_limit: null, testing_gate: true, simplify_gate: true, learnings_gate: true, swarm_invocation_gate: true, verify_before_done: true, sdd_gate: true, task_status_gate: 'block' };
80
80
  var content = MOFLO_YAML;
81
81
  if (content) {
82
82
  // Boolean forms are accepted so this key reads like every other gate in the
@@ -91,6 +91,17 @@ function loadGateConfig() {
91
91
  if (/memory_first:\s*false/i.test(content)) defaults.memory_first = false;
92
92
  if (/task_create_first:\s*false/i.test(content)) defaults.task_create_first = false;
93
93
  if (/context_tracking:\s*false/i.test(content)) defaults.context_tracking = false;
94
+ // Optional and OFF by default (#1487). Only an explicit value lets the
95
+ // context notice quote a percentage — see contextLimit() for why nothing
96
+ // infers one.
97
+ // Anchored to the start of a line, unlike the boolean keys above, because
98
+ // this one is DOCUMENTED as a commented-out example (`# context_limit: 200k`
99
+ // in the README and the yaml reference). An unanchored match would read that
100
+ // comment as configuration the moment anyone pasted the block, quietly
101
+ // pinning a 200k denominator onto a 1M session — this issue's exact failure,
102
+ // redelivered through the docs.
103
+ var climit = /^[ \t]*context_limit:\s*['"]?([0-9.]+\s*[km]?)['"]?/im.exec(content);
104
+ if (climit) defaults.context_limit = parseTokenCount(climit[1]);
94
105
  if (/testing_gate:\s*false/i.test(content)) defaults.testing_gate = false;
95
106
  if (/simplify_gate:\s*false/i.test(content)) defaults.simplify_gate = false;
96
107
  if (/learnings_gate:\s*false/i.test(content)) defaults.learnings_gate = false;
@@ -863,6 +874,226 @@ function classifyBashNamespaceHint(cmd) {
863
874
  return '';
864
875
  }
865
876
 
877
+ // ─────────────────────────────────────────────────────────────────────────────
878
+ // Context usage (#1487)
879
+ //
880
+ // The banner this feeds used to be driven by `interactionCount` — a monotonic
881
+ // per-prompt counter with no reset anywhere, including on compaction. Past 30
882
+ // prompts it emitted "Context: CRITICAL. Commit, store learnings, suggest new
883
+ // session." on EVERY subsequent turn for the life of the session state, while
884
+ // real usage sat around 12%. Because the text was shaped as an instruction
885
+ // rather than a metric, the model complied: truncated investigations, handed
886
+ // back partial findings, and recommended a fresh session over a nearly empty
887
+ // window. A wrong instruction-shaped hook message is worse than none — the
888
+ // model cannot check it and will generally obey.
889
+ //
890
+ // So: measure instead of guess. Claude Code already writes the per-turn token
891
+ // usage into the session transcript it hands every hook, and that number drops
892
+ // the moment a compaction lands, which makes the whole class of "stuck after
893
+ // compact" impossible rather than merely patched.
894
+
895
+ // Accepts `200000`, `200k`, `1m`, `1.5m`. One parser for BOTH the yaml key and
896
+ // the env override, because they were two: `parseInt('200k')` is 200, so an
897
+ // operator who wrote the documented `MOFLO_CONTEXT_LIMIT=200k` got a 200-token
898
+ // window and `Context: 61000% used` every single turn — #1487 reproduced by the
899
+ // fix for #1487. Anything below 1000 tokens is not a context window; treat it as
900
+ // a typo and decline rather than quote arithmetic off it.
901
+ function parseTokenCount(raw) {
902
+ var m = /^\s*(\d+(?:\.\d+)?)\s*([km])?\s*$/i.exec(String(raw || ''));
903
+ if (!m) return null;
904
+ var unit = (m[2] || '').toLowerCase();
905
+ var n = Math.round(parseFloat(m[1]) * (unit === 'm' ? 1000000 : unit === 'k' ? 1000 : 1));
906
+ return n >= 1000 ? n : null;
907
+ }
908
+
909
+ // The context LIMIT is not observable from a hook, and this is the one place it
910
+ // would be tempting to guess. Claude Code's transcript records `claude-opus-5`
911
+ // for a 1M-context session and for a 200k one alike — there is no marker, no
912
+ // limit field, and nothing in the UserPromptSubmit payload. Guessing 200k would
913
+ // have reported a 122k 1M-window session as "61% used", which is #1487's
914
+ // original failure re-created with better arithmetic: a confident, wrong number
915
+ // the model cannot check.
916
+ //
917
+ // So a percentage is quoted ONLY when the operator states the window, via
918
+ // `gates.context_limit` in moflo.yaml or MOFLO_CONTEXT_LIMIT in the environment.
919
+ // Otherwise the notice reports the raw token count, which cannot be wrong.
920
+ function contextLimit(config) {
921
+ var override = parseTokenCount(process.env.MOFLO_CONTEXT_LIMIT);
922
+ if (override) return override;
923
+ return config && config.context_limit > 0 ? config.context_limit : null;
924
+ }
925
+
926
+ // Absolute-token milestones for the no-limit case, descending. Crossing one is a
927
+ // real event about a real number, and it keeps the notice rare without implying
928
+ // anything about how full the window is.
929
+ var CONTEXT_MILESTONES = [800000, 400000, 200000, 100000];
930
+
931
+ // Ordinal for "is this worse than the last thing we announced". Only an UPWARD
932
+ // move is announced: emitting "checkpointing progress is worth considering" on
933
+ // the way DOWN — right after a compaction freed the window — is the same
934
+ // backwards advice #1487 is about. A downward move still updates the memo, which
935
+ // is what re-arms the next genuine crossing.
936
+ function contextBandRank(band) {
937
+ if (!band || band === 'FRESH') return 0;
938
+ if (band.indexOf('tokens:') === 0) {
939
+ var n = parseInt(band.slice(7), 10);
940
+ for (var i = CONTEXT_MILESTONES.length - 1, rank = 1; i >= 0; i--, rank++) {
941
+ if (CONTEXT_MILESTONES[i] === n) return rank;
942
+ }
943
+ return 1;
944
+ }
945
+ return band === 'CRITICAL' ? 3 : band === 'DEPLETED' ? 2 : 1;
946
+ }
947
+
948
+ // First read. Sized for the common case: the newest assistant record is within a
949
+ // few KB of EOF.
950
+ var CONTEXT_TAIL_BYTES = 64 * 1024;
951
+ // Escalation, used only when the first window held no assistant usage record at
952
+ // all. One assistant turn carrying a large tool result can exceed the first
953
+ // window entirely, leaving a tail with no newline in it — and without this the
954
+ // gate would fall silent permanently, which is the failure this whole change
955
+ // exists to remove.
956
+ var CONTEXT_TAIL_MAX_BYTES = 8 * 1024 * 1024;
957
+
958
+ // Scan the last `tailBytes` of the transcript backwards for the newest main-loop
959
+ // assistant turn's context size. Returns { used, sawUsage }: `used` is null when
960
+ // nothing qualified, and `sawUsage` distinguishes "no record in this window"
961
+ // (escalate) from "records found but all older than minTs" (do not escalate —
962
+ // a wider window can only find even older ones).
963
+ //
964
+ // Cross-platform (Rule #1): fs primitives and an env-supplied absolute path —
965
+ // no shell, no `tail`, no path separators of our own.
966
+ function scanTranscriptTail(transcript, tailBytes, minTs) {
967
+ var fd = null;
968
+ var result = { used: null, sawUsage: false };
969
+ try {
970
+ var st = fs.statSync(transcript);
971
+ if (!st.isFile() || !st.size) return result;
972
+ var start = st.size > tailBytes ? st.size - tailBytes : 0;
973
+ var length = st.size - start;
974
+ // allocUnsafe, not alloc: `filled` bounds every read below, so the
975
+ // uninitialised tail is never observed — and zero-filling this on every
976
+ // prompt in every consumer session buys nothing.
977
+ var buf = Buffer.allocUnsafe(length);
978
+ fd = fs.openSync(transcript, 'r');
979
+ // Loop: readSync is permitted to return a short read, and a partial fill
980
+ // would leave the tail of the buffer as garbage — which lands on the NEWEST
981
+ // record, the one record this function exists to find.
982
+ var filled = 0;
983
+ while (filled < length) {
984
+ var n = fs.readSync(fd, buf, filled, length - filled, start + filled);
985
+ if (n <= 0) break;
986
+ filled += n;
987
+ }
988
+ var text = buf.toString('utf-8', 0, filled);
989
+ // Walked backwards by newline index rather than split(): the split would
990
+ // allocate a substring per line of a window whose last few records are all
991
+ // we want, and this loop normally exits after one or two of them.
992
+ var end = text.length;
993
+ while (end > 0) {
994
+ var nl = text.lastIndexOf('\n', end - 1);
995
+ // A window that starts mid-file cuts its first record in half. Stop rather
996
+ // than parse the fragment — and note that a window holding NO newline at
997
+ // all leaves sawUsage false, which is what triggers escalation.
998
+ if (nl < 0) { if (start > 0) break; nl = -1; }
999
+ var line = text.slice(nl + 1, end).trim();
1000
+ end = nl;
1001
+ // Cheap pre-filter, same shape as readTaskLedger's. A transcript tail is
1002
+ // dominated by large tool_result and user records; JSON.parse on every one
1003
+ // of them is the whole cost of this function.
1004
+ if (!line || line.indexOf('"input_tokens"') < 0) continue;
1005
+ var entry;
1006
+ try { entry = JSON.parse(line); } catch (e) { continue; }
1007
+ if (!entry || entry.type !== 'assistant') continue;
1008
+ // Subagent turns carry their OWN small context. Counting one would report
1009
+ // a freshly-spawned agent's window as the main loop's.
1010
+ if (entry.isSidechain === true) continue;
1011
+ var usage = entry.message && entry.message.usage;
1012
+ if (!usage || typeof usage.input_tokens !== 'number') continue;
1013
+ // Cached and uncached input are both resident context; the split is a
1014
+ // billing detail. Output is excluded — it is this turn's reply, not the
1015
+ // window it was produced from, and it lands in the next turn's input.
1016
+ var used = usage.input_tokens +
1017
+ (usage.cache_read_input_tokens || 0) +
1018
+ (usage.cache_creation_input_tokens || 0);
1019
+ if (!(used > 0)) continue;
1020
+ result.sawUsage = true;
1021
+ // Older than the last compaction: this record describes a window that no
1022
+ // longer exists. See readTranscriptUsage for why that matters.
1023
+ if (minTs && !(String(entry.timestamp || '') > minTs)) continue;
1024
+ result.used = used;
1025
+ return result;
1026
+ }
1027
+ } catch (e) {
1028
+ // Unreadable transcript — the caller stays silent rather than guessing.
1029
+ } finally {
1030
+ if (fd !== null) { try { fs.closeSync(fd); } catch (e) { /* already gone */ } }
1031
+ }
1032
+ return result;
1033
+ }
1034
+
1035
+ // Newest main-loop assistant turn's context size in tokens, or null when the
1036
+ // transcript is absent, unreadable, or carries nothing newer than `minTs`.
1037
+ //
1038
+ // `minTs` is the last compaction's timestamp, and skipping past it is essential
1039
+ // rather than tidy: UserPromptSubmit fires BEFORE the first post-compaction
1040
+ // assistant turn exists, so the newest record on disk is still the pre-compaction
1041
+ // one. Without this the very first prompt after a `/compact` would report the
1042
+ // window the user just emptied — the reported bug, one turn wide.
1043
+ function readTranscriptUsage(minTs) {
1044
+ var transcript = process.env.HOOK_TRANSCRIPT_PATH || '';
1045
+ if (!transcript) return null;
1046
+ var r = scanTranscriptTail(transcript, CONTEXT_TAIL_BYTES, minTs);
1047
+ if (r.used !== null || r.sawUsage) return r.used;
1048
+ // Nothing in the first window even looked like a usage record — a single
1049
+ // oversized turn can be wider than the window itself. Widen once.
1050
+ return scanTranscriptTail(transcript, CONTEXT_TAIL_MAX_BYTES, minTs).used;
1051
+ }
1052
+
1053
+ function contextBandForPct(pct) {
1054
+ if (pct >= 90) return 'CRITICAL';
1055
+ if (pct >= 75) return 'DEPLETED';
1056
+ if (pct >= 50) return 'MODERATE';
1057
+ return 'FRESH';
1058
+ }
1059
+
1060
+ function formatTokens(n) {
1061
+ return n >= 1000 ? Math.round(n / 1000) + 'k' : String(n);
1062
+ }
1063
+
1064
+ // { band, line } for the current context, or null when it cannot be measured.
1065
+ //
1066
+ // There is deliberately NO fallback here. The obvious one — fall back to the
1067
+ // turn counter when the transcript is unreadable — reintroduces the bug twice
1068
+ // over: a turn count is not context usage (that IS #1487), and mixing the two
1069
+ // sources makes the memo oscillate, so a transcript that reads intermittently
1070
+ // would emit, fall silent, and emit the same line again forever. A hook with
1071
+ // nothing to measure has nothing to say.
1072
+ function describeContext(config, minTs) {
1073
+ var used = readTranscriptUsage(minTs);
1074
+ if (used === null) return null;
1075
+ var limit = contextLimit(config);
1076
+ if (limit) {
1077
+ var pct = Math.round((used / limit) * 100);
1078
+ var band = contextBandForPct(pct);
1079
+ var line = 'Context: ' + pct + '% used (' + formatTokens(used) + ' of ' +
1080
+ formatTokens(limit) + ' tokens).';
1081
+ if (band === 'DEPLETED') line += ' Checkpointing progress is worth considering.';
1082
+ else if (band === 'CRITICAL') line += ' /compact or a fresh session is worth considering.';
1083
+ return { band: band, line: line };
1084
+ }
1085
+ var milestone = 0;
1086
+ for (var i = 0; i < CONTEXT_MILESTONES.length; i++) {
1087
+ if (used >= CONTEXT_MILESTONES[i]) { milestone = CONTEXT_MILESTONES[i]; break; }
1088
+ }
1089
+ // No advice attached on purpose: without a window size there is nothing
1090
+ // honest to advise, and the model already knows its own limit.
1091
+ return {
1092
+ band: milestone ? 'tokens:' + milestone : 'FRESH',
1093
+ line: 'Context: ' + formatTokens(used) + ' tokens in the window.',
1094
+ };
1095
+ }
1096
+
866
1097
  // Apply per-prompt state reset shared by `prompt-reminder` (full) and
867
1098
  // `prompt-state-reset` (defensive safety-net, no emission). Idempotent — both
868
1099
  // UserPromptSubmit hooks can run it without compounding any field. Caller
@@ -2266,6 +2497,24 @@ switch (command) {
2266
2497
  // written after it; refreshed every prompt, so a /clear that mints a new id
2267
2498
  // is picked up on the next turn rather than going stale.
2268
2499
  if (process.env.HOOK_SESSION_ID) s.sessionId = process.env.HOOK_SESSION_ID;
2500
+ // #1487 — resolved BEFORE the write so the edge-trigger memo rides the same
2501
+ // one, and emitted after the /flo modifiers below so the authoritative run
2502
+ // modes stay at the top of the hook's output.
2503
+ var contextNotice = '';
2504
+ if (config.context_tracking) {
2505
+ var ctx = describeContext(config, s.compactedAt);
2506
+ // Nothing measurable → nothing said, and the memo is left exactly as it
2507
+ // was so a transient read failure cannot re-arm an already-announced band.
2508
+ if (ctx) {
2509
+ // Edge-triggered, not level-triggered, and only upward. A banner
2510
+ // repeated verbatim for dozens of turns carries no information after the
2511
+ // first one and trains the model to treat it as a standing instruction.
2512
+ // A downward move still updates the memo — that is what re-arms the next
2513
+ // genuine crossing after a compaction.
2514
+ if (contextBandRank(ctx.band) > contextBandRank(s.contextBand)) contextNotice = ctx.line;
2515
+ s.contextBand = ctx.band;
2516
+ }
2517
+ }
2269
2518
  writeState(s);
2270
2519
  // Announce the resolved /flo run modifiers. The gate already parsed
2271
2520
  // moflo.yaml in THIS process (fresh per prompt — a git pull or a mid-session
@@ -2294,12 +2543,7 @@ switch (command) {
2294
2543
  console.log('[moflo] merge is ON via ' + floRun.mergeSrc + ' — the PR will be auto-merged. Opt out: --no-merge.');
2295
2544
  }
2296
2545
  }
2297
- if (config.context_tracking) {
2298
- var ic = s.interactionCount;
2299
- if (ic > 30) console.log('Context: CRITICAL. Commit, store learnings, suggest new session.');
2300
- else if (ic > 20) console.log('Context: DEPLETED. Checkpoint progress. Recommend /compact or fresh session.');
2301
- else if (ic > 10) console.log('Context: MODERATE. Re-state goal before architectural decisions. Use agents for >300 LOC.');
2302
- }
2546
+ if (contextNotice) console.log(contextNotice);
2303
2547
  break;
2304
2548
  }
2305
2549
  case 'prompt-state-reset': {
@@ -32,6 +32,16 @@ if (typeof hookContext.session_id === 'string' && hookContext.session_id) {
32
32
  env.HOOK_SESSION_ID = hookContext.session_id;
33
33
  }
34
34
 
35
+ // #1487 — forward the transcript path so `prompt-reminder` can report REAL
36
+ // context usage instead of a turn counter. Claude Code writes per-turn token
37
+ // usage into this file, and it is the only place a UserPromptSubmit hook can
38
+ // read it: the payload carries no usage field of its own. Absent or non-string
39
+ // (an older host, a test harness) simply leaves the var unset, and gate.cjs
40
+ // falls back to the turn count.
41
+ if (typeof hookContext.transcript_path === 'string' && hookContext.transcript_path) {
42
+ env.HOOK_TRANSCRIPT_PATH = hookContext.transcript_path;
43
+ }
44
+
35
45
  // Run prompt-reminder via gate.cjs
36
46
  var projectDir = (env.CLAUDE_PROJECT_DIR || process.cwd()).replace(/^\/([a-z])\//i, '$1:/');
37
47
  var gateScript = resolve(projectDir, '.claude/helpers/gate.cjs');
package/README.md CHANGED
@@ -324,7 +324,9 @@ gates:
324
324
  memory_first: true # Set to false to disable memory-first enforcement
325
325
  task_create_first: true # Set to false to disable TaskCreate enforcement (disables task_status_gate too)
326
326
  task_status_gate: block # Open tasks block `gh pr create`; warn → report only; off → silent
327
- context_tracking: true # Set to false to disable context bracket warnings
327
+ context_tracking: true # Set to false to disable the context-usage notice
328
+ # context_limit: 200k # Optional. Your model's window, so the notice can quote a %.
329
+ # Unset (default) = report raw tokens; moflo never guesses a window.
328
330
  verify_before_done: true # On by default (#1294); set false to skip /verify before `gh pr create`
329
331
  ```
330
332
 
@@ -1013,7 +1015,8 @@ gates:
1013
1015
  memory_first: true # Must search memory before file exploration
1014
1016
  task_create_first: true # Must TaskCreate before Agent tool
1015
1017
  task_status_gate: block # Open tasks block `gh pr create`; warn → report only; off → silent
1016
- context_tracking: true # Track context window depletion
1018
+ context_tracking: true # Report measured context-window usage once per band crossing
1019
+ # context_limit: 200k # Optional window size (200000 / 200k / 1m) to get a % instead of raw tokens
1017
1020
  verify_before_done: true # On by default (#1294); /verify before `gh pr create` (unless --no-verify). false to disable
1018
1021
 
1019
1022
  sdd: