@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.
- package/README.md +25 -2
- package/dist/cli.js +388 -113
- 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
|
|
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
|
|
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(
|
|
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(
|
|
92
|
-
writeFileSync2(
|
|
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:
|
|
97
|
-
TMP:
|
|
98
|
-
TEMP:
|
|
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:
|
|
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
|
-
|
|
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
|
|
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.
|
|
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": "^
|
|
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
|
|
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))
|
|
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
|
|
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(
|
|
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
|
|
7172
|
-
import { join as
|
|
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
|
|
7177
|
-
import { join as
|
|
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
|
|
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(
|
|
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 =
|
|
7418
|
+
const path9 = join5(cwd, adapter.relativePath);
|
|
7269
7419
|
let content;
|
|
7270
7420
|
try {
|
|
7271
|
-
content = await
|
|
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
|
|
7300
|
-
import { join as
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
7364
|
-
await mkdir(
|
|
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 =
|
|
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 = [
|
|
7389
|
-
if (gitHooks) candidates.push(
|
|
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
|
|
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
|
|
7406
|
-
import { join as
|
|
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 =
|
|
7567
|
+
const settingsPath = join7(dir, "settings.json");
|
|
7418
7568
|
let settings = {};
|
|
7419
7569
|
let rawSettings;
|
|
7420
7570
|
try {
|
|
7421
|
-
rawSettings = await
|
|
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 =
|
|
7598
|
+
const settingsPath = join7(dir, "settings.json");
|
|
7449
7599
|
let raw;
|
|
7450
7600
|
try {
|
|
7451
|
-
raw = await
|
|
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 =
|
|
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
|
|
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
|
|
7503
|
-
await
|
|
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
|
|
7509
|
-
await
|
|
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 =
|
|
7765
|
+
const dest = join9(cwd, adapter.relativePath);
|
|
7537
7766
|
let existing;
|
|
7538
7767
|
try {
|
|
7539
|
-
existing = await
|
|
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
|
|
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 =
|
|
7805
|
+
const manifestPath = join9(cwd, MANIFEST_FILENAME);
|
|
7577
7806
|
try {
|
|
7578
|
-
await
|
|
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(
|
|
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
|
|
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(
|
|
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 =
|
|
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 =
|
|
7889
|
+
const dir = join10(tasksDir, taskId);
|
|
7655
7890
|
return {
|
|
7656
7891
|
taskId,
|
|
7657
7892
|
dir,
|
|
7658
|
-
promptPath:
|
|
7659
|
-
repoDir:
|
|
7660
|
-
heldOutDir:
|
|
7661
|
-
heldOutPath:
|
|
7662
|
-
mockPath:
|
|
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
|
|
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
|
|
7673
|
-
import { dirname as dirname3, join as
|
|
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 =
|
|
7680
|
-
const raw = await
|
|
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
|
|
7690
|
-
await
|
|
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
|
|
7709
|
-
import { join as
|
|
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(
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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(
|
|
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(
|
|
8107
|
+
const tempDir = await mkdtemp(join13(tmpdir(), "proctor-bench-"));
|
|
7873
8108
|
try {
|
|
7874
|
-
await cp(
|
|
8109
|
+
await cp(join13(taskDir, "repo"), tempDir, { recursive: true });
|
|
7875
8110
|
linkNodeModules(tempDir);
|
|
7876
8111
|
try {
|
|
7877
|
-
await cp(
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
8089
|
-
await
|
|
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
|
|
8094
|
-
await
|
|
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
|
|
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
|
|
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 ?
|
|
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(
|
|
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("
|
|
8635
|
-
|
|
8636
|
-
|
|
8637
|
-
|
|
8638
|
-
|
|
8639
|
-
|
|
8640
|
-
|
|
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 ?
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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 =
|
|
9163
|
+
const configPath = join17(process.cwd(), "proctor.config.json");
|
|
8889
9164
|
let config = {};
|
|
8890
9165
|
try {
|
|
8891
|
-
config = JSON.parse(await
|
|
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
|
|
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.
|
|
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": "^
|
|
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",
|