agy-context-saver 1.4.0 → 1.5.0

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/CHANGELOG.md CHANGED
@@ -5,6 +5,25 @@ All notable changes to `Agy-Context-Saver` will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.5.0] - 2026-10-08
9
+
10
+ ### Performance & Memory Optimizations
11
+ - **File-Descriptor Leak Fixes (PERF-01, PERF-02)**: Ensured guaranteed stream destruction via `finally { rl.close(); fileStream.destroy(); }` across `batchFindFullSteps`, `handleCheckContextHealth`, `handleReadTranscript`, and `handleQueryTranscript`, eliminating open handle leaks on early exits and line caps (verified 0/20 leaked).
12
+ - **Subprocess Call Reduction in PreToolUse Hook (PERF-03)**: Dropped redundant `rtk --version` probe in hook initialization; executes `rtk rewrite` directly and falls back to plugin binary only on `ENOENT`, reducing cache miss latency from 302ms to 249ms (-17.5%).
13
+ - **Surgical Tail-Window Task State Resolution (PERF-04, PERF-07)**: Unified task lifecycle checking into a single `getTaskState()` reading the last 256KB tail of `transcript.jsonl` with literal needle matching (`scanTaskMarkers`), dropping check latency from 123ms to 2ms on 10.5MB transcripts (-98%).
14
+ - **Atomic Concurrency-Safe Rewrite Cache (PERF-05, PERF-09)**: Rewrite cache now filters out timeouts and non-zero/non-clean exit codes, re-reads disk state to merge concurrent updates, and persists atomically via temporary files.
15
+ - **Pre-Rewrite Fast Denial Ordering (PERF-10)**: Reordered command checks so raw-command gates (internal state access and running task log denials) execute before invoking `rtk rewrite`, eliminating subprocess overhead for denied commands.
16
+ - **Bounded Query Memory & Raw-Line Prefilter (PERF-11, PERF-12)**: Implemented ring-buffered step retention bounded by `effectiveLimit` with `matchedCount` in `query_transcript`, and added raw-line ASCII substring pre-filtering to skip `JSON.parse` on non-matching lines.
17
+ - **Active Conversation Resolution Cache (PERF-13)**: Cached `resolveTranscriptPath("current")` lookups for 5s and implemented resilient per-entry directory stats.
18
+ - **Sliding-Window Parsing & String Pre-Slicing (PERF-14, PERF-15, PERF-16)**: `read_transcript` now streams raw lines and only parses retained turns; `cleanMessageContent` pre-slices oversized payloads to `4 * maxChars` before running regexes; `check_context_health` skips parsing for steps without tool calls.
19
+ - **Redundant Syscall Elimination (PERF-17, PERF-18, PERF-19)**: Removed unnecessary `existsSync` checks before reads and replaced dynamic task id regexes with literal string searching.
20
+
21
+ ### Governance & Configuration Hardening
22
+ - **Installed Hook Matcher Inclusion (OBS-A)**: Added `replace_file_content|write_to_file` to hook matchers in `hooks.json` and `scripts/install-register.mjs`, ensuring benchmark mutation guards (LH-09) are wired in installed environments.
23
+ - **Transcript Authority for Silent Running Tasks (OBS-B)**: Updated task state resolver to treat background tasks with start notices as actively `RUNNING` regardless of log quiet duration until an explicit finish notice appears, preventing premature inspection of quiet background jobs.
24
+
25
+ ---
26
+
8
27
  ## [1.4.0] - 2026-10-08
9
28
 
10
29
  ### Added (Output Inspection Governance & Forensic Dereferencing - LH-12)
package/README.md CHANGED
@@ -111,7 +111,7 @@ For complete technical references, architectural specifications, and governance
111
111
  - 🏗️ **[Architecture & Internals](docs/architecture.md)**: Closed Execution Topology, RTK separation of concerns, and the Four Non-Fragile Architectural Pillars.
112
112
  - 🛡️ **[Lifecycle Governance & Hooks](docs/governance.md)**: Proportional Backoff curve, Reactive Wakeup invariants, and the 5-strike circuit breaker.
113
113
  - 🧰 **[MCP Tools & CLI Reference](docs/tools.md)**: Complete specifications for all 6 canonical autonomous MCP tools, slash commands, and diagnostic utilities.
