agy-context-saver 1.5.0 → 1.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agy-context-saver",
3
- "version": "1.5.0",
3
+ "version": "1.6.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,22 @@
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-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"
15
+ "preflight": "node scripts/release-preflight.mjs",
16
+ "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 && node tests/test-subagent-trials.mjs && node tests/test-check-only-readonly.mjs && node tests/test-malformed-config-preservation.mjs"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "https://github.com/paragon-ux/Agy-Context-Saver.git"
21
+ },
22
+ "homepage": "https://paragon-ux.github.io/agy-context-saver/",
23
+ "bugs": {
24
+ "url": "https://github.com/paragon-ux/Agy-Context-Saver/issues"
25
+ },
26
+ "engines": {
27
+ "node": ">=22"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
16
31
  },
17
32
  "keywords": [
18
33
  "antigravity",
@@ -24,6 +39,7 @@
24
39
  "agentic-coding"
25
40
  ],
26
41
  "files": [
42
+ "assets",
27
43
  "mcp",
28
44
  "scripts",
29
45
  "rules",
package/plugin.json CHANGED
@@ -1,5 +1,12 @@
1
1
  {
2
2
  "name": "agy-context-saver",
3
- "version": "1.5.0",
4
- "description": "Deterministic background polling governor and subagent context preservation shield for Antigravity"
3
+ "displayName": "Antigravity Context Shield & Governor",
4
+ "version": "1.6.0",
5
+ "description": "Deterministic background polling governor, RTK terminal optimizer, and subagent context preservation shield for Antigravity.",
6
+ "logo": "assets/logo.svg",
7
+ "suggestedPrompts": [
8
+ "Audit context health and token consumption for this session",
9
+ "Formulate an isolated subagent brief under Pointer-Over-Wire protocol",
10
+ "Inspect recent conversation turns without raw JSON transcript bloat"
11
+ ]
5
12
  }
package/rules/AGENTS.md CHANGED
@@ -55,12 +55,14 @@
55
55
  - **Fail-Fast Flags are Opt-In**: Fail-fast flags (`-x`, `--maxfail=1`) are opt-in. Use them when quickly debugging a localized failure, but omit them when assessing the full blast radius across the codebase to avoid the "whack-a-mole" loop.
56
56
  - **Coordinated Watchdog Mandatory for Full Sweeps**: When running full multi-minute test sweeps without `-x`, agents **MUST** pair execution with the **Coordinated Watchdog Protocol** (`schedule(DurationSeconds=45, ...)`) and output a **Pre-Yield Status Card**. Never yield silently.
57
57
  - **Subagent Delegation for Deep Audits**: When a comprehensive repository-wide test audit is required, delegate the sweep to a subagent (`invoke_subagent`) to keep the main thread responsive.
58
- - **Subagent Delegation**: Delegate heavy multi-file exploration and exploratory research to subagents (`invoke_subagent`). Subagents absorb intermediate steps and return high-signal summaries.
58
+ - **Subagent Delegation & Pointer-Over-Wire (POW)**:
59
+ - Delegate heavy multi-file exploration, deep research, and repo sweeps to subagents (`invoke_subagent`). Subagents absorb intermediate steps and return high-signal summaries.
60
+ - **POW Protocol**: Payloads or deliverables exceeding 1 KB must be written to workspace scratch files (e.g. `scratch/<task>_findings.md` or artifacts). Transmit **ONLY** an Executive Summary (3–5 sentences), a Scoreboard / Verdict table, and a clickable file link (`file:///...`) in `send_message` to prevent transport truncation and parent context bloat.
59
61
  - **Benchmark / Evaluator Integrity**: Never mutate benchmark definitions, prompt templates, or scoring artifacts during an active evaluation without explicit user confirmation.
60
62
 
61
63
  ---
62
64
 
63
- ## Layer 4: Zero-Guesswork Testing & Verification Playbook
65
+ ## Appendix: Zero-Guesswork Testing & Verification Playbook
64
66
  When an agent or developer tests this framework, follow this exact sequence without guessing:
65
67
 
66
68
  1. **Verify Live Registration & Health (Fast, non-blocking)**:
@@ -75,21 +77,24 @@ When an agent or developer tests this framework, follow this exact sequence with
75
77
  ```bash
76
78
  npm test
77
79
  ```
78
- *Expected*: All 5 test suites pass (Hook unit tests, MCP protocol tests, E2E probes, live system validation, lifecycle rollback).
80
+ *Expected*: All 8 test suites pass (Hook unit tests, MCP protocol tests, output governance, E2E probes, performance probes, live system validation, lifecycle rollback, subagent trials).
79
81
 
80
82
  3. **Verify Individual Layers On-Demand**:
81
83
  - Governance Hook: `node tests/test-hook.mjs`
82
84
  - MCP Protocol & JSON-RPC: `node tests/test-mcp.mjs`
85
+ - Output Governance: `node tests/test-output-governance.mjs`
83
86
  - E2E Probes: `node tests/test-e2e-probes.mjs`
87
+ - Performance Probes: `node tests/test-performance-probes.mjs`
84
88
  - Live System Checks: `node tests/test-installed-verification.mjs`
85
89
  - Lifecycle & Rollback: `node tests/test-lifecycle-rollback.mjs`
90
+ - Subagent Delegation Trials: `node tests/test-subagent-trials.mjs`
86
91
 
87
92
  4. **Verify MCP Tools in Antigravity**:
88
93
  - `get_installation_status`: Inspect live integration layers.
89
94
  - `check_context_health`: Pass `{ "transcriptPath": "<path-to-transcript.jsonl>" }`.
90
95
  - `read_transcript`: Pass `{ "conversationId": "<uuid>", "lastTurns": 3, "mode": "compact" }`.
91
96
  - `query_transcript`: Pass `{ "conversationId": "<uuid>", "query": "<pattern>", "summaryOnly": true }`.
92
- - `subagent_brief`: Pass `{ "objective": "<goal>", "scopeFiles": ["..."] }`.
97
+ - `subagent_brief`: Pass `{ "objective": "<goal>", "role": "<role>", "scopeFiles": ["..."] }` or pass multiple via `{ "subagents": [...] }` to generate ready-to-use `invoke_subagent` JSON payloads.
93
98
 
94
99
  5. **Operational Anti-Patterns (What NOT to do)**:
95
100
  - ❌ **Do NOT run `npm start` or `node mcp/index.js` interactively**: It starts the stdio JSON-RPC daemon and waits indefinitely on stdin.
@@ -32,6 +32,7 @@ import os from "node:os";
32
32
  import path from "node:path";
33
33
  import process from "node:process";
34
34
  import { spawnSync } from "node:child_process";
35
+ import { fileURLToPath } from "node:url";
35
36
 
36
37
  function respond(obj) {
37
38
  process.stdout.write(JSON.stringify(obj) + "\n");
@@ -207,8 +208,12 @@ function getTaskLogMatch(text) {
207
208
  const TASK_TAIL_BYTES = 262144; // transcript tail scanned first (256 KB)
208
209
  const TASK_QUIET_MS = 300000; // log-mtime fallback: quiet for 5 min => treated as finished
209
210
 
211
+ function getProfileHomeDir() {
212
+ return process.env.AGY_TEST_PROFILE_DIR || os.homedir();
213
+ }
214
+
210
215
  function getBrainDir() {
211
- return process.env.AGY_BRAIN_DIR || path.join(os.homedir(), ".gemini", "antigravity", "brain");
216
+ return process.env.AGY_BRAIN_DIR || path.join(getProfileHomeDir(), ".gemini", "antigravity", "brain");
212
217
  }
213
218
 
214
219
  /** Reads the last `bytes` of a file. Returns { text, whole } or null. */
@@ -280,19 +285,54 @@ function getTaskState(match, convId) {
280
285
  return "UNKNOWN";
281
286
  }
282
287
 
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);
288
+ function isCompatibleRtkVersion(versionStr) {
289
+ if (!versionStr || typeof versionStr !== "string") return false;
290
+ const match = versionStr.match(/(\d+)\.(\d+)\.(\d+)/);
291
+ if (!match) return false;
292
+ const major = parseInt(match[1], 10);
293
+ const minor = parseInt(match[2], 10);
294
+ return major === 0 && (minor === 50 || minor === 51);
295
+ }
296
+
297
+ function getLocalRtkCandidate() {
298
+ const names = os.platform() === "win32" ? ["rtk.exe", "rtk.cmd"] : ["rtk"];
299
+ const hookDir = path.dirname(fileURLToPath(import.meta.url));
300
+ for (const exeName of names) {
301
+ const pluginBin = path.join(getProfileHomeDir(), ".gemini", "config", "plugins", "agy-context-saver", "bin", exeName);
302
+ if (fs.existsSync(pluginBin)) return pluginBin;
303
+ }
304
+ for (const exeName of names) {
305
+ const repoBin = path.resolve(hookDir, "..", "bin", exeName);
306
+ if (fs.existsSync(repoBin)) return repoBin;
307
+ }
308
+ return "";
309
+ }
310
+
311
+ function findFirstRtkOnPath() {
312
+ const pathEnv = process.env.PATH || process.env.Path || "";
313
+ const dirs = pathEnv.split(path.delimiter).filter(Boolean);
314
+ const exts = os.platform() === "win32" ? [".exe", ".cmd", ".bat"] : [""];
315
+ for (const dir of dirs) {
316
+ for (const ext of exts) {
317
+ const candidate = path.join(dir, `rtk${ext}`);
318
+ try {
319
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
320
+ return candidate;
321
+ }
322
+ } catch {}
323
+ }
294
324
  }
295
- return res;
325
+ return "";
326
+ }
327
+
328
+ function spawnRtk(bin, args, opts) {
329
+ if (!bin) return { error: Object.assign(new Error("ENOENT"), { code: "ENOENT" }), status: null, stdout: "", stderr: "" };
330
+ if (os.platform() === "win32" && /\.(cmd|bat)$/i.test(bin)) {
331
+ const quotedBin = /\s/.test(bin) ? `"${bin}"` : bin;
332
+ const quotedArgs = args.map((a) => (/\s|["&|<>^]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a)).join(" ");
333
+ return spawnSync("cmd.exe", ["/d", "/s", "/c", `"${quotedBin} ${quotedArgs}"`], { ...opts, windowsVerbatimArguments: true });
334
+ }
335
+ return spawnSync(bin, args, opts);
296
336
  }
297
337
 
298
338
  const NON_REWRITABLE_BINARIES = new Set([
@@ -332,18 +372,77 @@ function loadRewriteCache() {
332
372
  return inMemoryRewriteCache;
333
373
  }
334
374
 
375
+ function resolveCompatibleRtk() {
376
+ const localCandidate = getLocalRtkCandidate();
377
+ const pathCandidate = findFirstRtkOnPath();
378
+ const envSig = `${pathCandidate}::${localCandidate}`;
379
+ const cache = loadRewriteCache();
380
+ if (cache.__envSig === envSig && cache.__rtkResolved !== undefined) {
381
+ return cache.__rtkResolved;
382
+ }
383
+
384
+ for (const k of Object.keys(cache)) delete cache[k];
385
+
386
+ const opts = { encoding: "utf-8", timeout: 2000, windowsHide: true };
387
+ let resolved = null;
388
+
389
+ const pathRes = pathCandidate ? spawnRtk(pathCandidate, ["--version"], opts) : { status: 1, stdout: "" };
390
+ const pathCompatible = !pathRes.error && pathRes.status === 0 && isCompatibleRtkVersion(pathRes.stdout);
391
+
392
+ if (pathCompatible) {
393
+ resolved = { exe: pathCandidate, prefix: "rtk" };
394
+ } else if (localCandidate) {
395
+ const localRes = spawnRtk(localCandidate, ["--version"], opts);
396
+ const localCompatible = !localRes.error && localRes.status === 0 && isCompatibleRtkVersion(localRes.stdout);
397
+ if (localCompatible) {
398
+ const normalizedLocal = localCandidate.replace(/\\/g, "/");
399
+ const prefix = /\s/.test(normalizedLocal) ? `"${normalizedLocal}"` : normalizedLocal;
400
+ resolved = { exe: localCandidate, prefix };
401
+ }
402
+ }
403
+
404
+ cache.__envSig = envSig;
405
+ cache.__rtkResolved = resolved;
406
+ try {
407
+ const tmp = `${REWRITE_CACHE_FILE}.${process.pid}.tmp`;
408
+ fs.writeFileSync(tmp, JSON.stringify(cache), "utf-8");
409
+ try {
410
+ fs.renameSync(tmp, REWRITE_CACHE_FILE);
411
+ } catch {
412
+ fs.writeFileSync(REWRITE_CACHE_FILE, JSON.stringify(cache), "utf-8");
413
+ try { fs.unlinkSync(tmp); } catch {}
414
+ }
415
+ } catch {}
416
+
417
+ return resolved;
418
+ }
419
+
420
+ function runRtkRewrite(cmd, resolvedRtk) {
421
+ const rtk = resolvedRtk || resolveCompatibleRtk();
422
+ if (!rtk) return { status: 1, stdout: "", stderr: "No compatible RTK binary available" };
423
+ const opts = { encoding: "utf-8", timeout: 2000, windowsHide: true };
424
+ return spawnRtk(rtk.exe, ["rewrite", cmd], opts);
425
+ }
426
+
335
427
  /**
336
428
  * Persists one rewrite result. Re-reads the file first so concurrent hook processes merge instead
337
429
  * of overwriting each other, trims to the newest entries, and writes atomically (temp + rename).
338
430
  */
339
431
  function rememberRewrite(cmd, rewritten) {
340
- loadRewriteCache()[cmd] = rewritten;
432
+ const cache = loadRewriteCache();
433
+ cache[cmd] = rewritten;
341
434
  try {
342
435
  const disk = readRewriteCacheFile();
436
+ const envSig = cache.__envSig ?? disk.__envSig;
437
+ const rtkResolved = cache.__rtkResolved ?? disk.__rtkResolved;
438
+ delete disk.__envSig;
439
+ delete disk.__rtkResolved;
343
440
  delete disk[cmd];
344
441
  disk[cmd] = rewritten;
345
442
  const keys = Object.keys(disk);
346
443
  for (const k of keys.slice(0, Math.max(0, keys.length - REWRITE_CACHE_MAX))) delete disk[k];
444
+ if (envSig !== undefined) disk.__envSig = envSig;
445
+ if (rtkResolved !== undefined) disk.__rtkResolved = rtkResolved;
347
446
  const tmp = `${REWRITE_CACHE_FILE}.${process.pid}.tmp`;
348
447
  fs.writeFileSync(tmp, JSON.stringify(disk), "utf-8");
349
448
  try {
@@ -364,7 +463,11 @@ function rewriteCommandWithRtk(rawCmd) {
364
463
  if (!trimmed) return rawCmd;
365
464
 
366
465
  // Already prefixed with rtk
367
- if (/^rtk(?:\.exe)?\s+/i.test(trimmed)) {
466
+ if (/^rtk(?:\.exe)?(?=\s|$)/i.test(trimmed)) {
467
+ const rtk = resolveCompatibleRtk();
468
+ if (rtk && rtk.prefix !== "rtk") {
469
+ return trimmed.replace(/^rtk(?:\.exe)?(?=\s|$)/i, rtk.prefix);
470
+ }
368
471
  return trimmed;
369
472
  }
370
473
 
@@ -382,7 +485,7 @@ function rewriteCommandWithRtk(rawCmd) {
382
485
  return trimmed;
383
486
  }
384
487
 
385
- // Fast-path bypass for shell builtins, non-rewritable binaries, and powershell cmdlets
488
+ // Fast-path bypass for shell builtins, non-rewritable binaries, and powershell cmdlets
386
489
  const firstWord = (trimmed.match(/^[^\s"']+/)?.[0] || "").toLowerCase();
387
490
  if (
388
491
  NON_REWRITABLE_BINARIES.has(firstWord) ||
@@ -397,6 +500,9 @@ function rewriteCommandWithRtk(rawCmd) {
397
500
  return trimmed;
398
501
  }
399
502
 
503
+ const rtk = resolveCompatibleRtk();
504
+ if (!rtk) return trimmed;
505
+
400
506
  // Check persistent cache
401
507
  const cache = loadRewriteCache();
402
508
  if (cache[trimmed] !== undefined) {
@@ -406,7 +512,7 @@ function rewriteCommandWithRtk(rawCmd) {
406
512
  let rewritten = trimmed;
407
513
  let cacheable = false;
408
514
  try {
409
- const res = runRtkRewrite(trimmed);
515
+ const res = runRtkRewrite(trimmed, rtk);
410
516
  // Never cache timeouts, spawn errors or signals: a transient failure must not pin a command
411
517
  // to "no rewrite" for the lifetime of the cache.
412
518
  cacheable = !res.error && !res.signal && typeof res.status === "number" && res.status >= 0 && res.status <= 3;
@@ -414,6 +520,9 @@ function rewriteCommandWithRtk(rawCmd) {
414
520
  // RTK exits 0 or 3 when rewritten, with new command on stdout
415
521
  if ((res.status === 0 || res.status === 3) && res.stdout && res.stdout.trim()) {
416
522
  rewritten = res.stdout.trim();
523
+ if (rtk.prefix !== "rtk") {
524
+ rewritten = rewritten.replace(/^rtk(?:\.exe)?(?=\s|$)/i, rtk.prefix);
525
+ }
417
526
  }
418
527
  } catch {}
419
528
 
@@ -542,7 +651,7 @@ try {
542
651
 
543
652
  const rewritten = rewriteCommandWithRtk(rawCmd);
544
653
  const effectiveCmd = rewritten;
545
- const isRtkRead = /^rtk(?:\.exe)?\s+read\b/i.test(effectiveCmd);
654
+ const isRtkRead = /^(?:["']?[^\s"']*[/\\])?rtk(?:\.exe|\.cmd|\.bat)?["']?\s+read\b/i.test(effectiveCmd);
546
655
 
547
656
  // Check 3: Step Output Spillover Safe Harbor (rtk read only; it clamps + windows output)
548
657
  if (isSpillover && !isRtkRead) {
@@ -655,14 +764,57 @@ try {
655
764
  respond({ decision: "allow" });
656
765
  }
657
766
 
658
- // 2c. Hard-Route Workspace Code/Data Inspection to Canonical RTK Commands (LH-07)
659
- if (toolName === "view_file" || toolName === "read_file") {
767
+ // 2e. Bounded Slice Safe Harbor for view_file
768
+ // Allow view_file IF AND ONLY IF it is a bounded slice inspection (StartLine and EndLine <= 150 lines)
769
+ if (toolName === "view_file") {
770
+ const rawStart = args.StartLine !== undefined ? args.StartLine : args.start_line;
771
+ const rawEnd = args.EndLine !== undefined ? args.EndLine : args.end_line;
772
+ const hasSliceParams = typeof rawStart !== "undefined" || typeof rawEnd !== "undefined";
773
+
774
+ if (hasSliceParams) {
775
+ const startLine = Number(rawStart);
776
+ const endLine = Number(rawEnd);
777
+
778
+ if (
779
+ typeof rawStart !== "undefined" &&
780
+ typeof rawEnd !== "undefined" &&
781
+ Number.isInteger(startLine) &&
782
+ Number.isInteger(endLine) &&
783
+ startLine > 0 &&
784
+ endLine >= startLine
785
+ ) {
786
+ const span = endLine - startLine + 1;
787
+ const MAX_SAFE_SLICE_LINES = 150;
788
+ if (span <= MAX_SAFE_SLICE_LINES) {
789
+ respond({ decision: "allow" });
790
+ }
791
+ respond({
792
+ decision: "deny",
793
+ reason: `Antigravity Governance: Oversized file slice via 'view_file' (${span} lines > ${MAX_SAFE_SLICE_LINES} lines max safe window). Reduce line range or run 'rtk read ${target || "<file>"}' via run_command instead.`
794
+ });
795
+ }
796
+
797
+ respond({
798
+ decision: "deny",
799
+ reason: `Antigravity Governance: Invalid slice range via 'view_file'. Ensure StartLine and EndLine are positive integers with EndLine >= StartLine (max 150 lines), or run 'rtk read ${target || "<file>"}' via run_command instead.`
800
+ });
801
+ }
802
+
803
+ // Default when no slice attempted: keep hook identical to original behavior (zero slice advertisement)
660
804
  respond({
661
805
  decision: "deny",
662
806
  reason: `Antigravity Governance: Native file inspection via '${toolName}' bypasses token optimization. Run 'rtk read ${target || "<file>"}' via run_command instead.`
663
807
  });
664
808
  }
665
809
 
810
+ // 2f. Hard-Route Full-File / Other Workspace Code Inspection to Canonical RTK Commands (LH-07)
811
+ if (toolName === "read_file") {
812
+ respond({
813
+ decision: "deny",
814
+ reason: `Antigravity Governance: Native file inspection via 'read_file' bypasses token optimization. Run 'rtk read ${target || "<file>"}' via run_command instead.`
815
+ });
816
+ }
817
+
666
818
  if (toolName === "read_many_files") {
667
819
  respond({
668
820
  decision: "deny",
@@ -706,8 +858,8 @@ try {
706
858
  if (toolName === "manage_task") {
707
859
  const action = String(args.Action || args.action || "").replace(/^["']|["']$/g, "").trim().toLowerCase();
708
860
 
709
- // kill, send_input are always allowed immediately
710
- if (action !== "status" && action !== "list") {
861
+ // kill, send_input, and list are always allowed immediately
862
+ if (action !== "status") {
711
863
  respond({ decision: "allow" });
712
864
  }
713
865