agy-context-saver 1.3.2 → 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,40 @@ 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
+
27
+ ## [1.4.0] - 2026-10-08
28
+
29
+ ### Added (Output Inspection Governance & Forensic Dereferencing - LH-12)
30
+ - **Folded Surgical Step Dereferencing in `query_transcript` (NFF-05)**: Added optional `stepIndex` (number) and `field` (string) parameters to `query_transcript`, allowing surgical single-step extraction from `transcript_full.jsonl` without creating redundant tools. Recovers un-truncated subagent messages and arguments with zero surrounding context bloat.
31
+ - **Safe-Harbor `rtk read` for Step Output Spillovers (NFF-01 & NFF-02)**: Provided a safe harbor in `execution-guard-hook.mjs` allowing `rtk read` on runtime step output spillovers (`.system_generated/steps/<step>/output.txt`). Native `view_file` and shell `cat` invocations are automatically routed to `rtk read <path>`, leveraging `rtk read`'s built-in line clamping, head/tail windowing, and token truncation.
32
+ - **Lifecycle-Gated Task Output Inspection (NFF-03)**: Added lifecycle governance for background task logs (`tasks/<taskId>.log`) in `execution-guard-hook.mjs`. Invocations are strictly **denied** under Proportional Backoff while tasks are `RUNNING` to stop polling loops, but allowed via `rtk read` once `COMPLETED` / `TERMINATED` for bounded stack trace and failure inspection.
33
+ - **Pointer-Over-Wire (POW) Subagent Contract (NFF-06)**: Integrated the POW protocol into `subagent_brief`. Research findings $>1\text{ KB}$ are written to workspace artifacts (`scratch/...`), while `send_message` transmits only executive summaries and clickable file links, preventing message bus truncations.
34
+ - **Output Governance Test Suite**: Added `tests/test-output-governance.mjs` verifying spillover safe-harbor access, task output lifecycle gating, forensic step extraction, and hook routing.
35
+
36
+ ### Changed
37
+ - **Streamlined Tool Architecture (0 New Tools)**: Eliminated tool proliferation by consolidating capabilities into existing tools and hooks, maintaining strictly the 6 core canonical MCP tools (zero prompt token bloat, zero tool-selection ambiguity).
38
+ - **Typed Argument Descriptors (NFF-04)**: Replaced blind `JSON.stringify().slice(0, 80)` in `formatTranscriptItem()` with typed argument descriptors (`[14.2 KB String]`, `[Array(5)]`, `{Object(3 keys)}`), preserving JSON payload integrity and transcript legibility.
39
+
40
+ ---
41
+
8
42
  ## [1.3.2] - 2026-10-07
9
43
 
10
44
  ### Governance & Command Semantics (LH-11 Hardening)
package/README.md CHANGED
@@ -103,6 +103,33 @@ npm run status
103
103
 
104
104
  ---
105
105
 
106
+ ## Comprehensive Documentation
107
+
108
+ For complete technical references, architectural specifications, and governance rules, visit our **[Zensical Documentation Site](https://paragon-ux.github.io/agy-context-saver/)**:
109
+
110
+ - 📖 **[Getting Started & Installation](docs/installation.md)**: Zero-delay setup, native plugin linking, hooks registration, and verification matrix.
111
+ - 🏗️ **[Architecture & Internals](docs/architecture.md)**: Closed Execution Topology, RTK separation of concerns, and the Four Non-Fragile Architectural Pillars.
112
+ - 🛡️ **[Lifecycle Governance & Hooks](docs/governance.md)**: Proportional Backoff curve, Reactive Wakeup invariants, and the 5-strike circuit breaker.
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](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
+ - 🚀 **[Publishing & Verification Playbook](docs/publishing.md)**: Clean-room test runs, release procedures, and multi-suite verification matrix.
116
+
117
+ ---
118
+
119
+ ## Local Documentation Server
120
+
121
+ Build or serve the documentation locally using **Zensical**:
122
+
123
+ ```bash
124
+ # Build static documentation site
125
+ zensical build
126
+
127
+ # Serve live preview server
128
+ zensical serve
129
+ ```
130
+
131
+ ---
132
+
106
133
  ## Zero-Guesswork Testing & Verification Playbook
107
134
 
108
135
  To cleanly test every layer of the framework without guesswork or interactive stalls:
@@ -119,7 +146,7 @@ node mcp/index.js status
119
146
  ```bash
120
147
  npm test
121
148
  ```
122
- *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).*
123
150
 
124
151
  ### 3. Run Targeted Tests Individually
