agent-roast 0.10.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import path7 from "path";
4
+ import path11 from "path";
5
5
 
6
6
  // src/git.ts
7
7
  import { spawn, spawnSync } from "child_process";
@@ -847,12 +847,12 @@ var VALID_DETECTORS = [
847
847
  // src/file-read.ts
848
848
  import * as fs from "fs";
849
849
  function readRegularFile(filename, limit) {
850
- const stat = fs.lstatSync(filename);
851
- if (!stat.isFile() || stat.isSymbolicLink() || stat.size > limit) throw new Error(`Expected a regular file no larger than ${limit} bytes.`);
850
+ const stat3 = fs.lstatSync(filename);
851
+ if (!stat3.isFile() || stat3.isSymbolicLink() || stat3.size > limit) throw new Error(`Expected a regular file no larger than ${limit} bytes.`);
852
852
  const fd = fs.openSync(filename, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0) | (fs.constants.O_NONBLOCK || 0));
853
853
  try {
854
854
  const opened = fs.fstatSync(fd);
855
- if (!opened.isFile() || opened.ino !== stat.ino || opened.dev !== stat.dev) throw new Error("File changed while opening.");
855
+ if (!opened.isFile() || opened.ino !== stat3.ino || opened.dev !== stat3.dev) throw new Error("File changed while opening.");
856
856
  const buffer = Buffer.alloc(limit + 1);
857
857
  let size = 0;
858
858
  while (size < buffer.length) {
@@ -1161,11 +1161,149 @@ function matchIgnorePath(filePath, ignorePatterns) {
1161
1161
  // src/metrics.ts
1162
1162
  var PYTHON_TEST_SKIP_PATTERNS = [
1163
1163
  { regex: /@pytest\s*\.\s*mark\s*\.\s*skip\b/, name: "skipped pytest (@pytest.mark.skip)" },
1164
- { regex: /(?<![\w.])pytest\s*\.\s*skip\s*\(/, name: "imperative pytest skip (pytest.skip)" },
1165
- { regex: /@unittest\s*\.\s*skip\s*\(/, name: "skipped unittest (@unittest.skip)" },
1166
- { regex: /\bself\s*\.\s*skipTest\s*\(/, name: "imperative unittest skip (self.skipTest)" }
1164
+ { regex: /(?<![\w.])pytest\s*\.\s*skip\s*\(/, name: "imperative pytest skip (pytest.skip)", imperative: true },
1165
+ { regex: /@unittest\s*\.\s*skip\s*\(/, name: "skipped unittest (@unittest.skip)", imperative: false },
1166
+ { regex: /\bself\s*\.\s*skipTest\s*\(/, name: "imperative unittest skip (self.skipTest)", imperative: true }
1167
+ ];
1168
+ function isGuardedSkip(lines) {
1169
+ const indentOf = (text) => text.length - text.trimStart().length;
1170
+ const mentionsCi = (raw) => /\bCI\b|\bGITHUB_ACTIONS\b/.test(raw);
1171
+ const own = lines[lines.length - 1];
1172
+ if (/^\s*(?:if|elif|else)\b.*:\s*\S/.test(own.code)) return !mentionsCi(own.raw);
1173
+ let limit = indentOf(own.code);
1174
+ for (let i = lines.length - 2; i >= 0 && lines.length - 1 - i <= 200; i--) {
1175
+ if (lines[i].number !== lines[i + 1].number - 1) return false;
1176
+ const { code, raw } = lines[i];
1177
+ if (!code.trim() || indentOf(code) >= limit) continue;
1178
+ const keyword = /^\s*(async\s+def|def|class|if|elif|else|try|except|finally|with|for|while)\b/.exec(code)?.[1];
1179
+ if (keyword === "def" || keyword === "async def") return !/^\s*(?:async\s+)?def\s+test/.test(code);
1180
+ if (keyword === "class") return false;
1181
+ if (keyword === "if" || keyword === "elif" || keyword === "else" || keyword === "try" || keyword === "except" || keyword === "finally") return !mentionsCi(raw);
1182
+ limit = indentOf(code) + (keyword ? 0 : 1);
1183
+ }
1184
+ return false;
1185
+ }
1186
+ var GO_GUARD = {
1187
+ conditional: /^\s*(?:\}\s*)?(?:if|else|switch|case|select|for)\b/,
1188
+ test: /^\s*func\s+(?:\([^)]*\)\s*)?(?:Test|Benchmark|Fuzz|Example)\w*\s*\(|\.Run\s*\(|\bfunc\s*\(\s*\w+\s+\*testing\.[TB]\s*\)/,
1189
+ helper: /^\s*func\s+(?:\([^)]*\)\s*)?\w+\s*\(/
1190
+ };
1191
+ var RUBY_GUARD = {
1192
+ modifier: /^\s*(?:skip|pending)\b.*\b(?:if|unless)\b/,
1193
+ conditional: /^\s*(?:if|unless|else|elsif|case|when|begin|rescue|ensure|while|until)\b/,
1194
+ test: /^\s*(?:it|specify|example|scenario|test|before|after|around)\b|^\s*def\s+test_/,
1195
+ helper: /^\s*def\s+\w+/
1196
+ };
1197
+ var PHP_GUARD = {
1198
+ modifier: /\b(?:or|and)\b.*markTest|(?:&&|\|\||\?).*markTest/,
1199
+ conditional: /^\s*(?:\}\s*)?(?:if|else|elseif|switch|case|foreach|for|while|try|catch)\b/,
1200
+ test: /^\s*(?:(?:public|protected|private|static)\s+)*function\s+test\w*\s*\(/,
1201
+ helper: /^\s*(?:(?:public|protected|private|static)\s+)*function\s+\w+\s*\(/
1202
+ };
1203
+ var SWIFT_GUARD = {
1204
+ conditional: /^\s*(?:\}\s*)?(?:if|else|guard|switch|case|for|while|do|catch)\b/,
1205
+ test: /^\s*(?:@\w+\s+)*(?:(?:private|fileprivate|static)\s+)*func\s+test\w*\s*\(/,
1206
+ helper: /^\s*(?:@\w+\s+)*(?:(?:private|fileprivate|static)\s+)*func\s+\w+\s*\(/
1207
+ };
1208
+ var LANGUAGE_LINE_RULES = [
1209
+ { files: /_test\.go$/i, rules: [
1210
+ { type: "test-skip", regex: /\b(?:t|tb|b)\s*\.\s*(?:Skip|Skipf|SkipNow)\s*\(|\.T\(\)\s*\.\s*(?:Skip|Skipf|SkipNow)\s*\(/, name: "skipped Go test (t.Skip)", guard: GO_GUARD },
1211
+ { type: "trivial-assert", regex: /\b(?:assert|require)\s*\.\s*True\s*\(\s*\w+\s*,\s*true\s*\)/, name: "trivial assert (assert.True(t, true))" },
1212
+ { type: "trivial-assert", regex: /\b(?:assert|require)\s*\.\s*False\s*\(\s*\w+\s*,\s*false\s*\)/, name: "trivial assert (assert.False(t, false))" },
1213
+ { type: "trivial-assert", regex: /\b(?:assert|require)\s*\.\s*Equal\s*\(\s*\w+\s*,\s*(\d+|true|false|nil)\s*,\s*\1\s*\)/, name: "trivial assert (assert.Equal(t, 1, 1))" }
1214
+ ] },
1215
+ { files: /\.rs$/i, rules: [
1216
+ { type: "test-skip", regex: /^\s*#\[\s*ignore\b/, name: "ignored Rust test (#[ignore])" },
1217
+ { type: "trivial-assert", regex: /(?<![\w!])assert!\s*\(\s*true\s*\)/, name: "trivial assert (assert!(true))" },
1218
+ { type: "trivial-assert", regex: /(?<![\w!])assert_eq!\s*\(\s*(\d+|true|false)\s*,\s*\1\s*\)/, name: "trivial assert (assert_eq!(1, 1))" }
1219
+ ] },
1220
+ { files: /\.(?:java|kt)$/i, rules: [
1221
+ { type: "test-skip", regex: /(?:^|\s)@(?:Ignore|Disabled)\b/, name: "ignored JUnit test (@Ignore / @Disabled)" },
1222
+ { type: "test-skip", regex: /@Test\s*\([^)]*\benabled\s*=\s*false\b/, name: "disabled TestNG test (enabled = false)" },
1223
+ { type: "trivial-assert", regex: /\bassertTrue\s*\(\s*true\s*\)/, name: "trivial assert (assertTrue(true))" },
1224
+ { type: "trivial-assert", regex: /\bassertFalse\s*\(\s*false\s*\)/, name: "trivial assert (assertFalse(false))" },
1225
+ { type: "trivial-assert", regex: /\bassertEquals\s*\(\s*(\d+|true|false|null)\s*,\s*\1\s*\)/, name: "trivial assert (assertEquals(1, 1))" },
1226
+ { type: "trivial-assert", regex: /\bassertThat\s*\(\s*true\s*\)\s*\.\s*isTrue\s*\(/, name: "trivial assert (assertThat(true).isTrue())" }
1227
+ ] },
1228
+ { files: /\.cs$/i, rules: [
1229
+ { type: "test-skip", regex: /\[\s*Ignore\b|\b(?:Fact|Theory)\s*\(\s*(?:[^)]*,\s*)?Skip\s*=/, name: "ignored .NET test ([Ignore] / Skip =)" },
1230
+ { type: "trivial-assert", regex: /\bAssert\s*\.\s*(?:True|IsTrue)\s*\(\s*true\s*\)/, name: "trivial assert (Assert.True(true))" },
1231
+ { type: "trivial-assert", regex: /\bAssert\s*\.\s*(?:False|IsFalse)\s*\(\s*false\s*\)/, name: "trivial assert (Assert.False(false))" },
1232
+ { type: "trivial-assert", regex: /\bAssert\s*\.\s*(?:AreEqual|Equal)\s*\(\s*(\d+|true|false|null)\s*,\s*\1\s*\)/, name: "trivial assert (Assert.Equal(1, 1))" }
1233
+ ] },
1234
+ // Ruby has no file-name convention as strict as the others, and `skip`/`pending` are ordinary method names: only spec and test files.
1235
+ { files: /(?:^|\/)(?:spec|test)\/.*\.rb$|_(?:spec|test)\.rb$/i, rules: [
1236
+ { type: "test-focus", regex: /^\s*(?:fit|fdescribe|fcontext|fspecify|fexample|fscenario)\b/, name: "focused RSpec example (fit / fdescribe)" },
1237
+ { type: "test-focus", regex: /\b(?:it|describe|context|specify|scenario)\b[^\n]*[,(]\s*(?::focus\b|focus:\s*true\b)/, name: "focused RSpec example (focus: true)" },
1238
+ { type: "test-skip", regex: /^\s*(?:xit|xdescribe|xcontext|xspecify|xexample|xscenario|xfeature)\b/, name: "disabled RSpec example (xit / xdescribe)" },
1239
+ { type: "test-skip", regex: /\b(?:it|describe|context|specify|scenario)\b[^\n]*[,(]\s*(?::skip\b|skip:\s*(?:true\b|["']))/, name: "disabled RSpec example (skip: true)" },
1240
+ { type: "test-skip", regex: /^\s*skip(?:\s|\(|$)/, name: "skipped Ruby test (skip)", guard: RUBY_GUARD },
1241
+ { type: "test-skip", regex: /^\s*pending(?:\s+["'(]|\s*$)/, name: "pending Ruby example (pending)", guard: RUBY_GUARD },
1242
+ { type: "trivial-assert", regex: /\bexpect\s*\(\s*(true|false|1|0|nil)\s*\)\s*\.\s*to\s+(?:eq|eql|be|equal)\b\s*\(?\s*\1\b/, name: "trivial expectation (expect(true).to eq(true))" },
1243
+ { type: "trivial-assert", regex: /^\s*assert\s*\(?\s*true\s*\)?\s*$/, name: "trivial assert (assert true)" },
1244
+ { type: "trivial-assert", regex: /\bassert_equal\s*\(?\s*(\d+|true|false|nil)\s*,\s*\1\s*\)?\s*$/, name: "trivial assert (assert_equal 1, 1)" }
1245
+ ] },
1246
+ { files: /\.php$/i, rules: [
1247
+ { type: "test-skip", regex: /\$this\s*->\s*markTest(?:Skipped|Incomplete)\s*\(/, name: "skipped PHPUnit test (markTestSkipped)", guard: PHP_GUARD },
1248
+ { type: "trivial-assert", regex: /\$this\s*->\s*assertTrue\s*\(\s*true\s*\)/, name: "trivial assert ($this->assertTrue(true))" },
1249
+ { type: "trivial-assert", regex: /\$this\s*->\s*assertFalse\s*\(\s*false\s*\)/, name: "trivial assert ($this->assertFalse(false))" },
1250
+ { type: "trivial-assert", regex: /\$this\s*->\s*assert(?:Equals|Same)\s*\(\s*(\d+|true|false|null)\s*,\s*\1\s*\)/, name: "trivial assert ($this->assertEquals(1, 1))" }
1251
+ ] },
1252
+ { files: /\.swift$/i, rules: [
1253
+ { type: "test-skip", regex: /\bthrow\s+XCTSkip\s*\(/, name: "skipped XCTest (XCTSkip)", guard: SWIFT_GUARD },
1254
+ { type: "test-skip", regex: /@(?:Test|Suite)\s*\([^)]*\.disabled\b/, name: "disabled Swift Testing test (.disabled)" },
1255
+ { type: "trivial-assert", regex: /\bXCTAssertTrue\s*\(\s*true\s*\)/, name: "trivial assert (XCTAssertTrue(true))" },
1256
+ { type: "trivial-assert", regex: /\bXCTAssertFalse\s*\(\s*false\s*\)/, name: "trivial assert (XCTAssertFalse(false))" },
1257
+ { type: "trivial-assert", regex: /\bXCTAssertEqual\s*\(\s*(\d+|true|false|nil)\s*,\s*\1\s*\)/, name: "trivial assert (XCTAssertEqual(1, 1))" },
1258
+ { type: "trivial-assert", regex: /#expect\s*\(\s*true\s*\)/, name: "trivial expectation (#expect(true))" }
1259
+ ] }
1167
1260
  ];
1261
+ function isGuardedBlockSkip(lines, profile) {
1262
+ const indentOf = (text) => text.length - text.trimStart().length;
1263
+ const mentionsCi = (raw) => /\bCI\b|\bGITHUB_ACTIONS\b/.test(raw);
1264
+ const own = lines[lines.length - 1];
1265
+ if (profile.modifier?.test(own.code) || profile.conditional.test(own.code)) return !mentionsCi(own.raw);
1266
+ let limit = indentOf(own.code);
1267
+ for (let i = lines.length - 2; i >= 0 && lines.length - 1 - i <= 200; i--) {
1268
+ if (lines[i].number !== lines[i + 1].number - 1) return false;
1269
+ const { code, raw } = lines[i];
1270
+ if (!code.trim() || indentOf(code) >= limit) continue;
1271
+ if (profile.conditional.test(code)) return !mentionsCi(raw);
1272
+ if (profile.test.test(code)) return false;
1273
+ if (profile.helper.test(code)) return true;
1274
+ limit = indentOf(code);
1275
+ }
1276
+ return false;
1277
+ }
1168
1278
  var JS_TEST_API = /(?<![\w$.])(?:(?:it|test|describe|suite)(?:\s*\.\s*(?:concurrent|serial|parallel|describe|failing|sequential))*\s*\.\s*(skip|todo|only)|((?:xit|xtest|xdescribe|fit|fdescribe)))\b((?:\s*\.\s*(?:each|failing|concurrent|serial|parallel|sequential))*)\s*(?=\(|\u0001)/g;
1279
+ function isConditionalSkip(source, raw, open2) {
1280
+ if (source[open2] !== "(") return false;
1281
+ const args = [];
1282
+ let depth = 0;
1283
+ let start = open2 + 1;
1284
+ for (let i = start; i < source.length && i - open2 <= 2e3; i++) {
1285
+ const char = source[i];
1286
+ if (char === "\0") return false;
1287
+ if ("([{".includes(char)) depth++;
1288
+ else if (")]}".includes(char)) {
1289
+ if (depth) {
1290
+ depth--;
1291
+ continue;
1292
+ }
1293
+ if (char !== ")") return false;
1294
+ args.push(raw.slice(start, i).trim());
1295
+ while (args.length && !args.at(-1)) args.pop();
1296
+ const isString = (arg) => /^(['"`])[\s\S]*\1$/.test(arg);
1297
+ if (!args.length || isString(args[0]) || args[0] === "true") return false;
1298
+ if (args.length > 1 && !isString(args.at(-1))) return false;
1299
+ return !/\bCI\b|\bisCI\b|GITHUB_ACTIONS/.test(args[0]);
1300
+ } else if (char === "," && !depth) {
1301
+ args.push(raw.slice(start, i).trim());
1302
+ start = i + 1;
1303
+ }
1304
+ }
1305
+ return false;
1306
+ }
1169
1307
  var JS_COMMENT_DIRECTIVE_PATTERNS = [
1170
1308
  { regex: /^\s*\/\/\s*@ts-ignore\b/, name: "compiler ignore (@ts-ignore)" },
1171
1309
  { regex: /^\s*\/\/\s*@ts-expect-error\b/, name: "compiler suppression (@ts-expect-error)" },
@@ -1222,7 +1360,8 @@ var CODE_EXTENSIONS = /* @__PURE__ */ new Set([
1222
1360
  ".rb",
1223
1361
  ".cs",
1224
1362
  ".cpp",
1225
- ".c"
1363
+ ".c",
1364
+ ".swift"
1226
1365
  ]);
1227
1366
  var IGNORED_PATH_REGEX = /(?:^|\/)(?:fixtures|__fixtures__|testdata|mocks|test_data|__snapshots__|sample[s]?)\//i;
1228
1367
  var TEST_PATH_REGEX = /(?:^|\/)test_[^/]+\.py$|(?:^|\/)(?:tests?|__tests__|specs?)\/|[._-](?:test|spec)\.[a-z0-9]+$/i;
@@ -1258,6 +1397,7 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
1258
1397
  const directives = javascript ? JS_COMMENT_DIRECTIVE_PATTERNS : python ? PYTHON_COMMENT_DIRECTIVE_PATTERNS : [];
1259
1398
  const stubs = javascript ? JS_STUB_PATTERNS : python ? PYTHON_STUB_PATTERNS : rust ? RUST_STUB_PATTERNS : [];
1260
1399
  const firstFinding = infractions.length;
1400
+ const languageRules = LANGUAGE_LINE_RULES.find((entry) => entry.files.test(file.path))?.rules;
1261
1401
  const masker = new SourceMasker(file.path);
1262
1402
  const maskedLines = [];
1263
1403
  for (const added of file.addedLines) {
@@ -1271,6 +1411,7 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
1271
1411
  if (python && !isComment) {
1272
1412
  for (const rule of PYTHON_TEST_SKIP_PATTERNS) {
1273
1413
  if (rule.regex.test(cleanedLine)) {
1414
+ if (rule.imperative && isGuardedSkip(maskedLines)) break;
1274
1415
  infractions.push({
1275
1416
  type: "test-skip",
1276
1417
  file: file.path,
@@ -1284,6 +1425,14 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
1284
1425
  }
1285
1426
  }
1286
1427
  }
1428
+ if (languageRules && !isComment) {
1429
+ for (const rule of languageRules) {
1430
+ if (!rule.regex.test(cleanedLine)) continue;
1431
+ if (rule.guard && isGuardedBlockSkip(maskedLines, rule.guard)) break;
1432
+ infractions.push({ type: rule.type, file: file.path, lineNumber: added.lineNumber, lineSnippet: rawLine.trim(), commitHash, commitDate, reason: rule.name });
1433
+ break;
1434
+ }
1435
+ }
1287
1436
  let foundEscape = false;
1288
1437
  for (const rule of directives) {
1289
1438
  if (rule.regex.test(masked.comments)) {
@@ -1376,6 +1525,7 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
1376
1525
  if (match[2] && !isTestFile(file.path)) continue;
1377
1526
  if (/\bfunction$/.test(source.slice(Math.max(0, previous - 8), previous + 1))) continue;
1378
1527
  if (source[match.index + match[0].length] === "" && !/\.\s*each\s*$/.test(match[3])) continue;
1528
+ if (match[1] === "skip" && !match[3] && isConditionalSkip(source, raw, match.index + match[0].length)) continue;
1379
1529
  const focus = match[1] === "only" || match[2] === "fit" || match[2] === "fdescribe";
1380
1530
  infractions.push({
1381
1531
  type: focus ? "test-focus" : "test-skip",
@@ -1887,6 +2037,9 @@ var RULE_PRIORITY = {
1887
2037
  "expectation-rewritten": 1,
1888
2038
  "test-removed": 1
1889
2039
  };
2040
+ function isCheckRule(type) {
2041
+ return RULE_PRIORITY[type] !== void 0;
2042
+ }
1890
2043
  function findingRank(type, failedRules) {
1891
2044
  return failedRules.includes(type) ? -1 : RULE_PRIORITY[type] ?? 2;
1892
2045
  }
@@ -2095,12 +2248,15 @@ function formatCheckMarkdown(result) {
2095
2248
  for (const skipped of result.skippedFiles) lines.push("", `Not scanned: ${markdownCell(skipped.file)} \u2014 ${markdownCell(skipped.reason)}`);
2096
2249
  return lines.join("\n");
2097
2250
  }
2251
+ var REVIEW_TITLES = { commits: "context review", index: "staged context review", worktree: "working tree review" };
2252
+ var reviewTitle = (result) => REVIEW_TITLES[result.source ?? "commits"];
2253
+ var reviewHead = (result) => result.headSha.slice(0, (result.source ?? "commits") === "commits" ? 7 : 18);
2098
2254
  function formatReviewTerminal(result) {
2099
2255
  const status = !result.complete ? "INCOMPLETE" : result.passed ? "PASSED" : "BLOCKED";
2100
2256
  const lines = [
2101
2257
  "",
2102
- pc.bold(`agent-roast \u2014 ${result.source === "index" ? "staged context review" : "context review"}: ${status}`),
2103
- ` ${result.mergeBaseSha.slice(0, 7)} \u2192 ${result.headSha.slice(0, result.source === "index" ? 18 : 7)} \xB7 policy: ${result.policySha === "EMPTY" ? "built-in first-commit defaults" : "base " + result.policySha.slice(0, 7)}`,
2258
+ pc.bold(`agent-roast \u2014 ${reviewTitle(result)}: ${status}`),
2259
+ ` ${result.mergeBaseSha.slice(0, 7)} \u2192 ${reviewHead(result)} \xB7 policy: ${result.policySha === "EMPTY" ? "built-in first-commit defaults" : "base " + result.policySha.slice(0, 7)}`,
2104
2260
  ` ${result.filesInspected} changed files compared \xB7 ${result.linesInspected} source lines inspected across both versions`,
2105
2261
  ` ${result.infractions.length} new \xB7 ${result.unchangedCount} existing \xB7 ${result.removedFindings.length} no longer detected`
2106
2262
  ];
@@ -2108,25 +2264,26 @@ function formatReviewTerminal(result) {
2108
2264
  if (result.policyChanges.length) lines.push(" Candidate policy changes were ignored; adopt them separately after review.");
2109
2265
  if (result.excludedFiles || result.unsupportedFiles) lines.push(` ${result.excludedFiles} excluded \xB7 ${result.unsupportedFiles} unsupported/non-source changes`);
2110
2266
  for (const finding2 of mostImportantFirst(result.infractions, result.failedRules).slice(0, 20)) {
2267
+ const failing = result.failedRules.includes(finding2.type);
2111
2268
  lines.push(
2112
2269
  "",
2113
- ` ${result.failedRules.includes(finding2.type) ? "FAIL" : "WARN"} [${finding2.type}] ${terminalText(finding2.file || "unknown")}:${finding2.lineNumber}`,
2270
+ (failing ? pc.red : pc.yellow)(` ${failing ? "FAIL" : "WARN"} [${finding2.type}] ${terminalText(finding2.file || "unknown")}:${finding2.lineNumber}`),
2114
2271
  ` ${terminalText(finding2.lineSnippet || finding2.reason)}`,
2115
- ` Next: ${terminalText(finding2.suggestion || "Review this occurrence.")}`
2272
+ pc.dim(` Next: ${terminalText(finding2.suggestion || "Review this occurrence.")}`)
2116
2273
  );
2117
2274
  }
2118
2275
  if (result.infractions.length > 20) lines.push(` ${result.infractions.length - 20} more new findings; use --format json for all results.`);
2119
2276
  for (const skipped of result.skippedFiles) lines.push(` NOT COMPARED ${terminalText(skipped.file)}: ${terminalText(skipped.reason)}`);
2120
- lines.push("", !result.complete ? " INCOMPLETE: some changed source was not compared (exit 2)." : result.passed ? " PASSED: no new blocking findings." : ` BLOCKED: ${result.failedRules.join(", ")}.`, "");
2277
+ lines.push("", !result.complete ? pc.red(" INCOMPLETE: some changed source was not compared (exit 2).") : result.passed ? pc.green(" PASSED: no new blocking findings.") : pc.red(` BLOCKED: ${result.failedRules.join(", ")}.`), "");
2121
2278
  if (!result.filesInspected) lines.push(" No supported changed source files compared.", "");
2122
2279
  return lines.join("\n");
2123
2280
  }
2124
2281
  function formatReviewMarkdown(result) {
2125
2282
  const status = !result.complete ? "INCOMPLETE" : result.passed ? "PASSED" : "BLOCKED";
2126
2283
  const lines = [
2127
- `## agent-roast ${result.source === "index" ? "staged context review" : "context review"}: ${status}`,
2284
+ `## agent-roast ${reviewTitle(result)}: ${status}`,
2128
2285
  "",
2129
- `Compared ${result.filesInspected} changed files at \`${result.mergeBaseSha.slice(0, 7)}\` \u2192 \`${result.headSha.slice(0, result.source === "index" ? 18 : 7)}\`. Policy: \`${result.policySha === "EMPTY" ? "built-in first-commit defaults" : result.policySha.slice(0, 7)}\`.`,
2286
+ `Compared ${result.filesInspected} changed files at \`${result.mergeBaseSha.slice(0, 7)}\` \u2192 \`${reviewHead(result)}\`. Policy: \`${result.policySha === "EMPTY" ? "built-in first-commit defaults" : result.policySha.slice(0, 7)}\`.`,
2130
2287
  "",
2131
2288
  `**${result.infractions.length} new** \xB7 ${result.unchangedCount} existing \xB7 ${result.removedFindings.length} no longer detected.`,
2132
2289
  ""
@@ -2206,9 +2363,9 @@ function createNewFile(root, relative2, content, mode = 420) {
2206
2363
  let directory = root;
2207
2364
  for (const segment of path3.dirname(inside).split(path3.sep).filter((part) => part !== ".")) {
2208
2365
  directory = path3.join(directory, segment);
2209
- let stat;
2366
+ let stat3;
2210
2367
  try {
2211
- stat = fs4.lstatSync(directory);
2368
+ stat3 = fs4.lstatSync(directory);
2212
2369
  } catch (error) {
2213
2370
  if (error.code !== "ENOENT") throw error;
2214
2371
  try {
@@ -2216,9 +2373,9 @@ function createNewFile(root, relative2, content, mode = 420) {
2216
2373
  } catch (mkdirError) {
2217
2374
  if (mkdirError.code !== "EEXIST") throw mkdirError;
2218
2375
  }
2219
- stat = fs4.lstatSync(directory);
2376
+ stat3 = fs4.lstatSync(directory);
2220
2377
  }
2221
- if (!stat.isDirectory() || stat.isSymbolicLink()) throw new ConfigError("Initialization refuses symlinked or non-directory parent paths.");
2378
+ if (!stat3.isDirectory() || stat3.isSymbolicLink()) throw new ConfigError("Initialization refuses symlinked or non-directory parent paths.");
2222
2379
  }
2223
2380
  const temporary = path3.join(directory, `.agent-roast-init-${randomUUID3()}.tmp`);
2224
2381
  let fd;
@@ -2471,14 +2628,15 @@ import { constants as constants2 } from "fs";
2471
2628
  import path5 from "path";
2472
2629
 
2473
2630
  // src/snapshot.ts
2474
- import { createHash as createHash2 } from "crypto";
2631
+ import { createHash as createHash2, randomBytes } from "crypto";
2475
2632
  import * as fs5 from "fs/promises";
2633
+ import { constants as fsConstants } from "fs";
2476
2634
  import path4 from "path";
2477
2635
  import { spawn as spawn2 } from "child_process";
2478
2636
  function assertNotAborted(signal) {
2479
2637
  if (signal?.aborted) throw new ConfigError("Review cancelled; no complete result was produced.");
2480
2638
  }
2481
- async function readGit(cwd, args, signal, limit = 8 * 1024 * 1024, indexFile) {
2639
+ async function readGit(cwd, args, signal, limit = 8 * 1024 * 1024, indexFile, input) {
2482
2640
  assertNotAborted(signal);
2483
2641
  const env = getHermeticGitEnv({ GIT_NO_REPLACE_OBJECTS: "1", GIT_NO_LAZY_FETCH: "1", GIT_TERMINAL_PROMPT: "0" });
2484
2642
  if (indexFile) env.GIT_INDEX_FILE = indexFile;
@@ -2486,9 +2644,12 @@ async function readGit(cwd, args, signal, limit = 8 * 1024 * 1024, indexFile) {
2486
2644
  const child = spawn2("git", [...HERMETIC_GIT_FLAGS, ...args], {
2487
2645
  cwd,
2488
2646
  env,
2489
- stdio: ["ignore", "pipe", "pipe"],
2647
+ stdio: ["pipe", "pipe", "pipe"],
2490
2648
  windowsHide: true
2491
2649
  });
2650
+ child.stdin.on("error", () => {
2651
+ });
2652
+ child.stdin.end(input);
2492
2653
  const chunks = [];
2493
2654
  let bytes = 0;
2494
2655
  let stderr = "";
@@ -2530,6 +2691,20 @@ async function requireLocalObjects2(cwd, signal) {
2530
2691
  if (!(error instanceof GitError && error.code === 1)) throw error;
2531
2692
  }
2532
2693
  }
2694
+ async function unresolvedRefHint(cwd, ref, signal) {
2695
+ try {
2696
+ const count = async (...args) => Number((await readGit(cwd, ["rev-list", "--count", ...args, "HEAD"], signal, 64)).toString().trim());
2697
+ if ((await readGit(cwd, ["rev-parse", "--is-shallow-repository"], signal, 64)).toString().trim() === "true") {
2698
+ return `This clone is shallow (commits available: ${await count()}), so the ref may lie outside it. Run 'git fetch --unshallow' (in GitHub Actions: actions/checkout with fetch-depth: 0) or check the name.`;
2699
+ }
2700
+ const back = /^HEAD~(\d+)$/.exec(ref);
2701
+ const depth = back ? await count("--first-parent") : 0;
2702
+ if (back && Number(back[1]) >= depth) return `HEAD has only ${depth} commit${depth === 1 ? "" : "s"} on its main line${depth > 1 ? `; try HEAD~${depth - 1}` : ""}.`;
2703
+ } catch {
2704
+ assertNotAborted(signal);
2705
+ }
2706
+ return "Check that it names a commit, branch or tag of this repository.";
2707
+ }
2533
2708
  async function reviewRefs(cwd, base, head, signal) {
2534
2709
  const resolve2 = async (ref) => {
2535
2710
  if (!ref.trim()) throw new ConfigError("Review refs must not be empty.");
@@ -2537,7 +2712,7 @@ async function reviewRefs(cwd, base, head, signal) {
2537
2712
  return (await readGit(cwd, ["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`], signal, 1024)).toString().trim();
2538
2713
  } catch (error) {
2539
2714
  assertNotAborted(signal);
2540
- throw new ConfigError(`Cannot resolve review ref '${ref}'. Use valid commits and fetch complete history (actions/checkout fetch-depth: 0). ${error instanceof Error ? error.message : String(error)}`);
2715
+ throw new ConfigError(`Cannot resolve review ref '${ref}'. ${await unresolvedRefHint(cwd, ref, signal)} ${error instanceof Error ? error.message : String(error)}`);
2541
2716
  }
2542
2717
  };
2543
2718
  await requireLocalObjects2(cwd, signal);
@@ -2594,35 +2769,36 @@ async function reviewPolicy(cwd, baseSha, signal) {
2594
2769
  }
2595
2770
  return { config: configFromText("{}", "default review policy"), filename: null };
2596
2771
  }
2772
+ async function resolveHead(cwd, signal) {
2773
+ try {
2774
+ return (await readGit(cwd, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], signal, 1024)).toString().trim();
2775
+ } catch (error) {
2776
+ if (!(error instanceof GitError && error.code === 1)) throw error;
2777
+ const ref = (await readGit(cwd, ["symbolic-ref", "--quiet", "HEAD"], signal, 1024)).toString().trim();
2778
+ try {
2779
+ await readGit(cwd, ["show-ref", "--verify", "--quiet", ref], signal, 1024);
2780
+ } catch (missing) {
2781
+ if (missing instanceof GitError && missing.code === 1) return "EMPTY";
2782
+ throw missing;
2783
+ }
2784
+ throw new ConfigError("HEAD exists but cannot be resolved to a commit.");
2785
+ }
2786
+ }
2597
2787
  async function stagedSnapshot(cwd, signal) {
2598
2788
  await requireLocalObjects2(cwd, signal);
2599
2789
  let indexFile;
2600
2790
  if (process.env.GIT_INDEX_FILE) {
2601
- const gitDirectory = await fs5.realpath(gitOutputPath((await readGit(cwd, ["rev-parse", "--absolute-git-dir"], signal, 8192)).toString()));
2791
+ const gitDirectory2 = await fs5.realpath(gitOutputPath((await readGit(cwd, ["rev-parse", "--absolute-git-dir"], signal, 8192)).toString()));
2602
2792
  const requested = path4.resolve(cwd, process.env.GIT_INDEX_FILE);
2603
- const stat = await fs5.lstat(requested);
2793
+ const stat3 = await fs5.lstat(requested);
2604
2794
  const resolved = await fs5.realpath(requested);
2605
- const relative2 = path4.relative(gitDirectory, resolved);
2606
- if (!stat.isFile() || stat.isSymbolicLink() || !relative2 || relative2 === ".." || relative2.startsWith(".." + path4.sep) || path4.isAbsolute(relative2)) {
2795
+ const relative2 = path4.relative(gitDirectory2, resolved);
2796
+ if (!stat3.isFile() || stat3.isSymbolicLink() || !relative2 || relative2 === ".." || relative2.startsWith(".." + path4.sep) || path4.isAbsolute(relative2)) {
2607
2797
  throw new ConfigError("GIT_INDEX_FILE must be a regular candidate index inside this repository's Git directory; refusing to review another index.");
2608
2798
  }
2609
2799
  indexFile = resolved;
2610
2800
  }
2611
- const head = async () => {
2612
- try {
2613
- return (await readGit(cwd, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], signal, 1024)).toString().trim();
2614
- } catch (error) {
2615
- if (!(error instanceof GitError && error.code === 1)) throw error;
2616
- const ref = (await readGit(cwd, ["symbolic-ref", "--quiet", "HEAD"], signal, 1024)).toString().trim();
2617
- try {
2618
- await readGit(cwd, ["show-ref", "--verify", "--quiet", ref], signal, 1024);
2619
- } catch (missing) {
2620
- if (missing instanceof GitError && missing.code === 1) return "EMPTY";
2621
- throw missing;
2622
- }
2623
- throw new ConfigError("HEAD exists but cannot be resolved to a commit.");
2624
- }
2625
- };
2801
+ const head = () => resolveHead(cwd, signal);
2626
2802
  const baseSha = await head();
2627
2803
  const inventory = () => readGit(cwd, [
2628
2804
  "diff",
@@ -2659,6 +2835,99 @@ async function stagedSnapshot(cwd, signal) {
2659
2835
  }
2660
2836
  };
2661
2837
  }
2838
+ var MAX_WORKTREE_FILE = 2 * 1024 * 1024;
2839
+ var outside = (relative2) => !relative2 || relative2 === ".." || relative2.startsWith(".." + path4.sep) || path4.isAbsolute(relative2);
2840
+ async function worktreeSnapshot(cwd, signal) {
2841
+ await requireLocalObjects2(cwd, signal);
2842
+ const text = async (...args) => gitOutputPath((await readGit(cwd, args, signal, 8192)).toString());
2843
+ const [root, gitDirectory2, indexPath, baseSha] = await Promise.all([
2844
+ text("rev-parse", "--show-toplevel").then((file) => fs5.realpath(file)),
2845
+ text("rev-parse", "--absolute-git-dir").then((file) => fs5.realpath(file)),
2846
+ text("rev-parse", "--git-path", "index"),
2847
+ resolveHead(cwd, signal)
2848
+ ]);
2849
+ const realIndex = path4.resolve(cwd, indexPath);
2850
+ const indexFile = path4.join(gitDirectory2, `agent-roast-${process.pid}-${randomBytes(6).toString("hex")}.index`);
2851
+ const dispose = async () => {
2852
+ await Promise.all([indexFile, indexFile + ".lock"].map((file) => fs5.rm(file, { force: true })));
2853
+ };
2854
+ try {
2855
+ let restoreIndexTime = async () => {
2856
+ };
2857
+ try {
2858
+ const original = await fs5.stat(realIndex);
2859
+ await fs5.copyFile(realIndex, indexFile, fsConstants.COPYFILE_EXCL);
2860
+ const seconds = Math.floor(original.mtimeMs) / 1e3;
2861
+ restoreIndexTime = () => fs5.utimes(indexFile, seconds, seconds);
2862
+ await restoreIndexTime();
2863
+ } catch (error) {
2864
+ if (error.code !== "ENOENT") throw error;
2865
+ }
2866
+ const conflicts = await readGit(root, ["ls-files", "--unmerged", "-z", "--full-name"], signal, 8 * 1024 * 1024, indexFile);
2867
+ if (conflicts.length) throw new ConfigError("Resolve unmerged index entries before reviewing the working tree.");
2868
+ await readGit(root, ["update-index", "-q", "--refresh"], signal, 1024 * 1024, indexFile);
2869
+ await restoreIndexTime();
2870
+ await readGit(root, ["add", "--intent-to-add", "--", "."], signal, 1024 * 1024, indexFile);
2871
+ await restoreIndexTime();
2872
+ const tree = baseSha === "EMPTY" ? (await readGit(root, ["hash-object", "-t", "tree", "--stdin"], signal, 256, void 0, Buffer.alloc(0))).toString().trim() : baseSha;
2873
+ const inventory = () => readGit(root, [
2874
+ "diff-index",
2875
+ "--raw",
2876
+ "-z",
2877
+ "--no-abbrev",
2878
+ "--no-ext-diff",
2879
+ "--no-textconv",
2880
+ "--ignore-submodules=none",
2881
+ "--find-renames=50%",
2882
+ "-l1000",
2883
+ "--end-of-options",
2884
+ tree,
2885
+ "--"
2886
+ ], signal, 8 * 1024 * 1024, indexFile);
2887
+ const pinned = await inventory();
2888
+ const changes = parseTreeChanges(pinned);
2889
+ const headSha = "WORKTREE:" + createHash2("sha256").update(baseSha).update(pinned).digest("hex");
2890
+ const stamps = /* @__PURE__ */ new Map();
2891
+ const stamp = (stat3) => `${stat3.size}:${stat3.mtimeMs}:${stat3.ino}`;
2892
+ return {
2893
+ baseSha,
2894
+ headSha,
2895
+ mergeBaseSha: baseSha,
2896
+ changes,
2897
+ readWorktree: async (relative2) => {
2898
+ const filename = path4.resolve(root, relative2);
2899
+ if (outside(path4.relative(root, filename))) throw new ConfigError("A changed path escapes the repository.");
2900
+ let stat3;
2901
+ try {
2902
+ stat3 = await fs5.lstat(filename);
2903
+ } catch (error) {
2904
+ if (error.code === "ENOENT") throw new ConfigError("A changed file disappeared during the review; rerun it.");
2905
+ throw error;
2906
+ }
2907
+ if (!stat3.isFile()) throw new ConfigError("Changed source is a symlink or special file; comparison is incomplete.");
2908
+ if (stat3.size > MAX_WORKTREE_FILE) throw new ConfigError(`Source file exceeds the ${MAX_WORKTREE_FILE} byte limit.`);
2909
+ if (outside(path4.relative(root, await fs5.realpath(filename)))) throw new ConfigError("A changed file resolves outside the repository.");
2910
+ const content = readRegularFile(filename, MAX_WORKTREE_FILE);
2911
+ stamps.set(filename, stamp(stat3));
2912
+ return content;
2913
+ },
2914
+ verify: async () => {
2915
+ assertNotAborted(signal);
2916
+ if (await resolveHead(cwd, signal) !== baseSha || !(await inventory()).equals(pinned)) {
2917
+ throw new ConfigError("HEAD or the working tree changed during review; rerun it. No complete result was produced.");
2918
+ }
2919
+ for (const [filename, before] of stamps) {
2920
+ const now = await fs5.lstat(filename).catch(() => null);
2921
+ if (!now || stamp(now) !== before) throw new ConfigError("A changed file was modified during review; rerun it. No complete result was produced.");
2922
+ }
2923
+ },
2924
+ dispose
2925
+ };
2926
+ } catch (error) {
2927
+ await dispose();
2928
+ throw error;
2929
+ }
2930
+ }
2662
2931
 
2663
2932
  // src/check.ts
2664
2933
  var MAX_FILE_BYTES = 2 * 1024 * 1024;
@@ -2677,21 +2946,21 @@ function insideRoot(root, filename) {
2677
2946
  async function readSource(root, file, signal) {
2678
2947
  const filename = path5.resolve(root, file);
2679
2948
  if (!insideRoot(root, filename)) throw new Error("File escapes the repository.");
2680
- let stat;
2949
+ let stat3;
2681
2950
  try {
2682
- stat = await fs6.lstat(filename);
2951
+ stat3 = await fs6.lstat(filename);
2683
2952
  } catch (error) {
2684
2953
  if (error.code === "ENOENT") return null;
2685
2954
  throw error;
2686
2955
  }
2687
- if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("Symlinks and special files are not scanned.");
2956
+ if (!stat3.isFile() || stat3.isSymbolicLink()) throw new Error("Symlinks and special files are not scanned.");
2688
2957
  const real = await fs6.realpath(filename);
2689
2958
  if (!insideRoot(root, real)) throw new Error("File resolves outside the repository.");
2690
- if (stat.size > MAX_FILE_BYTES) throw new Error("File exceeds the 2 MiB scan limit.");
2959
+ if (stat3.size > MAX_FILE_BYTES) throw new Error("File exceeds the 2 MiB scan limit.");
2691
2960
  const handle = await fs6.open(filename, constants2.O_RDONLY | (constants2.O_NOFOLLOW || 0) | (constants2.O_NONBLOCK || 0));
2692
2961
  try {
2693
2962
  const opened = await handle.stat();
2694
- if (opened.ino !== stat.ino || opened.dev !== stat.dev || !opened.isFile()) throw new Error("File changed while opening.");
2963
+ if (opened.ino !== stat3.ino || opened.dev !== stat3.dev || !opened.isFile()) throw new Error("File changed while opening.");
2695
2964
  const chunks = [];
2696
2965
  let size = 0;
2697
2966
  while (true) {
@@ -3226,28 +3495,38 @@ function compareFindings(before, after, newPath) {
3226
3495
  return { introduced, removed: [...old.values()].flatMap((bucket) => bucket.findings.slice(bucket.matched)), unchanged };
3227
3496
  }
3228
3497
  function reviewChanges(options) {
3229
- return compareSourceChanges({ ...options, staged: false });
3498
+ return compareSourceChanges({ ...options, source: "commits" });
3230
3499
  }
3231
3500
  function reviewStagedChanges(options = {}) {
3232
- return compareSourceChanges({ ...options, staged: true });
3501
+ return compareSourceChanges({ ...options, source: "index" });
3502
+ }
3503
+ function reviewWorktreeChanges(options = {}) {
3504
+ return compareSourceChanges({ ...options, source: "worktree" });
3233
3505
  }
3234
3506
  async function compareSourceChanges(options) {
3235
3507
  assertNotAborted(options.signal);
3236
3508
  const cwd = path6.resolve(options.cwd || process.cwd());
3237
- const staged = options.staged ? await stagedSnapshot(cwd, options.signal) : void 0;
3238
- const refs = options.staged ? { baseSha: staged.baseSha, headSha: staged.headSha, mergeBaseSha: staged.mergeBaseSha } : await reviewRefs(cwd, options.base, options.head ?? "HEAD", options.signal);
3509
+ const snapshot = options.source === "index" ? await stagedSnapshot(cwd, options.signal) : options.source === "worktree" ? await worktreeSnapshot(cwd, options.signal) : void 0;
3510
+ try {
3511
+ return await compareSnapshot(options, cwd, snapshot);
3512
+ } finally {
3513
+ await snapshot?.dispose?.();
3514
+ }
3515
+ }
3516
+ async function compareSnapshot(options, cwd, snapshot) {
3517
+ const refs = options.source === "commits" ? await reviewRefs(cwd, options.base, options.head ?? "HEAD", options.signal) : { baseSha: snapshot.baseSha, headSha: snapshot.headSha, mergeBaseSha: snapshot.mergeBaseSha };
3239
3518
  const policy = refs.baseSha === "EMPTY" ? { config: configFromText("{}", "default first-commit policy"), filename: null } : await reviewPolicy(cwd, refs.baseSha, options.signal);
3240
3519
  const format = options.format ?? "terminal";
3241
3520
  if (!["terminal", "json", "github", "markdown", "sarif"].includes(format)) throw new ConfigError("Unsupported review format.");
3242
3521
  const failOn = options.failOn ?? policy.config.failOn ?? ["test-skip", "trivial-assert", "test-focus", "assertion-weakened", "guard-weakened"];
3243
3522
  for (const rule of failOn) if (!VALID_DETECTORS.includes(rule)) throw new ConfigError(`Unknown review rule '${rule}'.`);
3244
- const changes = staged?.changes ?? await changedTreeFiles(cwd, refs.mergeBaseSha, refs.headSha, options.signal);
3523
+ const changes = snapshot?.changes ?? await changedTreeFiles(cwd, refs.mergeBaseSha, refs.headSha, options.signal);
3245
3524
  const policyChanges = changes.filter((change) => CONFIG_FILENAMES.includes(change.oldPath) || CONFIG_FILENAMES.includes(change.newPath)).map((change) => change.newPath);
3246
3525
  const ignores = [...GENERATED_PATHS2, ...policy.config.ignorePaths || []];
3247
3526
  const result = {
3248
- schemaVersion: "1.1.0",
3527
+ schemaVersion: "1.2.0",
3249
3528
  mode: "review",
3250
- source: staged ? "index" : "commits",
3529
+ source: options.source,
3251
3530
  ...refs,
3252
3531
  policySha: refs.baseSha,
3253
3532
  policyFile: policy.filename,
@@ -3275,15 +3554,26 @@ async function compareSourceChanges(options) {
3275
3554
  const guardFindings = [];
3276
3555
  const ciCounts = [];
3277
3556
  let productionChanged = false;
3278
- const readText = async (mode, oid) => {
3279
- if (mode === "000000") return null;
3280
- if (!["100644", "100755"].includes(mode)) throw new ConfigError("Changed source is a symlink, submodule or unsupported Git mode; comparison is incomplete.");
3281
- const content = await readBlob(cwd, oid, options.signal);
3557
+ const decode = (content) => {
3282
3558
  totalBytes += content.length;
3283
3559
  if (totalBytes > MAX_SCAN_BYTES2) throw new ConfigError("Review exceeds the 50 MiB source comparison limit.");
3284
3560
  if (content.includes(0)) throw new ConfigError("Binary content in a source comparison.");
3285
3561
  return new TextDecoder("utf-8", { fatal: true }).decode(content);
3286
3562
  };
3563
+ const regularMode = (mode) => {
3564
+ if (!["100644", "100755"].includes(mode)) throw new ConfigError("Changed source is a symlink, submodule or unsupported Git mode; comparison is incomplete.");
3565
+ };
3566
+ const readText = async (mode, oid) => {
3567
+ if (mode === "000000") return null;
3568
+ regularMode(mode);
3569
+ return decode(await readBlob(cwd, oid, options.signal));
3570
+ };
3571
+ const readNew = async (change) => {
3572
+ if (options.source !== "worktree") return readText(change.newMode, change.newOid);
3573
+ if (change.newMode === "000000") return null;
3574
+ regularMode(change.newMode);
3575
+ return decode(await snapshot.readWorktree(change.newPath));
3576
+ };
3287
3577
  const existingCounts = /* @__PURE__ */ new Map();
3288
3578
  for (const change of changes) {
3289
3579
  assertNotAborted(options.signal);
@@ -3297,7 +3587,7 @@ async function compareSourceChanges(options) {
3297
3587
  }
3298
3588
  try {
3299
3589
  const oldText = await readText(change.oldMode, change.oldOid);
3300
- const newText = await readText(change.newMode, change.newOid);
3590
+ const newText = await readNew(change);
3301
3591
  guardFindings.push(...analyzeGuardFile(change.newPath, oldText, newText, refs.headSha));
3302
3592
  const ci = ciTestSteps(change.newPath, oldText, newText);
3303
3593
  if (ci) ciCounts.push(ci);
@@ -3328,7 +3618,7 @@ async function compareSourceChanges(options) {
3328
3618
  throw new ConfigError("Source moved to an unsupported extension; cannot claim its findings were resolved.");
3329
3619
  }
3330
3620
  const oldText = await readText(change.oldMode, change.oldOid);
3331
- const newText = await readText(change.newMode, change.newOid);
3621
+ const newText = await readNew(change);
3332
3622
  const before = scan(oldText, change.oldPath, refs.mergeBaseSha);
3333
3623
  const after = scan(newText, change.newPath, refs.headSha);
3334
3624
  if (isTestFile(change.oldPath) || isTestFile(change.newPath)) tamperingFiles.push(analyzeTestFile(change.oldPath, change.newPath, oldText, newText, refs.headSha));
@@ -3355,7 +3645,7 @@ async function compareSourceChanges(options) {
3355
3645
  }
3356
3646
  }
3357
3647
  assertNotAborted(options.signal);
3358
- await staged?.verify();
3648
+ await snapshot?.verify();
3359
3649
  result.complete = result.skippedFiles.length === 0;
3360
3650
  const rules = policy.config.rules ?? {};
3361
3651
  result.infractions.push(...[...resolveTampering(tamperingFiles, productionChanged), ...guardFindings, ...resolveCiTestSteps(ciCounts, refs.headSha)].filter((finding2) => rules[finding2.type] !== "off"));
@@ -3374,6 +3664,528 @@ async function compareSourceChanges(options) {
3374
3664
  return result;
3375
3665
  }
3376
3666
 
3667
+ // src/replay.ts
3668
+ import path7 from "path";
3669
+ var MAX_COMMITS = 1e3;
3670
+ var CONCURRENCY = 4;
3671
+ var LISTED_COMMITS = 15;
3672
+ var LISTED_PER_RULE = 2;
3673
+ var plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
3674
+ function clipEvidence(text, limit = 200) {
3675
+ if (text.length <= limit) return text;
3676
+ const arrow = text.indexOf(" \u2192 ");
3677
+ const clip2 = (part, size) => part.length > size ? `${part.slice(0, size - 1)}\u2026` : part;
3678
+ if (arrow < 0) return clip2(text, limit);
3679
+ const side = Math.floor((limit - 3) / 2);
3680
+ return `${clip2(text.slice(0, arrow), side)} \u2192 ${clip2(text.slice(arrow + 3), side)}`;
3681
+ }
3682
+ async function replayHistory(options = {}) {
3683
+ assertNotAborted(options.signal);
3684
+ const cwd = path7.resolve(options.cwd || process.cwd());
3685
+ const last = options.last ?? 50;
3686
+ if (!Number.isInteger(last) || last < 1 || last > MAX_COMMITS) throw new ConfigError(`Replay needs a whole number of commits between 1 and ${MAX_COMMITS}.`);
3687
+ const format = options.format ?? "terminal";
3688
+ if (format !== "terminal" && format !== "json") throw new ConfigError("Replay supports the terminal and json formats.");
3689
+ let log;
3690
+ try {
3691
+ log = (await readGit(cwd, ["log", "--first-parent", "-n", String(last), "--format=%H%x1f%P%x1f%cI%x1f%s"], options.signal)).toString("utf8");
3692
+ } catch (error) {
3693
+ assertNotAborted(options.signal);
3694
+ throw new ConfigError(`Cannot read the commit history. ${error instanceof Error ? error.message : String(error)}`);
3695
+ }
3696
+ const entries = log.split("\n").filter(Boolean).map((line) => {
3697
+ const [sha, parents, date, ...subject] = line.split("");
3698
+ return { sha, base: parents.split(" ")[0], date, subject: subject.join(" ") };
3699
+ });
3700
+ if (!entries.length) throw new ConfigError("The repository has no commits to replay.");
3701
+ const reviewable = entries.filter((entry) => entry.base);
3702
+ const commits = new Array(reviewable.length);
3703
+ let next = 0;
3704
+ let done = 0;
3705
+ await Promise.all(Array.from({ length: Math.min(CONCURRENCY, reviewable.length) }, async () => {
3706
+ while (next < reviewable.length) {
3707
+ const index = next++;
3708
+ commits[index] = await replayCommit(cwd, reviewable[index], options.signal);
3709
+ options.onProgress?.(++done, reviewable.length);
3710
+ }
3711
+ }));
3712
+ if (commits.length && commits.every((commit) => commit.status === "failed")) throw new ConfigError(`No commit could be reviewed. ${commits[0].error}`);
3713
+ const summary = { commits: commits.length, blocked: 0, warned: 0, noted: 0, clean: 0, untouched: 0, incomplete: 0, failed: 0, findingsByRule: {} };
3714
+ for (const commit of commits) {
3715
+ summary[commit.status]++;
3716
+ for (const finding2 of commit.findings) summary.findingsByRule[finding2.type] = (summary.findingsByRule[finding2.type] ?? 0) + 1;
3717
+ }
3718
+ const shallow = (await readGit(cwd, ["rev-parse", "--is-shallow-repository"], options.signal, 64).catch(() => Buffer.from(""))).toString().trim() === "true";
3719
+ const result = { schemaVersion: "1.0.0", mode: "replay", head: entries[0].sha, requested: last, commitsWithoutParent: entries.length - reviewable.length, shallow, summary, commits, exitCode: 0, output: "" };
3720
+ result.output = format === "json" ? JSON.stringify({ ...result, output: void 0 }, null, 2) : formatReplayTerminal(result, options.allRules);
3721
+ return result;
3722
+ }
3723
+ async function replayCommit(cwd, entry, signal) {
3724
+ const identity = { sha: entry.sha, date: entry.date, subject: entry.subject };
3725
+ try {
3726
+ const review = await reviewChanges({ cwd, base: entry.base, head: entry.sha, format: "json", signal });
3727
+ const status = review.failedRules.length ? "blocked" : !review.complete ? "incomplete" : review.infractions.some((finding2) => isCheckRule(finding2.type)) ? "warned" : review.infractions.length ? "noted" : review.filesInspected ? "clean" : "untouched";
3728
+ return { ...identity, status, failedRules: review.failedRules, findings: mostImportantFirst(review.infractions, review.failedRules), filesInspected: review.filesInspected, skipped: review.skippedFiles };
3729
+ } catch (error) {
3730
+ assertNotAborted(signal);
3731
+ return { ...identity, status: "failed", failedRules: [], findings: [], filesInspected: 0, skipped: [], error: error instanceof Error ? error.message : String(error) };
3732
+ }
3733
+ }
3734
+ function formatReplayTerminal(result, allRules = false) {
3735
+ const { summary } = result;
3736
+ const lines = [
3737
+ "",
3738
+ `agent-roast \u2014 replay of the last ${plural(summary.commits, "commit")} (up to ${result.head.slice(0, 7)})`,
3739
+ " Each commit is reviewed on its own against its parent, as a pull-request check would have seen it."
3740
+ ];
3741
+ const shallowHint = result.shallow ? [" This clone is shallow, so only the commits it holds were replayed. Run 'git fetch --unshallow' to replay more."] : [];
3742
+ if (!summary.commits) return [...lines, " There is no earlier commit to compare with, so nothing was reviewed.", ...shallowHint, ""].join("\n");
3743
+ lines.push(...shallowHint);
3744
+ lines.push(
3745
+ ` ${plural(summary.commits - summary.untouched - summary.failed, "commit")} changed source, tests or CI config \xB7 ${summary.untouched} did not`,
3746
+ ` Would have blocked: ${plural(summary.blocked, "commit")} \xB7 warnings on ${summary.warned} \xB7 line patterns only on ${summary.noted} \xB7 nothing to report on ${summary.clean}`
3747
+ );
3748
+ if (summary.incomplete + summary.failed) lines.push(` Not reviewed in full: ${plural(summary.incomplete + summary.failed, "commit")}`);
3749
+ const listed = result.commits.filter((commit) => commit.status === "blocked" || commit.status === "warned" || allRules && commit.status === "noted");
3750
+ const ordered = [...listed.filter((commit) => commit.status === "blocked"), ...listed.filter((commit) => commit.status !== "blocked")];
3751
+ for (const commit of ordered.slice(0, LISTED_COMMITS)) {
3752
+ lines.push("", ` ${commit.status === "blocked" ? "BLOCK" : "WARN "} ${commit.sha.slice(0, 7)} ${commit.date.slice(0, 10)} ${terminalText(commit.subject).slice(0, 80)}`);
3753
+ const shown = commit.findings.filter((finding2) => allRules || isCheckRule(finding2.type));
3754
+ for (const rule of new Set(shown.map((finding2) => finding2.type))) {
3755
+ const group = shown.filter((finding2) => finding2.type === rule);
3756
+ for (const finding2 of group.slice(0, LISTED_PER_RULE)) {
3757
+ lines.push(
3758
+ ` ${commit.failedRules.includes(rule) ? "FAIL" : "WARN"} [${rule}] ${terminalText(finding2.file || "unknown")}:${finding2.lineNumber}`,
3759
+ ` ${clipEvidence(terminalText(finding2.lineSnippet || finding2.reason))}`
3760
+ );
3761
+ }
3762
+ if (group.length > LISTED_PER_RULE) lines.push(` \u2026 and ${group.length - LISTED_PER_RULE} more [${rule}] in this commit`);
3763
+ }
3764
+ }
3765
+ if (ordered.length > LISTED_COMMITS) lines.push("", ` \u2026 ${plural(ordered.length - LISTED_COMMITS, "more commit")} with findings; use --json for all of them.`);
3766
+ if (!ordered.length) lines.push("", " Nothing to report on these commits.");
3767
+ if (!allRules) {
3768
+ const hidden = result.commits.flatMap((commit) => commit.findings.filter((finding2) => !isCheckRule(finding2.type)));
3769
+ if (hidden.length) {
3770
+ const byRule = Object.entries(hidden.reduce((counts, finding2) => ({ ...counts, [finding2.type]: (counts[finding2.type] ?? 0) + 1 }), {})).sort((a, b) => b[1] - a[1]);
3771
+ lines.push("", ` Line patterns not listed: ${plural(hidden.length, "finding")} (${byRule.slice(0, 3).map(([rule, count]) => `${rule} ${count}`).join(", ")}). Add --all-rules to list them.`);
3772
+ }
3773
+ }
3774
+ for (const commit of result.commits.filter((commit2) => commit2.status === "incomplete" || commit2.status === "failed").slice(0, 5)) {
3775
+ lines.push(` Not reviewed in full: ${commit.sha.slice(0, 7)} ${terminalText(commit.subject).slice(0, 60)} \u2014 ${terminalText(commit.error ?? `${plural(commit.skipped.length, "changed file")} could not be compared`)}`);
3776
+ }
3777
+ lines.push(
3778
+ "",
3779
+ " Findings are review candidates, not verdicts: `agent-roast explain <rule>` says what a rule means.",
3780
+ " To run this on every pull request: `agent-roast init --ci`.",
3781
+ ""
3782
+ );
3783
+ return lines.join("\n");
3784
+ }
3785
+
3786
+ // src/hook.ts
3787
+ import path8 from "path";
3788
+ import * as fs7 from "fs/promises";
3789
+ var HOOK_AGENTS = ["claude", "codex", "cursor"];
3790
+ var DEFAULT_TIMEOUT_MS = 25e3;
3791
+ var PAUSE_FILE = "agent-roast-hook-paused";
3792
+ var LISTED = 6;
3793
+ var LISTED_WARNINGS = 3;
3794
+ function detectAgent(input) {
3795
+ if ("workspace_roots" in input || "cursor_version" in input || "conversation_id" in input) return "cursor";
3796
+ if ("turn_id" in input) return "codex";
3797
+ return "claude";
3798
+ }
3799
+ function inputCwd(agent, input, env) {
3800
+ const roots = input.workspace_roots;
3801
+ if (agent === "cursor" && Array.isArray(roots) && typeof roots[0] === "string") return roots[0];
3802
+ if (typeof input.cwd === "string" && input.cwd) return input.cwd;
3803
+ return env.CLAUDE_PROJECT_DIR || env.CURSOR_PROJECT_DIR || process.cwd();
3804
+ }
3805
+ function clip(text, limit) {
3806
+ const flat = terminalText(text).replace(/\s+/g, " ").trim();
3807
+ return flat.length > limit ? `${flat.slice(0, limit - 1)}\u2026` : flat;
3808
+ }
3809
+ function describe(finding2, index) {
3810
+ const where = finding2.file ? `${terminalText(finding2.file)}${finding2.lineNumber ? `:${finding2.lineNumber}` : ""}` : "unknown location";
3811
+ const evidence = clip(finding2.lineSnippet || finding2.reason, 220);
3812
+ return [`${index}. ${where} [${finding2.type}]`, ` ${evidence}`];
3813
+ }
3814
+ function formatAgentFeedback(result) {
3815
+ const ranked = mostImportantFirst(result.infractions, result.failedRules);
3816
+ const blocking = ranked.filter((finding2) => result.failedRules.includes(finding2.type));
3817
+ const notes = ranked.filter((finding2) => !result.failedRules.includes(finding2.type) && isCheckRule(finding2.type));
3818
+ const lines = ["agent-roast: the uncommitted changes make the checks easier to pass instead of making the code correct.", ""];
3819
+ blocking.slice(0, LISTED).forEach((finding2, index) => lines.push(...describe(finding2, index + 1)));
3820
+ if (blocking.length > LISTED) lines.push(` \u2026 and ${blocking.length - LISTED} more.`);
3821
+ if (notes.length) {
3822
+ lines.push("", "Also worth a second look (these do not block):");
3823
+ notes.slice(0, LISTED_WARNINGS).forEach((finding2) => lines.push(`- ${describe(finding2, 0)[0].slice(3)}: ${clip(finding2.lineSnippet || finding2.reason, 120)}`));
3824
+ if (notes.length > LISTED_WARNINGS) lines.push(`- \u2026 and ${notes.length - LISTED_WARNINGS} more.`);
3825
+ }
3826
+ lines.push(
3827
+ "",
3828
+ "Make the original checks pass by fixing the code. Do not skip, delete, loosen or silence tests, assertions, coverage gates, type checks or CI steps to get a green run.",
3829
+ "If a change to a check is really intended, stop and ask the user to approve it explicitly rather than keeping it quietly."
3830
+ );
3831
+ return lines.join("\n");
3832
+ }
3833
+ function formatUserWarning(result) {
3834
+ const blocking = result.infractions.filter((finding2) => result.failedRules.includes(finding2.type));
3835
+ const first = blocking[0];
3836
+ return `agent-roast: the agent finished with ${blocking.length} change${blocking.length === 1 ? "" : "s"} that weaken${blocking.length === 1 ? "s" : ""} checks (${result.failedRules.join(", ")})${first ? `, for example ${terminalText(first.file ?? "unknown")}:${first.lineNumber ?? "?"}` : ""}. Review them before you commit: npx agent-roast review --worktree`;
3837
+ }
3838
+ async function gitDirectory(cwd, signal) {
3839
+ return fs7.realpath(gitOutputPath((await readGit(cwd, ["rev-parse", "--absolute-git-dir"], signal, 8192)).toString()));
3840
+ }
3841
+ async function hookPausedUntil(cwd, now = /* @__PURE__ */ new Date()) {
3842
+ try {
3843
+ const file = path8.join(await gitDirectory(cwd), PAUSE_FILE);
3844
+ const until = new Date((await fs7.readFile(file, "utf8")).trim());
3845
+ if (!Number.isNaN(until.getTime()) && until > now) return until;
3846
+ await fs7.rm(file, { force: true });
3847
+ } catch {
3848
+ }
3849
+ return null;
3850
+ }
3851
+ async function pauseHook(cwd, until) {
3852
+ const file = path8.join(await gitDirectory(cwd), PAUSE_FILE);
3853
+ await fs7.writeFile(file, until.toISOString() + "\n");
3854
+ return file;
3855
+ }
3856
+ async function resumeHook(cwd) {
3857
+ const file = path8.join(await gitDirectory(cwd), PAUSE_FILE);
3858
+ const existed = await fs7.stat(file).then(() => true, () => false);
3859
+ await fs7.rm(file, { force: true });
3860
+ return existed;
3861
+ }
3862
+ function parseDuration(text) {
3863
+ const match = /^(\d+)\s*([mhd])$/i.exec(text.trim());
3864
+ if (!match || Number(match[1]) < 1) throw new ConfigError("Use a duration such as 30m, 2h or 1d.");
3865
+ return Number(match[1]) * { m: 6e4, h: 36e5, d: 864e5 }[match[2].toLowerCase()];
3866
+ }
3867
+ var outcome = (action, stdout = "", stderr = "", result) => ({ action, stdout, stderr, exitCode: 0, result });
3868
+ async function runHook(rawInput, options = {}) {
3869
+ const env = options.env ?? process.env;
3870
+ let input = {};
3871
+ try {
3872
+ const parsed = rawInput.trim() ? JSON.parse(rawInput) : {};
3873
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) input = parsed;
3874
+ } catch {
3875
+ return outcome("skip", "", "agent-roast hook: input was not JSON; nothing was checked.\n");
3876
+ }
3877
+ if (["off", "0", "false"].includes((env.AGENT_ROAST_HOOK ?? "").toLowerCase())) return outcome("skip");
3878
+ const agent = options.agent ?? detectAgent(input);
3879
+ const event = String(input.hook_event_name ?? "Stop");
3880
+ if (event.toLowerCase() !== "stop") return outcome("skip");
3881
+ if (agent === "cursor" && typeof input.status === "string" && input.status !== "completed") return outcome("skip");
3882
+ const cwd = options.cwd ?? inputCwd(agent, input, env);
3883
+ if (await hookPausedUntil(cwd, options.now)) return outcome("skip");
3884
+ const controller = new AbortController();
3885
+ const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
3886
+ let result;
3887
+ try {
3888
+ result = await reviewWorktreeChanges({ cwd, signal: controller.signal });
3889
+ } catch (error) {
3890
+ const reason = error instanceof Error ? error.message : String(error);
3891
+ return outcome("skip", "", `agent-roast hook: could not check the working tree (${clip(reason, 200)}); the agent was allowed to finish.
3892
+ `);
3893
+ } finally {
3894
+ clearTimeout(timer);
3895
+ }
3896
+ if (!result.failedRules.length) return outcome("pass", agent === "cursor" ? "{}\n" : "", "", result);
3897
+ const alreadyAsked = input.stop_hook_active === true || typeof input.loop_count === "number" && input.loop_count > 0;
3898
+ if (alreadyAsked) {
3899
+ return outcome("warn", agent === "cursor" ? "{}\n" : JSON.stringify({ systemMessage: formatUserWarning(result) }) + "\n", "", result);
3900
+ }
3901
+ const message = formatAgentFeedback(result);
3902
+ return outcome("block", JSON.stringify(agent === "cursor" ? { followup_message: message } : { decision: "block", reason: message }) + "\n", "", result);
3903
+ }
3904
+
3905
+ // src/hook-install.ts
3906
+ import * as fs8 from "fs";
3907
+ import os from "os";
3908
+ import path9 from "path";
3909
+ import { randomUUID as randomUUID4 } from "crypto";
3910
+ import { createRequire as createRequire2 } from "module";
3911
+ import { spawnSync as spawnSync3 } from "child_process";
3912
+ var AGENT_LABELS = { claude: "Claude Code", codex: "Codex", cursor: "Cursor" };
3913
+ var TIMEOUT_SECONDS = 60;
3914
+ function packageVersion() {
3915
+ const require3 = createRequire2(import.meta.url);
3916
+ return String(require3("../package.json").version);
3917
+ }
3918
+ function hookCommand(agent, command = `npx --yes agent-roast@${packageVersion()}`) {
3919
+ return `${command.trim()} hook run --agent ${agent}`;
3920
+ }
3921
+ var isOurs = (command) => typeof command === "string" && /\bagent-roast\b.*\bhook\s+run\b/.test(command);
3922
+ function repositoryRoot(cwd) {
3923
+ const absolute = fs8.realpathSync(path9.resolve(cwd));
3924
+ const run = spawnSync3("git", [...HERMETIC_GIT_FLAGS, "rev-parse", "--show-toplevel"], {
3925
+ cwd: absolute,
3926
+ env: { ...getHermeticGitEnv(), LC_ALL: "C" },
3927
+ encoding: "utf8",
3928
+ timeout: 3e4,
3929
+ maxBuffer: 8192,
3930
+ windowsHide: true
3931
+ });
3932
+ return run.status === 0 ? fs8.realpathSync(gitOutputPath(run.stdout)) : absolute;
3933
+ }
3934
+ function hookConfigPath(agent, scope, root, home, env = process.env) {
3935
+ if (scope === "local" && agent !== "claude") throw new ConfigError(`${AGENT_LABELS[agent]} has no hook file that stays out of the repository; use the project file or --global.`);
3936
+ const claude = path9.join(scope === "global" ? home : root, ".claude");
3937
+ if (agent === "claude") return path9.join(claude, scope === "local" ? "settings.local.json" : "settings.json");
3938
+ if (agent === "cursor") return path9.join(scope === "global" ? home : root, ".cursor", "hooks.json");
3939
+ return path9.join(scope === "global" ? env.CODEX_HOME || path9.join(home, ".codex") : path9.join(root, ".codex"), "hooks.json");
3940
+ }
3941
+ function readJsonFile(file) {
3942
+ let raw;
3943
+ let mode;
3944
+ try {
3945
+ mode = fs8.statSync(file).mode & 511;
3946
+ raw = fs8.readFileSync(file, "utf8").replace(/^/, "");
3947
+ } catch (error) {
3948
+ if (error.code === "ENOENT") return { data: {}, indent: 2, mode: void 0, existed: false };
3949
+ throw error;
3950
+ }
3951
+ if (!raw.trim()) return { data: {}, indent: 2, mode, existed: true };
3952
+ let data;
3953
+ try {
3954
+ data = JSON.parse(raw);
3955
+ } catch {
3956
+ throw new ConfigError(`${file} is not valid JSON, so it was left alone. Fix it, or add the hook by hand (see docs/integrations.md).`);
3957
+ }
3958
+ if (!data || typeof data !== "object" || Array.isArray(data)) throw new ConfigError(`${file} must contain a JSON object, so it was left alone.`);
3959
+ const indent = /^([ \t]+)"/m.exec(raw)?.[1] ?? 2;
3960
+ return { data, indent, mode, existed: true };
3961
+ }
3962
+ function writeJsonFile(link, json) {
3963
+ const file = json.existed ? fs8.realpathSync(link) : link;
3964
+ fs8.mkdirSync(path9.dirname(file), { recursive: true });
3965
+ const temporary = path9.join(path9.dirname(file), `.agent-roast-${randomUUID4()}.tmp`);
3966
+ try {
3967
+ fs8.writeFileSync(temporary, JSON.stringify(json.data, null, json.indent) + "\n", { mode: json.mode ?? 420 });
3968
+ fs8.renameSync(temporary, file);
3969
+ } finally {
3970
+ fs8.rmSync(temporary, { force: true });
3971
+ }
3972
+ }
3973
+ var asArray = (value) => Array.isArray(value) ? value : [];
3974
+ function addStopHook(agent, data, command) {
3975
+ const before = JSON.stringify(data);
3976
+ data.hooks = data.hooks && typeof data.hooks === "object" && !Array.isArray(data.hooks) ? data.hooks : {};
3977
+ if (agent === "cursor") {
3978
+ data.version ??= 1;
3979
+ const handlers = asArray(data.hooks.stop);
3980
+ const ours = handlers.find((handler) => isOurs(handler?.command));
3981
+ if (ours) ours.command = command;
3982
+ else handlers.push({ command, timeout: TIMEOUT_SECONDS, loop_limit: 2 });
3983
+ data.hooks.stop = handlers;
3984
+ } else {
3985
+ const groups = asArray(data.hooks.Stop);
3986
+ const ours = groups.flatMap((group) => asArray(group?.hooks)).find((handler) => isOurs(handler?.command));
3987
+ if (ours) ours.command = command;
3988
+ else groups.push({ hooks: [{ type: "command", command, timeout: TIMEOUT_SECONDS }] });
3989
+ data.hooks.Stop = groups;
3990
+ }
3991
+ return JSON.stringify(data) !== before;
3992
+ }
3993
+ function removeStopHook(agent, data) {
3994
+ const before = JSON.stringify(data);
3995
+ const hooks = data.hooks;
3996
+ if (!hooks || typeof hooks !== "object") return false;
3997
+ if (agent === "cursor") {
3998
+ const kept = asArray(hooks.stop).filter((handler) => !isOurs(handler?.command));
3999
+ if (kept.length) hooks.stop = kept;
4000
+ else delete hooks.stop;
4001
+ } else {
4002
+ const groups = asArray(hooks.Stop).map((group) => ({ ...group, hooks: asArray(group?.hooks).filter((handler) => !isOurs(handler?.command)) }));
4003
+ const kept = groups.filter((group) => group.hooks.length);
4004
+ if (kept.length) hooks.Stop = kept;
4005
+ else delete hooks.Stop;
4006
+ }
4007
+ if (!Object.keys(hooks).length) delete data.hooks;
4008
+ return JSON.stringify(data) !== before;
4009
+ }
4010
+ function context(options) {
4011
+ return { root: repositoryRoot(options.cwd ?? "."), scope: options.scope ?? "project", home: options.home ?? os.homedir(), env: options.env ?? process.env };
4012
+ }
4013
+ function installHooks(options) {
4014
+ const { root, scope, home, env } = context(options);
4015
+ return options.agents.map((agent) => {
4016
+ const file = hookConfigPath(agent, scope, root, home, env);
4017
+ const command = hookCommand(agent, options.command);
4018
+ const json = readJsonFile(file);
4019
+ const changed = addStopHook(agent, json.data, command);
4020
+ if (changed && !options.dryRun) writeJsonFile(file, json);
4021
+ return { agent, scope, file, command, action: !json.existed ? "created" : changed ? "updated" : "unchanged" };
4022
+ });
4023
+ }
4024
+ function uninstallHooks(options) {
4025
+ const { root, scope, home, env } = context(options);
4026
+ return options.agents.map((agent) => {
4027
+ const file = hookConfigPath(agent, scope, root, home, env);
4028
+ const json = readJsonFile(file);
4029
+ const removed = json.existed && removeStopHook(agent, json.data);
4030
+ if (removed && !options.dryRun) writeJsonFile(file, json);
4031
+ return { agent, scope, file, action: removed ? "removed" : "absent" };
4032
+ });
4033
+ }
4034
+ function hookStatus(options) {
4035
+ const { root, home, env } = context({ ...options, scope: "project" });
4036
+ const rows = [];
4037
+ for (const agent of ["claude", "codex", "cursor"]) {
4038
+ for (const scope of ["project", "local", "global"]) {
4039
+ if (scope === "local" && agent !== "claude") continue;
4040
+ const file = hookConfigPath(agent, scope, root, home, env);
4041
+ let command;
4042
+ try {
4043
+ const data = readJsonFile(file).data;
4044
+ const handlers = agent === "cursor" ? asArray(data.hooks?.stop) : asArray(data.hooks?.Stop).flatMap((group) => asArray(group?.hooks));
4045
+ command = handlers.map((handler) => handler?.command).find(isOurs);
4046
+ } catch {
4047
+ }
4048
+ rows.push({ agent, scope, file, command, installed: Boolean(command), action: command ? "unchanged" : "absent" });
4049
+ }
4050
+ }
4051
+ return rows;
4052
+ }
4053
+ var INSTRUCTIONS_START = "<!-- agent-roast:start -->";
4054
+ var INSTRUCTIONS_END = "<!-- agent-roast:end -->";
4055
+ var INSTRUCTIONS_BLOCK = `${INSTRUCTIONS_START}
4056
+ ## Keep the checks honest
4057
+
4058
+ When a test, type check, linter or CI step fails, fix the code. Do not make the build green by skipping or deleting tests,
4059
+ weakening assertions, lowering coverage thresholds, switching off \`strict\`, or letting a CI step fail. If you think a check
4060
+ is wrong, say so and ask before changing it. Before you finish, run \`npx agent-roast review --worktree\`: it lists uncommitted
4061
+ changes that weaken the checks.
4062
+ ${INSTRUCTIONS_END}
4063
+ `;
4064
+ function installInstructions(options = {}) {
4065
+ const root = repositoryRoot(options.cwd ?? ".");
4066
+ const existing = ["AGENTS.md", "CLAUDE.md"].filter((name) => fs8.existsSync(path9.join(root, name)));
4067
+ return (existing.length ? existing : ["AGENTS.md"]).map((name) => {
4068
+ const file = path9.join(root, name);
4069
+ const exists = fs8.existsSync(file);
4070
+ const text = exists ? fs8.readFileSync(file, "utf8") : "";
4071
+ const start = text.indexOf(INSTRUCTIONS_START);
4072
+ const end = text.indexOf(INSTRUCTIONS_END);
4073
+ let next;
4074
+ if (start >= 0 && end > start) next = text.slice(0, start) + INSTRUCTIONS_BLOCK.trimEnd() + text.slice(end + INSTRUCTIONS_END.length);
4075
+ else next = text + (text && !text.endsWith("\n\n") ? text.endsWith("\n") ? "\n" : "\n\n" : "") + INSTRUCTIONS_BLOCK;
4076
+ if (next === text) return { file, action: "unchanged" };
4077
+ if (!options.dryRun) fs8.writeFileSync(file, next);
4078
+ return { file, action: exists ? "updated" : "created" };
4079
+ });
4080
+ }
4081
+
4082
+ // src/demo.ts
4083
+ import * as fs9 from "fs";
4084
+ import os2 from "os";
4085
+ import path10 from "path";
4086
+ import { spawnSync as spawnSync4 } from "child_process";
4087
+ import pc2 from "picocolors";
4088
+ var CART = `export function total(prices: number[], discount = 0): number {
4089
+ const sum = prices.reduce((a, b) => a + b, 0);
4090
+ return sum - sum * discount;
4091
+ }
4092
+ `;
4093
+ var CART_TEST = (weakened) => `import { total } from "./cart";
4094
+
4095
+ describe("cart total", () => {
4096
+ it("sums the prices", () => {
4097
+ expect(total([10, 20, 12])).${weakened ? "toBeDefined()" : "toBe(42)"};
4098
+ });
4099
+
4100
+ ${weakened ? "it.skip" : "it"}("applies a discount", () => {
4101
+ expect(total([50, 50], 0.1)).toBe(90);
4102
+ });
4103
+
4104
+ it("is zero for an empty cart", () => {
4105
+ ${weakened ? "expect(true).toBe(true)" : "expect(total([])).toBe(0)"};
4106
+ });
4107
+ });
4108
+ `;
4109
+ var VITEST_CONFIG = (lines, branches) => `import { defineConfig } from "vitest/config";
4110
+
4111
+ export default defineConfig({
4112
+ test: { coverage: { thresholds: { lines: ${lines}, branches: ${branches} } } },
4113
+ });
4114
+ `;
4115
+ var CI = (lenient) => `name: CI
4116
+ on: [push, pull_request]
4117
+ jobs:
4118
+ test:
4119
+ runs-on: ubuntu-latest
4120
+ steps:
4121
+ - uses: actions/checkout@v4
4122
+ - run: npm ci
4123
+ - name: Run tests
4124
+ ${lenient ? " continue-on-error: true\n" : ""} run: npm test
4125
+ `;
4126
+ async function runDemo(options = {}) {
4127
+ const dir = fs9.mkdtempSync(path10.join(os2.tmpdir(), "agent-roast-demo-"));
4128
+ try {
4129
+ const git2 = (...args) => {
4130
+ const run = spawnSync4(
4131
+ "git",
4132
+ [
4133
+ ...HERMETIC_GIT_FLAGS,
4134
+ "-c",
4135
+ "user.name=agent-roast demo",
4136
+ "-c",
4137
+ "user.email=demo@example.invalid",
4138
+ "-c",
4139
+ "commit.gpgsign=false",
4140
+ "-c",
4141
+ `core.hooksPath=${path10.join(dir, ".no-hooks")}`,
4142
+ ...args
4143
+ ],
4144
+ { cwd: dir, env: getHermeticGitEnv(), encoding: "utf8", timeout: 3e4, windowsHide: true }
4145
+ );
4146
+ if (run.error || run.status !== 0) throw new ConfigError(`The demo needs Git to create a temporary repository: ${run.error?.message ?? run.stderr.trim()}`);
4147
+ };
4148
+ const write = (file, text) => {
4149
+ const destination = path10.join(dir, file);
4150
+ fs9.mkdirSync(path10.dirname(destination), { recursive: true });
4151
+ fs9.writeFileSync(destination, text);
4152
+ };
4153
+ git2("init", "-q", "-b", "main");
4154
+ write("src/cart.ts", CART);
4155
+ write("src/cart.test.ts", CART_TEST(false));
4156
+ write("vitest.config.ts", VITEST_CONFIG(90, 85));
4157
+ write(".github/workflows/ci.yml", CI(false));
4158
+ git2("add", "-A");
4159
+ git2("commit", "-q", "-m", "Add cart total");
4160
+ write("src/cart.test.ts", CART_TEST(true));
4161
+ write("vitest.config.ts", VITEST_CONFIG(50, 40));
4162
+ write(".github/workflows/ci.yml", CI(true));
4163
+ const result = await reviewWorktreeChanges({ cwd: dir, signal: options.signal });
4164
+ const output = [
4165
+ "",
4166
+ pc2.bold("agent-roast demo") + pc2.dim(" \u2014 a scripted example in a temporary repository; nothing of yours is read or changed"),
4167
+ "",
4168
+ 'A build was red. The "fix" below turns it green without touching the code under test:',
4169
+ "",
4170
+ ` ${pc2.cyan("src/cart.test.ts")} toBe(42) \u2192 toBeDefined() \xB7 it.skip(\u2026) \xB7 expect(true).toBe(true)`,
4171
+ ` ${pc2.cyan("vitest.config.ts")} coverage thresholds lowered (lines 90 \u2192 50, branches 85 \u2192 40)`,
4172
+ ` ${pc2.cyan(".github/workflows/ci.yml")} continue-on-error: true on the test step`,
4173
+ "",
4174
+ "What agent-roast reports for that working tree:",
4175
+ formatReviewTerminal(result).replace(/\s+$/, ""),
4176
+ "",
4177
+ pc2.bold("Use it on your own work"),
4178
+ ` ${pc2.cyan("npx agent-roast")} review what you have not committed yet`,
4179
+ ` ${pc2.cyan("npx agent-roast replay")} replay your last 50 commits, one at a time`,
4180
+ ` ${pc2.cyan("npx agent-roast hook install")} send your coding agent back to fix the code when it weakens a check`,
4181
+ ""
4182
+ ].join("\n");
4183
+ return { exitCode: result.failedRules.length ? 0 : 1, output, result };
4184
+ } finally {
4185
+ fs9.rmSync(dir, { recursive: true, force: true });
4186
+ }
4187
+ }
4188
+
3377
4189
  // src/index.ts
3378
4190
  var InvalidDetectorError = class extends Error {
3379
4191
  constructor(message) {
@@ -3382,9 +4194,9 @@ var InvalidDetectorError = class extends Error {
3382
4194
  }
3383
4195
  };
3384
4196
  async function roastRepository(options = {}) {
3385
- const cwd = path7.resolve(options.cwd || process.cwd());
4197
+ const cwd = path11.resolve(options.cwd || process.cwd());
3386
4198
  const since = options.since || "90 days ago";
3387
- const repoName = path7.basename(cwd);
4199
+ const repoName = path11.basename(cwd);
3388
4200
  const collector = new MetricsCollector({ all: options.all, ignorePaths: options.ignorePaths, rules: options.rules, panicLoopWindowMinutes: options.panicLoopWindowMinutes });
3389
4201
  const gitStream = streamGitLog(cwd, since);
3390
4202
  await parseGitLogStream(gitStream, (commit, stats) => {
@@ -3399,7 +4211,7 @@ async function roastRepository(options = {}) {
3399
4211
  return { summary, output };
3400
4212
  }
3401
4213
  async function auditPullRequest(options) {
3402
- const cwd = path7.resolve(options.cwd || process.cwd());
4214
+ const cwd = path11.resolve(options.cwd || process.cwd());
3403
4215
  const format = options.format || "terminal";
3404
4216
  const failOn = (options.failOn || ["test-skip"]).map((s) => s.trim()).filter(Boolean);
3405
4217
  for (const det of failOn) {
@@ -3522,6 +4334,8 @@ export {
3522
4334
  generateShareUrl,
3523
4335
  formatTerminalCard,
3524
4336
  formatPrTerminalCard,
4337
+ isCheckRule,
4338
+ mostImportantFirst,
3525
4339
  formatGithubAnnotations,
3526
4340
  formatGithubStepSummary,
3527
4341
  formatPrMarkdownComment,
@@ -3543,6 +4357,29 @@ export {
3543
4357
  compareFindings,
3544
4358
  reviewChanges,
3545
4359
  reviewStagedChanges,
4360
+ reviewWorktreeChanges,
4361
+ replayHistory,
4362
+ formatReplayTerminal,
4363
+ HOOK_AGENTS,
4364
+ formatAgentFeedback,
4365
+ hookPausedUntil,
4366
+ pauseHook,
4367
+ resumeHook,
4368
+ parseDuration,
4369
+ runHook,
4370
+ AGENT_LABELS,
4371
+ packageVersion,
4372
+ hookCommand,
4373
+ repositoryRoot,
4374
+ hookConfigPath,
4375
+ installHooks,
4376
+ uninstallHooks,
4377
+ hookStatus,
4378
+ INSTRUCTIONS_START,
4379
+ INSTRUCTIONS_END,
4380
+ INSTRUCTIONS_BLOCK,
4381
+ installInstructions,
4382
+ runDemo,
3546
4383
  InvalidDetectorError,
3547
4384
  roastRepository,
3548
4385
  auditPullRequest