replume-cli 0.1.0 → 0.1.2

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/dist/cli.js CHANGED
@@ -6,7 +6,7 @@ import { Command, Option } from "commander";
6
6
  // package.json
7
7
  var package_default = {
8
8
  name: "replume-cli",
9
- version: "0.1.0",
9
+ version: "0.1.2",
10
10
  type: "module",
11
11
  bin: {
12
12
  replume: "dist/cli.js"
@@ -48,9 +48,9 @@ var package_default = {
48
48
  };
49
49
 
50
50
  // src/cli.ts
51
- import { execFileSync as execFileSync10 } from "child_process";
52
- import { existsSync as existsSync18, lstatSync as lstatSync3, readFileSync as readFileSync17 } from "fs";
53
- import { join as join23, resolve as resolve6 } from "path";
51
+ import { execFileSync as execFileSync11 } from "child_process";
52
+ import { existsSync as existsSync20, lstatSync as lstatSync3, readFileSync as readFileSync19 } from "fs";
53
+ import { join as join25, resolve as resolve8 } from "path";
54
54
 
55
55
  // src/hooks/session-start.ts
56
56
  import { execFileSync as execFileSync2 } from "child_process";
@@ -342,20 +342,21 @@ function markPathsOutsideRepo(text, repoRoot2) {
342
342
  const path = (value) => {
343
343
  const rel = relative(repoRoot2, value);
344
344
  if (!rel) return "[REPO_ROOT]";
345
+ if (/^\/dev\/[\w./-]+$/.test(value)) return value;
345
346
  return rel !== ".." && !rel.startsWith("../") && !isAbsolute(rel) ? rel.replace(/\\/g, "/") : "[OUTSIDE_REPO]";
346
347
  };
347
- return text.replace(/(^|[\s"'=(,:])(\/(?!\/)[^\s"'`,;)]+)/g, (_m, prefix, absolute) => `${prefix}${path(absolute)}`);
348
+ return text.replace(/(^|[\s"'=(,:])(\/(?!\/)[^\s"'`,;)(]+)(?![^\s"'`,;)])/g, (_m, prefix, absolute) => `${prefix}${path(absolute)}`);
348
349
  }
349
350
  function redactLedgerText(text, repoRoot2) {
350
351
  const out = redact(text).replace(SECRET_ENV_ANYCASE, `$1${REDACTED}`).replace(URL_USER, `$1${REDACTED}:${REDACTED}@`);
351
352
  return markPathsOutsideRepo(out, repoRoot2);
352
353
  }
353
354
  function redactForLedger(value, repoRoot2) {
354
- const str3 = (s) => redactLedgerText(s, repoRoot2);
355
+ const str4 = (s) => redactLedgerText(s, repoRoot2);
355
356
  const deep = (v) => {
356
- if (typeof v === "string") return str3(v);
357
+ if (typeof v === "string") return str4(v);
357
358
  if (Array.isArray(v)) return v.map(deep);
358
- if (v && typeof v === "object") return Object.fromEntries(Object.entries(v).map(([k, c]) => [str3(k), deep(c)]));
359
+ if (v && typeof v === "object") return Object.fromEntries(Object.entries(v).map(([k, c]) => [str4(k), deep(c)]));
359
360
  return v;
360
361
  };
361
362
  return deep(value);
@@ -601,7 +602,7 @@ async function runVerify(opts) {
601
602
  // src/agents/claude/handler.ts
602
603
  import { execFileSync as execFileSync5 } from "child_process";
603
604
  import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync6 } from "fs";
604
- import { join as join8 } from "path";
605
+ import { join as join10 } from "path";
605
606
  import { homedir as homedir2 } from "os";
606
607
 
607
608
  // src/core/usage-store.ts
@@ -886,7 +887,9 @@ function validateReport(x) {
886
887
  const valid = isObj(a) && isObj(t) && isObj(tokens) && [t.wall, t.active, t.humanWait, tokens.input, tokens.output, tokens.cacheRead, tokens.cacheWrite, a.apiEquivalentUsd].every(metric) && typeof tokens.partial === "boolean" && typeof a.accepted === "boolean" && typeof a.priceVersion === "string" && (a.apiEquivalentReason === void 0 || typeof a.apiEquivalentReason === "string" && a.apiEquivalentReason.length > 0 && a.apiEquivalentReason.length <= 200);
887
888
  if (!valid) p.push("metrics.accounting has invalid evidence or value");
888
889
  }
889
- const levelled = ["done", "failed", "extra", "tampering", "notChecked", "checks"].flatMap((k) => Array.isArray(x[k]) ? x[k] : []);
890
+ if (x.observedOnly !== void 0 && (!Array.isArray(x.observedOnly) || !x.observedOnly.every((e) => isObj(e) && typeof e.path === "string" && typeof e.reason === "string")))
891
+ p.push("observedOnly must be an array of {path, reason}");
892
+ const levelled = ["done", "failed", "extra", "tampering", "notChecked", "observedOnly", "checks"].flatMap((k) => Array.isArray(x[k]) ? x[k] : []);
890
893
  if (levelled.some((e) => isObj(e) && e.evidence_level !== void 0 && !EVIDENCE_LEVELS.includes(e.evidence_level)))
891
894
  p.push(`evidence_level must be one of ${EVIDENCE_LEVELS.join(", ")}`);
892
895
  return p;
@@ -1039,7 +1042,7 @@ var Parser = class {
1039
1042
  return ITEM.test(first.body) ? this.sequence(indent) : this.mapping(indent);
1040
1043
  }
1041
1044
  mapping(indent, seed) {
1042
- const obj2 = seed?.obj ?? {};
1045
+ const obj3 = seed?.obj ?? {};
1043
1046
  while (this.peek()) {
1044
1047
  const line = this.peek();
1045
1048
  if (line.indent < indent) break;
@@ -1048,20 +1051,20 @@ var Parser = class {
1048
1051
  const m = KEY.exec(line.body);
1049
1052
  if (!m) throw new Error(`line ${line.n}: expected "key: value"`);
1050
1053
  this.i++;
1051
- this.entry(obj2, m[1], m[2], line, indent);
1054
+ this.entry(obj3, m[1], m[2], line, indent);
1052
1055
  }
1053
- return obj2;
1056
+ return obj3;
1054
1057
  }
1055
- entry(obj2, key, rest, line, indent) {
1056
- if (key in obj2) throw new Error(`line ${line.n}: duplicate key ${key}`);
1058
+ entry(obj3, key, rest, line, indent) {
1059
+ if (key in obj3) throw new Error(`line ${line.n}: duplicate key ${key}`);
1057
1060
  if (rest !== void 0 && rest.trim() !== "") {
1058
- obj2[key] = parseInline(rest, `line ${line.n}`);
1061
+ obj3[key] = parseInline(rest, `line ${line.n}`);
1059
1062
  return;
1060
1063
  }
1061
1064
  const next = this.peek();
1062
- if (next && next.indent > indent) obj2[key] = this.block(next.indent);
1063
- else if (next && next.indent === indent && ITEM.test(next.body)) obj2[key] = this.sequence(indent);
1064
- else obj2[key] = null;
1065
+ if (next && next.indent > indent) obj3[key] = this.block(next.indent);
1066
+ else if (next && next.indent === indent && ITEM.test(next.body)) obj3[key] = this.sequence(indent);
1067
+ else obj3[key] = null;
1065
1068
  }
1066
1069
  sequence(indent) {
1067
1070
  const items = [];
@@ -1080,11 +1083,11 @@ var Parser = class {
1080
1083
  continue;
1081
1084
  }
1082
1085
  const column = indent + (line.body.length - body.length);
1083
- const obj2 = {};
1084
- this.entry(obj2, km[1], km[2], line, column);
1086
+ const obj3 = {};
1087
+ this.entry(obj3, km[1], km[2], line, column);
1085
1088
  const next = this.peek();
1086
- if (next && next.indent === column && !ITEM.test(next.body)) this.mapping(column, { obj: obj2 });
1087
- items.push(obj2);
1089
+ if (next && next.indent === column && !ITEM.test(next.body)) this.mapping(column, { obj: obj3 });
1090
+ items.push(obj3);
1088
1091
  }
1089
1092
  return items;
1090
1093
  }
@@ -1111,9 +1114,9 @@ function emitScalar(v) {
1111
1114
  if (typeof v === "string") return JSON.stringify(v);
1112
1115
  return String(v);
1113
1116
  }
1114
- function stringifyYaml(obj2, comments = {}) {
1117
+ function stringifyYaml(obj3, comments = {}) {
1115
1118
  const lines = [];
1116
- for (const [k, v] of Object.entries(obj2)) {
1119
+ for (const [k, v] of Object.entries(obj3)) {
1117
1120
  if (v === void 0) continue;
1118
1121
  const c = comments[k] ? ` # ${comments[k]}` : "";
1119
1122
  if (Array.isArray(v)) {
@@ -1258,7 +1261,7 @@ function buildContract(input) {
1258
1261
 
1259
1262
  // src/core/rules.ts
1260
1263
  import { createHash } from "crypto";
1261
- import { isAbsolute as isAbsolute3, relative as relative3, resolve as resolve2, sep } from "path";
1264
+ import { isAbsolute as isAbsolute3, join as join7, relative as relative3, resolve as resolve2, sep } from "path";
1262
1265
  import { homedir, tmpdir } from "os";
1263
1266
 
1264
1267
  // src/core/glob.ts
@@ -1294,16 +1297,160 @@ function matchesAny(path, patterns) {
1294
1297
  return null;
1295
1298
  }
1296
1299
 
1300
+ // src/core/capture.ts
1301
+ import { existsSync as existsSync6, readFileSync as readFileSync7 } from "fs";
1302
+ import { dirname as dirname4, join as join6 } from "path";
1303
+ var EXPECTED_TYPES = ["prompt", "tool_pre", "command", "stop"];
1304
+ var SHOWN_PATHS = 5;
1305
+ var count2 = (m, k) => {
1306
+ m[k] = (m[k] ?? 0) + 1;
1307
+ };
1308
+ var HOOK_CRASH = "hook-errors.log exists: at least one hook crashed, so some steps may not have been recorded";
1309
+ function captureCoverage(eventsFile, changedPaths, hooksConfigured = false) {
1310
+ const absence = hooksConfigured ? "Replume project hooks are configured, but no hook events were recorded; agent steps and scope were not observed" : "no Replume project hooks were found; agent steps and scope were not observed";
1311
+ const empty = (ledger, notes) => ({
1312
+ ledger,
1313
+ hooksConfigured,
1314
+ events: 0,
1315
+ sessions: 0,
1316
+ agents: [],
1317
+ byType: {},
1318
+ tools: {},
1319
+ firstTs: null,
1320
+ lastTs: null,
1321
+ badLines: 0,
1322
+ hookErrorsLogged: hookErrors(eventsFile),
1323
+ changedFiles: changedPaths.length,
1324
+ changedFilesSeen: 0,
1325
+ changedFilesLanded: 0,
1326
+ changedFilesAttemptedOnly: [],
1327
+ changedFilesNotSeen: changedPaths.slice(0, SHOWN_PATHS),
1328
+ unobservableCommands: 0,
1329
+ // a hook that crashed on every call leaves no ledger: say so here too, not only in the JSON flag
1330
+ notSeen: hookErrors(eventsFile) ? [...notes, HOOK_CRASH] : notes
1331
+ });
1332
+ if (!existsSync6(eventsFile)) {
1333
+ return empty("absent", [`no ledger file: ${absence}`]);
1334
+ }
1335
+ const text = readFileSync7(eventsFile, "utf-8");
1336
+ const { events, badLines } = parseEvents(text);
1337
+ if (!events.length) {
1338
+ return empty("empty", [
1339
+ badLines.length ? `the ledger has ${badLines.length} line(s) but none is a valid event` : `the ledger is empty: ${absence}`
1340
+ ]);
1341
+ }
1342
+ const byType = {};
1343
+ const tools = {};
1344
+ const seenPaths = /* @__PURE__ */ new Set();
1345
+ const landedPaths = /* @__PURE__ */ new Set();
1346
+ const blockedPaths = /* @__PURE__ */ new Set();
1347
+ for (const e of events) {
1348
+ count2(byType, e.type);
1349
+ if (e.type === "tool_pre" && e.tool) count2(tools, e.tool);
1350
+ const blocked = e.action === "block" && e.type !== "file_edit" && e.type !== "command";
1351
+ const landed = e.type === "file_edit" || e.type === "command" && e.raw?.ok !== false;
1352
+ for (const p of e.paths) {
1353
+ if (blocked) blockedPaths.add(p);
1354
+ else seenPaths.add(p);
1355
+ const writes = e.type === "command" ? e.raw?.writes : void 0;
1356
+ if (landed && (!Array.isArray(writes) || writes.includes(p))) landedPaths.add(p);
1357
+ }
1358
+ }
1359
+ const ts = events.map((e) => e.ts).sort();
1360
+ const notSeen = [];
1361
+ const missing = EXPECTED_TYPES.filter((t) => !byType[t]);
1362
+ if (missing.length) notSeen.push(`no ${missing.join(", ")} events`);
1363
+ const truncated = badLines.length > 0 || !text.endsWith("\n");
1364
+ if (truncated) notSeen.push(`the ledger may be truncated or damaged (${badLines.length} unreadable line(s)${text.endsWith("\n") ? "" : ", last line has no line break"}); events after that point may be missing`);
1365
+ const attemptedOnly = changedPaths.filter((p) => !seenPaths.has(p) && blockedPaths.has(p));
1366
+ if (attemptedOnly.length)
1367
+ notSeen.push(
1368
+ `${attemptedOnly.length} changed file(s) appear in the ledger only as blocked attempts (${attemptedOnly.slice(0, SHOWN_PATHS).join(", ")}${attemptedOnly.length > SHOWN_PATHS ? ", \u2026" : ""}): the change that landed was not observed by any hook`
1369
+ );
1370
+ const unseen = changedPaths.filter((p) => !seenPaths.has(p) && !blockedPaths.has(p));
1371
+ if (unseen.length) {
1372
+ notSeen.push(
1373
+ `${unseen.length} of ${changedPaths.length} changed file(s) are named by no ledger event (${unseen.slice(0, SHOWN_PATHS).join(", ")}${unseen.length > SHOWN_PATHS ? ", \u2026" : ""}); a file changed through a shell command is seen only if the command text names it`
1374
+ );
1375
+ }
1376
+ const opaque = [...new Set(events.filter((e) => (e.type === "command" || e.type === "tool_pre") && e.command && unobservable(e.command)).map((e) => e.command))];
1377
+ const unparsed = [...new Set(events.filter((e) => (e.type === "command" || e.type === "tool_pre") && e.command && !unobservable(e.command) && e.raw?.writeNote === "write target not parsed").map((e) => e.command))];
1378
+ if (opaque.length)
1379
+ notSeen.push(
1380
+ `${opaque.length} shell command${opaque.length === 1 ? "" : "s"} ran a script file or wrote to a path built at run time (e.g. \`${opaque[0].slice(0, 80)}\`); what ${opaque.length === 1 ? "it" : "they"} wrote is not observable by Replume's shell rules`
1381
+ );
1382
+ if (unparsed.length)
1383
+ notSeen.push(
1384
+ `${unparsed.length} shell command${unparsed.length === 1 ? "" : "s"} wrote to a target that was not parsed (variable, glob, inline code, patch or a git command that rewrites files; e.g. \`${unparsed[0].slice(0, 80)}\`): write target not parsed, so ${unparsed.length === 1 ? "its" : "their"} writes are not attributed to any file`
1385
+ );
1386
+ const hookErrorsLogged = hookErrors(eventsFile);
1387
+ if (hookErrorsLogged) notSeen.push(HOOK_CRASH);
1388
+ return {
1389
+ ledger: "present",
1390
+ events: events.length,
1391
+ sessions: new Set(events.map((e) => e.sessionId)).size,
1392
+ agents: [...new Set(events.map((e) => e.agent))].sort(),
1393
+ byType,
1394
+ tools,
1395
+ firstTs: ts[0],
1396
+ lastTs: ts[ts.length - 1],
1397
+ badLines: badLines.length,
1398
+ hookErrorsLogged,
1399
+ changedFiles: changedPaths.length,
1400
+ changedFilesSeen: changedPaths.filter((p) => seenPaths.has(p)).length,
1401
+ changedFilesLanded: changedPaths.filter((p) => landedPaths.has(p)).length,
1402
+ changedFilesAttemptedOnly: attemptedOnly.slice(0, SHOWN_PATHS),
1403
+ changedFilesNotSeen: unseen.slice(0, SHOWN_PATHS),
1404
+ unobservableCommands: opaque.length + unparsed.length,
1405
+ notSeen
1406
+ };
1407
+ }
1408
+ var SCRIPT_RUN = /(?:^|[;&|(]\s*|\s(?:sudo|exec|env|nohup|time)\s+)(?:(?:node|nodejs|deno|bun|python[0-9.]*|ruby|perl|php|bash|sh|zsh|pwsh|tsx|ts-node)\s+(?:-[^-ce\s][^\s]*\s+)*[\w./~-]+\.(?:js|mjs|cjs|ts|py|rb|pl|php|sh|bash|zsh|ps1)\b|\.{1,2}\/[\w./-]+)/;
1409
+ var RUNTIME_TARGET = /(?:>{1,2}|>\||\btee(?:\s+-\w+)*)\s*["']?[^\s"';|&]*(?:\$|`)/;
1410
+ var unobservable = (command) => SCRIPT_RUN.test(command) || RUNTIME_TARGET.test(command);
1411
+ function hookErrors(eventsFile) {
1412
+ return existsSync6(join6(dirname4(eventsFile), "hook-errors.log"));
1413
+ }
1414
+ var list = (m) => Object.entries(m).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([k, n]) => `${k} ${n}`).join(", ");
1415
+ function renderCapture(c) {
1416
+ const out = ["CAPTURE COVERAGE"];
1417
+ if (c.ledger !== "present") {
1418
+ out.push(` seen nothing (ledger ${c.ledger === "absent" ? "file not found" : "empty"})`);
1419
+ } else {
1420
+ out.push(` seen ${c.events} events from ${c.agents.join(", ")} in ${c.sessions} session${c.sessions === 1 ? "" : "s"} (${c.firstTs} to ${c.lastTs}): ${list(c.byType)}`);
1421
+ if (Object.keys(c.tools).length) out.push(` tools ${list(c.tools)}`);
1422
+ if (c.changedFilesLanded !== void 0) {
1423
+ const a = c.changedFilesAttemptedOnly ?? [];
1424
+ out.push(
1425
+ ` files ${c.changedFilesSeen} of ${c.changedFiles} changed file(s) named by an allowed step; ${c.changedFilesLanded} landed and observed (file_edit or successful command event); ${a.length} only attempted (blocked)${a.length ? `: ${a.join(", ")}` : ""}`
1426
+ );
1427
+ }
1428
+ }
1429
+ out.push(` not seen ${c.notSeen.length ? c.notSeen.join("; ") : "nothing flagged by these checks (they cannot see steps taken outside the hooks)"}`);
1430
+ out.push(" limits only agents with a Replume adapter and installed hooks write events; the ledger is a local file that can be edited or deleted. This is what was captured, not proof of what the agent did.");
1431
+ return out;
1432
+ }
1433
+
1297
1434
  // src/core/rules.ts
1298
1435
  var PREFIX = "Replume:";
1299
1436
  function fingerprint(tool, input) {
1300
1437
  return createHash("sha1").update(tool).update("\0").update(JSON.stringify(input ?? null)).digest("hex").slice(0, 16);
1301
1438
  }
1439
+ function expandHome(p) {
1440
+ if (p === "~") return homedir();
1441
+ if (p.startsWith("~/")) return join7(homedir(), p.slice(2));
1442
+ return p;
1443
+ }
1302
1444
  function splitPaths(repoRoot2, paths) {
1303
1445
  const inside = [];
1304
1446
  const outside = [];
1305
1447
  const root = resolve2(repoRoot2);
1306
- for (const p of paths) {
1448
+ for (const raw3 of paths) {
1449
+ const p = expandHome(raw3);
1450
+ if (p.startsWith("~")) {
1451
+ outside.push(p);
1452
+ continue;
1453
+ }
1307
1454
  const abs = isAbsolute3(p) ? resolve2(p) : resolve2(root, p);
1308
1455
  const rel = relative3(root, abs);
1309
1456
  if (rel === "" || rel.startsWith("..") || isAbsolute3(rel)) outside.push(abs);
@@ -1332,11 +1479,32 @@ function shellTokens(src) {
1332
1479
  const toks = [];
1333
1480
  let cur = "";
1334
1481
  let has = false;
1482
+ const heredocs = [];
1335
1483
  const flush = () => {
1336
- if (has) toks.push({ word: cur });
1484
+ if (has) {
1485
+ const prev = toks[toks.length - 1];
1486
+ if (prev && "op" in prev && prev.op === "<<") {
1487
+ const strip = cur.startsWith("-");
1488
+ heredocs.push({ delim: strip ? cur.slice(1) : cur, strip });
1489
+ }
1490
+ toks.push({ word: cur });
1491
+ }
1337
1492
  cur = "";
1338
1493
  has = false;
1339
1494
  };
1495
+ const skipBodies = (from) => {
1496
+ let at = from;
1497
+ for (const h of heredocs.splice(0)) {
1498
+ for (; ; ) {
1499
+ if (at >= src.length) return src.length;
1500
+ const nl = src.indexOf("\n", at);
1501
+ const line = src.slice(at, nl < 0 ? src.length : nl);
1502
+ at = nl < 0 ? src.length : nl + 1;
1503
+ if ((h.strip ? line.replace(/^\t+/, "") : line) === h.delim) break;
1504
+ }
1505
+ }
1506
+ return at;
1507
+ };
1340
1508
  for (let i = 0; i < src.length; ) {
1341
1509
  const c = src[i];
1342
1510
  if (c === "'") {
@@ -1363,15 +1531,23 @@ function shellTokens(src) {
1363
1531
  i = j < 0 ? src.length : j;
1364
1532
  } else if (/\s/.test(c)) {
1365
1533
  flush();
1366
- if (c === "\n") toks.push({ op: ";" });
1534
+ if (c === "\n") {
1535
+ toks.push({ op: ";" });
1536
+ if (heredocs.length) {
1537
+ i = skipBodies(i + 1);
1538
+ continue;
1539
+ }
1540
+ }
1367
1541
  i++;
1368
1542
  } else if (">|<&;()".includes(c)) {
1369
1543
  const op = /^(?:&>>|>>|>\||>&|&>|&&|\|\||\|&|<<<|<<|<&|[>|<&;()])/.exec(src.slice(i))[0];
1544
+ let fd;
1370
1545
  if ((op[0] === ">" || op[0] === "<") && has && /^\d+$/.test(cur)) {
1546
+ fd = Number(cur);
1371
1547
  cur = "";
1372
1548
  has = false;
1373
1549
  } else flush();
1374
- toks.push({ op });
1550
+ toks.push(fd === void 0 ? { op } : { op, fd });
1375
1551
  i += op.length;
1376
1552
  } else {
1377
1553
  cur += c;
@@ -1383,8 +1559,122 @@ function shellTokens(src) {
1383
1559
  return toks;
1384
1560
  }
1385
1561
  var SHELLS = /* @__PURE__ */ new Set(["sh", "bash", "zsh", "dash", "ksh"]);
1386
- var WRAPPERS = /* @__PURE__ */ new Set(["sudo", "command", "exec", "time", "xargs", "env", "nohup"]);
1387
1562
  var WRITE_OPS = /* @__PURE__ */ new Set([">", ">>", ">|", "&>", "&>>"]);
1563
+ var MAX_NEST = 8;
1564
+ var SHELL_KEYWORDS = /* @__PURE__ */ new Set(["if", "then", "else", "elif", "fi", "while", "until", "do", "done", "esac", "{", "}", "!"]);
1565
+ var HEADER_KEYWORDS = /* @__PURE__ */ new Set(["for", "select", "case"]);
1566
+ var WRAPPER_SPECS = {
1567
+ sudo: { withArg: ["-u", "-g", "-h", "-p", "-C", "-T", "-r", "-t", "-U", "-D", "-R", "--user", "--group", "--host", "--prompt"] },
1568
+ doas: { withArg: ["-u", "-C"] },
1569
+ env: { withArg: ["-u", "-C", "--unset", "--chdir"] },
1570
+ command: { withArg: [] },
1571
+ exec: { withArg: ["-a"] },
1572
+ time: { withArg: ["-f", "-o", "--format", "--output"] },
1573
+ nohup: { withArg: [] },
1574
+ nice: { withArg: ["-n", "--adjustment"] },
1575
+ ionice: { withArg: ["-c", "-n", "-p", "-P", "-u", "--class", "--classdata"] },
1576
+ stdbuf: { withArg: ["-i", "-o", "-e"] },
1577
+ timeout: { withArg: ["-s", "-k", "--signal", "--kill-after"], positional: 1 },
1578
+ xargs: { withArg: ["-I", "-n", "-P", "-L", "-s", "-d", "-E", "-a"] }
1579
+ };
1580
+ function unwrapCommand(input) {
1581
+ let words = input;
1582
+ for (let guard = 0; guard < 16 && words.length; guard++) {
1583
+ const w = words[0];
1584
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w) || SHELL_KEYWORDS.has(w)) {
1585
+ words = words.slice(1);
1586
+ continue;
1587
+ }
1588
+ if (HEADER_KEYWORDS.has(w)) return [];
1589
+ if (w === "function") {
1590
+ words = words.slice(2);
1591
+ continue;
1592
+ }
1593
+ const spec = WRAPPER_SPECS[w.split("/").pop()];
1594
+ if (!spec) break;
1595
+ let i = 1;
1596
+ while (i < words.length) {
1597
+ const a = words[i];
1598
+ if (a === "--") {
1599
+ i++;
1600
+ break;
1601
+ }
1602
+ if (a.startsWith("-") && a.length > 1) i += spec.withArg.includes(a) ? 2 : 1;
1603
+ else break;
1604
+ }
1605
+ if (spec.positional && /^\d+(?:\.\d+)?[smhd]?$/.test(words[i] ?? "")) i += spec.positional;
1606
+ words = words.slice(i);
1607
+ }
1608
+ return words;
1609
+ }
1610
+ function splitFlags(args) {
1611
+ const flags = [];
1612
+ const operands = [];
1613
+ let done = false;
1614
+ for (const a of args) {
1615
+ if (done) operands.push(a);
1616
+ else if (a === "--") done = true;
1617
+ else if (a.startsWith("-") && a.length > 1) flags.push(a);
1618
+ else operands.push(a);
1619
+ }
1620
+ return { flags, operands };
1621
+ }
1622
+ var isBlockDevice = (p) => /^\/dev\/(?:(?:s|h|v|xv)d[a-z]+[0-9]*|nvme[0-9]+n[0-9]+(?:p[0-9]+)?|mmcblk[0-9]+(?:p[0-9]+)?|r?disk[0-9]+(?:s[0-9]+)*)$/.test(p.trim());
1623
+ function substitutionBodies(src) {
1624
+ const text = src.replace(/<<(-?)[ \t]*(['"]?)([A-Za-z_][\w-]*)\2[^\n]*\n[\s\S]*?\n[ \t]*\3(?=\n|$)/g, "");
1625
+ const out = [];
1626
+ const closing = (from) => {
1627
+ let depth = 0;
1628
+ for (let j = from; j < text.length; j++) {
1629
+ const c = text[j];
1630
+ if (c === "\\") {
1631
+ j++;
1632
+ continue;
1633
+ }
1634
+ if (c === "'") {
1635
+ j = text.indexOf("'", j + 1);
1636
+ if (j < 0) return text.length;
1637
+ continue;
1638
+ }
1639
+ if (c === '"') {
1640
+ for (j++; j < text.length && text[j] !== '"'; j++) if (text[j] === "\\") j++;
1641
+ continue;
1642
+ }
1643
+ if (c === "(") depth++;
1644
+ else if (c === ")" && --depth === 0) return j;
1645
+ }
1646
+ return text.length;
1647
+ };
1648
+ let dq = false;
1649
+ for (let i = 0; i < text.length; i++) {
1650
+ const c = text[i];
1651
+ if (c === "\\") {
1652
+ i++;
1653
+ continue;
1654
+ }
1655
+ if (c === "'" && !dq) {
1656
+ const j = text.indexOf("'", i + 1);
1657
+ if (j < 0) break;
1658
+ i = j;
1659
+ continue;
1660
+ }
1661
+ if (c === '"') {
1662
+ dq = !dq;
1663
+ continue;
1664
+ }
1665
+ if (text[i + 1] === "(" && (c === "$" && text[i + 2] !== "(" || (c === "<" || c === ">") && !dq)) {
1666
+ const end = closing(i + 1);
1667
+ out.push(text.slice(i + 2, end));
1668
+ i = end;
1669
+ } else if (c === "`") {
1670
+ let j = i + 1;
1671
+ while (j < text.length && text[j] !== "`") j += text[j] === "\\" ? 2 : 1;
1672
+ out.push(text.slice(i + 1, j));
1673
+ i = j;
1674
+ }
1675
+ }
1676
+ return out;
1677
+ }
1388
1678
  function redirectTargets(command, depth = 0) {
1389
1679
  const toks = shellTokens(command);
1390
1680
  const out = [];
@@ -1397,13 +1687,13 @@ function redirectTargets(command, depth = 0) {
1397
1687
  }
1398
1688
  if (!("word" in t)) return;
1399
1689
  const prev = toks[i - 1];
1400
- const atCommand = !prev || "op" in prev || WRAPPERS.has(word(prev) ?? "");
1690
+ const atCommand = !prev || "op" in prev || (word(prev) ?? "") in WRAPPER_SPECS;
1401
1691
  if (t.word === "tee" && atCommand) {
1402
1692
  for (let j = i + 1; j < toks.length && "word" in toks[j]; j++) {
1403
1693
  const w = word(toks[j]);
1404
1694
  if (!w.startsWith("-")) out.push(w);
1405
1695
  }
1406
- } else if (depth < 2 && SHELLS.has(t.word.split("/").pop())) {
1696
+ } else if (depth < MAX_NEST && SHELLS.has(t.word.split("/").pop())) {
1407
1697
  const k = toks.findIndex((x, j) => j > i && "word" in x && /^-[a-z]*c$/.test(x.word));
1408
1698
  const script = k >= 0 ? word(toks[k + 1]) : null;
1409
1699
  if (script) out.push(...redirectTargets(script, depth + 1));
@@ -1421,18 +1711,18 @@ function fileVerbOperands(command, depth = 0) {
1421
1711
  const t = toks[i];
1422
1712
  if ("op" in t) {
1423
1713
  if (SEPARATORS.has(t.op)) simple.push([]);
1424
- else if (WRITE_OPS.has(t.op) || t.op === "<" || t.op === "<<<") i++;
1714
+ else if (WRITE_OPS.has(t.op) || t.op === "<" || t.op === "<<<" || t.op === "<<") i++;
1425
1715
  continue;
1426
1716
  }
1427
1717
  simple[simple.length - 1].push(t.word);
1428
1718
  }
1429
- for (let words of simple) {
1430
- while (words.length && (WRAPPERS.has(words[0]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]))) words = words.slice(1);
1719
+ for (const sw of simple) {
1720
+ const words = unwrapCommand(sw);
1431
1721
  if (!words.length) continue;
1432
1722
  const verb = words[0].split("/").pop();
1433
1723
  const args = words.slice(1);
1434
- const operands = args.filter((w) => !w.startsWith("-")).map((w) => w.replace(/^of=/, ""));
1435
- if (SHELLS.has(verb) && depth < 2) {
1724
+ const operands = splitFlags(args).operands.map((w) => w.replace(/^of=/, ""));
1725
+ if (SHELLS.has(verb) && depth < MAX_NEST) {
1436
1726
  const k = args.findIndex((w) => /^-[a-z]*c$/.test(w));
1437
1727
  if (k >= 0 && args[k + 1]) out.push(...fileVerbOperands(args[k + 1], depth + 1));
1438
1728
  } else if (FILE_VERBS.has(verb)) out.push(...operands);
@@ -1441,16 +1731,179 @@ function fileVerbOperands(command, depth = 0) {
1441
1731
  }
1442
1732
  return out.filter((p) => p && !/[$*?{}`]/.test(p));
1443
1733
  }
1734
+ var DYNAMIC = /[$*?{}`]/;
1735
+ var INLINE_INTERP = /* @__PURE__ */ new Set(["node", "nodejs", "python", "python3", "perl", "ruby", "deno", "bun", "php"]);
1736
+ var INLINE_WRITE = /writeFile|appendFile|createWriteStream|copyFile|rename|unlink|rmSync|mkdir|open\s*\(|\.write\s*\(|write_text|write_bytes|shutil\.|os\.remove|File\.write|IO\.write/;
1737
+ var GIT_TREE_CHANGERS = /* @__PURE__ */ new Set(["switch", "reset", "stash", "apply", "am", "merge", "pull", "rebase", "cherry-pick", "revert", "clean"]);
1738
+ function shellWriteTargets(command, depth = 0) {
1739
+ const out = [];
1740
+ const certain = [];
1741
+ let unparsed = false;
1742
+ const toks = shellTokens(command);
1743
+ let cond = false;
1744
+ let base3 = false;
1745
+ const orLists = /* @__PURE__ */ new Set();
1746
+ let list2 = 0;
1747
+ let construct = 0;
1748
+ let pendingFunc = false;
1749
+ const stack = [];
1750
+ const simple = [{ words: [], redirects: [], conditional: false, list: 0, depth: 0, construct: 0, after: ";" }];
1751
+ const next = (after) => simple.push({ words: [], redirects: [], conditional: cond, list: list2, depth: stack.length, construct, after });
1752
+ for (let i = 0; i < toks.length; i++) {
1753
+ const t = toks[i];
1754
+ if ("op" in t) {
1755
+ const cur2 = simple[simple.length - 1];
1756
+ if (t.op === ";") {
1757
+ cond = base3;
1758
+ list2++;
1759
+ pendingFunc = false;
1760
+ next(";");
1761
+ } else if (t.op === "&&") {
1762
+ cond = true;
1763
+ next("&&");
1764
+ } else if (t.op === "||") {
1765
+ cond = true;
1766
+ orLists.add(list2);
1767
+ next("||");
1768
+ } else if (t.op === "(") {
1769
+ if (cur2.words.length === 1 && /^[A-Za-z_][\w.-]*$/.test(cur2.words[0])) pendingFunc = true;
1770
+ stack.push({ cond, base: base3, func: false });
1771
+ base3 = cond;
1772
+ next("(");
1773
+ } else if (t.op === ")") {
1774
+ const top = stack.pop();
1775
+ cond = top?.cond ?? false;
1776
+ base3 = top?.base ?? false;
1777
+ next(")");
1778
+ } else if (SEPARATORS.has(t.op)) next(t.op);
1779
+ else if (WRITE_OPS.has(t.op)) {
1780
+ const target = toks[i + 1];
1781
+ const w = target && "word" in target ? target.word : null;
1782
+ const fds = t.fd !== void 0 ? [t.fd] : t.op === "&>" || t.op === "&>>" ? [1, 2] : [1];
1783
+ cur2.redirects.push({ fds, target: w });
1784
+ i++;
1785
+ } else if (t.op === "<" || t.op === "<<<" || t.op === "<<" || t.op === ">&" || t.op === "<&") i++;
1786
+ continue;
1787
+ }
1788
+ const cur = simple[simple.length - 1];
1789
+ if (cur.words.length === 0) {
1790
+ if (["if", "while", "until", "for", "select", "case"].includes(t.word)) construct++;
1791
+ else if (["fi", "done", "esac"].includes(t.word)) construct = Math.max(0, construct - 1);
1792
+ else if (t.word === "function") pendingFunc = true;
1793
+ else if (t.word === "{") {
1794
+ stack.push({ cond, base: base3, func: pendingFunc });
1795
+ base3 = cond;
1796
+ if (pendingFunc) {
1797
+ construct++;
1798
+ cur.construct = construct;
1799
+ }
1800
+ pendingFunc = false;
1801
+ } else if (t.word === "}") {
1802
+ const top = stack.pop();
1803
+ cond = top?.cond ?? false;
1804
+ base3 = top?.base ?? false;
1805
+ if (top?.func) construct = Math.max(0, construct - 1);
1806
+ }
1807
+ }
1808
+ cur.words.push(t.word);
1809
+ }
1810
+ const fails = (sc) => {
1811
+ const w = unwrapCommand(sc.words);
1812
+ return w[0] === "false" || (w[0] === "exit" || w[0] === "return") && /^[1-9][0-9]*$/.test(w[1] ?? "");
1813
+ };
1814
+ const skipped = simple.map(() => false);
1815
+ simple.forEach((sc, i) => {
1816
+ if (i > 0 && sc.after === "&&" && (fails(simple[i - 1]) || skipped[i - 1])) skipped[i] = true;
1817
+ });
1818
+ const lastList = [...simple].reverse().find((x) => x.words.length || x.redirects.length)?.list ?? 0;
1819
+ simple.forEach((sc, idx) => {
1820
+ const sure = !skipped[idx] && sc.construct === 0 && (!sc.conditional || sc.depth === 0 && !orLists.has(sc.list) && sc.list === lastList);
1821
+ const last = /* @__PURE__ */ new Map();
1822
+ sc.redirects.forEach((r, j) => r.fds.forEach((fd) => last.set(fd, j)));
1823
+ sc.redirects.forEach((r, j) => {
1824
+ if (!r.target || DYNAMIC.test(r.target)) {
1825
+ unparsed = true;
1826
+ return;
1827
+ }
1828
+ if (DEVICE_SINK.test(r.target)) return;
1829
+ out.push(r.target);
1830
+ if (sure && r.fds.some((fd) => last.get(fd) === j)) certain.push(r.target);
1831
+ });
1832
+ const add = (ws, content = true) => {
1833
+ for (const w of ws) {
1834
+ if (!w) continue;
1835
+ if (DYNAMIC.test(w)) unparsed = true;
1836
+ else if (!DEVICE_SINK.test(w)) {
1837
+ out.push(w);
1838
+ if (content && sure) certain.push(w);
1839
+ }
1840
+ }
1841
+ };
1842
+ const words = unwrapCommand(sc.words);
1843
+ if (!words.length) return;
1844
+ const verb = words[0].split("/").pop();
1845
+ const args = words.slice(1);
1846
+ const operands = splitFlags(args).operands;
1847
+ if (sc.words.slice(0, sc.words.length - words.length).some((x) => x.split("/").pop() === "xargs")) {
1848
+ if (FILE_VERBS.has(verb) || verb === "sed" || verb === "perl") unparsed = true;
1849
+ return;
1850
+ }
1851
+ if (SHELLS.has(verb)) {
1852
+ const k = args.findIndex((w) => /^-[a-z]*c$/.test(w));
1853
+ if (k >= 0 && args[k + 1] !== void 0) {
1854
+ if (depth >= MAX_NEST) unparsed = true;
1855
+ else {
1856
+ const r = shellWriteTargets(args[k + 1], depth + 1);
1857
+ out.push(...r.paths);
1858
+ if (sure) certain.push(...r.certain);
1859
+ unparsed ||= r.unparsed;
1860
+ }
1861
+ }
1862
+ } else if (["tee", "touch", "truncate", "rm", "unlink", "chmod", "chown"].includes(verb)) {
1863
+ add(verb === "chmod" || verb === "chown" ? operands.slice(1) : verb === "truncate" ? args.filter((w, j) => !w.startsWith("-") && !/^-s$|^--size$/.test(args[j - 1] ?? "")) : operands, !["touch", "chmod", "chown"].includes(verb));
1864
+ } else if (verb === "cp" || verb === "install" || verb === "ln") {
1865
+ const t = args.findIndex((w) => w === "-t" || w === "--target-directory");
1866
+ if (t >= 0 || args.some((w) => w.startsWith("--target-directory="))) unparsed = true;
1867
+ else if (operands.length >= 2) add([operands[operands.length - 1]]);
1868
+ } else if (verb === "mv") add(operands);
1869
+ else if (verb === "dd") add(args.filter((w) => w.startsWith("of=")).map((w) => w.slice(3)));
1870
+ else if (verb === "patch") unparsed = true;
1871
+ else if (verb === "sed" && args.some((w) => /^-[a-zA-Z]*i|^--in-place/.test(w))) {
1872
+ const ops = args.filter((w, j) => !w.startsWith("-") && !/^-[a-zA-Z]*[ef]$|^--expression$|^--file$/.test(args[j - 1] ?? "") && !(w === "" && /^-[a-zA-Z]*i$/.test(args[j - 1] ?? "")));
1873
+ const scripted = args.some((w) => /^-[a-zA-Z]*[ef]$|^--expression|^--file/.test(w));
1874
+ add(scripted ? ops : ops.slice(1));
1875
+ } else if (verb === "perl" && args.some((w) => /^-[a-zA-Z]*i/.test(w))) {
1876
+ add(args.filter((w, j) => !w.startsWith("-") && !/^-[a-zA-Z]*e$/.test(args[j - 1] ?? "")));
1877
+ } else if (verb === "git") {
1878
+ const sub = args[0] ?? "";
1879
+ if (sub === "rm" || sub === "mv") add(operands.slice(1));
1880
+ else if (sub === "checkout" || sub === "restore") {
1881
+ const dd = args.indexOf("--");
1882
+ if (dd >= 0) add(args.slice(dd + 1));
1883
+ else if (sub === "restore") add(operands.slice(1));
1884
+ else unparsed = true;
1885
+ } else if (GIT_TREE_CHANGERS.has(sub)) unparsed = true;
1886
+ } else if (INLINE_INTERP.has(verb.replace(/[0-9.]+$/, "")) && args.some((w) => /^-[a-zA-Z]*[ec]$|^eval$/.test(w)) && INLINE_WRITE.test(args.join(" "))) unparsed = true;
1887
+ });
1888
+ const bodies = substitutionBodies(command);
1889
+ if (bodies.length && depth >= MAX_NEST) unparsed = true;
1890
+ else for (const body of bodies) {
1891
+ const r = shellWriteTargets(body, depth + 1);
1892
+ out.push(...r.paths);
1893
+ unparsed ||= r.unparsed;
1894
+ }
1895
+ if (depth === 0 && unobservable(command)) unparsed = true;
1896
+ return { paths: [...new Set(out)], unparsed, certain: [...new Set(certain)] };
1897
+ }
1444
1898
  function deletedOperands(command, depth = 0) {
1445
1899
  const out = [];
1446
1900
  for (const t of shellTokens(command).reduce((acc, x) => ("op" in x && SEPARATORS.has(x.op) ? acc.push([]) : "word" in x && acc[acc.length - 1].push(x.word), acc), [[]])) {
1447
- let words = t;
1448
- while (words.length && (WRAPPERS.has(words[0]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]))) words = words.slice(1);
1901
+ const words = unwrapCommand(t);
1449
1902
  const verb = words[0]?.split("/").pop();
1450
- const ops = words.slice(1).filter((w) => !w.startsWith("-"));
1903
+ const ops = splitFlags(words.slice(1)).operands;
1451
1904
  if (verb === "rm" || verb === "unlink") out.push(...ops);
1452
1905
  else if (verb === "git" && words[1] === "rm") out.push(...ops.slice(1));
1453
- else if (verb && SHELLS.has(verb) && depth < 2) {
1906
+ else if (verb && SHELLS.has(verb) && depth < MAX_NEST) {
1454
1907
  const k = words.findIndex((w) => /^-[a-z]*c$/.test(w));
1455
1908
  if (k >= 0 && words[k + 1]) out.push(...deletedOperands(words[k + 1], depth + 1));
1456
1909
  }
@@ -1485,7 +1938,7 @@ function stripShellComments(src) {
1485
1938
  }
1486
1939
  function writeTargets(a, repoRoot2) {
1487
1940
  if (a.kind !== "command" || !a.command) return { inside: [], outside: [] };
1488
- return splitPaths(repoRoot2, redirectTargets(a.command));
1941
+ return splitPaths(repoRoot2, [...redirectTargets(a.command), ...shellWriteTargets(a.command).paths]);
1489
1942
  }
1490
1943
  var isWrite = (a) => a.kind === "edit" || a.kind === "write";
1491
1944
  var outOfScopeEdit = (a, ctx) => {
@@ -1512,6 +1965,11 @@ var MUTATING_VERBS = /(^|[\s;&|(])(rm|mv|cp|truncate|tee|chmod|chown|ln|unlink|p
1512
1965
  function commandPathTokens(command) {
1513
1966
  return (command.match(/[A-Za-z0-9_./@+-]+/g) ?? []).filter((t) => t.includes("/") || t.includes("."));
1514
1967
  }
1968
+ function allowedForTask(ctx, protectedPaths) {
1969
+ const allow = ctx.contract?.allow ?? [];
1970
+ if (!allow.length) return null;
1971
+ return allow.map((g) => protectedPaths.some((p) => matchesAny(p, [g])) ? `${g} (except protected files)` : g).join(", ");
1972
+ }
1515
1973
  var protectedPathModified = (a, ctx) => {
1516
1974
  const protect = ctx.contract?.protect ?? [];
1517
1975
  if (isWrite(a)) {
@@ -1525,22 +1983,24 @@ var protectedPathModified = (a, ctx) => {
1525
1983
  }
1526
1984
  const hit = a.paths.filter((p) => matchesAny(p, protect));
1527
1985
  if (!hit.length) return null;
1986
+ const allowed = allowedForTask(ctx, hit);
1528
1987
  return {
1529
1988
  rule: "protected_path_modified",
1530
1989
  action: "block",
1531
- message: `${PREFIX} ${hit.join(", ")} is protected by the task contract and must not change. Leave it as it is and make the implementation satisfy it. If the task truly requires changing it, stop and ask the user.`
1990
+ message: allowed ? `${PREFIX} ${hit.join(", ")} is protected. Allowed for this task: ${allowed}. Change those instead; if the protected file must change, stop and ask the user.` : `${PREFIX} ${hit.join(", ")} is protected by the task contract and must not change. Leave it as it is and make the implementation satisfy it. If the task truly requires changing it, stop and ask the user.`
1532
1991
  };
1533
1992
  }
1534
1993
  if (a.kind === "command" && a.command) {
1535
1994
  const guarded = (p) => matchesAny(p, protect) !== null || p === ".replume" || p.startsWith(".replume/");
1536
1995
  const redirected = writeTargets(a, ctx.repoRoot).inside.filter(guarded);
1537
- const operands = splitPaths(ctx.repoRoot, fileVerbOperands(a.command)).inside.filter(guarded);
1996
+ const operands = splitPaths(ctx.repoRoot, [a.command, ...innerScripts(a.command)].flatMap((c) => fileVerbOperands(c))).inside.filter(guarded);
1538
1997
  const hit = [.../* @__PURE__ */ new Set([...redirected, ...operands])];
1539
1998
  if (!hit.length) return null;
1999
+ const allowed = allowedForTask(ctx, hit);
1540
2000
  return {
1541
2001
  rule: "protected_path_modified",
1542
2002
  action: "block",
1543
- message: `${PREFIX} this command would modify ${hit.join(", ")}, which the task contract protects. Do not change protected files through the shell either. If it is required, stop and ask the user.`
2003
+ message: allowed ? `${PREFIX} this command would modify ${hit.join(", ")}, which is protected. Allowed for this task: ${allowed}. Change those instead; if the protected file must change, stop and ask the user.` : `${PREFIX} this command would modify ${hit.join(", ")}, which the task contract protects. Do not change protected files through the shell either. If it is required, stop and ask the user.`
1544
2004
  };
1545
2005
  }
1546
2006
  return null;
@@ -1548,7 +2008,7 @@ var protectedPathModified = (a, ctx) => {
1548
2008
  var ASSERTION = /\b(expect|assert(?:Equal|StrictEqual|DeepEqual|True|False|Raises|That)?|should)\s*[.(]|\bassert\s+\S|\bt\.(is|not|deepEqual|equal|ok|notOk|true|false|throws|same|match)\s*\(|\.(toBe|toEqual|toStrictEqual|toMatch|toThrow|toHaveBeenCalled\w*|toContain|toHaveLength)\s*\(/g;
1549
2009
  var SKIP_MARK = /\b(?:it|test|describe|suite|context)\.(?:skip|only|todo)\s*\(|\b(?:xit|xtest|xdescribe|fit|fdescribe)\s*\(|@pytest\.mark\.skip|@unittest\.skip|\bt\.skip\s*\(|\bpytest\.skip\s*\(|\.skip\s*=\s*true/g;
1550
2010
  var FORCED_SUCCESS = /(\b(test|jest|vitest|mocha|pytest|ava|tap|go test|cargo test|npm t)\b[^\n]*(\|\|\s*(true|:|exit 0)|;\s*exit 0))/;
1551
- var count2 = (re, s) => (s.match(re) ?? []).length;
2011
+ var count3 = (re, s) => (s.match(re) ?? []).length;
1552
2012
  function assertionLiterals(s) {
1553
2013
  return s.split("\n").filter((l) => new RegExp(ASSERTION.source).test(l)).map((l) => l.trim()).sort();
1554
2014
  }
@@ -1561,7 +2021,7 @@ var testWeakened = (a) => {
1561
2021
  message: `${PREFIX} this command forces the test run to succeed (\`|| true\` / \`exit 0\`), which hides failures. Run the tests plainly and fix what fails.`
1562
2022
  };
1563
2023
  }
1564
- const rmTests = [...new Set(deletedOperands(a.command).filter((t) => isTestFile(t)))];
2024
+ const rmTests = [...new Set([a.command, ...innerScripts(a.command)].flatMap((c) => deletedOperands(c)).filter((t) => isTestFile(t)))];
1565
2025
  if (rmTests.length) {
1566
2026
  return {
1567
2027
  rule: "test_weakened",
@@ -1580,8 +2040,8 @@ var testWeakened = (a) => {
1580
2040
  severity = "block";
1581
2041
  }
1582
2042
  if (!isTestFile(ch.path)) continue;
1583
- const removed = count2(ASSERTION, ch.before) - count2(ASSERTION, ch.after);
1584
- const skipsAdded = count2(SKIP_MARK, ch.after) - count2(SKIP_MARK, ch.before);
2043
+ const removed = count3(ASSERTION, ch.before) - count3(ASSERTION, ch.after);
2044
+ const skipsAdded = count3(SKIP_MARK, ch.after) - count3(SKIP_MARK, ch.before);
1585
2045
  if (ch.before && removed > 0) {
1586
2046
  findings.push(`${ch.path}: ${removed} assertion${removed > 1 ? "s" : ""} removed`);
1587
2047
  severity = "block";
@@ -1614,10 +2074,32 @@ var ALWAYS_DESTRUCTIVE = [
1614
2074
  { re: /\bgit\s+branch\s+-D\b/, why: "force-deletes a branch" },
1615
2075
  { re: /\b(DROP\s+(TABLE|DATABASE|SCHEMA)|TRUNCATE\s+TABLE)\b/i, why: "drops or truncates database data" },
1616
2076
  { re: /\bDELETE\s+FROM\s+\w+\s*(;|$|")/i, why: "deletes every row of a table" },
1617
- { re: /\bmkfs(\.\w+)?\b|\bdd\s+[^\n]*of=\/dev\//, why: "overwrites a disk" },
1618
2077
  { re: /\bchmod\s+-R\s+777\s+\//, why: "makes the filesystem world-writable" },
1619
2078
  { re: /:\(\)\s*\{\s*:\|:&\s*\};:/, why: "is a fork bomb" }
1620
2079
  ];
2080
+ function shellScripts(command, depth = 0) {
2081
+ if (depth >= MAX_NEST) return [];
2082
+ const out = [];
2083
+ const groups = [[]];
2084
+ for (const t of shellTokens(command)) {
2085
+ if ("op" in t) {
2086
+ if (SEPARATORS.has(t.op)) groups.push([]);
2087
+ } else groups[groups.length - 1].push(t.word);
2088
+ }
2089
+ for (const g of groups) {
2090
+ const words = unwrapCommand(g);
2091
+ if (!words.length || !SHELLS.has(words[0].split("/").pop())) continue;
2092
+ const k = words.findIndex((w, i) => i > 0 && /^-[a-z]*c$/.test(w));
2093
+ const script = k >= 0 ? words[k + 1] : void 0;
2094
+ if (script) out.push(script, ...shellScripts(script, depth + 1));
2095
+ }
2096
+ return out;
2097
+ }
2098
+ function innerScripts(command, depth = 0) {
2099
+ if (depth >= MAX_NEST) return [];
2100
+ const direct = [...shellScripts(command, MAX_NEST - 1), ...substitutionBodies(command)];
2101
+ return [...new Set(direct)].flatMap((x) => [x, ...innerScripts(x, depth + 1)]);
2102
+ }
1621
2103
  function deletionTargets(command) {
1622
2104
  const groups = [[]];
1623
2105
  for (const token of shellTokens(command)) {
@@ -1625,32 +2107,54 @@ function deletionTargets(command) {
1625
2107
  if (SEPARATORS.has(token.op)) groups.push([]);
1626
2108
  } else groups[groups.length - 1].push(token.word);
1627
2109
  }
1628
- const result = [];
1629
- for (let words of groups) {
1630
- while (words.length && (WRAPPERS.has(words[0]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]))) words = words.slice(1);
2110
+ const result2 = [];
2111
+ for (const g of groups) {
2112
+ const words = unwrapCommand(g);
1631
2113
  const verb = words[0]?.split("/").pop();
1632
2114
  if (verb === "rm") {
1633
- const args = words.slice(1);
1634
- if (args.some((x) => /^-[^-]*[rR]/.test(x) || x === "--recursive"))
1635
- result.push({ targets: args.filter((x) => x !== "--" && !x.startsWith("-")), gitClean: false });
2115
+ const { flags, operands } = splitFlags(words.slice(1));
2116
+ if (flags.some((x) => /^-[^-]*[rR]/.test(x) || x === "--recursive")) result2.push({ targets: operands, gitClean: false });
1636
2117
  } else if (verb === "rmdir") {
1637
- result.push({ targets: words.slice(1).filter((x) => x !== "--" && !x.startsWith("-")), gitClean: false });
2118
+ result2.push({ targets: splitFlags(words.slice(1)).operands, gitClean: false });
1638
2119
  } else if (verb === "find" && words.includes("-delete")) {
1639
- result.push({ targets: words.slice(1, words.indexOf("-delete")).filter((x) => x !== "--" && !x.startsWith("-") && x !== "-depth"), gitClean: false });
2120
+ result2.push({ targets: words.slice(1, words.indexOf("-delete")).filter((x) => x !== "--" && !x.startsWith("-") && x !== "-depth"), gitClean: false });
1640
2121
  } else if (verb === "git") {
1641
2122
  const clean = words.indexOf("clean");
1642
2123
  if (clean < 0 || !words.slice(clean + 1).some((x) => /^-[^-]*f/.test(x) || x === "--force")) continue;
1643
2124
  const c = words.indexOf("-C");
1644
2125
  const cwd = c >= 0 && c < clean ? words[c + 1] : null;
1645
2126
  const paths = words.slice(clean + 1).filter((x) => x !== "--" && !x.startsWith("-"));
1646
- result.push({ targets: [...cwd ? [cwd] : [], ...paths], gitClean: true });
2127
+ result2.push({ targets: [...cwd ? [cwd] : [], ...paths], gitClean: true });
1647
2128
  }
1648
2129
  }
1649
- return result;
2130
+ return result2;
2131
+ }
2132
+ function expandTmpdirVar(target, ctx) {
2133
+ const m = /^\$\{?TMPDIR\}?(?=\/|$)/.exec(target);
2134
+ if (!m) return target;
2135
+ const env = process.env.TMPDIR === void 0 ? tmpdir() : process.env.TMPDIR;
2136
+ if (!env || !isAbsolute3(env)) return null;
2137
+ const base3 = resolve2(env);
2138
+ if (!isTempDir(ctx, base3)) return null;
2139
+ return base3 + target.slice(m[0].length);
2140
+ }
2141
+ function isTempDir(ctx, abs) {
2142
+ const roots = ["/tmp", "/private/tmp", "/var/tmp", "/private/var/tmp", ...ctx.scratchDirs ?? []].map((d) => resolve2(d));
2143
+ return roots.some((d) => abs === d || abs.startsWith(d + sep)) || /^\/(?:private\/)?var\/folders\/[^/]+\/[^/]+\/T(?:\/|$)/.test(abs);
1650
2144
  }
1651
2145
  function deletionLocation(target, ctx) {
1652
- if (target.startsWith("~") || /[$*?{}`]/.test(target)) return "critical";
1653
- const expanded = target === "~" || target.startsWith("~/") ? homedir() + target.slice(1) : target;
2146
+ const bare = target.replace(/^["']|["']$/g, "");
2147
+ const t = expandTmpdirVar(bare, ctx);
2148
+ if (t === null) return "critical";
2149
+ if (t.startsWith("~") || /[$?{}`]/.test(t) && !/[*?[]/.test(t)) return "critical";
2150
+ if (/[*?[]/.test(t)) {
2151
+ const segs = t.split("/");
2152
+ const gi = segs.findIndex((x) => /[*?[]/.test(x));
2153
+ const prefix = segs.slice(0, gi).join("/") || (t.startsWith("/") ? "/" : "");
2154
+ if (!/[$`{]/.test(prefix) && prefix && isTempDir(ctx, resolve2(ctx.repoRoot, prefix))) return "outside";
2155
+ return "critical";
2156
+ }
2157
+ const expanded = t === "~" || t.startsWith("~/") ? homedir() + t.slice(1) : t;
1654
2158
  const abs = resolve2(ctx.repoRoot, expanded);
1655
2159
  const root = resolve2(ctx.repoRoot);
1656
2160
  const home = resolve2(homedir());
@@ -1658,6 +2162,32 @@ function deletionLocation(target, ctx) {
1658
2162
  const rel = relative3(root, abs);
1659
2163
  return !rel || !rel.startsWith("..") && !isAbsolute3(rel) ? "inside" : "outside";
1660
2164
  }
2165
+ function resolvedTarget(target, ctx) {
2166
+ const t = expandTmpdirVar(target.replace(/^["']|["']$/g, ""), ctx) ?? target;
2167
+ return resolve2(ctx.repoRoot, t);
2168
+ }
2169
+ function blockDeviceWrites(scripts) {
2170
+ const out = shellWriteTargets(scripts[0] ?? "").paths.filter(isBlockDevice);
2171
+ for (const script of scripts) {
2172
+ const groups = [[]];
2173
+ const toks = shellTokens(script);
2174
+ for (let i = 0; i < toks.length; i++) {
2175
+ const t = toks[i];
2176
+ if ("op" in t) {
2177
+ if (SEPARATORS.has(t.op)) groups.push([]);
2178
+ else if (WRITE_OPS.has(t.op) || t.op === "<" || t.op === "<<<" || t.op === "<<") i++;
2179
+ continue;
2180
+ }
2181
+ groups[groups.length - 1].push(t.word);
2182
+ }
2183
+ for (const g of groups) {
2184
+ const words = unwrapCommand(g);
2185
+ const verb = words[0]?.split("/").pop() ?? "";
2186
+ if (verb === "mkfs" || verb.startsWith("mkfs.") || verb === "mkswap") out.push(...splitFlags(words.slice(1)).operands.filter(isBlockDevice));
2187
+ }
2188
+ }
2189
+ return [...new Set(out)];
2190
+ }
1661
2191
  function rmRfTargets(command) {
1662
2192
  const out = [];
1663
2193
  for (const seg of command.split(/&&|\|\||;|\|/)) {
@@ -1691,7 +2221,14 @@ var destructiveCommand = (a, ctx) => {
1691
2221
  };
1692
2222
  }
1693
2223
  }
1694
- const deletions = deletionTargets(cmd);
2224
+ const scripts = [cmd, ...innerScripts(cmd)];
2225
+ const devices = blockDeviceWrites(scripts);
2226
+ if (devices.length) return {
2227
+ rule: "destructive_command",
2228
+ action: "block",
2229
+ message: `${PREFIX} this command writes to a raw disk or block device (${devices.join(", ")}), which overwrites what is on it. It is blocked. If this is really intended, ask the user to run it themselves.`
2230
+ };
2231
+ const deletions = scripts.flatMap(deletionTargets);
1695
2232
  const deletionPaths = deletions.flatMap((d) => d.targets);
1696
2233
  const critical = deletionPaths.filter((p) => deletionLocation(p, ctx) === "critical");
1697
2234
  if (critical.length) return {
@@ -1699,7 +2236,7 @@ var destructiveCommand = (a, ctx) => {
1699
2236
  action: "block",
1700
2237
  message: `${PREFIX} this command can delete the home directory, filesystem root, or a parent of this repository (${critical.join(", ")}). It is blocked.`
1701
2238
  };
1702
- const foreignDeletion = deletionPaths.filter((p) => deletionLocation(p, ctx) === "outside" && !matchesAny(resolve2(ctx.repoRoot, p), ctx.contract?.allow ?? []));
2239
+ const foreignDeletion = deletionPaths.filter((p) => deletionLocation(p, ctx) === "outside" && !matchesAny(resolvedTarget(p, ctx), ctx.contract?.allow ?? []));
1703
2240
  if (foreignDeletion.length) return {
1704
2241
  rule: "destructive_command",
1705
2242
  action: "warn",
@@ -1719,11 +2256,11 @@ var destructiveCommand = (a, ctx) => {
1719
2256
  };
1720
2257
  }
1721
2258
  }
1722
- for (const targets of rmRfTargets(cmd)) {
2259
+ for (const targets of scripts.flatMap(rmRfTargets)) {
1723
2260
  const bad = targets.filter((t) => {
1724
2261
  const clean = t.replace(/^["']|["']$/g, "");
1725
- if (/^(\/|~|\$HOME|\*|\.{1,2}\/?)$/.test(clean) || clean.startsWith("~") || clean.includes("$")) return true;
1726
- const { inside, outside } = splitPaths(ctx.repoRoot, [clean]);
2262
+ if (/^(\/|~|\$HOME|\*|\.{1,2}\/?)$/.test(clean) || clean.startsWith("~") || deletionLocation(clean, ctx) === "critical") return true;
2263
+ const { inside, outside } = splitPaths(ctx.repoRoot, [expandTmpdirVar(clean, ctx) ?? clean]);
1727
2264
  if (outside.length) return !inScratch(ctx, outside[0]);
1728
2265
  return !REGENERABLE.test(inside[0]);
1729
2266
  });
@@ -1743,7 +2280,7 @@ var destructiveCommand = (a, ctx) => {
1743
2280
  message: `${PREFIX} this command deletes backup files. It is blocked; ask the user before removing backups.`
1744
2281
  };
1745
2282
  }
1746
- const verbTargets = MUTATING_VERBS.test(cmd) ? a.outsidePaths : [];
2283
+ const verbTargets = MUTATING_VERBS.test(cmd) ? a.outsidePaths.filter((p) => !p.startsWith("/dev/")) : [];
1747
2284
  const foreign = [.../* @__PURE__ */ new Set([...verbTargets, ...writeTargets(a, ctx.repoRoot).outside])].filter((p) => !inScratch(ctx, p) && !DEVICE_SINK.test(p));
1748
2285
  if (foreign.length) {
1749
2286
  return {
@@ -2034,7 +2571,7 @@ function parseJudgeResponse(x) {
2034
2571
  function commandJudge(executable, apiKey) {
2035
2572
  return {
2036
2573
  name: `command:${executable}`,
2037
- judge: (req, signal) => new Promise((resolve7, reject) => {
2574
+ judge: (req, signal) => new Promise((resolve9, reject) => {
2038
2575
  const env = { PATH: process.env.PATH, HOME: process.env.HOME };
2039
2576
  if (apiKey) env[ENV.apiKey] = apiKey;
2040
2577
  const child = spawn(executable, [], { env, stdio: ["pipe", "pipe", "pipe"], signal });
@@ -2051,7 +2588,7 @@ function commandJudge(executable, apiKey) {
2051
2588
  if (code !== 0) return reject(new Error(`judge command exited ${code}: ${err.trim().slice(0, 200)}`));
2052
2589
  if (stdinError) return reject(new Error(`judge command stdin failed: ${stdinError.message}`));
2053
2590
  try {
2054
- resolve7(parseJudgeResponse(JSON.parse(out)));
2591
+ resolve9(parseJudgeResponse(JSON.parse(out)));
2055
2592
  } catch (e) {
2056
2593
  reject(e);
2057
2594
  }
@@ -2415,12 +2952,12 @@ async function supervise(input, opts) {
2415
2952
  }
2416
2953
 
2417
2954
  // src/supervisor/done.ts
2418
- import { existsSync as existsSync6, readFileSync as readFileSync7 } from "fs";
2419
- import { join as join7 } from "path";
2955
+ import { existsSync as existsSync7, readFileSync as readFileSync8 } from "fs";
2956
+ import { join as join9 } from "path";
2420
2957
 
2421
2958
  // src/sandbox/scenarios.ts
2422
2959
  import { lstat, readdir, readFile, realpath } from "fs/promises";
2423
- import { join as join6, relative as relative4, sep as sep2 } from "path";
2960
+ import { join as join8, relative as relative4, sep as sep2 } from "path";
2424
2961
  var required = (ok, file2, field) => {
2425
2962
  if (!ok) throw new Error(`${file2}: invalid or missing ${field}`);
2426
2963
  };
@@ -2463,14 +3000,14 @@ async function loadScenarios(dir, root = dir) {
2463
3000
  if (!inside(await realpath(dir))) throw new Error(`scenario directory ${rel(dir)} resolves outside the commit; ${NOT_REGULAR}`);
2464
3001
  const files = (await readdir(dir)).filter((n) => n.endsWith(".yaml") || n.endsWith(".yml")).sort();
2465
3002
  for (const n of files) {
2466
- const p = join6(dir, n);
3003
+ const p = join8(dir, n);
2467
3004
  const st = await lstat(p);
2468
3005
  if (st.isSymbolicLink()) throw new Error(`scenario file ${rel(p)} is a symbolic link; ${NOT_REGULAR}`);
2469
3006
  if (!st.isFile()) throw new Error(`scenario file ${rel(p)} is not a regular file; ${NOT_REGULAR}`);
2470
3007
  if (!inside(await realpath(p))) throw new Error(`scenario file ${rel(p)} resolves outside the commit; ${NOT_REGULAR}`);
2471
3008
  }
2472
3009
  const scenarios = await Promise.all(files.map(async (n) => {
2473
- const s = parseScenario(await readFile(join6(dir, n), "utf8"), n);
3010
+ const s = parseScenario(await readFile(join8(dir, n), "utf8"), n);
2474
3011
  required(n.replace(/\.ya?ml$/, "") === s.id, n, "id (must match filename)");
2475
3012
  return s;
2476
3013
  }));
@@ -2552,10 +3089,10 @@ function doneStatus(contract, sessionEvents, repoRoot2) {
2552
3089
  for (const d of entries) {
2553
3090
  if ("run" in d) need(d.run, `\`${d.run}\``, d.run);
2554
3091
  else {
2555
- const file2 = join7(".replume", "scenarios", `${d.scenario}.yaml`);
3092
+ const file2 = join9(".replume", "scenarios", `${d.scenario}.yaml`);
2556
3093
  let run = null;
2557
3094
  try {
2558
- if (existsSync6(join7(repoRoot2, file2))) run = parseScenario(readFileSync7(join7(repoRoot2, file2), "utf-8"), file2).run;
3095
+ if (existsSync7(join9(repoRoot2, file2))) run = parseScenario(readFileSync8(join9(repoRoot2, file2), "utf-8"), file2).run;
2559
3096
  } catch {
2560
3097
  run = null;
2561
3098
  }
@@ -2572,7 +3109,7 @@ function doneMessage(s) {
2572
3109
  }
2573
3110
 
2574
3111
  // src/agents/claude/payload.ts
2575
- import { existsSync as existsSync7, readFileSync as readFileSync8 } from "fs";
3112
+ import { existsSync as existsSync8, readFileSync as readFileSync9 } from "fs";
2576
3113
  import { isAbsolute as isAbsolute4, resolve as resolve3 } from "path";
2577
3114
  function parsePayload(text) {
2578
3115
  const x = JSON.parse(text);
@@ -2585,13 +3122,13 @@ function parsePayload(text) {
2585
3122
  var str = (v) => typeof v === "string" ? v : "";
2586
3123
  function readIfExists(path) {
2587
3124
  try {
2588
- return existsSync7(path) ? readFileSync8(path, "utf-8") : "";
3125
+ return existsSync8(path) ? readFileSync9(path, "utf-8") : "";
2589
3126
  } catch {
2590
3127
  return "";
2591
3128
  }
2592
3129
  }
2593
3130
  function absolutePathsIn(command) {
2594
- return (command.match(/(?<![\w.:/])(?:\/|~\/)[\w.@+-]+(?:\/[\w.@+-]*)*/g) ?? []).map(
3131
+ return (command.match(/(?<![\w.:/])(?:\/|~\/)[\w.@+-]+(?:\/[\w.@+-]*)*(?![\w.@+(/-])/g) ?? []).map(
2595
3132
  (p) => p.startsWith("~/") ? resolve3(process.env.HOME ?? "/", p.slice(2)) : p
2596
3133
  );
2597
3134
  }
@@ -2601,14 +3138,24 @@ function toProposedAction(p, repoRoot2) {
2601
3138
  let kind = "other";
2602
3139
  let rawPaths = [];
2603
3140
  let command = null;
3141
+ let writeTargetNotParsed = false;
3142
+ let mentioned = [];
3143
+ let certainRaw = [];
2604
3144
  const pending = [];
2605
- const abs = (fp) => isAbsolute4(fp) ? fp : resolve3(p.cwd, fp);
3145
+ const abs = (fp) => {
3146
+ const h = expandHome(fp);
3147
+ return isAbsolute4(h) || h.startsWith("~") ? h : resolve3(p.cwd, h);
3148
+ };
2606
3149
  switch (tool) {
2607
3150
  case "Bash":
2608
3151
  case "PowerShell": {
2609
3152
  kind = "command";
2610
3153
  command = str(input.command);
2611
- rawPaths = absolutePathsIn(command);
3154
+ const w = tool === "Bash" ? shellWriteTargets(command) : { paths: [], unparsed: false };
3155
+ writeTargetNotParsed = w.unparsed;
3156
+ rawPaths = w.paths.map(abs);
3157
+ certainRaw = "certain" in w ? w.certain.map(abs) : [];
3158
+ mentioned = absolutePathsIn(command);
2612
3159
  break;
2613
3160
  }
2614
3161
  case "Write": {
@@ -2654,13 +3201,15 @@ function toProposedAction(p, repoRoot2) {
2654
3201
  break;
2655
3202
  }
2656
3203
  }
2657
- const { inside, outside } = splitPaths(repoRoot2, rawPaths.filter(Boolean));
3204
+ const { inside, outside: writtenOutside } = splitPaths(repoRoot2, rawPaths.filter(Boolean));
3205
+ const outside = [.../* @__PURE__ */ new Set([...writtenOutside, ...splitPaths(repoRoot2, mentioned).outside])];
3206
+ const certainPaths = [...new Set(splitPaths(repoRoot2, certainRaw).inside)];
2658
3207
  const changes = [];
2659
3208
  for (const c of pending) {
2660
3209
  const rel = splitPaths(repoRoot2, [c.abs]).inside[0];
2661
3210
  if (rel !== void 0) changes.push({ path: rel, before: c.before, after: c.after });
2662
3211
  }
2663
- return { kind, tool, paths: inside, outsidePaths: outside, command, changes, fingerprint: fingerprint(tool, input) };
3212
+ return { kind, tool, paths: [...new Set(inside)], outsidePaths: outside, command, changes, fingerprint: fingerprint(tool, input), ...writeTargetNotParsed ? { writeTargetNotParsed } : {}, ...kind === "command" ? { certainPaths } : {} };
2664
3213
  }
2665
3214
 
2666
3215
  // src/agents/claude/handler.ts
@@ -2673,8 +3222,13 @@ function resolveRepoRoot(p, env = process.env) {
2673
3222
  return p.cwd;
2674
3223
  }
2675
3224
  }
3225
+ var WRITE_NOT_PARSED = "write target not parsed";
3226
+ function writeFields(a) {
3227
+ if (a.kind !== "command") return {};
3228
+ return { writes: a.certainPaths ?? a.paths, ...a.writeTargetNotParsed ? { writeNote: WRITE_NOT_PARSED } : {} };
3229
+ }
2676
3230
  function summarize(a) {
2677
- if (a.command !== null) return `${a.tool}: ${a.command}`;
3231
+ if (a.command !== null) return `${a.tool}: ${a.command}${a.writeTargetNotParsed ? ` (${WRITE_NOT_PARSED})` : ""}`;
2678
3232
  const targets = [...a.paths, ...a.outsidePaths];
2679
3233
  return targets.length ? `${a.tool} ${targets.join(", ")}` : a.tool;
2680
3234
  }
@@ -2762,14 +3316,14 @@ function decide(p, repoRoot2, proposal, sessionEvents, env, deps) {
2762
3316
  };
2763
3317
  const opts = { layer2, judge, sessionEvents, slot };
2764
3318
  if (layer2Inactive(opts)) return { result: superviseSync(input, opts), contract };
2765
- return supervise(input, opts).then((result) => ({ result, contract }));
3319
+ return supervise(input, opts).then((result2) => ({ result: result2, contract }));
2766
3320
  }
2767
3321
  function preToolUse(p, repoRoot2, env, deps) {
2768
3322
  const ledger = ledgerPath(repoRoot2);
2769
3323
  const action = toProposedAction(p, repoRoot2);
2770
3324
  const session = readEvents(ledger).events.filter((e) => e.sessionId === p.session_id);
2771
- return andThen(decide(p, repoRoot2, { kind: "action", action }, session, env, deps), ({ result, contract }) => {
2772
- const deciding = result.findings.find((f) => f.decision === result.decision);
3325
+ return andThen(decide(p, repoRoot2, { kind: "action", action }, session, env, deps), ({ result: result2, contract }) => {
3326
+ const deciding = result2.findings.find((f) => f.decision === result2.decision);
2773
3327
  appendEvent(ledger, {
2774
3328
  ...base(p, "tool_pre"),
2775
3329
  tool: action.tool,
@@ -2777,21 +3331,22 @@ function preToolUse(p, repoRoot2, env, deps) {
2777
3331
  command: action.command,
2778
3332
  summary: summarize(action),
2779
3333
  rule: deciding?.rule ?? null,
2780
- action: ledgerAction(result.decision),
2781
- message: result.message,
3334
+ action: ledgerAction(result2.decision),
3335
+ message: result2.message,
2782
3336
  raw: {
2783
3337
  fingerprint: action.fingerprint,
2784
- decision: result.decision,
2785
- layer: result.layer,
2786
- layer2Mode: result.layer2.mode,
3338
+ decision: result2.decision,
3339
+ layer: result2.layer,
3340
+ layer2Mode: result2.layer2.mode,
2787
3341
  tool_use_id: p.tool_use_id ?? null,
2788
- tool_input: p.tool_input ?? null
3342
+ tool_input: p.tool_input ?? null,
3343
+ ...writeFields(action)
2789
3344
  }
2790
3345
  });
2791
3346
  const rec = { p, ledger, tool: action.tool, paths: action.paths, command: action.command, target: summarize(action), contractId: contract?.id ?? null };
2792
- recordFindings(rec, result.findings);
2793
- recordLayer2(rec, result.layer2Run, result.layer2.mode);
2794
- return { exitCode: 0, stdout: decisionOutput("PreToolUse", result), stderr: "" };
3347
+ recordFindings(rec, result2.findings);
3348
+ recordLayer2(rec, result2.layer2Run, result2.layer2.mode);
3349
+ return { exitCode: 0, stdout: decisionOutput("PreToolUse", result2), stderr: "" };
2795
3350
  });
2796
3351
  }
2797
3352
  var DONE_SLOT_RULE = "done_stop_slot";
@@ -2819,18 +3374,18 @@ function stop(p, repoRoot2, env, deps) {
2819
3374
  const session = readEvents(ledger).events.filter((e) => e.sessionId === p.session_id);
2820
3375
  const lastAssistantMessage = typeof p.last_assistant_message === "string" ? p.last_assistant_message : "";
2821
3376
  const stopHookActive = p.stop_hook_active === true;
2822
- return andThen(decide(p, repoRoot2, { kind: "stop", lastAssistantMessage, stopHookActive }, session, env, deps), ({ result, contract }) => {
2823
- const deciding = result.findings.find((f) => f.decision === result.decision);
3377
+ return andThen(decide(p, repoRoot2, { kind: "stop", lastAssistantMessage, stopHookActive }, session, env, deps), ({ result: result2, contract }) => {
3378
+ const deciding = result2.findings.find((f) => f.decision === result2.decision);
2824
3379
  const done = contract?.done?.length && !stopHookActive ? doneStatus(contract, session, repoRoot2) : null;
2825
3380
  const cap = contract?.max_stop_blocks ?? DEFAULT_MAX_STOP_BLOCKS;
2826
3381
  const unmet = done !== null && done.missing.length > 0;
2827
3382
  const slot = unmet ? reserveStopSlot(p, ledger) : null;
2828
3383
  const blocksSoFar = slot?.ahead ?? 0;
2829
3384
  const blockStop = unmet && blocksSoFar < cap;
2830
- const steer = result.decision === "block" ? { decision: "steer", message: result.message } : result;
3385
+ const steer = result2.decision === "block" ? { decision: "steer", message: result2.message } : result2;
2831
3386
  const doneText = !unmet ? null : blockStop ? `${doneMessage(done)} (stop block ${blocksSoFar + 1} of ${cap})` : `${doneMessage(done)} Replume is not blocking again (max_stop_blocks ${cap} reached).`;
2832
3387
  const message = doneText ? [doneText, steer.decision === "ok" ? "" : steer.message ?? ""].filter(Boolean).join("\n") : null;
2833
- const stopDecision = doneText ? blockStop ? "block" : "steer" : result.decision;
3388
+ const stopDecision = doneText ? blockStop ? "block" : "steer" : result2.decision;
2834
3389
  appendEvent(ledger, {
2835
3390
  ...base(p, "stop"),
2836
3391
  tool: null,
@@ -2839,19 +3394,19 @@ function stop(p, repoRoot2, env, deps) {
2839
3394
  summary: stopHookActive ? "agent finished its turn (continuing after stop-hook feedback)" : "agent finished its turn",
2840
3395
  rule: doneText ? DONE_RULE : deciding?.rule ?? null,
2841
3396
  action: ledgerAction(stopDecision),
2842
- message: message ?? result.message,
3397
+ message: message ?? result2.message,
2843
3398
  raw: {
2844
3399
  decision: stopDecision,
2845
- layer: result.layer,
2846
- layer2Mode: result.layer2.mode,
3400
+ layer: result2.layer,
3401
+ layer2Mode: result2.layer2.mode,
2847
3402
  stopHookActive,
2848
3403
  lastAssistantMessage: lastAssistantMessage.slice(0, 2e3),
2849
3404
  ...done ? { done: { checked: done.checked, missing: done.missing, blocked: blockStop } } : {}
2850
3405
  }
2851
3406
  });
2852
3407
  const rec = { p, ledger, tool: null, paths: [], command: null, target: "stop", contractId: contract?.id ?? null };
2853
- recordFindings(rec, result.findings);
2854
- recordLayer2(rec, result.layer2Run, result.layer2.mode);
3408
+ recordFindings(rec, result2.findings);
3409
+ recordLayer2(rec, result2.layer2Run, result2.layer2.mode);
2855
3410
  if (!unmet || !doneText || !message) return { exitCode: 0, stdout: decisionOutput("Stop", steer), stderr: "" };
2856
3411
  appendEvent(ledger, {
2857
3412
  ...base(p, "rule_hit"),
@@ -2886,6 +3441,7 @@ function postToolUse(p, repoRoot2, failed) {
2886
3441
  tool_use_id: p.tool_use_id ?? null,
2887
3442
  duration_ms: p.duration_ms ?? null,
2888
3443
  ok: !failed,
3444
+ ...writeFields(action),
2889
3445
  ...failed ? {
2890
3446
  exitCode: exitMatch ? Number(exitMatch[1]) : null,
2891
3447
  isInterrupt: p.is_interrupt === true,
@@ -2913,9 +3469,9 @@ function prompt(p, repoRoot2) {
2913
3469
  }
2914
3470
  function failOpen(err, repoRoot2, env) {
2915
3471
  try {
2916
- const dir = join8(repoRoot2 ?? env.CLAUDE_PROJECT_DIR ?? process.cwd(), REPLUME_DIR);
3472
+ const dir = join10(repoRoot2 ?? env.CLAUDE_PROJECT_DIR ?? process.cwd(), REPLUME_DIR);
2917
3473
  mkdirSync6(dir, { recursive: true });
2918
- appendFileSync3(join8(dir, "hook-errors.log"), `${(/* @__PURE__ */ new Date()).toISOString()} ${err.stack ?? err}
3474
+ appendFileSync3(join10(dir, "hook-errors.log"), `${(/* @__PURE__ */ new Date()).toISOString()} ${err.stack ?? err}
2919
3475
  `);
2920
3476
  } catch {
2921
3477
  }
@@ -2950,19 +3506,19 @@ function handleClaudeHook(stdinText, env = process.env, deps = {}) {
2950
3506
  out = QUIET;
2951
3507
  }
2952
3508
  const root = repoRoot2;
2953
- const finish = (result) => {
3509
+ const finish = (result2) => {
2954
3510
  if ((p.hook_event_name === "Stop" || p.hook_event_name === "SessionEnd") && p.transcript_path) {
2955
3511
  const contract = loadContract(contractPath(root));
2956
3512
  if (contract && contract.status !== "draft") captureClaudeUsage({
2957
3513
  repoRoot: root,
2958
- allowedRoot: join8(env.HOME ?? homedir2(), ".claude", "projects"),
3514
+ allowedRoot: join10(env.HOME ?? homedir2(), ".claude", "projects"),
2959
3515
  transcriptPath: p.transcript_path,
2960
3516
  taskId: contract.id,
2961
3517
  sessionId: p.session_id,
2962
3518
  final: p.hook_event_name === "SessionEnd"
2963
3519
  });
2964
3520
  }
2965
- return result;
3521
+ return result2;
2966
3522
  };
2967
3523
  return out instanceof Promise ? out.then(finish).catch((err) => failOpen(err, root, env)) : Promise.resolve(finish(out));
2968
3524
  } catch (err) {
@@ -3217,13 +3773,16 @@ function ruleDecisionToHookResponse(hookEventName, decision2) {
3217
3773
  }
3218
3774
 
3219
3775
  // src/agents/cursor/action.ts
3220
- import { isAbsolute as isAbsolute6, join as join9 } from "path";
3776
+ import { isAbsolute as isAbsolute6, join as join11 } from "path";
3221
3777
  var str2 = (v) => typeof v === "string" && v.length > 0 ? v : null;
3222
3778
  function cursorPayloadToAction(raw3, repoRoot2, fallback = {}) {
3223
3779
  const hook2 = str2(raw3.hook_event_name);
3224
3780
  const toolInput = raw3.tool_input && typeof raw3.tool_input === "object" ? raw3.tool_input : {};
3225
3781
  const command = str2(raw3.command) && hook2 !== "beforeMCPExecution" ? str2(raw3.command) : str2(raw3.tool_name) === "Shell" ? str2(toolInput.command) : fallback.command ?? null;
3226
- const abs = (p) => isAbsolute6(p) ? p : join9(repoRoot2, p);
3782
+ const abs = (p) => {
3783
+ const h = expandHome(p);
3784
+ return isAbsolute6(h) || h.startsWith("~") ? h : join11(repoRoot2, h);
3785
+ };
3227
3786
  const proposedPath = hook2 === "preToolUse" && /write|edit|delete/i.test(str2(raw3.tool_name) ?? "") ? str2(toolInput.file_path) : null;
3228
3787
  const files = [str2(raw3.file_path), proposedPath, ...fallback.paths ?? []].filter((p) => !!p).map(abs);
3229
3788
  const edits = Array.isArray(raw3.edits) ? raw3.edits : [];
@@ -3231,7 +3790,8 @@ function cursorPayloadToAction(raw3, repoRoot2, fallback = {}) {
3231
3790
  if (command) kind = "command";
3232
3791
  else if (edits.length || hook2 === "afterFileEdit" || hook2 === "afterTabFileEdit" || /write|edit|delete/i.test(fallback.tool ?? "")) kind = "edit";
3233
3792
  else if (hook2 === "beforeReadFile" || hook2 === "beforeTabFileRead" || /read|grep/i.test(fallback.tool ?? "")) kind = "read";
3234
- const { inside, outside } = splitPaths(repoRoot2, [...new Set(files)]);
3793
+ const w = kind === "command" && command ? shellWriteTargets(command) : null;
3794
+ const { inside, outside } = splitPaths(repoRoot2, w ? w.paths.map(abs) : [...new Set(files)]);
3235
3795
  const changes = kind === "edit" && inside.length && edits.length ? [
3236
3796
  {
3237
3797
  path: inside[0],
@@ -3247,7 +3807,9 @@ function cursorPayloadToAction(raw3, repoRoot2, fallback = {}) {
3247
3807
  outsidePaths: outside,
3248
3808
  command,
3249
3809
  changes,
3250
- fingerprint: fingerprint(tool, { files: inside, command, edits })
3810
+ fingerprint: fingerprint(tool, { files: inside, command, edits }),
3811
+ ...w?.unparsed ? { writeTargetNotParsed: true } : {},
3812
+ ...w ? { certainPaths: [...new Set(splitPaths(repoRoot2, w.certain.map(abs)).inside)] } : {}
3251
3813
  };
3252
3814
  }
3253
3815
 
@@ -3272,12 +3834,14 @@ function handleCursorHook(stdinText, env = process.env) {
3272
3834
  action: evaluation?.action ?? "ok",
3273
3835
  message: evaluation?.message ?? null
3274
3836
  };
3837
+ const writes = action.kind === "command" ? { writes: action.certainPaths ?? action.paths, ...action.writeTargetNotParsed ? { writeNote: "write target not parsed" } : {} } : {};
3275
3838
  appendEvent(ledger, sanitizeCursorLedgerEvent({
3276
3839
  ...event,
3840
+ ...action.kind === "command" ? { paths: action.paths } : {},
3277
3841
  rule: decision2.rule,
3278
3842
  action: decision2.action,
3279
3843
  message: decision2.message,
3280
- raw: { ...event.raw, fingerprint: action.fingerprint }
3844
+ raw: { ...event.raw, fingerprint: action.fingerprint, ...writes }
3281
3845
  }, root));
3282
3846
  for (const hit of evaluation?.hits ?? []) {
3283
3847
  appendEvent(ledger, sanitizeCursorLedgerEvent(ruleHitEvent(event, hit, { id: newEventId(), ts: (/* @__PURE__ */ new Date()).toISOString() }), root));
@@ -3289,10 +3853,221 @@ function handleCursorHook(stdinText, env = process.env) {
3289
3853
  }
3290
3854
  }
3291
3855
 
3292
- // src/agents/cursor/install.ts
3856
+ // src/agents/codex/handler.ts
3857
+ import { execFileSync as execFileSync6 } from "child_process";
3293
3858
  import { createHash as createHash2 } from "crypto";
3294
- import { existsSync as existsSync8, mkdirSync as mkdirSync7, readFileSync as readFileSync9, renameSync, rmSync as rmSync2, writeFileSync as writeFileSync5 } from "fs";
3295
- import { dirname as dirname4, join as join10 } from "path";
3859
+ import { existsSync as existsSync9, readFileSync as readFileSync10 } from "fs";
3860
+ import { resolve as resolve6 } from "path";
3861
+
3862
+ // src/agents/codex/action.ts
3863
+ import { resolve as resolve5 } from "path";
3864
+ function codexAction(payload, root) {
3865
+ const tool = typeof payload.tool_name === "string" ? payload.tool_name : "unknown";
3866
+ const input = payload.tool_input && typeof payload.tool_input === "object" && !Array.isArray(payload.tool_input) ? payload.tool_input : {};
3867
+ const command = typeof input.command === "string" ? input.command : "";
3868
+ if (tool === "Bash") {
3869
+ return toProposedAction({ session_id: "", cwd: String(payload.cwd), hook_event_name: "PreToolUse", tool_name: tool, tool_input: input }, root);
3870
+ }
3871
+ if (tool === "apply_patch") {
3872
+ const rawPaths = [...command.matchAll(/^\*\*\* (?:Add File|Update File|Delete File|Move to): (.+)$/gm)].map((m) => m[1].trim());
3873
+ const { inside, outside } = splitPaths(root, rawPaths.map((p) => resolve5(String(payload.cwd), p)));
3874
+ return {
3875
+ kind: "edit",
3876
+ tool,
3877
+ paths: [...new Set(inside)],
3878
+ outsidePaths: outside,
3879
+ command: null,
3880
+ changes: [],
3881
+ fingerprint: fingerprint(tool, input)
3882
+ };
3883
+ }
3884
+ return { kind: "other", tool, paths: [], outsidePaths: [], command: null, changes: [], fingerprint: fingerprint(tool, input) };
3885
+ }
3886
+
3887
+ // src/agents/codex/handler.ts
3888
+ var QUIET2 = { stdout: "", stderr: "", exitCode: 0 };
3889
+ var object = (x) => !!x && typeof x === "object" && !Array.isArray(x);
3890
+ var str3 = (x) => typeof x === "string" ? x : "";
3891
+ var result = (stdout) => ({ ...QUIET2, stdout });
3892
+ var fileHash = (path) => {
3893
+ try {
3894
+ return createHash2("sha256").update(readFileSync10(path)).digest("hex");
3895
+ } catch {
3896
+ return null;
3897
+ }
3898
+ };
3899
+ function handleCodexHook(stdinText) {
3900
+ try {
3901
+ if (stdinText.length > 1024 * 1024) throw new Error("oversized hook input");
3902
+ const p = JSON.parse(stdinText);
3903
+ if (!object(p) || !str3(p.session_id) || !str3(p.cwd) || !str3(p.hook_event_name)) throw new Error("invalid hook input");
3904
+ const cwd = resolve6(str3(p.cwd));
3905
+ let root = cwd;
3906
+ try {
3907
+ root = execFileSync6("git", ["rev-parse", "--show-toplevel"], { cwd, stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
3908
+ } catch {
3909
+ }
3910
+ if (!existsSync9(resolve6(root, ".replume"))) return QUIET2;
3911
+ const ledger = ledgerPath(root);
3912
+ const base3 = (type) => ({
3913
+ agent: "codex",
3914
+ agentVersion: null,
3915
+ sessionId: str3(p.session_id),
3916
+ type,
3917
+ tool: null,
3918
+ paths: [],
3919
+ command: null,
3920
+ summary: str3(p.hook_event_name),
3921
+ rule: null,
3922
+ action: null,
3923
+ message: null
3924
+ });
3925
+ const event = str3(p.hook_event_name);
3926
+ if (event === "UserPromptSubmit") {
3927
+ appendEvent(ledger, { ...base3("prompt"), summary: "Codex prompt received" });
3928
+ return QUIET2;
3929
+ }
3930
+ if (event === "Stop" || event === "SessionEnd") {
3931
+ appendEvent(ledger, { ...base3("stop"), summary: `Codex ${event}` });
3932
+ return QUIET2;
3933
+ }
3934
+ if (event !== "PreToolUse" && event !== "PostToolUse") return QUIET2;
3935
+ const a = codexAction(p, root);
3936
+ const raw3 = {
3937
+ tool_use_id: str3(p.tool_use_id),
3938
+ fingerprint: a.fingerprint,
3939
+ ...a.kind === "command" ? { writes: a.certainPaths ?? a.paths, ...a.writeTargetNotParsed ? { writeNote: "write target not parsed" } : {} } : {}
3940
+ };
3941
+ const loaded = loadContract(contractPath(root));
3942
+ const contract = isDraft(loaded) ? null : loaded;
3943
+ if (event === "PreToolUse") {
3944
+ const history = readEvents(ledger).events.filter((e) => e.sessionId === str3(p.session_id));
3945
+ const verdict = evaluate(a, { contract, history, repoRoot: root });
3946
+ const first = verdict.hits[0];
3947
+ appendEvent(ledger, {
3948
+ ...base3("tool_pre"),
3949
+ tool: a.tool,
3950
+ paths: a.paths,
3951
+ command: a.command,
3952
+ summary: a.command ? `${a.tool}: ${a.command}` : `${a.tool} ${a.paths.join(", ")}`,
3953
+ rule: first?.rule ?? null,
3954
+ action: verdict.action,
3955
+ message: verdict.message,
3956
+ raw: { ...raw3, beforeHashes: a.kind === "edit" ? Object.fromEntries(a.paths.map((path) => [path, fileHash(resolve6(root, path))])) : void 0 }
3957
+ });
3958
+ for (const hit of verdict.hits) appendEvent(ledger, {
3959
+ ...base3("rule_hit"),
3960
+ tool: a.tool,
3961
+ paths: a.paths,
3962
+ command: a.command,
3963
+ summary: `${hit.rule}: ${hit.action}`,
3964
+ rule: hit.rule,
3965
+ action: hit.action,
3966
+ message: hit.message,
3967
+ raw: { tool_use_id: str3(p.tool_use_id), layer: "layer1", sentToAgent: true }
3968
+ });
3969
+ if (verdict.action === "block") return result(JSON.stringify({ hookSpecificOutput: {
3970
+ hookEventName: "PreToolUse",
3971
+ permissionDecision: "deny",
3972
+ permissionDecisionReason: verdict.message
3973
+ } }));
3974
+ if (verdict.action === "warn") return result(JSON.stringify({ hookSpecificOutput: {
3975
+ hookEventName: "PreToolUse",
3976
+ additionalContext: verdict.message
3977
+ } }));
3978
+ return QUIET2;
3979
+ }
3980
+ const response = object(p.tool_response) ? p.tool_response : {};
3981
+ const responseText = str3(p.tool_response);
3982
+ const exit = typeof response.exit_code === "number" ? response.exit_code : typeof response.exitCode === "number" ? response.exitCode : /^Exit code: (\d+)\b/m.exec(responseText)?.[1];
3983
+ const success = exit === 0 || exit === "0";
3984
+ const prior = readEvents(ledger).events.reverse().find((e) => e.type === "tool_pre" && e.raw?.tool_use_id === str3(p.tool_use_id));
3985
+ const before = prior?.raw?.beforeHashes;
3986
+ const changed = a.paths.filter((path) => before && path in before && fileHash(resolve6(root, path)) !== before[path]);
3987
+ appendEvent(ledger, {
3988
+ ...base3("tool_post"),
3989
+ tool: a.tool,
3990
+ paths: a.paths,
3991
+ command: a.command,
3992
+ summary: `${a.tool} completed`,
3993
+ raw: { ...raw3, ok: success }
3994
+ });
3995
+ if (a.kind === "edit" && changed.length && (success || responseText.includes("Success."))) {
3996
+ for (const path of changed)
3997
+ appendEvent(ledger, {
3998
+ ...base3("file_edit"),
3999
+ tool: a.tool,
4000
+ paths: [path],
4001
+ summary: `${a.tool} changed ${path}`,
4002
+ action: "ok",
4003
+ raw: raw3
4004
+ });
4005
+ } else if (a.kind === "command") {
4006
+ appendEvent(ledger, {
4007
+ ...base3("command"),
4008
+ tool: a.tool,
4009
+ paths: a.paths,
4010
+ command: a.command,
4011
+ summary: `${a.tool}: ${a.command}`,
4012
+ raw: { ...raw3, ok: success }
4013
+ });
4014
+ }
4015
+ return QUIET2;
4016
+ } catch {
4017
+ return { stdout: "", stderr: "replume codex hook: failed to process payload\n", exitCode: 0 };
4018
+ }
4019
+ }
4020
+
4021
+ // src/agents/codex/install.ts
4022
+ import { copyFileSync as copyFileSync2, existsSync as existsSync10, mkdirSync as mkdirSync7, readFileSync as readFileSync11, writeFileSync as writeFileSync5 } from "fs";
4023
+ import { dirname as dirname5, join as join12 } from "path";
4024
+ var CODEX_HOOK_COMMAND = "replume hook codex";
4025
+ var CODEX_HOOK_EVENTS = ["PreToolUse", "PostToolUse", "UserPromptSubmit", "Stop", "SessionEnd"];
4026
+ var codexHooksPath = (root) => join12(root, ".codex", "hooks.json");
4027
+ var obj2 = (x) => !!x && typeof x === "object" && !Array.isArray(x);
4028
+ function mergeCodexHooks(config, command = CODEX_HOOK_COMMAND) {
4029
+ if (config.hooks !== void 0 && !obj2(config.hooks)) throw new Error("hooks must be an object");
4030
+ const hooks = { ...config.hooks ?? {} };
4031
+ const added = [];
4032
+ for (const event of CODEX_HOOK_EVENTS) {
4033
+ const groups = hooks[event] ?? [];
4034
+ if (!Array.isArray(groups)) throw new Error(`hooks.${event} must be an array`);
4035
+ if (groups.some((g) => obj2(g) && Array.isArray(g.hooks) && g.hooks.some((h) => obj2(h) && h.command === command))) continue;
4036
+ const entry = { hooks: [{ type: "command", command, timeout: event === "SessionEnd" ? 2 : 10 }] };
4037
+ hooks[event] = [...groups, event === "PreToolUse" || event === "PostToolUse" ? { matcher: "^(Bash|apply_patch)$", ...entry } : entry];
4038
+ added.push(event);
4039
+ }
4040
+ return { config: { ...config, hooks }, added };
4041
+ }
4042
+ function validateCodexHooks(root) {
4043
+ const path = codexHooksPath(root);
4044
+ if (!existsSync10(path)) return;
4045
+ try {
4046
+ const parsed = JSON.parse(readFileSync11(path, "utf8"));
4047
+ if (!obj2(parsed)) throw new Error("expected an object");
4048
+ mergeCodexHooks(parsed);
4049
+ } catch (e) {
4050
+ throw new Error(`${path} is invalid (${e.message}); nothing was changed. Fix it and run replume init again.`);
4051
+ }
4052
+ }
4053
+ function installCodexHooks(root) {
4054
+ validateCodexHooks(root);
4055
+ const path = codexHooksPath(root);
4056
+ const original = existsSync10(path) ? readFileSync11(path, "utf8") : null;
4057
+ const parsed = original ? JSON.parse(original) : {};
4058
+ const { config, added } = mergeCodexHooks(parsed);
4059
+ if (added.length) {
4060
+ mkdirSync7(dirname5(path), { recursive: true });
4061
+ if (original !== null && !existsSync10(`${path}.replume-backup`)) copyFileSync2(path, `${path}.replume-backup`);
4062
+ writeFileSync5(path, JSON.stringify(config, null, 2) + "\n");
4063
+ }
4064
+ return { path, added, backupPath: original === null ? null : `${path}.replume-backup` };
4065
+ }
4066
+
4067
+ // src/agents/cursor/install.ts
4068
+ import { createHash as createHash3 } from "crypto";
4069
+ import { existsSync as existsSync11, mkdirSync as mkdirSync8, readFileSync as readFileSync12, renameSync, rmSync as rmSync2, writeFileSync as writeFileSync6 } from "fs";
4070
+ import { dirname as dirname6, join as join13 } from "path";
3296
4071
  var CURSOR_HOOK_COMMAND = "replume hook cursor";
3297
4072
  var CURSOR_HOOK_EVENTS = [
3298
4073
  "preToolUse",
@@ -3306,9 +4081,9 @@ var CURSOR_HOOK_EVENTS = [
3306
4081
  "stop",
3307
4082
  "sessionEnd"
3308
4083
  ];
3309
- var hash = (text) => createHash2("sha256").update(text).digest("hex");
4084
+ var hash = (text) => createHash3("sha256").update(text).digest("hex");
3310
4085
  var isObject = (x) => !!x && typeof x === "object" && !Array.isArray(x);
3311
- var cursorHooksPath = (root) => join10(root, ".cursor", "hooks.json");
4086
+ var cursorHooksPath = (root) => join13(root, ".cursor", "hooks.json");
3312
4087
  var backupPathFor = (path) => `${path}.replume-backup`;
3313
4088
  var statePathFor = (path) => `${path}.replume-state.json`;
3314
4089
  function parseConfig(text, path) {
@@ -3332,17 +4107,17 @@ function exactInstalledEntry(x, command) {
3332
4107
  function validateCursorHooksConfig(root, opts = {}) {
3333
4108
  const path = cursorHooksPath(root);
3334
4109
  const command = opts.command ?? CURSOR_HOOK_COMMAND;
3335
- const config = existsSync8(path) ? parseConfig(readFileSync9(path, "utf8"), path) : {};
4110
+ const config = existsSync11(path) ? parseConfig(readFileSync12(path, "utf8"), path) : {};
3336
4111
  const hooks = config.hooks ?? {};
3337
4112
  for (const event of CURSOR_HOOK_EVENTS) {
3338
4113
  if (hooks[event] !== void 0 && !Array.isArray(hooks[event]))
3339
4114
  throw new Error(`${path}.hooks.${event} is not an array; nothing was changed. Fix it and run replume init again.`);
3340
4115
  }
3341
4116
  const statePath = statePathFor(path);
3342
- if (existsSync8(statePath)) {
4117
+ if (existsSync11(statePath)) {
3343
4118
  let state;
3344
4119
  try {
3345
- state = JSON.parse(readFileSync9(statePath, "utf8"));
4120
+ state = JSON.parse(readFileSync12(statePath, "utf8"));
3346
4121
  } catch {
3347
4122
  throw new Error(`${statePath} is not valid JSON; nothing was changed. Fix it and run replume init again.`);
3348
4123
  }
@@ -3354,9 +4129,9 @@ function installCursorHooks(root, opts = {}) {
3354
4129
  const path = cursorHooksPath(root);
3355
4130
  const backupPath = backupPathFor(path);
3356
4131
  const statePath = statePathFor(path);
3357
- const hadOriginal = existsSync8(path);
4132
+ const hadOriginal = existsSync11(path);
3358
4133
  const command = opts.command ?? CURSOR_HOOK_COMMAND;
3359
- const original = hadOriginal ? readFileSync9(path, "utf8") : null;
4134
+ const original = hadOriginal ? readFileSync12(path, "utf8") : null;
3360
4135
  const config = original === null ? {} : parseConfig(original, path);
3361
4136
  const hooks = { ...config.hooks ?? {} };
3362
4137
  const added = [];
@@ -3368,36 +4143,36 @@ function installCursorHooks(root, opts = {}) {
3368
4143
  added.push(event);
3369
4144
  }
3370
4145
  }
3371
- if (!added.length) return { path, backupPath: existsSync8(backupPath) ? backupPath : null, added };
4146
+ if (!added.length) return { path, backupPath: existsSync11(backupPath) ? backupPath : null, added };
3372
4147
  const updated = JSON.stringify({ ...config, version: 1, hooks }, null, 2) + "\n";
3373
- mkdirSync7(dirname4(path), { recursive: true });
3374
- if (!existsSync8(statePath)) {
3375
- if (original !== null) writeFileSync5(backupPath, original, { flag: "wx" });
3376
- writeFileSync5(statePath, JSON.stringify({ hadOriginal, installedHash: hash(updated), added, command }) + "\n", { flag: "wx" });
4148
+ mkdirSync8(dirname6(path), { recursive: true });
4149
+ if (!existsSync11(statePath)) {
4150
+ if (original !== null) writeFileSync6(backupPath, original, { flag: "wx" });
4151
+ writeFileSync6(statePath, JSON.stringify({ hadOriginal, installedHash: hash(updated), added, command }) + "\n", { flag: "wx" });
3377
4152
  } else {
3378
- const state = JSON.parse(readFileSync9(statePath, "utf8"));
4153
+ const state = JSON.parse(readFileSync12(statePath, "utf8"));
3379
4154
  if (state.command !== command) throw new Error(`Cursor hooks already installed with a different command; uninstall first`);
3380
4155
  state.added = [.../* @__PURE__ */ new Set([...state.added, ...added])];
3381
4156
  state.installedHash = hash(updated);
3382
- writeFileSync5(statePath, JSON.stringify(state) + "\n");
4157
+ writeFileSync6(statePath, JSON.stringify(state) + "\n");
3383
4158
  }
3384
4159
  const temp = `${path}.replume-tmp`;
3385
- writeFileSync5(temp, updated);
4160
+ writeFileSync6(temp, updated);
3386
4161
  renameSync(temp, path);
3387
- return { path, backupPath: existsSync8(backupPath) ? backupPath : null, added };
4162
+ return { path, backupPath: existsSync11(backupPath) ? backupPath : null, added };
3388
4163
  }
3389
4164
  function uninstallCursorHooks(root) {
3390
4165
  const path = cursorHooksPath(root);
3391
4166
  const backupPath = backupPathFor(path);
3392
4167
  const statePath = statePathFor(path);
3393
- if (!existsSync8(statePath)) return { restored: false, path };
3394
- const state = JSON.parse(readFileSync9(statePath, "utf8"));
3395
- const current = existsSync8(path) ? readFileSync9(path, "utf8") : null;
4168
+ if (!existsSync11(statePath)) return { restored: false, path };
4169
+ const state = JSON.parse(readFileSync12(statePath, "utf8"));
4170
+ const current = existsSync11(path) ? readFileSync12(path, "utf8") : null;
3396
4171
  const unchanged = current !== null && hash(current) === state.installedHash;
3397
4172
  if (unchanged) {
3398
4173
  if (state.hadOriginal) {
3399
- if (!existsSync8(backupPath)) throw new Error(`Cursor hook backup missing: ${backupPath}`);
3400
- writeFileSync5(path, readFileSync9(backupPath));
4174
+ if (!existsSync11(backupPath)) throw new Error(`Cursor hook backup missing: ${backupPath}`);
4175
+ writeFileSync6(path, readFileSync12(backupPath));
3401
4176
  } else rmSync2(path);
3402
4177
  } else if (current !== null) {
3403
4178
  const config = parseConfig(current, path);
@@ -3413,20 +4188,20 @@ function uninstallCursorHooks(root) {
3413
4188
  if (kept.length) hooks[event] = kept;
3414
4189
  else delete hooks[event];
3415
4190
  }
3416
- writeFileSync5(path, JSON.stringify({ ...config, hooks }, null, 2) + "\n");
4191
+ writeFileSync6(path, JSON.stringify({ ...config, hooks }, null, 2) + "\n");
3417
4192
  }
3418
4193
  rmSync2(statePath);
3419
4194
  if (unchanged) rmSync2(backupPath, { force: true });
3420
- return { restored: true, path, backupRetained: !unchanged && existsSync8(backupPath) };
4195
+ return { restored: true, path, backupRetained: !unchanged && existsSync11(backupPath) };
3421
4196
  }
3422
4197
 
3423
4198
  // src/agents/claude/install.ts
3424
- import { copyFileSync as copyFileSync2, existsSync as existsSync9, mkdirSync as mkdirSync8, readFileSync as readFileSync10, writeFileSync as writeFileSync6 } from "fs";
3425
- import { dirname as dirname5, join as join11 } from "path";
4199
+ import { copyFileSync as copyFileSync3, existsSync as existsSync12, mkdirSync as mkdirSync9, readFileSync as readFileSync13, writeFileSync as writeFileSync7 } from "fs";
4200
+ import { dirname as dirname7, join as join14 } from "path";
3426
4201
  var DEFAULT_HOOK_COMMAND = "replume hook claude";
3427
4202
  var HOOK_EVENTS = ["PreToolUse", "PostToolUse", "PostToolUseFailure", "UserPromptSubmit", "Stop", "SessionEnd"];
3428
4203
  function claudeSettingsPath(repoRoot2) {
3429
- return join11(repoRoot2, ".claude", "settings.json");
4204
+ return join14(repoRoot2, ".claude", "settings.json");
3430
4205
  }
3431
4206
  var isReplume = (h, command) => typeof h.command === "string" && (h.command === command || /(^|\s)hook claude\s*$/.test(h.command));
3432
4207
  function mergeHooks(settings, command = DEFAULT_HOOK_COMMAND) {
@@ -3455,8 +4230,8 @@ function installClaudeHooks(repoRoot2, opts = {}) {
3455
4230
  const settingsPath = claudeSettingsPath(repoRoot2);
3456
4231
  let current = {};
3457
4232
  let original = null;
3458
- if (existsSync9(settingsPath)) {
3459
- original = readFileSync10(settingsPath, "utf-8");
4233
+ if (existsSync12(settingsPath)) {
4234
+ original = readFileSync13(settingsPath, "utf-8");
3460
4235
  try {
3461
4236
  const parsed = original.trim() ? JSON.parse(original) : {};
3462
4237
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("not a JSON object");
@@ -3471,24 +4246,24 @@ function installClaudeHooks(repoRoot2, opts = {}) {
3471
4246
  if (original !== null) {
3472
4247
  const stamp = (opts.now ?? /* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
3473
4248
  backupPath = `${settingsPath}.replume-backup-${stamp}`;
3474
- copyFileSync2(settingsPath, backupPath);
4249
+ copyFileSync3(settingsPath, backupPath);
3475
4250
  }
3476
- mkdirSync8(dirname5(settingsPath), { recursive: true });
3477
- writeFileSync6(settingsPath, JSON.stringify(settings, null, 2) + "\n");
4251
+ mkdirSync9(dirname7(settingsPath), { recursive: true });
4252
+ writeFileSync7(settingsPath, JSON.stringify(settings, null, 2) + "\n");
3478
4253
  }
3479
4254
  return { settingsPath, backupPath, added, alreadyPresent };
3480
4255
  }
3481
4256
 
3482
4257
  // src/agents/claude/replay-hooks.ts
3483
- import { mkdtempSync, mkdirSync as mkdirSync9, rmSync as rmSync3, writeFileSync as writeFileSync7 } from "fs";
4258
+ import { mkdtempSync, mkdirSync as mkdirSync10, rmSync as rmSync3, writeFileSync as writeFileSync8 } from "fs";
3484
4259
  import { tmpdir as tmpdir2 } from "os";
3485
- import { join as join12 } from "path";
4260
+ import { join as join15 } from "path";
3486
4261
  var REPLAY_HOOKS_BANNER = "HOOK PAYLOAD REPLAY. Each line is a Claude Code hook payload run through Replume's live hook handler in a temporary project.\nThis shows Replume's answers to these payloads; it does not show a live agent receiving or following them.";
3487
4262
  async function replayHooks(content, opts = {}) {
3488
- const repo = mkdtempSync(join12(tmpdir2(), "replume-replay-hooks-"));
4263
+ const repo = mkdtempSync(join15(tmpdir2(), "replume-replay-hooks-"));
3489
4264
  try {
3490
- mkdirSync9(join12(repo, ".replume"), { recursive: true });
3491
- if (opts.contractYaml) writeFileSync7(contractPath(repo), opts.contractYaml);
4265
+ mkdirSync10(join15(repo, ".replume"), { recursive: true });
4266
+ if (opts.contractYaml) writeFileSync8(contractPath(repo), opts.contractYaml);
3492
4267
  const env = { REPLUME_JUDGE: "off", ...opts.env, CLAUDE_PROJECT_DIR: repo };
3493
4268
  const steps = [];
3494
4269
  const lines = content.split("\n").filter((l) => l.trim());
@@ -3518,10 +4293,10 @@ async function replayHooks(content, opts = {}) {
3518
4293
  }
3519
4294
 
3520
4295
  // src/core/init.ts
3521
- import { execFileSync as execFileSync7 } from "child_process";
3522
- import { existsSync as existsSync11, mkdirSync as mkdirSync10, writeFileSync as writeFileSync9 } from "fs";
4296
+ import { execFileSync as execFileSync8 } from "child_process";
4297
+ import { existsSync as existsSync14, mkdirSync as mkdirSync11, writeFileSync as writeFileSync10 } from "fs";
3523
4298
  import { homedir as homedir3 } from "os";
3524
- import { join as join14 } from "path";
4299
+ import { delimiter, join as join17 } from "path";
3525
4300
 
3526
4301
  // src/core/capabilities.ts
3527
4302
  var CAPABILITIES = [
@@ -3543,11 +4318,11 @@ var CAPABILITIES = [
3543
4318
  },
3544
4319
  {
3545
4320
  agent: "Codex",
3546
- observe: "unverified",
3547
- block: "unverified",
3548
- steer: "unverified",
4321
+ observe: "verified live (CLI)",
4322
+ block: "verified live (CLI)",
4323
+ steer: "documented",
3549
4324
  installedByInit: false,
3550
- notes: "partial at best; live hook delivery has not been verified"
4325
+ notes: "Codex CLI 0.144.4: project hooks delivered PreToolUse and PostToolUse with trust enabled; Replume blocked one protected apply_patch and observed one allowed edit leading to a no-scenario PASS on 20.10.2026. Warning delivery and Stop continuation not tested; hook trust and disabled hooks limit capture; cloud project hooks unsupported."
3551
4326
  }
3552
4327
  ];
3553
4328
  function renderCapabilities(caps = CAPABILITIES, detected = {}) {
@@ -3576,10 +4351,10 @@ function renderCapabilities(caps = CAPABILITIES, detected = {}) {
3576
4351
  }
3577
4352
 
3578
4353
  // src/core/baseline.ts
3579
- import { createHash as createHash3 } from "crypto";
3580
- import { execFileSync as execFileSync6 } from "child_process";
3581
- import { appendFileSync as appendFileSync4, existsSync as existsSync10, lstatSync, readFileSync as readFileSync11, readdirSync, readlinkSync, writeFileSync as writeFileSync8 } from "fs";
3582
- import { dirname as dirname6, join as join13 } from "path";
4354
+ import { createHash as createHash4 } from "crypto";
4355
+ import { execFileSync as execFileSync7 } from "child_process";
4356
+ import { appendFileSync as appendFileSync4, existsSync as existsSync13, lstatSync, readFileSync as readFileSync14, readdirSync, readlinkSync, writeFileSync as writeFileSync9 } from "fs";
4357
+ import { dirname as dirname8, join as join16 } from "path";
3583
4358
  var BASELINE_FILE = "baseline.json";
3584
4359
  var DEFAULT_REPLUME_GITIGNORE = `events.jsonl
3585
4360
  reports/
@@ -3591,22 +4366,22 @@ acceptance.jsonl
3591
4366
  `;
3592
4367
  var SCENARIOS_SUBDIR = "scenarios";
3593
4368
  var PREFLIGHT_STATUS = ["works", "fails_before", "not_runnable", "skipped"];
3594
- var sha256 = (data) => createHash3("sha256").update(data).digest("hex");
4369
+ var sha256 = (data) => createHash4("sha256").update(data).digest("hex");
3595
4370
  var IGNORED_NAMES = /* @__PURE__ */ new Set([".DS_Store"]);
3596
4371
  function hashTree(dir) {
3597
4372
  const out = {};
3598
4373
  const walk = (abs, rel) => {
3599
4374
  for (const name of readdirSync(abs).sort()) {
3600
4375
  if (IGNORED_NAMES.has(name)) continue;
3601
- const p = join13(abs, name);
4376
+ const p = join16(abs, name);
3602
4377
  const r = rel ? `${rel}/${name}` : name;
3603
4378
  const st = lstatSync(p);
3604
4379
  if (st.isSymbolicLink()) out[r] = sha256(`symlink:${readlinkSync(p)}`);
3605
4380
  else if (st.isDirectory()) walk(p, r);
3606
- else if (st.isFile()) out[r] = sha256(readFileSync11(p));
4381
+ else if (st.isFile()) out[r] = sha256(readFileSync14(p));
3607
4382
  }
3608
4383
  };
3609
- if (existsSync10(dir)) {
4384
+ if (existsSync13(dir)) {
3610
4385
  try {
3611
4386
  if (lstatSync(dir).isDirectory()) walk(dir, "");
3612
4387
  } catch {
@@ -3616,19 +4391,19 @@ function hashTree(dir) {
3616
4391
  }
3617
4392
  function headCommit(repoRoot2) {
3618
4393
  try {
3619
- return execFileSync6("git", ["rev-parse", "--verify", "-q", "HEAD^{commit}"], { cwd: repoRoot2, stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
4394
+ return execFileSync7("git", ["rev-parse", "--verify", "-q", "HEAD^{commit}"], { cwd: repoRoot2, stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
3620
4395
  } catch {
3621
4396
  return null;
3622
4397
  }
3623
4398
  }
3624
- var baselinePath = (contractPath2) => join13(dirname6(contractPath2), BASELINE_FILE);
4399
+ var baselinePath = (contractPath2) => join16(dirname8(contractPath2), BASELINE_FILE);
3625
4400
  function ensureBaselineIgnored(replumeDir) {
3626
- const gi = join13(replumeDir, ".gitignore");
3627
- if (!existsSync10(gi)) {
3628
- writeFileSync8(gi, DEFAULT_REPLUME_GITIGNORE);
4401
+ const gi = join16(replumeDir, ".gitignore");
4402
+ if (!existsSync13(gi)) {
4403
+ writeFileSync9(gi, DEFAULT_REPLUME_GITIGNORE);
3629
4404
  return;
3630
4405
  }
3631
- const text = readFileSync11(gi, "utf-8");
4406
+ const text = readFileSync14(gi, "utf-8");
3632
4407
  if (text.split(/\r?\n/).some((l) => l.trim() === BASELINE_FILE)) return;
3633
4408
  appendFileSync4(gi, `${text === "" || text.endsWith("\n") ? "" : "\n"}${BASELINE_FILE}
3634
4409
  `);
@@ -3640,12 +4415,12 @@ function recordBaseline(opts) {
3640
4415
  contract_id: opts.contractId,
3641
4416
  recorded_at: (opts.now ?? /* @__PURE__ */ new Date()).toISOString(),
3642
4417
  base_commit: headCommit(opts.repoRoot),
3643
- policy_hash: sha256(readFileSync11(opts.contractPath)),
3644
- scenario_hashes: hashTree(opts.scenariosDir ?? join13(dirname6(opts.contractPath), SCENARIOS_SUBDIR)),
4418
+ policy_hash: sha256(readFileSync14(opts.contractPath)),
4419
+ scenario_hashes: hashTree(opts.scenariosDir ?? join16(dirname8(opts.contractPath), SCENARIOS_SUBDIR)),
3645
4420
  ...opts.preflight ? { preflight: opts.preflight } : {}
3646
4421
  };
3647
- writeFileSync8(path, JSON.stringify(baseline, null, 2) + "\n", "utf-8");
3648
- ensureBaselineIgnored(dirname6(opts.contractPath));
4422
+ writeFileSync9(path, JSON.stringify(baseline, null, 2) + "\n", "utf-8");
4423
+ ensureBaselineIgnored(dirname8(opts.contractPath));
3649
4424
  return { path, baseline };
3650
4425
  }
3651
4426
  var isHash = (x) => typeof x === "string" && /^[0-9a-f]{64}$/.test(x);
@@ -3666,10 +4441,10 @@ function validateBaseline(x) {
3666
4441
  return p;
3667
4442
  }
3668
4443
  function readBaseline(path) {
3669
- if (!existsSync10(path)) return { status: "absent" };
4444
+ if (!existsSync13(path)) return { status: "absent" };
3670
4445
  let raw3;
3671
4446
  try {
3672
- raw3 = JSON.parse(readFileSync11(path, "utf-8"));
4447
+ raw3 = JSON.parse(readFileSync14(path, "utf-8"));
3673
4448
  } catch (e) {
3674
4449
  return { status: "unreadable", detail: e.message };
3675
4450
  }
@@ -3703,26 +4478,39 @@ function compareToBaseline(baseline, current, displayPaths) {
3703
4478
  // src/core/init.ts
3704
4479
  function onPath(bin) {
3705
4480
  try {
3706
- execFileSync7(process.platform === "win32" ? "where" : "which", [bin], { stdio: "ignore" });
4481
+ execFileSync8(process.platform === "win32" ? "where" : "which", [bin], { stdio: "ignore" });
3707
4482
  return true;
3708
4483
  } catch {
3709
4484
  return false;
3710
4485
  }
3711
4486
  }
4487
+ function launchedThroughNpx() {
4488
+ return process.env.npm_command === "exec" || (process.env.PATH ?? "").split(delimiter).some((entry) => entry.split(/[\\/]/).includes("_npx"));
4489
+ }
4490
+ function gitIdentity(repoRoot2, key) {
4491
+ try {
4492
+ return execFileSync8("git", ["-C", repoRoot2, "config", "--get", key], {
4493
+ encoding: "utf8",
4494
+ stdio: ["ignore", "pipe", "ignore"]
4495
+ }).trim().length > 0;
4496
+ } catch {
4497
+ return false;
4498
+ }
4499
+ }
3712
4500
  function detectAgents(repoRoot2, home = homedir3()) {
3713
4501
  return {
3714
- "Claude Code": onPath("claude") || existsSync11(join14(repoRoot2, ".claude")) || existsSync11(join14(home, ".claude")),
3715
- Cursor: onPath("cursor") || existsSync11(join14(repoRoot2, ".cursor")) || existsSync11(join14(home, ".cursor")),
3716
- Codex: onPath("codex") || existsSync11(join14(home, ".codex"))
4502
+ "Claude Code": onPath("claude") || existsSync14(join17(repoRoot2, ".claude")) || existsSync14(join17(home, ".claude")),
4503
+ Cursor: onPath("cursor") || existsSync14(join17(repoRoot2, ".cursor")) || existsSync14(join17(home, ".cursor")),
4504
+ Codex: onPath("codex") || existsSync14(join17(home, ".codex"))
3717
4505
  };
3718
4506
  }
3719
4507
  function runInit(repoRoot2, opts = {}) {
3720
4508
  const lines = [];
3721
- const dir = join14(repoRoot2, REPLUME_DIR);
3722
- mkdirSync10(join14(dir, "scenarios"), { recursive: true });
3723
- const gi = join14(dir, ".gitignore");
3724
- if (!existsSync11(gi)) {
3725
- writeFileSync9(gi, DEFAULT_REPLUME_GITIGNORE);
4509
+ const dir = join17(repoRoot2, REPLUME_DIR);
4510
+ mkdirSync11(join17(dir, "scenarios"), { recursive: true });
4511
+ const gi = join17(dir, ".gitignore");
4512
+ if (!existsSync14(gi)) {
4513
+ writeFileSync10(gi, DEFAULT_REPLUME_GITIGNORE);
3726
4514
  }
3727
4515
  lines.push(`\u2713 ${REPLUME_DIR}/ ready (scenarios/ for team scenarios; ledger and reports are git-ignored)`);
3728
4516
  const detected = opts.detected ?? detectAgents(repoRoot2);
@@ -3734,11 +4522,14 @@ function runInit(repoRoot2, opts = {}) {
3734
4522
  lines.push(`\u2713 Claude Code hooks added to .claude/settings.json: ${claude.added.join(", ")}`);
3735
4523
  if (claude.backupPath) lines.push(` backup of your previous settings: ${claude.backupPath}`);
3736
4524
  } else lines.push("\u2713 Claude Code hooks already present (nothing changed)");
3737
- if (!opts.hookCommand && !onPath("replume")) {
3738
- lines.push("! `replume` is not on PATH, so the hooks will fail (Claude Code then continues without them).");
3739
- lines.push(" Install the published command with `npm i -g replume-cli`, then re-run `replume init`.");
3740
- }
3741
4525
  } else lines.push("- Claude Code not found: no hooks written");
4526
+ const npx = launchedThroughNpx();
4527
+ const persistentCommand = !npx && onPath("replume");
4528
+ const defaultHooks = detected["Claude Code"] && !opts.hookCommand || opts.cursorHooks && !opts.cursorHookCommand || opts.codexHooks;
4529
+ if (defaultHooks && !persistentCommand) {
4530
+ lines.push(npx ? "! `replume` is not guaranteed on PATH for later agent hooks: npx's temporary PATH entry does not persist, so those hooks may fail." : "! `replume` is not on PATH, so the hooks will fail (the agent then continues without them).");
4531
+ lines.push(" Install the persistent command with `npm install -g replume-cli`, then re-run `replume init`.");
4532
+ }
3742
4533
  for (const c of CAPABILITIES.filter((x) => !x.installedByInit)) {
3743
4534
  if (detected[c.agent]) lines.push(`- ${c.agent} found, but replume init does not install its hooks yet (${c.observe})`);
3744
4535
  }
@@ -3746,15 +4537,18 @@ function runInit(repoRoot2, opts = {}) {
3746
4537
  lines.push("Capability matrix");
3747
4538
  lines.push(renderCapabilities(CAPABILITIES, detected));
3748
4539
  lines.push("");
3749
- lines.push('Next: replume draft --task "<what you asked the agent>"; review .replume/contract.yaml, then run `replume confirm`.');
3750
- lines.push('To make commits, Git needs an identity in this repository: `git config user.name "Your Name"` and `git config user.email "you@example.com"`.');
4540
+ const draftCommand = persistentCommand ? "replume draft" : "npm install -g replume-cli && replume draft";
4541
+ lines.push(`Next: ${draftCommand} --task "<what you asked the agent>"; review .replume/contract.yaml, then run \`replume confirm\`.`);
4542
+ lines.push(" Scenarios are optional (D-062): verify can PASS when the contract's test_command runs at least one test and passes, nothing is EXTRA or TAMPERING, and the hooks record the agent changing each file. A scenario in .replume/scenarios/ adds a stated behaviour check.");
4543
+ if (!gitIdentity(repoRoot2, "user.name")) lines.push('To make commits, set Git user.name: `git config user.name "Your Name"`.');
4544
+ if (!gitIdentity(repoRoot2, "user.email")) lines.push('To make commits, set Git user.email: `git config user.email "you@example.com"`.');
3751
4545
  return { lines, claude };
3752
4546
  }
3753
4547
 
3754
4548
  // src/core/draft.ts
3755
- import { execFileSync as execFileSync8 } from "child_process";
3756
- import { existsSync as existsSync12, lstatSync as lstatSync2, readFileSync as readFileSync12 } from "fs";
3757
- import { join as join15, posix } from "path";
4549
+ import { execFileSync as execFileSync9 } from "child_process";
4550
+ import { existsSync as existsSync15, lstatSync as lstatSync2, readFileSync as readFileSync15 } from "fs";
4551
+ import { join as join18, posix } from "path";
3758
4552
  var LOCKFILES = ["package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "pnpm-lock.yaml", "bun.lockb", "poetry.lock", "Pipfile.lock", "uv.lock", "Cargo.lock", "go.sum", "Gemfile.lock", "composer.lock"];
3759
4553
  var CI_CONFIG = [
3760
4554
  [".github/workflows/**", /^\.github\/workflows\//],
@@ -3772,20 +4566,20 @@ var STOPWORDS = new Set(
3772
4566
  var NPM_PLACEHOLDER = /no test specified/;
3773
4567
  function trackedFiles(repoRoot2) {
3774
4568
  try {
3775
- return execFileSync8("git", ["ls-files", "-z"], { cwd: repoRoot2, stdio: ["ignore", "pipe", "ignore"], maxBuffer: 64 * 1024 * 1024 }).toString().split("\0").filter(Boolean);
4569
+ return execFileSync9("git", ["ls-files", "-z"], { cwd: repoRoot2, stdio: ["ignore", "pipe", "ignore"], maxBuffer: 64 * 1024 * 1024 }).toString().split("\0").filter(Boolean);
3776
4570
  } catch {
3777
4571
  return [];
3778
4572
  }
3779
4573
  }
3780
4574
  var read = (repoRoot2, rel) => {
3781
4575
  try {
3782
- return readFileSync12(join15(repoRoot2, rel), "utf-8");
4576
+ return readFileSync15(join18(repoRoot2, rel), "utf-8");
3783
4577
  } catch {
3784
4578
  return "";
3785
4579
  }
3786
4580
  };
3787
4581
  function detectTestCommand(repoRoot2, files) {
3788
- const has = (f) => files.includes(f) || existsSync12(join15(repoRoot2, f));
4582
+ const has = (f) => files.includes(f) || existsSync15(join18(repoRoot2, f));
3789
4583
  if (has("package.json")) {
3790
4584
  let script;
3791
4585
  try {
@@ -3815,10 +4609,10 @@ function draftContract(input) {
3815
4609
  const t = detectTestCommand(input.repoRoot, files);
3816
4610
  notes.push(t.command ? `test_command "${t.command}": ${t.reason}` : `test_command: none (${t.reason})`);
3817
4611
  const scenarios = files.filter((f) => /^\.replume\/scenarios\/[^/]+\.ya?ml$/.test(f)).flatMap((f) => {
3818
- const path = join15(input.repoRoot, f);
4612
+ const path = join18(input.repoRoot, f);
3819
4613
  try {
3820
4614
  if (!lstatSync2(path).isFile() || lstatSync2(path).isSymbolicLink()) return [];
3821
- const s = parseScenario(readFileSync12(path, "utf8"), f);
4615
+ const s = parseScenario(readFileSync15(path, "utf8"), f);
3822
4616
  return [s.id === posix.basename(f).replace(/\.ya?ml$/, "") ? s : null].filter((x) => x !== null);
3823
4617
  } catch {
3824
4618
  notes.push(`must_check: skipped invalid scenario ${f}; fix it before verification`);
@@ -3885,7 +4679,7 @@ function draftContract(input) {
3885
4679
  for (const p of namedCandidates) notes.push(`candidate: ${p} (named in the task; review before allowing)`);
3886
4680
  let recent = [];
3887
4681
  try {
3888
- recent = execFileSync8("git", ["log", "-5", "--name-only", "--pretty=format:"], { cwd: input.repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).split("\n").filter(Boolean);
4682
+ recent = execFileSync9("git", ["log", "-5", "--name-only", "--pretty=format:"], { cwd: input.repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).split("\n").filter(Boolean);
3889
4683
  } catch {
3890
4684
  }
3891
4685
  const recentCandidates = [...new Set(recent.filter((f) => source.includes(f) && !namedCandidates.includes(f) && !["README.md", "package.json"].includes(f)))].slice(0, Math.max(0, 5 - namedCandidates.length));
@@ -3900,10 +4694,14 @@ function draftContract(input) {
3900
4694
  };
3901
4695
  return { contract, notes, broad: broad.filter((g) => allow.includes(g)) };
3902
4696
  }
4697
+ function allowHasNoSource(allow) {
4698
+ return allow.every((g) => isTestFile(g.replace(/\*+/g, "x")));
4699
+ }
4700
+ var ALLOW_NO_SOURCE_WARNING = "allow contains no source files; the agent may have no allowed way to finish this task. Name the files in --task or edit allow.";
3903
4701
 
3904
4702
  // src/sandbox/preflight.ts
3905
- import { existsSync as existsSync16 } from "fs";
3906
- import { join as join21 } from "path";
4703
+ import { existsSync as existsSync18 } from "fs";
4704
+ import { join as join23 } from "path";
3907
4705
 
3908
4706
  // src/sandbox/docker.ts
3909
4707
  import { spawn as spawn2, spawnSync } from "child_process";
@@ -3911,12 +4709,17 @@ import { randomUUID } from "crypto";
3911
4709
  import { rmSync as rmSync4 } from "fs";
3912
4710
  import { chmod, cp, lstat as lstat2, mkdtemp, readdir as readdir2, rm, writeFile } from "fs/promises";
3913
4711
  import { tmpdir as tmpdir3 } from "os";
3914
- import { basename as basename2, join as join16 } from "path";
4712
+ import { basename as basename2, join as join19 } from "path";
3915
4713
  import { performance } from "perf_hooks";
3916
4714
  var DEFAULT_DOCKER_IMAGE = "node:20-bookworm-slim";
3917
- var DOCKER_PROBE_TIMEOUT_MS = 2e4;
3918
- async function probeDaemon(binary = "docker", timeoutMs = DOCKER_PROBE_TIMEOUT_MS) {
3919
- const context = await dockerCli(binary, ["context", "inspect"], timeoutMs);
4715
+ var DOCKER_PROBE_TIMEOUT_MS = 15e3;
4716
+ var remaining = (deadline) => Math.max(0, Math.ceil(deadline - performance.now()));
4717
+ var probeCli = (binary, args, deadline) => {
4718
+ const left = remaining(deadline);
4719
+ return left > 0 ? dockerCli(binary, args, left) : Promise.resolve({ code: null, stdout: "", stderr: "", timedOut: true, notFound: false });
4720
+ };
4721
+ async function probeDaemonUntil(binary, deadline) {
4722
+ const context = await probeCli(binary, ["context", "inspect"], deadline);
3920
4723
  if (context.timedOut) return { status: "timeout", step: "docker context inspect" };
3921
4724
  if (context.notFound) return { status: "unavailable", detail: "docker CLI not found" };
3922
4725
  if (context.code !== 0) return { status: "unavailable" };
@@ -3926,12 +4729,12 @@ async function probeDaemon(binary = "docker", timeoutMs = DOCKER_PROBE_TIMEOUT_M
3926
4729
  } catch {
3927
4730
  return { status: "unavailable" };
3928
4731
  }
3929
- const info = await dockerCli(binary, ["info", "--format", "{{.ServerVersion}}"], timeoutMs);
4732
+ const info = await probeCli(binary, ["info", "--format", "{{.ServerVersion}}"], deadline);
3930
4733
  if (info.timedOut) return { status: "timeout", step: "docker info" };
3931
4734
  return info.code === 0 ? { status: "ok" } : { status: "unavailable" };
3932
4735
  }
3933
- async function probeImage(image, binary = "docker", timeoutMs = DOCKER_PROBE_TIMEOUT_MS) {
3934
- const r = await dockerCli(binary, ["image", "inspect", image], timeoutMs);
4736
+ async function probeImageUntil(image, binary, deadline) {
4737
+ const r = await probeCli(binary, ["image", "inspect", image], deadline);
3935
4738
  return r.timedOut ? "timeout" : r.code === 0 ? "present" : "missing";
3936
4739
  }
3937
4740
  var seconds = (ms) => `${ms / 1e3} s`;
@@ -3939,19 +4742,20 @@ async function resolveIsolation(choice, image, probe = {}) {
3939
4742
  if (choice === "worktree") return { mode: "worktree", reason: "requested: worktree" };
3940
4743
  const legacy = "daemon" in probe;
3941
4744
  const timeoutMs = legacy ? DOCKER_PROBE_TIMEOUT_MS : probe.timeoutMs ?? DOCKER_PROBE_TIMEOUT_MS;
3942
- const daemon = legacy ? await probe.daemon() ? { status: "ok" } : { status: "unavailable" } : await probeDaemon(probe.binary, timeoutMs);
4745
+ const deadline = performance.now() + timeoutMs;
4746
+ const daemon = legacy ? await probe.daemon() ? { status: "ok" } : { status: "unavailable" } : await probeDaemonUntil(probe.binary ?? "docker", deadline);
3943
4747
  if (daemon.status === "timeout") {
3944
4748
  if (choice === "docker") throw new Error(`Docker was requested but did not answer within ${seconds(timeoutMs)} (${daemon.step})`);
3945
- return { mode: "worktree", reason: `auto: Docker did not answer within ${seconds(timeoutMs)} (${daemon.step}); used the worktree` };
4749
+ return { mode: "worktree", reason: `auto: Docker did not answer within ${seconds(timeoutMs)} (${daemon.step}); using the isolated working copy instead (not a security sandbox)` };
3946
4750
  }
3947
4751
  if (daemon.status === "unavailable") {
3948
4752
  if (choice === "docker") throw new Error(`Docker was requested but the Docker CLI or a local daemon is unavailable${daemon.detail ? ` (${daemon.detail})` : ""}`);
3949
4753
  return { mode: "worktree", reason: `auto: no local Docker daemon${daemon.detail ? ` (${daemon.detail})` : ""}` };
3950
4754
  }
3951
- const img = legacy ? await probe.image(image) ? "present" : "missing" : await probeImage(image, probe.binary, timeoutMs);
4755
+ const img = legacy ? await probe.image(image) ? "present" : "missing" : await probeImageUntil(image, probe.binary ?? "docker", deadline);
3952
4756
  if (img === "timeout") {
3953
4757
  if (choice === "docker") throw new Error(`Docker was requested but did not answer within ${seconds(timeoutMs)} (docker image inspect)`);
3954
- return { mode: "worktree", reason: `auto: Docker did not answer within ${seconds(timeoutMs)} (docker image inspect); used the worktree` };
4758
+ return { mode: "worktree", reason: `auto: Docker did not answer within ${seconds(timeoutMs)} (docker image inspect); using the isolated working copy instead (not a security sandbox)` };
3955
4759
  }
3956
4760
  if (img === "missing") {
3957
4761
  if (choice === "docker") throw new Error(`Docker was requested but image ${image} is not present locally; run: docker pull ${image}`);
@@ -3963,14 +4767,14 @@ function cliEnv() {
3963
4767
  return { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: process.env.HOME, DOCKER_CONFIG: process.env.DOCKER_CONFIG };
3964
4768
  }
3965
4769
  function dockerCli(binary, args, timeoutMs) {
3966
- return new Promise((resolve7) => {
4770
+ return new Promise((resolve9) => {
3967
4771
  const child = spawn2(binary, args, { env: cliEnv(), stdio: ["ignore", "pipe", "pipe"] });
3968
4772
  let stdout = "", stderr = "", settled = false, timedOut = false, notFound = false;
3969
4773
  const done = (code) => {
3970
4774
  if (settled) return;
3971
4775
  settled = true;
3972
4776
  clearTimeout(timer);
3973
- resolve7({ code, stdout, stderr, timedOut, notFound });
4777
+ resolve9({ code, stdout, stderr, timedOut, notFound });
3974
4778
  };
3975
4779
  child.stdout.on("data", (chunk) => {
3976
4780
  stdout += chunk.toString();
@@ -3991,6 +4795,8 @@ function dockerCli(binary, args, timeoutMs) {
3991
4795
  }, timeoutMs);
3992
4796
  });
3993
4797
  }
4798
+ var commandStartMarker = (name) => `__replume_command_start_${name}__`;
4799
+ var DOCKER_STARTUP_TIMEOUT_SEC = 120;
3994
4800
  function buildDockerRunArgs(input) {
3995
4801
  const uid = process.getuid?.() ?? 1e3, gid = process.getgid?.() ?? 1e3;
3996
4802
  return [
@@ -4026,11 +4832,14 @@ function buildDockerRunArgs(input) {
4026
4832
  "--env",
4027
4833
  "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
4028
4834
  ...Object.entries(input.labels ?? {}).flatMap(([k, v]) => ["--label", `${k}=${v}`]),
4835
+ // the marker, then exactly the old `sh -lc <command>` (passed as an argument, never spliced into this script)
4029
4836
  "--workdir",
4030
4837
  "/repo",
4031
4838
  input.image,
4032
4839
  "sh",
4033
- "-lc",
4840
+ "-c",
4841
+ `printf '%s\\n' ${commandStartMarker(input.name)} >&2; exec sh -lc "$1"`,
4842
+ "replume-scenario",
4034
4843
  input.command
4035
4844
  ];
4036
4845
  }
@@ -4047,11 +4856,11 @@ async function sweepStaleStaging(tmpRoot = tmpdir3(), now = Date.now()) {
4047
4856
  }
4048
4857
  for (const n of names) {
4049
4858
  if (!n.startsWith(STAGING_PREFIX)) continue;
4050
- const dir = join16(tmpRoot, n);
4859
+ const dir = join19(tmpRoot, n);
4051
4860
  try {
4052
4861
  const st = await lstat2(dir);
4053
4862
  if (st.isSymbolicLink() || !st.isDirectory()) continue;
4054
- const marker = await lstat2(join16(dir, STAGING_MARKER));
4863
+ const marker = await lstat2(join19(dir, STAGING_MARKER));
4055
4864
  if (!marker.isFile() || now - marker.mtimeMs < STALE_AFTER_MS) continue;
4056
4865
  await rm(dir, { recursive: true, force: true });
4057
4866
  removed.push(dir);
@@ -4088,44 +4897,57 @@ async function runDockerScenario(input) {
4088
4897
  const binary = input.dockerBinary ?? "docker";
4089
4898
  const tmpRoot = input.tmpRoot ?? tmpdir3();
4090
4899
  await sweepStaleStaging(tmpRoot);
4091
- const temp = await mkdtemp(join16(tmpRoot, STAGING_PREFIX));
4092
- const staged = join16(temp, "repo");
4900
+ const temp = await mkdtemp(join19(tmpRoot, STAGING_PREFIX));
4901
+ const staged = join19(temp, "repo");
4093
4902
  const name = `replume-${randomUUID()}`;
4094
4903
  active.set(temp, { binary, name });
4095
4904
  attachSignals();
4096
4905
  try {
4097
- await writeFile(join16(temp, STAGING_MARKER), JSON.stringify({ by: "replume", pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString() }) + "\n");
4906
+ await writeFile(join19(temp, STAGING_MARKER), JSON.stringify({ by: "replume", pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString() }) + "\n");
4098
4907
  await cp(input.root, staged, { recursive: true, filter: (path) => basename2(path) !== ".git" });
4099
4908
  if (process.getuid?.() === 0) await chmod(temp, 493);
4100
4909
  const args = buildDockerRunArgs({ name, image: input.image ?? DEFAULT_DOCKER_IMAGE, source: staged, command: input.command, labels: input.labels });
4101
- return await new Promise((resolve7) => {
4910
+ return await new Promise((resolve9) => {
4102
4911
  const child = spawn2(binary, args, { env: cliEnv(), stdio: ["ignore", "pipe", "pipe"] });
4103
- let stdout = "", stderr = "", timedOut = false, settled = false;
4912
+ const marker = `${commandStartMarker(name)}
4913
+ `;
4914
+ let stdout = "", stderr = "", timedOut = false, settled = false, commandStarted = false;
4915
+ let timer;
4104
4916
  const finish = (exitCode) => {
4105
4917
  if (settled) return;
4106
4918
  settled = true;
4107
4919
  clearTimeout(timer);
4108
- resolve7({ stdout, stderr, exitCode, timedOut, durationMs: Math.round(performance.now() - started) });
4920
+ resolve9({ stdout, stderr, exitCode, timedOut, durationMs: Math.round(performance.now() - started) });
4921
+ };
4922
+ const expire = (note) => async () => {
4923
+ timedOut = true;
4924
+ if (note) stderr += note;
4925
+ const removed = await dockerCli(binary, ["rm", "--force", name], 1e4);
4926
+ if (removed.code !== 0) stderr += `
4927
+ Docker timeout cleanup: ${removed.stderr.trim()}`;
4928
+ child.kill("SIGKILL");
4929
+ finish(null);
4109
4930
  };
4110
4931
  child.stdout.on("data", (chunk) => {
4111
4932
  stdout += chunk.toString();
4112
4933
  });
4113
4934
  child.stderr.on("data", (chunk) => {
4114
4935
  stderr += chunk.toString();
4936
+ if (!commandStarted && stderr.includes(marker)) {
4937
+ commandStarted = true;
4938
+ stderr = stderr.replace(marker, "");
4939
+ clearTimeout(timer);
4940
+ timer = setTimeout(expire(""), input.timeoutSec * 1e3);
4941
+ }
4115
4942
  });
4116
4943
  child.on("error", (error) => {
4117
4944
  stderr += error.message;
4118
4945
  finish(null);
4119
4946
  });
4120
4947
  child.on("close", finish);
4121
- const timer = setTimeout(async () => {
4122
- timedOut = true;
4123
- const removed = await dockerCli(binary, ["rm", "--force", name], 1e4);
4124
- if (removed.code !== 0) stderr += `
4125
- Docker timeout cleanup: ${removed.stderr.trim()}`;
4126
- child.kill("SIGKILL");
4127
- finish(null);
4128
- }, input.timeoutSec * 1e3);
4948
+ const startup = input.startupTimeoutSec ?? DOCKER_STARTUP_TIMEOUT_SEC;
4949
+ timer = setTimeout(expire(`
4950
+ Docker container did not start the scenario command within ${startup}s (start-up limit; timeout_sec counts from the command start)`), startup * 1e3);
4129
4951
  });
4130
4952
  } finally {
4131
4953
  try {
@@ -4141,20 +4963,20 @@ Docker timeout cleanup: ${removed.stderr.trim()}`;
4141
4963
  }
4142
4964
 
4143
4965
  // src/sandbox/index.ts
4144
- import { existsSync as existsSync15, readFileSync as readFileSync15 } from "fs";
4145
- import { execFileSync as execFileSync9 } from "child_process";
4966
+ import { existsSync as existsSync17, readFileSync as readFileSync17 } from "fs";
4967
+ import { execFileSync as execFileSync10 } from "child_process";
4146
4968
  import { lstat as lstat3, mkdir, writeFile as writeFile2 } from "fs/promises";
4147
- import { isAbsolute as isAbsolute7, join as join20, relative as relative6, resolve as resolve5, sep as sep3 } from "path";
4969
+ import { isAbsolute as isAbsolute7, join as join22, relative as relative6, resolve as resolve7, sep as sep3 } from "path";
4148
4970
  import { performance as performance3 } from "perf_hooks";
4149
4971
 
4150
4972
  // src/sandbox/working-copy.ts
4151
4973
  import { spawn as spawn3 } from "child_process";
4152
4974
  import { mkdtemp as mkdtemp2, rm as rm2 } from "fs/promises";
4153
4975
  import { tmpdir as tmpdir4 } from "os";
4154
- import { join as join17 } from "path";
4976
+ import { join as join20 } from "path";
4155
4977
  import { performance as performance2 } from "perf_hooks";
4156
4978
  async function git(cwd, args) {
4157
- return new Promise((resolve7, reject) => {
4979
+ return new Promise((resolve9, reject) => {
4158
4980
  const child = spawn3("git", args, { cwd, env: { PATH: process.env.PATH ?? "/usr/bin:/bin" }, stdio: ["ignore", "pipe", "pipe"] });
4159
4981
  let out = "", err = "";
4160
4982
  child.stdout.on("data", (v) => {
@@ -4164,13 +4986,13 @@ async function git(cwd, args) {
4164
4986
  err += v.toString();
4165
4987
  });
4166
4988
  child.on("error", reject);
4167
- child.on("close", (code) => code === 0 ? resolve7(out.trim()) : reject(new Error(`git ${args.join(" ")}: ${err.trim() || `exit ${code}`}`)));
4989
+ child.on("close", (code) => code === 0 ? resolve9(out.trim()) : reject(new Error(`git ${args.join(" ")}: ${err.trim() || `exit ${code}`}`)));
4168
4990
  });
4169
4991
  }
4170
4992
  async function withWorkingCopy(repo, commit, keep, fn) {
4171
- const temp = await mkdtemp2(join17(tmpdir4(), "replume-copy-"));
4172
- const root = join17(temp, "repo");
4173
- const home = join17(temp, "home");
4993
+ const temp = await mkdtemp2(join20(tmpdir4(), "replume-copy-"));
4994
+ const root = join20(temp, "repo");
4995
+ const home = join20(temp, "home");
4174
4996
  let added = false;
4175
4997
  try {
4176
4998
  await git(repo, ["worktree", "add", "--detach", root, commit]);
@@ -4190,14 +5012,14 @@ async function withWorkingCopy(repo, commit, keep, fn) {
4190
5012
  }
4191
5013
  async function runCommand(command, cwd, home, timeoutSec) {
4192
5014
  const started = performance2.now();
4193
- return new Promise((resolve7) => {
5015
+ return new Promise((resolve9) => {
4194
5016
  const child = spawn3(command, { cwd, shell: true, detached: process.platform !== "win32", env: { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: home }, stdio: ["ignore", "pipe", "pipe"] });
4195
5017
  let stdout = "", stderr = "", timedOut = false, settled = false;
4196
5018
  const finish = (exitCode) => {
4197
5019
  if (settled) return;
4198
5020
  settled = true;
4199
5021
  clearTimeout(timer);
4200
- resolve7({ stdout, stderr, exitCode, durationMs: Math.round(performance2.now() - started), timedOut });
5022
+ resolve9({ stdout, stderr, exitCode, durationMs: Math.round(performance2.now() - started), timedOut });
4201
5023
  };
4202
5024
  child.stdout.on("data", (v) => {
4203
5025
  stdout += v.toString();
@@ -4301,10 +5123,22 @@ function buildReport(input) {
4301
5123
  extra.push({ path: l.file, reason: `lockfile adds ${l.names.length} package${l.names.length === 1 ? "" : "s"} with no new direct dependency: ${l.names.slice(0, 10).join(", ")}${l.names.length > 10 ? ", \u2026" : ""}` });
4302
5124
  const ran = new Set(input.results.filter((r) => !["not_run", "error"].includes(r.status)).map((r) => r.id));
4303
5125
  const claims = scenarios.filter((s) => ran.has(s.id)).flatMap((s) => s.declared_covers);
4304
- const notChecked = paths.filter((p) => !matches(claims, p)).map((path) => ({
4305
- path,
4306
- reason: input.draft ? "changed in diff; the contract is an unconfirmed draft, so its policy was not applied (run `replume confirm`); no scenario declares coverage among scenarios that ran" : "changed in diff; no scenario declares coverage among scenarios that ran"
4307
- }));
5126
+ const testsPassed = !input.draft && input.testCommand?.status === "passed";
5127
+ const obs = input.observation;
5128
+ const named = new Set(obs?.namedPaths ?? []);
5129
+ const reasonFor = (path) => {
5130
+ if (input.draft) return "changed in diff; the contract is an unconfirmed draft, so its policy was not applied (run `replume confirm`); no scenario declares coverage among scenarios that ran";
5131
+ if (!testsPassed) return `changed in diff; no scenario declares coverage, and ${!contract.test_command ? "the contract has no test_command" : "test_command did not pass with at least one test"}`;
5132
+ if (!obs || obs.sessions === 0) return "changed in diff; no scenario declares coverage; agent steps not observed (no agent session for this task in the ledger)";
5133
+ if (!named.has(path)) return "changed in diff; no scenario declares coverage; change not seen by hooks (no ledger event records a write to this file)";
5134
+ return null;
5135
+ };
5136
+ const uncovered = paths.filter((p) => !matches(claims, p));
5137
+ const notChecked = uncovered.flatMap((path) => {
5138
+ const reason = reasonFor(path);
5139
+ return reason ? [{ path, reason }] : [];
5140
+ });
5141
+ const observedOnly = uncovered.filter((p) => reasonFor(p) === null).map((path) => ({ path, reason: "checked by test_command and observed steps; no scenario" }));
4308
5142
  const failed = results.filter((r) => r.status !== "passed" && r.status !== "unconfirmed").map((r) => ({ id: r.id, evidence: r.expectationFailures.join("; ") || r.status }));
4309
5143
  const evidenceFor = (r) => {
4310
5144
  if (r.id === TEST_COMMAND_ID) return `\`${contract.test_command}\` exit ${r.exitCode}`;
@@ -4332,7 +5166,7 @@ function buildReport(input) {
4332
5166
  const checks = [
4333
5167
  emptyDiff ? { name: "scope", status: "not_run", detail: `empty diff, nothing compared: base ${base3.slice(0, 7)} and commit ${commit.slice(0, 7)} have no differences` } : input.draft ? { name: "scope", status: "not_run", detail: "contract is an unconfirmed draft, so its allow globs were not applied" } : contract.allow.length ? { name: "scope", status: "done", detail: `${n(paths.length, "changed file")} compared with allow globs, ${outsideAllow} outside` } : { name: "scope", status: "not_run", detail: "contract has no allow globs, so changes outside the task were not checked" },
4334
5168
  { name: "tampering", status: "done", detail: `${n(testFiles, "changed test file")} inspected for removed assertions, skip/only and forced success; ${input.draft ? "protect globs of the unconfirmed draft not applied" : n(contract.protect.length, "protect glob")}; .replume/ changes` },
4335
- !input.testCommand ? { name: "test_command", status: "not_run", detail: input.draft ? "contract is an unconfirmed draft, so its test_command was not run" : "contract lists no test_command" } : input.testCommand.status === "unconfirmed" ? { name: "test_command", status: "unconfirmed", detail: `\`${contract.test_command}\` exit ${input.testCommand.exitCode}; ${input.testCommand.expectationFailures.join("; ")}` } : input.testCommand.status === "passed" ? { name: "test_command", status: "done", detail: `\`${contract.test_command}\` exit 0 (declares no coverage)` } : { name: "test_command", status: "failed", detail: `\`${contract.test_command}\` ${input.testCommand.expectationFailures.join("; ") || input.testCommand.status}` },
5169
+ !input.testCommand ? { name: "test_command", status: "not_run", detail: input.draft ? "contract is an unconfirmed draft, so its test_command was not run" : "contract lists no test_command" } : input.testCommand.status === "unconfirmed" ? { name: "test_command", status: "unconfirmed", detail: `\`${contract.test_command}\` exit ${input.testCommand.exitCode}; ${input.testCommand.expectationFailures.join("; ")}` } : input.testCommand.status === "passed" ? { name: "test_command", status: "done", detail: `\`${contract.test_command}\` exit 0 (declares no coverage by itself; with observed agent steps it checks changed files no scenario declares, D-062)` } : { name: "test_command", status: "failed", detail: `\`${contract.test_command}\` ${input.testCommand.expectationFailures.join("; ") || input.testCommand.status}` },
4336
5170
  scenarioRuns.length ? { name: "scenarios", status: scenarioRuns.every((r) => r.status === "passed") ? "done" : "failed", detail: `${n(scenarioRuns.length, "scenario")} ran` } : { name: "scenarios", status: "not_run", detail: scenarios.length ? "no scenario ran" : "no scenario files at this commit" },
4337
5171
  ...input.baselineCheck ? [input.baselineCheck] : [],
4338
5172
  ...deps ? [dependencyCheck(deps)] : [],
@@ -4355,6 +5189,7 @@ function buildReport(input) {
4355
5189
  extra: lv(extra, () => "verified_here"),
4356
5190
  tampering: lv(tampering, (t) => RECORDED.has(t.rule) ? "observed" : "verified_here"),
4357
5191
  notChecked: lv(notChecked, () => "unknown"),
5192
+ ...observedOnly.length ? { observedOnly: lv(observedOnly, () => "observed") } : {},
4358
5193
  scenarios: [...results, ...doneRuns],
4359
5194
  baseCommit: base3,
4360
5195
  ...input.baseSource ? { baseSource: input.baseSource } : {},
@@ -4401,15 +5236,15 @@ function parsePackageJson(text) {
4401
5236
  return { error: "not valid JSON" };
4402
5237
  }
4403
5238
  if (!j || typeof j !== "object" || Array.isArray(j)) return { error: "not a package manifest" };
4404
- const obj2 = j;
5239
+ const obj3 = j;
4405
5240
  for (const s of NPM_SECTIONS) {
4406
- if (!(s in obj2) || obj2[s] == null) continue;
4407
- const sec = obj2[s];
5241
+ if (!(s in obj3) || obj3[s] == null) continue;
5242
+ const sec = obj3[s];
4408
5243
  if (typeof sec !== "object" || Array.isArray(sec)) return { error: `${s} is not an object` };
4409
5244
  }
4410
5245
  const entries = [];
4411
5246
  for (const s of NPM_SECTIONS) {
4412
- const sec = obj2[s];
5247
+ const sec = obj3[s];
4413
5248
  if (sec && typeof sec === "object" && !Array.isArray(sec)) {
4414
5249
  for (const name of Object.keys(sec)) entries.push({ name, section: s });
4415
5250
  }
@@ -4590,134 +5425,6 @@ async function checkRegistry(d, fetcher, timeoutMs = REGISTRY_TIMEOUT_MS) {
4590
5425
  }
4591
5426
  var dependencyAllowed = (name, allow) => !!allow?.length && matchesAny(name, allow) !== null;
4592
5427
 
4593
- // src/core/capture.ts
4594
- import { existsSync as existsSync13, readFileSync as readFileSync13 } from "fs";
4595
- import { dirname as dirname7, join as join18 } from "path";
4596
- var EXPECTED_TYPES = ["prompt", "tool_pre", "command", "stop"];
4597
- var SHOWN_PATHS = 5;
4598
- var count3 = (m, k) => {
4599
- m[k] = (m[k] ?? 0) + 1;
4600
- };
4601
- var HOOK_CRASH = "hook-errors.log exists: at least one hook crashed, so some steps may not have been recorded";
4602
- function captureCoverage(eventsFile, changedPaths, hooksConfigured = false) {
4603
- const absence = hooksConfigured ? "Replume project hooks are configured, but no hook events were recorded; agent steps and scope were not observed" : "no Replume project hooks were found; agent steps and scope were not observed";
4604
- const empty = (ledger, notes) => ({
4605
- ledger,
4606
- hooksConfigured,
4607
- events: 0,
4608
- sessions: 0,
4609
- agents: [],
4610
- byType: {},
4611
- tools: {},
4612
- firstTs: null,
4613
- lastTs: null,
4614
- badLines: 0,
4615
- hookErrorsLogged: hookErrors(eventsFile),
4616
- changedFiles: changedPaths.length,
4617
- changedFilesSeen: 0,
4618
- changedFilesLanded: 0,
4619
- changedFilesAttemptedOnly: [],
4620
- changedFilesNotSeen: changedPaths.slice(0, SHOWN_PATHS),
4621
- unobservableCommands: 0,
4622
- // a hook that crashed on every call leaves no ledger: say so here too, not only in the JSON flag
4623
- notSeen: hookErrors(eventsFile) ? [...notes, HOOK_CRASH] : notes
4624
- });
4625
- if (!existsSync13(eventsFile)) {
4626
- return empty("absent", [`no ledger file: ${absence}`]);
4627
- }
4628
- const text = readFileSync13(eventsFile, "utf-8");
4629
- const { events, badLines } = parseEvents(text);
4630
- if (!events.length) {
4631
- return empty("empty", [
4632
- badLines.length ? `the ledger has ${badLines.length} line(s) but none is a valid event` : `the ledger is empty: ${absence}`
4633
- ]);
4634
- }
4635
- const byType = {};
4636
- const tools = {};
4637
- const seenPaths = /* @__PURE__ */ new Set();
4638
- const landedPaths = /* @__PURE__ */ new Set();
4639
- const blockedPaths = /* @__PURE__ */ new Set();
4640
- for (const e of events) {
4641
- count3(byType, e.type);
4642
- if (e.type === "tool_pre" && e.tool) count3(tools, e.tool);
4643
- const blocked = e.action === "block" && e.type !== "file_edit" && e.type !== "command";
4644
- const landed = e.type === "file_edit" || e.type === "command" && e.raw?.ok !== false;
4645
- for (const p of e.paths) {
4646
- if (blocked) blockedPaths.add(p);
4647
- else seenPaths.add(p);
4648
- if (landed) landedPaths.add(p);
4649
- }
4650
- }
4651
- const ts = events.map((e) => e.ts).sort();
4652
- const notSeen = [];
4653
- const missing = EXPECTED_TYPES.filter((t) => !byType[t]);
4654
- if (missing.length) notSeen.push(`no ${missing.join(", ")} events`);
4655
- const truncated = badLines.length > 0 || !text.endsWith("\n");
4656
- if (truncated) notSeen.push(`the ledger may be truncated or damaged (${badLines.length} unreadable line(s)${text.endsWith("\n") ? "" : ", last line has no line break"}); events after that point may be missing`);
4657
- const attemptedOnly = changedPaths.filter((p) => !seenPaths.has(p) && blockedPaths.has(p));
4658
- if (attemptedOnly.length)
4659
- notSeen.push(
4660
- `${attemptedOnly.length} changed file(s) appear in the ledger only as blocked attempts (${attemptedOnly.slice(0, SHOWN_PATHS).join(", ")}${attemptedOnly.length > SHOWN_PATHS ? ", \u2026" : ""}): the change that landed was not observed by any hook`
4661
- );
4662
- const unseen = changedPaths.filter((p) => !seenPaths.has(p) && !blockedPaths.has(p));
4663
- if (unseen.length) {
4664
- notSeen.push(
4665
- `${unseen.length} of ${changedPaths.length} changed file(s) are named by no ledger event (${unseen.slice(0, SHOWN_PATHS).join(", ")}${unseen.length > SHOWN_PATHS ? ", \u2026" : ""}); a file changed through a shell command is seen only if the command text names it`
4666
- );
4667
- }
4668
- const opaque = [...new Set(events.filter((e) => (e.type === "command" || e.type === "tool_pre") && e.command && unobservable(e.command)).map((e) => e.command))];
4669
- if (opaque.length)
4670
- notSeen.push(
4671
- `${opaque.length} shell command${opaque.length === 1 ? "" : "s"} ran a script file or wrote to a path built at run time (e.g. \`${opaque[0].slice(0, 80)}\`); what ${opaque.length === 1 ? "it" : "they"} wrote is not observable by Replume's shell rules`
4672
- );
4673
- const hookErrorsLogged = hookErrors(eventsFile);
4674
- if (hookErrorsLogged) notSeen.push(HOOK_CRASH);
4675
- return {
4676
- ledger: "present",
4677
- events: events.length,
4678
- sessions: new Set(events.map((e) => e.sessionId)).size,
4679
- agents: [...new Set(events.map((e) => e.agent))].sort(),
4680
- byType,
4681
- tools,
4682
- firstTs: ts[0],
4683
- lastTs: ts[ts.length - 1],
4684
- badLines: badLines.length,
4685
- hookErrorsLogged,
4686
- changedFiles: changedPaths.length,
4687
- changedFilesSeen: changedPaths.filter((p) => seenPaths.has(p)).length,
4688
- changedFilesLanded: changedPaths.filter((p) => landedPaths.has(p)).length,
4689
- changedFilesAttemptedOnly: attemptedOnly.slice(0, SHOWN_PATHS),
4690
- changedFilesNotSeen: unseen.slice(0, SHOWN_PATHS),
4691
- unobservableCommands: opaque.length,
4692
- notSeen
4693
- };
4694
- }
4695
- var SCRIPT_RUN = /(?:^|[;&|(]\s*|\s(?:sudo|exec|env|nohup|time)\s+)(?:(?:node|nodejs|deno|bun|python[0-9.]*|ruby|perl|php|bash|sh|zsh|pwsh|tsx|ts-node)\s+(?:-[^-ce\s][^\s]*\s+)*[\w./~-]+\.(?:js|mjs|cjs|ts|py|rb|pl|php|sh|bash|zsh|ps1)\b|\.{1,2}\/[\w./-]+)/;
4696
- var RUNTIME_TARGET = /(?:>{1,2}|>\||\btee(?:\s+-\w+)*)\s*["']?[^\s"';|&]*(?:\$|`)/;
4697
- var unobservable = (command) => SCRIPT_RUN.test(command) || RUNTIME_TARGET.test(command);
4698
- function hookErrors(eventsFile) {
4699
- return existsSync13(join18(dirname7(eventsFile), "hook-errors.log"));
4700
- }
4701
- var list = (m) => Object.entries(m).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([k, n]) => `${k} ${n}`).join(", ");
4702
- function renderCapture(c) {
4703
- const out = ["CAPTURE COVERAGE"];
4704
- if (c.ledger !== "present") {
4705
- out.push(` seen nothing (ledger ${c.ledger === "absent" ? "file not found" : "empty"})`);
4706
- } else {
4707
- out.push(` seen ${c.events} events from ${c.agents.join(", ")} in ${c.sessions} session${c.sessions === 1 ? "" : "s"} (${c.firstTs} to ${c.lastTs}): ${list(c.byType)}`);
4708
- if (Object.keys(c.tools).length) out.push(` tools ${list(c.tools)}`);
4709
- if (c.changedFilesLanded !== void 0) {
4710
- const a = c.changedFilesAttemptedOnly ?? [];
4711
- out.push(
4712
- ` files ${c.changedFilesSeen} of ${c.changedFiles} changed file(s) named by an allowed step; ${c.changedFilesLanded} landed and observed (file_edit or successful command event); ${a.length} only attempted (blocked)${a.length ? `: ${a.join(", ")}` : ""}`
4713
- );
4714
- }
4715
- }
4716
- out.push(` not seen ${c.notSeen.length ? c.notSeen.join("; ") : "nothing flagged by these checks (they cannot see steps taken outside the hooks)"}`);
4717
- out.push(" limits only agents with a Replume adapter and installed hooks write events; the ledger is a local file that can be edited or deleted. This is what was captured, not proof of what the agent did.");
4718
- return out;
4719
- }
4720
-
4721
5428
  // src/sandbox/failing-tests.ts
4722
5429
  var PATTERNS2 = [
4723
5430
  /^[ \t]*✖ (.+?) \(\d+(?:\.\d+)?m?s\)[ \t]*$/,
@@ -4750,29 +5457,29 @@ function failingTestNames(output) {
4750
5457
  }
4751
5458
 
4752
5459
  // src/core/acceptance.ts
4753
- import { appendFileSync as appendFileSync5, existsSync as existsSync14, mkdirSync as mkdirSync11, readFileSync as readFileSync14, writeFileSync as writeFileSync10 } from "fs";
4754
- import { join as join19 } from "path";
4755
- var file = (root) => join19(root, ".replume", "acceptance.jsonl");
5460
+ import { appendFileSync as appendFileSync5, existsSync as existsSync16, mkdirSync as mkdirSync12, readFileSync as readFileSync16, writeFileSync as writeFileSync11 } from "fs";
5461
+ import { join as join21 } from "path";
5462
+ var file = (root) => join21(root, ".replume", "acceptance.jsonl");
4756
5463
  function recordAcceptance(repoRoot2, opts) {
4757
5464
  if (opts.verdict !== "pass") throw new Error("accept requires a fresh verify PASS");
4758
5465
  if (!/^[a-zA-Z0-9_-]{1,128}$/.test(opts.taskId) || !/^[0-9a-f]{40}$/.test(opts.commit)) throw new Error("invalid task ID or commit");
4759
5466
  const existing = readAcceptance(repoRoot2, opts.taskId, opts.commit);
4760
5467
  if (existing) return { ...existing, alreadyAccepted: true };
4761
- const dir = join19(repoRoot2, ".replume");
4762
- mkdirSync11(dir, { recursive: true });
4763
- const ignore = join19(dir, ".gitignore");
4764
- const prior = existsSync14(ignore) ? readFileSync14(ignore, "utf8") : "";
4765
- if (!prior.split(/\r?\n/).includes("acceptance.jsonl")) writeFileSync10(ignore, `${prior}${prior && !prior.endsWith("\n") ? "\n" : ""}acceptance.jsonl
5468
+ const dir = join21(repoRoot2, ".replume");
5469
+ mkdirSync12(dir, { recursive: true });
5470
+ const ignore = join21(dir, ".gitignore");
5471
+ const prior = existsSync16(ignore) ? readFileSync16(ignore, "utf8") : "";
5472
+ if (!prior.split(/\r?\n/).includes("acceptance.jsonl")) writeFileSync11(ignore, `${prior}${prior && !prior.endsWith("\n") ? "\n" : ""}acceptance.jsonl
4766
5473
  `);
4767
5474
  const entry = { taskId: opts.taskId, commit: opts.commit, acceptedAt: (opts.now ?? /* @__PURE__ */ new Date()).toISOString() };
4768
5475
  appendFileSync5(file(repoRoot2), JSON.stringify(entry) + "\n");
4769
5476
  return { ...entry, alreadyAccepted: false };
4770
5477
  }
4771
5478
  function readAcceptance(repoRoot2, taskId, commit) {
4772
- if (!existsSync14(file(repoRoot2))) return null;
5479
+ if (!existsSync16(file(repoRoot2))) return null;
4773
5480
  let content;
4774
5481
  try {
4775
- content = readFileSync14(file(repoRoot2), "utf8");
5482
+ content = readFileSync16(file(repoRoot2), "utf8");
4776
5483
  } catch {
4777
5484
  return null;
4778
5485
  }
@@ -4901,15 +5608,15 @@ async function isLink(p) {
4901
5608
  }
4902
5609
  }
4903
5610
  function safeRelative(root, path) {
4904
- const full = resolve5(root, path);
5611
+ const full = resolve7(root, path);
4905
5612
  if (full !== root && !full.startsWith(root + "/")) throw new Error(`path outside repository: ${path}`);
4906
5613
  return full;
4907
5614
  }
4908
5615
  function projectHooksConfigured(root) {
4909
- for (const rel of [".claude/settings.json", ".cursor/hooks.json"]) {
5616
+ for (const rel of [".claude/settings.json", ".cursor/hooks.json", ".codex/hooks.json"]) {
4910
5617
  try {
4911
- const settings = JSON.parse(readFileSync15(join20(root, rel), "utf8"));
4912
- if (JSON.stringify(settings.hooks ?? {}).includes("replume hook claude") || JSON.stringify(settings.hooks ?? {}).includes("replume hook cursor")) return true;
5618
+ const settings = JSON.parse(readFileSync17(join22(root, rel), "utf8"));
5619
+ if (["claude", "cursor", "codex"].some((agent) => JSON.stringify(settings.hooks ?? {}).includes(`replume hook ${agent}`))) return true;
4913
5620
  } catch {
4914
5621
  }
4915
5622
  }
@@ -4998,7 +5705,7 @@ var showRef = (t) => `${/^[0-9a-f]{40}$/.test(t.ref) ? t.ref.slice(0, 7) : t.ref
4998
5705
  async function compareToTrustedBase(repo, t, commit, contractRel, scenariosRel, contractFile) {
4999
5706
  const where = `the trusted base ${showRef(t)}`;
5000
5707
  const out = [];
5001
- const local = existsSync15(contractFile) ? readFileSync15(contractFile, "utf-8").trim() : null;
5708
+ const local = existsSync17(contractFile) ? readFileSync17(contractFile, "utf-8").trim() : null;
5002
5709
  if (local !== t.contractText.trim())
5003
5710
  out.push({ rule: "policy_differs_from_trusted_base", path: contractRel, detail: local === null ? `contract missing in the working tree; ${where} has one` : `working-tree contract differs from the one at ${where}; the contract at the ref was used as the policy` });
5004
5711
  const blobs = async (rev) => new Map(
@@ -5015,8 +5722,22 @@ async function compareToTrustedBase(repo, t, commit, contractRel, scenariosRel,
5015
5722
  }
5016
5723
  return out;
5017
5724
  }
5725
+ function observedChanges(events, changedPaths, since) {
5726
+ const t = since ? Date.parse(since) : NaN;
5727
+ const inTask = events.filter((e) => !!e.sessionId && !!e.agent && (Number.isNaN(t) || Date.parse(e.ts) >= t));
5728
+ const named = /* @__PURE__ */ new Set();
5729
+ for (const e of inTask) {
5730
+ const raw3 = e.raw ?? {};
5731
+ if (e.action === "block" || raw3.ok === false) continue;
5732
+ if (e.type === "file_edit") for (const p of e.paths) named.add(p);
5733
+ else if (e.type === "command" && raw3.hook_event_name !== "beforeShellExecution" && Array.isArray(raw3.writes)) {
5734
+ for (const p of raw3.writes) if (typeof p === "string") named.add(p);
5735
+ }
5736
+ }
5737
+ return { sessions: new Set(inTask.map((e) => e.sessionId)).size, namedPaths: changedPaths.filter((p) => named.has(p)) };
5738
+ }
5018
5739
  async function runVerify2(opts) {
5019
- const repo = resolve5(opts.repoPath);
5740
+ const repo = resolve7(opts.repoPath);
5020
5741
  if (!isAbsolute7(opts.repoPath)) throw new Error("repoPath must be absolute");
5021
5742
  const startedAt = (/* @__PURE__ */ new Date()).toISOString(), started = performance3.now();
5022
5743
  const image = opts.dockerImage ?? DEFAULT_DOCKER_IMAGE;
@@ -5039,9 +5760,9 @@ async function runVerify2(opts) {
5039
5760
  }
5040
5761
  const loaded = trusted ? parseContract(trusted.contractText, `${trusted.ref}:${contractRel}`) : loadContract(contractFile);
5041
5762
  if (!loaded) {
5042
- const example = join20(repo, ".replume/contract.example.yaml");
5763
+ const example = join22(repo, ".replume/contract.example.yaml");
5043
5764
  throw new Error(
5044
- `no task contract at ${contractFile}; write one with: replume task "<what you asked the agent>" --allow "<glob>"` + (existsSync15(example) ? ", or copy .replume/contract.example.yaml to .replume/contract.yaml" : "")
5765
+ `no task contract at ${contractFile}; write one with: replume task "<what you asked the agent>" --allow "<glob>"` + (existsSync17(example) ? ", or copy .replume/contract.example.yaml to .replume/contract.yaml" : "")
5045
5766
  );
5046
5767
  }
5047
5768
  const draft = isDraft(loaded);
@@ -5096,7 +5817,7 @@ async function runVerify2(opts) {
5096
5817
  const entries = await diffEntries(repo, base3, commit);
5097
5818
  const setup = /* @__PURE__ */ new Set();
5098
5819
  if (baseline?.base_commit === base3) {
5099
- const committed = (p) => execFileSync9("git", ["show", `${commit}:${p}`], { cwd: repo });
5820
+ const committed = (p) => execFileSync10("git", ["show", `${commit}:${p}`], { cwd: repo });
5100
5821
  for (const e of entries) {
5101
5822
  if (e.status !== "A" && e.status !== "M" || e.oldPath) continue;
5102
5823
  if (e.path === contractRel && sha256(committed(e.path)) === baseline.policy_hash) setup.add(e.path);
@@ -5108,10 +5829,14 @@ async function runVerify2(opts) {
5108
5829
  const tampering = await tamperingFindings(repo, base3, commit, agentEntries);
5109
5830
  const newTests = agentEntries.filter((e) => e.status === "A" && TEST_PATH.test(e.path) && !tampering.some((t) => t.path === e.path)).map((e) => e.path);
5110
5831
  const dependencies = await dependencySection(repo, base3, commit, paths, contract, opts);
5832
+ const eventsFile = safeRelative(repo, opts.eventsPath ?? ".replume/events.jsonl");
5833
+ const ledgerEvents = readEvents(eventsFile).events;
5834
+ const since = baseline?.recorded_at ?? null;
5835
+ const observation = observedChanges(ledgerEvents, paths, since);
5111
5836
  const report = await withWorkingCopy(repo, commit, mode === "worktree" && (opts.keepWorkdir ?? false), async (root, home) => {
5112
5837
  const run = (command, timeoutSec) => mode === "docker" ? runDockerScenario({ root, command, timeoutSec, image }) : runCommand(command, root, home, timeoutSec);
5113
5838
  const dir = safeRelative(root, opts.scenariosDir ?? ".replume/scenarios");
5114
- const scenarios = existsSync15(dir) || await isLink(dir) ? await loadScenarios(dir, root) : [];
5839
+ const scenarios = existsSync17(dir) || await isLink(dir) ? await loadScenarios(dir, root) : [];
5115
5840
  if (scenarios.some((s) => s.id === TEST_COMMAND_ID)) throw new Error(`scenario id "${TEST_COMMAND_ID}" is reserved for the contract's test_command`);
5116
5841
  const results = [];
5117
5842
  for (const scenario of scenarios) results.push(await execute(scenario, run, opts.timeoutSecDefault ?? 60));
@@ -5139,7 +5864,7 @@ async function runVerify2(opts) {
5139
5864
  }
5140
5865
  const baselineFindings = trusted ? await compareToTrustedBase(repo, trusted, commit, contractRel, scenariosRel, contractFile) : baseline ? compareToBaseline(
5141
5866
  baseline,
5142
- { policyHash: sha256(readFileSync15(contractFile)), scenarioHashes: hashTree(dir) },
5867
+ { policyHash: sha256(readFileSync17(contractFile)), scenarioHashes: hashTree(dir) },
5143
5868
  { contract: opts.contractPath ?? ".replume/contract.yaml", scenariosDir: opts.scenariosDir ?? ".replume/scenarios" }
5144
5869
  ) : [];
5145
5870
  return buildReport({
@@ -5157,6 +5882,7 @@ async function runVerify2(opts) {
5157
5882
  draft,
5158
5883
  dependencies,
5159
5884
  newTests,
5885
+ observation,
5160
5886
  baselineCheck: trusted ? {
5161
5887
  name: "baseline",
5162
5888
  status: "done",
@@ -5168,9 +5894,6 @@ async function runVerify2(opts) {
5168
5894
  isolationReason: reason
5169
5895
  });
5170
5896
  });
5171
- const eventsFile = safeRelative(repo, opts.eventsPath ?? ".replume/events.jsonl");
5172
- const ledgerEvents = readEvents(eventsFile).events;
5173
- const since = baseline?.recorded_at ?? null;
5174
5897
  const { interventions, layer2, layer2Modes, scope, sessions } = supervisorSection(ledgerEvents, paths, since);
5175
5898
  report.metrics = taskMetrics(ledgerEvents, {
5176
5899
  repoRoot: repo,
@@ -5203,9 +5926,9 @@ async function runVerify2(opts) {
5203
5926
  }
5204
5927
  }
5205
5928
  const safe = redactDeep(report, Number.POSITIVE_INFINITY);
5206
- const reportsDir = join20(repo, ".replume/reports");
5929
+ const reportsDir = join22(repo, ".replume/reports");
5207
5930
  await mkdir(reportsDir, { recursive: true });
5208
- await writeFile2(join20(reportsDir, `${commit}.json`), JSON.stringify(safe, null, 2) + "\n");
5931
+ await writeFile2(join22(reportsDir, `${commit}.json`), JSON.stringify(safe, null, 2) + "\n");
5209
5932
  return safe;
5210
5933
  }
5211
5934
  function isolationFooter(report) {
@@ -5228,11 +5951,21 @@ function countTag(levels) {
5228
5951
  if (counts.size <= 1) return TAG[levels[0] ?? "verified_here"];
5229
5952
  return `[evidence: ${[...counts].map(([l, k]) => `${k} ${LEVEL_NAME[l]}`).join("; ")}]`;
5230
5953
  }
5954
+ function notCheckedCauses(report) {
5955
+ const causes = [...new Set(report.notChecked.map((x) => {
5956
+ const m = /; (agent steps not observed|change not seen by hooks)/.exec(x.reason);
5957
+ if (m) return `no scenario declares coverage; ${m[1]}`;
5958
+ if (/the contract has no test_command/.test(x.reason)) return "no scenario declares coverage and no test_command";
5959
+ if (/test_command did not pass/.test(x.reason)) return "no scenario declares coverage and test_command did not pass with at least one test";
5960
+ return "no scenario declares coverage";
5961
+ }))];
5962
+ return causes.length ? ` (${causes.join("; ")})` : "";
5963
+ }
5231
5964
  function renderReport(report, opts = {}) {
5232
5965
  if (opts.json) return JSON.stringify(report, null, 2);
5233
5966
  const quietCapture = report.capture?.ledger === "absent" || report.capture?.ledger === "empty";
5234
5967
  const first = report.verdict === "pass" ? "Verdict: PASS \u2014 the checks that ran passed" : report.verdict === "fail" ? "Verdict: FAIL \u2014 at least one required check failed" : "Verdict: INCOMPLETE \u2014 the available checks cannot establish a pass";
5235
- const why = report.verdict === "pass" ? `Why: ${report.done.length} scenario(s) passed; no failing check, blocking extra change, tampering or undeclared coverage was reported.` : report.verdict === "fail" ? `Why: ${report.failed.length} failed scenario(s), ${report.extra.filter((e) => !e.informational).length} blocking extra change(s), ${report.tampering.length} tampering finding(s); inspect the details below.` : `Why: ${report.notChecked.length} path(s) have no scenario declaring coverage, or a required check did not run; inspect NOT RUN below.`;
5968
+ const why = report.verdict === "pass" ? `Why: ${report.done.length} check(s) passed (${report.done.map((d) => d.id).join(", ")}); no failing check, blocking extra change, tampering or undeclared coverage was reported${report.observedOnly?.length ? `; ${report.observedOnly.length} changed file(s) checked by test_command and observed steps, no scenario` : ""}.` : report.verdict === "fail" ? `Why: ${report.failed.length} failed scenario(s), ${report.extra.filter((e) => !e.informational).length} blocking extra change(s), ${report.tampering.length} tampering finding(s); inspect the details below.` : `Why: ${report.notChecked.length} path(s) are not checked${notCheckedCauses(report)}, or a required check did not run; inspect NOT CHECKED and NOT RUN below.`;
5236
5969
  const activity = quietCapture ? `WARNING: ${report.capture?.hooksConfigured ? "Replume project hooks are configured, but no hook events were recorded" : "no Replume project hooks were found"}; agent steps and scope were not observed.` : report.capture ? `Agent activity: ${report.capture.events} event(s) reached Replume; this is not proof that every step was captured.` : "Agent activity: no capture information is available in this report.";
5237
5970
  const lines = [first, why, activity, `Replume report commit ${report.commit.slice(0, 7)} task: ${report.contractId}`];
5238
5971
  if (report.baseSource) lines.push(`Base ${report.baseCommit.slice(0, 7)} ${BASE_SOURCE_TEXT[report.baseSource]} ${TAG[report.baseSource === "recorded_at_task" ? "observed" : "verified_here"]}`);
@@ -5266,7 +5999,9 @@ function renderReport(report, opts = {}) {
5266
5999
  }
5267
6000
  lines.push(`EXTRA (${report.extra.length}) ${report.extra.map((x) => `${x.dependency ? `${x.path}: ${x.dependency.name}` : x.path} ${x.reason}`).join(", ") || "\u2014"} ${countTag(report.extra.map((x) => levelOf(x, "verified_here")))}`);
5268
6001
  lines.push(`TAMPERING (${report.tampering.length}) ${report.tampering.map((x) => `${x.path} ${x.detail}`).join(", ") || "\u2014"} ${countTag(report.tampering.map((x) => levelOf(x, "verified_here")))}`);
5269
- lines.push(`NOT CHECKED (${report.notChecked.length}) ${report.notChecked.map((x) => x.path).join(", ") || "\u2014"}${report.notChecked.length ? " no scenario declares coverage" : ""} ${TAG.unknown}`);
6002
+ if (report.observedOnly?.length)
6003
+ lines.push(`OBSERVED (${report.observedOnly.length}) ${report.observedOnly.map((x) => x.path).join(", ")} checked by test_command and observed steps; no scenario [Observed]`);
6004
+ lines.push(`NOT CHECKED (${report.notChecked.length}) ${report.notChecked.map((x) => x.path).join(", ") || "\u2014"}${report.notChecked.length ? ` ${notCheckedCauses(report).replace(/^ \(|\)$/g, "")}` : ""} ${TAG.unknown}`);
5270
6005
  if (report.supervisor) {
5271
6006
  const [head, ...rest] = renderInterventions(report.supervisor, report.supervisor.scope);
5272
6007
  lines.push(`${head} ${TAG.observed}`, " Meaning: SUPERVISOR lists permission decisions; matching Cursor shell hooks are counted once. Hook output alone does not confirm execution. These records are editable.", ...rest);
@@ -5326,8 +6061,8 @@ async function runPreflight(opts) {
5326
6061
  const { mode, reason } = await resolveIsolation(opts.isolation ?? "auto", image);
5327
6062
  const results = await withWorkingCopy(opts.repoPath, opts.commit, false, async (root, home) => {
5328
6063
  const run = (command, t) => mode === "docker" ? runDockerScenario({ root, command, timeoutSec: t, image }) : runCommand(command, root, home, t);
5329
- const dir = join21(root, opts.scenariosDir ?? ".replume/scenarios");
5330
- const scenarios = existsSync16(dir) ? await loadScenarios(dir, root).catch(() => []) : [];
6064
+ const dir = join23(root, opts.scenariosDir ?? ".replume/scenarios");
6065
+ const scenarios = existsSync18(dir) ? await loadScenarios(dir, root).catch(() => []) : [];
5331
6066
  const out = [];
5332
6067
  for (const id of c.must_check) {
5333
6068
  const s = scenarios.find((x) => x.id === id);
@@ -5383,7 +6118,7 @@ function comparePreflight(p, now) {
5383
6118
  }
5384
6119
 
5385
6120
  // src/core/watch.ts
5386
- import { existsSync as existsSync17, readFileSync as readFileSync16, statSync as statSync2, watchFile, unwatchFile } from "fs";
6121
+ import { existsSync as existsSync19, readFileSync as readFileSync18, statSync as statSync2, watchFile, unwatchFile } from "fs";
5387
6122
  function formatEvent(e) {
5388
6123
  const time = e.ts.slice(11, 19);
5389
6124
  const action = e.action && e.action !== "ok" ? e.action.toUpperCase() : "";
@@ -5394,11 +6129,11 @@ ${" ".repeat(19)}${e.message.split("\n")[0]}`;
5394
6129
  return head;
5395
6130
  }
5396
6131
  function readNew(path, offset) {
5397
- if (!existsSync17(path)) return { lines: [], offset, bad: 0 };
6132
+ if (!existsSync19(path)) return { lines: [], offset, bad: 0 };
5398
6133
  const size = statSync2(path).size;
5399
6134
  if (size < offset) offset = 0;
5400
6135
  if (size === offset) return { lines: [], offset, bad: 0 };
5401
- const buf = readFileSync16(path);
6136
+ const buf = readFileSync18(path);
5402
6137
  const chunk = buf.subarray(offset, size).toString("utf-8");
5403
6138
  const lastNl = chunk.lastIndexOf("\n");
5404
6139
  if (lastNl < 0) return { lines: [], offset, bad: 0 };
@@ -5422,7 +6157,7 @@ function watchLedger(path, print, opts = {}) {
5422
6157
  }
5423
6158
 
5424
6159
  // src/core/replay.ts
5425
- import { isAbsolute as isAbsolute8, join as join22 } from "path";
6160
+ import { isAbsolute as isAbsolute8, join as join24 } from "path";
5426
6161
  var REPLAY_ROOT = "/replay-repo";
5427
6162
  var REPLAY_BANNER = "RECORDED SESSION (replay). These are recorded events run through Replume's rules now.\nThis shows what the rules decide on these events; it does not show a live agent being steered.";
5428
6163
  function kindOf(e) {
@@ -5440,10 +6175,10 @@ function actionFromEvent(e) {
5440
6175
  for (const k of ["file_path", "notebook_path", "path"]) {
5441
6176
  const v = input[k];
5442
6177
  if (typeof v !== "string") continue;
5443
- if (!isAbsolute8(v)) input[k] = join22(REPLAY_ROOT, v);
6178
+ if (!isAbsolute8(v)) input[k] = join24(REPLAY_ROOT, v);
5444
6179
  else {
5445
6180
  const rel = e.paths.find((p) => v.endsWith(`/${p}`));
5446
- if (rel) input[k] = join22(REPLAY_ROOT, rel);
6181
+ if (rel) input[k] = join24(REPLAY_ROOT, rel);
5447
6182
  }
5448
6183
  }
5449
6184
  return toProposedAction(
@@ -5459,7 +6194,7 @@ function actionFromEvent(e) {
5459
6194
  });
5460
6195
  }
5461
6196
  const kind = kindOf(e);
5462
- const { inside, outside } = splitPaths(REPLAY_ROOT, e.paths.map((p) => isAbsolute8(p) ? p : join22(REPLAY_ROOT, p)));
6197
+ const { inside, outside } = splitPaths(REPLAY_ROOT, e.paths.map((p) => isAbsolute8(p) ? p : join24(REPLAY_ROOT, p)));
5463
6198
  const rawEdit = raw3;
5464
6199
  const before = rawEdit.before ?? rawEdit.old_string;
5465
6200
  const after = rawEdit.after ?? rawEdit.new_string;
@@ -5557,17 +6292,17 @@ var builtInEvents = JSON.stringify({
5557
6292
 
5558
6293
  // src/cli.ts
5559
6294
  function readStdin() {
5560
- return new Promise((resolve7, reject) => {
6295
+ return new Promise((resolve9, reject) => {
5561
6296
  let data = "";
5562
6297
  process.stdin.setEncoding("utf-8");
5563
6298
  process.stdin.on("data", (chunk) => data += chunk);
5564
- process.stdin.on("end", () => resolve7(data));
6299
+ process.stdin.on("end", () => resolve9(data));
5565
6300
  process.stdin.on("error", reject);
5566
6301
  });
5567
6302
  }
5568
6303
  function repoRoot() {
5569
6304
  try {
5570
- return execFileSync10("git", ["rev-parse", "--show-toplevel"], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
6305
+ return execFileSync11("git", ["rev-parse", "--show-toplevel"], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
5571
6306
  } catch {
5572
6307
  return process.cwd();
5573
6308
  }
@@ -5613,16 +6348,16 @@ usageCommand.command("import-cursor").requiredOption("--result <path>", "local C
5613
6348
  });
5614
6349
  program.name("replume").description("Verify what your AI coding agent actually did").version(package_default.version);
5615
6350
  program.addHelpText("after", "\nOff-machine options: verify --legacy-judge sends the full git diff, task text, and agent action summary to api.openai.com; --post-github-comment posts task text to the PR on api.github.com; --check-registry sends package names to npm/PyPI. HTTP layer 2 is off unless REPLUME_JUDGE=shadow|on and REPLUME_JUDGE_PROVIDER=http with REPLUME_JUDGE_URL; HTTP needs REPLUME_JUDGE_ALLOW_INSECURE_HTTP=1.\n");
5616
- program.command("init").description("Create .replume/, install agent hooks where supported, print the capability matrix").option("--hook-command <cmd>", "command Claude Code should run for hooks", void 0).option("--cursor", "install Cursor project hooks even when .cursor/ is absent").option("--cursor-hook-command <cmd>", "command Cursor should run for hooks").option("--remove-cursor-hooks", "restore the Cursor hook config from before Replume init").action((opts) => {
6351
+ program.command("init").description("Create .replume/, install agent hooks where supported, print the capability matrix").option("--hook-command <cmd>", "command Claude Code should run for hooks", void 0).option("--cursor", "install Cursor project hooks even when .cursor/ is absent").option("--codex", "install Codex project hooks even when .codex/ is absent").option("--cursor-hook-command <cmd>", "command Cursor should run for hooks").option("--remove-cursor-hooks", "restore the Cursor hook config from before Replume init").action((opts) => {
5617
6352
  try {
5618
6353
  const root = repoRoot();
5619
6354
  if (opts.removeCursorHooks) {
5620
- const result = uninstallCursorHooks(root);
5621
- console.log(result.restored ? result.backupRetained ? `Cursor hooks removed where still identifiable; edited settings kept at ${result.path}; original backup retained beside it` : `Cursor hooks removed; previous configuration restored at ${result.path}` : "No Replume Cursor installation found");
6355
+ const result2 = uninstallCursorHooks(root);
6356
+ console.log(result2.restored ? result2.backupRetained ? `Cursor hooks removed where still identifiable; edited settings kept at ${result2.path}; original backup retained beside it` : `Cursor hooks removed; previous configuration restored at ${result2.path}` : "No Replume Cursor installation found");
5622
6357
  return;
5623
6358
  }
5624
6359
  const detected = detectAgents(root);
5625
- const cursorDir = join23(root, ".cursor");
6360
+ const cursorDir = join25(root, ".cursor");
5626
6361
  let cursorEntry = null;
5627
6362
  try {
5628
6363
  cursorEntry = lstatSync3(cursorDir);
@@ -5632,12 +6367,23 @@ program.command("init").description("Create .replume/, install agent hooks where
5632
6367
  if (opts.cursor && cursorEntry && !cursorEntry.isDirectory())
5633
6368
  throw new Error(`${cursorDir} must be a directory; nothing was changed.`);
5634
6369
  const wantCursor = Boolean(opts.cursor || cursorEntry?.isDirectory());
6370
+ const codexDir = join25(root, ".codex");
6371
+ let codexEntry = null;
6372
+ try {
6373
+ codexEntry = lstatSync3(codexDir);
6374
+ } catch (e) {
6375
+ if (e.code !== "ENOENT") throw e;
6376
+ }
6377
+ if (opts.codex && codexEntry && !codexEntry.isDirectory())
6378
+ throw new Error(`${codexDir} must be a directory; nothing was changed.`);
6379
+ const wantCodex = Boolean(opts.codex || codexEntry?.isDirectory());
5635
6380
  if (wantCursor) validateCursorHooksConfig(root, { command: opts.cursorHookCommand });
5636
- if (detected["Claude Code"] && existsSync18(claudeSettingsPath(root))) {
6381
+ if (wantCodex) validateCodexHooks(root);
6382
+ if (detected["Claude Code"] && existsSync20(claudeSettingsPath(root))) {
5637
6383
  const path = claudeSettingsPath(root);
5638
6384
  let parsed;
5639
6385
  try {
5640
- parsed = JSON.parse(readFileSync17(path, "utf8"));
6386
+ parsed = JSON.parse(readFileSync19(path, "utf8"));
5641
6387
  } catch {
5642
6388
  throw new Error(`${path} is not valid JSON; nothing was changed. Fix it and run replume init again.`);
5643
6389
  }
@@ -5645,14 +6391,28 @@ program.command("init").description("Create .replume/, install agent hooks where
5645
6391
  throw new Error(`${path} is not a JSON object; nothing was changed. Fix it and run replume init again.`);
5646
6392
  mergeHooks(parsed, opts.hookCommand ?? DEFAULT_HOOK_COMMAND);
5647
6393
  }
5648
- const { lines } = runInit(root, { hookCommand: opts.hookCommand, detected });
6394
+ const { lines } = runInit(root, {
6395
+ hookCommand: opts.hookCommand,
6396
+ cursorHookCommand: opts.cursorHookCommand,
6397
+ cursorHooks: wantCursor,
6398
+ codexHooks: wantCodex,
6399
+ detected
6400
+ });
5649
6401
  const oldNotice = lines.findIndex((l) => l.startsWith("- Cursor found, but"));
5650
6402
  if (oldNotice >= 0) lines.splice(oldNotice, 1);
6403
+ const codexNotice = lines.findIndex((l) => l.startsWith("- Codex found, but"));
6404
+ if (codexNotice >= 0) lines.splice(codexNotice, 1);
5651
6405
  const matrixAt = lines.indexOf("Capability matrix");
5652
6406
  lines[matrixAt + 1] = renderCapabilities(CAPABILITIES.map((c) => c.agent === "Cursor" ? {
5653
6407
  ...c,
5654
6408
  installedByInit: wantCursor
5655
- } : c), detected);
6409
+ } : c.agent === "Codex" ? { ...c, installedByInit: wantCodex } : c), detected);
6410
+ if (wantCodex) {
6411
+ const codex = installCodexHooks(root);
6412
+ lines.splice(matrixAt, 0, codex.added.length ? `\u2713 Codex hooks added to .codex/hooks.json: ${codex.added.join(", ")}; run /hooks in Codex to trust this definition` : "\u2713 Codex hooks already present; run /hooks in Codex if the definition changed");
6413
+ } else {
6414
+ lines.splice(matrixAt, 0, "- Codex project hooks not installed: run replume init --codex; otherwise Codex agent steps will not be recorded in the ledger");
6415
+ }
5656
6416
  if (wantCursor) {
5657
6417
  const cursor = installCursorHooks(root, { command: opts.cursorHookCommand });
5658
6418
  lines.splice(matrixAt, 0, cursor.added.length ? `\u2713 Cursor hooks added to .cursor/hooks.json: ${cursor.added.join(", ")}${cursor.backupPath ? ` (backup: ${cursor.backupPath})` : ""}` : "\u2713 Cursor hooks already present (nothing changed)");
@@ -5695,6 +6455,9 @@ addPreflightOptions(program.command("task")).description("Write the Task Contrac
5695
6455
  fail(e.message);
5696
6456
  }
5697
6457
  });
6458
+ function passPathHint(c) {
6459
+ return c.test_command ? ` Scenarios are optional: verify can PASS when \`${c.test_command}\` runs at least one test and passes, nothing is EXTRA or TAMPERING, and the hooks record the agent changing each changed file. A scenario adds a stated behaviour check.` : " No test_command: without a test_command or a scenario, verify cannot reach PASS. Set test_command in .replume/contract.yaml (scenarios are optional).";
6460
+ }
5698
6461
  addPreflightOptions(program.command("draft")).description("Propose a Task Contract from the repository and the task text (status: draft, not policy until confirmed)").requiredOption("--task <text>", "what the agent will be asked to do").option("--yes", "confirm at once (for CI): the draft becomes policy and the baseline is recorded").option("--accept-broad", "with --yes: also confirm allow globs broader than the paths the task names").option("--refine", "model refinement of the draft (not built)").action(async (opts) => {
5699
6462
  if (opts.refine) fail("model refinement is not built; the draft is deterministic only (no model is called). Edit .replume/contract.yaml by hand instead.");
5700
6463
  const root = repoRoot();
@@ -5725,8 +6488,10 @@ addPreflightOptions(program.command("draft")).description("Propose a Task Contra
5725
6488
  console.log(serializeContract(c).trimEnd());
5726
6489
  console.log("");
5727
6490
  console.log(finalAllow);
6491
+ if (allowHasNoSource(c.allow)) console.log(` WARNING: ${ALLOW_NO_SOURCE_WARNING}`);
5728
6492
  if (opts.yes) await preflightAndRecord(root, path, c, opts);
5729
6493
  else console.log(" This draft is NOT policy yet: verify reports the contract as NOT CHECKED and the hooks ignore it. Review the file, then run `replume confirm`.");
6494
+ console.log(passPathHint(c));
5730
6495
  } catch (e) {
5731
6496
  fail(e.message);
5732
6497
  }
@@ -5744,14 +6509,15 @@ addPreflightOptions(program.command("confirm")).description("Confirm the draft c
5744
6509
  writeContract(path, confirmed);
5745
6510
  console.log(`\u2713 confirmed ${REPLUME_DIR}/contract.yaml (${c.id}, status: confirmed)`);
5746
6511
  await preflightAndRecord(root, path, confirmed, opts);
5747
- console.log(" commit .replume/contract.yaml before running the agent (commit the scenario files and .replume/.gitignore too)");
6512
+ console.log(passPathHint(confirmed));
6513
+ console.log(" commit .replume/contract.yaml before running the agent (and .replume/.gitignore, and any scenario files)");
5748
6514
  } catch (e) {
5749
6515
  fail(e.message);
5750
6516
  }
5751
6517
  });
5752
6518
  program.command("watch").description("Live view of .replume/events.jsonl with rule hits").option("--once", "print what is there and exit").action((opts) => {
5753
6519
  const path = ledgerPath(repoRoot());
5754
- if (!existsSync18(path)) console.log(`(no events yet at ${path}; waiting)`);
6520
+ if (!existsSync20(path)) console.log(`(no events yet at ${path}; waiting)`);
5755
6521
  watchLedger(path, (l) => console.log(l), { once: opts.once });
5756
6522
  });
5757
6523
  program.command("verify").description("Run the contract and scenarios on a commit in an isolated working copy (exit 0 pass, 1 fail, 2 incomplete)").option("--commit <sha>", "commit to verify", "HEAD").option("--base <sha>", "commit to diff against").option("--trusted-base <ref>", "git ref (commit or protected branch) whose contract and scenarios are the policy; also the diff base unless --base is given").option("--json", "print the report as JSON").option("--keep-workdir", "keep the isolated working copy for debugging (worktree mode only)").addOption(
@@ -5765,7 +6531,7 @@ program.command("verify").description("Run the contract and scenarios on a commi
5765
6531
  if (opts.checkRegistry) console.error("replume: opt-in registry check sends package names from dependency changes to registry.npmjs.org or pypi.org; no source file contents are sent by this lookup.");
5766
6532
  try {
5767
6533
  const root = repoRoot();
5768
- const commit = execFileSync10("git", ["rev-parse", "--verify", `${opts.commit}^{commit}`], { cwd: root }).toString().trim();
6534
+ const commit = execFileSync11("git", ["rev-parse", "--verify", `${opts.commit}^{commit}`], { cwd: root }).toString().trim();
5769
6535
  const report = await runVerify2({
5770
6536
  repoPath: root,
5771
6537
  commit,
@@ -5785,7 +6551,7 @@ program.command("verify").description("Run the contract and scenarios on a commi
5785
6551
  program.command("accept").description("Explicitly accept HEAD after a fresh verification PASS; only then count cost per accepted change").option("--base <sha>", "commit to diff against").addOption(new Option("--isolation <mode>", "verification mode").choices(["auto", "docker", "worktree"]).default("auto")).action(async (opts) => {
5786
6552
  const root = repoRoot();
5787
6553
  try {
5788
- const commit = execFileSync10("git", ["rev-parse", "--verify", "HEAD^{commit}"], { cwd: root, encoding: "utf8" }).trim();
6554
+ const commit = execFileSync11("git", ["rev-parse", "--verify", "HEAD^{commit}"], { cwd: root, encoding: "utf8" }).trim();
5789
6555
  const report = await runVerify2({ repoPath: root, commit, baseCommit: opts.base, isolation: opts.isolation });
5790
6556
  if (report.isolationMode === "worktree") console.error(WORKTREE_WARNING);
5791
6557
  if (report.verdict !== "pass") fail(`accept requires a fresh verify PASS; this commit is ${report.verdict.toUpperCase()}`);
@@ -5799,15 +6565,15 @@ program.command("report").description("Re-print the report saved for a commit (.
5799
6565
  const root = repoRoot();
5800
6566
  let commit;
5801
6567
  try {
5802
- commit = execFileSync10("git", ["rev-parse", "--verify", `${opts.commit}^{commit}`], { cwd: root, stdio: ["ignore", "pipe", "pipe"] }).toString().trim();
6568
+ commit = execFileSync11("git", ["rev-parse", "--verify", `${opts.commit}^{commit}`], { cwd: root, stdio: ["ignore", "pipe", "pipe"] }).toString().trim();
5803
6569
  } catch {
5804
6570
  fail(`not a commit: ${opts.commit}`);
5805
6571
  }
5806
- const file2 = join23(root, REPLUME_DIR, "reports", `${commit}.json`);
5807
- if (!existsSync18(file2)) fail(`no report for ${commit.slice(0, 7)} at ${file2}; run replume verify --commit ${opts.commit}`);
6572
+ const file2 = join25(root, REPLUME_DIR, "reports", `${commit}.json`);
6573
+ if (!existsSync20(file2)) fail(`no report for ${commit.slice(0, 7)} at ${file2}; run replume verify --commit ${opts.commit}`);
5808
6574
  let report;
5809
6575
  try {
5810
- report = JSON.parse(readFileSync17(file2, "utf-8"));
6576
+ report = JSON.parse(readFileSync19(file2, "utf-8"));
5811
6577
  } catch (e) {
5812
6578
  fail(`${file2} is not valid JSON: ${e.message}`);
5813
6579
  }
@@ -5831,25 +6597,25 @@ demo.option("--replay <file>", "run a recorded events.jsonl session through the
5831
6597
  console.log(renderReplay(replay(builtInEvents, builtInContract), builtInContract));
5832
6598
  return;
5833
6599
  }
5834
- const file2 = resolve6(opts.replay);
5835
- if (!existsSync18(file2)) fail(`replay file not found: ${file2}`);
6600
+ const file2 = resolve8(opts.replay);
6601
+ if (!existsSync20(file2)) fail(`replay file not found: ${file2}`);
5836
6602
  try {
5837
6603
  const sibling = file2.replace(/\.jsonl?$/, "") + ".contract.yaml";
5838
- const contractFile = opts.contract ? resolve6(opts.contract) : existsSync18(sibling) ? sibling : contractPath(repoRoot());
6604
+ const contractFile = opts.contract ? resolve8(opts.contract) : existsSync20(sibling) ? sibling : contractPath(repoRoot());
5839
6605
  const contract = loadContract(contractFile);
5840
- console.log(renderReplay(replay(readFileSync17(file2, "utf-8"), contract), contract, contract ? contractFile : void 0));
6606
+ console.log(renderReplay(replay(readFileSync19(file2, "utf-8"), contract), contract, contract ? contractFile : void 0));
5841
6607
  } catch (e) {
5842
6608
  fail(e.message);
5843
6609
  }
5844
6610
  });
5845
6611
  async function demoReplayHooks(path, contractOpt) {
5846
- const file2 = resolve6(path);
5847
- if (!existsSync18(file2)) fail(`replay file not found: ${file2}`);
6612
+ const file2 = resolve8(path);
6613
+ if (!existsSync20(file2)) fail(`replay file not found: ${file2}`);
5848
6614
  const sibling = file2.replace(/\.jsonl?$/, "") + ".contract.yaml";
5849
- const contractFile = contractOpt ? resolve6(contractOpt) : existsSync18(sibling) ? sibling : null;
6615
+ const contractFile = contractOpt ? resolve8(contractOpt) : existsSync20(sibling) ? sibling : null;
5850
6616
  try {
5851
- const r = await replayHooks(readFileSync17(file2, "utf-8"), {
5852
- contractYaml: contractFile ? readFileSync17(contractFile, "utf-8") : void 0,
6617
+ const r = await replayHooks(readFileSync19(file2, "utf-8"), {
6618
+ contractYaml: contractFile ? readFileSync19(contractFile, "utf-8") : void 0,
5853
6619
  env: process.env
5854
6620
  });
5855
6621
  console.log(REPLAY_HOOKS_BANNER);
@@ -5872,13 +6638,13 @@ program.command("install").description("[legacy, August MVP] Register SessionSta
5872
6638
  });
5873
6639
  async function legacyVerify(postGithubComment) {
5874
6640
  const repoDir = process.cwd();
5875
- const headSha = process.env.REPLUME_HEAD_SHA ?? execFileSync10("git", ["rev-parse", "HEAD"], { cwd: repoDir }).toString().trim();
6641
+ const headSha = process.env.REPLUME_HEAD_SHA ?? execFileSync11("git", ["rev-parse", "HEAD"], { cwd: repoDir }).toString().trim();
5876
6642
  const [owner, repo] = (process.env.GITHUB_REPOSITORY ?? "/").split("/");
5877
6643
  const prNumber = Number(process.env.REPLUME_PR_NUMBER ?? "0");
5878
6644
  const baseRef = process.env.REPLUME_BASE_REF ?? "main";
5879
6645
  let diff = "";
5880
6646
  try {
5881
- diff = execFileSync10("git", ["diff", `origin/${baseRef}...HEAD`], { cwd: repoDir }).toString();
6647
+ diff = execFileSync11("git", ["diff", `origin/${baseRef}...HEAD`], { cwd: repoDir }).toString();
5882
6648
  } catch (err) {
5883
6649
  console.warn(`replume: could not compute diff against origin/${baseRef}, continuing with empty diff:`, err);
5884
6650
  }
@@ -5897,6 +6663,12 @@ async function legacyVerify(postGithubComment) {
5897
6663
  console.log(`replume verdict: ${verdict}`);
5898
6664
  }
5899
6665
  var hook = program.command("hook").description("Internal: agent hook entry points");
6666
+ hook.command("codex").description("Codex CLI hook handler").action(async () => {
6667
+ const out = handleCodexHook(await readStdin());
6668
+ if (out.stdout) process.stdout.write(out.stdout);
6669
+ if (out.stderr) process.stderr.write(out.stderr);
6670
+ process.exitCode = out.exitCode;
6671
+ });
5900
6672
  hook.command("cursor").description("Cursor hook handler").action(async () => {
5901
6673
  const out = handleCursorHook(await readStdin());
5902
6674
  process.stdout.write(out.stdout);