replume-cli 0.1.0 → 0.1.1

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.
Files changed (3) hide show
  1. package/README.md +36 -18
  2. package/dist/cli.js +472 -255
  3. package/package.json +1 -1
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.1",
10
10
  type: "module",
11
11
  bin: {
12
12
  replume: "dist/cli.js"
@@ -601,7 +601,7 @@ async function runVerify(opts) {
601
601
  // src/agents/claude/handler.ts
602
602
  import { execFileSync as execFileSync5 } from "child_process";
603
603
  import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync6 } from "fs";
604
- import { join as join8 } from "path";
604
+ import { join as join9 } from "path";
605
605
  import { homedir as homedir2 } from "os";
606
606
 
607
607
  // src/core/usage-store.ts
@@ -886,7 +886,9 @@ function validateReport(x) {
886
886
  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
887
  if (!valid) p.push("metrics.accounting has invalid evidence or value");
888
888
  }
889
- const levelled = ["done", "failed", "extra", "tampering", "notChecked", "checks"].flatMap((k) => Array.isArray(x[k]) ? x[k] : []);
889
+ if (x.observedOnly !== void 0 && (!Array.isArray(x.observedOnly) || !x.observedOnly.every((e) => isObj(e) && typeof e.path === "string" && typeof e.reason === "string")))
890
+ p.push("observedOnly must be an array of {path, reason}");
891
+ const levelled = ["done", "failed", "extra", "tampering", "notChecked", "observedOnly", "checks"].flatMap((k) => Array.isArray(x[k]) ? x[k] : []);
890
892
  if (levelled.some((e) => isObj(e) && e.evidence_level !== void 0 && !EVIDENCE_LEVELS.includes(e.evidence_level)))
891
893
  p.push(`evidence_level must be one of ${EVIDENCE_LEVELS.join(", ")}`);
892
894
  return p;
@@ -1294,6 +1296,139 @@ function matchesAny(path, patterns) {
1294
1296
  return null;
1295
1297
  }
1296
1298
 
1299
+ // src/core/capture.ts
1300
+ import { existsSync as existsSync6, readFileSync as readFileSync7 } from "fs";
1301
+ import { dirname as dirname4, join as join6 } from "path";
1302
+ var EXPECTED_TYPES = ["prompt", "tool_pre", "command", "stop"];
1303
+ var SHOWN_PATHS = 5;
1304
+ var count2 = (m, k) => {
1305
+ m[k] = (m[k] ?? 0) + 1;
1306
+ };
1307
+ var HOOK_CRASH = "hook-errors.log exists: at least one hook crashed, so some steps may not have been recorded";
1308
+ function captureCoverage(eventsFile, changedPaths, hooksConfigured = false) {
1309
+ 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";
1310
+ const empty = (ledger, notes) => ({
1311
+ ledger,
1312
+ hooksConfigured,
1313
+ events: 0,
1314
+ sessions: 0,
1315
+ agents: [],
1316
+ byType: {},
1317
+ tools: {},
1318
+ firstTs: null,
1319
+ lastTs: null,
1320
+ badLines: 0,
1321
+ hookErrorsLogged: hookErrors(eventsFile),
1322
+ changedFiles: changedPaths.length,
1323
+ changedFilesSeen: 0,
1324
+ changedFilesLanded: 0,
1325
+ changedFilesAttemptedOnly: [],
1326
+ changedFilesNotSeen: changedPaths.slice(0, SHOWN_PATHS),
1327
+ unobservableCommands: 0,
1328
+ // a hook that crashed on every call leaves no ledger: say so here too, not only in the JSON flag
1329
+ notSeen: hookErrors(eventsFile) ? [...notes, HOOK_CRASH] : notes
1330
+ });
1331
+ if (!existsSync6(eventsFile)) {
1332
+ return empty("absent", [`no ledger file: ${absence}`]);
1333
+ }
1334
+ const text = readFileSync7(eventsFile, "utf-8");
1335
+ const { events, badLines } = parseEvents(text);
1336
+ if (!events.length) {
1337
+ return empty("empty", [
1338
+ badLines.length ? `the ledger has ${badLines.length} line(s) but none is a valid event` : `the ledger is empty: ${absence}`
1339
+ ]);
1340
+ }
1341
+ const byType = {};
1342
+ const tools = {};
1343
+ const seenPaths = /* @__PURE__ */ new Set();
1344
+ const landedPaths = /* @__PURE__ */ new Set();
1345
+ const blockedPaths = /* @__PURE__ */ new Set();
1346
+ for (const e of events) {
1347
+ count2(byType, e.type);
1348
+ if (e.type === "tool_pre" && e.tool) count2(tools, e.tool);
1349
+ const blocked = e.action === "block" && e.type !== "file_edit" && e.type !== "command";
1350
+ const landed = e.type === "file_edit" || e.type === "command" && e.raw?.ok !== false;
1351
+ for (const p of e.paths) {
1352
+ if (blocked) blockedPaths.add(p);
1353
+ else seenPaths.add(p);
1354
+ if (landed) landedPaths.add(p);
1355
+ }
1356
+ }
1357
+ const ts = events.map((e) => e.ts).sort();
1358
+ const notSeen = [];
1359
+ const missing = EXPECTED_TYPES.filter((t) => !byType[t]);
1360
+ if (missing.length) notSeen.push(`no ${missing.join(", ")} events`);
1361
+ const truncated = badLines.length > 0 || !text.endsWith("\n");
1362
+ 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`);
1363
+ const attemptedOnly = changedPaths.filter((p) => !seenPaths.has(p) && blockedPaths.has(p));
1364
+ if (attemptedOnly.length)
1365
+ notSeen.push(
1366
+ `${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`
1367
+ );
1368
+ const unseen = changedPaths.filter((p) => !seenPaths.has(p) && !blockedPaths.has(p));
1369
+ if (unseen.length) {
1370
+ notSeen.push(
1371
+ `${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`
1372
+ );
1373
+ }
1374
+ const opaque = [...new Set(events.filter((e) => (e.type === "command" || e.type === "tool_pre") && e.command && unobservable(e.command)).map((e) => e.command))];
1375
+ 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))];
1376
+ if (opaque.length)
1377
+ notSeen.push(
1378
+ `${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`
1379
+ );
1380
+ if (unparsed.length)
1381
+ notSeen.push(
1382
+ `${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`
1383
+ );
1384
+ const hookErrorsLogged = hookErrors(eventsFile);
1385
+ if (hookErrorsLogged) notSeen.push(HOOK_CRASH);
1386
+ return {
1387
+ ledger: "present",
1388
+ events: events.length,
1389
+ sessions: new Set(events.map((e) => e.sessionId)).size,
1390
+ agents: [...new Set(events.map((e) => e.agent))].sort(),
1391
+ byType,
1392
+ tools,
1393
+ firstTs: ts[0],
1394
+ lastTs: ts[ts.length - 1],
1395
+ badLines: badLines.length,
1396
+ hookErrorsLogged,
1397
+ changedFiles: changedPaths.length,
1398
+ changedFilesSeen: changedPaths.filter((p) => seenPaths.has(p)).length,
1399
+ changedFilesLanded: changedPaths.filter((p) => landedPaths.has(p)).length,
1400
+ changedFilesAttemptedOnly: attemptedOnly.slice(0, SHOWN_PATHS),
1401
+ changedFilesNotSeen: unseen.slice(0, SHOWN_PATHS),
1402
+ unobservableCommands: opaque.length + unparsed.length,
1403
+ notSeen
1404
+ };
1405
+ }
1406
+ 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./-]+)/;
1407
+ var RUNTIME_TARGET = /(?:>{1,2}|>\||\btee(?:\s+-\w+)*)\s*["']?[^\s"';|&]*(?:\$|`)/;
1408
+ var unobservable = (command) => SCRIPT_RUN.test(command) || RUNTIME_TARGET.test(command);
1409
+ function hookErrors(eventsFile) {
1410
+ return existsSync6(join6(dirname4(eventsFile), "hook-errors.log"));
1411
+ }
1412
+ var list = (m) => Object.entries(m).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([k, n]) => `${k} ${n}`).join(", ");
1413
+ function renderCapture(c) {
1414
+ const out = ["CAPTURE COVERAGE"];
1415
+ if (c.ledger !== "present") {
1416
+ out.push(` seen nothing (ledger ${c.ledger === "absent" ? "file not found" : "empty"})`);
1417
+ } else {
1418
+ 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)}`);
1419
+ if (Object.keys(c.tools).length) out.push(` tools ${list(c.tools)}`);
1420
+ if (c.changedFilesLanded !== void 0) {
1421
+ const a = c.changedFilesAttemptedOnly ?? [];
1422
+ out.push(
1423
+ ` 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(", ")}` : ""}`
1424
+ );
1425
+ }
1426
+ }
1427
+ out.push(` not seen ${c.notSeen.length ? c.notSeen.join("; ") : "nothing flagged by these checks (they cannot see steps taken outside the hooks)"}`);
1428
+ 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.");
1429
+ return out;
1430
+ }
1431
+
1297
1432
  // src/core/rules.ts
1298
1433
  var PREFIX = "Replume:";
