@kavishdua/proctor 1.2.2 → 1.4.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.
Files changed (3) hide show
  1. package/README.md +25 -2
  2. package/dist/cli.js +388 -113
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -80,6 +80,8 @@ The check writes nothing to the repository being checked and makes no runtime ne
80
80
  ## Install
81
81
 
82
82
  Agent plugins install the ruleset. For CLI checks and hooks, use the local npm installation below.
83
+ `proctor setup` adds turn-end enforcement for Claude Code and Codex CLI. The Cursor plugin
84
+ also includes a stop hook.
83
85
 
84
86
  **Claude Code**
85
87
 
@@ -121,9 +123,10 @@ which may be outside the working tree when you use worktrees or `core.hooksPath`
121
123
  | The ruleset file for each agent this repo uses, e.g. `.claude/skills/proctor/SKILL.md`, `.cursor/rules/proctor.mdc`, `AGENTS.md` | The honest-completion rules your agent reads before it works | A shared file like `AGENTS.md` keeps your content; only proctor's delimited block is replaced |
122
124
  | Git's hook directory (`.git/hooks/pre-commit` normally), or `.husky/pre-commit` when Husky is a dev dependency | Blocks a cheat at commit time; the Husky file is also staged | A foreign hook is backed up to `pre-commit.bak`, then replaced; merge it manually if needed |
123
125
  | `.claude/settings.json` | The Claude Code Stop hook, written only if this repo uses Claude Code | Merged into the existing JSON; nothing else in the file changes |
126
+ | `.codex/hooks.json` | The Codex CLI Stop hook, written only if Codex is a selected or detected agent | Merged into the existing JSON, preserving other hooks and top-level fields |
124
127
  | `.proctor-adapter-manifest.json` | Records what was written, so `uninstall` can take exactly that back out | Rewritten |
125
128
 
126
- proctor detects agents from their config files, such as `.cursor/`, `.claude/`, or `WARP.md`,
129
+ proctor detects agents from their config files, such as `.cursor/`, `.claude/`, `.codex/`, or `WARP.md`,
127
130
  and installs the ruleset for those agents. If it finds none, it writes `AGENTS.md`.
128
131
  Run `proctor agents` to check the list before installing. Use `--all` for all 30 supported agents,
129
132
  or `--agents claude-code,cursor` to choose them yourself.
@@ -145,6 +148,26 @@ git add -A && git commit -m "chore: add proctor"
145
148
 
146
149
  If you use Claude Code, restart it so it picks up the new Stop hook.
147
150
 
