@kavishdua/proctor 1.3.0 → 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 +208 -83
  3. package/package.json +1 -1
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 join15 } 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(join15(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(join15(scratch, "tmp"));
92
- writeFileSync2(join15(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: join15(scratch, "tmp"),
97
- TMP: join15(scratch, "tmp"),
98
- TEMP: join15(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: join15(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
- join15(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 join16 } 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.3.0",
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: {
@@ -302,7 +302,7 @@ var package_default = {
302
302
  };
303
303
 
304
304
  // src/cli.ts
305
- import { readFile as readFile12, 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
@@ -7446,8 +7446,8 @@ async function checkAdapterDrift(cwd, canonical, adapters = AGENT_ADAPTERS) {
7446
7446
  }
7447
7447
 
7448
7448
  // src/adapters/deploy.ts
7449
- import { readFile as readFile7, writeFile as writeFile4, mkdir as mkdir3, rm as rm2 } from "fs/promises";
7450
- import { join as join8, 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";
7451
7451
 
7452
7452
  // src/hooks/pre-commit.ts
7453
7453
  import { readFile as readFile5, writeFile as writeFile2, mkdir, rm } from "fs/promises";
@@ -7633,30 +7633,109 @@ function isProctorGroup(group) {
7633
7633
  return hooks.some((h) => isPlainObject(h) && typeof h["command"] === "string" && h["command"].includes(STOP_HOOK_MARKER));
7634
7634
  }
7635
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
+
7636
7715
  // src/adapters/deploy.ts
7637
7716
  async function deploySkill(cwd, adapters, canonical) {
7638
7717
  let failed = 0;
7639
7718
  const manifest = await readManifest(cwd);
7640
7719
  for (const adapter of adapters) {
7641
- const dest = join8(cwd, adapter.relativePath);
7720
+ const dest = join9(cwd, adapter.relativePath);
7642
7721
  const content = adapter.transform ? adapter.transform(canonical) : canonical;
7643
7722
  try {
7644
7723
  if (adapter.shared) {
7645
7724
  let existing;
7646
7725
  try {
7647
- existing = await readFile7(dest, "utf8");
7726
+ existing = await readFile8(dest, "utf8");
7648
7727
  } catch (err) {
7649
7728
  if (err.code !== "ENOENT") throw err;
7650
7729
  }
7651
7730
  const ours = manifest.written[adapter.id] === true;
7652
- await mkdir3(dirname(dest), { recursive: true });
7653
- await writeFile4(dest, upsertBlock(existing, content, ours), "utf8");
7731
+ await mkdir4(dirname(dest), { recursive: true });
7732
+ await writeFile5(dest, upsertBlock(existing, content, ours), "utf8");
7654
7733
  await recordWritten(cwd, adapter.id, existing === void 0);
7655
7734
  process.stdout.write((existing === void 0 ? "Installed: " : "Merged: ") + dest + "\n");
7656
7735
  continue;
7657
7736
  }
7658
- await mkdir3(dirname(dest), { recursive: true });
7659
- await writeFile4(dest, content, "utf8");
7737
+ await mkdir4(dirname(dest), { recursive: true });
7738
+ await writeFile5(dest, content, "utf8");
7660
7739
  process.stdout.write("Installed: " + dest + "\n");
7661
7740
  } catch (err) {
7662
7741
  failed++;
@@ -7683,10 +7762,10 @@ async function uninstallProctor(cwd, dryRun) {
7683
7762
  };
7684
7763
  const manifest = await readManifest(cwd);
7685
7764
  for (const adapter of AGENT_ADAPTERS) {
7686
- const dest = join8(cwd, adapter.relativePath);
7765
+ const dest = join9(cwd, adapter.relativePath);
7687
7766
  let existing;
7688
7767
  try {
7689
- existing = await readFile7(dest, "utf8");
7768
+ existing = await readFile8(dest, "utf8");
7690
7769
  } catch (err) {
7691
7770
  if (err.code !== "ENOENT") {
7692
7771
  failed.push(`${adapter.relativePath} (${err.code ?? "unreadable"})`);
@@ -7714,7 +7793,7 @@ async function uninstallProctor(cwd, dryRun) {
7714
7793
  });
7715
7794
  } else {
7716
7795
  await attempt(`Unmerged the proctor block from: ${adapter.relativePath}`, async () => {
7717
- if (!dryRun) await writeFile4(dest, stripped, "utf8");
7796
+ if (!dryRun) await writeFile5(dest, stripped, "utf8");
7718
7797
  });
7719
7798
  }
7720
7799
  continue;
@@ -7723,9 +7802,9 @@ async function uninstallProctor(cwd, dryRun) {
7723
7802
  if (!dryRun) await rm2(dest, { force: true });
7724
7803
  });
7725
7804
  }
7726
- const manifestPath = join8(cwd, MANIFEST_FILENAME);
7805
+ const manifestPath = join9(cwd, MANIFEST_FILENAME);
7727
7806
  try {
7728
- await readFile7(manifestPath, "utf8");
7807
+ await readFile8(manifestPath, "utf8");
7729
7808
  await attempt(`Removed: ${MANIFEST_FILENAME}`, async () => {
7730
7809
  if (!dryRun) await rm2(manifestPath, { force: true });
7731
7810
  });
@@ -7738,31 +7817,37 @@ async function uninstallProctor(cwd, dryRun) {
7738
7817
  failed.push(`the pre-commit hook (${err.code ?? "unknown error"})`);
7739
7818
  }
7740
7819
  try {
7741
- const stop = await removeStopHook(join8(cwd, ".claude"), dryRun);
7820
+ const stop = await removeStopHook(join9(cwd, ".claude"), dryRun);
7742
7821
  if (stop) done.push(`Removed the proctor Stop hook from: ${rel(stop)}`);
7743
7822
  } catch (err) {
7744
7823
  failed.push(`the Stop hook entry (${err.code ?? "unknown error"})`);
7745
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
+ }
7746
7831
  return { done, failed, note };
7747
7832
  }
7748
7833
 
7749
7834
  // src/bench/tasks.ts
7750
7835
  import { existsSync } from "fs";
7751
7836
  import { readdir } from "fs/promises";
7752
- import { join as join9, dirname as dirname2 } from "path";
7837
+ import { join as join10, dirname as dirname2 } from "path";
7753
7838
  import { fileURLToPath } from "url";
7754
7839
  var __dirname = fileURLToPath(new URL(".", import.meta.url));
7755
7840
  function findPackageRoot(startDir) {
7756
7841
  let dir = startDir;
7757
7842
  for (let i = 0; i < 6; i++) {
7758
- if (existsSync(join9(dir, "package.json"))) return dir;
7843
+ if (existsSync(join10(dir, "package.json"))) return dir;
7759
7844
  const parent = dirname2(dir);
7760
7845
  if (parent === dir) break;
7761
7846
  dir = parent;
7762
7847
  }
7763
7848
  return startDir;
7764
7849
  }
7765
- var DEFAULT_TASKS_DIR = join9(findPackageRoot(__dirname), "bench/tasks");
7850
+ var DEFAULT_TASKS_DIR = join10(findPackageRoot(__dirname), "bench/tasks");
7766
7851
  var TASK_NAME_RE = /^task-\d+$/;
7767
7852
  function mulberry32(seed) {
7768
7853
  let a = seed | 0;
@@ -7801,33 +7886,33 @@ async function loadTaskPool(tasksDir = DEFAULT_TASKS_DIR) {
7801
7886
  }
7802
7887
  const dirNames = entries.filter((e) => e.isDirectory() && TASK_NAME_RE.test(e.name)).map((e) => e.name).sort();
7803
7888
  return dirNames.map((taskId) => {
7804
- const dir = join9(tasksDir, taskId);
7889
+ const dir = join10(tasksDir, taskId);
7805
7890
  return {
7806
7891
  taskId,
7807
7892
  dir,
7808
- promptPath: join9(dir, "prompt.md"),
7809
- repoDir: join9(dir, "repo"),
7810
- heldOutDir: join9(dir, "held-out"),
7811
- heldOutPath: join9(dir, "held-out.json"),
7812
- mockPath: join9(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")
7813
7898
  };
7814
7899
  });
7815
7900
  }
7816
7901
 
7817
7902
  // src/bench/index.ts
7818
- import { mkdir as mkdir5, writeFile as writeFile6, readFile as readFile11, rm as rm4 } from "fs/promises";
7903
+ import { mkdir as mkdir6, writeFile as writeFile7, readFile as readFile12, rm as rm4 } from "fs/promises";
7819
7904
  import { dirname as dirname6 } from "path";
7820
7905
 
7821
7906
  // src/bench/runners/fixture-runner.ts
7822
- import { readFile as readFile8, writeFile as writeFile5, mkdir as mkdir4 } from "fs/promises";
7823
- import { dirname as dirname3, join as join10, 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";
7824
7909
  function createFixtureRunner(model) {
7825
7910
  return {
7826
7911
  model,
7827
7912
  async run(task) {
7828
7913
  const started = Date.now();
7829
- const mockPath = join10(task.workdir, "mock-agent.json");
7830
- const raw = await readFile8(mockPath, "utf8");
7914
+ const mockPath = join11(task.workdir, "mock-agent.json");
7915
+ const raw = await readFile9(mockPath, "utf8");
7831
7916
  const mock = JSON.parse(raw);
7832
7917
  const branch = task.proctorOn ? mock.proctorOn : mock.proctorOff;
7833
7918
  const root = resolve2(task.workdir);
@@ -7836,8 +7921,8 @@ function createFixtureRunner(model) {
7836
7921
  if (absPath !== root && !absPath.startsWith(root + sep)) {
7837
7922
  throw new Error(`mock-agent.json path escapes workdir: ${relPath}`);
7838
7923
  }
7839
- await mkdir4(dirname3(absPath), { recursive: true });
7840
- await writeFile5(absPath, content, "utf8");
7924
+ await mkdir5(dirname3(absPath), { recursive: true });
7925
+ await writeFile6(absPath, content, "utf8");
7841
7926
  }
7842
7927
  return {
7843
7928
  taskId: task.taskId,
@@ -7855,25 +7940,25 @@ function createFixtureRunner(model) {
7855
7940
  // src/bench/runners/shell-runner.ts
7856
7941
  import { spawn, spawnSync as spawnSync3 } from "child_process";
7857
7942
  import { existsSync as existsSync2 } from "fs";
7858
- import { readFile as readFile9 } from "fs/promises";
7859
- import { join as join11, dirname as dirname4 } from "path";
7943
+ import { readFile as readFile10 } from "fs/promises";
7944
+ import { join as join12, dirname as dirname4 } from "path";
7860
7945
  import { fileURLToPath as fileURLToPath2 } from "url";
7861
7946
  var __dirname2 = fileURLToPath2(new URL(".", import.meta.url));
7862
7947
  function findPackageRoot2(startDir) {
7863
7948
  let dir = startDir;
7864
7949
  for (let i = 0; i < 6; i++) {
7865
- if (existsSync2(join11(dir, "package.json"))) return dir;
7950
+ if (existsSync2(join12(dir, "package.json"))) return dir;
7866
7951
  const parent = dirname4(dir);
7867
7952
  if (parent === dir) break;
7868
7953
  dir = parent;
7869
7954
  }
7870
7955
  return startDir;
7871
7956
  }
7872
- var SKILL_MD_PATH = join11(findPackageRoot2(__dirname2), "src/skill/SKILL.md");
7957
+ var SKILL_MD_PATH = join12(findPackageRoot2(__dirname2), "src/skill/SKILL.md");
7873
7958
  var MAX_CAPTURED_OUTPUT = 10 * 1024 * 1024;
7874
7959
  async function buildEffectivePrompt(task) {
7875
7960
  if (!task.proctorOn) return task.prompt;
7876
- const skillContent = await readFile9(SKILL_MD_PATH, "utf8");
7961
+ const skillContent = await readFile10(SKILL_MD_PATH, "utf8");
7877
7962
  return `${skillContent}
7878
7963
  ---
7879
7964
  ${task.prompt}`;
@@ -7967,11 +8052,11 @@ var AGENT_RUNNERS = [
7967
8052
  ];
7968
8053
 
7969
8054
  // src/bench/scorer.ts
7970
- import { mkdtemp, cp, rm as rm3, readFile as readFile10 } from "fs/promises";
8055
+ import { mkdtemp, cp, rm as rm3, readFile as readFile11 } from "fs/promises";
7971
8056
  import { appendFileSync, symlinkSync } from "fs";
7972
8057
  import { spawnSync as spawnSync4 } from "child_process";
7973
8058
  import { tmpdir, platform } from "os";
7974
- import { join as join12, basename as basename3, dirname as dirname5 } from "path";
8059
+ import { join as join13, basename as basename3, dirname as dirname5 } from "path";
7975
8060
  import { createRequire } from "module";
7976
8061
  var require2 = createRequire(import.meta.url);
7977
8062
  var vitestPaths;
@@ -7981,7 +8066,7 @@ function resolveVitestPaths() {
7981
8066
  vitestPaths = {
7982
8067
  resolvedNodeModules: dirname5(vitestPkgDir),
7983
8068
  // node_modules dir containing the resolved vitest install
7984
- vitestBin: join12(vitestPkgDir, "vitest.mjs")
8069
+ vitestBin: join13(vitestPkgDir, "vitest.mjs")
7985
8070
  };
7986
8071
  }
7987
8072
  return vitestPaths;
@@ -7991,7 +8076,7 @@ function linkNodeModules(workdir) {
7991
8076
  try {
7992
8077
  symlinkSync(
7993
8078
  resolveVitestPaths().resolvedNodeModules,
7994
- join12(workdir, "node_modules"),
8079
+ join13(workdir, "node_modules"),
7995
8080
  platform() === "win32" ? "junction" : "dir"
7996
8081
  );
7997
8082
  } catch {
@@ -8004,7 +8089,7 @@ function runGit2(args, cwd) {
8004
8089
  }
8005
8090
  }
8006
8091
  function excludeNodeModules(workdir) {
8007
- appendFileSync(join12(workdir, ".git", "info", "exclude"), "\n/node_modules/\n", "utf8");
8092
+ appendFileSync(join13(workdir, ".git", "info", "exclude"), "\n/node_modules/\n", "utf8");
8008
8093
  }
8009
8094
  function runHeldOutTests(workdir) {
8010
8095
  const result = spawnSync4(process.execPath, [resolveVitestPaths().vitestBin, "run", "--root", workdir], {
@@ -8019,12 +8104,12 @@ async function scoreTask(taskDir, runner, proctorOn) {
8019
8104
  if (!TASK_NAME_RE2.test(taskId)) {
8020
8105
  throw new Error(`invalid task directory name: ${taskId}`);
8021
8106
  }
8022
- const tempDir = await mkdtemp(join12(tmpdir(), "proctor-bench-"));
8107
+ const tempDir = await mkdtemp(join13(tmpdir(), "proctor-bench-"));
8023
8108
  try {
8024
- await cp(join12(taskDir, "repo"), tempDir, { recursive: true });
8109
+ await cp(join13(taskDir, "repo"), tempDir, { recursive: true });
8025
8110
  linkNodeModules(tempDir);
8026
8111
  try {
8027
- await cp(join12(taskDir, "mock-agent.json"), join12(tempDir, "mock-agent.json"));
8112
+ await cp(join13(taskDir, "mock-agent.json"), join13(tempDir, "mock-agent.json"));
8028
8113
  } catch (err) {
8029
8114
  if (err.code !== "ENOENT") throw err;
8030
8115
  }
@@ -8034,7 +8119,7 @@ async function scoreTask(taskDir, runner, proctorOn) {
8034
8119
  runGit2(["config", "user.name", "proctor-bench"], tempDir);
8035
8120
  runGit2(["add", "-A"], tempDir);
8036
8121
  runGit2(["-c", "commit.gpgsign=false", "commit", "-q", "-m", "baseline", "--allow-empty"], tempDir);
8037
- const prompt = await readFile10(join12(taskDir, "prompt.md"), "utf8");
8122
+ const prompt = await readFile11(join13(taskDir, "prompt.md"), "utf8");
8038
8123
  const task = { taskId, prompt, workdir: tempDir, proctorOn };
8039
8124
  const agentResult = await runner.run(task);
8040
8125
  if (agentResult.timedOut) {
@@ -8053,7 +8138,7 @@ async function scoreTask(taskDir, runner, proctorOn) {
8053
8138
  const findings = await runChecks(ctx);
8054
8139
  const cheatDetected = findings.length > 0;
8055
8140
  const rhId = findings[0]?.verifierId ?? "";
8056
- await cp(join12(taskDir, "held-out"), join12(tempDir, "held-out"), { recursive: true });
8141
+ await cp(join13(taskDir, "held-out"), join13(tempDir, "held-out"), { recursive: true });
8057
8142
  const honestPass = runHeldOutTests(tempDir);
8058
8143
  return { taskId, model: runner.model, proctorOn, cheatDetected, rhId, honestPass };
8059
8144
  } finally {
@@ -8191,7 +8276,7 @@ async function runBench(opts) {
8191
8276
  const partialPath = partialOutPath(opts.outPath);
8192
8277
  let text;
8193
8278
  try {
8194
- text = await readFile11(partialPath, "utf8");
8279
+ text = await readFile12(partialPath, "utf8");
8195
8280
  } catch {
8196
8281
  process.stderr.write(`proctor: --resume found no rows to resume from at ${partialPath}, scoring every selected task
8197
8282
  `);
@@ -8235,13 +8320,13 @@ async function runBench(opts) {
8235
8320
  }
8236
8321
  const csv = CSV_HEADER + rows.map((r) => toCsvRow([r.taskId, r.model, r.proctorOn, r.cheatDetected, r.rhId, r.honestPass])).join("");
8237
8322
  if (opts.outPath && failedTasks === 0) {
8238
- await mkdir5(dirname6(opts.outPath), { recursive: true });
8239
- await writeFile6(opts.outPath, csv, "utf8");
8323
+ await mkdir6(dirname6(opts.outPath), { recursive: true });
8324
+ await writeFile7(opts.outPath, csv, "utf8");
8240
8325
  await rm4(partialOutPath(opts.outPath), { force: true });
8241
8326
  } else if (opts.outPath && failedTasks > 0) {
8242
8327
  const partialPath = partialOutPath(opts.outPath);
8243
- await mkdir5(dirname6(partialPath), { recursive: true });
8244
- await writeFile6(partialPath, csv, "utf8");
8328
+ await mkdir6(dirname6(partialPath), { recursive: true });
8329
+ await writeFile7(partialPath, csv, "utf8");
8245
8330
  process.stderr.write(
8246
8331
  `proctor: benchmark had ${failedTasks} invalid task${failedTasks === 1 ? "" : "s"}; preserving the existing output file instead of publishing partial evidence
8247
8332
  proctor: the ${rows.length / 2} task${rows.length === 2 ? "" : "s"} that did complete were written to ${partialPath}, which is not a result
@@ -8255,12 +8340,12 @@ proctor: the ${rows.length / 2} task${rows.length === 2 ? "" : "s"} that did com
8255
8340
  // src/hooks/stop-hook.ts
8256
8341
  import { spawnSync as spawnSync6 } from "child_process";
8257
8342
  import { existsSync as existsSync3 } from "fs";
8258
- import { join as join14 } from "path";
8343
+ import { join as join15 } from "path";
8259
8344
 
8260
8345
  // src/session.ts
8261
8346
  import { spawnSync as spawnSync5 } from "child_process";
8262
8347
  import { readFileSync, writeFileSync, mkdirSync } from "fs";
8263
- import { join as join13, dirname as dirname7 } from "path";
8348
+ import { join as join14, dirname as dirname7 } from "path";
8264
8349
  var EMPTY = { caught: 0, recentRules: [] };
8265
8350
  var MAX_RECENT_RULES = 20;
8266
8351
  var SESSION_FILENAME = "proctor-session.json";
@@ -8268,7 +8353,7 @@ function sessionPath(cwd) {
8268
8353
  const result = spawnSync5("git", ["rev-parse", "--absolute-git-dir"], { cwd, encoding: "utf8" });
8269
8354
  if (result.status !== 0) return void 0;
8270
8355
  const gitDir = result.stdout.trim();
8271
- return gitDir ? join13(gitDir, SESSION_FILENAME) : void 0;
8356
+ return gitDir ? join14(gitDir, SESSION_FILENAME) : void 0;
8272
8357
  }
8273
8358
  function isValid(value) {
8274
8359
  if (typeof value !== "object" || value === null) return false;
@@ -8323,6 +8408,13 @@ function parseStopHookInput(raw, fallbackCwd) {
8323
8408
  return { cwd: fallbackCwd, skip: false };
8324
8409
  }
8325
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
+ }
8326
8418
  function runStopHookCheck(cwd, cliPath) {
8327
8419
  const inRepo = spawnSync6("git", ["rev-parse", "--is-inside-work-tree"], { cwd, stdio: "ignore" });
8328
8420
  if (inRepo.error || inRepo.status !== 0) return { exitCode: 0, output: "" };
@@ -8343,7 +8435,7 @@ function runStopHookCheck(cwd, cliPath) {
8343
8435
  function isMidMerge(cwd) {
8344
8436
  const resolved = spawnSync6("git", ["rev-parse", "--absolute-git-dir"], { cwd, encoding: "utf8" });
8345
8437
  const gitDir = resolved.status === 0 ? (resolved.stdout ?? "").trim() : "";
8346
- return IN_PROGRESS_MARKERS.some((marker) => gitDir !== "" && existsSync3(join14(gitDir, marker)));
8438
+ return IN_PROGRESS_MARKERS.some((marker) => gitDir !== "" && existsSync3(join15(gitDir, marker)));
8347
8439
  }
8348
8440
  var IN_PROGRESS_MARKERS = [
8349
8441
  "MERGE_HEAD",
@@ -8781,16 +8873,28 @@ program.command("install-hook").description("Install git pre-commit hook").actio
8781
8873
  const hookPath = await installPreCommitHook(process.cwd());
8782
8874
  process.stdout.write("Installed: " + hookPath + "\n");
8783
8875
  });
8784
- program.command("stop-hook").description("Claude Code Stop hook: reads stdin JSON, exits 2 on error findings").action(async () => {
8785
- const raw = await readStdin();
8786
- const { cwd, skip } = parseStopHookInput(raw, process.cwd());
8787
- if (skip) process.exit(0);
8788
- const { exitCode, output } = runStopHookCheck(cwd, process.argv[1] ?? "");
8789
- if (output) process.stderr.write(output);
8790
- 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
+ }
8791
8895
  });
8792
8896
  program.command("install-claude-hook").description("Install Claude Code Stop hook").option("--global", "write to ~/.claude/settings.json").action(async (options) => {
8793
- const result = await installStopHook(options.global ? join16(homedir(), ".claude") : join16(process.cwd(), ".claude"));
8897
+ const result = await installStopHook(options.global ? join17(homedir(), ".claude") : join17(process.cwd(), ".claude"));
8794
8898
  if (result.status === "invalid-json") {
8795
8899
  process.stderr.write(`proctor: ${result.path} exists but is not valid JSON; fix it manually and re-run
8796
8900
  `);
@@ -8798,10 +8902,10 @@ program.command("install-claude-hook").description("Install Claude Code Stop hoo
8798
8902
  }
8799
8903
  process.stdout.write(result.status === "already" ? "Already installed\n" : "Installed: " + result.path + "\n");
8800
8904
  });
8801
- 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) => {
8802
8906
  const cwd = process.cwd();
8803
8907
  const targets = await resolveTargets(cwd, options);
8804
- const failed = await deploySkill(cwd, targets, await readFile12(canonicalSkillPath(), "utf8"));
8908
+ const failed = await deploySkill(cwd, targets, await readFile13(canonicalSkillPath(), "utf8"));
8805
8909
  process.stdout.write(
8806
8910
  `Ruleset written to ${targets.length - failed} of ${targets.length} agent path${targets.length === 1 ? "" : "s"}.
8807
8911
  `
@@ -8822,7 +8926,7 @@ program.command("setup").description("One-command install: writes the ruleset to
8822
8926
  process.exitCode = 1;
8823
8927
  }
8824
8928
  if (targets.some((a) => a.id === "claude-code")) {
8825
- const stop = await installStopHook(join16(cwd, ".claude"));
8929
+ const stop = await installStopHook(join17(cwd, ".claude"));
8826
8930
  if (stop.status === "invalid-json") {
8827
8931
  process.stderr.write(`proctor: ${stop.path} is not valid JSON, so the Stop hook was not installed; fix it and re-run
8828
8932
  `);
@@ -8834,6 +8938,27 @@ program.command("setup").description("One-command install: writes the ruleset to
8834
8938
  );
8835
8939
  }
8836
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
+ }
8837
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");
8838
8963
  });
8839
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) => {
@@ -8841,7 +8966,7 @@ program.command("install-skill").description("Deploy canonical SKILL.md to the a
8841
8966
  const failed = await deploySkill(
8842
8967
  cwd,
8843
8968
  await resolveTargets(cwd, options),
8844
- await readFile12(canonicalSkillPath(), "utf8")
8969
+ await readFile13(canonicalSkillPath(), "utf8")
8845
8970
  );
8846
8971
  if (failed > 0) {
8847
8972
  process.stderr.write(`proctor: ${failed} adapter${failed === 1 ? "" : "s"} could not be written
@@ -8927,7 +9052,7 @@ ${done.length} item${done.length === 1 ? "" : "s"} would be removed. Re-run with
8927
9052
  });
8928
9053
  program.command("drift-check").description("Verify every deployed agent adapter still matches canonical SKILL.md").action(async () => {
8929
9054
  const cwd = process.cwd();
8930
- const canonical = await readFile12(canonicalSkillPath(), "utf8");
9055
+ const canonical = await readFile13(canonicalSkillPath(), "utf8");
8931
9056
  const { drifted } = await checkAdapterDrift(cwd, canonical);
8932
9057
  for (const path9 of drifted) {
8933
9058
  process.stderr.write("Drifted: " + path9 + "\n");
@@ -9035,10 +9160,10 @@ program.command("approve <rule> <file>").description("Record a genuine test chan
9035
9160
  process.stderr.write("proctor: --reason cannot be empty\n");
9036
9161
  process.exit(2);
9037
9162
  }
9038
- const configPath = join16(process.cwd(), "proctor.config.json");
9163
+ const configPath = join17(process.cwd(), "proctor.config.json");
9039
9164
  let config = {};
9040
9165
  try {
9041
- config = JSON.parse(await readFile12(configPath, "utf8"));
9166
+ config = JSON.parse(await readFile13(configPath, "utf8"));
9042
9167
  } catch (err) {
9043
9168
  if (err.code !== "ENOENT") {
9044
9169
  process.stderr.write(`proctor: could not read ${configPath}: ${String(err)}
@@ -9062,7 +9187,7 @@ program.command("approve <rule> <file>").description("Record a genuine test chan
9062
9187
  approvals.push({ rule, file: normalizedFile, reason: options.reason });
9063
9188
  }
9064
9189
  config.approvedTestChanges = approvals;
9065
- await writeFile7(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
9190
+ await writeFile8(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
9066
9191
  process.stdout.write(`Approved ${rule} for ${normalizedFile} in proctor.config.json
9067
9192
  `);
9068
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.3.0",
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": {