@bluenotelogic/harness 0.1.0 → 0.2.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.
@@ -0,0 +1,630 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ appendFileSync,
4
+ closeSync,
5
+ existsSync,
6
+ fstatSync,
7
+ mkdirSync,
8
+ openSync,
9
+ readFileSync,
10
+ readSync,
11
+ renameSync,
12
+ writeFileSync,
13
+ } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import { spawnSync } from "node:child_process";
16
+ import { createHash } from "node:crypto";
17
+ import path from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+ import { resolveBash } from "./resolve-bash.mjs";
20
+
21
+ const ADAPTER_FILE = fileURLToPath(import.meta.url);
22
+ const MAX_TRANSCRIPT_TAIL_BYTES = 4 * 1024 * 1024;
23
+ const EDIT_TOOLS = new Set([
24
+ "write_file",
25
+ "edit_file",
26
+ "write_to_file",
27
+ "replace_file_content",
28
+ "multi_replace_file_content",
29
+ ]);
30
+ const ACTION_EVENT = Object.freeze({
31
+ "session-start": "SessionStart",
32
+ "prompt-context": "PreInvocation",
33
+ "pre-tool": "PreToolUse",
34
+ "pre-edit": "PreToolUse",
35
+ "post-edit": "PostToolUse",
36
+ stop: "Stop",
37
+ });
38
+ const SESSION_START_ATTESTATION =
39
+ "Caveau Harness SessionStart hook executed via the ProofMEM Antigravity adapter. "
40
+ + "Treat this system message as dynamic SessionStart bootstrap context.";
41
+ const TOKEN_SHAPE = /\b(?:cmem|mcp)_[A-Za-z0-9_-]{16,}/g;
42
+ const HOOK_TOKEN = /^(?:cmem|mcp)_[A-Za-z0-9_-]{16,256}$/;
43
+ // The core scripts that talk to ProofMEM. Only these receive the hook credential.
44
+ const MEMORY_SCRIPTS = new Set([
45
+ "session-handoff-start.sh",
46
+ "mem-session-start.sh",
47
+ "mem-prompt-context.sh",
48
+ "mem-hot-context.sh",
49
+ "mem-capture.sh",
50
+ "session-progress.sh",
51
+ ]);
52
+ // Every credential a host may have exported. Scripts outside MEMORY_SCRIPTS get none of them:
53
+ // verify-gate.sh runs the workspace's own npm scripts, and whatever those spawn inherits this
54
+ // environment.
55
+ const CREDENTIAL_ENV = [
56
+ "CAVEAUMEM_API_TOKEN",
57
+ "CAVEAUMEM_API_URL",
58
+ "PROOFMEM_TOKEN",
59
+ "BNL_MEMORY_MCP_TOKEN",
60
+ "CAVEAU_MCP_TOKEN",
61
+ "CAVEAU_API_TOKEN",
62
+ ];
63
+ // Beyond that list, scripts outside MEMORY_SCRIPTS lose every variable whose NAME reads as a
64
+ // credential: split on anything but a letter or digit, a segment that is KEY(S), PAT, PWD or PASS,
65
+ // or ends in TOKEN, SECRET, PASSWORD, PASSWD, PASSPHRASE, CREDENTIAL, BEARER, APIKEY or JWT
66
+ // (optionally followed by KEY, plural allowed). That catches GITHUB_TOKEN, AWS_SECRET_ACCESS_KEY,
67
+ // OPENAI_API_KEY, NPM_TOKEN, PGPASSWORD, GITEA_PAT, DB_PWD, SSH_PASSPHRASE, CI_JOB_JWT and
68
+ // SECRETKEY, and leaves TOKENIZERS_PARALLELISM, MONKEY_PATCH and COMPAT_MODE alone. The README
69
+ // lists exactly these, so change both together.
70
+ const CREDENTIAL_NAME_SEGMENT =
71
+ /^(?:[A-Z0-9]*(?:TOKEN|SECRET|PASSWORD|PASSWD|PASSPHRASE|CREDENTIAL|BEARER|APIKEY|JWT)(?:KEY)?S?|KEYS?|PAT|PWD|PASS)$/;
72
+ // A whole AUTH segment is a credential too (npm's NPM_CONFIG__AUTH, DOCKER_AUTH_CONFIG, an
73
+ // SSH_AUTH_SOCK agent), except in the Harness's own HARNESS_* names, where it is a mode setting
74
+ // (HARNESS_MEMORY_AUTH=auto|env|proxy|none, HARNESS_AUTH_MODE) that the core reads.
75
+ const AUTH_SEGMENT = "AUTH";
76
+ // What a script needs to run at all is never stripped, whatever the spelling (Windows says Path).
77
+ // PWD is the shell's working directory, which verify-gate.sh reads; it only looks like DB_PWD.
78
+ const PRESERVED_ENV = new Set(["PATH", "HOME", "USERPROFILE", "SYSTEMROOT", "TEMP", "TMP", "COMSPEC", "PWD"]);
79
+ // Credential BRIDGES: no secret in the value, but a child that inherits one can still use the
80
+ // user's Git or SSH credentials. GIT_ASKPASS and SSH_ASKPASS name the program Git and ssh ask for
81
+ // a password; in a VS Code-family editor such as Antigravity that program asks the editor, over
82
+ // VSCODE_GIT_IPC_HANDLE, which can answer with the signed-in account's token and show no prompt.
83
+ // SSH_AUTH_SOCK reaches the running ssh-agent's keys, GIT_SSH_COMMAND and GIT_SSH choose the ssh
84
+ // program (and with it a key file), and GIT_CONFIG_PARAMETERS or GIT_CONFIG_COUNT with
85
+ // GIT_CONFIG_KEY_<n>/GIT_CONFIG_VALUE_<n> inject Git config, a credential.helper included. Scripts
86
+ // outside MEMORY_SCRIPTS lose all of them; session-handoff-start.sh keeps them for its Git
87
+ // refresh. The README lists exactly these, so change both together.
88
+ const CREDENTIAL_BRIDGE_ENV = new Set([
89
+ "GIT_ASKPASS",
90
+ "SSH_ASKPASS",
91
+ "SSH_ASKPASS_REQUIRE",
92
+ "SSH_AUTH_SOCK",
93
+ "GIT_SSH_COMMAND",
94
+ "GIT_SSH",
95
+ "GIT_CONFIG_PARAMETERS",
96
+ "GIT_CONFIG_COUNT",
97
+ ]);
98
+ const CREDENTIAL_BRIDGE_PREFIX = /^(?:VSCODE_GIT_|GIT_CONFIG_KEY_|GIT_CONFIG_VALUE_)/;
99
+ const RENEW_HINT = "Renew it by re-running `npx @bluenotelogic/harness init --antigravity --harness-home "
100
+ + "<your caveau-harness-v2 path>`; do not export a token in the environment instead.";
101
+ const EXPIRY_WARNING_MS = 14 * 24 * 60 * 60 * 1000;
102
+
103
+ function redactCredentials(text) {
104
+ return String(text).replace(TOKEN_SHAPE, "[redacted credential]");
105
+ }
106
+
107
+ function cleanMessage(value, fallback) {
108
+ const text = redactCredentials(String(value || fallback || "").replace(/[\u0000-\u001f]+/g, " ")).trim();
109
+ return text.slice(0, 1400) || String(fallback || "Hook failed.");
110
+ }
111
+
112
+ export function isHarnessV2Home(candidate) {
113
+ if (!candidate) return false;
114
+ try {
115
+ return readFileSync(path.join(candidate, ".harness-generation"), "utf8").trim() === "caveau-harness-v2";
116
+ } catch {
117
+ return false;
118
+ }
119
+ }
120
+
121
+ export function readPluginConfig(adapterFile = ADAPTER_FILE) {
122
+ const pluginRoot = path.resolve(path.dirname(adapterFile), "..");
123
+ try {
124
+ const config = JSON.parse(readFileSync(path.join(pluginRoot, "harness-home.json"), "utf8"));
125
+ return config && typeof config === "object" && !Array.isArray(config) ? config : {};
126
+ } catch {
127
+ return {};
128
+ }
129
+ }
130
+
131
+ function ancestorCandidates(adapterFile) {
132
+ const candidates = [];
133
+ let current = path.dirname(adapterFile);
134
+ for (let depth = 0; depth < 6; depth += 1) {
135
+ candidates.push(current);
136
+ const parent = path.dirname(current);
137
+ if (parent === current) break;
138
+ current = parent;
139
+ }
140
+ return candidates;
141
+ }
142
+
143
+ export function resolveHarnessHome(env = process.env, adapterFile = ADAPTER_FILE) {
144
+ if (typeof env.HARNESS_HOME === "string" && env.HARNESS_HOME.trim()) {
145
+ const explicit = path.resolve(env.HARNESS_HOME.trim());
146
+ return isHarnessV2Home(explicit) ? explicit : null;
147
+ }
148
+
149
+ const candidates = [...ancestorCandidates(adapterFile)];
150
+ const config = readPluginConfig(adapterFile);
151
+ if (typeof config.harnessHome === "string" && config.harnessHome.trim()) {
152
+ candidates.unshift(path.resolve(config.harnessHome.trim()));
153
+ }
154
+
155
+ candidates.push(path.join(homedir(), ".caveau", "harness"), path.join(homedir(), "caveau-harness-v2"));
156
+ return candidates.find(isHarnessV2Home) || null;
157
+ }
158
+
159
+ function workspacePath(payload) {
160
+ const candidate = payload?.toolCall?.args?.Cwd
161
+ || payload?.toolCall?.args?.cwd
162
+ || payload?.workspacePaths?.[0]
163
+ || process.cwd();
164
+ if (typeof candidate !== "string" || !candidate.trim()) return process.cwd();
165
+ if (!candidate.startsWith("file:")) return candidate;
166
+ try {
167
+ return fileURLToPath(candidate);
168
+ } catch {
169
+ return candidate.replace(/^file:\/\/?/, "");
170
+ }
171
+ }
172
+
173
+ function promptText(content) {
174
+ if (typeof content === "string") return content;
175
+ if (Array.isArray(content)) {
176
+ return content.map((item) => {
177
+ if (typeof item === "string") return item;
178
+ return typeof item?.text === "string" ? item.text : "";
179
+ }).filter(Boolean).join("\n");
180
+ }
181
+ if (content && typeof content === "object") {
182
+ if (typeof content.text === "string") return content.text;
183
+ if (typeof content.content === "string") return content.content;
184
+ }
185
+ return "";
186
+ }
187
+
188
+ export function latestUserInput(transcriptPath) {
189
+ if (typeof transcriptPath !== "string" || !transcriptPath || !existsSync(transcriptPath)) return null;
190
+ let fd;
191
+ try {
192
+ fd = openSync(transcriptPath, "r");
193
+ const size = fstatSync(fd).size;
194
+ const length = Math.min(size, MAX_TRANSCRIPT_TAIL_BYTES);
195
+ const buffer = Buffer.alloc(length);
196
+ readSync(fd, buffer, 0, length, size - length);
197
+ let text = buffer.toString("utf8");
198
+ if (size > length) text = text.slice(text.indexOf("\n") + 1);
199
+ const lines = text.split(/\r?\n/);
200
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
201
+ if (!lines[index].trim()) continue;
202
+ let row;
203
+ try {
204
+ row = JSON.parse(lines[index]);
205
+ } catch {
206
+ continue;
207
+ }
208
+ if (row?.type !== "USER_INPUT") continue;
209
+ const prompt = promptText(row.content).trim();
210
+ if (!prompt) continue;
211
+ const digest = createHash("sha256").update(prompt).digest("hex");
212
+ return { key: `${row.step_index ?? "unknown"}:${digest}`, prompt: prompt.slice(0, 24_000) };
213
+ }
214
+ } catch {
215
+ return null;
216
+ } finally {
217
+ if (typeof fd === "number") closeSync(fd);
218
+ }
219
+ return null;
220
+ }
221
+
222
+ function statePath(payload) {
223
+ const root = payload?.artifactDirectoryPath;
224
+ return typeof root === "string" && root.trim()
225
+ ? path.join(root, ".caveau-harness-antigravity.json")
226
+ : null;
227
+ }
228
+
229
+ function readState(payload) {
230
+ const file = statePath(payload);
231
+ if (!file) return {};
232
+ try {
233
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
234
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
235
+ } catch {
236
+ return {};
237
+ }
238
+ }
239
+
240
+ function writeState(payload, state) {
241
+ const file = statePath(payload);
242
+ if (!file) return;
243
+ try {
244
+ mkdirSync(path.dirname(file), { recursive: true });
245
+ const temp = `${file}.${process.pid}.tmp`;
246
+ writeFileSync(temp, `${JSON.stringify(state, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
247
+ renameSync(temp, file);
248
+ } catch {
249
+ // State is advisory: hooks remain fail-open when the artifact directory is unavailable.
250
+ }
251
+ }
252
+
253
+ function recordAdapterEvent(payload, message) {
254
+ const root = payload?.artifactDirectoryPath;
255
+ if (typeof root !== "string" || !root.trim()) return;
256
+ try {
257
+ mkdirSync(root, { recursive: true });
258
+ appendFileSync(
259
+ path.join(root, ".caveau-harness-antigravity.log"),
260
+ `${new Date().toISOString()} ${message}\n`,
261
+ { encoding: "utf8", mode: 0o600 },
262
+ );
263
+ } catch {
264
+ // Lifecycle evidence is advisory and contains no prompt or credential content.
265
+ }
266
+ }
267
+
268
+ function parseCoreJson(stdout) {
269
+ try {
270
+ const parsed = JSON.parse(stdout);
271
+ return parsed && typeof parsed === "object" ? parsed : null;
272
+ } catch {
273
+ return null;
274
+ }
275
+ }
276
+
277
+ function contextFrom(result) {
278
+ const parsed = parseCoreJson(result.stdout);
279
+ const value = parsed?.hookSpecificOutput?.additionalContext;
280
+ if (typeof value === "string" && value.trim()) return redactCredentials(value.trim());
281
+ return redactCredentials(result.stdout.trim());
282
+ }
283
+
284
+ /** True when a memory script reported that ProofMEM refused the hook credential. */
285
+ function credentialRejected(...results) {
286
+ return results.some((result) => /answered 401\b/.test(`${result?.stdout || ""}\n${result?.stderr || ""}`));
287
+ }
288
+
289
+ /** A SessionStart warning when the installer-recorded expiry is past or under 14 days away. */
290
+ export function hookCredentialExpiryNotice(pluginConfig, now = Date.now()) {
291
+ const raw = typeof pluginConfig?.hookTokenExpiresAt === "string" ? pluginConfig.hookTokenExpiresAt : "";
292
+ const at = Date.parse(raw);
293
+ if (!raw || Number.isNaN(at)) return "";
294
+ if (at <= now) return `The ProofMEM hook credential expired at ${raw}. ${RENEW_HINT}`;
295
+ if (at - now <= EXPIRY_WARNING_MS) return `The ProofMEM hook credential expires at ${raw}. ${RENEW_HINT}`;
296
+ return "";
297
+ }
298
+
299
+ /**
300
+ * The ProofMEM credential and API URL for memory scripts, always as a PAIR from one source. A
301
+ * complete pair exported in the environment is a deliberate override; otherwise the protected
302
+ * hook-token file travels with the plugin's own URL. Mixing sources could send this credential
303
+ * to whatever CAVEAUMEM_API_URL happened to be exported, or write into another tenant.
304
+ */
305
+ export function hookCredential(env, pluginConfig) {
306
+ if (env.CAVEAUMEM_API_TOKEN && env.CAVEAUMEM_API_URL) {
307
+ return { token: env.CAVEAUMEM_API_TOKEN, url: env.CAVEAUMEM_API_URL.replace(/\/$/, "") };
308
+ }
309
+ const file = typeof pluginConfig?.hookTokenFile === "string" ? pluginConfig.hookTokenFile.trim() : "";
310
+ const url = typeof pluginConfig?.memoryApiUrl === "string" ? pluginConfig.memoryApiUrl.trim().replace(/\/$/, "") : "";
311
+ if (!file || !url) return null;
312
+ try {
313
+ const token = readFileSync(file, "utf8").trim();
314
+ return HOOK_TOKEN.test(token) ? { token, url } : null;
315
+ } catch {
316
+ return null;
317
+ }
318
+ }
319
+
320
+ /** True when an environment variable's NAME looks like it holds a credential. */
321
+ export function isCredentialEnvName(name) {
322
+ const upper = String(name).toUpperCase();
323
+ if (PRESERVED_ENV.has(upper)) return false;
324
+ const segments = upper.split(/[^A-Z0-9]+/);
325
+ if (segments.some((segment) => CREDENTIAL_NAME_SEGMENT.test(segment))) return true;
326
+ return !upper.startsWith("HARNESS_") && segments.includes(AUTH_SEGMENT);
327
+ }
328
+
329
+ /** True when a variable lets a child use the user's Git or SSH credentials (CREDENTIAL_BRIDGE_ENV). */
330
+ export function isCredentialBridgeEnvName(name) {
331
+ const upper = String(name).toUpperCase();
332
+ return CREDENTIAL_BRIDGE_ENV.has(upper) || CREDENTIAL_BRIDGE_PREFIX.test(upper);
333
+ }
334
+
335
+ /** A copy of `env` without any credential-shaped variable or credential bridge. */
336
+ export function withoutCredentialEnv(env) {
337
+ return Object.fromEntries(
338
+ Object.entries(env).filter(([name]) => !isCredentialEnvName(name) && !isCredentialBridgeEnvName(name)),
339
+ );
340
+ }
341
+
342
+ /** The environment one core script runs with. Pure apart from reading the hook-token file. */
343
+ export function scriptEnv(script, env, pluginConfig, home) {
344
+ const memoryScript = MEMORY_SCRIPTS.has(script);
345
+ const childEnv = {
346
+ // The guard, checkpoint, lint and verify scripts get no credential-shaped variable and no
347
+ // credential bridge at all: verify-gate.sh runs the workspace's own npm scripts, which would
348
+ // otherwise inherit the editor's cloud, registry and Git tokens, or its way of asking for them.
349
+ ...(memoryScript ? env : withoutCredentialEnv(env)),
350
+ HARNESS_HOME: home,
351
+ HARNESS_AGENT_HOST: "antigravity",
352
+ CAVEAU_HARNESS_PLUGIN_ROOT: path.resolve(path.dirname(ADAPTER_FILE), ".."),
353
+ };
354
+ for (const name of CREDENTIAL_ENV) delete childEnv[name];
355
+ // The harness core also reads a token from whichever variable HARNESS_MEMORY_TOKEN_VAR names.
356
+ const named = env.HARNESS_MEMORY_TOKEN_VAR;
357
+ if (typeof named === "string" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(named)) delete childEnv[named];
358
+ // Antigravity keeps its OAuth bearer inside the editor, so memory scripts use the separate,
359
+ // bounded hook credential. Only its value enters the environment; it is never logged.
360
+ if (memoryScript) {
361
+ const credential = hookCredential(env, pluginConfig);
362
+ if (credential) {
363
+ childEnv.CAVEAUMEM_API_TOKEN = credential.token;
364
+ childEnv.CAVEAUMEM_API_URL = credential.url;
365
+ // This is a ProofMEM credential, so the core must talk to ProofMEM. An exported
366
+ // HARNESS_MEMORY_PROVIDER or HARNESS_COORDINATION_STORE (caveauai is the CaveauAI setting)
367
+ // would otherwise route this session's memory to a store the credential does not belong to.
368
+ childEnv.HARNESS_MEMORY_PROVIDER = "caveaumem";
369
+ childEnv.HARNESS_COORDINATION_STORE = "caveaumem";
370
+ }
371
+ }
372
+ return withAntigravityScriptDefaults(script, childEnv, pluginConfig);
373
+ }
374
+
375
+ function corePayload(action, payload, prompt = "") {
376
+ const args = payload?.toolCall?.args || {};
377
+ const toolName = payload?.toolCall?.name || "";
378
+ const cwd = workspacePath(payload);
379
+ const toolInput = {};
380
+ if (toolName === "run_command") toolInput.command = args.CommandLine || args.command || "";
381
+ if (EDIT_TOOLS.has(toolName)) toolInput.file_path = args.Path || args.TargetFile || args.file_path || "";
382
+ return {
383
+ session_id: payload?.conversationId || "unknown",
384
+ transcript_path: payload?.transcriptPath || "",
385
+ cwd,
386
+ source: action === "session-start" ? "startup" : undefined,
387
+ hook_event_name: ACTION_EVENT[action],
388
+ prompt,
389
+ tool_name: toolName,
390
+ tool_input: toolInput,
391
+ reason: payload?.terminationReason || "",
392
+ stop_hook_active: Number(payload?.executionNum || 0) > 0,
393
+ };
394
+ }
395
+
396
+ export function runCoreScript({ home, script, payload, timeoutMs, env = process.env, pluginConfig = readPluginConfig() }) {
397
+ if (!home) return { status: 0, stdout: "", stderr: "", missing: true };
398
+ const scriptPath = path.join(home, "scripts", script);
399
+ if (!existsSync(scriptPath)) return { status: 0, stdout: "", stderr: "", missing: true };
400
+ const cwd = typeof payload.cwd === "string" && existsSync(payload.cwd) ? payload.cwd : process.cwd();
401
+ // A missing or unreadable credential leaves memory scripts unauthenticated; the core then emits
402
+ // its own fail-open diagnostic.
403
+ const resolvedChildEnv = scriptEnv(script, env, pluginConfig, home);
404
+ let bashPath;
405
+ try {
406
+ bashPath = resolveBash();
407
+ } catch (error) {
408
+ return { status: 0, stdout: "", stderr: error.message, missing: true };
409
+ }
410
+ const result = spawnSync(bashPath, [scriptPath], {
411
+ cwd,
412
+ env: resolvedChildEnv,
413
+ input: JSON.stringify(payload),
414
+ encoding: "utf8",
415
+ timeout: timeoutMs,
416
+ windowsHide: true,
417
+ maxBuffer: 1024 * 1024,
418
+ });
419
+ return {
420
+ status: typeof result.status === "number" ? result.status : 1,
421
+ stdout: result.stdout || "",
422
+ stderr: result.stderr || "",
423
+ missing: false,
424
+ error: result.error,
425
+ };
426
+ }
427
+
428
+ export function withAntigravityScriptDefaults(script, env, pluginConfig = {}) {
429
+ const childEnv = { ...env };
430
+ // verify-gate.sh runs the open workspace's own build/test/lint scripts on every idle Stop,
431
+ // outside Antigravity's terminal approval, so opening an untrusted repo would run its code.
432
+ // Off unless the user opted in (`init --antigravity --verify-gate`); an exported
433
+ // HARNESS_VERIFY_GATE still wins, and a repo's harness.tenant cannot override a set value.
434
+ // An empty or blank export counts as unset: verify-gate.sh reads `${HARNESS_VERIFY_GATE:-1}`,
435
+ // so passing "" through switched the gate ON, and harness.tenant treats "" as unset too.
436
+ const gate = childEnv.HARNESS_VERIFY_GATE;
437
+ if (script === "verify-gate.sh" && (typeof gate !== "string" || gate.trim() === "")) {
438
+ childEnv.HARNESS_VERIFY_GATE = pluginConfig?.verifyGate === true ? "1" : "0";
439
+ }
440
+ if (!childEnv.HARNESS_MEMORY_PROVIDER) childEnv.HARNESS_MEMORY_PROVIDER = "caveaumem";
441
+ if (!childEnv.HARNESS_COORDINATION_STORE) childEnv.HARNESS_COORDINATION_STORE = "caveaumem";
442
+ if (!childEnv.HARNESS_CAVEAUAI_CONNECTOR) childEnv.HARNESS_CAVEAUAI_CONNECTOR = "disabled";
443
+ // session-handoff-start.sh permits its Git refresh to run for 8 seconds by default. Keep the
444
+ // editor adapter's refresh below its own subprocess deadline and the enclosing
445
+ // 40-second SessionStart handler while still refreshing the forge ref when it responds quickly.
446
+ if (script === "session-handoff-start.sh" && !childEnv.HARNESS_STALE_BRANCH_FETCH_SECS) {
447
+ childEnv.HARNESS_STALE_BRANCH_FETCH_SECS = "5";
448
+ }
449
+ // The canonical ProofMEM hooks speak its REST API. Native editor OAuth remains confined to the
450
+ // MCP connection and is never copied into this subprocess.
451
+ if (!childEnv.HARNESS_MEM_TRANSPORT) childEnv.HARNESS_MEM_TRANSPORT = "rest";
452
+ return childEnv;
453
+ }
454
+
455
+ function injectedSystemMessage(text) {
456
+ return {
457
+ // Keep this identical to Antigravity's documented SessionStart shape.
458
+ // Its Windows protobuf scrubber currently panics on string-valued metadata maps.
459
+ injectSteps: [{ systemMessage: { systemMessage: text } }],
460
+ };
461
+ }
462
+
463
+ function injectedEphemeralMessage(text) {
464
+ return { injectSteps: [{ ephemeralMessage: text }] };
465
+ }
466
+
467
+ function failOpenOutput(action, message) {
468
+ const reason = cleanMessage(message, "Caveau Harness hook skipped.");
469
+ if (action === "pre-tool" || action === "pre-edit") return { decision: "allow", reason };
470
+ if (action === "stop") return { decision: "allow", reason };
471
+ if (action === "post-edit") return {};
472
+ if (action === "session-start") return injectedSystemMessage(reason);
473
+ return injectedEphemeralMessage(reason);
474
+ }
475
+
476
+ export async function handleHook(action, payload, options = {}) {
477
+ const env = options.env || process.env;
478
+ const runner = options.runner || ((args) => runCoreScript({ ...args, env }));
479
+ if (!Object.hasOwn(ACTION_EVENT, action)) return failOpenOutput(action, `Unknown Caveau Harness hook action: ${action}`);
480
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
481
+ return failOpenOutput(action, "Caveau Harness hook received malformed input and failed open.");
482
+ }
483
+ const home = resolveHarnessHome(env, options.adapterFile || ADAPTER_FILE);
484
+ const pluginConfig = options.pluginConfig || readPluginConfig(options.adapterFile || ADAPTER_FILE);
485
+ const run = (script, currentPayload, timeoutMs = 12_000) =>
486
+ runner({ home, script, payload: currentPayload, timeoutMs, env, pluginConfig });
487
+ const rejectedNotice = `ProofMEM rejected the Antigravity hook credential (expired or revoked). ${RENEW_HINT}`;
488
+ const missing = () => failOpenOutput(action, "Caveau Harness v2 core is unavailable; set HARNESS_HOME and run the doctor. Hook failed open.");
489
+
490
+ if (action === "session-start") {
491
+ const translated = corePayload(action, payload);
492
+ const handoff = run("session-handoff-start.sh", translated, 8_000);
493
+ const memory = run("mem-session-start.sh", translated, 30_000);
494
+ if (handoff.missing && memory.missing) return missing();
495
+ const contexts = [contextFrom(handoff), contextFrom(memory)].filter(Boolean);
496
+ const warnings = [handoff, memory]
497
+ .filter((item) => !item.missing && item.status !== 0)
498
+ .map((item) => cleanMessage(item.stderr, "bootstrap helper unavailable"));
499
+ if (warnings.length) contexts.push(`Caveau Harness bootstrap partially failed open: ${warnings.join("; ")}`);
500
+ if (credentialRejected(handoff, memory)) contexts.push(rejectedNotice);
501
+ else {
502
+ const expiry = hookCredentialExpiryNotice(pluginConfig);
503
+ if (expiry) contexts.push(expiry);
504
+ }
505
+ if (!contexts.length) {
506
+ contexts.push("The Harness core returned no handoff or memory content; continuing fail-open with an empty bootstrap.");
507
+ }
508
+ const context = [SESSION_START_ATTESTATION, ...contexts].join("\n\n").slice(0, 24_000);
509
+ recordAdapterEvent(
510
+ payload,
511
+ `session-start handoff=${handoff.status} memory=${memory.status} context_chars=${context.length} inject_steps=1`,
512
+ );
513
+ return injectedSystemMessage(context);
514
+ }
515
+
516
+ if (action === "prompt-context") {
517
+ const state = readState(payload);
518
+ const contexts = [];
519
+ if (typeof state.pendingContext === "string" && state.pendingContext.trim()) {
520
+ contexts.push(state.pendingContext.trim());
521
+ delete state.pendingContext;
522
+ }
523
+ const latest = latestUserInput(payload.transcriptPath);
524
+ if (latest && state.lastPromptKey !== latest.key) {
525
+ state.lastPromptKey = latest.key;
526
+ const result = run("mem-prompt-context.sh", corePayload(action, payload, latest.prompt), 25_000);
527
+ recordAdapterEvent(payload, `prompt-context status=${result.status} key=${latest.key.split(":", 1)[0]}`);
528
+ if (credentialRejected(result)) {
529
+ contexts.push(rejectedNotice);
530
+ } else if (!result.missing && result.status === 0) {
531
+ const context = contextFrom(result);
532
+ if (context) contexts.push(context.slice(0, 12_000));
533
+ } else if (result.missing) {
534
+ contexts.push("Caveau Harness v2 core is unavailable; set HARNESS_HOME and run the doctor.");
535
+ } else {
536
+ contexts.push(`Focused memory context failed open: ${cleanMessage(result.stderr, "provider unavailable")}`);
537
+ }
538
+ }
539
+ writeState(payload, state);
540
+ return contexts.length ? injectedEphemeralMessage(contexts.join("\n\n").slice(0, 14_000)) : {};
541
+ }
542
+
543
+ if (action === "pre-tool") {
544
+ const result = run("pre-bash-guard.sh", corePayload(action, payload), 12_000);
545
+ recordAdapterEvent(payload, `pre-tool status=${result.status}`);
546
+ if (result.missing) return missing();
547
+ if (result.status === 2) {
548
+ return { decision: "deny", reason: cleanMessage(result.stderr, "Command blocked by Caveau Harness policy.") };
549
+ }
550
+ if (result.status !== 0) {
551
+ return { decision: "allow", reason: `Destructive-command guard failed open: ${cleanMessage(result.stderr, "guard error")}` };
552
+ }
553
+ return { decision: "allow" };
554
+ }
555
+
556
+ if (action === "pre-edit") {
557
+ const result = run("checkpoint-snapshot.sh", corePayload(action, payload), 12_000);
558
+ recordAdapterEvent(payload, `pre-edit status=${result.status}`);
559
+ if (result.missing) return missing();
560
+ return result.status === 0
561
+ ? { decision: "allow" }
562
+ : { decision: "allow", reason: `Pre-edit checkpoint failed open: ${cleanMessage(result.stderr, "checkpoint error")}` };
563
+ }
564
+
565
+ if (action === "post-edit") {
566
+ const translated = corePayload(action, payload);
567
+ const lint = run("post-edit-lint.sh", translated, 45_000);
568
+ const hot = run("mem-hot-context.sh", translated, 4_000);
569
+ recordAdapterEvent(payload, `post-edit lint=${lint.status} hot=${hot.status}`);
570
+ if (lint.status === 2) {
571
+ const state = readState(payload);
572
+ state.pendingContext = cleanMessage(lint.stderr, "Edited-file verification failed.");
573
+ writeState(payload, state);
574
+ }
575
+ return {};
576
+ }
577
+
578
+ if (payload.fullyIdle === false) {
579
+ recordAdapterEvent(payload, "stop skipped: background work remains");
580
+ return { decision: "allow" };
581
+ }
582
+ const translated = corePayload(action, payload);
583
+ const progress = run("session-progress.sh", translated, 12_000);
584
+ const verify = run("verify-gate.sh", translated, 170_000);
585
+ const capture = run("mem-capture.sh", translated, 25_000);
586
+ recordAdapterEvent(payload, `stop progress=${progress.status} verify=${verify.status} capture=${capture.status}`);
587
+ if (verify.missing) return missing();
588
+ if (verify.status === 2) {
589
+ const reason = cleanMessage(verify.stderr, "Workspace verification failed.");
590
+ if (translated.stop_hook_active) {
591
+ return { decision: "allow", reason: `${reason} Continuation already ran; allowing stop to avoid a loop.` };
592
+ }
593
+ return { decision: "continue", reason };
594
+ }
595
+ if (verify.status !== 0) {
596
+ return { decision: "allow", reason: `Verification helper failed open: ${cleanMessage(verify.stderr, "verification error")}` };
597
+ }
598
+ return { decision: "allow" };
599
+ }
600
+
601
+ async function main() {
602
+ const action = process.argv[2] || "";
603
+ if (action === "probe-home") {
604
+ const home = resolveHarnessHome(process.env);
605
+ if (home) process.stdout.write(`${home}\n`);
606
+ else process.stderr.write("Caveau Harness v2 core cannot be resolved from this Antigravity plugin runtime.\n");
607
+ process.exitCode = home ? 0 : 2;
608
+ return;
609
+ }
610
+ let raw = "";
611
+ process.stdin.setEncoding("utf8");
612
+ for await (const chunk of process.stdin) raw += chunk;
613
+ let payload;
614
+ try {
615
+ payload = JSON.parse(raw || "{}");
616
+ } catch {
617
+ payload = null;
618
+ }
619
+ try {
620
+ const output = await handleHook(action, payload);
621
+ process.stdout.write(`${JSON.stringify(output || {})}\n`);
622
+ } catch (error) {
623
+ recordAdapterEvent(payload, `adapter exception: ${cleanMessage(error?.message, "unknown")}`);
624
+ process.stdout.write(`${JSON.stringify(failOpenOutput(action, `Caveau Harness adapter failed open: ${cleanMessage(error?.message, "unknown error")}`))}\n`);
625
+ }
626
+ }
627
+
628
+ if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(ADAPTER_FILE)) {
629
+ await main();
630
+ }
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
3
+ import { existsSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ export const WSL_REFUSAL =
8
+ "Git Bash was not found. Install Git for Windows; this launcher deliberately does not use WSL bash.";
9
+
10
+ export function gitBashCandidates(env = process.env) {
11
+ const programFiles = env.ProgramFiles || "C:\\Program Files";
12
+ const programFilesX86 = env["ProgramFiles(x86)"] || "C:\\Program Files (x86)";
13
+ const candidates = [
14
+ path.join(programFiles, "Git", "bin", "bash.exe"),
15
+ path.join(programFiles, "Git", "usr", "bin", "bash.exe"),
16
+ path.join(programFilesX86, "Git", "bin", "bash.exe"),
17
+ ];
18
+ // Git for Windows' per-user ("only for me") installer location. Without it every hook silently
19
+ // failed open on such machines.
20
+ if (env.LOCALAPPDATA) candidates.push(path.join(env.LOCALAPPDATA, "Programs", "Git", "bin", "bash.exe"));
21
+ return candidates;
22
+ }
23
+
24
+ export function resolveBash({ platform = process.platform, env = process.env, exists = existsSync } = {}) {
25
+ if (platform !== "win32") return "bash";
26
+ for (const candidate of gitBashCandidates(env)) {
27
+ if (exists(candidate)) return candidate;
28
+ }
29
+ throw new Error(WSL_REFUSAL);
30
+ }
31
+
32
+ export function runBash(args, options = {}) {
33
+ return spawnSync(resolveBash(), args, { stdio: "inherit", windowsHide: true, ...options });
34
+ }
35
+
36
+ if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))) {
37
+ try {
38
+ const result = runBash(process.argv.slice(2));
39
+ if (result.error) {
40
+ console.error(result.error.message);
41
+ process.exit(1);
42
+ }
43
+ process.exit(typeof result.status === "number" ? result.status : 1);
44
+ } catch (error) {
45
+ console.error(error.message);
46
+ process.exit(1);
47
+ }
48
+ }