agy-context-saver 1.4.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/CHANGELOG.md +61 -0
- package/README.md +48 -4
- package/assets/logo.svg +51 -0
- package/hooks.json +1 -1
- package/install.ps1 +86 -42
- package/mcp/index.js +323 -101
- package/package.json +18 -2
- package/plugin.json +9 -2
- package/rules/AGENTS.md +9 -4
- package/scripts/execution-guard-hook.mjs +347 -280
- package/scripts/install-register.mjs +178 -64
- package/scripts/provision-rtk.mjs +67 -14
- package/scripts/release-preflight.mjs +147 -0
- package/skills/context-governance/SKILL.md +26 -50
|
@@ -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");
|
|
@@ -66,13 +67,11 @@ function getStateFilePath(convId = "global") {
|
|
|
66
67
|
function getSessionState(convId = "global") {
|
|
67
68
|
const file = getStateFilePath(convId);
|
|
68
69
|
try {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
return entry;
|
|
75
|
-
}
|
|
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;
|
|
76
75
|
}
|
|
77
76
|
} catch {}
|
|
78
77
|
return { pollCount: 0, denials: 0, taskIds: [], lastPollTime: 0 };
|
|
@@ -86,14 +85,12 @@ function saveSessionState(convId = "global", entry) {
|
|
|
86
85
|
let toWrite;
|
|
87
86
|
if (!convId || convId === "global") {
|
|
88
87
|
let state = {};
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
} catch {}
|
|
96
|
-
}
|
|
88
|
+
try {
|
|
89
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
90
|
+
if (parsed && typeof parsed === "object" && typeof parsed.pollCount !== "number") {
|
|
91
|
+
state = parsed;
|
|
92
|
+
}
|
|
93
|
+
} catch {}
|
|
97
94
|
state[convId] = entry;
|
|
98
95
|
|
|
99
96
|
// Prune stale entries
|
|
@@ -123,12 +120,7 @@ function saveSessionState(convId = "global", entry) {
|
|
|
123
120
|
*/
|
|
124
121
|
function isProtectedInternalState(targetPath) {
|
|
125
122
|
if (!targetPath || typeof targetPath !== "string") return false;
|
|
126
|
-
|
|
127
|
-
return (
|
|
128
|
-
normalized.includes("/.system_generated/") ||
|
|
129
|
-
normalized.endsWith("/.system_generated") ||
|
|
130
|
-
/\.system_generated(?:$|[\/\\])/i.test(normalized)
|
|
131
|
-
);
|
|
123
|
+
return /\.system_generated(?:$|[\/\\])/i.test(targetPath);
|
|
132
124
|
}
|
|
133
125
|
|
|
134
126
|
/**
|
|
@@ -189,6 +181,7 @@ function isProtectedBenchmarkPath(targetPath) {
|
|
|
189
181
|
*/
|
|
190
182
|
function isStepSpilloverPath(targetPath) {
|
|
191
183
|
if (!targetPath || typeof targetPath !== "string") return false;
|
|
184
|
+
if (!isProtectedInternalState(targetPath)) return false; // cheap precheck
|
|
192
185
|
const normalized = targetPath.replace(/\\/g, "/");
|
|
193
186
|
return (
|
|
194
187
|
/\.system_generated[/\\]steps[/\\]\d+[/\\]output\.txt/i.test(normalized) ||
|
|
@@ -197,180 +190,149 @@ function isStepSpilloverPath(targetPath) {
|
|
|
197
190
|
}
|
|
198
191
|
|
|
199
192
|
/**
|
|
200
|
-
* Extracts task
|
|
193
|
+
* Extracts the task-log reference from a target path or command, or null.
|
|
194
|
+
* Returns { id, token }: `id` is the bare task id; `token` is the matched path text
|
|
195
|
+
* (relative "tasks/task-1.log" or absolute ".../.system_generated/tasks/task-1.log").
|
|
201
196
|
*/
|
|
202
|
-
function getTaskLogMatch(
|
|
203
|
-
if (!
|
|
204
|
-
const normalized =
|
|
205
|
-
|
|
206
|
-
|
|
197
|
+
function getTaskLogMatch(text) {
|
|
198
|
+
if (!text || typeof text !== "string" || !text.includes(".log")) return null;
|
|
199
|
+
const normalized = text.replace(/\\/g, "/");
|
|
200
|
+
if (!normalized.includes("tasks/")) return null;
|
|
201
|
+
const m = normalized.match(/(?:^|[\s"'])((?:[^\s"']*\/)?tasks\/(?:task-)?([a-zA-Z0-9_-]+)\.log)/i);
|
|
202
|
+
if (!m) return null;
|
|
203
|
+
let token = m[1].replace(/^file:\/\//i, "");
|
|
204
|
+
if (/^\/[A-Za-z]:/.test(token)) token = token.slice(1);
|
|
205
|
+
return { id: m[2], token };
|
|
207
206
|
}
|
|
208
207
|
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
}
|
|
208
|
+
const TASK_TAIL_BYTES = 262144; // transcript tail scanned first (256 KB)
|
|
209
|
+
const TASK_QUIET_MS = 300000; // log-mtime fallback: quiet for 5 min => treated as finished
|
|
223
210
|
|
|
224
|
-
|
|
225
|
-
|
|
211
|
+
function getProfileHomeDir() {
|
|
212
|
+
return process.env.AGY_TEST_PROFILE_DIR || os.homedir();
|
|
213
|
+
}
|
|
226
214
|
|
|
227
|
-
|
|
228
|
-
|
|
215
|
+
function getBrainDir() {
|
|
216
|
+
return process.env.AGY_BRAIN_DIR || path.join(getProfileHomeDir(), ".gemini", "antigravity", "brain");
|
|
217
|
+
}
|
|
229
218
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
219
|
+
/** Reads the last `bytes` of a file. Returns { text, whole } or null. */
|
|
220
|
+
function readTail(file, bytes) {
|
|
221
|
+
let fd;
|
|
222
|
+
try {
|
|
223
|
+
fd = fs.openSync(file, "r");
|
|
224
|
+
const size = fs.fstatSync(fd).size;
|
|
225
|
+
const len = Math.min(size, bytes);
|
|
226
|
+
const buf = Buffer.alloc(len);
|
|
227
|
+
fs.readSync(fd, buf, 0, len, size - len);
|
|
228
|
+
return { text: buf.toString("utf-8"), whole: len === size };
|
|
229
|
+
} catch {
|
|
230
|
+
return null;
|
|
231
|
+
} finally {
|
|
232
|
+
if (fd !== undefined) { try { fs.closeSync(fd); } catch {} }
|
|
235
233
|
}
|
|
234
|
+
}
|
|
236
235
|
|
|
237
|
-
|
|
236
|
+
/**
|
|
237
|
+
* Looks for the finish / start notices of `taskName` in transcript text using literal needles
|
|
238
|
+
* (JSON-escaped and raw variants). Finish: `<conv>/task-N" finished with result`.
|
|
239
|
+
* Start: `<conv>/task-N` followed by a newline and "Task Description" or "Task logs".
|
|
240
|
+
* Returns "COMPLETED", "RUNNING" or null.
|
|
241
|
+
*/
|
|
242
|
+
function scanTaskMarkers(text, taskName) {
|
|
243
|
+
if (text.includes(`/${taskName}\\" finished`) || text.includes(`/${taskName}" finished`)) return "COMPLETED";
|
|
244
|
+
if (text.includes(`/${taskName}\\nTask `) || text.includes(`/${taskName}\nTask `)) return "RUNNING";
|
|
245
|
+
return null;
|
|
238
246
|
}
|
|
239
247
|
|
|
240
248
|
/**
|
|
241
|
-
*
|
|
249
|
+
* Resolves a background task's lifecycle state: "RUNNING", "COMPLETED" or "UNKNOWN".
|
|
250
|
+
* Authority order: transcript notices (tail first, full file only if needed), then log mtime.
|
|
242
251
|
*/
|
|
243
|
-
function
|
|
244
|
-
const
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
}
|
|
259
|
-
} catch {}
|
|
252
|
+
function getTaskState(match, convId) {
|
|
253
|
+
const taskName = `task-${match.id}`;
|
|
254
|
+
const token = match.token;
|
|
255
|
+
let logPath = null;
|
|
256
|
+
let transcript = null;
|
|
257
|
+
|
|
258
|
+
if (/^(?:[A-Za-z]:)?\//.test(token)) {
|
|
259
|
+
logPath = token;
|
|
260
|
+
const sysDir = path.posix.dirname(path.posix.dirname(token));
|
|
261
|
+
if (path.posix.basename(sysDir) === ".system_generated") transcript = `${sysDir}/logs/transcript.jsonl`;
|
|
262
|
+
}
|
|
263
|
+
if (convId && convId !== "global") {
|
|
264
|
+
const sysDir = path.join(getBrainDir(), convId, ".system_generated");
|
|
265
|
+
logPath = logPath || path.join(sysDir, "tasks", `${taskName}.log`);
|
|
266
|
+
transcript = transcript || path.join(sysDir, "logs", "transcript.jsonl");
|
|
260
267
|
}
|
|
261
268
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
if (taskRegex.test(content)) return true;
|
|
271
|
-
} catch {}
|
|
269
|
+
if (transcript) {
|
|
270
|
+
const tail = readTail(transcript, TASK_TAIL_BYTES);
|
|
271
|
+
if (tail) {
|
|
272
|
+
let state = scanTaskMarkers(tail.text, taskName);
|
|
273
|
+
if (!state && !tail.whole) {
|
|
274
|
+
try { state = scanTaskMarkers(fs.readFileSync(transcript, "utf-8"), taskName); } catch {}
|
|
275
|
+
}
|
|
276
|
+
if (state) return state;
|
|
272
277
|
}
|
|
273
278
|
}
|
|
274
279
|
|
|
275
|
-
|
|
276
|
-
if (logPath && fs.existsSync(logPath)) {
|
|
277
|
-
if (cleanId.includes("live") || cleanId.includes("running")) {
|
|
278
|
-
return false;
|
|
279
|
-
}
|
|
280
|
+
if (logPath) {
|
|
280
281
|
try {
|
|
281
|
-
|
|
282
|
-
const mtimeAgeMs = Date.now() - stats.mtimeMs;
|
|
283
|
-
if (mtimeAgeMs > 15000) {
|
|
284
|
-
return true;
|
|
285
|
-
}
|
|
282
|
+
return (Date.now() - fs.statSync(logPath).mtimeMs) > TASK_QUIET_MS ? "COMPLETED" : "RUNNING";
|
|
286
283
|
} catch {}
|
|
287
284
|
}
|
|
285
|
+
return "UNKNOWN";
|
|
286
|
+
}
|
|
288
287
|
|
|
289
|
-
|
|
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);
|
|
290
295
|
}
|
|
291
296
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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 {}
|
|
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;
|
|
311
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
|
+
}
|
|
312
310
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
318
|
try {
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
if (taskRegex.test(content)) {
|
|
322
|
-
return false; // completed!
|
|
319
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
|
|
320
|
+
return candidate;
|
|
323
321
|
}
|
|
324
322
|
} catch {}
|
|
325
323
|
}
|
|
326
324
|
}
|
|
327
|
-
|
|
328
|
-
// 3. File modification age check
|
|
329
|
-
if (logPath && fs.existsSync(logPath)) {
|
|
330
|
-
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!
|
|
337
|
-
} catch {}
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
// 4. Test environment / naming convention
|
|
341
|
-
if (cleanId.includes("live") || cleanId.includes("running")) {
|
|
342
|
-
return true;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
return false;
|
|
325
|
+
return "";
|
|
346
326
|
}
|
|
347
327
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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;
|
|
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 });
|
|
370
334
|
}
|
|
371
|
-
|
|
372
|
-
resolvedRtkPath = "rtk";
|
|
373
|
-
return resolvedRtkPath;
|
|
335
|
+
return spawnSync(bin, args, opts);
|
|
374
336
|
}
|
|
375
337
|
|
|
376
338
|
const NON_REWRITABLE_BINARIES = new Set([
|
|
@@ -397,31 +359,98 @@ const REWRITE_CACHE_FILE = path.join(os.tmpdir(), "agy-rtk-rewrite-cache.json");
|
|
|
397
359
|
const REWRITE_CACHE_MAX = 100;
|
|
398
360
|
let inMemoryRewriteCache = null;
|
|
399
361
|
|
|
362
|
+
function readRewriteCacheFile() {
|
|
363
|
+
try {
|
|
364
|
+
const data = JSON.parse(fs.readFileSync(REWRITE_CACHE_FILE, "utf-8"));
|
|
365
|
+
if (data && typeof data === "object" && !Array.isArray(data)) return data;
|
|
366
|
+
} catch {}
|
|
367
|
+
return {};
|
|
368
|
+
}
|
|
369
|
+
|
|
400
370
|
function loadRewriteCache() {
|
|
401
|
-
if (inMemoryRewriteCache)
|
|
371
|
+
if (!inMemoryRewriteCache) inMemoryRewriteCache = readRewriteCacheFile();
|
|
372
|
+
return inMemoryRewriteCache;
|
|
373
|
+
}
|
|
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;
|
|
402
406
|
try {
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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 {}
|
|
409
414
|
}
|
|
410
415
|
} catch {}
|
|
411
|
-
|
|
412
|
-
return
|
|
416
|
+
|
|
417
|
+
return resolved;
|
|
413
418
|
}
|
|
414
419
|
|
|
415
|
-
function
|
|
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
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Persists one rewrite result. Re-reads the file first so concurrent hook processes merge instead
|
|
429
|
+
* of overwriting each other, trims to the newest entries, and writes atomically (temp + rename).
|
|
430
|
+
*/
|
|
431
|
+
function rememberRewrite(cmd, rewritten) {
|
|
432
|
+
const cache = loadRewriteCache();
|
|
433
|
+
cache[cmd] = rewritten;
|
|
416
434
|
try {
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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;
|
|
440
|
+
delete disk[cmd];
|
|
441
|
+
disk[cmd] = rewritten;
|
|
442
|
+
const keys = Object.keys(disk);
|
|
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;
|
|
446
|
+
const tmp = `${REWRITE_CACHE_FILE}.${process.pid}.tmp`;
|
|
447
|
+
fs.writeFileSync(tmp, JSON.stringify(disk), "utf-8");
|
|
448
|
+
try {
|
|
449
|
+
fs.renameSync(tmp, REWRITE_CACHE_FILE);
|
|
450
|
+
} catch {
|
|
451
|
+
fs.writeFileSync(REWRITE_CACHE_FILE, JSON.stringify(disk), "utf-8");
|
|
452
|
+
try { fs.unlinkSync(tmp); } catch {}
|
|
453
|
+
}
|
|
425
454
|
} catch {}
|
|
426
455
|
}
|
|
427
456
|
|
|
@@ -434,7 +463,11 @@ function rewriteCommandWithRtk(rawCmd) {
|
|
|
434
463
|
if (!trimmed) return rawCmd;
|
|
435
464
|
|
|
436
465
|
// Already prefixed with rtk
|
|
437
|
-
if (/^rtk(?:\.exe)
|
|
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
|
+
}
|
|
438
471
|
return trimmed;
|
|
439
472
|
}
|
|
440
473
|
|
|
@@ -452,7 +485,7 @@ function rewriteCommandWithRtk(rawCmd) {
|
|
|
452
485
|
return trimmed;
|
|
453
486
|
}
|
|
454
487
|
|
|
455
|
-
|
|
488
|
+
// Fast-path bypass for shell builtins, non-rewritable binaries, and powershell cmdlets
|
|
456
489
|
const firstWord = (trimmed.match(/^[^\s"']+/)?.[0] || "").toLowerCase();
|
|
457
490
|
if (
|
|
458
491
|
NON_REWRITABLE_BINARIES.has(firstWord) ||
|
|
@@ -467,6 +500,9 @@ function rewriteCommandWithRtk(rawCmd) {
|
|
|
467
500
|
return trimmed;
|
|
468
501
|
}
|
|
469
502
|
|
|
503
|
+
const rtk = resolveCompatibleRtk();
|
|
504
|
+
if (!rtk) return trimmed;
|
|
505
|
+
|
|
470
506
|
// Check persistent cache
|
|
471
507
|
const cache = loadRewriteCache();
|
|
472
508
|
if (cache[trimmed] !== undefined) {
|
|
@@ -474,22 +510,23 @@ function rewriteCommandWithRtk(rawCmd) {
|
|
|
474
510
|
}
|
|
475
511
|
|
|
476
512
|
let rewritten = trimmed;
|
|
513
|
+
let cacheable = false;
|
|
477
514
|
try {
|
|
478
|
-
const
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
windowsHide: true
|
|
483
|
-
});
|
|
515
|
+
const res = runRtkRewrite(trimmed, rtk);
|
|
516
|
+
// Never cache timeouts, spawn errors or signals: a transient failure must not pin a command
|
|
517
|
+
// to "no rewrite" for the lifetime of the cache.
|
|
518
|
+
cacheable = !res.error && !res.signal && typeof res.status === "number" && res.status >= 0 && res.status <= 3;
|
|
484
519
|
|
|
485
520
|
// RTK exits 0 or 3 when rewritten, with new command on stdout
|
|
486
521
|
if ((res.status === 0 || res.status === 3) && res.stdout && res.stdout.trim()) {
|
|
487
522
|
rewritten = res.stdout.trim();
|
|
523
|
+
if (rtk.prefix !== "rtk") {
|
|
524
|
+
rewritten = rewritten.replace(/^rtk(?:\.exe)?(?=\s|$)/i, rtk.prefix);
|
|
525
|
+
}
|
|
488
526
|
}
|
|
489
527
|
} catch {}
|
|
490
528
|
|
|
491
|
-
|
|
492
|
-
saveRewriteCache(cache);
|
|
529
|
+
if (cacheable) rememberRewrite(trimmed, rewritten);
|
|
493
530
|
return rewritten;
|
|
494
531
|
}
|
|
495
532
|
|
|
@@ -504,6 +541,34 @@ function getRequiredBackoffSeconds(pollCount) {
|
|
|
504
541
|
return Math.min(600, Math.round(30 * Math.pow(2.5, Math.max(0, pollCount - 1))));
|
|
505
542
|
}
|
|
506
543
|
|
|
544
|
+
/**
|
|
545
|
+
* Denies reading the log of an actively RUNNING task: records the denial, trips the circuit
|
|
546
|
+
* breaker at 5 cumulative denials, otherwise answers with the Proportional Backoff message.
|
|
547
|
+
*/
|
|
548
|
+
function denyRunningTask(taskId, convId) {
|
|
549
|
+
const session = getSessionState(convId);
|
|
550
|
+
session.denials++;
|
|
551
|
+
saveSessionState(convId, session);
|
|
552
|
+
|
|
553
|
+
if (session.denials >= 5) {
|
|
554
|
+
respond({
|
|
555
|
+
decision: "force_ask",
|
|
556
|
+
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.`
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const requiredBackoffSec = Math.max(30, getRequiredBackoffSeconds(session.pollCount));
|
|
561
|
+
const requiredBackoffMs = requiredBackoffSec * 1000;
|
|
562
|
+
const elapsedMs = session.lastPollTime ? (Date.now() - session.lastPollTime) : Infinity;
|
|
563
|
+
const elapsedSec = Math.floor(elapsedMs / 1000);
|
|
564
|
+
const remainingSec = Math.max(1, Math.ceil((requiredBackoffMs - elapsedMs) / 1000));
|
|
565
|
+
|
|
566
|
+
respond({
|
|
567
|
+
decision: "deny",
|
|
568
|
+
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.`
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
|
|
507
572
|
// --- Main Hook Entrypoint ---
|
|
508
573
|
let isGovernedTool = false;
|
|
509
574
|
|
|
@@ -563,71 +628,49 @@ try {
|
|
|
563
628
|
});
|
|
564
629
|
}
|
|
565
630
|
|
|
566
|
-
|
|
567
|
-
|
|
631
|
+
// Raw-command governance gates are pure string/fs checks: run them BEFORE the rtk rewrite
|
|
632
|
+
// spawn so denied commands never pay for a subprocess.
|
|
633
|
+
const isSpillover = isStepSpilloverPath(rawCmd);
|
|
634
|
+
const taskMatch = isSpillover ? null : getTaskLogMatch(rawCmd);
|
|
568
635
|
|
|
569
|
-
// Check 1:
|
|
570
|
-
if (
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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
|
-
}
|
|
636
|
+
// Check 1: Other protected internal state (transcripts, scheduler, progress)
|
|
637
|
+
if (!isSpillover && !taskMatch && isProtectedInternalState(rawCmd)) {
|
|
638
|
+
respond({
|
|
639
|
+
decision: "deny",
|
|
640
|
+
reason:
|
|
641
|
+
"Antigravity Execution Governance: Direct shell access to internal Antigravity execution state (.system_generated) is strictly prohibited to prevent transcript bloat and polling loops.\n" +
|
|
642
|
+
"- To inspect conversation transcripts: Call MCP tool read_transcript(conversationId=\"...\", mode=\"compact\") or query_transcript(...).\n" +
|
|
643
|
+
"- To inspect background tasks: Yield execution turn and rely on native Reactive Wakeup (<SYSTEM_MESSAGE>). Do not read internal task logs."
|
|
644
|
+
});
|
|
645
|
+
}
|
|
596
646
|
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
const remainingSec = Math.max(1, Math.ceil((requiredBackoffMs - elapsedMs) / 1000));
|
|
647
|
+
// Check 2: Task Log Inspection (Lifecycle-Gated, 0 New Tools): RUNNING => Proportional Backoff
|
|
648
|
+
if (taskMatch && getTaskState(taskMatch, convId) === "RUNNING") {
|
|
649
|
+
denyRunningTask(taskMatch.id, convId);
|
|
650
|
+
}
|
|
602
651
|
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
});
|
|
607
|
-
}
|
|
652
|
+
const rewritten = rewriteCommandWithRtk(rawCmd);
|
|
653
|
+
const effectiveCmd = rewritten;
|
|
654
|
+
const isRtkRead = /^(?:["']?[^\s"']*[/\\])?rtk(?:\.exe|\.cmd|\.bat)?["']?\s+read\b/i.test(effectiveCmd);
|
|
608
655
|
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
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
|
-
}
|
|
656
|
+
// Check 3: Step Output Spillover Safe Harbor (rtk read only; it clamps + windows output)
|
|
657
|
+
if (isSpillover && !isRtkRead) {
|
|
658
|
+
respond({
|
|
659
|
+
decision: "deny",
|
|
660
|
+
reason:
|
|
661
|
+
"Antigravity Execution Governance: Runtime step output spillover files must be inspected using 'rtk read <path>' via run_command to prevent context blowouts."
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Check 4: Terminated / unknown-state task log: allow only through rtk read
|
|
666
|
+
if (taskMatch && !isRtkRead) {
|
|
667
|
+
respond({
|
|
668
|
+
decision: "deny",
|
|
669
|
+
reason:
|
|
670
|
+
"Antigravity Execution Governance: Direct shell access to internal Antigravity execution state (.system_generated) is strictly prohibited to prevent transcript bloat and polling loops.\n" +
|
|
671
|
+
`- Once completed, inspect output using 'rtk read <path>' via run_command.\n` +
|
|
672
|
+
"- If actively running, yield execution turn and rely on native Reactive Wakeup (<SYSTEM_MESSAGE>)."
|
|
673
|
+
});
|
|
631
674
|
}
|
|
632
675
|
|
|
633
676
|
const isDaemon = args.IsDaemon === true || args.isDaemon === true;
|
|
@@ -682,33 +725,14 @@ try {
|
|
|
682
725
|
}
|
|
683
726
|
|
|
684
727
|
// 2b. Task Log Inspection (Lifecycle-Gated)
|
|
685
|
-
const
|
|
686
|
-
if (
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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
|
-
});
|
|
728
|
+
const taskMatch = getTaskLogMatch(target);
|
|
729
|
+
if (taskMatch) {
|
|
730
|
+
const state = getTaskState(taskMatch, convId);
|
|
731
|
+
if (state === "RUNNING") {
|
|
732
|
+
denyRunningTask(taskMatch.id, convId);
|
|
709
733
|
}
|
|
710
734
|
|
|
711
|
-
if (
|
|
735
|
+
if (state === "COMPLETED") {
|
|
712
736
|
// If terminated / completed, route to rtk read:
|
|
713
737
|
respond({
|
|
714
738
|
decision: "deny",
|
|
@@ -740,14 +764,57 @@ try {
|
|
|
740
764
|
respond({ decision: "allow" });
|
|
741
765
|
}
|
|
742
766
|
|
|
743
|
-
//
|
|
744
|
-
|
|
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)
|
|
745
804
|
respond({
|
|
746
805
|
decision: "deny",
|
|
747
806
|
reason: `Antigravity Governance: Native file inspection via '${toolName}' bypasses token optimization. Run 'rtk read ${target || "<file>"}' via run_command instead.`
|
|
748
807
|
});
|
|
749
808
|
}
|
|
750
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
|
+
|
|
751
818
|
if (toolName === "read_many_files") {
|
|
752
819
|
respond({
|
|
753
820
|
decision: "deny",
|
|
@@ -791,8 +858,8 @@ try {
|
|
|
791
858
|
if (toolName === "manage_task") {
|
|
792
859
|
const action = String(args.Action || args.action || "").replace(/^["']|["']$/g, "").trim().toLowerCase();
|
|
793
860
|
|
|
794
|
-
// kill, send_input are always allowed immediately
|
|
795
|
-
if (action !== "status"
|
|
861
|
+
// kill, send_input, and list are always allowed immediately
|
|
862
|
+
if (action !== "status") {
|
|
796
863
|
respond({ decision: "allow" });
|
|
797
864
|
}
|
|
798
865
|
|