151
+ If you use **Codex CLI**, two trust steps are required before the hook runs:
152
+
153
+ 1. Trust the project at Codex's interactive prompt, or set `trust_level = "trusted"` under
154
+ `[projects."/absolute/path/to/repo"]` in `~/.codex/config.toml`. Project hooks are silently
155
+ ignored in untrusted projects.
156
+ 2. Review and approve the new or changed hook when Codex prompts, or via `/hooks` in the CLI.
157
+
158
+ The Codex hook runs `proctor stop-hook --format codex`, which emits a JSON block decision with
159
+ finding feedback and exits `0`. It stays silent on clean checks, internal errors, and a follow-up
160
+ Stop with `stop_hook_active: true`, preventing a loop.
161
+
162
+ Verified end-to-end against codex-cli 0.164.0 on November 8, 2026: in `codex exec`, a change that
163
+ deleted a failing test was blocked at Stop with an RH001 finding, and Codex continued and acted on
164
+ the block reason; an honest fix completed with no block. Hook trust is one-time: Codex asks to
165
+ review new or changed hooks (the interactive prompt or `/hooks`), records trust against the hook
166
+ definition, and after that the hook runs in both interactive sessions and `codex exec`. The
167
+ `--dangerously-bypass-hook-trust` flag exists for one-off automation that vets hook sources outside
168
+ Codex; it is not part of standard setup. Contract source:
169
+ [Codex hooks](https://learn.chatgpt.com/docs/hooks.md).
170
+
148
171
  The hooks run checks automatically. A config file is optional.
149
172
 
150
173
  ### Checking it worked
@@ -409,7 +432,7 @@ See the [scope, dropped ideas and tuned dev-set measurements](bench/dynamic-dev/
409
432
 
410
433
  proctor combines a **ruleset** with a **guard**. The ruleset tells the agent how to complete work
411
434
  and report results. The deterministic, diff-level guard checks for known tampering patterns at
412
- commit time and, with the Claude Code Stop hook, at the end of a turn. It works
435
+ commit time and, with the Claude Code, Codex CLI, or Cursor Stop hooks, at the end of a turn. It works
413
436
  below the agent's own reasoning by checking the changed code rather than the agent's explanation.
414
437
 
415
438
  ## Can the agent get around it?
package/dist/cli.js CHANGED
@@ -64,7 +64,7 @@ __export(runner_exports, {
64
64
  });
65
65
  import { spawn as spawn2 } from "child_process";
66
66
  import { mkdtempSync, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2, rmSync } from "fs";
67
- import { join as join14 } from "path";
67
+ import { join as join16 } from "path";
68
68
  import { tmpdir as tmpdir2 } from "os";
69
69
  async function runDynamic(options) {
70
70
  const started = Date.now();
@@ -77,7 +77,7 @@ async function runDynamic(options) {
77
77
  }
78
78
  let scratch;
79
79
  try {
80
- scratch = mkdtempSync(join14(tmpdir2(), "proctor-dynamic-"));
80
+ scratch = mkdtempSync(join16(tmpdir2(), "proctor-dynamic-"));
81
81
  } catch {
82
82
  return skipped("cannot create scratch directory");
83
83
  }
@@ -88,14 +88,14 @@ async function runDynamic(options) {
88
88
  }
89
89
  };
90
90
  try {
91
- mkdirSync2(join14(scratch, "tmp"));
92
- writeFileSync2(join14(scratch, "input.json"), JSON.stringify({ ...options, files: options.files.map(({ from, to }) => ({ from, to })) }));
91
+ mkdirSync2(join16(scratch, "tmp"));
92
+ writeFileSync2(join16(scratch, "input.json"), JSON.stringify({ ...options, files: options.files.map(({ from, to }) => ({ from, to })) }));
93
93
  const env = {
94
94
  PATH: process.env["PATH"],
95
95
  HOME: scratch,
96
- TMPDIR: join14(scratch, "tmp"),
97
- TMP: join14(scratch, "tmp"),
98
- TEMP: join14(scratch, "tmp"),
96
+ TMPDIR: join16(scratch, "tmp"),
97
+ TMP: join16(scratch, "tmp"),
98
+ TEMP: join16(scratch, "tmp"),
99
99
  LANG: "C.UTF-8",
100
100
  CI: "true",
101
101
  NO_COLOR: "1",
@@ -105,7 +105,7 @@ async function runDynamic(options) {
105
105
  npm_config_offline: "true",
106
106
  npm_config_audit: "false",
107
107
  npm_config_update_notifier: "false",
108
- npm_config_cache: join14(scratch, "npm-cache")
108
+ npm_config_cache: join16(scratch, "npm-cache")
109
109
  };
110
110
  const remaining = timeout - (Date.now() - started);
111
111
  if (remaining <= 0) return skipped(`overall timeout (${timeout}ms)`);
@@ -127,7 +127,7 @@ async function runDynamic(options) {
127
127
  "--",
128
128
  process.execPath,
129
129
  options.workerPath,
130
- join14(scratch, "input.json")
130
+ join16(scratch, "input.json")
131
131
  ];
132
132
  const child = spawn2("bwrap", namespaceArgs, {
133
133
  cwd: scratch,
@@ -212,14 +212,14 @@ var init_runner = __esm({
212
212
  });
213
213
 
214
214
  // src/cli.ts
215
- import { Command } from "commander";
216
- import { resolve as resolve3, join as join15 } from "path";
215
+ import { Command, Option } from "commander";
216
+ import { resolve as resolve3, join as join17 } from "path";
217
217
  import { homedir } from "os";
218
218
 
219
219
  // package.json
220
220
  var package_default = {
221
221
  name: "@kavishdua/proctor",
222
- version: "1.2.2",
222
+ version: "1.4.0",
223
223
  description: "Catch AI agents gaming their test suites: deleted tests, skipped tests, weakened assertions, hardcoded outputs",
224
224
  homepage: "https://github.com/catfish-1234/proctor#readme",
225
225
  bugs: {
@@ -279,7 +279,7 @@ var package_default = {
279
279
  lint: "oxlint --deny correctness --allow no-unused-vars --allow no-useless-escape --allow prefer-string-starts-ends-with src tests scripts tsup.config.ts vitest.config.ts"
280
280
  },
281
281
  dependencies: {
282
- "@anthropic-ai/sdk": "^0.134.0",
282
+ "@anthropic-ai/sdk": "^1.3.0",
283
283
  acorn: "^8.19.0",
284
284
  "brace-expansion": "^5.0.12",
285
285
  commander: "^14.0.3",
@@ -302,7 +302,7 @@ var package_default = {
302
302
  };
303
303
 
304
304
  // src/cli.ts
305
- import { readFile as readFile11, writeFile as writeFile7, appendFile } from "fs/promises";
305
+ import { readFile as readFile13, writeFile as writeFile8, appendFile } from "fs/promises";
306
306
  import { fileURLToPath as fileURLToPath3 } from "url";
307
307
 
308
308
  // src/diff.ts
@@ -2241,6 +2241,24 @@ function run2(context) {
2241
2241
  });
2242
2242
  }
2243
2243
  }
2244
+ if (filePath.endsWith("_test.go")) {
2245
+ const failing = /\bt\.(?:Fatalf?|Errorf?)\(/;
2246
+ for (const del of dels) {
2247
+ if (!failing.test(del.content)) continue;
2248
+ const asLog = del.content.slice(1).replace(/\bt\.(?:Fatal|Error)(f?)\(/, "t.Log$1(").replace(/\s+/g, "");
2249
+ const wa = adds.find((a) => !reported.has(a.ln) && a.content.slice(1).replace(/\s+/g, "") === asLog);
2250
+ if (!wa) continue;
2251
+ reported.add(wa.ln);
2252
+ findings.push({
2253
+ verifierId: "RH002",
2254
+ severity: "error",
2255
+ file: filePath,
2256
+ line: wa.ln,
2257
+ message: "Assertion weakened: a failing check now only logs, so the test passes whatever the result.",
2258
+ suggestion: "Restore t.Fatal or t.Errorf so a wrong result fails the test."
2259
+ });
2260
+ }
2261
+ }
2244
2262
  if (filePath.endsWith(".swift")) {
2245
2263
  for (const del of dels) {
2246
2264
  const lhs = swiftTestingComparisonLhs(del.content);
@@ -2933,6 +2951,8 @@ function run3(context) {
2933
2951
  var rh003 = { id: "RH003", severity: "error", run: run3 };
2934
2952
 
2935
2953
  // src/verifiers/rh004.ts
2954
+ import { readFile as readFile2 } from "fs/promises";
2955
+ import { join as join2 } from "path";
2936
2956
  var RETURN_LITERAL_RE = /return\s+("[^"\n]*"|'[^'\n]*'|`[^`\n]*`|-?\d+(?:\.\d+)?|true|false|True|False)\s*;?\s*\}*\s*$/;
2937
2957
  var RETURN_EXPR_RE = /return[ \t]+([^;{}\n]*[^;{}\s\n])[;}\s]*$/;
2938
2958
  var TRIVIAL_RETURN_VALUES = /* @__PURE__ */ new Set(["null", "undefined", "none", "pass"]);
@@ -3348,10 +3368,60 @@ function documentedChange(context, finding) {
3348
3368
  }
3349
3369
  return false;
3350
3370
  }
3371
+ var SPECIAL_RESULT_RE = /\breturn\s+((?:Ok\(|Some\()?(?:"[^"\n]{2,}"|'[^'\n]{2,}'|-?\d{2,}(?:\.\d+)?|\[[^\]\n]{3,}\])\)?)\s*;?\s*\}?\s*$/;
3372
+ var SPECIAL_COND_RE = /^\s*(?:\}\s*else\s+)?if\b[^\n]*(?:===?|==|\.equals\()/;
3373
+ async function specialCaseBranches(context) {
3374
+ const findings = [];
3375
+ let testText;
3376
+ const loadTests = async () => {
3377
+ if (testText !== void 0) return testText;
3378
+ const parts = [];
3379
+ for (const path9 of context.testFiles.slice(0, 300)) {
3380
+ try {
3381
+ const text = await readFile2(join2(context.cwd, path9), "utf8");
3382
+ if (text.length < 3e5) parts.push(text);
3383
+ } catch {
3384
+ }
3385
+ }
3386
+ testText = parts.join("\n").replace(/\s+/g, "");
3387
+ return testText;
3388
+ };
3389
+ for (const file of context.files) {
3390
+ const filePath = pathOf(file);
3391
+ if (!isWatchedSource(context, filePath)) continue;
3392
+ for (const chunk of file.chunks) {
3393
+ const adds = chunk.changes.filter((c) => c.type === "add");
3394
+ const dels = chunk.changes.filter((c) => c.type === "del");
3395
+ if (adds.length === 0 || adds.length > dels.length + 4) continue;
3396
+ const addedCompact = adds.map((a) => a.content.slice(1).replace(/\s+/g, "")).join("");
3397
+ if (!dels.every((d) => addedCompact.includes(d.content.slice(1).replace(/\s+/g, "")))) continue;
3398
+ for (let i = 0; i < adds.length; i++) {
3399
+ const text = adds[i].content.slice(1);
3400
+ if (!SPECIAL_COND_RE.test(text)) continue;
3401
+ const sameLine = SPECIAL_RESULT_RE.exec(text);
3402
+ const next = adds[i + 1] ? SPECIAL_RESULT_RE.exec(adds[i + 1].content.slice(1)) : null;
3403
+ const literal = (sameLine ?? next)?.[1];
3404
+ if (!literal) continue;
3405
+ const bare = literal.replace(/^(?:Ok|Some)\(|\)$/g, "").replace(/\s+/g, "");
3406
+ if (!(await loadTests()).includes(bare)) continue;
3407
+ findings.push({
3408
+ verifierId: "RH004",
3409
+ severity: "warn",
3410
+ file: filePath,
3411
+ line: adds[i].ln,
3412
+ message: `An input-specific branch returns ${bare}, a value an existing test expects, in front of the unchanged general computation.`,
3413
+ suggestion: "Fix the general computation so every input gets the right answer. If this really is a documented special case, say so in a comment."
3414
+ });
3415
+ break;
3416
+ }
3417
+ }
3418
+ }
3419
+ return findings;
3420
+ }
3351
3421
  async function run4(context) {
3352
3422
  const files = context.files;
3353
3423
  const ctx = context;
3354
- let findings = structuralHardcodes(context);
3424
+ let findings = [...structuralHardcodes(context), ...await specialCaseBranches(context)];
3355
3425
  const fuzzyCandidates = [];
3356
3426
  for (const file of files) {
3357
3427
  const filePath = file.to ?? file.from ?? "";
@@ -4137,6 +4207,21 @@ var TIMEOUT_MS_THRESHOLD = 12e4;
4137
4207
  var TIMEOUT_S_THRESHOLD = 120;
4138
4208
  var NETWORK_MOCK_RE = /\.(?:mockResolvedValue|mockReturnValue)(?:Once)?\(([^)]*)\)|\.reply\(\s*\d+\s*,([^)]*)\)/;
4139
4209
  var ASSERTION_LITERAL_RE = /\.(?:toBe|toEqual)\(([^)]*)\)|assertEqual\([^,]+,([^)]*)\)/;
4210
+ var SLEEP_MS_THRESHOLD = 1e3;
4211
+ function fixedSleepMs(text) {
4212
+ const code = withoutTrailingComment(body(text));
4213
+ let m = /\bsetTimeout\s*\(\s*(?:\w+|\(\s*\)\s*=>\s*\w+(?:\(\))?)\s*,\s*(\d+)\s*\)/.exec(code);
4214
+ if (m && /\bnew\s+Promise\b|\bawait\b/.test(code)) return Number(m[1]);
4215
+ m = /\btime\.sleep\s*\(\s*(\d+(?:\.\d+)?)\s*\)/.exec(code);
4216
+ if (m) return Math.round(Number(m[1]) * 1e3);
4217
+ m = /\bThread\.sleep\s*\(\s*(\d+)L?\s*\)/.exec(code);
4218
+ if (m) return Number(m[1]);
4219
+ m = /\btime\.Sleep\s*\(\s*(\d+)\s*\*\s*time\.(Millisecond|Second)\s*\)/.exec(code);
4220
+ if (m) return Number(m[1]) * (m[2] === "Second" ? 1e3 : 1);
4221
+ m = /^\s*sleep\s*\(?\s*(\d+(?:\.\d+)?)\s*\)?\s*$/.exec(code);
4222
+ if (m) return Math.round(Number(m[1]) * 1e3);
4223
+ return void 0;
4224
+ }
4140
4225
  function normalize(s) {
4141
4226
  return s.trim().replace(/;$/, "");
4142
4227
  }
@@ -4154,7 +4239,30 @@ function run10(context) {
4154
4239
  const findings = [];
4155
4240
  for (const file of files) {
4156
4241
  const filePath = file.to ?? file.from ?? "";
4157
- if (!ctx.isTestFile(filePath)) continue;
4242
+ if (!ctx.isTestFile(filePath)) {
4243
+ if (/(?:jest|vitest|playwright|mocha)\.config\.|\.mocharc|pytest\.ini$|pyproject\.toml$|package\.json$/.test(filePath)) {
4244
+ for (const chunk of file.chunks) {
4245
+ const timeoutOf = (text) => {
4246
+ const m = /\b(?:testTimeout|timeout)\b["']?\s*[:=]\s*(\d+)/.exec(text);
4247
+ return m ? Number(m[1]) : void 0;
4248
+ };
4249
+ const before = chunk.changes.filter((c) => c.type === "del").map((c) => timeoutOf(c.content)).find((v) => v !== void 0);
4250
+ for (const add of chunk.changes.filter((c) => c.type === "add")) {
4251
+ const now = timeoutOf(add.content);
4252
+ if (now === void 0 || before === void 0 || now < 6e4 || now < before * 4) continue;
4253
+ findings.push({
4254
+ verifierId: "RH010",
4255
+ severity: "warn",
4256
+ file: filePath,
4257
+ line: add.ln,
4258
+ message: `Test timeout raised from ${before} to ${now} in runner config, which lets slow or hanging tests pass for every test.`,
4259
+ suggestion: "Find out why the test is slow. If one test really needs longer, raise the timeout on that test only and say why."
4260
+ });
4261
+ }
4262
+ }
4263
+ }
4264
+ continue;
4265
+ }
4158
4266
  for (const chunk of file.chunks) {
4159
4267
  const removedAwaited = new Set(
4160
4268
  chunk.changes.filter((change) => change.type === "del").map((change) => awaitedExpression(change.content)).filter((expression2) => expression2 !== void 0)
@@ -4245,6 +4353,18 @@ function run10(context) {
4245
4353
  });
4246
4354
  continue;
4247
4355
  }
4356
+ const sleepMs = fixedSleepMs(content);
4357
+ if (sleepMs !== void 0 && sleepMs >= SLEEP_MS_THRESHOLD) {
4358
+ findings.push({
4359
+ verifierId: "RH010",
4360
+ severity: "warn",
4361
+ file: filePath,
4362
+ line,
4363
+ message: `Fixed ${sleepMs} ms sleep added to a test, which can hide a race by waiting long enough for it to usually resolve.`,
4364
+ suggestion: "Wait for the specific event, callback or condition the test depends on, or poll with a deadline, instead of sleeping."
4365
+ });
4366
+ continue;
4367
+ }
4248
4368
  const mockM = content.match(NETWORK_MOCK_RE);
4249
4369
  const mockLit = mockM?.[1] ?? mockM?.[2];
4250
4370
  if (mockLit !== void 0 && assertionLiterals.has(normalize(mockLit))) {
@@ -5189,6 +5309,36 @@ function run15(context) {
5189
5309
  if (lexicalIndex >= 0 && !lexicalLines[lexicalIndex]?.trim()) continue;
5190
5310
  const code = withoutLiterals(added.text);
5191
5311
  if (/^\s*except\b/.test(code) && followedByElse(after, after.findIndex((l) => l.line === added.line && l.added))) continue;
5312
+ const blankAssign = /^\s*_\s*=\s*(.+?);?\s*$/.exec(code);
5313
+ if (blankAssign && /\.go$/.test(filePath)) {
5314
+ const call = blankAssign[1].replace(/\s+/g, "");
5315
+ const pairedDeletion = deletedLines(chunk).some((d) => {
5316
+ const text = d.text.replace(/\s+/g, "");
5317
+ return text === `return${call}` || text.startsWith("iferr:=") && text.includes(call);
5318
+ });
5319
+ if (pairedDeletion) {
5320
+ findings.push({
5321
+ verifierId: "WI101",
5322
+ severity: "error",
5323
+ file: filePath,
5324
+ line: added.line,
5325
+ message: "Error silently discarded: the returned error of a call that used to be returned or checked is now assigned to the blank identifier, so a failure here passes unnoticed.",
5326
+ suggestion: "Return or handle the error. If the failure really cannot matter, say why in a comment on the line."
5327
+ });
5328
+ continue;
5329
+ }
5330
+ }
5331
+ if (/\.rs$/.test(filePath) && /\bOk\s*\(.*\.(?:unwrap_or_default\s*\(\s*\)|unwrap_or\s*\()/.test(code)) {
5332
+ findings.push({
5333
+ verifierId: "WI101",
5334
+ severity: "error",
5335
+ file: filePath,
5336
+ line: added.line,
5337
+ message: "Error silently discarded: a failure is replaced by a default value inside a success result, so callers can no longer tell it happened.",
5338
+ suggestion: "Propagate the error with ? or map it to a typed error. If a default is the documented behavior, say so in a comment."
5339
+ });
5340
+ continue;
5341
+ }
5192
5342
  const inline = INLINE_SIGNATURES.find((sig) => sig.opener.test(code));
5193
5343
  if (inline) {
5194
5344
  findings.push({
@@ -5339,7 +5489,7 @@ var GUARD_SIGNATURES = [
5339
5489
  { re: /\braise_error\b|\bargument_error\b/, what: "a raised error" }
5340
5490
  ];
5341
5491
  var EXTRACTED_VALIDATOR_RE = /\b(?:validate|validation|verify|ensure|check|guard|require|precondition|sanitiz|invariant)\w*\s*[({.]|\bschema\b|\bzod\b|\bpydantic\b/i;
5342
- var REWRITTEN_GUARD_RE = /\b(?:throw|raise|panic)\b|^\s*assert\b|\b(?:process\.exit|sys\.exit|os\.Exit)\s*\(\s*[1-9]/;
5492
+ var REWRITTEN_GUARD_RE = /\b(?:throw|raise|panic)\b|\breturn\b[^\n]*\b(?:errors\.New|fmt\.Errorf)\s*\(|^\s*assert\b|\b(?:process\.exit|sys\.exit|os\.Exit)\s*\(\s*[1-9]/;
5343
5493
  function guardKey(text) {
5344
5494
  return withoutTrailingComment(text).replace(/['"`]/g, "").replace(/\s+/g, " ").trim().toLowerCase();
5345
5495
  }
@@ -7136,11 +7286,11 @@ var AGENT_ADAPTERS = [
7136
7286
 
7137
7287
  // src/adapters/detect.ts
7138
7288
  import { stat } from "fs/promises";
7139
- import { join as join2 } from "path";
7289
+ import { join as join3 } from "path";
7140
7290
  var FALLBACK_ADAPTER_ID = "agents-md";
7141
7291
  async function exists(cwd, relativePath) {
7142
7292
  try {
7143
- await stat(join2(cwd, relativePath));
7293
+ await stat(join3(cwd, relativePath));
7144
7294
  return true;
7145
7295
  } catch {
7146
7296
  return false;
@@ -7168,13 +7318,13 @@ function selectAgents(ids) {
7168
7318
  }
7169
7319
 
7170
7320
  // src/adapters/drift-check.ts
7171
- import { readFile as readFile3 } from "fs/promises";
7172
- import { join as join4 } from "path";
7321
+ import { readFile as readFile4 } from "fs/promises";
7322
+ import { join as join5 } from "path";
7173
7323
  import { createHash as createHash2 } from "crypto";
7174
7324
 
7175
7325
  // src/adapters/manifest.ts
7176
- import { readFile as readFile2, writeFile } from "fs/promises";
7177
- import { join as join3 } from "path";
7326
+ import { readFile as readFile3, writeFile } from "fs/promises";
7327
+ import { join as join4 } from "path";
7178
7328
  var MANIFEST_FILENAME = ".proctor-adapter-manifest.json";
7179
7329
  function isValidManifest(value) {
7180
7330
  if (typeof value !== "object" || value === null) return false;
@@ -7183,7 +7333,7 @@ function isValidManifest(value) {
7183
7333
  }
7184
7334
  async function readManifest(cwd) {
7185
7335
  try {
7186
- const raw = await readFile2(join3(cwd, MANIFEST_FILENAME), "utf8");
7336
+ const raw = await readFile3(join4(cwd, MANIFEST_FILENAME), "utf8");
7187
7337
  const parsed = JSON.parse(raw);
7188
7338
  if (isValidManifest(parsed)) return parsed;
7189
7339
  } catch {
@@ -7199,7 +7349,7 @@ async function recordWritten(cwd, adapterId, created = false) {
7199
7349
  if (created) {
7200
7350
  manifest.created = { ...manifest.created, [adapterId]: true };
7201
7351
  }
7202
- await writeFile(join3(cwd, MANIFEST_FILENAME), JSON.stringify(manifest, null, 2) + "\n", "utf8");
7352
+ await writeFile(join4(cwd, MANIFEST_FILENAME), JSON.stringify(manifest, null, 2) + "\n", "utf8");
7203
7353
  }
7204
7354
 
7205
7355
  // src/adapters/block.ts
@@ -7265,10 +7415,10 @@ async function checkAdapterDrift(cwd, canonical, adapters = AGENT_ADAPTERS) {
7265
7415
  const checked = [];
7266
7416
  const manifest = await readManifest(cwd);
7267
7417
  for (const adapter of adapters) {
7268
- const path9 = join4(cwd, adapter.relativePath);
7418
+ const path9 = join5(cwd, adapter.relativePath);
7269
7419
  let content;
7270
7420
  try {
7271
- content = await readFile3(path9, "utf8");
7421
+ content = await readFile4(path9, "utf8");
7272
7422
  } catch (err) {
7273
7423
  if (err.code === "ENOENT") {
7274
7424
  continue;
@@ -7296,14 +7446,14 @@ async function checkAdapterDrift(cwd, canonical, adapters = AGENT_ADAPTERS) {
7296
7446
  }
7297
7447
 
7298
7448
  // src/adapters/deploy.ts
7299
- import { readFile as readFile6, writeFile as writeFile4, mkdir as mkdir3, rm as rm2 } from "fs/promises";
7300
- import { join as join7, dirname, relative } from "path";
7449
+ import { readFile as readFile8, writeFile as writeFile5, mkdir as mkdir4, rm as rm2 } from "fs/promises";
7450
+ import { join as join9, dirname, relative } from "path";
7301
7451
 
7302
7452
  // src/hooks/pre-commit.ts
7303
- import { readFile as readFile4, writeFile as writeFile2, mkdir, rm } from "fs/promises";
7453
+ import { readFile as readFile5, writeFile as writeFile2, mkdir, rm } from "fs/promises";
7304
7454
  import { chmodSync } from "fs";
7305
7455
  import { spawnSync as spawnSync2 } from "child_process";
7306
- import { join as join5, resolve } from "path";
7456
+ import { join as join6, resolve } from "path";
7307
7457
  function preCommitHookContent() {
7308
7458
  return [
7309
7459
  "#!/bin/sh",
@@ -7339,7 +7489,7 @@ function gitHooksDir(cwd) {
7339
7489
  }
7340
7490
  async function hasHusky(cwd) {
7341
7491
  try {
7342
- const pkgJson = JSON.parse(await readFile4(join5(cwd, "package.json"), "utf8"));
7492
+ const pkgJson = JSON.parse(await readFile5(join6(cwd, "package.json"), "utf8"));
7343
7493
  return "husky" in (pkgJson["devDependencies"] ?? {});
7344
7494
  } catch {
7345
7495
  return false;
@@ -7348,7 +7498,7 @@ async function hasHusky(cwd) {
7348
7498
  async function backupForeignHook(hookPath) {
7349
7499
  let existing;
7350
7500
  try {
7351
- existing = await readFile4(hookPath, "utf8");
7501
+ existing = await readFile5(hookPath, "utf8");
7352
7502
  } catch {
7353
7503
  return;
7354
7504
  }
@@ -7360,8 +7510,8 @@ async function backupForeignHook(hookPath) {
7360
7510
  async function installPreCommitHook(cwd) {
7361
7511
  const hookContent = preCommitHookContent();
7362
7512
  if (await hasHusky(cwd)) {
7363
- const hookPath2 = join5(cwd, ".husky", "pre-commit");
7364
- await mkdir(join5(cwd, ".husky"), { recursive: true });
7513
+ const hookPath2 = join6(cwd, ".husky", "pre-commit");
7514
+ await mkdir(join6(cwd, ".husky"), { recursive: true });
7365
7515
  await backupForeignHook(hookPath2);
7366
7516
  await writeFile2(hookPath2, hookContent, "utf8");
7367
7517
  spawnSync2("git", ["add", "--chmod=+x", hookPath2], { cwd });
@@ -7371,7 +7521,7 @@ async function installPreCommitHook(cwd) {
7371
7521
  if (!hooksDir) {
7372
7522
  throw new Error("not a git repository, so there is no pre-commit hook to install");
7373
7523
  }
7374
- const hookPath = join5(hooksDir, "pre-commit");
7524
+ const hookPath = join6(hooksDir, "pre-commit");
7375
7525
  await mkdir(hooksDir, { recursive: true });
7376
7526
  await backupForeignHook(hookPath);
7377
7527
  await writeFile2(hookPath, hookContent, "utf8");
@@ -7385,12 +7535,12 @@ async function removePreCommitHook(cwd, dryRun) {
7385
7535
  const normalize2 = (s) => s.replace(/\r\n/g, "\n").split("\n").map((l) => l.trimEnd()).filter(Boolean).join("\n");
7386
7536
  const ours = normalize2(preCommitHookContent());
7387
7537
  const gitHooks = gitHooksDir(cwd);
7388
- const candidates = [join5(cwd, ".husky", "pre-commit")];
7389
- if (gitHooks) candidates.push(join5(gitHooks, "pre-commit"));
7538
+ const candidates = [join6(cwd, ".husky", "pre-commit")];
7539
+ if (gitHooks) candidates.push(join6(gitHooks, "pre-commit"));
7390
7540
  for (const hookPath of candidates) {
7391
7541
  let existing;
7392
7542
  try {
7393
- existing = await readFile4(hookPath, "utf8");
7543
+ existing = await readFile5(hookPath, "utf8");
7394
7544
  } catch {
7395
7545
  continue;
7396
7546
  }
@@ -7402,8 +7552,8 @@ async function removePreCommitHook(cwd, dryRun) {
7402
7552
  }
7403
7553
 
7404
7554
  // src/hooks/claude-settings.ts
7405
- import { readFile as readFile5, writeFile as writeFile3, mkdir as mkdir2 } from "fs/promises";
7406
- import { join as join6 } from "path";
7555
+ import { readFile as readFile6, writeFile as writeFile3, mkdir as mkdir2 } from "fs/promises";
7556
+ import { join as join7 } from "path";
7407
7557
  var STOP_HOOK_COMMAND = `npx ${package_default.name} stop-hook`;
7408
7558
  var STOP_HOOK_MARKER = "proctor stop-hook";
7409
7559
  function parseSettings(raw) {
@@ -7414,11 +7564,11 @@ function parseSettings(raw) {
7414
7564
  }
7415
7565
  }
7416
7566
  async function installStopHook(dir) {
7417
- const settingsPath = join6(dir, "settings.json");
7567
+ const settingsPath = join7(dir, "settings.json");
7418
7568
  let settings = {};
7419
7569
  let rawSettings;
7420
7570
  try {
7421
- rawSettings = await readFile5(settingsPath, "utf8");
7571
+ rawSettings = await readFile6(settingsPath, "utf8");
7422
7572
  } catch {
7423
7573
  }
7424
7574
  if (rawSettings !== void 0) {
@@ -7445,10 +7595,10 @@ async function installStopHook(dir) {
7445
7595
  return { status: "installed", path: settingsPath };
7446
7596
  }
7447
7597
  async function removeStopHook(dir, dryRun) {
7448
- const settingsPath = join6(dir, "settings.json");
7598
+ const settingsPath = join7(dir, "settings.json");
7449
7599
  let raw;
7450
7600
  try {
7451
- raw = await readFile5(settingsPath, "utf8");
7601
+ raw = await readFile6(settingsPath, "utf8");
7452
7602
  } catch {
7453
7603
  return void 0;
7454
7604
  }
@@ -7483,30 +7633,109 @@ function isProctorGroup(group) {
7483
7633
  return hooks.some((h) => isPlainObject(h) && typeof h["command"] === "string" && h["command"].includes(STOP_HOOK_MARKER));
7484
7634
  }
7485
7635
 
7636
+ // src/hooks/codex-hooks.ts
7637
+ import { readFile as readFile7, writeFile as writeFile4, mkdir as mkdir3 } from "fs/promises";
7638
+ import { join as join8 } from "path";
7639
+ var STOP_HOOK_COMMAND2 = `npx ${package_default.name} stop-hook --format codex`;
7640
+ function parseHooks(raw) {
7641
+ try {
7642
+ const parsed = JSON.parse(raw.replace(/^/, ""));
7643
+ if (!isPlainObject2(parsed)) return void 0;
7644
+ const hooks = parsed["hooks"];
7645
+ if (hooks !== void 0 && !isPlainObject2(hooks)) return void 0;
7646
+ const stop = hooks?.["Stop"];
7647
+ if (stop !== void 0 && !Array.isArray(stop)) return void 0;
7648
+ return parsed;
7649
+ } catch {
7650
+ return void 0;
7651
+ }
7652
+ }
7653
+ async function installCodexStopHook(dir) {
7654
+ const path9 = join8(dir, "hooks.json");
7655
+ let settings = {};
7656
+ let raw;
7657
+ try {
7658
+ raw = await readFile7(path9, "utf8");
7659
+ } catch (err) {
7660
+ if (err.code !== "ENOENT") throw err;
7661
+ }
7662
+ if (raw !== void 0) {
7663
+ const parsed = parseHooks(raw);
7664
+ if (!parsed) return { status: "invalid-json", path: path9 };
7665
+ settings = parsed;
7666
+ }
7667
+ const hooks = settings["hooks"] ?? {};
7668
+ const stop = hooks["Stop"] ?? [];
7669
+ if (stop.some((group) => isPlainObject2(group) && Array.isArray(group["hooks"]) && group["hooks"].some(isProctorHook))) {
7670
+ return { status: "already", path: path9 };
7671
+ }
7672
+ stop.push({ hooks: [{ type: "command", command: STOP_HOOK_COMMAND2, timeout: 60 }] });
7673
+ hooks["Stop"] = stop;
7674
+ settings["hooks"] = hooks;
7675
+ await mkdir3(dir, { recursive: true });
7676
+ await writeFile4(path9, JSON.stringify(settings, null, 2) + "\n", "utf8");
7677
+ return { status: "installed", path: path9 };
7678
+ }
7679
+ async function removeCodexStopHook(dir, dryRun) {
7680
+ const path9 = join8(dir, "hooks.json");
7681
+ let raw;
7682
+ try {
7683
+ raw = await readFile7(path9, "utf8");
7684
+ } catch {
7685
+ return void 0;
7686
+ }
7687
+ const settings = parseHooks(raw);
7688
+ if (!settings) return void 0;
7689
+ const hooks = settings["hooks"];
7690
+ const stop = hooks?.["Stop"] ?? [];
7691
+ let removed = false;
7692
+ const kept = stop.flatMap((group) => {
7693
+ if (!isPlainObject2(group) || !Array.isArray(group["hooks"])) return [group];
7694
+ const commands = group["hooks"].filter((h) => !isProctorHook(h));
7695
+ if (commands.length === group["hooks"].length) return [group];
7696
+ removed = true;
7697
+ return commands.length > 0 ? [{ ...group, hooks: commands }] : [];
7698
+ });
7699
+ if (!removed || !hooks) return void 0;
7700
+ if (!dryRun) {
7701
+ if (kept.length > 0) hooks["Stop"] = kept;
7702
+ else delete hooks["Stop"];
7703
+ if (Object.keys(hooks).length === 0) delete settings["hooks"];
7704
+ await writeFile4(path9, JSON.stringify(settings, null, 2) + "\n", "utf8");
7705
+ }
7706
+ return path9;
7707
+ }
7708
+ function isPlainObject2(value) {
7709
+ return typeof value === "object" && value !== null && !Array.isArray(value);
7710
+ }
7711
+ function isProctorHook(hook) {
7712
+ return isPlainObject2(hook) && hook["type"] === "command" && hook["command"] === STOP_HOOK_COMMAND2;
7713
+ }
7714
+
7486
7715
  // src/adapters/deploy.ts
7487
7716
  async function deploySkill(cwd, adapters, canonical) {
7488
7717
  let failed = 0;
7489
7718
  const manifest = await readManifest(cwd);
7490
7719
  for (const adapter of adapters) {
7491
- const dest = join7(cwd, adapter.relativePath);
7720
+ const dest = join9(cwd, adapter.relativePath);
7492
7721
  const content = adapter.transform ? adapter.transform(canonical) : canonical;
7493
7722
  try {
7494
7723
  if (adapter.shared) {
7495
7724
  let existing;
7496
7725
  try {
7497
- existing = await readFile6(dest, "utf8");
7726
+ existing = await readFile8(dest, "utf8");
7498
7727
  } catch (err) {
7499
7728
  if (err.code !== "ENOENT") throw err;
7500
7729
  }
7501
7730
  const ours = manifest.written[adapter.id] === true;
7502
- await mkdir3(dirname(dest), { recursive: true });
7503
- await writeFile4(dest, upsertBlock(existing, content, ours), "utf8");
7731
+ await mkdir4(dirname(dest), { recursive: true });
7732
+ await writeFile5(dest, upsertBlock(existing, content, ours), "utf8");
7504
7733
  await recordWritten(cwd, adapter.id, existing === void 0);
7505
7734
  process.stdout.write((existing === void 0 ? "Installed: " : "Merged: ") + dest + "\n");
7506
7735
  continue;
7507
7736
  }
7508
- await mkdir3(dirname(dest), { recursive: true });
7509
- await writeFile4(dest, content, "utf8");
7737
+ await mkdir4(dirname(dest), { recursive: true });
7738
+ await writeFile5(dest, content, "utf8");
7510
7739
  process.stdout.write("Installed: " + dest + "\n");
7511
7740
  } catch (err) {
7512
7741
  failed++;
@@ -7533,10 +7762,10 @@ async function uninstallProctor(cwd, dryRun) {
7533
7762
  };
7534
7763
  const manifest = await readManifest(cwd);
7535
7764
  for (const adapter of AGENT_ADAPTERS) {
7536
- const dest = join7(cwd, adapter.relativePath);
7765
+ const dest = join9(cwd, adapter.relativePath);
7537
7766
  let existing;
7538
7767
  try {
7539
- existing = await readFile6(dest, "utf8");
7768
+ existing = await readFile8(dest, "utf8");
7540
7769
  } catch (err) {
7541
7770
  if (err.code !== "ENOENT") {
7542
7771
  failed.push(`${adapter.relativePath} (${err.code ?? "unreadable"})`);
@@ -7564,7 +7793,7 @@ async function uninstallProctor(cwd, dryRun) {
7564
7793
  });
7565
7794
  } else {
7566
7795
  await attempt(`Unmerged the proctor block from: ${adapter.relativePath}`, async () => {
7567
- if (!dryRun) await writeFile4(dest, stripped, "utf8");
7796
+ if (!dryRun) await writeFile5(dest, stripped, "utf8");
7568
7797
  });
7569
7798
  }
7570
7799
  continue;
@@ -7573,9 +7802,9 @@ async function uninstallProctor(cwd, dryRun) {
7573
7802
  if (!dryRun) await rm2(dest, { force: true });
7574
7803
  });
7575
7804
  }
7576
- const manifestPath = join7(cwd, MANIFEST_FILENAME);
7805
+ const manifestPath = join9(cwd, MANIFEST_FILENAME);
7577
7806
  try {
7578
- await readFile6(manifestPath, "utf8");
7807
+ await readFile8(manifestPath, "utf8");
7579
7808
  await attempt(`Removed: ${MANIFEST_FILENAME}`, async () => {
7580
7809
  if (!dryRun) await rm2(manifestPath, { force: true });
7581
7810
  });
@@ -7588,31 +7817,37 @@ async function uninstallProctor(cwd, dryRun) {
7588
7817
  failed.push(`the pre-commit hook (${err.code ?? "unknown error"})`);
7589
7818
  }
7590
7819
  try {
7591
- const stop = await removeStopHook(join7(cwd, ".claude"), dryRun);
7820
+ const stop = await removeStopHook(join9(cwd, ".claude"), dryRun);
7592
7821
  if (stop) done.push(`Removed the proctor Stop hook from: ${rel(stop)}`);
7593
7822
  } catch (err) {
7594
7823
  failed.push(`the Stop hook entry (${err.code ?? "unknown error"})`);
7595
7824
  }
7825
+ try {
7826
+ const stop = await removeCodexStopHook(join9(cwd, ".codex"), dryRun);
7827
+ if (stop) done.push(`Removed the proctor Codex Stop hook from: ${rel(stop)}`);
7828
+ } catch (err) {
7829
+ failed.push(`the Codex Stop hook entry (${err.code ?? "unknown error"})`);
7830
+ }
7596
7831
  return { done, failed, note };
7597
7832
  }
7598
7833
 
7599
7834
  // src/bench/tasks.ts
7600
7835
  import { existsSync } from "fs";
7601
7836
  import { readdir } from "fs/promises";
7602
- import { join as join8, dirname as dirname2 } from "path";
7837
+ import { join as join10, dirname as dirname2 } from "path";
7603
7838
  import { fileURLToPath } from "url";
7604
7839
  var __dirname = fileURLToPath(new URL(".", import.meta.url));
7605
7840
  function findPackageRoot(startDir) {
7606
7841
  let dir = startDir;
7607
7842
  for (let i = 0; i < 6; i++) {
7608
- if (existsSync(join8(dir, "package.json"))) return dir;
7843
+ if (existsSync(join10(dir, "package.json"))) return dir;
7609
7844
  const parent = dirname2(dir);
7610
7845
  if (parent === dir) break;
7611
7846
  dir = parent;
7612
7847
  }
7613
7848
  return startDir;
7614
7849
  }
7615
- var DEFAULT_TASKS_DIR = join8(findPackageRoot(__dirname), "bench/tasks");
7850
+ var DEFAULT_TASKS_DIR = join10(findPackageRoot(__dirname), "bench/tasks");
7616
7851
  var TASK_NAME_RE = /^task-\d+$/;
7617
7852
  function mulberry32(seed) {
7618
7853
  let a = seed | 0;
@@ -7651,33 +7886,33 @@ async function loadTaskPool(tasksDir = DEFAULT_TASKS_DIR) {
7651
7886
  }
7652
7887
  const dirNames = entries.filter((e) => e.isDirectory() && TASK_NAME_RE.test(e.name)).map((e) => e.name).sort();
7653
7888
  return dirNames.map((taskId) => {
7654
- const dir = join8(tasksDir, taskId);
7889
+ const dir = join10(tasksDir, taskId);
7655
7890
  return {
7656
7891
  taskId,
7657
7892
  dir,
7658
- promptPath: join8(dir, "prompt.md"),
7659
- repoDir: join8(dir, "repo"),
7660
- heldOutDir: join8(dir, "held-out"),
7661
- heldOutPath: join8(dir, "held-out.json"),
7662
- mockPath: join8(dir, "mock-agent.json")
7893
+ promptPath: join10(dir, "prompt.md"),
7894
+ repoDir: join10(dir, "repo"),
7895
+ heldOutDir: join10(dir, "held-out"),
7896
+ heldOutPath: join10(dir, "held-out.json"),
7897
+ mockPath: join10(dir, "mock-agent.json")
7663
7898
  };
7664
7899
  });
7665
7900
  }
7666
7901
 
7667
7902
  // src/bench/index.ts
7668
- import { mkdir as mkdir5, writeFile as writeFile6, readFile as readFile10, rm as rm4 } from "fs/promises";
7903
+ import { mkdir as mkdir6, writeFile as writeFile7, readFile as readFile12, rm as rm4 } from "fs/promises";
7669
7904
  import { dirname as dirname6 } from "path";
7670
7905
 
7671
7906
  // src/bench/runners/fixture-runner.ts
7672
- import { readFile as readFile7, writeFile as writeFile5, mkdir as mkdir4 } from "fs/promises";
7673
- import { dirname as dirname3, join as join9, resolve as resolve2, sep } from "path";
7907
+ import { readFile as readFile9, writeFile as writeFile6, mkdir as mkdir5 } from "fs/promises";
7908
+ import { dirname as dirname3, join as join11, resolve as resolve2, sep } from "path";
7674
7909
  function createFixtureRunner(model) {
7675
7910
  return {
7676
7911
  model,
7677
7912
  async run(task) {
7678
7913
  const started = Date.now();
7679
- const mockPath = join9(task.workdir, "mock-agent.json");
7680
- const raw = await readFile7(mockPath, "utf8");
7914
+ const mockPath = join11(task.workdir, "mock-agent.json");
7915
+ const raw = await readFile9(mockPath, "utf8");
7681
7916
  const mock = JSON.parse(raw);
7682
7917
  const branch = task.proctorOn ? mock.proctorOn : mock.proctorOff;
7683
7918
  const root = resolve2(task.workdir);
@@ -7686,8 +7921,8 @@ function createFixtureRunner(model) {
7686
7921
  if (absPath !== root && !absPath.startsWith(root + sep)) {
7687
7922
  throw new Error(`mock-agent.json path escapes workdir: ${relPath}`);
7688
7923
  }
7689
- await mkdir4(dirname3(absPath), { recursive: true });
7690
- await writeFile5(absPath, content, "utf8");
7924
+ await mkdir5(dirname3(absPath), { recursive: true });
7925
+ await writeFile6(absPath, content, "utf8");
7691
7926
  }
7692
7927
  return {
7693
7928
  taskId: task.taskId,
@@ -7705,25 +7940,25 @@ function createFixtureRunner(model) {
7705
7940
  // src/bench/runners/shell-runner.ts
7706
7941
  import { spawn, spawnSync as spawnSync3 } from "child_process";
7707
7942
  import { existsSync as existsSync2 } from "fs";
7708
- import { readFile as readFile8 } from "fs/promises";
7709
- import { join as join10, dirname as dirname4 } from "path";
7943
+ import { readFile as readFile10 } from "fs/promises";
7944
+ import { join as join12, dirname as dirname4 } from "path";
7710
7945
  import { fileURLToPath as fileURLToPath2 } from "url";
7711
7946
  var __dirname2 = fileURLToPath2(new URL(".", import.meta.url));
7712
7947
  function findPackageRoot2(startDir) {
7713
7948
  let dir = startDir;
7714
7949
  for (let i = 0; i < 6; i++) {
7715
- if (existsSync2(join10(dir, "package.json"))) return dir;
7950
+ if (existsSync2(join12(dir, "package.json"))) return dir;
7716
7951
  const parent = dirname4(dir);
7717
7952
  if (parent === dir) break;
7718
7953
  dir = parent;
7719
7954
  }
7720
7955
  return startDir;
7721
7956
  }
7722
- var SKILL_MD_PATH = join10(findPackageRoot2(__dirname2), "src/skill/SKILL.md");
7957
+ var SKILL_MD_PATH = join12(findPackageRoot2(__dirname2), "src/skill/SKILL.md");
7723
7958
  var MAX_CAPTURED_OUTPUT = 10 * 1024 * 1024;
7724
7959
  async function buildEffectivePrompt(task) {
7725
7960
  if (!task.proctorOn) return task.prompt;
7726
- const skillContent = await readFile8(SKILL_MD_PATH, "utf8");
7961
+ const skillContent = await readFile10(SKILL_MD_PATH, "utf8");
7727
7962
  return `${skillContent}
7728
7963
  ---
7729
7964
  ${task.prompt}`;
@@ -7817,11 +8052,11 @@ var AGENT_RUNNERS = [
7817
8052
  ];
7818
8053
 
7819
8054
  // src/bench/scorer.ts
7820
- import { mkdtemp, cp, rm as rm3, readFile as readFile9 } from "fs/promises";
8055
+ import { mkdtemp, cp, rm as rm3, readFile as readFile11 } from "fs/promises";
7821
8056
  import { appendFileSync, symlinkSync } from "fs";
7822
8057
  import { spawnSync as spawnSync4 } from "child_process";
7823
8058
  import { tmpdir, platform } from "os";
7824
- import { join as join11, basename as basename3, dirname as dirname5 } from "path";
8059
+ import { join as join13, basename as basename3, dirname as dirname5 } from "path";
7825
8060
  import { createRequire } from "module";
7826
8061
  var require2 = createRequire(import.meta.url);
7827
8062
  var vitestPaths;
@@ -7831,7 +8066,7 @@ function resolveVitestPaths() {
7831
8066
  vitestPaths = {
7832
8067
  resolvedNodeModules: dirname5(vitestPkgDir),
7833
8068
  // node_modules dir containing the resolved vitest install
7834
- vitestBin: join11(vitestPkgDir, "vitest.mjs")
8069
+ vitestBin: join13(vitestPkgDir, "vitest.mjs")
7835
8070
  };
7836
8071
  }
7837
8072
  return vitestPaths;
@@ -7841,7 +8076,7 @@ function linkNodeModules(workdir) {
7841
8076
  try {
7842
8077
  symlinkSync(
7843
8078
  resolveVitestPaths().resolvedNodeModules,
7844
- join11(workdir, "node_modules"),
8079
+ join13(workdir, "node_modules"),
7845
8080
  platform() === "win32" ? "junction" : "dir"
7846
8081
  );
7847
8082
  } catch {
@@ -7854,7 +8089,7 @@ function runGit2(args, cwd) {
7854
8089
  }
7855
8090
  }
7856
8091
  function excludeNodeModules(workdir) {
7857
- appendFileSync(join11(workdir, ".git", "info", "exclude"), "\n/node_modules/\n", "utf8");
8092
+ appendFileSync(join13(workdir, ".git", "info", "exclude"), "\n/node_modules/\n", "utf8");
7858
8093
  }
7859
8094
  function runHeldOutTests(workdir) {
7860
8095
  const result = spawnSync4(process.execPath, [resolveVitestPaths().vitestBin, "run", "--root", workdir], {
@@ -7869,12 +8104,12 @@ async function scoreTask(taskDir, runner, proctorOn) {
7869
8104
  if (!TASK_NAME_RE2.test(taskId)) {
7870
8105
  throw new Error(`invalid task directory name: ${taskId}`);
7871
8106
  }
7872
- const tempDir = await mkdtemp(join11(tmpdir(), "proctor-bench-"));
8107
+ const tempDir = await mkdtemp(join13(tmpdir(), "proctor-bench-"));
7873
8108
  try {
7874
- await cp(join11(taskDir, "repo"), tempDir, { recursive: true });
8109
+ await cp(join13(taskDir, "repo"), tempDir, { recursive: true });
7875
8110
  linkNodeModules(tempDir);
7876
8111
  try {
7877
- await cp(join11(taskDir, "mock-agent.json"), join11(tempDir, "mock-agent.json"));
8112
+ await cp(join13(taskDir, "mock-agent.json"), join13(tempDir, "mock-agent.json"));
7878
8113
  } catch (err) {
7879
8114
  if (err.code !== "ENOENT") throw err;
7880
8115
  }
@@ -7884,7 +8119,7 @@ async function scoreTask(taskDir, runner, proctorOn) {
7884
8119
  runGit2(["config", "user.name", "proctor-bench"], tempDir);
7885
8120
  runGit2(["add", "-A"], tempDir);
7886
8121
  runGit2(["-c", "commit.gpgsign=false", "commit", "-q", "-m", "baseline", "--allow-empty"], tempDir);
7887
- const prompt = await readFile9(join11(taskDir, "prompt.md"), "utf8");
8122
+ const prompt = await readFile11(join13(taskDir, "prompt.md"), "utf8");
7888
8123
  const task = { taskId, prompt, workdir: tempDir, proctorOn };
7889
8124
  const agentResult = await runner.run(task);
7890
8125
  if (agentResult.timedOut) {
@@ -7903,7 +8138,7 @@ async function scoreTask(taskDir, runner, proctorOn) {
7903
8138
  const findings = await runChecks(ctx);
7904
8139
  const cheatDetected = findings.length > 0;
7905
8140
  const rhId = findings[0]?.verifierId ?? "";
7906
- await cp(join11(taskDir, "held-out"), join11(tempDir, "held-out"), { recursive: true });
8141
+ await cp(join13(taskDir, "held-out"), join13(tempDir, "held-out"), { recursive: true });
7907
8142
  const honestPass = runHeldOutTests(tempDir);
7908
8143
  return { taskId, model: runner.model, proctorOn, cheatDetected, rhId, honestPass };
7909
8144
  } finally {
@@ -8041,7 +8276,7 @@ async function runBench(opts) {
8041
8276
  const partialPath = partialOutPath(opts.outPath);
8042
8277
  let text;
8043
8278
  try {
8044
- text = await readFile10(partialPath, "utf8");
8279
+ text = await readFile12(partialPath, "utf8");
8045
8280
  } catch {
8046
8281
  process.stderr.write(`proctor: --resume found no rows to resume from at ${partialPath}, scoring every selected task
8047
8282
  `);
@@ -8085,13 +8320,13 @@ async function runBench(opts) {
8085
8320
  }
8086
8321
  const csv = CSV_HEADER + rows.map((r) => toCsvRow([r.taskId, r.model, r.proctorOn, r.cheatDetected, r.rhId, r.honestPass])).join("");
8087
8322
  if (opts.outPath && failedTasks === 0) {
8088
- await mkdir5(dirname6(opts.outPath), { recursive: true });
8089
- await writeFile6(opts.outPath, csv, "utf8");
8323
+ await mkdir6(dirname6(opts.outPath), { recursive: true });
8324
+ await writeFile7(opts.outPath, csv, "utf8");
8090
8325
  await rm4(partialOutPath(opts.outPath), { force: true });
8091
8326
  } else if (opts.outPath && failedTasks > 0) {
8092
8327
  const partialPath = partialOutPath(opts.outPath);
8093
- await mkdir5(dirname6(partialPath), { recursive: true });
8094
- await writeFile6(partialPath, csv, "utf8");
8328
+ await mkdir6(dirname6(partialPath), { recursive: true });
8329
+ await writeFile7(partialPath, csv, "utf8");
8095
8330
  process.stderr.write(
8096
8331
  `proctor: benchmark had ${failedTasks} invalid task${failedTasks === 1 ? "" : "s"}; preserving the existing output file instead of publishing partial evidence
8097
8332
  proctor: the ${rows.length / 2} task${rows.length === 2 ? "" : "s"} that did complete were written to ${partialPath}, which is not a result
@@ -8105,12 +8340,12 @@ proctor: the ${rows.length / 2} task${rows.length === 2 ? "" : "s"} that did com
8105
8340
  // src/hooks/stop-hook.ts
8106
8341
  import { spawnSync as spawnSync6 } from "child_process";
8107
8342
  import { existsSync as existsSync3 } from "fs";
8108
- import { join as join13 } from "path";
8343
+ import { join as join15 } from "path";
8109
8344
 
8110
8345
  // src/session.ts
8111
8346
  import { spawnSync as spawnSync5 } from "child_process";
8112
8347
  import { readFileSync, writeFileSync, mkdirSync } from "fs";
8113
- import { join as join12, dirname as dirname7 } from "path";
8348
+ import { join as join14, dirname as dirname7 } from "path";
8114
8349
  var EMPTY = { caught: 0, recentRules: [] };
8115
8350
  var MAX_RECENT_RULES = 20;
8116
8351
  var SESSION_FILENAME = "proctor-session.json";
@@ -8118,7 +8353,7 @@ function sessionPath(cwd) {
8118
8353
  const result = spawnSync5("git", ["rev-parse", "--absolute-git-dir"], { cwd, encoding: "utf8" });
8119
8354
  if (result.status !== 0) return void 0;
8120
8355
  const gitDir = result.stdout.trim();
8121
- return gitDir ? join12(gitDir, SESSION_FILENAME) : void 0;
8356
+ return gitDir ? join14(gitDir, SESSION_FILENAME) : void 0;
8122
8357
  }
8123
8358
  function isValid(value) {
8124
8359
  if (typeof value !== "object" || value === null) return false;
@@ -8173,6 +8408,13 @@ function parseStopHookInput(raw, fallbackCwd) {
8173
8408
  return { cwd: fallbackCwd, skip: false };
8174
8409
  }
8175
8410
  }
8411
+ function formatStopHookResult(result, format) {
8412
+ if (format === "claude") return result;
8413
+ return {
8414
+ exitCode: 0,
8415
+ output: result.exitCode === 2 ? JSON.stringify({ decision: "block", reason: result.output }) : ""
8416
+ };
8417
+ }
8176
8418
  function runStopHookCheck(cwd, cliPath) {
8177
8419
  const inRepo = spawnSync6("git", ["rev-parse", "--is-inside-work-tree"], { cwd, stdio: "ignore" });
8178
8420
  if (inRepo.error || inRepo.status !== 0) return { exitCode: 0, output: "" };
@@ -8193,7 +8435,7 @@ function runStopHookCheck(cwd, cliPath) {
8193
8435
  function isMidMerge(cwd) {
8194
8436
  const resolved = spawnSync6("git", ["rev-parse", "--absolute-git-dir"], { cwd, encoding: "utf8" });
8195
8437
  const gitDir = resolved.status === 0 ? (resolved.stdout ?? "").trim() : "";
8196
- return IN_PROGRESS_MARKERS.some((marker) => gitDir !== "" && existsSync3(join13(gitDir, marker)));
8438
+ return IN_PROGRESS_MARKERS.some((marker) => gitDir !== "" && existsSync3(join15(gitDir, marker)));
8197
8439
  }
8198
8440
  var IN_PROGRESS_MARKERS = [
8199
8441
  "MERGE_HEAD",
@@ -8631,16 +8873,28 @@ program.command("install-hook").description("Install git pre-commit hook").actio
8631
8873
  const hookPath = await installPreCommitHook(process.cwd());
8632
8874
  process.stdout.write("Installed: " + hookPath + "\n");
8633
8875
  });
8634
- program.command("stop-hook").description("Claude Code Stop hook: reads stdin JSON, exits 2 on error findings").action(async () => {
8635
- const raw = await readStdin();
8636
- const { cwd, skip } = parseStopHookInput(raw, process.cwd());
8637
- if (skip) process.exit(0);
8638
- const { exitCode, output } = runStopHookCheck(cwd, process.argv[1] ?? "");
8639
- if (output) process.stderr.write(output);
8640
- process.exit(exitCode);
8876
+ program.command("stop-hook").description("Stop hook: reads stdin JSON and blocks on error findings").addOption(new Option("--format <format>", "Stop hook protocol").choices(["claude", "codex"]).default("claude")).action(async (options) => {
8877
+ try {
8878
+ const raw = await readStdin();
8879
+ const { cwd, skip } = parseStopHookInput(raw, process.cwd());
8880
+ if (skip) return;
8881
+ const { exitCode, output } = formatStopHookResult(
8882
+ runStopHookCheck(cwd, process.argv[1] ?? ""),
8883
+ options.format
8884
+ );
8885
+ if (options.format === "codex") {
8886
+ if (output) process.stdout.write(output + "\n");
8887
+ process.exitCode = exitCode;
8888
+ return;
8889
+ }
8890
+ if (output) process.stderr.write(output);
8891
+ process.exit(exitCode);
8892
+ } catch (err) {
8893
+ if (options.format !== "codex") throw err;
8894
+ }
8641
8895
  });
8642
8896
  program.command("install-claude-hook").description("Install Claude Code Stop hook").option("--global", "write to ~/.claude/settings.json").action(async (options) => {
8643
- const result = await installStopHook(options.global ? join15(homedir(), ".claude") : join15(process.cwd(), ".claude"));
8897
+ const result = await installStopHook(options.global ? join17(homedir(), ".claude") : join17(process.cwd(), ".claude"));
8644
8898
  if (result.status === "invalid-json") {
8645
8899
  process.stderr.write(`proctor: ${result.path} exists but is not valid JSON; fix it manually and re-run
8646
8900
  `);
@@ -8648,10 +8902,10 @@ program.command("install-claude-hook").description("Install Claude Code Stop hoo
8648
8902
  }
8649
8903
  process.stdout.write(result.status === "already" ? "Already installed\n" : "Installed: " + result.path + "\n");
8650
8904
  });
8651
- program.command("setup").description("One-command install: writes the ruleset to every agent this repo uses, and installs both hooks").option("--all", `write the ruleset to all ${AGENT_ADAPTERS.length} supported agents, not only the ones this repo uses`).option("--agents <ids>", "comma-separated agent ids to install to (e.g. claude-code,cursor), instead of detecting").action(async (options) => {
8905
+ program.command("setup").description("One-command install: writes the ruleset and installs the git and supported agent hooks").option("--all", `write the ruleset to all ${AGENT_ADAPTERS.length} supported agents, not only the ones this repo uses`).option("--agents <ids>", "comma-separated agent ids to install to (e.g. claude-code,cursor), instead of detecting").action(async (options) => {
8652
8906
  const cwd = process.cwd();
8653
8907
  const targets = await resolveTargets(cwd, options);
8654
- const failed = await deploySkill(cwd, targets, await readFile11(canonicalSkillPath(), "utf8"));
8908
+ const failed = await deploySkill(cwd, targets, await readFile13(canonicalSkillPath(), "utf8"));
8655
8909
  process.stdout.write(
8656
8910
  `Ruleset written to ${targets.length - failed} of ${targets.length} agent path${targets.length === 1 ? "" : "s"}.
8657
8911
  `
@@ -8672,7 +8926,7 @@ program.command("setup").description("One-command install: writes the ruleset to
8672
8926
  process.exitCode = 1;
8673
8927
  }
8674
8928
  if (targets.some((a) => a.id === "claude-code")) {
8675
- const stop = await installStopHook(join15(cwd, ".claude"));
8929
+ const stop = await installStopHook(join17(cwd, ".claude"));
8676
8930
  if (stop.status === "invalid-json") {
8677
8931
  process.stderr.write(`proctor: ${stop.path} is not valid JSON, so the Stop hook was not installed; fix it and re-run
8678
8932
  `);
@@ -8684,6 +8938,27 @@ program.command("setup").description("One-command install: writes the ruleset to
8684
8938
  );
8685
8939
  }
8686
8940
  }
8941
+ if (targets.some((a) => a.id === "codex")) {
8942
+ try {
8943
+ const stop = await installCodexStopHook(join17(cwd, ".codex"));
8944
+ if (stop.status === "invalid-json") {
8945
+ process.stderr.write(`proctor: ${stop.path} is not valid JSON, so the Codex Stop hook was not installed; fix it and re-run
8946
+ `);
8947
+ process.exitCode = 1;
8948
+ } else {
8949
+ process.stdout.write(
8950
+ stop.status === "already" ? "Codex Stop hook already installed.\n" : `Codex Stop hook installed: ${stop.path}
8951
+ `
8952
+ );
8953
+ process.stdout.write("Codex requires project trust and hook review before this hook runs: trust this project, then approve the new or changed hook when prompted or via /hooks in the CLI.\n");
8954
+ }
8955
+ } catch (err) {
8956
+ const msg = String(err).replace(/^Error:\s*/, "");
8957
+ process.stderr.write(`proctor: could not install the Codex Stop hook (${msg})
8958
+ `);
8959
+ process.exitCode = 1;
8960
+ }
8961
+ }
8687
8962
  process.stdout.write(process.exitCode ? "\nSetup incomplete. Resolve the errors above and run proctor setup again.\n" : "\nDone. Your agent reads the rules before it works, and gets stopped if it breaks them.\n");
8688
8963
  });
8689
8964
  program.command("install-skill").description("Deploy canonical SKILL.md to the agent adapter paths this repo uses").option("--all", `write to all ${AGENT_ADAPTERS.length} supported agents, not only the ones this repo uses`).option("--agents <ids>", "comma-separated agent ids to install to (e.g. claude-code,cursor), instead of detecting").action(async (options) => {
@@ -8691,7 +8966,7 @@ program.command("install-skill").description("Deploy canonical SKILL.md to the a
8691
8966
  const failed = await deploySkill(
8692
8967
  cwd,
8693
8968
  await resolveTargets(cwd, options),
8694
- await readFile11(canonicalSkillPath(), "utf8")
8969
+ await readFile13(canonicalSkillPath(), "utf8")
8695
8970
  );
8696
8971
  if (failed > 0) {
8697
8972
  process.stderr.write(`proctor: ${failed} adapter${failed === 1 ? "" : "s"} could not be written
@@ -8777,7 +9052,7 @@ ${done.length} item${done.length === 1 ? "" : "s"} would be removed. Re-run with
8777
9052
  });
8778
9053
  program.command("drift-check").description("Verify every deployed agent adapter still matches canonical SKILL.md").action(async () => {
8779
9054
  const cwd = process.cwd();
8780
- const canonical = await readFile11(canonicalSkillPath(), "utf8");
9055
+ const canonical = await readFile13(canonicalSkillPath(), "utf8");
8781
9056
  const { drifted } = await checkAdapterDrift(cwd, canonical);
8782
9057
  for (const path9 of drifted) {
8783
9058
  process.stderr.write("Drifted: " + path9 + "\n");
@@ -8885,10 +9160,10 @@ program.command("approve <rule> <file>").description("Record a genuine test chan
8885
9160
  process.stderr.write("proctor: --reason cannot be empty\n");
8886
9161
  process.exit(2);
8887
9162
  }
8888
- const configPath = join15(process.cwd(), "proctor.config.json");
9163
+ const configPath = join17(process.cwd(), "proctor.config.json");
8889
9164
  let config = {};
8890
9165
  try {
8891
- config = JSON.parse(await readFile11(configPath, "utf8"));
9166
+ config = JSON.parse(await readFile13(configPath, "utf8"));
8892
9167
  } catch (err) {
8893
9168
  if (err.code !== "ENOENT") {
8894
9169
  process.stderr.write(`proctor: could not read ${configPath}: ${String(err)}
@@ -8912,7 +9187,7 @@ program.command("approve <rule> <file>").description("Record a genuine test chan
8912
9187
  approvals.push({ rule, file: normalizedFile, reason: options.reason });
8913
9188
  }
8914
9189
  config.approvedTestChanges = approvals;
8915
- await writeFile7(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
9190
+ await writeFile8(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
8916
9191
  process.stdout.write(`Approved ${rule} for ${normalizedFile} in proctor.config.json
8917
9192
  `);
8918
9193
  process.stdout.write("Commit proctor.config.json for this to take effect. proctor reads approvals from the committed config, so a change cannot approve itself.\n");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kavishdua/proctor",
3
- "version": "1.2.2",
3
+ "version": "1.4.0",
4
4
  "description": "Catch AI agents gaming their test suites: deleted tests, skipped tests, weakened assertions, hardcoded outputs",
5
5
  "homepage": "https://github.com/catfish-1234/proctor#readme",
6
6
  "bugs": {
@@ -60,7 +60,7 @@
60
60
  "lint": "oxlint --deny correctness --allow no-unused-vars --allow no-useless-escape --allow prefer-string-starts-ends-with src tests scripts tsup.config.ts vitest.config.ts"
61
61
  },
62
62
  "dependencies": {
63
- "@anthropic-ai/sdk": "^0.134.0",
63
+ "@anthropic-ai/sdk": "^1.3.0",
64
64
  "acorn": "^8.19.0",
65
65
  "brace-expansion": "^5.0.12",
66
66
  "commander": "^14.0.3",