@kylecheng3146/agent-ops 0.1.15 → 0.1.17
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 +10 -8
- package/dist/packages/cli/src/args.js +23 -3
- package/dist/packages/cli/src/cli.js +2 -0
- package/dist/packages/cli/src/commands/review.js +5 -1
- package/dist/packages/cli/src/commands/task.js +9 -3
- package/dist/packages/cli/src/wizard.js +5 -5
- package/dist/runtime/src/install/doctor.js +18 -4
- package/dist/runtime/src/install/harness.js +4 -1
- package/dist/runtime/src/review/execute.js +348 -121
- package/dist/runtime/src/review/extract.js +10 -2
- package/dist/runtime/src/review/invocation.js +75 -15
- package/dist/runtime/src/review/probe.js +11 -8
- package/dist/runtime/src/review/render.js +20 -0
- package/dist/runtime/src/review/roles.js +13 -3
- package/dist/runtime/src/review/runner.js +134 -21
- package/dist/runtime/src/schema/validate.js +7 -1
- package/dist/runtime/src/security/permissions.js +18 -3
- package/dist/runtime/src/task/render.js +3 -0
- package/dist/runtime/src/task/service.js +18 -2
- package/dist/runtime/src/verify/spawn.js +27 -10
- package/docs/en/guides/configuration.md +17 -11
- package/docs/en/spec/review.md +21 -4
- package/docs/zh-TW/guides/configuration.md +14 -8
- package/docs/zh-TW/spec/review.md +15 -4
- package/package.json +1 -1
- package/schemas/task.schema.json +3 -0
|
@@ -1,20 +1,26 @@
|
|
|
1
|
-
import { mkdtemp, realpath, rm } from "node:fs/promises";
|
|
1
|
+
import { chmod, copyFile, lstat, mkdir, mkdtemp, realpath, rm } from "node:fs/promises";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
4
|
import { runVerificationCommand } from "../verify/spawn.js";
|
|
5
|
+
import { redactSecrets } from "../security/redact.js";
|
|
5
6
|
import { extractReviewObject } from "./extract.js";
|
|
6
7
|
import { buildTargetInvocation } from "./invocation.js";
|
|
7
8
|
import { reviewReportResults, reviewReportStatus, validateReviewReport } from "./report.js";
|
|
8
9
|
import { detectHostTarget, orderChain } from "./roles.js";
|
|
9
|
-
import { buildReviewPrompt } from "./runner.js";
|
|
10
|
+
import { buildAdversarialPrompt, buildReviewPrompt } from "./runner.js";
|
|
10
11
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
12
|
+
* Full repository reviews need far more headroom than the lightweight auth
|
|
13
|
+
* probe. Five minutes was not enough: reviewing a real working tree, codex and
|
|
14
|
+
* claude both exceeded it on this repository, and a timeout costs the whole
|
|
15
|
+
* review while looking like an unavailable target.
|
|
13
16
|
*/
|
|
14
|
-
export const DEFAULT_REVIEW_TIMEOUT_MS =
|
|
17
|
+
export const DEFAULT_REVIEW_TIMEOUT_MS = 900_000;
|
|
18
|
+
// USER is load-bearing, not cosmetic: a credential store keyed by account name
|
|
19
|
+
// — the macOS keychain claude reads — cannot be opened without it, and its
|
|
20
|
+
// absence surfaces as "Not logged in" on an install that is logged in.
|
|
15
21
|
const EXECUTION_ENV = [
|
|
16
22
|
"PATH", "PATHEXT", "SystemRoot", "SYSTEMROOT", "WINDIR", "COMSPEC",
|
|
17
|
-
"LANG", "LC_ALL", "TERM", "TMPDIR", "TEMP", "TMP"
|
|
23
|
+
"LANG", "LC_ALL", "TERM", "TMPDIR", "TEMP", "TMP", "USER"
|
|
18
24
|
];
|
|
19
25
|
const AUTH_ENV = {
|
|
20
26
|
claude: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"],
|
|
@@ -29,23 +35,45 @@ export function isolatedReviewEnvironment(target, directory, source) {
|
|
|
29
35
|
env[key] = value;
|
|
30
36
|
}
|
|
31
37
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
38
|
+
// claude and agy read their credentials out of the invoking user's home, so
|
|
39
|
+
// replacing it does not isolate them — it only makes them report "not logged
|
|
40
|
+
// in" on an install that is logged in. claude is isolated by its own flags
|
|
41
|
+
// instead: `--safe-mode` disables CLAUDE.md, skills, plugins, hooks, MCP
|
|
42
|
+
// servers and custom agents while explicitly keeping auth. codex keeps a
|
|
43
|
+
// replaced home because CODEX_HOME carries its credentials separately, so
|
|
44
|
+
// the isolation costs it nothing. agy uses its native sandbox and plan mode,
|
|
45
|
+
// but keeps its home because that is where its OAuth session lives.
|
|
46
|
+
if (target === "agy" || target === "claude") {
|
|
47
|
+
env.HOME = source.HOME ?? directory;
|
|
48
|
+
env.USERPROFILE = source.USERPROFILE ?? env.HOME;
|
|
49
|
+
env.XDG_CONFIG_HOME = source.XDG_CONFIG_HOME ?? join(env.HOME, ".config");
|
|
50
|
+
env.XDG_CACHE_HOME = source.XDG_CACHE_HOME ?? join(env.HOME, ".cache");
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
env.HOME = directory;
|
|
54
|
+
env.USERPROFILE = directory;
|
|
55
|
+
env.XDG_CONFIG_HOME = join(directory, "config");
|
|
56
|
+
env.XDG_CACHE_HOME = join(directory, "cache");
|
|
57
|
+
}
|
|
58
|
+
if (target === "codex") {
|
|
59
|
+
const codexHome = source.CODEX_HOME ??
|
|
60
|
+
(source.HOME === undefined ? undefined : join(source.HOME, ".codex"));
|
|
61
|
+
if (codexHome !== undefined) {
|
|
62
|
+
env.CODEX_HOME = codexHome;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
36
65
|
return env;
|
|
37
66
|
}
|
|
38
|
-
/**
|
|
39
|
-
export function hasRequiredReviewIsolation(target) {
|
|
40
|
-
return target === "claude";
|
|
41
|
-
}
|
|
67
|
+
/** Only eligible targets appear: an ineligible one never reaches this gate. */
|
|
42
68
|
const REQUIRED_HELP_FLAGS = {
|
|
43
69
|
claude: [
|
|
44
70
|
"--add-dir", "--permission-mode", "--no-session-persistence",
|
|
45
71
|
"--safe-mode", "--disable-slash-commands", "--json-schema"
|
|
46
72
|
],
|
|
47
|
-
|
|
48
|
-
|
|
73
|
+
agy: ["--add-dir", "--sandbox", "--mode", "--json-schema", "--log-file"],
|
|
74
|
+
codex: [
|
|
75
|
+
"--cd", "--ephemeral", "--ignore-user-config", "--ignore-rules"
|
|
76
|
+
]
|
|
49
77
|
};
|
|
50
78
|
/**
|
|
51
79
|
* Failure classes that mean no review happened, so trying the next target is
|
|
@@ -56,128 +84,327 @@ const ADVANCING = new Set([
|
|
|
56
84
|
"spawn-failed",
|
|
57
85
|
"timeout"
|
|
58
86
|
]);
|
|
87
|
+
const DIAGNOSTIC_MAX_CHARS = 200;
|
|
88
|
+
/**
|
|
89
|
+
* The target's first line of complaint, redacted and clipped. It travels on the
|
|
90
|
+
* attempt record rather than only in a progress line, because progress is
|
|
91
|
+
* suppressed under `--json`: a machine consumer would otherwise be left with
|
|
92
|
+
* the bare authentication guess this exists to qualify. Never evidence.
|
|
93
|
+
*/
|
|
94
|
+
function firstComplaint(...streams) {
|
|
95
|
+
for (const stream of streams) {
|
|
96
|
+
const line = redactSecrets(stream)
|
|
97
|
+
.split(/\r?\n/u)
|
|
98
|
+
.map((value) => value.trim())
|
|
99
|
+
.find((value) => value.length > 0);
|
|
100
|
+
if (line !== undefined) {
|
|
101
|
+
return line.slice(0, DIAGNOSTIC_MAX_CHARS);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
async function snapshotRepository(request, destination, options) {
|
|
107
|
+
const cloned = await runVerificationCommand({
|
|
108
|
+
id: `review-snapshot-${request.label}`,
|
|
109
|
+
command: "git",
|
|
110
|
+
args: ["clone", "--no-hardlinks", "--quiet", "--", request.repositoryRoot, destination],
|
|
111
|
+
cwd: dirname(destination),
|
|
112
|
+
required: true,
|
|
113
|
+
evidence: { kind: "exit-code" },
|
|
114
|
+
timeoutMs: Math.min(options.timeoutMs ?? DEFAULT_REVIEW_TIMEOUT_MS, 60_000)
|
|
115
|
+
}, {
|
|
116
|
+
cwd: dirname(destination),
|
|
117
|
+
...(options.runner === undefined ? {} : { runner: options.runner })
|
|
118
|
+
});
|
|
119
|
+
if (cloned.status !== "PASS") {
|
|
120
|
+
return firstComplaint(cloned.stderr, cloned.stdout) ??
|
|
121
|
+
`git clone failed (${cloned.failureClass})`;
|
|
122
|
+
}
|
|
123
|
+
for (const path of request.changedFiles ?? []) {
|
|
124
|
+
const source = join(request.repositoryRoot, path);
|
|
125
|
+
const target = join(destination, path);
|
|
126
|
+
try {
|
|
127
|
+
const stat = await lstat(source);
|
|
128
|
+
if (!stat.isFile()) {
|
|
129
|
+
return `changed path is not a regular file: ${path}`;
|
|
130
|
+
}
|
|
131
|
+
await mkdir(dirname(target), { recursive: true });
|
|
132
|
+
await copyFile(source, target);
|
|
133
|
+
await chmod(target, stat.mode & 0o777);
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") {
|
|
137
|
+
await rm(target, { recursive: true, force: true });
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* One target's attempt at one prompt, in a throwaway home directory. Returns a
|
|
147
|
+
* validated report or the reason this target produced no usable verdict; the
|
|
148
|
+
* caller decides whether that reason is worth advancing past.
|
|
149
|
+
*/
|
|
150
|
+
async function attemptTarget(request, options) {
|
|
151
|
+
const { target } = request;
|
|
152
|
+
const skip = (reason, diagnostic, verb = "trying next target") => ({
|
|
153
|
+
kind: "skip",
|
|
154
|
+
reason,
|
|
155
|
+
diagnostic,
|
|
156
|
+
message: `${target}: ${reason} → ${verb} (${diagnostic})`
|
|
157
|
+
});
|
|
158
|
+
const attemptDirectory = await mkdtemp(join(tmpdir(), "agent-ops-review-"));
|
|
159
|
+
try {
|
|
160
|
+
const invocationRequest = {
|
|
161
|
+
target,
|
|
162
|
+
prompt: request.prompt,
|
|
163
|
+
repositoryRoot: request.repositoryRoot,
|
|
164
|
+
...(target === "agy"
|
|
165
|
+
? { logFile: join(attemptDirectory, "agy.log") }
|
|
166
|
+
: {}),
|
|
167
|
+
...(options.model === undefined ? {} : { model: options.model }),
|
|
168
|
+
...(options.effort === undefined ? {} : { effort: options.effort })
|
|
169
|
+
};
|
|
170
|
+
let invocation = buildTargetInvocation(invocationRequest);
|
|
171
|
+
let executionDirectory = attemptDirectory;
|
|
172
|
+
if (invocation === undefined) {
|
|
173
|
+
return skip("capability-unavailable", "no read-only mode is available for this target", "skipping");
|
|
174
|
+
}
|
|
175
|
+
const environment = isolatedReviewEnvironment(target, attemptDirectory, options.env ?? process.env);
|
|
176
|
+
const capability = await runVerificationCommand({
|
|
177
|
+
id: `review-capability-${request.label}`,
|
|
178
|
+
command: invocation.command,
|
|
179
|
+
args: target === "codex" ? ["exec", "--help"] : ["--help"],
|
|
180
|
+
cwd: attemptDirectory,
|
|
181
|
+
required: true,
|
|
182
|
+
evidence: { kind: "exit-code" },
|
|
183
|
+
timeoutMs: Math.min(options.timeoutMs ?? DEFAULT_REVIEW_TIMEOUT_MS, 10_000)
|
|
184
|
+
}, {
|
|
185
|
+
cwd: attemptDirectory,
|
|
186
|
+
...(options.runner === undefined ? {} : { runner: options.runner }),
|
|
187
|
+
env: environment,
|
|
188
|
+
replaceEnv: true
|
|
189
|
+
});
|
|
190
|
+
const help = `${capability.stdout}\n${capability.stderr}`;
|
|
191
|
+
const missingFlags = (REQUIRED_HELP_FLAGS[target] ?? []).filter((flag) => !help.includes(flag));
|
|
192
|
+
if (capability.status !== "PASS" ||
|
|
193
|
+
capability.stdoutTruncated ||
|
|
194
|
+
capability.stderrTruncated ||
|
|
195
|
+
missingFlags.length > 0) {
|
|
196
|
+
// This gate is the one a renamed upstream flag trips, so it names the
|
|
197
|
+
// flags it could not find. Without them the skip is indistinguishable
|
|
198
|
+
// from an uninstalled CLI, in the human line and in the attempt record.
|
|
199
|
+
return skip("capability-unavailable", missingFlags.length > 0
|
|
200
|
+
? `help output is missing ${missingFlags.join(", ")}`
|
|
201
|
+
: capability.stdoutTruncated || capability.stderrTruncated
|
|
202
|
+
? "help output exceeded the capture limit"
|
|
203
|
+
: firstComplaint(capability.stderr, capability.stdout) ??
|
|
204
|
+
`help probe failed (${capability.failureClass})`, "skipping");
|
|
205
|
+
}
|
|
206
|
+
if (target === "agy") {
|
|
207
|
+
const snapshotRoot = join(attemptDirectory, "repository");
|
|
208
|
+
const snapshotError = await snapshotRepository(request, snapshotRoot, options);
|
|
209
|
+
if (snapshotError !== undefined) {
|
|
210
|
+
return skip("capability-unavailable", snapshotError, "skipping");
|
|
211
|
+
}
|
|
212
|
+
invocation = buildTargetInvocation({
|
|
213
|
+
...invocationRequest,
|
|
214
|
+
prompt: [
|
|
215
|
+
`Repository root: ${snapshotRoot}`,
|
|
216
|
+
"Run every repository-relative inspection in that directory.",
|
|
217
|
+
"For terminal commands, use only git status, git diff, git log, or git show; " +
|
|
218
|
+
"read specific files with file-reading tools instead of ls, find, cat, or rg.",
|
|
219
|
+
request.prompt
|
|
220
|
+
].join("\n"),
|
|
221
|
+
repositoryRoot: snapshotRoot
|
|
222
|
+
});
|
|
223
|
+
executionDirectory = snapshotRoot;
|
|
224
|
+
}
|
|
225
|
+
if (invocation === undefined) {
|
|
226
|
+
return skip("capability-unavailable", "review invocation disappeared", "skipping");
|
|
227
|
+
}
|
|
228
|
+
const spawned = await runVerificationCommand({
|
|
229
|
+
id: `review-${request.label}`,
|
|
230
|
+
command: invocation.command,
|
|
231
|
+
args: [...invocation.args],
|
|
232
|
+
cwd: executionDirectory,
|
|
233
|
+
required: true,
|
|
234
|
+
evidence: { kind: "exit-code" },
|
|
235
|
+
timeoutMs: options.timeoutMs ?? DEFAULT_REVIEW_TIMEOUT_MS
|
|
236
|
+
}, {
|
|
237
|
+
cwd: executionDirectory,
|
|
238
|
+
...(options.runner === undefined ? {} : { runner: options.runner }),
|
|
239
|
+
...(options.outputLimitBytes === undefined
|
|
240
|
+
? {}
|
|
241
|
+
: { outputLimitBytes: options.outputLimitBytes }),
|
|
242
|
+
stdin: invocation.stdin,
|
|
243
|
+
env: environment,
|
|
244
|
+
replaceEnv: true
|
|
245
|
+
});
|
|
246
|
+
if (ADVANCING.has(spawned.failureClass)) {
|
|
247
|
+
return {
|
|
248
|
+
...skip("missing-cli", `the process did not complete: ${spawned.failureClass}`),
|
|
249
|
+
...(spawned.failureClass === undefined
|
|
250
|
+
? {}
|
|
251
|
+
: { attemptReason: spawned.failureClass })
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
if (spawned.stdoutTruncated ||
|
|
255
|
+
(spawned.stderrTruncated && target !== "codex")) {
|
|
256
|
+
return skip("output-too-large", `${spawned.stdoutTruncated ? "stdout" : "stderr"} exceeded the capture limit`);
|
|
257
|
+
}
|
|
258
|
+
if (spawned.failureClass === "nonzero-exit") {
|
|
259
|
+
// A rejected call is usually missing authentication, but a stale flag
|
|
260
|
+
// shape exits non-zero too, and reporting only "login-required" sends
|
|
261
|
+
// the reader to `doctor --check-auth` for a problem it cannot see. The
|
|
262
|
+
// target's own first line of complaint distinguishes the two.
|
|
263
|
+
return skip("login-required", firstComplaint(spawned.stderr, spawned.stdout) ??
|
|
264
|
+
`the call was rejected with exit ${spawned.exitCode ?? "unknown"} and no output`);
|
|
265
|
+
}
|
|
266
|
+
const payload = extractReviewObject(target, spawned.stdout);
|
|
267
|
+
const parsed = payload === undefined
|
|
268
|
+
? undefined
|
|
269
|
+
: validateReviewReport(payload, request.expectedCriterionIds, request.changedFiles);
|
|
270
|
+
if (parsed === undefined || !parsed.ok) {
|
|
271
|
+
const reason = parsed?.errors.some((error) => error.code === "INCOMPLETE_SCOPE") ? "incomplete-scope" : "unparseable-output";
|
|
272
|
+
// Which contract the answer broke, or what the target said instead of
|
|
273
|
+
// answering. Without this the skip names only the classification, and a
|
|
274
|
+
// target that runs but never returns a usable report is undebuggable.
|
|
275
|
+
const errors = parsed === undefined
|
|
276
|
+
? ""
|
|
277
|
+
: parsed.errors
|
|
278
|
+
.slice(0, 3)
|
|
279
|
+
.map((error) => `${error.path}: ${error.code}`)
|
|
280
|
+
.join("; ");
|
|
281
|
+
const fields = parsed?.errors.some((error) => error.code === "INVALID_FIELDS") &&
|
|
282
|
+
payload !== undefined
|
|
283
|
+
? ` (fields: ${Object.keys(payload).sort().join(", ")})`
|
|
284
|
+
: "";
|
|
285
|
+
return skip(reason, errors.length > 0
|
|
286
|
+
? `${errors}${fields}`
|
|
287
|
+
: firstComplaint(spawned.stdout, spawned.stderr) ??
|
|
288
|
+
"the answer carried no review report");
|
|
289
|
+
}
|
|
290
|
+
return { kind: "verdict", report: parsed.value };
|
|
291
|
+
}
|
|
292
|
+
finally {
|
|
293
|
+
await rm(attemptDirectory, { recursive: true, force: true });
|
|
294
|
+
}
|
|
295
|
+
}
|
|
59
296
|
/**
|
|
60
297
|
* Builds the `execute` callback `runIndependentReview` expects: walk the
|
|
61
|
-
* configured targets in order and return the first real verdict.
|
|
298
|
+
* configured targets in order and return the first real verdict. A PASS is then
|
|
299
|
+
* handed to a different target to refute, so a single agreeable reviewer cannot
|
|
300
|
+
* wave a change through on its own.
|
|
62
301
|
*/
|
|
63
302
|
export function createReviewExecutor(options) {
|
|
64
303
|
const report = options.onProgress ?? (() => { });
|
|
65
304
|
const host = detectHostTarget(options.env ?? process.env);
|
|
66
305
|
const chain = orderChain(options.targets, host);
|
|
67
306
|
return async (request) => {
|
|
68
|
-
const
|
|
69
|
-
const prompt = buildReviewPrompt(request.invocation);
|
|
307
|
+
const expectedCriterionIds = request.invocation.packet.criteria.map((criterion) => criterion.id);
|
|
70
308
|
const repositoryRoot = await realpath(options.cwd);
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
309
|
+
const shared = {
|
|
310
|
+
repositoryRoot,
|
|
311
|
+
expectedCriterionIds,
|
|
312
|
+
...(request.invocation.scope?.changedFiles === undefined
|
|
313
|
+
? {}
|
|
314
|
+
: { changedFiles: request.invocation.scope.changedFiles })
|
|
315
|
+
};
|
|
316
|
+
const attempts = [];
|
|
317
|
+
let lastReason = "missing-cli";
|
|
318
|
+
/**
|
|
319
|
+
* Ask a target other than the one that passed — and other than the host —
|
|
320
|
+
* to refute the verdict. Returns undefined when no such target produced a
|
|
321
|
+
* report, which leaves the primary PASS standing unchallenged. Targets the
|
|
322
|
+
* primary pass already walked past are excluded: a CLI that could not
|
|
323
|
+
* answer the review prompt will not answer this one either.
|
|
324
|
+
*/
|
|
325
|
+
const refute = async (primary, primaryTarget) => {
|
|
326
|
+
const walked = new Set(attempts.map((attempt) => attempt.target));
|
|
327
|
+
const candidates = chain.filter((target) => target !== primaryTarget && target !== host && !walked.has(target));
|
|
328
|
+
for (const [index, target] of candidates.entries()) {
|
|
329
|
+
const outcome = await attemptTarget({
|
|
330
|
+
...shared,
|
|
81
331
|
target,
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const capability = await runVerificationCommand({
|
|
97
|
-
id: `review-capability-${target}-${index}`,
|
|
98
|
-
command: invocation.command,
|
|
99
|
-
args: ["--help"],
|
|
100
|
-
cwd: attemptDirectory,
|
|
101
|
-
required: true,
|
|
102
|
-
evidence: { kind: "exit-code" },
|
|
103
|
-
timeoutMs: Math.min(options.timeoutMs ?? DEFAULT_REVIEW_TIMEOUT_MS, 10_000)
|
|
104
|
-
}, {
|
|
105
|
-
cwd: attemptDirectory,
|
|
106
|
-
...(options.runner === undefined ? {} : { runner: options.runner }),
|
|
107
|
-
env: environment,
|
|
108
|
-
replaceEnv: true
|
|
109
|
-
});
|
|
110
|
-
if (capability.status !== "PASS" ||
|
|
111
|
-
capability.stdoutTruncated ||
|
|
112
|
-
capability.stderrTruncated ||
|
|
113
|
-
REQUIRED_HELP_FLAGS[target].some((flag) => !capability.stdout.includes(flag))) {
|
|
114
|
-
unavailable = true;
|
|
115
|
-
report(`${target}: required CLI capabilities unavailable → skipping`);
|
|
332
|
+
label: `adversarial-${target}-${index}`,
|
|
333
|
+
prompt: buildAdversarialPrompt({ ...request.invocation, harness: target }, primary)
|
|
334
|
+
}, options);
|
|
335
|
+
if (outcome.kind === "skip") {
|
|
336
|
+
// Recorded, not just reported: progress is suppressed under --json,
|
|
337
|
+
// and without this a PASS with no `adversarial` field cannot be told
|
|
338
|
+
// apart from a PASS that had no second target to challenge it.
|
|
339
|
+
attempts.push({
|
|
340
|
+
target,
|
|
341
|
+
status: "NOT_RUN",
|
|
342
|
+
reason: outcome.attemptReason ?? outcome.reason,
|
|
343
|
+
diagnostic: outcome.diagnostic
|
|
344
|
+
});
|
|
345
|
+
report(`${target}: adversarial re-check unavailable (${outcome.reason})`);
|
|
116
346
|
continue;
|
|
117
347
|
}
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
348
|
+
const refuted = reviewReportStatus(outcome.report) === "FAIL";
|
|
349
|
+
report(`${target}: adversarial re-check ${refuted ? "refuted the PASS" : "upheld the PASS"}`);
|
|
350
|
+
return { target, refuted, report: outcome.report };
|
|
351
|
+
}
|
|
352
|
+
if (candidates.length > 0) {
|
|
353
|
+
report("no independent target completed an adversarial re-check");
|
|
354
|
+
}
|
|
355
|
+
return undefined;
|
|
356
|
+
};
|
|
357
|
+
for (const [index, target] of chain.entries()) {
|
|
358
|
+
if (target === host) {
|
|
359
|
+
report(`${target}: reviewer == host; no independent target configured`);
|
|
360
|
+
}
|
|
361
|
+
const outcome = await attemptTarget({
|
|
362
|
+
...shared,
|
|
363
|
+
target,
|
|
364
|
+
label: `${target}-${index}`,
|
|
365
|
+
prompt: buildReviewPrompt({ ...request.invocation, harness: target })
|
|
366
|
+
}, options);
|
|
367
|
+
if (outcome.kind === "skip") {
|
|
368
|
+
lastReason = outcome.reason;
|
|
369
|
+
attempts.push({
|
|
370
|
+
target,
|
|
371
|
+
status: "NOT_RUN",
|
|
372
|
+
reason: outcome.attemptReason ?? outcome.reason,
|
|
373
|
+
diagnostic: outcome.diagnostic
|
|
135
374
|
});
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
continue;
|
|
139
|
-
}
|
|
140
|
-
if (spawned.stdoutTruncated || spawned.stderrTruncated) {
|
|
141
|
-
return { status: "NOT_RUN", reason: "output-too-large", harness: target };
|
|
142
|
-
}
|
|
143
|
-
if (spawned.failureClass === "nonzero-exit") {
|
|
144
|
-
return { status: "NOT_RUN", reason: "login-required", harness: target };
|
|
145
|
-
}
|
|
146
|
-
const payload = extractReviewObject(target, spawned.stdout);
|
|
147
|
-
const parsed = payload === undefined
|
|
148
|
-
? undefined
|
|
149
|
-
: validateReviewReport(payload, expected, request.invocation.scope?.changedFiles);
|
|
150
|
-
if (parsed === undefined || !parsed.ok) {
|
|
151
|
-
return {
|
|
152
|
-
status: "NOT_RUN",
|
|
153
|
-
reason: parsed?.errors.some((error) => error.code === "INCOMPLETE_SCOPE")
|
|
154
|
-
? "incomplete-scope"
|
|
155
|
-
: "unparseable-output",
|
|
156
|
-
harness: target,
|
|
157
|
-
...(parsed === undefined ? {} : { validationErrors: parsed.errors })
|
|
158
|
-
};
|
|
159
|
-
}
|
|
160
|
-
const reportValue = parsed.value;
|
|
161
|
-
const results = reviewReportResults(reportValue);
|
|
162
|
-
return {
|
|
163
|
-
status: reviewReportStatus(reportValue),
|
|
164
|
-
results,
|
|
165
|
-
report: reportValue,
|
|
166
|
-
harness: target,
|
|
167
|
-
independence: host === undefined
|
|
168
|
-
? "unknown"
|
|
169
|
-
: host === target
|
|
170
|
-
? "same-target"
|
|
171
|
-
: "different-target"
|
|
172
|
-
};
|
|
375
|
+
report(outcome.message);
|
|
376
|
+
continue;
|
|
173
377
|
}
|
|
174
|
-
|
|
175
|
-
|
|
378
|
+
const reportValue = outcome.report;
|
|
379
|
+
const status = reviewReportStatus(reportValue);
|
|
380
|
+
attempts.push({ target, status });
|
|
381
|
+
const independence = host === undefined
|
|
382
|
+
? "unknown"
|
|
383
|
+
: host === target
|
|
384
|
+
? "same-target"
|
|
385
|
+
: "different-target";
|
|
386
|
+
const verdict = {
|
|
387
|
+
results: reviewReportResults(reportValue),
|
|
388
|
+
report: reportValue,
|
|
389
|
+
harness: target,
|
|
390
|
+
attempts,
|
|
391
|
+
independence
|
|
392
|
+
};
|
|
393
|
+
if (status === "FAIL") {
|
|
394
|
+
return { status, ...verdict };
|
|
176
395
|
}
|
|
396
|
+
const adversarial = await refute(reportValue, target);
|
|
397
|
+
return {
|
|
398
|
+
status: adversarial?.refuted === true ? "FAIL" : "PASS",
|
|
399
|
+
...verdict,
|
|
400
|
+
...(adversarial === undefined ? {} : { adversarial })
|
|
401
|
+
};
|
|
177
402
|
}
|
|
178
403
|
return {
|
|
179
404
|
status: "NOT_RUN",
|
|
180
|
-
reason:
|
|
405
|
+
reason: lastReason,
|
|
406
|
+
...(attempts.length === 0 ? {} : { harness: attempts.at(-1)?.target }),
|
|
407
|
+
attempts
|
|
181
408
|
};
|
|
182
409
|
};
|
|
183
410
|
}
|
|
@@ -21,6 +21,11 @@ function parseObject(text) {
|
|
|
21
21
|
function isRecord(value) {
|
|
22
22
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
23
23
|
}
|
|
24
|
+
/** Agy appends plan UI metadata even when native JSON Schema is enabled. */
|
|
25
|
+
function withoutAgyPlanMetadata(value) {
|
|
26
|
+
const { toolAction: _toolAction, toolSummary: _toolSummary, ...report } = value;
|
|
27
|
+
return report;
|
|
28
|
+
}
|
|
24
29
|
/**
|
|
25
30
|
* The model's answer as text, before any JSON contract is applied. Returns
|
|
26
31
|
* undefined rather than throwing so the caller can report
|
|
@@ -63,7 +68,10 @@ export function extractReviewObject(target, stdout) {
|
|
|
63
68
|
const key = target === "claude" ? "structured_output" : "response";
|
|
64
69
|
const value = envelope?.[key];
|
|
65
70
|
if (isRecord(value)) {
|
|
66
|
-
return value;
|
|
71
|
+
return target === "agy" ? withoutAgyPlanMetadata(value) : value;
|
|
67
72
|
}
|
|
68
|
-
|
|
73
|
+
const parsed = typeof value === "string" ? extractJsonObject(value) : undefined;
|
|
74
|
+
return parsed === undefined || target !== "agy"
|
|
75
|
+
? parsed
|
|
76
|
+
: withoutAgyPlanMetadata(parsed);
|
|
69
77
|
}
|