self-bench 0.3.0 → 0.3.2

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 (66) hide show
  1. package/.dockerignore +10 -0
  2. package/Dockerfile +37 -0
  3. package/Dockerfile.sandbox +24 -0
  4. package/README.md +34 -26
  5. package/biome.json +18 -0
  6. package/bun.lock +1182 -0
  7. package/compose.yaml +85 -0
  8. package/dist/agent-smoke-main.js +1 -1
  9. package/dist/build-metadata.d.ts +2 -0
  10. package/dist/build-metadata.d.ts.map +1 -0
  11. package/dist/build-metadata.js +2 -0
  12. package/dist/build-metadata.js.map +1 -0
  13. package/dist/cli.js +18 -8
  14. package/dist/cli.js.map +1 -1
  15. package/dist/eval-main.js +1 -1
  16. package/dist/reaudit-main.js +1 -1
  17. package/dist/repair-main.js +1 -1
  18. package/dist/validate-main.js +1 -1
  19. package/docs/evaluations.md +67 -0
  20. package/docs/operations.md +178 -0
  21. package/docs/task-construction.md +94 -0
  22. package/package.json +28 -15
  23. package/scripts/verify-package.ts +57 -0
  24. package/scripts/write-build-metadata.ts +27 -0
  25. package/src/activities.ts +1236 -0
  26. package/src/agent-smoke-main.ts +63 -0
  27. package/src/agent-smoke.ts +132 -0
  28. package/src/api-main.ts +12 -0
  29. package/src/api.ts +239 -0
  30. package/src/artifacts.ts +361 -0
  31. package/src/audit.ts +106 -0
  32. package/src/build-metadata.ts +3 -0
  33. package/src/cli.ts +350 -0
  34. package/src/codex-review.ts +220 -0
  35. package/src/config.ts +117 -0
  36. package/src/contracts.ts +209 -0
  37. package/src/coupling.ts +259 -0
  38. package/src/docker-executor.ts +115 -0
  39. package/src/eval-main.ts +92 -0
  40. package/src/evaluate.ts +293 -0
  41. package/src/github.ts +26 -0
  42. package/src/harbor-results.ts +142 -0
  43. package/src/harbor-task.ts +528 -0
  44. package/src/hash.ts +5 -0
  45. package/src/modal-auth.ts +11 -0
  46. package/src/modal-executor.ts +176 -0
  47. package/src/parallel.ts +24 -0
  48. package/src/process.ts +165 -0
  49. package/src/provenance.ts +458 -0
  50. package/src/reaudit-main.ts +192 -0
  51. package/src/repair-main.ts +203 -0
  52. package/src/repair.ts +55 -0
  53. package/src/run-wait.ts +40 -0
  54. package/src/sandbox-author.ts +19 -0
  55. package/src/sandbox-repair.ts +160 -0
  56. package/src/sandbox-review.ts +17 -0
  57. package/src/sandbox-validation-repair.ts +174 -0
  58. package/src/sandbox.ts +51 -0
  59. package/src/subscription-auth.ts +67 -0
  60. package/src/temporal.ts +23 -0
  61. package/src/validate-main.ts +171 -0
  62. package/src/validation-repair.ts +94 -0
  63. package/src/worker-main.ts +32 -0
  64. package/src/workflow.ts +519 -0
  65. package/tsconfig.build.json +13 -0
  66. package/tsconfig.json +21 -0