114
- - 🔬 **[Forensic & Output Inspection](LEDGER_OUTPUT_FIXES.md)**: Ephemeral tool spillover resolution, post-mortem task retrieval, and Pointer-Over-Wire (POW) contracts.
114
+ - 🔬 **[Forensic & Output Inspection](docs/architecture.md#6-output-governance--spillover-safe-harbor-0-new-tools)**: Ephemeral tool spillover resolution, post-mortem task retrieval, and Pointer-Over-Wire (POW) contracts.
115
115
  - 🚀 **[Publishing & Verification Playbook](docs/publishing.md)**: Clean-room test runs, release procedures, and multi-suite verification matrix.
116
116
 
117
117
  ---
@@ -122,10 +122,10 @@ Build or serve the documentation locally using **Zensical**:
122
122
 
123
123
  ```bash
124
124
  # Build static documentation site
125
- py -3.11 -m zensical build
125
+ zensical build
126
126
 
127
127
  # Serve live preview server
128
- py -3.11 -m zensical serve
128
+ zensical serve
129
129
  ```
130
130
 
131
131
  ---
@@ -146,7 +146,7 @@ node mcp/index.js status
146
146
  ```bash
147
147
  npm test
148
148
  ```
149
- *Runs all 5 test suites (28 hook tests, MCP protocol tests, E2E probes, installed verification, lifecycle rollback).*
149
+ *Runs all 7 test suites (hook governance, MCP protocol, output inspection governance, E2E probes, performance probes, installed verification, lifecycle rollback).*
150
150
 
151
151
  ### 3. Run Targeted Tests Individually
152
152
  ```bash
@@ -156,9 +156,15 @@ node tests/test-hook.mjs
156
156
  # MCP server protocol & method tests
157
157
  node tests/test-mcp.mjs
158
158
 
159
+ # Output inspection & lifecycle gating tests
160
+ node tests/test-output-governance.mjs
161
+
159
162
  # End-to-end edge case & probe tests
160
163
  node tests/test-e2e-probes.mjs
161
164
 
165
+ # Performance, streaming & memory probes
166
+ node tests/test-performance-probes.mjs
167
+
162
168
  # Live installed environment verification
163
169
  node tests/test-installed-verification.mjs
164
170
 
package/hooks.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "agy-context-saver": {
3
3
  "PreToolUse": [
4
4
  {
5
- "matcher": "manage_task|run_command|schedule",
5
+ "matcher": "manage_task|schedule|run_command|view_file|read_file|read_many_files|grep_search|find_by_name|list_dir|replace_file_content|write_to_file",
6
6
  "hooks": [
7
7
  {
8
8
  "type": "command",
package/mcp/index.js CHANGED
@@ -98,7 +98,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
98
98
  const rulesPath = path.resolve(__dirname, "../rules/AGENTS.md");
99
99
 
100
100
  const SERVER_NAME = "agy-context-saver";
101
- const SERVER_VERSION = "1.4.0";
101
+ const SERVER_VERSION = "1.5.0";
102
102
 
103
103
  // --- Tools Specification ---
104
104
  const TOOLS = [
@@ -313,36 +313,52 @@ async function handleCheckContextHealth(args = {}) {
313
313
  let corruptLines = 0;
314
314
  const MAX_LINES = 100000;
315
315
 
316
- for await (const line of rl) {
317
- if (!line.trim()) continue;
318
- totalSteps++;
319
- totalBytes += line.length;
316
+ try {
317
+ for await (const line of rl) {
318
+ if (!line.trim()) continue;
319
+ totalSteps++;
320
+ totalBytes += line.length;
320
321
 
321
- if (totalSteps > MAX_LINES) {
322
- break;
323
- }
322
+ if (totalSteps > MAX_LINES) {
323
+ break;
324
+ }
324
325
 
325
- try {
326
- const item = JSON.parse(line);
327
- if (item.type === "USER_INPUT") userTurns++;
328
- if (item.type === "PLANNER_RESPONSE") modelTurns++;
329
- if (item.tool_calls && item.tool_calls.length) {
330
- toolCalls += item.tool_calls.length;
331
- for (const tc of item.tool_calls) {
332
- const name = tc.tool_name || tc.name;
333
- const args = tc.args || tc.arguments || {};
334
- const action = String(args.Action || args.action || "").replace(/^["']|["']$/g, "").trim().toLowerCase();
335
- const prompt = String(args.Prompt || args.prompt || "").replace(/^["']|["']$/g, "").trim().toLowerCase();
336
- const cond = String(args.TimerCondition || args.timerCondition || "").replace(/^["']|["']$/g, "").trim().toLowerCase();
337
- if (name === "manage_task" && action === "status") pollingEvents++;
338
- if (name === "schedule" && (cond.startsWith("task") || cond.includes("task") || prompt.includes("test") || prompt.includes("check on") || prompt.includes("status"))) pollingEvents++;
326
+ // Fast check for common step types without parsing if no tool calls (PERF-16)
327
+ const hasToolCalls = line.includes('"tool_calls"');
328
+ const head = line.slice(0, 300);
329
+ const isUserInput = head.includes('"type":"USER_INPUT"');
330
+ const isPlannerResp = head.includes('"type":"PLANNER_RESPONSE"');
331
+
332
+ if (!hasToolCalls && (isUserInput || isPlannerResp)) {
333
+ if (isUserInput) userTurns++;
334
+ else modelTurns++;
335
+ continue;
336
+ }
337
+
338
+ try {
339
+ const item = JSON.parse(line);
340
+ if (item.type === "USER_INPUT") userTurns++;
341
+ if (item.type === "PLANNER_RESPONSE") modelTurns++;
342
+ if (item.tool_calls && item.tool_calls.length) {
343
+ toolCalls += item.tool_calls.length;
344
+ for (const tc of item.tool_calls) {
345
+ const name = tc.tool_name || tc.name;
346
+ const args = tc.args || tc.arguments || {};
347
+ const action = String(args.Action || args.action || "").replace(/^["']|["']$/g, "").trim().toLowerCase();
348
+ const prompt = String(args.Prompt || args.prompt || "").replace(/^["']|["']$/g, "").trim().toLowerCase();
349
+ const cond = String(args.TimerCondition || args.timerCondition || "").replace(/^["']|["']$/g, "").trim().toLowerCase();
350
+ if (name === "manage_task" && action === "status") pollingEvents++;
351
+ if (name === "schedule" && (cond.startsWith("task") || cond.includes("task") || prompt.includes("test") || prompt.includes("check on") || prompt.includes("status"))) pollingEvents++;
352
+ }
339
353
  }
354
+ } catch (err) {
355
+ corruptLines++;
340
356
  }
341
- } catch (err) {
342
- corruptLines++;
343
357
  }
358
+ } finally {
359
+ rl.close();
360
+ fileStream.destroy();
344
361
  }
345
-
346
362
  const kb = (totalBytes / 1024).toFixed(1);
347
363
  const healthStatus = pollingEvents > 3 ? "CRITICAL (Active Polling Loops Detected)" : userTurns > 40 ? "WARNING (High Turn Budget)" : "HEALTHY";
348
364
 
@@ -459,6 +475,9 @@ function extractConvId(p) {
459
475
  return match ? match[1] : path.basename(normalized, path.extname(normalized));
460
476
  }
461
477
 
478
+ let cachedActiveConv = null;
479
+ let cachedActiveConvTime = 0;
480
+
462
481
  function resolveTranscriptPath(target, mode = "compact") {
463
482
  const isFull = mode === "full";
464
483
  const fileName = isFull ? "transcript_full.jsonl" : "transcript.jsonl";
@@ -469,19 +488,38 @@ function resolveTranscriptPath(target, mode = "compact") {
469
488
  if (process.env.GEMINI_CONVERSATION_ID) {
470
489
  raw = process.env.GEMINI_CONVERSATION_ID;
471
490
  } else {
472
- const brainDir = path.join(os.homedir(), ".gemini", "antigravity", "brain");
473
- if (fs.existsSync(brainDir)) {
491
+ const now = Date.now();
492
+ if (cachedActiveConv && (now - cachedActiveConvTime) < 5000) {
493
+ raw = cachedActiveConv;
494
+ } else {
495
+ const brainDir = path.join(os.homedir(), ".gemini", "antigravity", "brain");
474
496
  try {
475
- const entries = fs.readdirSync(brainDir, { withFileTypes: true })
476
- .filter((d) => d.isDirectory())
477
- .map((d) => {
478
- const p = path.join(brainDir, d.name);
479
- const stat = fs.statSync(p);
480
- return { name: d.name, mtime: stat.mtimeMs };
481
- })
482
- .sort((a, b) => b.mtime - a.mtime);
483
- if (entries.length > 0) {
484
- raw = entries[0].name;
497
+ const entries = fs.readdirSync(brainDir, { withFileTypes: true });
498
+ let bestMtime = 0;
499
+ let bestName = null;
500
+ for (const d of entries) {
501
+ if (!d.isDirectory()) continue;
502
+ try {
503
+ const transPath = path.join(brainDir, d.name, ".system_generated", "logs", "transcript.jsonl");
504
+ const mtime = fs.statSync(transPath).mtimeMs;
505
+ if (mtime > bestMtime) {
506
+ bestMtime = mtime;
507
+ bestName = d.name;
508
+ }
509
+ } catch {
510
+ try {
511
+ const mtime = fs.statSync(path.join(brainDir, d.name)).mtimeMs;
512
+ if (mtime > bestMtime) {
513
+ bestMtime = mtime;
514
+ bestName = d.name;
515
+ }
516
+ } catch {}
517
+ }
518
+ }
519
+ if (bestName) {
520
+ raw = bestName;
521
+ cachedActiveConv = bestName;
522
+ cachedActiveConvTime = now;
485
523
  }
486
524
  } catch {}
487
525
  }
@@ -541,6 +579,11 @@ function cleanMessageContent(content, maxChars = MAX_TURN_CHARS) {
541
579
  return text;
542
580
  }
543
581
 
582
+ const origLength = text.length;
583
+ // Pre-slice huge text to ~4x maxChars before heavy regex and line clamping (PERF-15)
584
+ if (text.length > maxChars * 4) {
585
+ text = text.slice(0, maxChars * 4);
586
+ }
544
587
  // Strip huge base64 media blocks only if relevant keywords or large length
545
588
  if (text.includes("base64") || text.length > 200) {
546
589
  text = text.replace(/data:image\/[^;]+;base64,[A-Za-z0-9+/=]+/g, "[Embedded Media/Binary Omitted]");
@@ -563,8 +606,9 @@ function cleanMessageContent(content, maxChars = MAX_TURN_CHARS) {
563
606
  }
564
607
  }
565
608
 
566
- if (text.length > maxChars) {
567
- return text.slice(0, maxChars) + `\n\n... [Content Truncated (${text.length - maxChars} chars omitted to preserve token budget)] ...`;
609
+ if (origLength > maxChars || text.length > maxChars) {
610
+ const omitted = Math.max(origLength - maxChars, 0);
611
+ return text.slice(0, maxChars) + `\n\n... [Content Truncated (${omitted} chars omitted to preserve token budget)] ...`;
568
612
  }
569
613
  return text;
570
614
  }
@@ -662,12 +706,14 @@ function getCompiledRegex(pattern) {
662
706
 
663
707
  async function batchFindFullSteps(fullPath, stepIndicesSet) {
664
708
  const result = new Map();
665
- if (!fs.existsSync(fullPath) || !stepIndicesSet || stepIndicesSet.size === 0) return result;
666
-
667
- const fileStream = fs.createReadStream(fullPath, { encoding: "utf-8" });
668
- const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
709
+ if (!stepIndicesSet || stepIndicesSet.size === 0) return result;
669
710
 
711
+ let fileStream;
712
+ let rl;
670
713
  try {
714
+ fileStream = fs.createReadStream(fullPath, { encoding: "utf-8" });
715
+ rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
716
+
671
717
  for await (const line of rl) {
672
718
  if (!line.trim()) continue;
673
719
  try {
@@ -675,13 +721,15 @@ async function batchFindFullSteps(fullPath, stepIndicesSet) {
675
721
  if (stepIndicesSet.has(item.step_index)) {
676
722
  result.set(item.step_index, item);
677
723
  if (result.size >= stepIndicesSet.size) {
678
- rl.close();
679
724
  break;
680
725
  }
681
726
  }
682
727
  } catch {}
683
728
  }
684
- } catch {}
729
+ } catch {} finally {
730
+ if (rl) rl.close();
731
+ if (fileStream) fileStream.destroy();
732
+ }
685
733
  return result;
686
734
  }
687
735
 
@@ -697,35 +745,49 @@ async function handleReadTranscript({ conversationId, mode = "compact", lastTurn
697
745
  }
698
746
 
699
747
  const { filePath, convId } = resolved;
700
- const items = [];
701
- const fileStream = fs.createReadStream(filePath, { encoding: "utf-8" });
702
- const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
748
+ const rawLines = [];
749
+ let fileStream;
750
+ let rl;
703
751
 
704
752
  let totalStepsCount = 0;
705
753
  const WINDOW_SIZE = lastTurns > 0 ? Math.max(100, lastTurns * 15) : Infinity;
706
754
 
707
755
  try {
756
+ fileStream = fs.createReadStream(filePath, { encoding: "utf-8" });
757
+ rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
708
758
  for await (const line of rl) {
709
- if (!line.trim()) continue;
710
- try {
711
- const item = JSON.parse(line);
712
- totalStepsCount++;
713
- items.push(item);
714
- if (items.length > WINDOW_SIZE) {
715
- items.shift();
759
+ const trimmed = line.trim();
760
+ if (!trimmed) continue;
761
+ totalStepsCount++;
762
+ if (WINDOW_SIZE === Infinity) {
763
+ rawLines.push(trimmed);
764
+ } else {
765
+ rawLines.push(trimmed);
766
+ if (rawLines.length > WINDOW_SIZE) {
767
+ rawLines.shift();
716
768
  }
717
- } catch {
718
- // Tolerates incomplete trailing flushes during concurrent writes
719
769
  }
720
770
  }
721
771
  } catch (err) {
722
772
  return { isError: true, content: [{ type: "text", text: `Error reading transcript stream: ${err.message}` }] };
773
+ } finally {
774
+ if (rl) rl.close();
775
+ if (fileStream) fileStream.destroy();
723
776
  }
724
777
 
725
778
  if (totalStepsCount === 0) {
726
779
  return { content: [{ type: "text", text: `Transcript is empty at: ${filePath}` }] };
727
780
  }
728
781
 
782
+ const items = [];
783
+ for (const line of rawLines) {
784
+ try {
785
+ items.push(JSON.parse(line));
786
+ } catch {
787
+ // Tolerates incomplete trailing flushes during concurrent writes
788
+ }
789
+ }
790
+
729
791
  let selected = items;
730
792
  if (lastTurns > 0) {
731
793
  const turnItems = items.filter((it) => it.type === "USER_INPUT" || it.type === "PLANNER_RESPONSE");
@@ -733,7 +795,6 @@ async function handleReadTranscript({ conversationId, mode = "compact", lastTurn
733
795
  const minStep = turnItems[cutoff] ? turnItems[cutoff].step_index : 0;
734
796
  selected = items.filter((it) => (it.step_index || 0) >= minStep);
735
797
  }
736
-
737
798
  const header = [
738
799
  `# Conversation Transcript: \`${convId}\``,
739
800
  `- Mode: **${mode}** (${path.basename(filePath)})`,
@@ -798,17 +859,16 @@ async function handleQueryTranscript(args = {}) {
798
859
 
799
860
  const resolvedFull = resolveTranscriptPath(conversationId, "full");
800
861
  let step = null;
801
- if (resolvedFull && resolvedFull.filePath && fs.existsSync(resolvedFull.filePath)) {
862
+ if (resolvedFull && resolvedFull.filePath) {
802
863
  step = await findFullStep(resolvedFull.filePath, numIndex);
803
864
  }
804
865
 
805
866
  if (!step) {
806
867
  const resolvedCompact = resolveTranscriptPath(conversationId, "compact");
807
- if (resolvedCompact && resolvedCompact.filePath && fs.existsSync(resolvedCompact.filePath)) {
868
+ if (resolvedCompact && resolvedCompact.filePath) {
808
869
  step = await findFullStep(resolvedCompact.filePath, numIndex);
809
870
  }
810
871
  }
811
-
812
872
  if (!step) {
813
873
  return {
814
874
  isError: true,
@@ -888,20 +948,31 @@ async function handleQueryTranscript(args = {}) {
888
948
 
889
949
  const { filePath, convId } = resolved;
890
950
  const fullSiblingPath = filePath.replace(/transcript\.jsonl$/, "transcript_full.jsonl");
891
- const hasFullSibling = fs.existsSync(fullSiblingPath);
892
951
 
893
952
  const roleSet = new Set((Array.isArray(roles) ? roles : [roles]).map((r) => String(r).toLowerCase()));
894
953
  const matchAllRoles = roleSet.has("all");
895
954
 
896
955
  const queryRegex = getCompiledRegex(query);
956
+ const isPlainLiteral = query && /^[A-Za-z0-9_ -]+$/.test(query);
957
+
958
+ const effectiveLimit = Math.min(lastTurns > 0 ? lastTurns : Infinity, maxResults > 0 ? maxResults : 5);
959
+ const ringCap = Number.isFinite(effectiveLimit) ? effectiveLimit : 1000;
960
+ const ring = [];
961
+ let matchedCount = 0;
897
962
 
898
- const matched = [];
899
- const fileStream = fs.createReadStream(filePath, { encoding: "utf-8" });
900
- const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
963
+ let fileStream;
964
+ let rl;
901
965
 
902
966
  try {
967
+ fileStream = fs.createReadStream(filePath, { encoding: "utf-8" });
968
+ rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
969
+
903
970
  for await (const line of rl) {
904
971
  if (!line.trim()) continue;
972
+
973
+ // PERF-12: Raw-line prefilter for plain literal queries
974
+ if (isPlainLiteral && !queryRegex.test(line)) continue;
975
+
905
976
  let item;
906
977
  try {
907
978
  item = JSON.parse(line);
@@ -943,13 +1014,20 @@ async function handleQueryTranscript(args = {}) {
943
1014
  if (!matches) continue;
944
1015
  }
945
1016
 
946
- matched.push(item);
1017
+ matchedCount++;
1018
+ ring.push(item);
1019
+ if (ring.length > ringCap) {
1020
+ ring.shift();
1021
+ }
947
1022
  }
948
1023
  } catch (err) {
949
1024
  return { isError: true, content: [{ type: "text", text: `Error streaming transcript: ${err.message}` }] };
1025
+ } finally {
1026
+ if (rl) rl.close();
1027
+ if (fileStream) fileStream.destroy();
950
1028
  }
951
1029
 
952
- if (matched.length === 0) {
1030
+ if (matchedCount === 0) {
953
1031
  return {
954
1032
  content: [{
955
1033
  type: "text",
@@ -958,8 +1036,8 @@ async function handleQueryTranscript(args = {}) {
958
1036
  };
959
1037
  }
960
1038
 
961
- let finalItems = matched;
962
- if (lastTurns && lastTurns > 0) {
1039
+ let finalItems = ring;
1040
+ if (lastTurns && lastTurns > 0 && finalItems.length > lastTurns) {
963
1041
  finalItems = finalItems.slice(-lastTurns);
964
1042
  }
965
1043
  if (finalItems.length > maxResults) {
@@ -967,8 +1045,8 @@ async function handleQueryTranscript(args = {}) {
967
1045
  }
968
1046
 
969
1047
  // Auto-dereferencing if mode is "auto" and fields were truncated (single pass)
970
- if (mode === "auto" && hasFullSibling) {
971
- const truncatedIndices = new Set();
1048
+ // PERF-17: check fs.existsSync(fullSiblingPath) lazily ONLY if truncatedIndices.size > 0
1049
+ if (mode === "auto") { const truncatedIndices = new Set();
972
1050
  for (const it of finalItems) {
973
1051
  if (Array.isArray(it.truncated_fields) && it.truncated_fields.length > 0 && it.step_index !== undefined) {
974
1052
  truncatedIndices.add(it.step_index);
@@ -1002,7 +1080,7 @@ async function handleQueryTranscript(args = {}) {
1002
1080
  const header = [
1003
1081
  `## Transcript Query Summary: \`${convId}\``,
1004
1082
  `- Filter: query=${query ? `"${query}"` : "NONE"}, roles=[${Array.from(roleSet).join(", ")}], mode=${mode}`,
1005
- `- Matched Steps: ${matched.length} (showing ${finalItems.length} in compact summary mode)`,
1083
+ `- Matched Steps: ${matchedCount} (showing ${finalItems.length} in compact summary mode)`,
1006
1084
  "",
1007
1085
  "---",
1008
1086
  ""
@@ -1018,7 +1096,7 @@ async function handleQueryTranscript(args = {}) {
1018
1096
  const header = [
1019
1097
  `## Transcript Query Results: \`${convId}\``,
1020
1098
  `- Filter: query=${query ? `"${query}"` : "NONE"}, roles=[${Array.from(roleSet).join(", ")}], mode=${mode}`,
1021
- `- Matched Steps: ${matched.length} (showing ${finalItems.length})`,
1099
+ `- Matched Steps: ${matchedCount} (showing ${finalItems.length})`,
1022
1100
  "",
1023
1101
  "---",
1024
1102
  ""
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agy-context-saver",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Universal Model Context Protocol (MCP) server & lifecycle governor purpose-built for Google Antigravity",
5
5
  "main": "mcp/index.js",
6
6
  "bin": {
package/plugin.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "agy-context-saver",
3
- "version": "1.2.1",
3
+ "version": "1.5.0",
4
4
  "description": "Deterministic background polling governor and subagent context preservation shield for Antigravity"
5
5
  }
@@ -66,13 +66,11 @@ function getStateFilePath(convId = "global") {
66
66
  function getSessionState(convId = "global") {
67
67
  const file = getStateFilePath(convId);
68
68
  try {
69
- if (fs.existsSync(file)) {
70
- const parsed = JSON.parse(fs.readFileSync(file, "utf-8"));
71
- const entry = (parsed && typeof parsed.pollCount === "number") ? parsed : (parsed && parsed[convId]);
72
- const now = Date.now();
73
- if (entry && typeof entry.lastPollTime === "number" && (now - entry.lastPollTime) < STATE_TTL_MS) {
74
- return entry;
75
- }
69
+ const parsed = JSON.parse(fs.readFileSync(file, "utf-8"));
70
+ const entry = (parsed && typeof parsed.pollCount === "number") ? parsed : (parsed && parsed[convId]);
71
+ const now = Date.now();
72
+ if (entry && typeof entry.lastPollTime === "number" && (now - entry.lastPollTime) < STATE_TTL_MS) {
73
+ return entry;
76
74
  }
77
75
  } catch {}
78
76
  return { pollCount: 0, denials: 0, taskIds: [], lastPollTime: 0 };
@@ -86,14 +84,12 @@ function saveSessionState(convId = "global", entry) {
86
84
  let toWrite;
87
85
  if (!convId || convId === "global") {
88
86
  let state = {};
89
- if (fs.existsSync(file)) {
90
- try {
91
- const parsed = JSON.parse(fs.readFileSync(file, "utf-8"));
92
- if (parsed && typeof parsed === "object" && typeof parsed.pollCount !== "number") {
93
- state = parsed;
94
- }
95
- } catch {}
96
- }
87
+ try {
88
+ const parsed = JSON.parse(fs.readFileSync(file, "utf-8"));
89
+ if (parsed && typeof parsed === "object" && typeof parsed.pollCount !== "number") {
90
+ state = parsed;
91
+ }
92
+ } catch {}
97
93
  state[convId] = entry;
98
94
 
99
95
  // Prune stale entries
@@ -123,12 +119,7 @@ function saveSessionState(convId = "global", entry) {
123
119
  */
124
120
  function isProtectedInternalState(targetPath) {
125
121
  if (!targetPath || typeof targetPath !== "string") return false;
126
- const normalized = targetPath.replace(/\\/g, "/");
127
- return (
128
- normalized.includes("/.system_generated/") ||
129
- normalized.endsWith("/.system_generated") ||
130
- /\.system_generated(?:$|[\/\\])/i.test(normalized)
131
- );
122
+ return /\.system_generated(?:$|[\/\\])/i.test(targetPath);
132
123
  }
133
124
 
134
125
  /**
@@ -189,6 +180,7 @@ function isProtectedBenchmarkPath(targetPath) {
189
180
  */
190
181
  function isStepSpilloverPath(targetPath) {
191
182
  if (!targetPath || typeof targetPath !== "string") return false;
183
+ if (!isProtectedInternalState(targetPath)) return false; // cheap precheck
192
184
  const normalized = targetPath.replace(/\\/g, "/");
193
185
  return (
194
186
  /\.system_generated[/\\]steps[/\\]\d+[/\\]output\.txt/i.test(normalized) ||
@@ -197,180 +189,110 @@ function isStepSpilloverPath(targetPath) {
197
189
  }
198
190
 
199
191
  /**
200
- * Extracts task ID from target path or command if targeting a task output log.
192
+ * Extracts the task-log reference from a target path or command, or null.
193
+ * Returns { id, token }: `id` is the bare task id; `token` is the matched path text
194
+ * (relative "tasks/task-1.log" or absolute ".../.system_generated/tasks/task-1.log").
201
195
  */
202
- function getTaskLogMatch(targetPath) {
203
- if (!targetPath || typeof targetPath !== "string") return null;
204
- const normalized = targetPath.replace(/\\/g, "/");
205
- const match = normalized.match(/(?:^|[\s"'/])(?:\.system_generated\/)?tasks\/(?:task-)?([a-zA-Z0-9_-]+)\.log/i);
206
- return match ? match[1] : null;
196
+ function getTaskLogMatch(text) {
197
+ if (!text || typeof text !== "string" || !text.includes(".log")) return null;
198
+ const normalized = text.replace(/\\/g, "/");
199
+ if (!normalized.includes("tasks/")) return null;
200
+ const m = normalized.match(/(?:^|[\s"'])((?:[^\s"']*\/)?tasks\/(?:task-)?([a-zA-Z0-9_-]+)\.log)/i);
201
+ if (!m) return null;
202
+ let token = m[1].replace(/^file:\/\//i, "");
203
+ if (/^\/[A-Za-z]:/.test(token)) token = token.slice(1);
204
+ return { id: m[2], token };
207
205
  }
208
206
 
209
- /**
210
- * Resolves full path to a task log on disk.
211
- */
212
- function resolveTaskLogFile(taskId, convId) {
213
- if (taskId && typeof taskId === "string") {
214
- if (fs.existsSync(taskId)) {
215
- const cleanId = String(taskId).replace(/^.*[\/\\]/, "").replace(/\.log$/i, "");
216
- return { logPath: taskId, convId: convId || "global", cleanId };
217
- }
218
- if (taskId.includes("/") || taskId.includes("\\")) {
219
- const cleanId = String(taskId).replace(/^.*[\/\\]/, "").replace(/\.log$/i, "");
220
- return { logPath: null, convId: convId || "global", cleanId };
221
- }
222
- }
223
-
224
- const cleanId = String(taskId).replace(/^.*[\/\\]/, "").replace(/\.log$/i, "");
225
- const baseTaskName = cleanId.startsWith("task-") ? cleanId : `task-${cleanId}`;
207
+ const TASK_TAIL_BYTES = 262144; // transcript tail scanned first (256 KB)
208
+ const TASK_QUIET_MS = 300000; // log-mtime fallback: quiet for 5 min => treated as finished
226
209
 
227
- const home = os.homedir();
228
- const brainDir = path.join(home, ".gemini", "antigravity", "brain");
210
+ function getBrainDir() {
211
+ return process.env.AGY_BRAIN_DIR || path.join(os.homedir(), ".gemini", "antigravity", "brain");
212
+ }
229
213
 
230
- if (convId && convId !== "global") {
231
- const candidate = path.join(brainDir, convId, ".system_generated", "tasks", `${baseTaskName}.log`);
232
- if (fs.existsSync(candidate)) {
233
- return { logPath: candidate, convId, cleanId: baseTaskName };
234
- }
214
+ /** Reads the last `bytes` of a file. Returns { text, whole } or null. */
215
+ function readTail(file, bytes) {
216
+ let fd;
217
+ try {
218
+ fd = fs.openSync(file, "r");
219
+ const size = fs.fstatSync(fd).size;
220
+ const len = Math.min(size, bytes);
221
+ const buf = Buffer.alloc(len);
222
+ fs.readSync(fd, buf, 0, len, size - len);
223
+ return { text: buf.toString("utf-8"), whole: len === size };
224
+ } catch {
225
+ return null;
226
+ } finally {
227
+ if (fd !== undefined) { try { fs.closeSync(fd); } catch {} }
235
228
  }
236
-
237
- return { logPath: null, convId: convId || "global", cleanId: baseTaskName };
238
229
  }
239
230
 
240
231
  /**
241
- * Determines whether a background task is verified completed / terminated.
232
+ * Looks for the finish / start notices of `taskName` in transcript text using literal needles
233
+ * (JSON-escaped and raw variants). Finish: `<conv>/task-N" finished with result`.
234
+ * Start: `<conv>/task-N` followed by a newline and "Task Description" or "Task logs".
235
+ * Returns "COMPLETED", "RUNNING" or null.
242
236
  */
243
- function isTaskCompleted(taskId, convId, rawPath) {
244
- const candidatePath = (rawPath && fs.existsSync(rawPath)) ? rawPath : null;
245
- const resolved = resolveTaskLogFile(candidatePath || taskId, convId);
246
- const logPath = resolved.logPath || candidatePath;
247
- const cleanId = resolved.cleanId;
248
-
249
- // 1. Session state check
250
- const safeId = String(resolved.convId || convId || "global").replace(/[^a-zA-Z0-9_-]/g, "_");
251
- const sessionPath = path.join(os.tmpdir(), `agy-session-${safeId}.json`);
252
- if (fs.existsSync(sessionPath)) {
253
- try {
254
- const sess = JSON.parse(fs.readFileSync(sessionPath, "utf-8"));
255
- if (sess && sess.tasks && sess.tasks[cleanId]) {
256
- if (sess.tasks[cleanId].state === "TERMINATED" || sess.tasks[cleanId].state === "COMPLETED") return true;
257
- if (sess.tasks[cleanId].state === "RUNNING") return false;
258
- }
259
- } catch {}
260
- }
261
-
262
- // 2. Transcript check
263
- if (resolved.convId && resolved.convId !== "global") {
264
- const home = os.homedir();
265
- const candidateTrans = path.join(home, ".gemini", "antigravity", "brain", resolved.convId, ".system_generated", "logs", "transcript.jsonl");
266
- if (fs.existsSync(candidateTrans)) {
267
- try {
268
- const content = fs.readFileSync(candidateTrans, "utf-8");
269
- const taskRegex = new RegExp(`Task id [^"]*${cleanId}[^"]* finished`, "i");
270
- if (taskRegex.test(content)) return true;
271
- } catch {}
272
- }
273
- }
274
-
275
- // 3. File exists on disk and age check (>15s and not marked live)
276
- if (logPath && fs.existsSync(logPath)) {
277
- if (cleanId.includes("live") || cleanId.includes("running")) {
278
- return false;
279
- }
280
- try {
281
- const stats = fs.statSync(logPath);
282
- const mtimeAgeMs = Date.now() - stats.mtimeMs;
283
- if (mtimeAgeMs > 15000) {
284
- return true;
285
- }
286
- } catch {}
287
- }
288
-
289
- return false;
237
+ function scanTaskMarkers(text, taskName) {
238
+ if (text.includes(`/${taskName}\\" finished`) || text.includes(`/${taskName}" finished`)) return "COMPLETED";
239
+ if (text.includes(`/${taskName}\\nTask `) || text.includes(`/${taskName}\nTask `)) return "RUNNING";
240
+ return null;
290
241
  }
291
242
 
292
243
  /**
293
- * Determines whether a background task is actively running.
244
+ * Resolves a background task's lifecycle state: "RUNNING", "COMPLETED" or "UNKNOWN".
245
+ * Authority order: transcript notices (tail first, full file only if needed), then log mtime.
294
246
  */
295
- function isTaskRunning(taskId, convId, rawPath) {
296
- const candidatePath = (rawPath && fs.existsSync(rawPath)) ? rawPath : null;
297
- const { logPath: resolvedPath, convId: resolvedConvId, cleanId } = resolveTaskLogFile(candidatePath || taskId, convId);
298
- const logPath = resolvedPath || candidatePath;
299
-
300
- // 1. Session state ledger check
301
- const safeId = String(resolvedConvId || convId || "global").replace(/[^a-zA-Z0-9_-]/g, "_");
302
- const sessionPath = path.join(os.tmpdir(), `agy-session-${safeId}.json`);
303
- if (fs.existsSync(sessionPath)) {
304
- try {
305
- const sess = JSON.parse(fs.readFileSync(sessionPath, "utf-8"));
306
- if (sess && sess.tasks && sess.tasks[cleanId]) {
307
- if (sess.tasks[cleanId].state === "RUNNING") return true;
308
- if (sess.tasks[cleanId].state === "TERMINATED" || sess.tasks[cleanId].state === "COMPLETED") return false;
309
- }
310
- } catch {}
247
+ function getTaskState(match, convId) {
248
+ const taskName = `task-${match.id}`;
249
+ const token = match.token;
250
+ let logPath = null;
251
+ let transcript = null;
252
+
253
+ if (/^(?:[A-Za-z]:)?\//.test(token)) {
254
+ logPath = token;
255
+ const sysDir = path.posix.dirname(path.posix.dirname(token));
256
+ if (path.posix.basename(sysDir) === ".system_generated") transcript = `${sysDir}/logs/transcript.jsonl`;
257
+ }
258
+ if (convId && convId !== "global") {
259
+ const sysDir = path.join(getBrainDir(), convId, ".system_generated");
260
+ logPath = logPath || path.join(sysDir, "tasks", `${taskName}.log`);
261
+ transcript = transcript || path.join(sysDir, "logs", "transcript.jsonl");
311
262
  }
312
263
 
313
- // 2. Transcript check for completion notice
314
- if (resolvedConvId && resolvedConvId !== "global") {
315
- const home = os.homedir();
316
- const candidateTrans = path.join(home, ".gemini", "antigravity", "brain", resolvedConvId, ".system_generated", "logs", "transcript.jsonl");
317
- if (fs.existsSync(candidateTrans)) {
318
- try {
319
- const content = fs.readFileSync(candidateTrans, "utf-8");
320
- const taskRegex = new RegExp(`Task id [^"]*${cleanId}[^"]* finished`, "i");
321
- if (taskRegex.test(content)) {
322
- return false; // completed!
323
- }
324
- } catch {}
264
+ if (transcript) {
265
+ const tail = readTail(transcript, TASK_TAIL_BYTES);
266
+ if (tail) {
267
+ let state = scanTaskMarkers(tail.text, taskName);
268
+ if (!state && !tail.whole) {
269
+ try { state = scanTaskMarkers(fs.readFileSync(transcript, "utf-8"), taskName); } catch {}
270
+ }
271
+ if (state) return state;
325
272
  }
326
273
  }
327
274
 
328
- // 3. File modification age check
329
- if (logPath && fs.existsSync(logPath)) {
275
+ if (logPath) {
330
276
  try {
331
- const stats = fs.statSync(logPath);
332
- const mtimeAgeMs = Date.now() - stats.mtimeMs;
333
- if (mtimeAgeMs > 15000) {
334
- return false; // Inactive for >15s -> terminated!
335
- }
336
- return true; // Modified within 15s and not marked finished -> RUNNING!
277
+ return (Date.now() - fs.statSync(logPath).mtimeMs) > TASK_QUIET_MS ? "COMPLETED" : "RUNNING";
337
278
  } catch {}
338
279
  }
339
-
340
- // 4. Test environment / naming convention
341
- if (cleanId.includes("live") || cleanId.includes("running")) {
342
- return true;
343
- }
344
-
345
- return false;
280
+ return "UNKNOWN";
346
281
  }
347
282
 
348
-
349
- let resolvedRtkPath = null;
350
- function getRtkBinary() {
351
- if (resolvedRtkPath) return resolvedRtkPath;
352
-
353
- // 1. Try global rtk on PATH
354
- try {
355
- const check = spawnSync("rtk", ["--version"], { timeout: 1000, windowsHide: true });
356
- if (check.status === 0) {
357
- resolvedRtkPath = "rtk";
358
- return resolvedRtkPath;
359
- }
360
- } catch {}
361
-
362
- // 2. Try plugin local bin
363
- const isWin = os.platform() === "win32";
364
- const exeName = isWin ? "rtk.exe" : "rtk";
365
- const homeDir = os.homedir();
366
- const pluginBin = path.join(homeDir, ".gemini", "config", "plugins", "agy-context-saver", "bin", exeName);
367
- if (fs.existsSync(pluginBin)) {
368
- resolvedRtkPath = pluginBin;
369
- return resolvedRtkPath;
283
+ /**
284
+ * Runs `rtk rewrite <cmd>`. Spawns the PATH binary directly (a separate `--version` probe doubled
285
+ * the cost of every cache miss); falls back to the plugin-local binary only when PATH has none.
286
+ */
287
+ function runRtkRewrite(cmd) {
288
+ const opts = { encoding: "utf-8", timeout: 2000, windowsHide: true };
289
+ const res = spawnSync("rtk", ["rewrite", cmd], opts);
290
+ if (res.error && res.error.code === "ENOENT") {
291
+ const exeName = os.platform() === "win32" ? "rtk.exe" : "rtk";
292
+ const pluginBin = path.join(os.homedir(), ".gemini", "config", "plugins", "agy-context-saver", "bin", exeName);
293
+ if (fs.existsSync(pluginBin)) return spawnSync(pluginBin, ["rewrite", cmd], opts);
370
294
  }
371
-
372
- resolvedRtkPath = "rtk";
373
- return resolvedRtkPath;
295
+ return res;
374
296
  }
375
297
 
376
298
  const NON_REWRITABLE_BINARIES = new Set([
@@ -397,31 +319,39 @@ const REWRITE_CACHE_FILE = path.join(os.tmpdir(), "agy-rtk-rewrite-cache.json");
397
319
  const REWRITE_CACHE_MAX = 100;
398
320
  let inMemoryRewriteCache = null;
399
321
 
400
- function loadRewriteCache() {
401
- if (inMemoryRewriteCache) return inMemoryRewriteCache;
322
+ function readRewriteCacheFile() {
402
323
  try {
403
- if (fs.existsSync(REWRITE_CACHE_FILE)) {
404
- const data = JSON.parse(fs.readFileSync(REWRITE_CACHE_FILE, "utf-8"));
405
- if (data && typeof data === "object") {
406
- inMemoryRewriteCache = data;
407
- return inMemoryRewriteCache;
408
- }
409
- }
324
+ const data = JSON.parse(fs.readFileSync(REWRITE_CACHE_FILE, "utf-8"));
325
+ if (data && typeof data === "object" && !Array.isArray(data)) return data;
410
326
  } catch {}
411
- inMemoryRewriteCache = {};
327
+ return {};
328
+ }
329
+
330
+ function loadRewriteCache() {
331
+ if (!inMemoryRewriteCache) inMemoryRewriteCache = readRewriteCacheFile();
412
332
  return inMemoryRewriteCache;
413
333
  }
414
334
 
415
- function saveRewriteCache(cache) {
335
+ /**
336
+ * Persists one rewrite result. Re-reads the file first so concurrent hook processes merge instead
337
+ * of overwriting each other, trims to the newest entries, and writes atomically (temp + rename).
338
+ */
339
+ function rememberRewrite(cmd, rewritten) {
340
+ loadRewriteCache()[cmd] = rewritten;
416
341
  try {
417
- const keys = Object.keys(cache);
418
- let toSave = cache;
419
- if (keys.length > REWRITE_CACHE_MAX) {
420
- const slice = keys.slice(keys.length - REWRITE_CACHE_MAX);
421
- toSave = {};
422
- for (const k of slice) toSave[k] = cache[k];
342
+ const disk = readRewriteCacheFile();
343
+ delete disk[cmd];
344
+ disk[cmd] = rewritten;
345
+ const keys = Object.keys(disk);
346
+ for (const k of keys.slice(0, Math.max(0, keys.length - REWRITE_CACHE_MAX))) delete disk[k];
347
+ const tmp = `${REWRITE_CACHE_FILE}.${process.pid}.tmp`;
348
+ fs.writeFileSync(tmp, JSON.stringify(disk), "utf-8");
349
+ try {
350
+ fs.renameSync(tmp, REWRITE_CACHE_FILE);
351
+ } catch {
352
+ fs.writeFileSync(REWRITE_CACHE_FILE, JSON.stringify(disk), "utf-8");
353
+ try { fs.unlinkSync(tmp); } catch {}
423
354
  }
424
- fs.writeFileSync(REWRITE_CACHE_FILE, JSON.stringify(toSave), "utf-8");
425
355
  } catch {}
426
356
  }
427
357
 
@@ -474,13 +404,12 @@ function rewriteCommandWithRtk(rawCmd) {
474
404
  }
475
405
 
476
406
  let rewritten = trimmed;
407
+ let cacheable = false;
477
408
  try {
478
- const rtkBin = getRtkBinary();
479
- const res = spawnSync(rtkBin, ["rewrite", trimmed], {
480
- encoding: "utf-8",
481
- timeout: 2000,
482
- windowsHide: true
483
- });
409
+ const res = runRtkRewrite(trimmed);
410
+ // Never cache timeouts, spawn errors or signals: a transient failure must not pin a command
411
+ // to "no rewrite" for the lifetime of the cache.
412
+ cacheable = !res.error && !res.signal && typeof res.status === "number" && res.status >= 0 && res.status <= 3;
484
413
 
485
414
  // RTK exits 0 or 3 when rewritten, with new command on stdout
486
415
  if ((res.status === 0 || res.status === 3) && res.stdout && res.stdout.trim()) {
@@ -488,8 +417,7 @@ function rewriteCommandWithRtk(rawCmd) {
488
417
  }
489
418
  } catch {}
490
419
 
491
- cache[trimmed] = rewritten;
492
- saveRewriteCache(cache);
420
+ if (cacheable) rememberRewrite(trimmed, rewritten);
493
421
  return rewritten;
494
422
  }
495
423
 
@@ -504,6 +432,34 @@ function getRequiredBackoffSeconds(pollCount) {
504
432
  return Math.min(600, Math.round(30 * Math.pow(2.5, Math.max(0, pollCount - 1))));
505
433
  }
506
434
 
435
+ /**
436
+ * Denies reading the log of an actively RUNNING task: records the denial, trips the circuit
437
+ * breaker at 5 cumulative denials, otherwise answers with the Proportional Backoff message.
438
+ */
439
+ function denyRunningTask(taskId, convId) {
440
+ const session = getSessionState(convId);
441
+ session.denials++;
442
+ saveSessionState(convId, session);
443
+
444
+ if (session.denials >= 5) {
445
+ respond({
446
+ decision: "force_ask",
447
+ reason: `[CIRCUIT BREAKER ACTIVATED] Autonomous loop suspended: reading task log '${taskId}' attempted prematurely ${session.denials} times during active backoff window. Stop calling tools and yield the turn for native Reactive Wakeup (<SYSTEM_MESSAGE>). User confirmation required to proceed.`
448
+ });
449
+ }
450
+
451
+ const requiredBackoffSec = Math.max(30, getRequiredBackoffSeconds(session.pollCount));
452
+ const requiredBackoffMs = requiredBackoffSec * 1000;
453
+ const elapsedMs = session.lastPollTime ? (Date.now() - session.lastPollTime) : Infinity;
454
+ const elapsedSec = Math.floor(elapsedMs / 1000);
455
+ const remainingSec = Math.max(1, Math.ceil((requiredBackoffMs - elapsedMs) / 1000));
456
+
457
+ respond({
458
+ decision: "deny",
459
+ reason: `Antigravity Execution Governance: Task '${taskId}' is actively RUNNING. Reading task logs in .system_generated during execution is strictly prohibited to prevent polling loops. Diagnostic check #${session.pollCount + 1} requires at least ${requiredBackoffSec}s elapsed (${elapsedSec}s elapsed, ${remainingSec}s remaining). Tasks execute asynchronously and notify you automatically via native Reactive Wakeup (<SYSTEM_MESSAGE>) upon completion. Stop calling tools and yield your turn now.`
460
+ });
461
+ }
462
+
507
463
  // --- Main Hook Entrypoint ---
508
464
  let isGovernedTool = false;
509
465
 
@@ -563,71 +519,49 @@ try {
563
519
  });
564
520
  }
565
521
 
522
+ // Raw-command governance gates are pure string/fs checks: run them BEFORE the rtk rewrite
523
+ // spawn so denied commands never pay for a subprocess.
524
+ const isSpillover = isStepSpilloverPath(rawCmd);
525
+ const taskMatch = isSpillover ? null : getTaskLogMatch(rawCmd);
526
+
527
+ // Check 1: Other protected internal state (transcripts, scheduler, progress)
528
+ if (!isSpillover && !taskMatch && isProtectedInternalState(rawCmd)) {
529
+ respond({
530
+ decision: "deny",
531
+ reason:
532
+ "Antigravity Execution Governance: Direct shell access to internal Antigravity execution state (.system_generated) is strictly prohibited to prevent transcript bloat and polling loops.\n" +
533
+ "- To inspect conversation transcripts: Call MCP tool read_transcript(conversationId=\"...\", mode=\"compact\") or query_transcript(...).\n" +
534
+ "- To inspect background tasks: Yield execution turn and rely on native Reactive Wakeup (<SYSTEM_MESSAGE>). Do not read internal task logs."
535
+ });
536
+ }
537
+
538
+ // Check 2: Task Log Inspection (Lifecycle-Gated, 0 New Tools): RUNNING => Proportional Backoff
539
+ if (taskMatch && getTaskState(taskMatch, convId) === "RUNNING") {
540
+ denyRunningTask(taskMatch.id, convId);
541
+ }
542
+
566
543
  const rewritten = rewriteCommandWithRtk(rawCmd);
567
544
  const effectiveCmd = rewritten;
545
+ const isRtkRead = /^rtk(?:\.exe)?\s+read\b/i.test(effectiveCmd);
568
546
 
569
- // Check 1: Step Output Spillover Safe Harbor (0 New Tools)
570
- if (isStepSpilloverPath(rawCmd) || isStepSpilloverPath(effectiveCmd)) {
571
- if (/^rtk(?:\.exe)?\s+read\b/i.test(effectiveCmd)) {
572
- // Safe harbor: rtk read already provides line clamping and token windowing
573
- } else {
574
- respond({
575
- decision: "deny",
576
- reason:
577
- "Antigravity Execution Governance: Runtime step output spillover files must be inspected using 'rtk read <path>' via run_command to prevent context blowouts."
578
- });
579
- }
580
- } else {
581
- // Check 2: Task Log Inspection (Lifecycle-Gated, 0 New Tools)
582
- const taskLogId = getTaskLogMatch(rawCmd) || getTaskLogMatch(effectiveCmd);
583
- if (taskLogId) {
584
- if (isTaskRunning(taskLogId, convId, rawCmd)) {
585
- // Task actively running: Deny with Proportional Backoff
586
- const session = getSessionState(convId);
587
- session.denials++;
588
- saveSessionState(convId, session);
589
-
590
- if (session.denials >= 5) {
591
- respond({
592
- decision: "force_ask",
593
- reason: `[CIRCUIT BREAKER ACTIVATED] Autonomous loop suspended: reading task log '${taskLogId}' attempted prematurely ${session.denials} times during active backoff window. Stop calling tools and yield the turn for native Reactive Wakeup (<SYSTEM_MESSAGE>). User confirmation required to proceed.`
594
- });
595
- }
596
-
597
- const requiredBackoffSec = Math.max(30, getRequiredBackoffSeconds(session.pollCount));
598
- const requiredBackoffMs = requiredBackoffSec * 1000;
599
- const elapsedMs = session.lastPollTime ? (Date.now() - session.lastPollTime) : Infinity;
600
- const elapsedSec = Math.floor(elapsedMs / 1000);
601
- const remainingSec = Math.max(1, Math.ceil((requiredBackoffMs - elapsedMs) / 1000));
602
-
603
- respond({
604
- decision: "deny",
605
- reason: `Antigravity Execution Governance: Task '${taskLogId}' is actively RUNNING. Reading task logs in .system_generated during execution is strictly prohibited to prevent polling loops. Diagnostic check #${session.pollCount + 1} requires at least ${requiredBackoffSec}s elapsed (${elapsedSec}s elapsed, ${remainingSec}s remaining). Tasks execute asynchronously and notify you automatically via native Reactive Wakeup (<SYSTEM_MESSAGE>) upon completion. Stop calling tools and yield your turn now.`
606
- });
607
- }
547
+ // Check 3: Step Output Spillover Safe Harbor (rtk read only; it clamps + windows output)
548
+ if (isSpillover && !isRtkRead) {
549
+ respond({
550
+ decision: "deny",
551
+ reason:
552
+ "Antigravity Execution Governance: Runtime step output spillover files must be inspected using 'rtk read <path>' via run_command to prevent context blowouts."
553
+ });
554
+ }
608
555
 
609
- // Task is TERMINATED / COMPLETED: allow rtk read
610
- if (/^rtk(?:\.exe)?\s+read\b/i.test(effectiveCmd)) {
611
- // Allowed under lifecycle governance
612
- } else {
613
- respond({
614
- decision: "deny",
615
- reason:
616
- "Antigravity Execution Governance: Direct shell access to internal Antigravity execution state (.system_generated) is strictly prohibited to prevent transcript bloat and polling loops.\n" +
617
- `- Once completed, inspect output using 'rtk read <path>' via run_command.\n` +
618
- "- If actively running, yield execution turn and rely on native Reactive Wakeup (<SYSTEM_MESSAGE>)."
619
- });
620
- }
621
- } else if (isProtectedInternalState(rawCmd) || isProtectedInternalState(effectiveCmd)) {
622
- // Check 3: Other protected internal state (transcripts, scheduler, progress)
623
- respond({
624
- decision: "deny",
625
- reason:
626
- "Antigravity Execution Governance: Direct shell access to internal Antigravity execution state (.system_generated) is strictly prohibited to prevent transcript bloat and polling loops.\n" +
627
- "- To inspect conversation transcripts: Call MCP tool read_transcript(conversationId=\"...\", mode=\"compact\") or query_transcript(...).\n" +
628
- "- To inspect background tasks: Yield execution turn and rely on native Reactive Wakeup (<SYSTEM_MESSAGE>). Do not read internal task logs."
629
- });
630
- }
556
+ // Check 4: Terminated / unknown-state task log: allow only through rtk read
557
+ if (taskMatch && !isRtkRead) {
558
+ respond({
559
+ decision: "deny",
560
+ reason:
561
+ "Antigravity Execution Governance: Direct shell access to internal Antigravity execution state (.system_generated) is strictly prohibited to prevent transcript bloat and polling loops.\n" +
562
+ `- Once completed, inspect output using 'rtk read <path>' via run_command.\n` +
563
+ "- If actively running, yield execution turn and rely on native Reactive Wakeup (<SYSTEM_MESSAGE>)."
564
+ });
631
565
  }
632
566
 
633
567
  const isDaemon = args.IsDaemon === true || args.isDaemon === true;
@@ -682,33 +616,14 @@ try {
682
616
  }
683
617
 
684
618
  // 2b. Task Log Inspection (Lifecycle-Gated)
685
- const taskLogId = getTaskLogMatch(target);
686
- if (taskLogId) {
687
- if (isTaskRunning(taskLogId, convId, target)) {
688
- const session = getSessionState(convId);
689
- session.denials++;
690
- saveSessionState(convId, session);
691
-
692
- if (session.denials >= 5) {
693
- respond({
694
- decision: "force_ask",
695
- reason: `[CIRCUIT BREAKER ACTIVATED] Autonomous loop suspended: reading task log '${taskLogId}' attempted prematurely ${session.denials} times during active backoff window. Stop calling tools and yield the turn for native Reactive Wakeup (<SYSTEM_MESSAGE>). User confirmation required to proceed.`
696
- });
697
- }
698
-
699
- const requiredBackoffSec = Math.max(30, getRequiredBackoffSeconds(session.pollCount));
700
- const requiredBackoffMs = requiredBackoffSec * 1000;
701
- const elapsedMs = session.lastPollTime ? (Date.now() - session.lastPollTime) : Infinity;
702
- const elapsedSec = Math.floor(elapsedMs / 1000);
703
- const remainingSec = Math.max(1, Math.ceil((requiredBackoffMs - elapsedMs) / 1000));
704
-
705
- respond({
706
- decision: "deny",
707
- reason: `Antigravity Execution Governance: Task '${taskLogId}' is actively RUNNING. Reading task logs in .system_generated during execution is strictly prohibited to prevent polling loops. Diagnostic check #${session.pollCount + 1} requires at least ${requiredBackoffSec}s elapsed (${elapsedSec}s elapsed, ${remainingSec}s remaining). Tasks execute asynchronously and notify you automatically via native Reactive Wakeup (<SYSTEM_MESSAGE>) upon completion. Stop calling tools and yield your turn now.`
708
- });
619
+ const taskMatch = getTaskLogMatch(target);
620
+ if (taskMatch) {
621
+ const state = getTaskState(taskMatch, convId);
622
+ if (state === "RUNNING") {
623
+ denyRunningTask(taskMatch.id, convId);
709
624
  }
710
625
 
711
- if (isTaskCompleted(taskLogId, convId, target)) {
626
+ if (state === "COMPLETED") {
712
627
  // If terminated / completed, route to rtk read:
713
628
  respond({
714
629
  decision: "deny",
@@ -239,7 +239,7 @@ export async function runInstall(options = {}) {
239
239
  hooksConfig["execution-guard"] = {
240
240
  PreToolUse: [
241
241
  {
242
- matcher: "manage_task|schedule|run_command|view_file|read_file|read_many_files|grep_search|find_by_name|list_dir",
242
+ matcher: "manage_task|schedule|run_command|view_file|read_file|read_many_files|grep_search|find_by_name|list_dir|replace_file_content|write_to_file",
243
243
  hooks: [
244
244
  {
245
245
  type: "command",