125
152
  ```bash
@@ -129,9 +156,15 @@ node tests/test-hook.mjs
129
156
  # MCP server protocol & method tests
130
157
  node tests/test-mcp.mjs
131
158
 
159
+ # Output inspection & lifecycle gating tests
160
+ node tests/test-output-governance.mjs
161
+
132
162
  # End-to-end edge case & probe tests
133
163
  node tests/test-e2e-probes.mjs
134
164
 
165
+ # Performance, streaming & memory probes
166
+ node tests/test-performance-probes.mjs
167
+
135
168
  # Live installed environment verification
136
169
  node tests/test-installed-verification.mjs
137
170
 
@@ -160,12 +193,16 @@ node tests/test-lifecycle-rollback.mjs
160
193
  - `/mcp:agy-context-saver:context_shield`: Injects 3-layer governance rules and RTK standards into any session with one click. *(MCP Prompts appear in your chat autocomplete menu; MCP Tools are called autonomously by the agent).*
161
194
  * **Agent MCP Tools**:
162
195
  1. `check_context_health`: Diagnoses conversation transcript turn budgets, payload size, RTK status, and polling loops.
163
- 2. `subagent_brief`: Formulates scope-isolated prompts for delegated subagents.
196
+ 2. `subagent_brief`: Formulates scope-isolated prompts for delegated subagents with Pointer-Over-Wire (POW) delivery contract.
164
197
  3. `read_transcript`: Reads recent conversation history in clean Markdown format (compact/full).
165
- 4. `query_transcript`: Forensic filtering and regex search engine for conversation logs.
198
+ 4. `query_transcript`: Forensic filtering, regex search engine, and surgical single-step dereferencing (`stepIndex`, `field`) from full transcripts.
166
199
  5. `get_installation_status`: Inspects live Antigravity plugin link, hook, MCP server, tool schemas, and RTK binary.
167
200
  6. `sync_installation`: Re-verifies and repairs all integration layers in ~25ms.
168
201
 
202
+ * **Streamlined Output Inspection (0 New Tools)**:
203
+ - **Runtime Step Spillovers** (`.system_generated/steps/<step>/output.txt`): Inspected via canonical `rtk read <path>` (safe-harbored in the PreToolUse hook; native `view_file` calls automatically route to `rtk read`).
204
+ - **Completed Background Task Logs**: Inspected via `rtk read tasks/<taskId>.log` (lifecycle-gated in the PreToolUse hook; strictly blocked under Proportional Backoff while running, allowed once completed).
205
+
169
206
  ---
170
207
 
171
208
  ## License
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.3.2";
101
+ const SERVER_VERSION = "1.5.0";
102
102
 
103
103
  // --- Tools Specification ---
104
104
  const TOOLS = [
@@ -243,6 +243,14 @@ const TOOLS = [
243
243
  summaryOnly: {
244
244
  type: "boolean",
245
245
  description: "If true, returns high-density 1-line step summaries instead of full message blocks to minimize UI card height (default: false)."
246
+ },
247
+ stepIndex: {
248
+ type: "number",
249
+ description: "Optional step_index to surgically retrieve full un-truncated content for a single step from transcript_full.jsonl."
250
+ },
251
+ field: {
252
+ type: "string",
253
+ description: "Optional specific field to extract when stepIndex is provided (e.g. 'content', 'thinking', 'tool_calls', 'tool_args', or 'tool_args:Message'). Defaults to full step."
246
254
  }
247
255
  },
248
256
  required: ["conversationId"]
@@ -305,36 +313,52 @@ async function handleCheckContextHealth(args = {}) {
305
313
  let corruptLines = 0;
306
314
  const MAX_LINES = 100000;
307
315
 
308
- for await (const line of rl) {
309
- if (!line.trim()) continue;
310
- totalSteps++;
311
- totalBytes += line.length;
316
+ try {
317
+ for await (const line of rl) {
318
+ if (!line.trim()) continue;
319
+ totalSteps++;
320
+ totalBytes += line.length;
312
321
 
313
- if (totalSteps > MAX_LINES) {
314
- break;
315
- }
322
+ if (totalSteps > MAX_LINES) {
323
+ break;
324
+ }
316
325
 
317
- try {
318
- const item = JSON.parse(line);
319
- if (item.type === "USER_INPUT") userTurns++;
320
- if (item.type === "PLANNER_RESPONSE") modelTurns++;
321
- if (item.tool_calls && item.tool_calls.length) {
322
- toolCalls += item.tool_calls.length;
323
- for (const tc of item.tool_calls) {
324
- const name = tc.tool_name || tc.name;
325
- const args = tc.args || tc.arguments || {};
326
- const action = String(args.Action || args.action || "").replace(/^["']|["']$/g, "").trim().toLowerCase();
327
- const prompt = String(args.Prompt || args.prompt || "").replace(/^["']|["']$/g, "").trim().toLowerCase();
328
- const cond = String(args.TimerCondition || args.timerCondition || "").replace(/^["']|["']$/g, "").trim().toLowerCase();
329
- if (name === "manage_task" && action === "status") pollingEvents++;
330
- 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
+ }
331
353
  }
354
+ } catch (err) {
355
+ corruptLines++;
332
356
  }
333
- } catch (err) {
334
- corruptLines++;
335
357
  }
358
+ } finally {
359
+ rl.close();
360
+ fileStream.destroy();
336
361
  }
337
-
338
362
  const kb = (totalBytes / 1024).toFixed(1);
339
363
  const healthStatus = pollingEvents > 3 ? "CRITICAL (Active Polling Loops Detected)" : userTurns > 40 ? "WARNING (High Turn Budget)" : "HEALTHY";
340
364
 
@@ -379,12 +403,17 @@ function handleSubagentBrief({ objective, scopeFiles = [], expectedDeliverable =
379
403
  `ROLE: Scope-Isolated Deep Research Subagent`,
380
404
  `OBJECTIVE: ${objective}${scopeStr}`,
381
405
  ``,
382
- `CRITICAL CONTEXT GOVERNANCE INSTRUCTIONS:`,
383
- `1. You are running in an isolated subagent sandbox. Do all necessary multi-file exploration, searches, and analysis here.`,
384
- `2. DO NOT dump raw file contents or massive grep outputs in your final response.`,
385
- `3. Deliver strictly the high-signal findings in the following format:`,
386
- ` ${expectedDeliverable}`,
387
- `4. Keep your final response concise, structured, and cite exact file:line references.`
406
+ `CRITICAL CONTEXT GOVERNANCE INSTRUCTIONS (POINTER-OVER-WIRE):`,
407
+ `1. Isolated Workspace: Do all necessary multi-file exploration, searches, and analysis in this subagent sandbox.`,
408
+ `2. Pointer-Over-Wire (POW) Protocol: In-band messaging channels (send_message) have strict transport payload limits (~16-20 KB). If your findings or deliverable exceed 1 KB:`,
409
+ ` a. Write the full comprehensive deliverable or raw output to a file in the workspace (e.g., 'scratch/<task>_findings.md' or an artifact).`,
410
+ ` b. In 'send_message', transmit ONLY:`,
411
+ ` - Executive Summary (3-5 high-signal sentences)`,
412
+ ` - Verdict / Scoreboard Table`,
413
+ ` - Clickable File Link: [Full Deliverable](file:///absolute/path/to/findings.md)`,
414
+ `3. Fast Turn Termination: Once 'send_message' is dispatched, stop calling tools and conclude your turn immediately so the parent agent wakes up without latency.`,
415
+ `4. Format: ${expectedDeliverable}`,
416
+ `5. Citation Mandate: Keep your final response concise, structured, and cite exact file:line references.`
388
417
  ].join("\n");