@@ -0,0 +1,458 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { basename, join } from "node:path";
3
+ import { assertPullRequestBelongsToRepository, githubRepository } from "./github.js";
4
+ import { runCommand } from "./process.js";
5
+
6
+ export type SessionProvenanceFormat = "codex" | "claude-code" | "pi" | "generic";
7
+ export type ProvenanceFormat = SessionProvenanceFormat | "github-pull-request";
8
+
9
+ interface ProvenanceMessageBase {
10
+ readonly sessionId: string;
11
+ readonly messageIndex: number;
12
+ readonly content: string;
13
+ }
14
+
15
+ export type ProvenanceMessage =
16
+ | (ProvenanceMessageBase & { readonly sourceType: SessionProvenanceFormat })
17
+ | (ProvenanceMessageBase & {
18
+ readonly sourceType: "github-pull-request";
19
+ readonly sourcePr: number;
20
+ readonly sourceUrl: string;
21
+ });
22
+
23
+ interface JsonRecord {
24
+ readonly [key: string]: unknown;
25
+ }
26
+
27
+ const SOURCE_ROOTS: readonly [SessionProvenanceFormat, string][] = [
28
+ ["pi", ".pi/agent/sessions"],
29
+ ["claude-code", ".claude/projects"],
30
+ ["codex", ".codex/sessions"],
31
+ ["codex", ".codex/archived_sessions"],
32
+ ];
33
+
34
+ const GITHUB_PULL_REQUEST_LIMIT = 500;
35
+ const MAX_GITHUB_BODY_LENGTH = 12_000;
36
+
37
+ const INJECTED_PREFIXES = [
38
+ "# AGENTS.md instructions",
39
+ "# Review Guidelines",
40
+ "<environment_context>",
41
+ "<permissions instructions>",
42
+ "<collaboration_mode>",
43
+ "<skills_instructions>",
44
+ "<apps_instructions>",
45
+ "<plugins_instructions>",
46
+ "<skill name=",
47
+ "Base directory for this skill:",
48
+ "## Memory",
49
+ ] as const;
50
+
51
+ export async function collectRepositoryProvenance(
52
+ repositoryPath: string,
53
+ homeDirectory: string,
54
+ ): Promise<ProvenanceMessage[]> {
55
+ const worktrees = await repositoryWorktrees(repositoryPath);
56
+ const encodedWorktrees = worktrees.flatMap((path) => [path, encodeSessionPath(path)]);
57
+ const messages: ProvenanceMessage[] = [];
58
+
59
+ for (const [format, relativeRoot] of SOURCE_ROOTS) {
60
+ const root = join(homeDirectory, relativeRoot);
61
+ for (const path of await listJsonFiles(root)) {
62
+ const raw = await readFile(path, "utf8").catch(() => undefined);
63
+ if (
64
+ !raw ||
65
+ !encodedWorktrees.some((needle) => raw.includes(needle) || path.includes(needle))
66
+ ) {
67
+ continue;
68
+ }
69
+ messages.push(...extractProvenanceMessages(raw, format, basename(path)));
70
+ }
71
+ }
72
+
73
+ return deduplicateMessages(messages);
74
+ }
75
+
76
+ export async function collectGitHubPullRequestProvenance(
77
+ repositoryUrl: string,
78
+ ): Promise<ProvenanceMessage[]> {
79
+ const repository = githubRepository(repositoryUrl);
80
+ const result = await runCommand("gh", [
81
+ "pr",
82
+ "list",
83
+ "--repo",
84
+ repository,
85
+ "--state",
86
+ "merged",
87
+ "--limit",
88
+ String(GITHUB_PULL_REQUEST_LIMIT),
89
+ "--json",
90
+ "number,title,body,url,author,isDraft,additions,deletions,changedFiles",
91
+ ]);
92
+ return extractGitHubPullRequestProvenance(result.stdout, repositoryUrl);
93
+ }
94
+
95
+ export function extractGitHubPullRequestProvenance(
96
+ raw: string,
97
+ repositoryUrl: string,
98
+ ): ProvenanceMessage[] {
99
+ const parsed: unknown = JSON.parse(raw);
100
+ if (!Array.isArray(parsed)) {
101
+ throw new Error("GitHub pull request response must be an array");
102
+ }
103
+ const repository = githubRepository(repositoryUrl);
104
+ const messages: ProvenanceMessage[] = [];
105
+ for (const value of parsed) {
106
+ if (!isRecord(value) || value.isDraft === true || !isHumanAuthor(value.author)) {
107
+ continue;
108
+ }
109
+ const sourcePr = positiveIntegerValue(value.number);
110
+ const sourceUrl = typeof value.url === "string" ? value.url : "";
111
+ const title = typeof value.title === "string" ? value.title.trim() : "";
112
+ const body = typeof value.body === "string" ? value.body.trim() : "";
113
+ const changedLines = nonnegativeNumber(value.additions) + nonnegativeNumber(value.deletions);
114
+ const changedFiles = nonnegativeNumber(value.changedFiles);
115
+ if (!sourcePr || !sourceUrl || !title || changedLines < 20 || changedFiles < 1) {
116
+ continue;
117
+ }
118
+ assertPullRequestBelongsToRepository(repositoryUrl, sourceUrl, sourcePr);
119
+ const content = redactSecrets(
120
+ body && body.length <= MAX_GITHUB_BODY_LENGTH ? `${title}\n\n${body}` : title,
121
+ );
122
+ messages.push({
123
+ sourceType: "github-pull-request",
124
+ sessionId: `github:${repository}#${sourcePr}`,
125
+ messageIndex: 0,
126
+ content,
127
+ sourcePr,
128
+ sourceUrl,
129
+ });
130
+ }
131
+ return messages;
132
+ }
133
+
134
+ export function assertProvenanceMatchesPullRequest(
135
+ message: ProvenanceMessage,
136
+ sourcePr: number,
137
+ sourceUrl: string,
138
+ ): void {
139
+ if (
140
+ message.sourceType === "github-pull-request" &&
141
+ (message.sourcePr !== sourcePr || message.sourceUrl !== sourceUrl)
142
+ ) {
143
+ throw new Error(
144
+ `pull request ${sourceUrl}#${sourcePr} does not match provenance ${message.sourceUrl}#${message.sourcePr}`,
145
+ );
146
+ }
147
+ }
148
+
149
+ export function extractProvenanceMessages(
150
+ raw: string,
151
+ format: SessionProvenanceFormat | "auto" = "auto",
152
+ fallbackSessionId = "unknown",
153
+ ): ProvenanceMessage[] {
154
+ const records = readRecords(raw);
155
+ const resolved = format === "auto" ? detectFormat(records) : format;
156
+ const sessionId = findSessionId(records, resolved) ?? fallbackSessionId;
157
+ const messages = extractTrace(records, resolved);
158
+ let messageIndex = 0;
159
+ const result: ProvenanceMessage[] = [];
160
+ for (const [role, rawContent] of messages) {
161
+ if (role !== "user") {
162
+ continue;
163
+ }
164
+ const content = redactSecrets(rawContent.trim());
165
+ if (!content || looksInjected(content)) {
166
+ continue;
167
+ }
168
+ result.push({ sourceType: resolved, sessionId, messageIndex, content });
169
+ messageIndex += 1;
170
+ }
171
+ return result;
172
+ }
173
+
174
+ function readRecords(raw: string): JsonRecord[] {
175
+ try {
176
+ const parsed: unknown = JSON.parse(raw);
177
+ if (Array.isArray(parsed)) {
178
+ return parsed.filter(isRecord);
179
+ }
180
+ if (isRecord(parsed)) {
181
+ const messages = parsed.messages;
182
+ return Array.isArray(messages) ? messages.filter(isRecord) : [parsed];
183
+ }
184
+ return [];
185
+ } catch {
186
+ return raw
187
+ .split("\n")
188
+ .filter((line) => line.trim())
189
+ .map((line) => {
190
+ try {
191
+ return JSON.parse(line) as unknown;
192
+ } catch {
193
+ return undefined;
194
+ }
195
+ })
196
+ .filter(isRecord);
197
+ }
198
+ }
199
+
200
+ function detectFormat(records: readonly JsonRecord[]): SessionProvenanceFormat {
201
+ if (
202
+ records.some((record) =>
203
+ ["event_msg", "response_item", "session_meta"].includes(String(record.type)),
204
+ )
205
+ ) {
206
+ return "codex";
207
+ }
208
+ if (
209
+ records.some(
210
+ (record) => "sessionId" in record && ["user", "assistant"].includes(String(record.type)),
211
+ )
212
+ ) {
213
+ return "claude-code";
214
+ }
215
+ if (records.some((record) => record.type === "message" && "parentId" in record)) {
216
+ return "pi";
217
+ }
218
+ return "generic";
219
+ }
220
+
221
+ function extractTrace(
222
+ records: readonly JsonRecord[],
223
+ format: SessionProvenanceFormat,
224
+ ): readonly ["user" | "assistant", string][] {
225
+ switch (format) {
226
+ case "codex":
227
+ return codexTrace(records);
228
+ case "claude-code":
229
+ return claudeTrace(records);
230
+ case "pi":
231
+ return nestedMessageTrace(records);
232
+ case "generic":
233
+ return genericTrace(records);
234
+ }
235
+ }
236
+
237
+ function codexTrace(records: readonly JsonRecord[]): readonly ["user" | "assistant", string][] {
238
+ const eventMessages: ["user" | "assistant", string][] = [];
239
+ for (const record of records) {
240
+ if (record.type !== "event_msg" || !isRecord(record.payload)) {
241
+ continue;
242
+ }
243
+ const role =
244
+ record.payload.type === "user_message"
245
+ ? "user"
246
+ : record.payload.type === "agent_message"
247
+ ? "assistant"
248
+ : undefined;
249
+ if (role && typeof record.payload.message === "string") {
250
+ eventMessages.push([role, record.payload.message]);
251
+ }
252
+ }
253
+ if (eventMessages.some(([role]) => role === "user")) {
254
+ return eventMessages;
255
+ }
256
+
257
+ const messages: ["user" | "assistant", string][] = [];
258
+ for (const record of records) {
259
+ if (
260
+ record.type !== "response_item" ||
261
+ !isRecord(record.payload) ||
262
+ record.payload.type !== "message"
263
+ ) {
264
+ continue;
265
+ }
266
+ const role = normalizeRole(record.payload.role);
267
+ const content = contentText(record.payload.content);
268
+ if (role && content) {
269
+ messages.push([role, content]);
270
+ }
271
+ }
272
+ return messages;
273
+ }
274
+
275
+ function claudeTrace(records: readonly JsonRecord[]): readonly ["user" | "assistant", string][] {
276
+ const messages: ["user" | "assistant", string][] = [];
277
+ for (const record of records) {
278
+ if (
279
+ !["user", "assistant"].includes(String(record.type)) ||
280
+ record.sourceToolAssistantUUID !== undefined
281
+ ) {
282
+ continue;
283
+ }
284
+ if (!isRecord(record.message)) {
285
+ continue;
286
+ }
287
+ const role = normalizeRole(record.message.role);
288
+ const content = contentText(record.message.content);
289
+ if (role && content) {
290
+ messages.push([role, content]);
291
+ }
292
+ }
293
+ return messages;
294
+ }
295
+
296
+ function nestedMessageTrace(
297
+ records: readonly JsonRecord[],
298
+ ): readonly ["user" | "assistant", string][] {
299
+ const messages: ["user" | "assistant", string][] = [];
300
+ for (const record of records) {
301
+ if (record.type !== "message" || !isRecord(record.message)) {
302
+ continue;
303
+ }
304
+ const role = normalizeRole(record.message.role);
305
+ const content = contentText(record.message.content);
306
+ if (role && content) {
307
+ messages.push([role, content]);
308
+ }
309
+ }
310
+ return messages;
311
+ }
312
+
313
+ function genericTrace(records: readonly JsonRecord[]): readonly ["user" | "assistant", string][] {
314
+ const messages: ["user" | "assistant", string][] = [];
315
+ for (const record of records) {
316
+ const directRole = normalizeRole(record.role);
317
+ const directContent = contentText(record.content);
318
+ if (directRole && directContent) {
319
+ messages.push([directRole, directContent]);
320
+ continue;
321
+ }
322
+ if (!isRecord(record.message)) {
323
+ continue;
324
+ }
325
+ const role = normalizeRole(record.message.role);
326
+ const content = contentText(record.message.content);
327
+ if (role && content) {
328
+ messages.push([role, content]);
329
+ }
330
+ }
331
+ return messages;
332
+ }
333
+
334
+ function contentText(content: unknown): string {
335
+ if (typeof content === "string") {
336
+ return content;
337
+ }
338
+ if (!Array.isArray(content)) {
339
+ return "";
340
+ }
341
+ return content
342
+ .flatMap((item) => {
343
+ if (typeof item === "string") {
344
+ return [item];
345
+ }
346
+ if (!isRecord(item) || !["text", "input_text"].includes(String(item.type))) {
347
+ return [];
348
+ }
349
+ return typeof item.text === "string" ? [item.text] : [];
350
+ })
351
+ .join("\n\n");
352
+ }
353
+
354
+ function findSessionId(
355
+ records: readonly JsonRecord[],
356
+ format: SessionProvenanceFormat,
357
+ ): string | undefined {
358
+ for (const record of records) {
359
+ if (format === "codex" && record.type === "session_meta" && isRecord(record.payload)) {
360
+ if (typeof record.payload.id === "string") {
361
+ return record.payload.id;
362
+ }
363
+ }
364
+ if (format === "claude-code" && typeof record.sessionId === "string") {
365
+ return record.sessionId;
366
+ }
367
+ if (format === "pi" && record.type === "session" && typeof record.id === "string") {
368
+ return record.id;
369
+ }
370
+ }
371
+ return undefined;
372
+ }
373
+
374
+ function normalizeRole(value: unknown): "user" | "assistant" | undefined {
375
+ return value === "user" || value === "assistant" ? value : undefined;
376
+ }
377
+
378
+ function looksInjected(content: string): boolean {
379
+ const trimmed = content.trimStart();
380
+ return INJECTED_PREFIXES.some((prefix) => trimmed.startsWith(prefix));
381
+ }
382
+
383
+ export function redactSecrets(value: string): string {
384
+ const replacements: readonly [RegExp, string][] = [
385
+ [
386
+ /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----/g,
387
+ "[REDACTED PRIVATE KEY]",
388
+ ],
389
+ [/Authorization\s*:\s*Bearer\s+[^\s,;]+/gi, "Authorization: Bearer [REDACTED]"],
390
+ [/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g, "AWS_ACCESS_KEY_ID=[REDACTED]"],
391
+ [/AWS_SECRET_ACCESS_KEY\s*[:=]\s*[^\s,;]+/gi, "AWS_SECRET_ACCESS_KEY=[REDACTED]"],
392
+ [/\bnpm_[A-Za-z0-9]{16,}\b/g, "npm_[REDACTED]"],
393
+ [/\bglpat-[A-Za-z0-9_-]{16,}\b/g, "glpat-[REDACTED]"],
394
+ [/\b(?:password|passwd|pwd)\s*[:=]\s*[^\s,;]+/gi, "password=[REDACTED]"],
395
+ [/\b(?:database_url|db_url)\s*[:=]\s*[^\s]+/gi, "DATABASE_URL=[REDACTED]"],
396
+ [
397
+ /\b(?:postgres(?:ql)?|mysql|mariadb|mongodb(?:\+srv)?|redis):\/\/[^\s]+/gi,
398
+ "[REDACTED DATABASE URL]",
399
+ ],
400
+ [/\bdari_[A-Za-z0-9_-]{16,}/g, "dari_[REDACTED]"],
401
+ [/\bsk-[A-Za-z0-9_-]{16,}/g, "sk-[REDACTED]"],
402
+ [/\b(?:ghp|github_pat)_[A-Za-z0-9_-]{16,}/g, "github_[REDACTED]"],
403
+ ];
404
+ return replacements.reduce(
405
+ (result, [pattern, replacement]) => result.replace(pattern, replacement),
406
+ value,
407
+ );
408
+ }
409
+
410
+ async function repositoryWorktrees(repositoryPath: string): Promise<string[]> {
411
+ const result = await runCommand("git", ["-C", repositoryPath, "worktree", "list", "--porcelain"]);
412
+ return result.stdout
413
+ .split("\n")
414
+ .filter((line) => line.startsWith("worktree "))
415
+ .map((line) => line.slice("worktree ".length));
416
+ }
417
+
418
+ async function listJsonFiles(root: string): Promise<string[]> {
419
+ const entries = await readdir(root, { recursive: true, withFileTypes: true }).catch(() => []);
420
+ return entries
421
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
422
+ .map((entry) => join(entry.parentPath, entry.name));
423
+ }
424
+
425
+ function encodeSessionPath(path: string): string {
426
+ return path.replaceAll("/", "-");
427
+ }
428
+
429
+ function deduplicateMessages(messages: readonly ProvenanceMessage[]): ProvenanceMessage[] {
430
+ const seen = new Set<string>();
431
+ return messages.filter((message) => {
432
+ const key = `${message.sourceType}\0${message.sessionId}\0${message.messageIndex}\0${message.content}`;
433
+ if (seen.has(key)) {
434
+ return false;
435
+ }
436
+ seen.add(key);
437
+ return true;
438
+ });
439
+ }
440
+
441
+ function isHumanAuthor(value: unknown): boolean {
442
+ if (!isRecord(value) || value.is_bot === true || typeof value.login !== "string") {
443
+ return false;
444
+ }
445
+ return !value.login.toLowerCase().endsWith("[bot]");
446
+ }
447
+
448
+ function positiveIntegerValue(value: unknown): number | undefined {
449
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
450
+ }
451
+
452
+ function nonnegativeNumber(value: unknown): number {
453
+ return typeof value === "number" && value >= 0 ? value : 0;
454
+ }
455
+
456
+ function isRecord(value: unknown): value is JsonRecord {
457
+ return typeof value === "object" && value !== null && !Array.isArray(value);
458
+ }
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { access, mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { basename, dirname, join, resolve } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { parseArgs } from "node:util";
8
+ import {
9
+ COUPLING_REVIEW_MODEL,
10
+ couplingReviewInput,
11
+ couplingReviewSchema,
12
+ } from "./codex-review.js";
13
+ import { loadConfig } from "./config.js";
14
+ import {
15
+ buildCouplingEvidence,
16
+ discoverContractArtifacts,
17
+ resolveCouplingReview,
18
+ scanBaseContractArtifacts,
19
+ } from "./coupling.js";
20
+ import { parallelMap } from "./parallel.js";
21
+ import { runCommand } from "./process.js";
22
+ import { createSandboxExecutor, type SandboxExecutor } from "./sandbox.js";
23
+ import { loadPiSubscriptionAuth } from "./subscription-auth.js";
24
+
25
+ const parsed = parseArgs({
26
+ options: {
27
+ tasks: { type: "string" },
28
+ task: { type: "string" },
29
+ output: { type: "string" },
30
+ concurrency: { type: "string", default: "10" },
31
+ help: { type: "boolean", short: "h" },
32
+ },
33
+ strict: true,
34
+ });
35
+ if (parsed.values.help) {
36
+ console.log(`Re-audit expanded Harbor tasks for test-to-gold coupling.
37
+
38
+ Usage:
39
+ self-bench-reaudit --tasks DIRECTORY --output REPORT.json [options]
40
+
41
+ Options:
42
+ --concurrency N Concurrent Sol reviews (default: 10)
43
+ --task ID Review only one task ID
44
+ -h, --help Show this help`);
45
+ process.exit(0);
46
+ }
47
+
48
+ const tasksDirectory = resolve(parsed.values.tasks ?? fail("--tasks is required"));
49
+ const outputPath = resolve(parsed.values.output ?? fail("--output is required"));
50
+ const concurrency = positiveInteger(parsed.values.concurrency, "--concurrency");
51
+ const config = loadConfig();
52
+ const sandbox = createSandboxExecutor(config.execution);
53
+ const [taskIds, piAuth, reviewer] = await Promise.all([
54
+ readdir(tasksDirectory),
55
+ loadPiSubscriptionAuth(),
56
+ readFile(join(assetRoot(), "dist/sandbox-review.bundle.js")),
57
+ ]);
58
+ const directories = (
59
+ await Promise.all(
60
+ taskIds.map(async (taskId) => {
61
+ const taskDirectory = join(tasksDirectory, taskId);
62
+ return await access(join(taskDirectory, "instruction.md")).then(
63
+ () => taskDirectory,
64
+ () => undefined,
65
+ );
66
+ }),
67
+ )
68
+ )
69
+ .filter((directory): directory is string => directory !== undefined)
70
+ .filter(
71
+ (directory) => parsed.values.task === undefined || basename(directory) === parsed.values.task,
72
+ )
73
+ .sort();
74
+ if (directories.length === 0) {
75
+ throw new Error(parsed.values.task ? `task not found: ${parsed.values.task}` : "no tasks found");
76
+ }
77
+
78
+ const tasks = await parallelMap(directories, concurrency, async (taskDirectory) => {
79
+ const taskId = basename(taskDirectory);
80
+ console.error(`reviewing ${taskId}`);
81
+ try {
82
+ const report = await auditTask(taskDirectory, taskId, piAuth, reviewer, sandbox);
83
+ console.error(`${taskId}: ${report.resolution.verdict}`);
84
+ return report;
85
+ } catch (error) {
86
+ const message = error instanceof Error ? error.message : String(error);
87
+ console.error(`${taskId}: error: ${message}`);
88
+ return { taskId, status: "error" as const, error: message };
89
+ }
90
+ });
91
+ sandbox.close();
92
+ const report = {
93
+ schemaVersion: 1,
94
+ reviewer: COUPLING_REVIEW_MODEL,
95
+ generatedAt: new Date().toISOString(),
96
+ sourceTasks: tasksDirectory,
97
+ provisional: true,
98
+ tasks,
99
+ summary: {
100
+ total: tasks.length,
101
+ clean: tasks.filter((task) => task.status === "reviewed" && task.resolution.verdict === "clean")
102
+ .length,
103
+ coupled: tasks.filter(
104
+ (task) => task.status === "reviewed" && task.resolution.verdict === "coupled",
105
+ ).length,
106
+ errors: tasks.filter((task) => task.status === "error").length,
107
+ },
108
+ };
109
+ await mkdir(dirname(outputPath), { recursive: true });
110
+ await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`);
111
+ console.log(JSON.stringify({ output: outputPath, ...report.summary }, null, 2));
112
+
113
+ async function auditTask(
114
+ taskDirectory: string,
115
+ taskId: string,
116
+ piAuth: string,
117
+ reviewer: Uint8Array,
118
+ sandbox: SandboxExecutor,
119
+ ) {
120
+ const scratch = await mkdtemp(join(tmpdir(), "selfbench-reaudit-"));
121
+ try {
122
+ const baseDirectory = join(scratch, "base");
123
+ const baseArchivePath = join(taskDirectory, "environment/repo.tar.gz");
124
+ await mkdir(baseDirectory);
125
+ await runCommand("tar", ["-xzf", baseArchivePath, "-C", baseDirectory]);
126
+ const [prompt, testPatch, goldPatch] = await Promise.all([
127
+ readFile(join(taskDirectory, "instruction.md"), "utf8"),
128
+ readFile(join(taskDirectory, "tests/test.patch"), "utf8"),
129
+ readFile(join(taskDirectory, "solution/gold.patch"), "utf8"),
130
+ ]);
131
+ const baseArtifacts = await scanBaseContractArtifacts(
132
+ baseDirectory,
133
+ scratch,
134
+ discoverContractArtifacts(testPatch),
135
+ );
136
+ const couplingEvidence = buildCouplingEvidence({
137
+ prompt,
138
+ testPatch,
139
+ goldPatch,
140
+ baseArtifacts,
141
+ });
142
+ const result = await sandbox.run({
143
+ runId: "selfbench-reaudit-v2",
144
+ stage: taskId,
145
+ timeoutMs: 15 * 60 * 1000,
146
+ files: [
147
+ { path: "/work/sandbox-review.js", contents: reviewer },
148
+ {
149
+ path: "/work/review-input.md",
150
+ contents: couplingReviewInput(prompt, testPatch, goldPatch, couplingEvidence),
151
+ },
152
+ ],
153
+ outputPaths: ["/work/review.json"],
154
+ secrets: { SELFBENCH_PI_AUTH_JSON: piAuth },
155
+ environment: { SELFBENCH_REVIEW_OUTPUT: "/work/review.json" },
156
+ command: ["node", "/work/sandbox-review.js"],
157
+ });
158
+ const output = result.outputs["/work/review.json"];
159
+ if (result.exitCode !== 0 || !output) {
160
+ throw new Error(
161
+ `sandboxed coupling review failed in ${result.sandboxId}: ${result.stderr.trim() || result.stdout.trim()}`,
162
+ );
163
+ }
164
+ const review = couplingReviewSchema.parse(JSON.parse(Buffer.from(output).toString("utf8")));
165
+ return {
166
+ taskId,
167
+ status: "reviewed" as const,
168
+ sandboxId: result.sandboxId,
169
+ couplingEvidence,
170
+ review,
171
+ resolution: resolveCouplingReview(couplingEvidence, review),
172
+ };
173
+ } finally {
174
+ await rm(scratch, { recursive: true, force: true });
175
+ }
176
+ }
177
+
178
+ function assetRoot(): string {
179
+ return resolve(dirname(fileURLToPath(import.meta.url)), "..");
180
+ }
181
+
182
+ function positiveInteger(value: string | undefined, label: string): number {
183
+ const parsed = Number(value);
184
+ if (!Number.isInteger(parsed) || parsed < 1) {
185
+ throw new Error(`${label} must be a positive integer`);
186
+ }
187
+ return parsed;
188
+ }
189
+
190
+ function fail(message: string): never {
191
+ throw new Error(message);
192
+ }