@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.
@@ -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
- * Deliberately below the five-minute `spawn.ts` default: a timeout advances the
12
- * chain, so the worst case is targets x timeout.
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 = 120_000;
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
- env.HOME = directory;
33
- env.USERPROFILE = directory;
34
- env.XDG_CONFIG_HOME = join(directory, "config");
35
- env.XDG_CACHE_HOME = join(directory, "cache");
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
- /** Codex and agy currently lack documented instruction/customization isolation. */
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
- codex: [],
48
- agy: []
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 expected = request.invocation.packet.criteria.map((criterion) => criterion.id);
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
- let unavailable = false;
72
- for (const [index, target] of chain.entries()) {
73
- if (!hasRequiredReviewIsolation(target)) {
74
- unavailable = true;
75
- report(`${target}: required context-isolation controls unavailable → skipping`);
76
- continue;
77
- }
78
- const attemptDirectory = await mkdtemp(join(tmpdir(), "agent-ops-review-"));
79
- try {
80
- const invocation = buildTargetInvocation({
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
- prompt,
83
- repositoryRoot,
84
- ...(options.model === undefined ? {} : { model: options.model }),
85
- ...(options.effort === undefined ? {} : { effort: options.effort })
86
- });
87
- if (invocation === undefined) {
88
- unavailable = true;
89
- report(`${target}: no read-only mode available → skipping`);
90
- continue;
91
- }
92
- if (target === host) {
93
- report(`${target}: reviewer == host; no independent target configured`);
94
- }
95
- const environment = isolatedReviewEnvironment(target, attemptDirectory, options.env ?? process.env);
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 spawned = await runVerificationCommand({
119
- id: `review-${target}-${index}`,
120
- command: invocation.command,
121
- args: [...invocation.args],
122
- cwd: attemptDirectory,
123
- required: true,
124
- evidence: { kind: "exit-code" },
125
- timeoutMs: options.timeoutMs ?? DEFAULT_REVIEW_TIMEOUT_MS
126
- }, {
127
- cwd: attemptDirectory,
128
- ...(options.runner === undefined ? {} : { runner: options.runner }),
129
- ...(options.outputLimitBytes === undefined
130
- ? {}
131
- : { outputLimitBytes: options.outputLimitBytes }),
132
- stdin: invocation.stdin,
133
- env: environment,
134
- replaceEnv: true
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
- if (ADVANCING.has(spawned.failureClass)) {
137
- report(`${target}: ${spawned.failureClass} → trying next target`);
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
- finally {
175
- await rm(attemptDirectory, { recursive: true, force: true });
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: unavailable ? "capability-unavailable" : "missing-cli"
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
- return typeof value === "string" ? extractJsonObject(value) : undefined;
73
+ const parsed = typeof value === "string" ? extractJsonObject(value) : undefined;
74
+ return parsed === undefined || target !== "agy"
75
+ ? parsed
76
+ : withoutAgyPlanMetadata(parsed);
69
77
  }