389
418
 
390
419
  return { content: [{ type: "text", text: prompt }] };
@@ -393,12 +422,13 @@ function handleSubagentBrief({ objective, scopeFiles = [], expectedDeliverable =
393
422
  function handleGetInstallationStatus() {
394
423
  const existing = detectExistingInstallation();
395
424
  const rtkInfo = findExistingRtk();
425
+ const schemaText = existing.details.schemas.exists ? "ALL 6 SCHEMAS PRESENT" : "MISSING";
396
426
  const report = [
397
427
  `### Agy-Context-Saver Installation Status: ${existing.isComplete ? "HEALTHY & ACTIVE 🛡️" : existing.isInstalled ? "PARTIAL INSTALLATION ⚠️" : "NOT INSTALLED ❌"}`,
398
428
  `- Native Plugin Link: ${existing.details.plugin.exists ? `ACTIVE (${existing.details.plugin.target})` : "NOT LINKED"}`,
399
429
  `- Governor Lifecycle Hook: ${existing.details.hook.registered ? "REGISTERED in hooks.json" : "NOT REGISTERED"} (Script: ${existing.details.hook.scriptExists ? "Present" : "Missing"})`,
400
430
  `- Universal MCP Server: ${existing.details.mcp.registered ? "CONFIGURED in mcp_config.json" : "NOT CONFIGURED"}`,
401
- `- Antigravity Tool Schemas: ${existing.details.schemas.exists ? "ALL 6 SCHEMAS PRESENT" : "MISSING"} (${existing.details.schemas.dir})`,
431
+ `- Antigravity Tool Schemas: ${schemaText} (${existing.details.schemas.dir})`,
402
432
  `- RTK Binary: ${rtkInfo ? `INSTALLED (${rtkInfo.version})` : "MISSING"}`,
403
433
  `- RTK Hook Optimization: ${existing.details.hook.registered ? "ACTIVE (via PreToolUse)" : "INACTIVE"}`,
404
434
  "",
@@ -445,6 +475,9 @@ function extractConvId(p) {
445
475
  return match ? match[1] : path.basename(normalized, path.extname(normalized));
446
476
  }
447
477
 
478
+ let cachedActiveConv = null;
479
+ let cachedActiveConvTime = 0;
480
+
448
481
  function resolveTranscriptPath(target, mode = "compact") {
449
482
  const isFull = mode === "full";
450
483
  const fileName = isFull ? "transcript_full.jsonl" : "transcript.jsonl";
@@ -455,19 +488,38 @@ function resolveTranscriptPath(target, mode = "compact") {
455
488
  if (process.env.GEMINI_CONVERSATION_ID) {
456
489
  raw = process.env.GEMINI_CONVERSATION_ID;
457
490
  } else {
458
- const brainDir = path.join(os.homedir(), ".gemini", "antigravity", "brain");
459
- 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");
460
496
  try {
461
- const entries = fs.readdirSync(brainDir, { withFileTypes: true })
462
- .filter((d) => d.isDirectory())
463
- .map((d) => {
464
- const p = path.join(brainDir, d.name);
465
- const stat = fs.statSync(p);
466
- return { name: d.name, mtime: stat.mtimeMs };
467
- })
468
- .sort((a, b) => b.mtime - a.mtime);
469
- if (entries.length > 0) {
470
- 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;
471
523
  }
472
524
  } catch {}
473
525
  }
@@ -527,6 +579,11 @@ function cleanMessageContent(content, maxChars = MAX_TURN_CHARS) {
527
579
  return text;
528
580
  }
529
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
+ }
530
587
  // Strip huge base64 media blocks only if relevant keywords or large length
531
588
  if (text.includes("base64") || text.length > 200) {
532
589
  text = text.replace(/data:image\/[^;]+;base64,[A-Za-z0-9+/=]+/g, "[Embedded Media/Binary Omitted]");
@@ -549,12 +606,35 @@ function cleanMessageContent(content, maxChars = MAX_TURN_CHARS) {
549
606
  }
550
607
  }
551
608
 
552
- if (text.length > maxChars) {
553
- 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)] ...`;
554
612
  }
555
613
  return text;
556
614
  }
557
615
 
616
+ function formatArgumentDescriptor(k, v) {
617
+ if (typeof v === "string") {
618
+ if (v.length <= 80 && !v.includes("\n")) {
619
+ return `${k}=${JSON.stringify(v)}`;
620
+ }
621
+ const preview = v.replace(/[\r\n\t]+/g, " ").slice(0, 50).trim();
622
+ const sizeKb = (Buffer.byteLength(v, "utf8") / 1024).toFixed(1);
623
+ return `${k}="${preview}..." [${sizeKb} KB]`;
624
+ }
625
+ if (typeof v === "number" || typeof v === "boolean" || v === null) {
626
+ return `${k}=${v}`;
627
+ }
628
+ if (Array.isArray(v)) {
629
+ return `${k}=[Array(${v.length})]`;
630
+ }
631
+ if (typeof v === "object") {
632
+ const keys = Object.keys(v);
633
+ return `${k}={Object(${keys.length} keys)}`;
634
+ }
635
+ return `${k}=${String(v).slice(0, 60)}`;
636
+ }
637
+
558
638
  function formatTranscriptItem(item, options = {}) {
559
639
  const { includeThinking = false, includeToolCalls = false, maxContentChars = MAX_TURN_CHARS } = options;
560
640
  const stepIdx = item.step_index !== undefined ? item.step_index : "?";
@@ -583,7 +663,7 @@ function formatTranscriptItem(item, options = {}) {
583
663
  const name = tc.name || tc.tool_name || "unknown_tool";
584
664
  const args = tc.args || tc.arguments || {};
585
665
  const compactArgs = Object.entries(args)
586
- .map(([k, v]) => `${k}=${JSON.stringify(v).slice(0, 80)}`)
666
+ .map(([k, v]) => formatArgumentDescriptor(k, v))
587
667
  .join(", ");
588
668
  return `- \`${name}(${compactArgs})\``;
589
669
  });
@@ -626,12 +706,14 @@ function getCompiledRegex(pattern) {
626
706
 
627
707
  async function batchFindFullSteps(fullPath, stepIndicesSet) {
628
708
  const result = new Map();
629
- if (!fs.existsSync(fullPath) || !stepIndicesSet || stepIndicesSet.size === 0) return result;
630
-
631
- const fileStream = fs.createReadStream(fullPath, { encoding: "utf-8" });
632
- const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
709
+ if (!stepIndicesSet || stepIndicesSet.size === 0) return result;
633
710
 
711
+ let fileStream;
712
+ let rl;
634
713
  try {
714
+ fileStream = fs.createReadStream(fullPath, { encoding: "utf-8" });
715
+ rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
716
+
635
717
  for await (const line of rl) {
636
718
  if (!line.trim()) continue;
637
719
  try {
@@ -639,13 +721,15 @@ async function batchFindFullSteps(fullPath, stepIndicesSet) {
639
721
  if (stepIndicesSet.has(item.step_index)) {
640
722
  result.set(item.step_index, item);
641
723
  if (result.size >= stepIndicesSet.size) {
642
- rl.close();
643
724
  break;
644
725
  }
645
726
  }
646
727
  } catch {}
647
728
  }
648
- } catch {}
729
+ } catch {} finally {
730
+ if (rl) rl.close();
731
+ if (fileStream) fileStream.destroy();
732
+ }
649
733
  return result;
650
734
  }
651
735
 
@@ -661,35 +745,49 @@ async function handleReadTranscript({ conversationId, mode = "compact", lastTurn
661
745
  }
662
746
 
663
747
  const { filePath, convId } = resolved;
664
- const items = [];
665
- const fileStream = fs.createReadStream(filePath, { encoding: "utf-8" });
666
- const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
748
+ const rawLines = [];
749
+ let fileStream;
750
+ let rl;
667
751
 
668
752
  let totalStepsCount = 0;
669
753
  const WINDOW_SIZE = lastTurns > 0 ? Math.max(100, lastTurns * 15) : Infinity;
670
754
 
671
755
  try {
756
+ fileStream = fs.createReadStream(filePath, { encoding: "utf-8" });
757
+ rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
672
758
  for await (const line of rl) {
673
- if (!line.trim()) continue;
674
- try {
675
- const item = JSON.parse(line);
676
- totalStepsCount++;
677
- items.push(item);
678
- if (items.length > WINDOW_SIZE) {
679
- 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();
680
768
  }
681
- } catch {
682
- // Tolerates incomplete trailing flushes during concurrent writes
683
769
  }
684
770
  }
685
771
  } catch (err) {
686
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();
687
776
  }
688
777
 
689
778
  if (totalStepsCount === 0) {
690
779
  return { content: [{ type: "text", text: `Transcript is empty at: ${filePath}` }] };
691
780
  }
692
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
+
693
791
  let selected = items;
694
792
  if (lastTurns > 0) {
695
793
  const turnItems = items.filter((it) => it.type === "USER_INPUT" || it.type === "PLANNER_RESPONSE");
@@ -697,7 +795,6 @@ async function handleReadTranscript({ conversationId, mode = "compact", lastTurn
697
795
  const minStep = turnItems[cutoff] ? turnItems[cutoff].step_index : 0;
698
796
  selected = items.filter((it) => (it.step_index || 0) >= minStep);
699
797
  }
700
-
701
798
  const header = [
702
799
  `# Conversation Transcript: \`${convId}\``,
703
800
  `- Mode: **${mode}** (${path.basename(filePath)})`,
@@ -745,9 +842,105 @@ async function handleQueryTranscript(args = {}) {
745
842
  includeThinking = false,
746
843
  includeToolCalls = false,
747
844
  maxResults = 5,
748
- summaryOnly = false
845
+ summaryOnly = false,
846
+ stepIndex,
847
+ field
749
848
  } = args;
750
849
 
850
+ // Surgical single-step dereferencing (folded from get_step_detail)
851
+ if (stepIndex !== undefined && stepIndex !== null) {
852
+ const numIndex = Number(stepIndex);
853
+ if (isNaN(numIndex)) {
854
+ return {
855
+ isError: true,
856
+ content: [{ type: "text", text: `Error: 'stepIndex' must be a number (received: ${stepIndex}).` }]
857
+ };
858
+ }
859
+
860
+ const resolvedFull = resolveTranscriptPath(conversationId, "full");
861
+ let step = null;
862
+ if (resolvedFull && resolvedFull.filePath) {
863
+ step = await findFullStep(resolvedFull.filePath, numIndex);
864
+ }
865
+
866
+ if (!step) {
867
+ const resolvedCompact = resolveTranscriptPath(conversationId, "compact");
868
+ if (resolvedCompact && resolvedCompact.filePath) {
869
+ step = await findFullStep(resolvedCompact.filePath, numIndex);
870
+ }
871
+ }
872
+ if (!step) {
873
+ return {
874
+ isError: true,
875
+ content: [{ type: "text", text: `Step index ${numIndex} not found in transcript.` }]
876
+ };
877
+ }
878
+
879
+ const MAX_FIELD_CHARS = 24000;
880
+
881
+ if (field) {
882
+ const f = String(field).trim();
883
+ if (f === "content") {
884
+ const val = step.content || "(No content in step)";
885
+ return {
886
+ content: [{ type: "text", text: `### Step ${numIndex} Content\n\n${val.slice(0, MAX_FIELD_CHARS)}` }]
887
+ };
888
+ }
889
+ if (f === "thinking") {
890
+ const val = step.thinking || "(No thinking block in step)";
891
+ return {
892
+ content: [{ type: "text", text: `### Step ${numIndex} Thinking\n\n${val.slice(0, MAX_FIELD_CHARS)}` }]
893
+ };
894
+ }
895
+ if (f === "tool_calls") {
896
+ const val = JSON.stringify(step.tool_calls || [], null, 2);
897
+ return {
898
+ content: [{ type: "text", text: `### Step ${numIndex} Tool Calls\n\`\`\`json\n${val.slice(0, MAX_FIELD_CHARS)}\n\`\`\`` }]
899
+ };
900
+ }
901
+ if (f === "tool_args" || f.startsWith("tool_args:")) {
902
+ const argName = f.includes(":") ? f.split(":")[1].trim() : null;
903
+ const tc = Array.isArray(step.tool_calls) && step.tool_calls.length > 0 ? step.tool_calls[0] : null;
904
+ if (!tc) {
905
+ return {
906
+ content: [{ type: "text", text: `Step ${numIndex} contains no tool calls.` }]
907
+ };
908
+ }
909
+ const tcArgs = tc.args || tc.arguments || {};
910
+ if (argName) {
911
+ const argVal = tcArgs[argName];
912
+ const displayVal = typeof argVal === "string" ? argVal : JSON.stringify(argVal, null, 2);
913
+ return {
914
+ content: [
915
+ {
916
+ type: "text",
917
+ text: `### Step ${numIndex} Argument: \`${argName}\`\n\n${String(displayVal).slice(0, MAX_FIELD_CHARS)}`
918
+ }
919
+ ]
920
+ };
921
+ }
922
+ return {
923
+ content: [
924
+ {
925
+ type: "text",
926
+ text: `### Step ${numIndex} Tool Arguments\n\`\`\`json\n${JSON.stringify(tcArgs, null, 2).slice(0, MAX_FIELD_CHARS)}\n\`\`\``
927
+ }
928
+ ]
929
+ };
930
+ }
931
+ }
932
+
933
+ const formatted = formatTranscriptItem(step, {
934
+ includeThinking: true,
935
+ includeToolCalls: true,
936
+ maxContentChars: MAX_FIELD_CHARS
937
+ });
938
+
939
+ return {
940
+ content: [{ type: "text", text: formatted }]
941
+ };
942
+ }
943
+
751
944
  const resolved = resolveTranscriptPath(conversationId, mode === "full" ? "full" : "compact");
752
945
  if (resolved.error) {
753
946
  return { isError: true, content: [{ type: "text", text: resolved.error }] };
@@ -755,20 +948,31 @@ async function handleQueryTranscript(args = {}) {
755
948
 
756
949
  const { filePath, convId } = resolved;
757
950
  const fullSiblingPath = filePath.replace(/transcript\.jsonl$/, "transcript_full.jsonl");
758
- const hasFullSibling = fs.existsSync(fullSiblingPath);
759
951
 
760
952
  const roleSet = new Set((Array.isArray(roles) ? roles : [roles]).map((r) => String(r).toLowerCase()));
761
953
  const matchAllRoles = roleSet.has("all");
762
954
 
763
955
  const queryRegex = getCompiledRegex(query);
956
+ const isPlainLiteral = query && /^[A-Za-z0-9_ -]+$/.test(query);
764
957
 
765
- const matched = [];
766
- const fileStream = fs.createReadStream(filePath, { encoding: "utf-8" });
767
- const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
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;
962
+
963
+ let fileStream;
964
+ let rl;
768
965
 
769
966
  try {
967
+ fileStream = fs.createReadStream(filePath, { encoding: "utf-8" });
968
+ rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
969
+
770
970
  for await (const line of rl) {
771
971
  if (!line.trim()) continue;
972
+
973
+ // PERF-12: Raw-line prefilter for plain literal queries
974
+ if (isPlainLiteral && !queryRegex.test(line)) continue;
975
+
772
976
  let item;
773
977
  try {
774
978
  item = JSON.parse(line);
@@ -810,13 +1014,20 @@ async function handleQueryTranscript(args = {}) {
810
1014
  if (!matches) continue;
811
1015
  }
812
1016
 
813
- matched.push(item);
1017
+ matchedCount++;
1018
+ ring.push(item);
1019
+ if (ring.length > ringCap) {
1020
+ ring.shift();
1021
+ }
814
1022
  }
815
1023
  } catch (err) {
816
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();
817
1028
  }
818
1029
 
819
- if (matched.length === 0) {
1030
+ if (matchedCount === 0) {
820
1031
  return {
821
1032
  content: [{
822
1033
  type: "text",
@@ -825,8 +1036,8 @@ async function handleQueryTranscript(args = {}) {
825
1036
  };
826
1037
  }
827
1038
 
828
- let finalItems = matched;
829
- if (lastTurns && lastTurns > 0) {
1039
+ let finalItems = ring;
1040
+ if (lastTurns && lastTurns > 0 && finalItems.length > lastTurns) {
830
1041
  finalItems = finalItems.slice(-lastTurns);
831
1042
  }
832
1043
  if (finalItems.length > maxResults) {
@@ -834,8 +1045,8 @@ async function handleQueryTranscript(args = {}) {
834
1045
  }
835
1046
 
836
1047
  // Auto-dereferencing if mode is "auto" and fields were truncated (single pass)
837
- if (mode === "auto" && hasFullSibling) {
838
- 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();
839
1050
  for (const it of finalItems) {
840
1051
  if (Array.isArray(it.truncated_fields) && it.truncated_fields.length > 0 && it.step_index !== undefined) {
841
1052
  truncatedIndices.add(it.step_index);
@@ -869,7 +1080,7 @@ async function handleQueryTranscript(args = {}) {
869
1080
  const header = [
870
1081
  `## Transcript Query Summary: \`${convId}\``,
871
1082
  `- Filter: query=${query ? `"${query}"` : "NONE"}, roles=[${Array.from(roleSet).join(", ")}], mode=${mode}`,
872
- `- Matched Steps: ${matched.length} (showing ${finalItems.length} in compact summary mode)`,
1083
+ `- Matched Steps: ${matchedCount} (showing ${finalItems.length} in compact summary mode)`,
873
1084
  "",
874
1085
  "---",
875
1086
  ""
@@ -885,7 +1096,7 @@ async function handleQueryTranscript(args = {}) {
885
1096
  const header = [
886
1097
  `## Transcript Query Results: \`${convId}\``,
887
1098
  `- Filter: query=${query ? `"${query}"` : "NONE"}, roles=[${Array.from(roleSet).join(", ")}], mode=${mode}`,
888
- `- Matched Steps: ${matched.length} (showing ${finalItems.length})`,
1099
+ `- Matched Steps: ${matchedCount} (showing ${finalItems.length})`,
889
1100
  "",
890
1101
  "---",
891
1102
  ""
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agy-context-saver",
3
- "version": "1.3.2",
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": {
@@ -12,7 +12,7 @@
12
12
  "setup": "node scripts/install-register.mjs",
13
13
  "uninstall": "node scripts/install-register.mjs --uninstall",
14
14
  "status": "node scripts/install-register.mjs --status",
15
- "test": "node tests/test-hook.mjs && node tests/test-mcp.mjs && node tests/test-e2e-probes.mjs && node tests/test-performance-probes.mjs && node tests/test-installed-verification.mjs && node tests/test-lifecycle-rollback.mjs"
15
+ "test": "node tests/test-hook.mjs && node tests/test-mcp.mjs && node tests/test-output-governance.mjs && node tests/test-e2e-probes.mjs && node tests/test-performance-probes.mjs && node tests/test-installed-verification.mjs && node tests/test-lifecycle-rollback.mjs"
16
16
  },
17
17
  "keywords": [
18
18
  "antigravity",
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
  /**
@@ -184,31 +175,124 @@ function isProtectedBenchmarkPath(targetPath) {
184
175
  );
185
176
  }
186
177
 
187
- let resolvedRtkPath = null;
188
- function getRtkBinary() {
189
- if (resolvedRtkPath) return resolvedRtkPath;
178
+ /**
179
+ * Checks if target path references runtime step output spillover file.
180
+ */
181
+ function isStepSpilloverPath(targetPath) {
182
+ if (!targetPath || typeof targetPath !== "string") return false;
183
+ if (!isProtectedInternalState(targetPath)) return false; // cheap precheck
184
+ const normalized = targetPath.replace(/\\/g, "/");
185
+ return (
186
+ /\.system_generated[/\\]steps[/\\]\d+[/\\]output\.txt/i.test(normalized) ||
187
+ (normalized.includes("/.system_generated/steps/") && normalized.endsWith("output.txt"))
188
+ );
189
+ }
190
190
 
191
- // 1. Try global rtk on PATH
191
+ /**
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").
195
+ */
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 };
205
+ }
206
+
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
209
+
210
+ function getBrainDir() {
211
+ return process.env.AGY_BRAIN_DIR || path.join(os.homedir(), ".gemini", "antigravity", "brain");
212
+ }
213
+
214
+ /** Reads the last `bytes` of a file. Returns { text, whole } or null. */
215
+ function readTail(file, bytes) {
216
+ let fd;
192
217
  try {
193
- const check = spawnSync("rtk", ["--version"], { timeout: 1000, windowsHide: true });
194
- if (check.status === 0) {
195
- resolvedRtkPath = "rtk";
196
- return resolvedRtkPath;
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 {} }
228
+ }
229
+ }
230
+
231
+ /**
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.
236
+ */
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;
241
+ }
242
+
243
+ /**
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.
246
+ */
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");
262
+ }
263
+
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;
197
272
  }
198
- } catch {}
273
+ }
199
274
 
200
- // 2. Try plugin local bin
201
- const isWin = os.platform() === "win32";
202
- const exeName = isWin ? "rtk.exe" : "rtk";
203
- const homeDir = os.homedir();
204
- const pluginBin = path.join(homeDir, ".gemini", "config", "plugins", "agy-context-saver", "bin", exeName);
205
- if (fs.existsSync(pluginBin)) {
206
- resolvedRtkPath = pluginBin;
207
- return resolvedRtkPath;
275
+ if (logPath) {
276
+ try {
277
+ return (Date.now() - fs.statSync(logPath).mtimeMs) > TASK_QUIET_MS ? "COMPLETED" : "RUNNING";
278
+ } catch {}
208
279
  }
280
+ return "UNKNOWN";
281
+ }
209
282
 
210
- resolvedRtkPath = "rtk";
211
- 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);
294
+ }
295
+ return res;
212
296
  }
213
297
 
214
298
  const NON_REWRITABLE_BINARIES = new Set([
@@ -235,31 +319,39 @@ const REWRITE_CACHE_FILE = path.join(os.tmpdir(), "agy-rtk-rewrite-cache.json");
235
319
  const REWRITE_CACHE_MAX = 100;
236
320
  let inMemoryRewriteCache = null;
237
321
 
238
- function loadRewriteCache() {
239
- if (inMemoryRewriteCache) return inMemoryRewriteCache;
322
+ function readRewriteCacheFile() {
240
323
  try {
241
- if (fs.existsSync(REWRITE_CACHE_FILE)) {
242
- const data = JSON.parse(fs.readFileSync(REWRITE_CACHE_FILE, "utf-8"));
243
- if (data && typeof data === "object") {
244
- inMemoryRewriteCache = data;
245
- return inMemoryRewriteCache;
246
- }
247
- }
324
+ const data = JSON.parse(fs.readFileSync(REWRITE_CACHE_FILE, "utf-8"));
325
+ if (data && typeof data === "object" && !Array.isArray(data)) return data;
248
326
  } catch {}
249
- inMemoryRewriteCache = {};
327
+ return {};
328
+ }
329
+
330
+ function loadRewriteCache() {
331
+ if (!inMemoryRewriteCache) inMemoryRewriteCache = readRewriteCacheFile();
250
332
  return inMemoryRewriteCache;
251
333
  }
252
334
 
253
- 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;
254
341
  try {
255
- const keys = Object.keys(cache);
256
- let toSave = cache;
257
- if (keys.length > REWRITE_CACHE_MAX) {
258
- const slice = keys.slice(keys.length - REWRITE_CACHE_MAX);
259
- toSave = {};
260
- 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 {}
261
354
  }
262
- fs.writeFileSync(REWRITE_CACHE_FILE, JSON.stringify(toSave), "utf-8");
263
355
  } catch {}
264
356
  }
265
357
 
@@ -312,13 +404,12 @@ function rewriteCommandWithRtk(rawCmd) {
312
404
  }
313
405
 
314
406
  let rewritten = trimmed;
407
+ let cacheable = false;
315
408
  try {
316
- const rtkBin = getRtkBinary();
317
- const res = spawnSync(rtkBin, ["rewrite", trimmed], {
318
- encoding: "utf-8",
319
- timeout: 2000,
320
- windowsHide: true
321
- });
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;
322
413
 
323
414
  // RTK exits 0 or 3 when rewritten, with new command on stdout
324
415
  if ((res.status === 0 || res.status === 3) && res.stdout && res.stdout.trim()) {
@@ -326,8 +417,7 @@ function rewriteCommandWithRtk(rawCmd) {
326
417
  }
327
418
  } catch {}
328
419
 
329
- cache[trimmed] = rewritten;
330
- saveRewriteCache(cache);
420
+ if (cacheable) rememberRewrite(trimmed, rewritten);
331
421
  return rewritten;
332
422
  }
333
423
 
@@ -342,6 +432,34 @@ function getRequiredBackoffSeconds(pollCount) {
342
432
  return Math.min(600, Math.round(30 * Math.pow(2.5, Math.max(0, pollCount - 1))));
343
433
  }
344
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
+
345
463
  // --- Main Hook Entrypoint ---
346
464
  let isGovernedTool = false;
347
465
 
@@ -389,8 +507,25 @@ try {
389
507
  });
390
508
  }
391
509
 
392
- // LH-02-B: Direct shell access to internal Antigravity execution state (.system_generated)
393
- if (isProtectedInternalState(rawCmd)) {
510
+ // LH-09-B: Shell command mutation of protected benchmark artifacts
511
+ if (
512
+ (rawCmd.includes(">") || /\b(?:rm|del|Remove-Item)\b/i.test(rawCmd)) &&
513
+ isProtectedBenchmarkPath(rawCmd)
514
+ ) {
515
+ respond({
516
+ decision: "force_ask",
517
+ reason:
518
+ "[BENCHMARK MUTATION DETECTED] Shell command mutation of protected evaluator/benchmark artifact requires explicit user confirmation to prevent benchmark contamination."
519
+ });
520
+ }
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)) {
394
529
  respond({
395
530
  decision: "deny",
396
531
  reason:
@@ -400,19 +535,35 @@ try {
400
535
  });
401
536
  }
402
537
 
403
- // LH-09-B: Shell command mutation of protected benchmark artifacts
404
- if (
405
- (rawCmd.includes(">") || /\b(?:rm|del|Remove-Item)\b/i.test(rawCmd)) &&
406
- isProtectedBenchmarkPath(rawCmd)
407
- ) {
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
+
543
+ const rewritten = rewriteCommandWithRtk(rawCmd);
544
+ const effectiveCmd = rewritten;
545
+ const isRtkRead = /^rtk(?:\.exe)?\s+read\b/i.test(effectiveCmd);
546
+
547
+ // Check 3: Step Output Spillover Safe Harbor (rtk read only; it clamps + windows output)
548
+ if (isSpillover && !isRtkRead) {
408
549
  respond({
409
- decision: "force_ask",
550
+ decision: "deny",
410
551
  reason:
411
- "[BENCHMARK MUTATION DETECTED] Shell command mutation of protected evaluator/benchmark artifact requires explicit user confirmation to prevent benchmark contamination."
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
+ }
555
+
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>)."
412
564
  });
413
565
  }
414
566
 
415
- const rewritten = rewriteCommandWithRtk(rawCmd);
416
567
  const isDaemon = args.IsDaemon === true || args.isDaemon === true;
417
568
  const waitMs = args.WaitMsBeforeAsync !== undefined ? args.WaitMsBeforeAsync : args.waitMsBeforeAsync;
418
569
  const targetMaxWait = Number(process.env.AGY_MAX_WAIT_MS) || 10000;
@@ -456,7 +607,39 @@ try {
456
607
  "";
457
608
  const target = String(rawTarget).trim();
458
609
 
459
- // 2a. Internal State Protection by Root (LH-02)
610
+ // 2a. Step Output Spillover Safe Harbor (hard-route to rtk read)
611
+ if (isStepSpilloverPath(target)) {
612
+ respond({
613
+ decision: "deny",
614
+ reason: `Antigravity Governance: Native file inspection via '${toolName}' bypasses token optimization. Run 'rtk read ${target}' via run_command instead.`
615
+ });
616
+ }
617
+
618
+ // 2b. Task Log Inspection (Lifecycle-Gated)
619
+ const taskMatch = getTaskLogMatch(target);
620
+ if (taskMatch) {
621
+ const state = getTaskState(taskMatch, convId);
622
+ if (state === "RUNNING") {
623
+ denyRunningTask(taskMatch.id, convId);
624
+ }
625
+
626
+ if (state === "COMPLETED") {
627
+ // If terminated / completed, route to rtk read:
628
+ respond({
629
+ decision: "deny",
630
+ reason: `Antigravity Governance: Native file inspection via '${toolName}' bypasses token optimization. Run 'rtk read ${target}' via run_command instead.`
631
+ });
632
+ }
633
+
634
+ // If not verified completed (or does not exist):
635
+ respond({
636
+ decision: "deny",
637
+ reason:
638
+ "Antigravity Execution Governance: Direct access to internal Antigravity execution state (.system_generated/tasks) is strictly prohibited to prevent polling loops. rely on native Reactive Wakeup (<SYSTEM_MESSAGE>) upon completion."
639
+ });
640
+ }
641
+
642
+ // 2c. Other Internal State Protection by Root (LH-02)
460
643
  if (isProtectedInternalState(target) || isProtectedInternalState(JSON.stringify(args))) {
461
644
  respond({
462
645
  decision: "deny",
@@ -467,7 +650,7 @@ try {
467
650
  });
468
651
  }
469
652
 
470
- // 2b. Allow Antigravity Special Files (SKILL.md, brain artifacts, configs)
653
+ // 2d. Allow Antigravity Special Files (SKILL.md, brain artifacts, configs)
471
654
  if (isAntigravitySpecialFile(target)) {
472
655
  respond({ decision: "allow" });
473
656
  }
@@ -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",
@@ -280,13 +280,21 @@ export async function runInstall(options = {}) {
280
280
  // 5. Mirror Antigravity Tool Schemas in parallel
281
281
  await fsPromises.mkdir(antigravityMcpDir, { recursive: true });
282
282
 
283
- // Clean up legacy safe_command schema if present
284
- const legacySafeCommandPath = path.join(antigravityMcpDir, "safe_command.json");
285
- if (fs.existsSync(legacySafeCommandPath)) {
286
- try {
287
- await fsPromises.unlink(legacySafeCommandPath);
288
- log(`[OK] Cleaned up legacy safe_command schema from: ${legacySafeCommandPath}`);
289
- } catch {}
283
+ // Clean up retired schemas if present
284
+ const retiredSchemas = [
285
+ "safe_command.json",
286
+ "get_spillover_content.json",
287
+ "read_task_output.json",
288
+ "get_step_detail.json"
289
+ ];
290
+ for (const s of retiredSchemas) {
291
+ const sPath = path.join(antigravityMcpDir, s);
292
+ if (fs.existsSync(sPath)) {
293
+ try {
294
+ await fsPromises.unlink(sPath);
295
+ log(`[OK] Cleaned up retired schema from: ${sPath}`);
296
+ } catch {}
297
+ }
290
298
  }
291
299
 
292
300
  const instructionsContent = `# Agy-Context-Saver MCP Server Instructions
@@ -302,15 +310,17 @@ Agy-Context-Saver is the universal Model Context Protocol server and lifecycle g
302
310
  2. **Never Read Internal State Directly**:
303
311
  - Access to \`.system_generated/\` via \`view_file\` is strictly denied to prevent transcript explosion.
304
312
  - Use \`read_transcript\` or \`query_transcript\` to inspect conversation history cleanly.
305
- 3. **Platform-Aware Codebase Inspection**:
313
+ 3. **Inspect Output Files Directly with RTK**:
314
+ - Runtime step output spillovers (\`.system_generated/steps/<step>/output.txt\`) and completed background task logs (\`tasks/<taskId>.log\`) are read directly via \`rtk read <path>\` under hook governance.
315
+ 4. **Platform-Aware Codebase Inspection**:
306
316
  - **Windows**: Use \`rtk read <file>\`, \`rtk find <path>\`, and \`rtk rg "<pattern>" .\` (the trailing \`.\` is required to avoid stdin stalls).
307
317
  - **POSIX**: Use \`rtk read <file>\`, \`rtk ls <path>\`, and \`rtk grep "<pattern>"\` or \`rtk rg "<pattern>"\`.
308
318
 
309
319
  ## Available MCP Tools
310
320
  - \`check_context_health\`: Diagnoses transcript turn counts, byte payload, and polling loops. Accepts \`transcriptPath\` or \`conversationId\`.
311
- - \`subagent_brief\`: Generates scope-isolated instructions for delegated subagents to prevent parent context bloat.
321
+ - \`subagent_brief\`: Generates scope-isolated instructions for delegated subagents with Pointer-Over-Wire delivery contract.
312
322
  - \`read_transcript\`: Streams recent turns from \`transcript.jsonl\` or \`transcript_full.jsonl\` formatted as clean Markdown.
313
- - \`query_transcript\`: Forensic regex search and filtering engine across transcript steps with optional \`summaryOnly: true\`.
323
+ - \`query_transcript\`: Forensic regex search and filtering engine across transcript steps with optional \`summaryOnly: true\`, and surgical step extraction via \`stepIndex\` and \`field\`.
314
324
  - \`get_installation_status\`: Audits live health of the plugin link, hook, MCP server, tool schemas, and RTK.
315
325
  - \`sync_installation\`: Synchronizes and repairs all 4 integration layers in ~25ms.
316
326
 
@@ -457,6 +467,14 @@ Agy-Context-Saver is the universal Model Context Protocol server and lifecycle g
457
467
  summaryOnly: {
458
468
  type: "boolean",
459
469
  description: "If true, returns high-density 1-line step summaries instead of full message blocks to minimize UI card height (default: false)."
470
+ },
471
+ stepIndex: {
472
+ type: "number",
473
+ description: "Optional step_index to surgically retrieve full un-truncated content for a single step from transcript_full.jsonl."
474
+ },
475
+ field: {
476
+ type: "string",
477
+ description: "Optional specific field to extract when stepIndex is provided (e.g. 'content', 'thinking', 'tool_calls', 'tool_args', or 'tool_args:Message'). Defaults to full step."
460
478
  }
461
479
  },
462
480
  required: ["conversationId"]