1299
1434
  function fingerprint(tool, input) {
@@ -1332,11 +1467,32 @@ function shellTokens(src) {
1332
1467
  const toks = [];
1333
1468
  let cur = "";
1334
1469
  let has = false;
1470
+ const heredocs = [];
1335
1471
  const flush = () => {
1336
- if (has) toks.push({ word: cur });
1472
+ if (has) {
1473
+ const prev = toks[toks.length - 1];
1474
+ if (prev && "op" in prev && prev.op === "<<") {
1475
+ const strip = cur.startsWith("-");
1476
+ heredocs.push({ delim: strip ? cur.slice(1) : cur, strip });
1477
+ }
1478
+ toks.push({ word: cur });
1479
+ }
1337
1480
  cur = "";
1338
1481
  has = false;
1339
1482
  };
1483
+ const skipBodies = (from) => {
1484
+ let at = from;
1485
+ for (const h of heredocs.splice(0)) {
1486
+ for (; ; ) {
1487
+ if (at >= src.length) return src.length;
1488
+ const nl = src.indexOf("\n", at);
1489
+ const line = src.slice(at, nl < 0 ? src.length : nl);
1490
+ at = nl < 0 ? src.length : nl + 1;
1491
+ if ((h.strip ? line.replace(/^\t+/, "") : line) === h.delim) break;
1492
+ }
1493
+ }
1494
+ return at;
1495
+ };
1340
1496
  for (let i = 0; i < src.length; ) {
1341
1497
  const c = src[i];
1342
1498
  if (c === "'") {
@@ -1363,7 +1519,13 @@ function shellTokens(src) {
1363
1519
  i = j < 0 ? src.length : j;
1364
1520
  } else if (/\s/.test(c)) {
1365
1521
  flush();
1366
- if (c === "\n") toks.push({ op: ";" });
1522
+ if (c === "\n") {
1523
+ toks.push({ op: ";" });
1524
+ if (heredocs.length) {
1525
+ i = skipBodies(i + 1);
1526
+ continue;
1527
+ }
1528
+ }
1367
1529
  i++;
1368
1530
  } else if (">|<&;()".includes(c)) {
1369
1531
  const op = /^(?:&>>|>>|>\||>&|&>|&&|\|\||\|&|<<<|<<|<&|[>|<&;()])/.exec(src.slice(i))[0];
@@ -1421,7 +1583,7 @@ function fileVerbOperands(command, depth = 0) {
1421
1583
  const t = toks[i];
1422
1584
  if ("op" in t) {
1423
1585
  if (SEPARATORS.has(t.op)) simple.push([]);
1424
- else if (WRITE_OPS.has(t.op) || t.op === "<" || t.op === "<<<") i++;
1586
+ else if (WRITE_OPS.has(t.op) || t.op === "<" || t.op === "<<<" || t.op === "<<") i++;
1425
1587
  continue;
1426
1588
  }
1427
1589
  simple[simple.length - 1].push(t.word);
@@ -1441,6 +1603,84 @@ function fileVerbOperands(command, depth = 0) {
1441
1603
  }
1442
1604
  return out.filter((p) => p && !/[$*?{}`]/.test(p));
1443
1605
  }
1606
+ var DYNAMIC = /[$*?{}`]/;
1607
+ var INLINE_INTERP = /* @__PURE__ */ new Set(["node", "nodejs", "python", "python3", "perl", "ruby", "deno", "bun", "php"]);
1608
+ 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/;
1609
+ var GIT_TREE_CHANGERS = /* @__PURE__ */ new Set(["switch", "reset", "stash", "apply", "am", "merge", "pull", "rebase", "cherry-pick", "revert", "clean"]);
1610
+ function shellWriteTargets(command, depth = 0) {
1611
+ const out = [];
1612
+ let unparsed = false;
1613
+ const toks = shellTokens(command);
1614
+ const simple = [[]];
1615
+ for (let i = 0; i < toks.length; i++) {
1616
+ const t = toks[i];
1617
+ if ("op" in t) {
1618
+ if (SEPARATORS.has(t.op)) simple.push([]);
1619
+ else if (WRITE_OPS.has(t.op)) {
1620
+ const target = toks[i + 1];
1621
+ const w = target && "word" in target ? target.word : null;
1622
+ if (!w || DYNAMIC.test(w)) unparsed = true;
1623
+ else if (!DEVICE_SINK.test(w)) out.push(w);
1624
+ i++;
1625
+ } else if (t.op === "<" || t.op === "<<<" || t.op === "<<") i++;
1626
+ continue;
1627
+ }
1628
+ simple[simple.length - 1].push(t.word);
1629
+ }
1630
+ const add = (ws) => {
1631
+ for (const w of ws) {
1632
+ if (!w) continue;
1633
+ if (DYNAMIC.test(w)) unparsed = true;
1634
+ else if (!DEVICE_SINK.test(w)) out.push(w);
1635
+ }
1636
+ };
1637
+ for (let words of simple) {
1638
+ while (words.length && (["sudo", "command", "exec", "time", "env", "nohup"].includes(words[0]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]))) words = words.slice(1);
1639
+ if (!words.length) continue;
1640
+ const verb = words[0].split("/").pop();
1641
+ const args = words.slice(1);
1642
+ const operands = args.filter((w) => !w.startsWith("-"));
1643
+ if (SHELLS.has(verb)) {
1644
+ const k = args.findIndex((w) => /^-[a-z]*c$/.test(w));
1645
+ if (k >= 0 && args[k + 1] !== void 0) {
1646
+ if (depth >= 2) unparsed = true;
1647
+ else {
1648
+ const r = shellWriteTargets(args[k + 1], depth + 1);
1649
+ out.push(...r.paths);
1650
+ unparsed ||= r.unparsed;
1651
+ }
1652
+ }
1653
+ } else if (verb === "xargs") {
1654
+ if (args.some((w) => FILE_VERBS.has(w) || w === "sed" || w === "perl")) unparsed = true;
1655
+ } else if (["tee", "touch", "truncate", "rm", "unlink", "chmod", "chown"].includes(verb)) {
1656
+ add(verb === "chmod" || verb === "chown" ? operands.slice(1) : verb === "truncate" ? args.filter((w, j) => !w.startsWith("-") && !/^-s$|^--size$/.test(args[j - 1] ?? "")) : operands);
1657
+ } else if (verb === "cp" || verb === "install" || verb === "ln") {
1658
+ const t = args.findIndex((w) => w === "-t" || w === "--target-directory");
1659
+ if (t >= 0 || args.some((w) => w.startsWith("--target-directory="))) unparsed = true;
1660
+ else if (operands.length >= 2) add([operands[operands.length - 1]]);
1661
+ } else if (verb === "mv") add(operands);
1662
+ else if (verb === "dd") add(args.filter((w) => w.startsWith("of=")).map((w) => w.slice(3)));
1663
+ else if (verb === "patch") unparsed = true;
1664
+ else if (verb === "sed" && args.some((w) => /^-[a-zA-Z]*i|^--in-place/.test(w))) {
1665
+ 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] ?? "")));
1666
+ const scripted = args.some((w) => /^-[a-zA-Z]*[ef]$|^--expression|^--file/.test(w));
1667
+ add(scripted ? ops : ops.slice(1));
1668
+ } else if (verb === "perl" && args.some((w) => /^-[a-zA-Z]*i/.test(w))) {
1669
+ add(args.filter((w, j) => !w.startsWith("-") && !/^-[a-zA-Z]*e$/.test(args[j - 1] ?? "")));
1670
+ } else if (verb === "git") {
1671
+ const sub = args[0] ?? "";
1672
+ if (sub === "rm" || sub === "mv") add(operands.slice(1));
1673
+ else if (sub === "checkout" || sub === "restore") {
1674
+ const dd = args.indexOf("--");
1675
+ if (dd >= 0) add(args.slice(dd + 1));
1676
+ else if (sub === "restore") add(operands.slice(1));
1677
+ else unparsed = true;
1678
+ } else if (GIT_TREE_CHANGERS.has(sub)) unparsed = true;
1679
+ } 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;
1680
+ }
1681
+ if (depth === 0 && unobservable(command)) unparsed = true;
1682
+ return { paths: [...new Set(out)], unparsed };
1683
+ }
1444
1684
  function deletedOperands(command, depth = 0) {
1445
1685
  const out = [];
1446
1686
  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), [[]])) {
@@ -1485,7 +1725,7 @@ function stripShellComments(src) {
1485
1725
  }
1486
1726
  function writeTargets(a, repoRoot2) {
1487
1727
  if (a.kind !== "command" || !a.command) return { inside: [], outside: [] };
1488
- return splitPaths(repoRoot2, redirectTargets(a.command));
1728
+ return splitPaths(repoRoot2, [...redirectTargets(a.command), ...shellWriteTargets(a.command).paths]);
1489
1729
  }
1490
1730
  var isWrite = (a) => a.kind === "edit" || a.kind === "write";
1491
1731
  var outOfScopeEdit = (a, ctx) => {
@@ -1548,7 +1788,7 @@ var protectedPathModified = (a, ctx) => {
1548
1788
  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
1789
  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
1790
  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;
1791
+ var count3 = (re, s) => (s.match(re) ?? []).length;
1552
1792
  function assertionLiterals(s) {
1553
1793
  return s.split("\n").filter((l) => new RegExp(ASSERTION.source).test(l)).map((l) => l.trim()).sort();
1554
1794
  }
@@ -1580,8 +1820,8 @@ var testWeakened = (a) => {
1580
1820
  severity = "block";
1581
1821
  }
1582
1822
  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);
1823
+ const removed = count3(ASSERTION, ch.before) - count3(ASSERTION, ch.after);
1824
+ const skipsAdded = count3(SKIP_MARK, ch.after) - count3(SKIP_MARK, ch.before);
1585
1825
  if (ch.before && removed > 0) {
1586
1826
  findings.push(`${ch.path}: ${removed} assertion${removed > 1 ? "s" : ""} removed`);
1587
1827
  severity = "block";
@@ -2415,12 +2655,12 @@ async function supervise(input, opts) {
2415
2655
  }
2416
2656
 
2417
2657
  // src/supervisor/done.ts
2418
- import { existsSync as existsSync6, readFileSync as readFileSync7 } from "fs";
2419
- import { join as join7 } from "path";
2658
+ import { existsSync as existsSync7, readFileSync as readFileSync8 } from "fs";
2659
+ import { join as join8 } from "path";
2420
2660
 
2421
2661
  // src/sandbox/scenarios.ts
2422
2662
  import { lstat, readdir, readFile, realpath } from "fs/promises";
2423
- import { join as join6, relative as relative4, sep as sep2 } from "path";
2663
+ import { join as join7, relative as relative4, sep as sep2 } from "path";
2424
2664
  var required = (ok, file2, field) => {
2425
2665
  if (!ok) throw new Error(`${file2}: invalid or missing ${field}`);
2426
2666
  };
@@ -2463,14 +2703,14 @@ async function loadScenarios(dir, root = dir) {
2463
2703
  if (!inside(await realpath(dir))) throw new Error(`scenario directory ${rel(dir)} resolves outside the commit; ${NOT_REGULAR}`);
2464
2704
  const files = (await readdir(dir)).filter((n) => n.endsWith(".yaml") || n.endsWith(".yml")).sort();
2465
2705
  for (const n of files) {
2466
- const p = join6(dir, n);
2706
+ const p = join7(dir, n);
2467
2707
  const st = await lstat(p);
2468
2708
  if (st.isSymbolicLink()) throw new Error(`scenario file ${rel(p)} is a symbolic link; ${NOT_REGULAR}`);
2469
2709
  if (!st.isFile()) throw new Error(`scenario file ${rel(p)} is not a regular file; ${NOT_REGULAR}`);
2470
2710
  if (!inside(await realpath(p))) throw new Error(`scenario file ${rel(p)} resolves outside the commit; ${NOT_REGULAR}`);
2471
2711
  }
2472
2712
  const scenarios = await Promise.all(files.map(async (n) => {
2473
- const s = parseScenario(await readFile(join6(dir, n), "utf8"), n);
2713
+ const s = parseScenario(await readFile(join7(dir, n), "utf8"), n);
2474
2714
  required(n.replace(/\.ya?ml$/, "") === s.id, n, "id (must match filename)");
2475
2715
  return s;
2476
2716
  }));
@@ -2552,10 +2792,10 @@ function doneStatus(contract, sessionEvents, repoRoot2) {
2552
2792
  for (const d of entries) {
2553
2793
  if ("run" in d) need(d.run, `\`${d.run}\``, d.run);
2554
2794
  else {
2555
- const file2 = join7(".replume", "scenarios", `${d.scenario}.yaml`);
2795
+ const file2 = join8(".replume", "scenarios", `${d.scenario}.yaml`);
2556
2796
  let run = null;
2557
2797
  try {
2558
- if (existsSync6(join7(repoRoot2, file2))) run = parseScenario(readFileSync7(join7(repoRoot2, file2), "utf-8"), file2).run;
2798
+ if (existsSync7(join8(repoRoot2, file2))) run = parseScenario(readFileSync8(join8(repoRoot2, file2), "utf-8"), file2).run;
2559
2799
  } catch {
2560
2800
  run = null;
2561
2801
  }
@@ -2572,7 +2812,7 @@ function doneMessage(s) {
2572
2812
  }
2573
2813
 
2574
2814
  // src/agents/claude/payload.ts
2575
- import { existsSync as existsSync7, readFileSync as readFileSync8 } from "fs";
2815
+ import { existsSync as existsSync8, readFileSync as readFileSync9 } from "fs";
2576
2816
  import { isAbsolute as isAbsolute4, resolve as resolve3 } from "path";
2577
2817
  function parsePayload(text) {
2578
2818
  const x = JSON.parse(text);
@@ -2585,7 +2825,7 @@ function parsePayload(text) {
2585
2825
  var str = (v) => typeof v === "string" ? v : "";
2586
2826
  function readIfExists(path) {
2587
2827
  try {
2588
- return existsSync7(path) ? readFileSync8(path, "utf-8") : "";
2828
+ return existsSync8(path) ? readFileSync9(path, "utf-8") : "";
2589
2829
  } catch {
2590
2830
  return "";
2591
2831
  }
@@ -2601,6 +2841,8 @@ function toProposedAction(p, repoRoot2) {
2601
2841
  let kind = "other";
2602
2842
  let rawPaths = [];
2603
2843
  let command = null;
2844
+ let writeTargetNotParsed = false;
2845
+ let mentioned = [];
2604
2846
  const pending = [];
2605
2847
  const abs = (fp) => isAbsolute4(fp) ? fp : resolve3(p.cwd, fp);
2606
2848
  switch (tool) {
@@ -2608,7 +2850,10 @@ function toProposedAction(p, repoRoot2) {
2608
2850
  case "PowerShell": {
2609
2851
  kind = "command";
2610
2852
  command = str(input.command);
2611
- rawPaths = absolutePathsIn(command);
2853
+ const w = tool === "Bash" ? shellWriteTargets(command) : { paths: [], unparsed: false };
2854
+ writeTargetNotParsed = w.unparsed;
2855
+ rawPaths = w.paths.map(abs);
2856
+ mentioned = absolutePathsIn(command);
2612
2857
  break;
2613
2858
  }
2614
2859
  case "Write": {
@@ -2654,13 +2899,14 @@ function toProposedAction(p, repoRoot2) {
2654
2899
  break;
2655
2900
  }
2656
2901
  }
2657
- const { inside, outside } = splitPaths(repoRoot2, rawPaths.filter(Boolean));
2902
+ const { inside, outside: writtenOutside } = splitPaths(repoRoot2, rawPaths.filter(Boolean));
2903
+ const outside = [.../* @__PURE__ */ new Set([...writtenOutside, ...splitPaths(repoRoot2, mentioned).outside])];
2658
2904
  const changes = [];
2659
2905
  for (const c of pending) {
2660
2906
  const rel = splitPaths(repoRoot2, [c.abs]).inside[0];
2661
2907
  if (rel !== void 0) changes.push({ path: rel, before: c.before, after: c.after });
2662
2908
  }
2663
- return { kind, tool, paths: inside, outsidePaths: outside, command, changes, fingerprint: fingerprint(tool, input) };
2909
+ return { kind, tool, paths: [...new Set(inside)], outsidePaths: outside, command, changes, fingerprint: fingerprint(tool, input), ...writeTargetNotParsed ? { writeTargetNotParsed } : {} };
2664
2910
  }
2665
2911
 
2666
2912
  // src/agents/claude/handler.ts
@@ -2673,8 +2919,13 @@ function resolveRepoRoot(p, env = process.env) {
2673
2919
  return p.cwd;
2674
2920
  }
2675
2921
  }
2922
+ var WRITE_NOT_PARSED = "write target not parsed";
2923
+ function writeFields(a) {
2924
+ if (a.kind !== "command") return {};
2925
+ return { writes: a.paths, ...a.writeTargetNotParsed ? { writeNote: WRITE_NOT_PARSED } : {} };
2926
+ }
2676
2927
  function summarize(a) {
2677
- if (a.command !== null) return `${a.tool}: ${a.command}`;
2928
+ if (a.command !== null) return `${a.tool}: ${a.command}${a.writeTargetNotParsed ? ` (${WRITE_NOT_PARSED})` : ""}`;
2678
2929
  const targets = [...a.paths, ...a.outsidePaths];
2679
2930
  return targets.length ? `${a.tool} ${targets.join(", ")}` : a.tool;
2680
2931
  }
@@ -2785,7 +3036,8 @@ function preToolUse(p, repoRoot2, env, deps) {
2785
3036
  layer: result.layer,
2786
3037
  layer2Mode: result.layer2.mode,
2787
3038
  tool_use_id: p.tool_use_id ?? null,
2788
- tool_input: p.tool_input ?? null
3039
+ tool_input: p.tool_input ?? null,
3040
+ ...writeFields(action)
2789
3041
  }
2790
3042
  });
2791
3043
  const rec = { p, ledger, tool: action.tool, paths: action.paths, command: action.command, target: summarize(action), contractId: contract?.id ?? null };
@@ -2886,6 +3138,7 @@ function postToolUse(p, repoRoot2, failed) {
2886
3138
  tool_use_id: p.tool_use_id ?? null,
2887
3139
  duration_ms: p.duration_ms ?? null,
2888
3140
  ok: !failed,
3141
+ ...writeFields(action),
2889
3142
  ...failed ? {
2890
3143
  exitCode: exitMatch ? Number(exitMatch[1]) : null,
2891
3144
  isInterrupt: p.is_interrupt === true,
@@ -2913,9 +3166,9 @@ function prompt(p, repoRoot2) {
2913
3166
  }
2914
3167
  function failOpen(err, repoRoot2, env) {
2915
3168
  try {
2916
- const dir = join8(repoRoot2 ?? env.CLAUDE_PROJECT_DIR ?? process.cwd(), REPLUME_DIR);
3169
+ const dir = join9(repoRoot2 ?? env.CLAUDE_PROJECT_DIR ?? process.cwd(), REPLUME_DIR);
2917
3170
  mkdirSync6(dir, { recursive: true });
2918
- appendFileSync3(join8(dir, "hook-errors.log"), `${(/* @__PURE__ */ new Date()).toISOString()} ${err.stack ?? err}
3171
+ appendFileSync3(join9(dir, "hook-errors.log"), `${(/* @__PURE__ */ new Date()).toISOString()} ${err.stack ?? err}
2919
3172
  `);
2920
3173
  } catch {
2921
3174
  }
@@ -2955,7 +3208,7 @@ function handleClaudeHook(stdinText, env = process.env, deps = {}) {
2955
3208
  const contract = loadContract(contractPath(root));
2956
3209
  if (contract && contract.status !== "draft") captureClaudeUsage({
2957
3210
  repoRoot: root,
2958
- allowedRoot: join8(env.HOME ?? homedir2(), ".claude", "projects"),
3211
+ allowedRoot: join9(env.HOME ?? homedir2(), ".claude", "projects"),
2959
3212
  transcriptPath: p.transcript_path,
2960
3213
  taskId: contract.id,
2961
3214
  sessionId: p.session_id,
@@ -3217,13 +3470,13 @@ function ruleDecisionToHookResponse(hookEventName, decision2) {
3217
3470
  }
3218
3471
 
3219
3472
  // src/agents/cursor/action.ts
3220
- import { isAbsolute as isAbsolute6, join as join9 } from "path";
3473
+ import { isAbsolute as isAbsolute6, join as join10 } from "path";
3221
3474
  var str2 = (v) => typeof v === "string" && v.length > 0 ? v : null;
3222
3475
  function cursorPayloadToAction(raw3, repoRoot2, fallback = {}) {
3223
3476
  const hook2 = str2(raw3.hook_event_name);
3224
3477
  const toolInput = raw3.tool_input && typeof raw3.tool_input === "object" ? raw3.tool_input : {};
3225
3478
  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);
3479
+ const abs = (p) => isAbsolute6(p) ? p : join10(repoRoot2, p);
3227
3480
  const proposedPath = hook2 === "preToolUse" && /write|edit|delete/i.test(str2(raw3.tool_name) ?? "") ? str2(toolInput.file_path) : null;
3228
3481
  const files = [str2(raw3.file_path), proposedPath, ...fallback.paths ?? []].filter((p) => !!p).map(abs);
3229
3482
  const edits = Array.isArray(raw3.edits) ? raw3.edits : [];
@@ -3231,7 +3484,8 @@ function cursorPayloadToAction(raw3, repoRoot2, fallback = {}) {
3231
3484
  if (command) kind = "command";
3232
3485
  else if (edits.length || hook2 === "afterFileEdit" || hook2 === "afterTabFileEdit" || /write|edit|delete/i.test(fallback.tool ?? "")) kind = "edit";
3233
3486
  else if (hook2 === "beforeReadFile" || hook2 === "beforeTabFileRead" || /read|grep/i.test(fallback.tool ?? "")) kind = "read";
3234
- const { inside, outside } = splitPaths(repoRoot2, [...new Set(files)]);
3487
+ const w = kind === "command" && command ? shellWriteTargets(command) : null;
3488
+ const { inside, outside } = splitPaths(repoRoot2, w ? w.paths.map(abs) : [...new Set(files)]);
3235
3489
  const changes = kind === "edit" && inside.length && edits.length ? [
3236
3490
  {
3237
3491
  path: inside[0],
@@ -3247,7 +3501,8 @@ function cursorPayloadToAction(raw3, repoRoot2, fallback = {}) {
3247
3501
  outsidePaths: outside,
3248
3502
  command,
3249
3503
  changes,
3250
- fingerprint: fingerprint(tool, { files: inside, command, edits })
3504
+ fingerprint: fingerprint(tool, { files: inside, command, edits }),
3505
+ ...w?.unparsed ? { writeTargetNotParsed: true } : {}
3251
3506
  };
3252
3507
  }
3253
3508
 
@@ -3272,12 +3527,14 @@ function handleCursorHook(stdinText, env = process.env) {
3272
3527
  action: evaluation?.action ?? "ok",
3273
3528
  message: evaluation?.message ?? null
3274
3529
  };
3530
+ const writes = action.kind === "command" ? { writes: action.paths, ...action.writeTargetNotParsed ? { writeNote: "write target not parsed" } : {} } : {};
3275
3531
  appendEvent(ledger, sanitizeCursorLedgerEvent({
3276
3532
  ...event,
3533
+ ...action.kind === "command" ? { paths: action.paths } : {},
3277
3534
  rule: decision2.rule,
3278
3535
  action: decision2.action,
3279
3536
  message: decision2.message,
3280
- raw: { ...event.raw, fingerprint: action.fingerprint }
3537
+ raw: { ...event.raw, fingerprint: action.fingerprint, ...writes }
3281
3538
  }, root));
3282
3539
  for (const hit of evaluation?.hits ?? []) {
3283
3540
  appendEvent(ledger, sanitizeCursorLedgerEvent(ruleHitEvent(event, hit, { id: newEventId(), ts: (/* @__PURE__ */ new Date()).toISOString() }), root));
@@ -3291,8 +3548,8 @@ function handleCursorHook(stdinText, env = process.env) {
3291
3548
 
3292
3549
  // src/agents/cursor/install.ts
3293
3550
  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";
3551
+ import { existsSync as existsSync9, mkdirSync as mkdirSync7, readFileSync as readFileSync10, renameSync, rmSync as rmSync2, writeFileSync as writeFileSync5 } from "fs";
3552
+ import { dirname as dirname5, join as join11 } from "path";
3296
3553
  var CURSOR_HOOK_COMMAND = "replume hook cursor";
3297
3554
  var CURSOR_HOOK_EVENTS = [
3298
3555
  "preToolUse",
@@ -3308,7 +3565,7 @@ var CURSOR_HOOK_EVENTS = [
3308
3565
  ];
3309
3566
  var hash = (text) => createHash2("sha256").update(text).digest("hex");
3310
3567
  var isObject = (x) => !!x && typeof x === "object" && !Array.isArray(x);
3311
- var cursorHooksPath = (root) => join10(root, ".cursor", "hooks.json");
3568
+ var cursorHooksPath = (root) => join11(root, ".cursor", "hooks.json");
3312
3569
  var backupPathFor = (path) => `${path}.replume-backup`;
3313
3570
  var statePathFor = (path) => `${path}.replume-state.json`;
3314
3571
  function parseConfig(text, path) {
@@ -3332,17 +3589,17 @@ function exactInstalledEntry(x, command) {
3332
3589
  function validateCursorHooksConfig(root, opts = {}) {
3333
3590
  const path = cursorHooksPath(root);
3334
3591
  const command = opts.command ?? CURSOR_HOOK_COMMAND;
3335
- const config = existsSync8(path) ? parseConfig(readFileSync9(path, "utf8"), path) : {};
3592
+ const config = existsSync9(path) ? parseConfig(readFileSync10(path, "utf8"), path) : {};
3336
3593
  const hooks = config.hooks ?? {};
3337
3594
  for (const event of CURSOR_HOOK_EVENTS) {
3338
3595
  if (hooks[event] !== void 0 && !Array.isArray(hooks[event]))
3339
3596
  throw new Error(`${path}.hooks.${event} is not an array; nothing was changed. Fix it and run replume init again.`);
3340
3597
  }
3341
3598
  const statePath = statePathFor(path);
3342
- if (existsSync8(statePath)) {
3599
+ if (existsSync9(statePath)) {
3343
3600
  let state;
3344
3601
  try {
3345
- state = JSON.parse(readFileSync9(statePath, "utf8"));
3602
+ state = JSON.parse(readFileSync10(statePath, "utf8"));
3346
3603
  } catch {
3347
3604
  throw new Error(`${statePath} is not valid JSON; nothing was changed. Fix it and run replume init again.`);
3348
3605
  }
@@ -3354,9 +3611,9 @@ function installCursorHooks(root, opts = {}) {
3354
3611
  const path = cursorHooksPath(root);
3355
3612
  const backupPath = backupPathFor(path);
3356
3613
  const statePath = statePathFor(path);
3357
- const hadOriginal = existsSync8(path);
3614
+ const hadOriginal = existsSync9(path);
3358
3615
  const command = opts.command ?? CURSOR_HOOK_COMMAND;
3359
- const original = hadOriginal ? readFileSync9(path, "utf8") : null;
3616
+ const original = hadOriginal ? readFileSync10(path, "utf8") : null;
3360
3617
  const config = original === null ? {} : parseConfig(original, path);
3361
3618
  const hooks = { ...config.hooks ?? {} };
3362
3619
  const added = [];
@@ -3368,14 +3625,14 @@ function installCursorHooks(root, opts = {}) {
3368
3625
  added.push(event);
3369
3626
  }
3370
3627
  }
3371
- if (!added.length) return { path, backupPath: existsSync8(backupPath) ? backupPath : null, added };
3628
+ if (!added.length) return { path, backupPath: existsSync9(backupPath) ? backupPath : null, added };
3372
3629
  const updated = JSON.stringify({ ...config, version: 1, hooks }, null, 2) + "\n";
3373
- mkdirSync7(dirname4(path), { recursive: true });
3374
- if (!existsSync8(statePath)) {
3630
+ mkdirSync7(dirname5(path), { recursive: true });
3631
+ if (!existsSync9(statePath)) {
3375
3632
  if (original !== null) writeFileSync5(backupPath, original, { flag: "wx" });
3376
3633
  writeFileSync5(statePath, JSON.stringify({ hadOriginal, installedHash: hash(updated), added, command }) + "\n", { flag: "wx" });
3377
3634
  } else {
3378
- const state = JSON.parse(readFileSync9(statePath, "utf8"));
3635
+ const state = JSON.parse(readFileSync10(statePath, "utf8"));
3379
3636
  if (state.command !== command) throw new Error(`Cursor hooks already installed with a different command; uninstall first`);
3380
3637
  state.added = [.../* @__PURE__ */ new Set([...state.added, ...added])];
3381
3638
  state.installedHash = hash(updated);
@@ -3384,20 +3641,20 @@ function installCursorHooks(root, opts = {}) {
3384
3641
  const temp = `${path}.replume-tmp`;
3385
3642
  writeFileSync5(temp, updated);
3386
3643
  renameSync(temp, path);
3387
- return { path, backupPath: existsSync8(backupPath) ? backupPath : null, added };
3644
+ return { path, backupPath: existsSync9(backupPath) ? backupPath : null, added };
3388
3645
  }
3389
3646
  function uninstallCursorHooks(root) {
3390
3647
  const path = cursorHooksPath(root);
3391
3648
  const backupPath = backupPathFor(path);
3392
3649
  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;
3650
+ if (!existsSync9(statePath)) return { restored: false, path };
3651
+ const state = JSON.parse(readFileSync10(statePath, "utf8"));
3652
+ const current = existsSync9(path) ? readFileSync10(path, "utf8") : null;
3396
3653
  const unchanged = current !== null && hash(current) === state.installedHash;
3397
3654
  if (unchanged) {
3398
3655
  if (state.hadOriginal) {
3399
- if (!existsSync8(backupPath)) throw new Error(`Cursor hook backup missing: ${backupPath}`);
3400
- writeFileSync5(path, readFileSync9(backupPath));
3656
+ if (!existsSync9(backupPath)) throw new Error(`Cursor hook backup missing: ${backupPath}`);
3657
+ writeFileSync5(path, readFileSync10(backupPath));
3401
3658
  } else rmSync2(path);
3402
3659
  } else if (current !== null) {
3403
3660
  const config = parseConfig(current, path);
@@ -3417,16 +3674,16 @@ function uninstallCursorHooks(root) {
3417
3674
  }
3418
3675
  rmSync2(statePath);
3419
3676
  if (unchanged) rmSync2(backupPath, { force: true });
3420
- return { restored: true, path, backupRetained: !unchanged && existsSync8(backupPath) };
3677
+ return { restored: true, path, backupRetained: !unchanged && existsSync9(backupPath) };
3421
3678
  }
3422
3679
 
3423
3680
  // 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";
3681
+ import { copyFileSync as copyFileSync2, existsSync as existsSync10, mkdirSync as mkdirSync8, readFileSync as readFileSync11, writeFileSync as writeFileSync6 } from "fs";
3682
+ import { dirname as dirname6, join as join12 } from "path";
3426
3683
  var DEFAULT_HOOK_COMMAND = "replume hook claude";
3427
3684
  var HOOK_EVENTS = ["PreToolUse", "PostToolUse", "PostToolUseFailure", "UserPromptSubmit", "Stop", "SessionEnd"];
3428
3685
  function claudeSettingsPath(repoRoot2) {
3429
- return join11(repoRoot2, ".claude", "settings.json");
3686
+ return join12(repoRoot2, ".claude", "settings.json");
3430
3687
  }
3431
3688
  var isReplume = (h, command) => typeof h.command === "string" && (h.command === command || /(^|\s)hook claude\s*$/.test(h.command));
3432
3689
  function mergeHooks(settings, command = DEFAULT_HOOK_COMMAND) {
@@ -3455,8 +3712,8 @@ function installClaudeHooks(repoRoot2, opts = {}) {
3455
3712
  const settingsPath = claudeSettingsPath(repoRoot2);
3456
3713
  let current = {};
3457
3714
  let original = null;
3458
- if (existsSync9(settingsPath)) {
3459
- original = readFileSync10(settingsPath, "utf-8");
3715
+ if (existsSync10(settingsPath)) {
3716
+ original = readFileSync11(settingsPath, "utf-8");
3460
3717
  try {
3461
3718
  const parsed = original.trim() ? JSON.parse(original) : {};
3462
3719
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("not a JSON object");
@@ -3473,7 +3730,7 @@ function installClaudeHooks(repoRoot2, opts = {}) {
3473
3730
  backupPath = `${settingsPath}.replume-backup-${stamp}`;
3474
3731
  copyFileSync2(settingsPath, backupPath);
3475
3732
  }
3476
- mkdirSync8(dirname5(settingsPath), { recursive: true });
3733
+ mkdirSync8(dirname6(settingsPath), { recursive: true });
3477
3734
  writeFileSync6(settingsPath, JSON.stringify(settings, null, 2) + "\n");
3478
3735
  }
3479
3736
  return { settingsPath, backupPath, added, alreadyPresent };
@@ -3482,12 +3739,12 @@ function installClaudeHooks(repoRoot2, opts = {}) {
3482
3739
  // src/agents/claude/replay-hooks.ts
3483
3740
  import { mkdtempSync, mkdirSync as mkdirSync9, rmSync as rmSync3, writeFileSync as writeFileSync7 } from "fs";
3484
3741
  import { tmpdir as tmpdir2 } from "os";
3485
- import { join as join12 } from "path";
3742
+ import { join as join13 } from "path";
3486
3743
  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
3744
  async function replayHooks(content, opts = {}) {
3488
- const repo = mkdtempSync(join12(tmpdir2(), "replume-replay-hooks-"));
3745
+ const repo = mkdtempSync(join13(tmpdir2(), "replume-replay-hooks-"));
3489
3746
  try {
3490
- mkdirSync9(join12(repo, ".replume"), { recursive: true });
3747
+ mkdirSync9(join13(repo, ".replume"), { recursive: true });
3491
3748
  if (opts.contractYaml) writeFileSync7(contractPath(repo), opts.contractYaml);
3492
3749
  const env = { REPLUME_JUDGE: "off", ...opts.env, CLAUDE_PROJECT_DIR: repo };
3493
3750
  const steps = [];
@@ -3519,9 +3776,9 @@ async function replayHooks(content, opts = {}) {
3519
3776
 
3520
3777
  // src/core/init.ts
3521
3778
  import { execFileSync as execFileSync7 } from "child_process";
3522
- import { existsSync as existsSync11, mkdirSync as mkdirSync10, writeFileSync as writeFileSync9 } from "fs";
3779
+ import { existsSync as existsSync12, mkdirSync as mkdirSync10, writeFileSync as writeFileSync9 } from "fs";
3523
3780
  import { homedir as homedir3 } from "os";
3524
- import { join as join14 } from "path";
3781
+ import { delimiter, join as join15 } from "path";
3525
3782
 
3526
3783
  // src/core/capabilities.ts
3527
3784
  var CAPABILITIES = [
@@ -3578,8 +3835,8 @@ function renderCapabilities(caps = CAPABILITIES, detected = {}) {
3578
3835
  // src/core/baseline.ts
3579
3836
  import { createHash as createHash3 } from "crypto";
3580
3837
  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";
3838
+ import { appendFileSync as appendFileSync4, existsSync as existsSync11, lstatSync, readFileSync as readFileSync12, readdirSync, readlinkSync, writeFileSync as writeFileSync8 } from "fs";
3839
+ import { dirname as dirname7, join as join14 } from "path";
3583
3840
  var BASELINE_FILE = "baseline.json";
3584
3841
  var DEFAULT_REPLUME_GITIGNORE = `events.jsonl
3585
3842
  reports/
@@ -3598,15 +3855,15 @@ function hashTree(dir) {
3598
3855
  const walk = (abs, rel) => {
3599
3856
  for (const name of readdirSync(abs).sort()) {
3600
3857
  if (IGNORED_NAMES.has(name)) continue;
3601
- const p = join13(abs, name);
3858
+ const p = join14(abs, name);
3602
3859
  const r = rel ? `${rel}/${name}` : name;
3603
3860
  const st = lstatSync(p);
3604
3861
  if (st.isSymbolicLink()) out[r] = sha256(`symlink:${readlinkSync(p)}`);
3605
3862
  else if (st.isDirectory()) walk(p, r);
3606
- else if (st.isFile()) out[r] = sha256(readFileSync11(p));
3863
+ else if (st.isFile()) out[r] = sha256(readFileSync12(p));
3607
3864
  }
3608
3865
  };
3609
- if (existsSync10(dir)) {
3866
+ if (existsSync11(dir)) {
3610
3867
  try {
3611
3868
  if (lstatSync(dir).isDirectory()) walk(dir, "");
3612
3869
  } catch {
@@ -3621,14 +3878,14 @@ function headCommit(repoRoot2) {
3621
3878
  return null;
3622
3879
  }
3623
3880
  }
3624
- var baselinePath = (contractPath2) => join13(dirname6(contractPath2), BASELINE_FILE);
3881
+ var baselinePath = (contractPath2) => join14(dirname7(contractPath2), BASELINE_FILE);
3625
3882
  function ensureBaselineIgnored(replumeDir) {
3626
- const gi = join13(replumeDir, ".gitignore");
3627
- if (!existsSync10(gi)) {
3883
+ const gi = join14(replumeDir, ".gitignore");
3884
+ if (!existsSync11(gi)) {
3628
3885
  writeFileSync8(gi, DEFAULT_REPLUME_GITIGNORE);
3629
3886
  return;
3630
3887
  }
3631
- const text = readFileSync11(gi, "utf-8");
3888
+ const text = readFileSync12(gi, "utf-8");
3632
3889
  if (text.split(/\r?\n/).some((l) => l.trim() === BASELINE_FILE)) return;
3633
3890
  appendFileSync4(gi, `${text === "" || text.endsWith("\n") ? "" : "\n"}${BASELINE_FILE}
3634
3891
  `);
@@ -3640,12 +3897,12 @@ function recordBaseline(opts) {
3640
3897
  contract_id: opts.contractId,
3641
3898
  recorded_at: (opts.now ?? /* @__PURE__ */ new Date()).toISOString(),
3642
3899
  base_commit: headCommit(opts.repoRoot),
3643
- policy_hash: sha256(readFileSync11(opts.contractPath)),
3644
- scenario_hashes: hashTree(opts.scenariosDir ?? join13(dirname6(opts.contractPath), SCENARIOS_SUBDIR)),
3900
+ policy_hash: sha256(readFileSync12(opts.contractPath)),
3901
+ scenario_hashes: hashTree(opts.scenariosDir ?? join14(dirname7(opts.contractPath), SCENARIOS_SUBDIR)),
3645
3902
  ...opts.preflight ? { preflight: opts.preflight } : {}
3646
3903
  };
3647
3904
  writeFileSync8(path, JSON.stringify(baseline, null, 2) + "\n", "utf-8");
3648
- ensureBaselineIgnored(dirname6(opts.contractPath));
3905
+ ensureBaselineIgnored(dirname7(opts.contractPath));
3649
3906
  return { path, baseline };
3650
3907
  }
3651
3908
  var isHash = (x) => typeof x === "string" && /^[0-9a-f]{64}$/.test(x);
@@ -3666,10 +3923,10 @@ function validateBaseline(x) {
3666
3923
  return p;
3667
3924
  }
3668
3925
  function readBaseline(path) {
3669
- if (!existsSync10(path)) return { status: "absent" };
3926
+ if (!existsSync11(path)) return { status: "absent" };
3670
3927
  let raw3;
3671
3928
  try {
3672
- raw3 = JSON.parse(readFileSync11(path, "utf-8"));
3929
+ raw3 = JSON.parse(readFileSync12(path, "utf-8"));
3673
3930
  } catch (e) {
3674
3931
  return { status: "unreadable", detail: e.message };
3675
3932
  }
@@ -3709,19 +3966,32 @@ function onPath(bin) {
3709
3966
  return false;
3710
3967
  }
3711
3968
  }
3969
+ function launchedThroughNpx() {
3970
+ return process.env.npm_command === "exec" || (process.env.PATH ?? "").split(delimiter).some((entry) => entry.split(/[\\/]/).includes("_npx"));
3971
+ }
3972
+ function gitIdentity(repoRoot2, key) {
3973
+ try {
3974
+ return execFileSync7("git", ["-C", repoRoot2, "config", "--get", key], {
3975
+ encoding: "utf8",
3976
+ stdio: ["ignore", "pipe", "ignore"]
3977
+ }).trim().length > 0;
3978
+ } catch {
3979
+ return false;
3980
+ }
3981
+ }
3712
3982
  function detectAgents(repoRoot2, home = homedir3()) {
3713
3983
  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"))
3984
+ "Claude Code": onPath("claude") || existsSync12(join15(repoRoot2, ".claude")) || existsSync12(join15(home, ".claude")),
3985
+ Cursor: onPath("cursor") || existsSync12(join15(repoRoot2, ".cursor")) || existsSync12(join15(home, ".cursor")),
3986
+ Codex: onPath("codex") || existsSync12(join15(home, ".codex"))
3717
3987
  };
3718
3988
  }
3719
3989
  function runInit(repoRoot2, opts = {}) {
3720
3990
  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)) {
3991
+ const dir = join15(repoRoot2, REPLUME_DIR);
3992
+ mkdirSync10(join15(dir, "scenarios"), { recursive: true });
3993
+ const gi = join15(dir, ".gitignore");
3994
+ if (!existsSync12(gi)) {
3725
3995
  writeFileSync9(gi, DEFAULT_REPLUME_GITIGNORE);
3726
3996
  }
3727
3997
  lines.push(`\u2713 ${REPLUME_DIR}/ ready (scenarios/ for team scenarios; ledger and reports are git-ignored)`);
@@ -3734,11 +4004,14 @@ function runInit(repoRoot2, opts = {}) {
3734
4004
  lines.push(`\u2713 Claude Code hooks added to .claude/settings.json: ${claude.added.join(", ")}`);
3735
4005
  if (claude.backupPath) lines.push(` backup of your previous settings: ${claude.backupPath}`);
3736
4006
  } 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
4007
  } else lines.push("- Claude Code not found: no hooks written");
4008
+ const npx = launchedThroughNpx();
4009
+ const persistentCommand = !npx && onPath("replume");
4010
+ const defaultHooks = detected["Claude Code"] && !opts.hookCommand || opts.cursorHooks && !opts.cursorHookCommand;
4011
+ if (defaultHooks && !persistentCommand) {
4012
+ 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).");
4013
+ lines.push(" Install the persistent command with `npm install -g replume-cli`, then re-run `replume init`.");
4014
+ }
3742
4015
  for (const c of CAPABILITIES.filter((x) => !x.installedByInit)) {
3743
4016
  if (detected[c.agent]) lines.push(`- ${c.agent} found, but replume init does not install its hooks yet (${c.observe})`);
3744
4017
  }
@@ -3746,15 +4019,18 @@ function runInit(repoRoot2, opts = {}) {
3746
4019
  lines.push("Capability matrix");
3747
4020
  lines.push(renderCapabilities(CAPABILITIES, detected));
3748
4021
  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"`.');
4022
+ const draftCommand = persistentCommand ? "replume draft" : "npm install -g replume-cli && replume draft";
4023
+ lines.push(`Next: ${draftCommand} --task "<what you asked the agent>"; review .replume/contract.yaml, then run \`replume confirm\`.`);
4024
+ 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.");
4025
+ if (!gitIdentity(repoRoot2, "user.name")) lines.push('To make commits, set Git user.name: `git config user.name "Your Name"`.');
4026
+ if (!gitIdentity(repoRoot2, "user.email")) lines.push('To make commits, set Git user.email: `git config user.email "you@example.com"`.');
3751
4027
  return { lines, claude };
3752
4028
  }
3753
4029
 
3754
4030
  // src/core/draft.ts
3755
4031
  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";
4032
+ import { existsSync as existsSync13, lstatSync as lstatSync2, readFileSync as readFileSync13 } from "fs";
4033
+ import { join as join16, posix } from "path";
3758
4034
  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
4035
  var CI_CONFIG = [
3760
4036
  [".github/workflows/**", /^\.github\/workflows\//],
@@ -3779,13 +4055,13 @@ function trackedFiles(repoRoot2) {
3779
4055
  }
3780
4056
  var read = (repoRoot2, rel) => {
3781
4057
  try {
3782
- return readFileSync12(join15(repoRoot2, rel), "utf-8");
4058
+ return readFileSync13(join16(repoRoot2, rel), "utf-8");
3783
4059
  } catch {
3784
4060
  return "";
3785
4061
  }
3786
4062
  };
3787
4063
  function detectTestCommand(repoRoot2, files) {
3788
- const has = (f) => files.includes(f) || existsSync12(join15(repoRoot2, f));
4064
+ const has = (f) => files.includes(f) || existsSync13(join16(repoRoot2, f));
3789
4065
  if (has("package.json")) {
3790
4066
  let script;
3791
4067
  try {
@@ -3815,10 +4091,10 @@ function draftContract(input) {
3815
4091
  const t = detectTestCommand(input.repoRoot, files);
3816
4092
  notes.push(t.command ? `test_command "${t.command}": ${t.reason}` : `test_command: none (${t.reason})`);
3817
4093
  const scenarios = files.filter((f) => /^\.replume\/scenarios\/[^/]+\.ya?ml$/.test(f)).flatMap((f) => {
3818
- const path = join15(input.repoRoot, f);
4094
+ const path = join16(input.repoRoot, f);
3819
4095
  try {
3820
4096
  if (!lstatSync2(path).isFile() || lstatSync2(path).isSymbolicLink()) return [];
3821
- const s = parseScenario(readFileSync12(path, "utf8"), f);
4097
+ const s = parseScenario(readFileSync13(path, "utf8"), f);
3822
4098
  return [s.id === posix.basename(f).replace(/\.ya?ml$/, "") ? s : null].filter((x) => x !== null);
3823
4099
  } catch {
3824
4100
  notes.push(`must_check: skipped invalid scenario ${f}; fix it before verification`);
@@ -3911,7 +4187,7 @@ import { randomUUID } from "crypto";
3911
4187
  import { rmSync as rmSync4 } from "fs";
3912
4188
  import { chmod, cp, lstat as lstat2, mkdtemp, readdir as readdir2, rm, writeFile } from "fs/promises";
3913
4189
  import { tmpdir as tmpdir3 } from "os";
3914
- import { basename as basename2, join as join16 } from "path";
4190
+ import { basename as basename2, join as join17 } from "path";
3915
4191
  import { performance } from "perf_hooks";
3916
4192
  var DEFAULT_DOCKER_IMAGE = "node:20-bookworm-slim";
3917
4193
  var DOCKER_PROBE_TIMEOUT_MS = 2e4;
@@ -3991,6 +4267,8 @@ function dockerCli(binary, args, timeoutMs) {
3991
4267
  }, timeoutMs);
3992
4268
  });
3993
4269
  }
4270
+ var commandStartMarker = (name) => `__replume_command_start_${name}__`;
4271
+ var DOCKER_STARTUP_TIMEOUT_SEC = 120;
3994
4272
  function buildDockerRunArgs(input) {
3995
4273
  const uid = process.getuid?.() ?? 1e3, gid = process.getgid?.() ?? 1e3;
3996
4274
  return [
@@ -4026,11 +4304,14 @@ function buildDockerRunArgs(input) {
4026
4304
  "--env",
4027
4305
  "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
4028
4306
  ...Object.entries(input.labels ?? {}).flatMap(([k, v]) => ["--label", `${k}=${v}`]),
4307
+ // the marker, then exactly the old `sh -lc <command>` (passed as an argument, never spliced into this script)
4029
4308
  "--workdir",
4030
4309
  "/repo",
4031
4310
  input.image,
4032
4311
  "sh",
4033
- "-lc",
4312
+ "-c",
4313
+ `printf '%s\\n' ${commandStartMarker(input.name)} >&2; exec sh -lc "$1"`,
4314
+ "replume-scenario",
4034
4315
  input.command
4035
4316
  ];
4036
4317
  }
@@ -4047,11 +4328,11 @@ async function sweepStaleStaging(tmpRoot = tmpdir3(), now = Date.now()) {
4047
4328
  }
4048
4329
  for (const n of names) {
4049
4330
  if (!n.startsWith(STAGING_PREFIX)) continue;
4050
- const dir = join16(tmpRoot, n);
4331
+ const dir = join17(tmpRoot, n);
4051
4332
  try {
4052
4333
  const st = await lstat2(dir);
4053
4334
  if (st.isSymbolicLink() || !st.isDirectory()) continue;
4054
- const marker = await lstat2(join16(dir, STAGING_MARKER));
4335
+ const marker = await lstat2(join17(dir, STAGING_MARKER));
4055
4336
  if (!marker.isFile() || now - marker.mtimeMs < STALE_AFTER_MS) continue;
4056
4337
  await rm(dir, { recursive: true, force: true });
4057
4338
  removed.push(dir);
@@ -4088,44 +4369,57 @@ async function runDockerScenario(input) {
4088
4369
  const binary = input.dockerBinary ?? "docker";
4089
4370
  const tmpRoot = input.tmpRoot ?? tmpdir3();
4090
4371
  await sweepStaleStaging(tmpRoot);
4091
- const temp = await mkdtemp(join16(tmpRoot, STAGING_PREFIX));
4092
- const staged = join16(temp, "repo");
4372
+ const temp = await mkdtemp(join17(tmpRoot, STAGING_PREFIX));
4373
+ const staged = join17(temp, "repo");
4093
4374
  const name = `replume-${randomUUID()}`;
4094
4375
  active.set(temp, { binary, name });
4095
4376
  attachSignals();
4096
4377
  try {
4097
- await writeFile(join16(temp, STAGING_MARKER), JSON.stringify({ by: "replume", pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString() }) + "\n");
4378
+ await writeFile(join17(temp, STAGING_MARKER), JSON.stringify({ by: "replume", pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString() }) + "\n");
4098
4379
  await cp(input.root, staged, { recursive: true, filter: (path) => basename2(path) !== ".git" });
4099
4380
  if (process.getuid?.() === 0) await chmod(temp, 493);
4100
4381
  const args = buildDockerRunArgs({ name, image: input.image ?? DEFAULT_DOCKER_IMAGE, source: staged, command: input.command, labels: input.labels });
4101
4382
  return await new Promise((resolve7) => {
4102
4383
  const child = spawn2(binary, args, { env: cliEnv(), stdio: ["ignore", "pipe", "pipe"] });
4103
- let stdout = "", stderr = "", timedOut = false, settled = false;
4384
+ const marker = `${commandStartMarker(name)}
4385
+ `;
4386
+ let stdout = "", stderr = "", timedOut = false, settled = false, commandStarted = false;
4387
+ let timer;
4104
4388
  const finish = (exitCode) => {
4105
4389
  if (settled) return;
4106
4390
  settled = true;
4107
4391
  clearTimeout(timer);
4108
4392
  resolve7({ stdout, stderr, exitCode, timedOut, durationMs: Math.round(performance.now() - started) });
4109
4393
  };
4394
+ const expire = (note) => async () => {
4395
+ timedOut = true;
4396
+ if (note) stderr += note;
4397
+ const removed = await dockerCli(binary, ["rm", "--force", name], 1e4);
4398
+ if (removed.code !== 0) stderr += `
4399
+ Docker timeout cleanup: ${removed.stderr.trim()}`;
4400
+ child.kill("SIGKILL");
4401
+ finish(null);
4402
+ };
4110
4403
  child.stdout.on("data", (chunk) => {
4111
4404
  stdout += chunk.toString();
4112
4405
  });
4113
4406
  child.stderr.on("data", (chunk) => {
4114
4407
  stderr += chunk.toString();
4408
+ if (!commandStarted && stderr.includes(marker)) {
4409
+ commandStarted = true;
4410
+ stderr = stderr.replace(marker, "");
4411
+ clearTimeout(timer);
4412
+ timer = setTimeout(expire(""), input.timeoutSec * 1e3);
4413
+ }
4115
4414
  });
4116
4415
  child.on("error", (error) => {
4117
4416
  stderr += error.message;
4118
4417
  finish(null);
4119
4418
  });
4120
4419
  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);
4420
+ const startup = input.startupTimeoutSec ?? DOCKER_STARTUP_TIMEOUT_SEC;
4421
+ timer = setTimeout(expire(`
4422
+ Docker container did not start the scenario command within ${startup}s (start-up limit; timeout_sec counts from the command start)`), startup * 1e3);
4129
4423
  });
4130
4424
  } finally {
4131
4425
  try {
@@ -4151,7 +4445,7 @@ import { performance as performance3 } from "perf_hooks";
4151
4445
  import { spawn as spawn3 } from "child_process";
4152
4446
  import { mkdtemp as mkdtemp2, rm as rm2 } from "fs/promises";
4153
4447
  import { tmpdir as tmpdir4 } from "os";
4154
- import { join as join17 } from "path";
4448
+ import { join as join18 } from "path";
4155
4449
  import { performance as performance2 } from "perf_hooks";
4156
4450
  async function git(cwd, args) {
4157
4451
  return new Promise((resolve7, reject) => {
@@ -4168,9 +4462,9 @@ async function git(cwd, args) {
4168
4462
  });
4169
4463
  }
4170
4464
  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");
4465
+ const temp = await mkdtemp2(join18(tmpdir4(), "replume-copy-"));
4466
+ const root = join18(temp, "repo");
4467
+ const home = join18(temp, "home");
4174
4468
  let added = false;
4175
4469
  try {
4176
4470
  await git(repo, ["worktree", "add", "--detach", root, commit]);
@@ -4301,10 +4595,22 @@ function buildReport(input) {
4301
4595
  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
4596
  const ran = new Set(input.results.filter((r) => !["not_run", "error"].includes(r.status)).map((r) => r.id));
4303
4597
  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
- }));
4598
+ const testsPassed = !input.draft && input.testCommand?.status === "passed";
4599
+ const obs = input.observation;
4600
+ const named = new Set(obs?.namedPaths ?? []);
4601
+ const reasonFor = (path) => {
4602
+ 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";
4603
+ 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"}`;
4604
+ 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)";
4605
+ 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)";
4606
+ return null;
4607
+ };
4608
+ const uncovered = paths.filter((p) => !matches(claims, p));
4609
+ const notChecked = uncovered.flatMap((path) => {
4610
+ const reason = reasonFor(path);
4611
+ return reason ? [{ path, reason }] : [];
4612
+ });
4613
+ const observedOnly = uncovered.filter((p) => reasonFor(p) === null).map((path) => ({ path, reason: "checked by test_command and observed steps; no scenario" }));
4308
4614
  const failed = results.filter((r) => r.status !== "passed" && r.status !== "unconfirmed").map((r) => ({ id: r.id, evidence: r.expectationFailures.join("; ") || r.status }));
4309
4615
  const evidenceFor = (r) => {
4310
4616
  if (r.id === TEST_COMMAND_ID) return `\`${contract.test_command}\` exit ${r.exitCode}`;
@@ -4332,7 +4638,7 @@ function buildReport(input) {
4332
4638
  const checks = [
4333
4639
  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
4640
  { 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}` },
4641
+ !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
4642
  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
4643
  ...input.baselineCheck ? [input.baselineCheck] : [],
4338
4644
  ...deps ? [dependencyCheck(deps)] : [],
@@ -4355,6 +4661,7 @@ function buildReport(input) {
4355
4661
  extra: lv(extra, () => "verified_here"),
4356
4662
  tampering: lv(tampering, (t) => RECORDED.has(t.rule) ? "observed" : "verified_here"),
4357
4663
  notChecked: lv(notChecked, () => "unknown"),
4664
+ ...observedOnly.length ? { observedOnly: lv(observedOnly, () => "observed") } : {},
4358
4665
  scenarios: [...results, ...doneRuns],
4359
4666
  baseCommit: base3,
4360
4667
  ...input.baseSource ? { baseSource: input.baseSource } : {},
@@ -4590,134 +4897,6 @@ async function checkRegistry(d, fetcher, timeoutMs = REGISTRY_TIMEOUT_MS) {
4590
4897
  }
4591
4898
  var dependencyAllowed = (name, allow) => !!allow?.length && matchesAny(name, allow) !== null;
4592
4899
 
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
4900
  // src/sandbox/failing-tests.ts
4722
4901
  var PATTERNS2 = [
4723
4902
  /^[ \t]*✖ (.+?) \(\d+(?:\.\d+)?m?s\)[ \t]*$/,
@@ -5015,6 +5194,20 @@ async function compareToTrustedBase(repo, t, commit, contractRel, scenariosRel,
5015
5194
  }
5016
5195
  return out;
5017
5196
  }
5197
+ function observedChanges(events, changedPaths, since) {
5198
+ const t = since ? Date.parse(since) : NaN;
5199
+ const inTask = events.filter((e) => !!e.sessionId && !!e.agent && (Number.isNaN(t) || Date.parse(e.ts) >= t));
5200
+ const named = /* @__PURE__ */ new Set();
5201
+ for (const e of inTask) {
5202
+ const raw3 = e.raw ?? {};
5203
+ if (e.action === "block" || raw3.ok === false) continue;
5204
+ if (e.type === "file_edit") for (const p of e.paths) named.add(p);
5205
+ else if (e.type === "command" && raw3.hook_event_name !== "beforeShellExecution" && Array.isArray(raw3.writes)) {
5206
+ for (const p of raw3.writes) if (typeof p === "string") named.add(p);
5207
+ }
5208
+ }
5209
+ return { sessions: new Set(inTask.map((e) => e.sessionId)).size, namedPaths: changedPaths.filter((p) => named.has(p)) };
5210
+ }
5018
5211
  async function runVerify2(opts) {
5019
5212
  const repo = resolve5(opts.repoPath);
5020
5213
  if (!isAbsolute7(opts.repoPath)) throw new Error("repoPath must be absolute");
@@ -5108,6 +5301,10 @@ async function runVerify2(opts) {
5108
5301
  const tampering = await tamperingFindings(repo, base3, commit, agentEntries);
5109
5302
  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
5303
  const dependencies = await dependencySection(repo, base3, commit, paths, contract, opts);
5304
+ const eventsFile = safeRelative(repo, opts.eventsPath ?? ".replume/events.jsonl");
5305
+ const ledgerEvents = readEvents(eventsFile).events;
5306
+ const since = baseline?.recorded_at ?? null;
5307
+ const observation = observedChanges(ledgerEvents, paths, since);
5111
5308
  const report = await withWorkingCopy(repo, commit, mode === "worktree" && (opts.keepWorkdir ?? false), async (root, home) => {
5112
5309
  const run = (command, timeoutSec) => mode === "docker" ? runDockerScenario({ root, command, timeoutSec, image }) : runCommand(command, root, home, timeoutSec);
5113
5310
  const dir = safeRelative(root, opts.scenariosDir ?? ".replume/scenarios");
@@ -5157,6 +5354,7 @@ async function runVerify2(opts) {
5157
5354
  draft,
5158
5355
  dependencies,
5159
5356
  newTests,
5357
+ observation,
5160
5358
  baselineCheck: trusted ? {
5161
5359
  name: "baseline",
5162
5360
  status: "done",
@@ -5168,9 +5366,6 @@ async function runVerify2(opts) {
5168
5366
  isolationReason: reason
5169
5367
  });
5170
5368
  });
5171
- const eventsFile = safeRelative(repo, opts.eventsPath ?? ".replume/events.jsonl");
5172
- const ledgerEvents = readEvents(eventsFile).events;
5173
- const since = baseline?.recorded_at ?? null;
5174
5369
  const { interventions, layer2, layer2Modes, scope, sessions } = supervisorSection(ledgerEvents, paths, since);
5175
5370
  report.metrics = taskMetrics(ledgerEvents, {
5176
5371
  repoRoot: repo,
@@ -5228,11 +5423,21 @@ function countTag(levels) {
5228
5423
  if (counts.size <= 1) return TAG[levels[0] ?? "verified_here"];
5229
5424
  return `[evidence: ${[...counts].map(([l, k]) => `${k} ${LEVEL_NAME[l]}`).join("; ")}]`;
5230
5425
  }
5426
+ function notCheckedCauses(report) {
5427
+ const causes = [...new Set(report.notChecked.map((x) => {
5428
+ const m = /; (agent steps not observed|change not seen by hooks)/.exec(x.reason);
5429
+ if (m) return `no scenario declares coverage; ${m[1]}`;
5430
+ if (/the contract has no test_command/.test(x.reason)) return "no scenario declares coverage and no test_command";
5431
+ 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";
5432
+ return "no scenario declares coverage";
5433
+ }))];
5434
+ return causes.length ? ` (${causes.join("; ")})` : "";
5435
+ }
5231
5436
  function renderReport(report, opts = {}) {
5232
5437
  if (opts.json) return JSON.stringify(report, null, 2);
5233
5438
  const quietCapture = report.capture?.ledger === "absent" || report.capture?.ledger === "empty";
5234
5439
  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.`;
5440
+ 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
5441
  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
5442
  const lines = [first, why, activity, `Replume report commit ${report.commit.slice(0, 7)} task: ${report.contractId}`];
5238
5443
  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 +5471,9 @@ function renderReport(report, opts = {}) {
5266
5471
  }
5267
5472
  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
5473
  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}`);
5474
+ if (report.observedOnly?.length)
5475
+ lines.push(`OBSERVED (${report.observedOnly.length}) ${report.observedOnly.map((x) => x.path).join(", ")} checked by test_command and observed steps; no scenario [Observed]`);
5476
+ 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
5477
  if (report.supervisor) {
5271
5478
  const [head, ...rest] = renderInterventions(report.supervisor, report.supervisor.scope);
5272
5479
  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);
@@ -5645,7 +5852,12 @@ program.command("init").description("Create .replume/, install agent hooks where
5645
5852
  throw new Error(`${path} is not a JSON object; nothing was changed. Fix it and run replume init again.`);
5646
5853
  mergeHooks(parsed, opts.hookCommand ?? DEFAULT_HOOK_COMMAND);
5647
5854
  }
5648
- const { lines } = runInit(root, { hookCommand: opts.hookCommand, detected });
5855
+ const { lines } = runInit(root, {
5856
+ hookCommand: opts.hookCommand,
5857
+ cursorHookCommand: opts.cursorHookCommand,
5858
+ cursorHooks: wantCursor,
5859
+ detected
5860
+ });
5649
5861
  const oldNotice = lines.findIndex((l) => l.startsWith("- Cursor found, but"));
5650
5862
  if (oldNotice >= 0) lines.splice(oldNotice, 1);
5651
5863
  const matrixAt = lines.indexOf("Capability matrix");
@@ -5695,6 +5907,9 @@ addPreflightOptions(program.command("task")).description("Write the Task Contrac
5695
5907
  fail(e.message);
5696
5908
  }
5697
5909
  });
5910
+ function passPathHint(c) {
5911
+ 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).";
5912
+ }
5698
5913
  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
5914
  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
5915
  const root = repoRoot();
@@ -5727,6 +5942,7 @@ addPreflightOptions(program.command("draft")).description("Propose a Task Contra
5727
5942
  console.log(finalAllow);
5728
5943
  if (opts.yes) await preflightAndRecord(root, path, c, opts);
5729
5944
  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`.");
5945
+ console.log(passPathHint(c));
5730
5946
  } catch (e) {
5731
5947
  fail(e.message);
5732
5948
  }
@@ -5744,7 +5960,8 @@ addPreflightOptions(program.command("confirm")).description("Confirm the draft c
5744
5960
  writeContract(path, confirmed);
5745
5961
  console.log(`\u2713 confirmed ${REPLUME_DIR}/contract.yaml (${c.id}, status: confirmed)`);
5746
5962
  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)");
5963
+ console.log(passPathHint(confirmed));
5964
+ console.log(" commit .replume/contract.yaml before running the agent (and .replume/.gitignore, and any scenario files)");
5748
5965
  } catch (e) {
5749
5966
  fail(e.message);
5750
5967
  }