agent-roast 0.11.0 → 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 path8 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) {
@@ -1183,6 +1183,98 @@ function isGuardedSkip(lines) {
1183
1183
  }
1184
1184
  return false;
1185
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
+ ] }
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
+ }
1186
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;
1187
1279
  function isConditionalSkip(source, raw, open2) {
1188
1280
  if (source[open2] !== "(") return false;
@@ -1268,7 +1360,8 @@ var CODE_EXTENSIONS = /* @__PURE__ */ new Set([
1268
1360
  ".rb",
1269
1361
  ".cs",
1270
1362
  ".cpp",
1271
- ".c"
1363
+ ".c",
1364
+ ".swift"
1272
1365
  ]);
1273
1366
  var IGNORED_PATH_REGEX = /(?:^|\/)(?:fixtures|__fixtures__|testdata|mocks|test_data|__snapshots__|sample[s]?)\//i;
1274
1367
  var TEST_PATH_REGEX = /(?:^|\/)test_[^/]+\.py$|(?:^|\/)(?:tests?|__tests__|specs?)\/|[._-](?:test|spec)\.[a-z0-9]+$/i;
@@ -1304,6 +1397,7 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
1304
1397
  const directives = javascript ? JS_COMMENT_DIRECTIVE_PATTERNS : python ? PYTHON_COMMENT_DIRECTIVE_PATTERNS : [];
1305
1398
  const stubs = javascript ? JS_STUB_PATTERNS : python ? PYTHON_STUB_PATTERNS : rust ? RUST_STUB_PATTERNS : [];
1306
1399
  const firstFinding = infractions.length;
1400
+ const languageRules = LANGUAGE_LINE_RULES.find((entry) => entry.files.test(file.path))?.rules;
1307
1401
  const masker = new SourceMasker(file.path);
1308
1402
  const maskedLines = [];
1309
1403
  for (const added of file.addedLines) {
@@ -1331,6 +1425,14 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
1331
1425
  }
1332
1426
  }
1333
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
+ }
1334
1436
  let foundEscape = false;
