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.
- package/README.md +36 -18
- package/dist/cli.js +472 -255
- 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.
|
|
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
|
|
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
|
-
|
|
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)
|
|
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")
|
|
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
|
|
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 =
|
|
1584
|
-
const skipsAdded =
|
|
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
|
|
2419
|
-
import { join as
|
|
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
|
|
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 =
|
|
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(
|
|
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 =
|
|
2795
|
+
const file2 = join8(".replume", "scenarios", `${d.scenario}.yaml`);
|
|
2556
2796
|
let run = null;
|
|
2557
2797
|
try {
|
|
2558
|
-
if (
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
3169
|
+
const dir = join9(repoRoot2 ?? env.CLAUDE_PROJECT_DIR ?? process.cwd(), REPLUME_DIR);
|
|
2917
3170
|
mkdirSync6(dir, { recursive: true });
|
|
2918
|
-
appendFileSync3(
|
|
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:
|
|
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
|
|
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 :
|
|
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
|
|
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
|
|
3295
|
-
import { dirname as
|
|
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) =>
|
|
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 =
|
|
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 (
|
|
3599
|
+
if (existsSync9(statePath)) {
|
|
3343
3600
|
let state;
|
|
3344
3601
|
try {
|
|
3345
|
-
state = JSON.parse(
|
|
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 =
|
|
3614
|
+
const hadOriginal = existsSync9(path);
|
|
3358
3615
|
const command = opts.command ?? CURSOR_HOOK_COMMAND;
|
|
3359
|
-
const original = hadOriginal ?
|
|
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:
|
|
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(
|
|
3374
|
-
if (!
|
|
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(
|
|
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:
|
|
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 (!
|
|
3394
|
-
const state = JSON.parse(
|
|
3395
|
-
const current =
|
|
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 (!
|
|
3400
|
-
writeFileSync5(path,
|
|
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 &&
|
|
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
|
|
3425
|
-
import { dirname as
|
|
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
|
|
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 (
|
|
3459
|
-
original =
|
|
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(
|
|
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
|
|
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(
|
|
3745
|
+
const repo = mkdtempSync(join13(tmpdir2(), "replume-replay-hooks-"));
|
|
3489
3746
|
try {
|
|
3490
|
-
mkdirSync9(
|
|
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
|
|
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
|
|
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
|
|
3582
|
-
import { dirname as
|
|
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 =
|
|
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(
|
|
3863
|
+
else if (st.isFile()) out[r] = sha256(readFileSync12(p));
|
|
3607
3864
|
}
|
|
3608
3865
|
};
|
|
3609
|
-
if (
|
|
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) =>
|
|
3881
|
+
var baselinePath = (contractPath2) => join14(dirname7(contractPath2), BASELINE_FILE);
|
|
3625
3882
|
function ensureBaselineIgnored(replumeDir) {
|
|
3626
|
-
const gi =
|
|
3627
|
-
if (!
|
|
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 =
|
|
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(
|
|
3644
|
-
scenario_hashes: hashTree(opts.scenariosDir ??
|
|
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(
|
|
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 (!
|
|
3926
|
+
if (!existsSync11(path)) return { status: "absent" };
|
|
3670
3927
|
let raw3;
|
|
3671
3928
|
try {
|
|
3672
|
-
raw3 = JSON.parse(
|
|
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") ||
|
|
3715
|
-
Cursor: onPath("cursor") ||
|
|
3716
|
-
Codex: onPath("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 =
|
|
3722
|
-
mkdirSync10(
|
|
3723
|
-
const gi =
|
|
3724
|
-
if (!
|
|
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
|
-
|
|
3750
|
-
lines.push(
|
|
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
|
|
3757
|
-
import { join as
|
|
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
|
|
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) ||
|
|
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 =
|
|
4094
|
+
const path = join16(input.repoRoot, f);
|
|
3819
4095
|
try {
|
|
3820
4096
|
if (!lstatSync2(path).isFile() || lstatSync2(path).isSymbolicLink()) return [];
|
|
3821
|
-
const s = parseScenario(
|
|
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
|
|
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
|
-
"-
|
|
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 =
|
|
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(
|
|
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(
|
|
4092
|
-
const staged =
|
|
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(
|
|
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
|
-
|
|
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
|
|
4122
|
-
|
|
4123
|
-
|
|
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
|
|
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(
|
|
4172
|
-
const root =
|
|
4173
|
-
const 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
|
|
4305
|
-
|
|
4306
|
-
|
|
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}
|
|
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
|
-
|
|
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, {
|
|
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(
|
|
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
|
}
|