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.
package/bin/gate.cjs CHANGED
@@ -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');
@@ -803,6 +803,26 @@ const KNOWN_SESSION_SOURCES = new Set(['startup', 'clear', 'compact', 'resume'])
803
803
  // thing this whole issue is about.
804
804
  const MEMORY_CREDIT_KEYS = ['memorySearched', 'memorySearchedBy', 'memoryRequired'];
805
805
 
806
+ // #1487 — the context banner's two fields are invalidated by a compaction for
807
+ // the same reason. `interactionCount` is the fallback turn heuristic, and a
808
+ // compaction is precisely the event that falsifies its premise; before this it
809
+ // had no reset ANYWHERE, so past 30 prompts the banner fired every turn for the
810
+ // life of the session state and a compaction did not clear it. `contextBand` is
811
+ // the edge-trigger memo, which must clear so the next genuine crossing is
812
+ // announced rather than swallowed as "already said that".
813
+ const CONTEXT_TRACKING_KEYS = ['interactionCount', 'contextBand'];
814
+ const COMPACTION_RESET_KEYS = [...MEMORY_CREDIT_KEYS, ...CONTEXT_TRACKING_KEYS];
815
+
816
+ // …and `compactedAt` is STAMPED rather than cleared, because clearing it is not
817
+ // enough. UserPromptSubmit fires BEFORE the first post-compaction assistant turn
818
+ // exists, so on the very next prompt the newest usage record in the transcript is
819
+ // still the pre-compaction one. gate.cjs skips records at or older than this
820
+ // stamp, so the first prompt after a compaction reports nothing instead of
821
+ // reporting the window the user just emptied.
822
+ function compactionStamp() {
823
+ return { compactedAt: new Date().toISOString() };
824
+ }
825
+
806
826
  // Full shape, not the 4-field literal this used to write. gate.cjs readState()
807
827
  // merges STATE_DEFAULTS over whatever it parses, so the short shape behaved
808
828
  // identically THERE — but it left a half-populated file for every other reader
@@ -827,6 +847,8 @@ function freshWorkflowState() {
827
847
  verifyOutcome: null,
828
848
  verifyFingerprint: null,
829
849
  interactionCount: 0,
850
+ contextBand: null,
851
+ compactedAt: null,
830
852
  sessionStart: new Date().toISOString(),
831
853
  lastBlockedAt: null,
832
854
  lastNamespaceHint: '',
@@ -840,11 +862,12 @@ function freshWorkflowState() {
840
862
  }
841
863
 
842
864
  // Derived from freshWorkflowState(), not a second literal: a re-armed memory
843
- // gate is by definition the fresh-session value of those keys, and one place to
844
- // change beats two that agree only by inspection.
845
- function rearmedMemoryState() {
865
+ // gate and a cleared context counter are by definition the fresh-session values
866
+ // of those keys, and one place to change beats two that agree only by
867
+ // inspection.
868
+ function rearmedCompactionState() {
846
869
  const fresh = freshWorkflowState();
847
- return Object.fromEntries(MEMORY_CREDIT_KEYS.map((key) => [key, fresh[key]]));
870
+ return Object.fromEntries(COMPACTION_RESET_KEYS.map((key) => [key, fresh[key]]));
848
871
  }
849
872
 
850
873
  // Bounded at 500ms by readHookStdin and short-circuited on a TTY, so a withheld
@@ -873,8 +896,8 @@ if (!CONTINUING_SESSION_SOURCES.has(sessionSource)) {
873
896
  // Non-fatal - workflow gate will use defaults
874
897
  }
875
898
  } else if (sessionSource === 'compact') {
876
- // Merge, never rewrite: everything the run has earned stays, only the memory
877
- // credit is re-armed. Skipped entirely when there is no state file yet — a
899
+ // Merge, never rewrite: everything the run has earned stays; only the memory
900
+ // credit and the context-tracking fields (#1487) are reset. Skipped entirely when there is no state file yet — a
878
901
  // compaction before any prompt has nothing to re-arm, and writing a partial
879
902
  // file here would defeat freshWorkflowState()'s shape guarantee.
880
903
  //
@@ -890,13 +913,13 @@ if (!CONTINUING_SESSION_SOURCES.has(sessionSource)) {
890
913
  const parsed = JSON.parse(readFileSync(stateFile, 'utf-8'));
891
914
  writeFileSync(
892
915
  stateFile,
893
- JSON.stringify({ ...parsed, ...rearmedMemoryState() }, null, 2),
916
+ JSON.stringify({ ...parsed, ...rearmedCompactionState(), ...compactionStamp() }, null, 2),
894
917
  );
895
918
  }
896
919
  } catch (err) {
897
920
  // Non-fatal, but not silent (#854): a failure here leaves the memory gate
898
921
  // credited over a context that no longer holds the results.
899
- emitWarning(`could not re-arm the memory gate after compaction (${errMessage(err)})`);
922
+ emitWarning(`could not reset gate state after compaction (${errMessage(err)})`);
900
923
  }
901
924
  }
902
925
 
@@ -513,7 +513,8 @@ ${srcDirs.map(d => ` - ${d}`).join('\n')}
513
513
  gates:
514
514
  memory_first: true # Search memory before Glob/Grep
515
515
  task_create_first: true # TaskCreate before Agent tool
516
- context_tracking: true # Track context bracket (FRESH/MODERATE/DEPLETED/CRITICAL)
516
+ context_tracking: true # Report measured context usage, once per band crossing (#1487)
517
+ # context_limit: 200k # Optional window size (200000 / 200k / 1m) so the notice quotes a %
517
518
  verify_before_done: true # Epic #1269/#1294: run /verify before 'gh pr create'. On by default; opt out with false or per-run --no-verify
518
519
 
519
520
  # Auto-index on session start