1335
1437
  for (const rule of directives) {
1336
1438
  if (rule.regex.test(masked.comments)) {
@@ -2146,12 +2248,15 @@ function formatCheckMarkdown(result) {
2146
2248
  for (const skipped of result.skippedFiles) lines.push("", `Not scanned: ${markdownCell(skipped.file)} \u2014 ${markdownCell(skipped.reason)}`);
2147
2249
  return lines.join("\n");
2148
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);
2149
2254
  function formatReviewTerminal(result) {
2150
2255
  const status = !result.complete ? "INCOMPLETE" : result.passed ? "PASSED" : "BLOCKED";
2151
2256
  const lines = [
2152
2257
  "",
2153
- pc.bold(`agent-roast \u2014 ${result.source === "index" ? "staged context review" : "context review"}: ${status}`),
2154
- ` ${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)}`,
2155
2260
  ` ${result.filesInspected} changed files compared \xB7 ${result.linesInspected} source lines inspected across both versions`,
2156
2261
  ` ${result.infractions.length} new \xB7 ${result.unchangedCount} existing \xB7 ${result.removedFindings.length} no longer detected`
2157
2262
  ];
@@ -2159,25 +2264,26 @@ function formatReviewTerminal(result) {
2159
2264
  if (result.policyChanges.length) lines.push(" Candidate policy changes were ignored; adopt them separately after review.");
2160
2265
  if (result.excludedFiles || result.unsupportedFiles) lines.push(` ${result.excludedFiles} excluded \xB7 ${result.unsupportedFiles} unsupported/non-source changes`);
2161
2266
  for (const finding2 of mostImportantFirst(result.infractions, result.failedRules).slice(0, 20)) {
2267
+ const failing = result.failedRules.includes(finding2.type);
2162
2268
  lines.push(
2163
2269
  "",
2164
- ` ${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}`),
2165
2271
  ` ${terminalText(finding2.lineSnippet || finding2.reason)}`,
2166
- ` Next: ${terminalText(finding2.suggestion || "Review this occurrence.")}`
2272
+ pc.dim(` Next: ${terminalText(finding2.suggestion || "Review this occurrence.")}`)
2167
2273
  );
2168
2274
  }
2169
2275
  if (result.infractions.length > 20) lines.push(` ${result.infractions.length - 20} more new findings; use --format json for all results.`);
2170
2276
  for (const skipped of result.skippedFiles) lines.push(` NOT COMPARED ${terminalText(skipped.file)}: ${terminalText(skipped.reason)}`);
2171
- 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(", ")}.`), "");
2172
2278
  if (!result.filesInspected) lines.push(" No supported changed source files compared.", "");
2173
2279
  return lines.join("\n");
2174
2280
  }
2175
2281
  function formatReviewMarkdown(result) {
2176
2282
  const status = !result.complete ? "INCOMPLETE" : result.passed ? "PASSED" : "BLOCKED";
2177
2283
  const lines = [
2178
- `## agent-roast ${result.source === "index" ? "staged context review" : "context review"}: ${status}`,
2284
+ `## agent-roast ${reviewTitle(result)}: ${status}`,
2179
2285
  "",
2180
- `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)}\`.`,
2181
2287
  "",
2182
2288
  `**${result.infractions.length} new** \xB7 ${result.unchangedCount} existing \xB7 ${result.removedFindings.length} no longer detected.`,
2183
2289
  ""
@@ -2257,9 +2363,9 @@ function createNewFile(root, relative2, content, mode = 420) {
2257
2363
  let directory = root;
2258
2364
  for (const segment of path3.dirname(inside).split(path3.sep).filter((part) => part !== ".")) {
2259
2365
  directory = path3.join(directory, segment);
2260
- let stat;
2366
+ let stat3;
2261
2367
  try {
2262
- stat = fs4.lstatSync(directory);
2368
+ stat3 = fs4.lstatSync(directory);
2263
2369
  } catch (error) {
2264
2370
  if (error.code !== "ENOENT") throw error;
2265
2371
  try {
@@ -2267,9 +2373,9 @@ function createNewFile(root, relative2, content, mode = 420) {
2267
2373
  } catch (mkdirError) {
2268
2374
  if (mkdirError.code !== "EEXIST") throw mkdirError;
2269
2375
  }
2270
- stat = fs4.lstatSync(directory);
2376
+ stat3 = fs4.lstatSync(directory);
2271
2377
  }
2272
- 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.");
2273
2379
  }
2274
2380
  const temporary = path3.join(directory, `.agent-roast-init-${randomUUID3()}.tmp`);
2275
2381
  let fd;
@@ -2522,14 +2628,15 @@ import { constants as constants2 } from "fs";
2522
2628
  import path5 from "path";
2523
2629
 
2524
2630
  // src/snapshot.ts
2525
- import { createHash as createHash2 } from "crypto";
2631
+ import { createHash as createHash2, randomBytes } from "crypto";
2526
2632
  import * as fs5 from "fs/promises";
2633
+ import { constants as fsConstants } from "fs";
2527
2634
  import path4 from "path";
2528
2635
  import { spawn as spawn2 } from "child_process";
2529
2636
  function assertNotAborted(signal) {
2530
2637
  if (signal?.aborted) throw new ConfigError("Review cancelled; no complete result was produced.");
2531
2638
  }
2532
- async function readGit(cwd, args, signal, limit = 8 * 1024 * 1024, indexFile) {
2639
+ async function readGit(cwd, args, signal, limit = 8 * 1024 * 1024, indexFile, input) {
2533
2640
  assertNotAborted(signal);
2534
2641
  const env = getHermeticGitEnv({ GIT_NO_REPLACE_OBJECTS: "1", GIT_NO_LAZY_FETCH: "1", GIT_TERMINAL_PROMPT: "0" });
2535
2642
  if (indexFile) env.GIT_INDEX_FILE = indexFile;
@@ -2537,9 +2644,12 @@ async function readGit(cwd, args, signal, limit = 8 * 1024 * 1024, indexFile) {
2537
2644
  const child = spawn2("git", [...HERMETIC_GIT_FLAGS, ...args], {
2538
2645
  cwd,
2539
2646
  env,
2540
- stdio: ["ignore", "pipe", "pipe"],
2647
+ stdio: ["pipe", "pipe", "pipe"],
2541
2648
  windowsHide: true
2542
2649
  });
2650
+ child.stdin.on("error", () => {
2651
+ });
2652
+ child.stdin.end(input);
2543
2653
  const chunks = [];
2544
2654
  let bytes = 0;
2545
2655
  let stderr = "";
@@ -2659,35 +2769,36 @@ async function reviewPolicy(cwd, baseSha, signal) {
2659
2769
  }
2660
2770
  return { config: configFromText("{}", "default review policy"), filename: null };
2661
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
+ }
2662
2787
  async function stagedSnapshot(cwd, signal) {
2663
2788
  await requireLocalObjects2(cwd, signal);
2664
2789
  let indexFile;
2665
2790
  if (process.env.GIT_INDEX_FILE) {
2666
- 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()));
2667
2792
  const requested = path4.resolve(cwd, process.env.GIT_INDEX_FILE);
2668
- const stat = await fs5.lstat(requested);
2793
+ const stat3 = await fs5.lstat(requested);
2669
2794
  const resolved = await fs5.realpath(requested);
2670
- const relative2 = path4.relative(gitDirectory, resolved);
2671
- 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)) {
2672
2797
  throw new ConfigError("GIT_INDEX_FILE must be a regular candidate index inside this repository's Git directory; refusing to review another index.");
2673
2798
  }
2674
2799
  indexFile = resolved;
2675
2800
  }
2676
- const head = async () => {
2677
- try {
2678
- return (await readGit(cwd, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], signal, 1024)).toString().trim();
2679
- } catch (error) {
2680
- if (!(error instanceof GitError && error.code === 1)) throw error;
2681
- const ref = (await readGit(cwd, ["symbolic-ref", "--quiet", "HEAD"], signal, 1024)).toString().trim();
2682
- try {
2683
- await readGit(cwd, ["show-ref", "--verify", "--quiet", ref], signal, 1024);
2684
- } catch (missing) {
2685
- if (missing instanceof GitError && missing.code === 1) return "EMPTY";
2686
- throw missing;
2687
- }
2688
- throw new ConfigError("HEAD exists but cannot be resolved to a commit.");
2689
- }
2690
- };
2801
+ const head = () => resolveHead(cwd, signal);
2691
2802
  const baseSha = await head();
2692
2803
  const inventory = () => readGit(cwd, [
2693
2804
  "diff",
@@ -2724,6 +2835,99 @@ async function stagedSnapshot(cwd, signal) {
2724
2835
  }
2725
2836
  };
2726
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
+ }
2727
2931
 
2728
2932
  // src/check.ts
2729
2933
  var MAX_FILE_BYTES = 2 * 1024 * 1024;
@@ -2742,21 +2946,21 @@ function insideRoot(root, filename) {
2742
2946
  async function readSource(root, file, signal) {
2743
2947
  const filename = path5.resolve(root, file);
2744
2948
  if (!insideRoot(root, filename)) throw new Error("File escapes the repository.");
2745
- let stat;
2949
+ let stat3;
2746
2950
  try {
2747
- stat = await fs6.lstat(filename);
2951
+ stat3 = await fs6.lstat(filename);
2748
2952
  } catch (error) {
2749
2953
  if (error.code === "ENOENT") return null;
2750
2954
  throw error;
2751
2955
  }
2752
- 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.");
2753
2957
  const real = await fs6.realpath(filename);
2754
2958
  if (!insideRoot(root, real)) throw new Error("File resolves outside the repository.");
2755
- 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.");
2756
2960
  const handle = await fs6.open(filename, constants2.O_RDONLY | (constants2.O_NOFOLLOW || 0) | (constants2.O_NONBLOCK || 0));
2757
2961
  try {
2758
2962
  const opened = await handle.stat();
2759
- 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.");
2760
2964
  const chunks = [];
2761
2965
  let size = 0;
2762
2966
  while (true) {
@@ -3291,28 +3495,38 @@ function compareFindings(before, after, newPath) {
3291
3495
  return { introduced, removed: [...old.values()].flatMap((bucket) => bucket.findings.slice(bucket.matched)), unchanged };
3292
3496
  }
3293
3497
  function reviewChanges(options) {
3294
- return compareSourceChanges({ ...options, staged: false });
3498
+ return compareSourceChanges({ ...options, source: "commits" });
3295
3499
  }
3296
3500
  function reviewStagedChanges(options = {}) {
3297
- return compareSourceChanges({ ...options, staged: true });
3501
+ return compareSourceChanges({ ...options, source: "index" });
3502
+ }
3503
+ function reviewWorktreeChanges(options = {}) {
3504
+ return compareSourceChanges({ ...options, source: "worktree" });
3298
3505
  }
3299
3506
  async function compareSourceChanges(options) {
3300
3507
  assertNotAborted(options.signal);
3301
3508
  const cwd = path6.resolve(options.cwd || process.cwd());
3302
- const staged = options.staged ? await stagedSnapshot(cwd, options.signal) : void 0;
3303
- 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 };
3304
3518
  const policy = refs.baseSha === "EMPTY" ? { config: configFromText("{}", "default first-commit policy"), filename: null } : await reviewPolicy(cwd, refs.baseSha, options.signal);
3305
3519
  const format = options.format ?? "terminal";
3306
3520
  if (!["terminal", "json", "github", "markdown", "sarif"].includes(format)) throw new ConfigError("Unsupported review format.");
3307
3521
  const failOn = options.failOn ?? policy.config.failOn ?? ["test-skip", "trivial-assert", "test-focus", "assertion-weakened", "guard-weakened"];
3308
3522
  for (const rule of failOn) if (!VALID_DETECTORS.includes(rule)) throw new ConfigError(`Unknown review rule '${rule}'.`);
3309
- 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);
3310
3524
  const policyChanges = changes.filter((change) => CONFIG_FILENAMES.includes(change.oldPath) || CONFIG_FILENAMES.includes(change.newPath)).map((change) => change.newPath);
3311
3525
  const ignores = [...GENERATED_PATHS2, ...policy.config.ignorePaths || []];
3312
3526
  const result = {
3313
- schemaVersion: "1.1.0",
3527
+ schemaVersion: "1.2.0",
3314
3528
  mode: "review",
3315
- source: staged ? "index" : "commits",
3529
+ source: options.source,
3316
3530
  ...refs,
3317
3531
  policySha: refs.baseSha,
3318
3532
  policyFile: policy.filename,
@@ -3340,15 +3554,26 @@ async function compareSourceChanges(options) {
3340
3554
  const guardFindings = [];
3341
3555
  const ciCounts = [];
3342
3556
  let productionChanged = false;
3343
- const readText = async (mode, oid) => {
3344
- if (mode === "000000") return null;
3345
- if (!["100644", "100755"].includes(mode)) throw new ConfigError("Changed source is a symlink, submodule or unsupported Git mode; comparison is incomplete.");
3346
- const content = await readBlob(cwd, oid, options.signal);
3557
+ const decode = (content) => {
3347
3558
  totalBytes += content.length;
3348
3559
  if (totalBytes > MAX_SCAN_BYTES2) throw new ConfigError("Review exceeds the 50 MiB source comparison limit.");
3349
3560
  if (content.includes(0)) throw new ConfigError("Binary content in a source comparison.");
3350
3561
  return new TextDecoder("utf-8", { fatal: true }).decode(content);
3351
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
+ };
3352
3577
  const existingCounts = /* @__PURE__ */ new Map();
3353
3578
  for (const change of changes) {
3354
3579
  assertNotAborted(options.signal);
@@ -3362,7 +3587,7 @@ async function compareSourceChanges(options) {
3362
3587
  }
3363
3588
  try {
3364
3589
  const oldText = await readText(change.oldMode, change.oldOid);
3365
- const newText = await readText(change.newMode, change.newOid);
3590
+ const newText = await readNew(change);
3366
3591
  guardFindings.push(...analyzeGuardFile(change.newPath, oldText, newText, refs.headSha));
3367
3592
  const ci = ciTestSteps(change.newPath, oldText, newText);
3368
3593
  if (ci) ciCounts.push(ci);
@@ -3393,7 +3618,7 @@ async function compareSourceChanges(options) {
3393
3618
  throw new ConfigError("Source moved to an unsupported extension; cannot claim its findings were resolved.");
3394
3619
  }
3395
3620
  const oldText = await readText(change.oldMode, change.oldOid);
3396
- const newText = await readText(change.newMode, change.newOid);
3621
+ const newText = await readNew(change);
3397
3622
  const before = scan(oldText, change.oldPath, refs.mergeBaseSha);
3398
3623
  const after = scan(newText, change.newPath, refs.headSha);
3399
3624
  if (isTestFile(change.oldPath) || isTestFile(change.newPath)) tamperingFiles.push(analyzeTestFile(change.oldPath, change.newPath, oldText, newText, refs.headSha));
@@ -3420,7 +3645,7 @@ async function compareSourceChanges(options) {
3420
3645
  }
3421
3646
  }
3422
3647
  assertNotAborted(options.signal);
3423
- await staged?.verify();
3648
+ await snapshot?.verify();
3424
3649
  result.complete = result.skippedFiles.length === 0;
3425
3650
  const rules = policy.config.rules ?? {};
3426
3651
  result.infractions.push(...[...resolveTampering(tamperingFiles, productionChanged), ...guardFindings, ...resolveCiTestSteps(ciCounts, refs.headSha)].filter((finding2) => rules[finding2.type] !== "off"));
@@ -3449,10 +3674,10 @@ var plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
3449
3674
  function clipEvidence(text, limit = 200) {
3450
3675
  if (text.length <= limit) return text;
3451
3676
  const arrow = text.indexOf(" \u2192 ");
3452
- const clip = (part, size) => part.length > size ? `${part.slice(0, size - 1)}\u2026` : part;
3453
- if (arrow < 0) return clip(text, limit);
3677
+ const clip2 = (part, size) => part.length > size ? `${part.slice(0, size - 1)}\u2026` : part;
3678
+ if (arrow < 0) return clip2(text, limit);
3454
3679
  const side = Math.floor((limit - 3) / 2);
3455
- return `${clip(text.slice(0, arrow), side)} \u2192 ${clip(text.slice(arrow + 3), side)}`;
3680
+ return `${clip2(text.slice(0, arrow), side)} \u2192 ${clip2(text.slice(arrow + 3), side)}`;
3456
3681
  }
3457
3682
  async function replayHistory(options = {}) {
3458
3683
  assertNotAborted(options.signal);
@@ -3558,6 +3783,409 @@ function formatReplayTerminal(result, allRules = false) {
3558
3783
  return lines.join("\n");
3559
3784
  }
3560
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
+
3561
4189
  // src/index.ts
3562
4190
  var InvalidDetectorError = class extends Error {
3563
4191
  constructor(message) {
@@ -3566,9 +4194,9 @@ var InvalidDetectorError = class extends Error {
3566
4194
  }
3567
4195
  };
3568
4196
  async function roastRepository(options = {}) {
3569
- const cwd = path8.resolve(options.cwd || process.cwd());
4197
+ const cwd = path11.resolve(options.cwd || process.cwd());
3570
4198
  const since = options.since || "90 days ago";
3571
- const repoName = path8.basename(cwd);
4199
+ const repoName = path11.basename(cwd);
3572
4200
  const collector = new MetricsCollector({ all: options.all, ignorePaths: options.ignorePaths, rules: options.rules, panicLoopWindowMinutes: options.panicLoopWindowMinutes });
3573
4201
  const gitStream = streamGitLog(cwd, since);
3574
4202
  await parseGitLogStream(gitStream, (commit, stats) => {
@@ -3583,7 +4211,7 @@ async function roastRepository(options = {}) {
3583
4211
  return { summary, output };
3584
4212
  }
3585
4213
  async function auditPullRequest(options) {
3586
- const cwd = path8.resolve(options.cwd || process.cwd());
4214
+ const cwd = path11.resolve(options.cwd || process.cwd());
3587
4215
  const format = options.format || "terminal";
3588
4216
  const failOn = (options.failOn || ["test-skip"]).map((s) => s.trim()).filter(Boolean);
3589
4217
  for (const det of failOn) {
@@ -3729,8 +4357,29 @@ export {
3729
4357
  compareFindings,
3730
4358
  reviewChanges,
3731
4359
  reviewStagedChanges,
4360
+ reviewWorktreeChanges,
3732
4361
  replayHistory,
3733
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,
3734
4383
  InvalidDetectorError,
3735
4384
  roastRepository,
3736
4385
  auditPullRequest