@kylecheng3146/agent-ops 0.1.6 → 0.1.7

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 CHANGED
@@ -261,6 +261,28 @@ For a full command reference, run `agent-ops --help`. The `task`, `verify`, and
261
261
  `review` commands support acceptance tracking and independent verification when
262
262
  the project configuration defines those workflows.
263
263
 
264
+ ### External review
265
+
266
+ `agent-ops review` can hand the review to another agent CLI, so the work is not
267
+ judged by the agent that produced it. Enable it during `agent-ops init` (the
268
+ default is off) and pick an ordered fallback chain of targets: `codex`, `agy`
269
+ (Antigravity), and `claude`. Each is launched with its own read-only flag, and a
270
+ target without one is skipped rather than run unsandboxed — which is why
271
+ `opencode` is not a review target despite being a supported harness.
272
+
273
+ The first target that actually runs produces the verdict. A `FAIL` is final:
274
+ the chain never retries elsewhere after a real verdict. `--yes` is still
275
+ required for every run, since each run spends another provider's quota.
276
+
277
+ Authentication is diagnosed, never guessed:
278
+
279
+ ```bash
280
+ agent-ops doctor # presence only: no tokens, no network
281
+ agent-ops doctor --check-auth # one real print call per configured target
282
+ ```
283
+
284
+ See [Configuration](docs/en/guides/configuration.md) for the full contract.
285
+
264
286
  ## Project principles
265
287
 
266
288
  - Define verifiable success before making changes.
@@ -13,6 +13,8 @@ export const COMMAND_NAMES = [
13
13
  const COMMAND_SET = new Set(COMMAND_NAMES);
14
14
  const SCOPES = new Set(["project", "user"]);
15
15
  const PROFILES = new Set(["advisory", "core", "guardrails", "loop"]);
16
+ // opencode is absent: it has no read-only flag, so it cannot review.
17
+ const REVIEW_TARGETS = new Set(["agy", "claude", "codex"]);
16
18
  export class CliArgumentError extends Error {
17
19
  code;
18
20
  option;
@@ -65,8 +67,10 @@ export function parseArgs(argv) {
65
67
  let title;
66
68
  let sessionId;
67
69
  const profiles = [];
70
+ const reviewTargets = [];
68
71
  const criteria = [];
69
72
  const evidence = [];
73
+ let checkAuth = false;
70
74
  let dryRun = false;
71
75
  let json = false;
72
76
  let yes = false;
@@ -121,6 +125,18 @@ export function parseArgs(argv) {
121
125
  index += 1;
122
126
  break;
123
127
  }
128
+ case "--review-target": {
129
+ const value = readOptionValue(argv, index, token);
130
+ if (!REVIEW_TARGETS.has(value)) {
131
+ invalidValue(token, value);
132
+ }
133
+ if (reviewTargets.includes(value)) {
134
+ duplicate(`${token} ${value}`);
135
+ }
136
+ reviewTargets.push(value);
137
+ index += 1;
138
+ break;
139
+ }
124
140
  case "--task": {
125
141
  if (taskId !== undefined) {
126
142
  duplicate(token);
@@ -163,6 +179,12 @@ export function parseArgs(argv) {
163
179
  index += 1;
164
180
  break;
165
181
  }
182
+ case "--check-auth":
183
+ if (checkAuth) {
184
+ duplicate(token);
185
+ }
186
+ checkAuth = true;
187
+ break;
166
188
  case "--dry-run":
167
189
  if (dryRun) {
168
190
  duplicate(token);
@@ -249,7 +271,9 @@ export function parseArgs(argv) {
249
271
  title !== undefined ||
250
272
  criteria.length > 0 ||
251
273
  evidence.length > 0 ||
274
+ reviewTargets.length > 0 ||
252
275
  sessionId !== undefined ||
276
+ checkAuth ||
253
277
  dryRun ||
254
278
  yes) {
255
279
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Only --json may be combined with global help or version.");
@@ -284,6 +308,12 @@ export function parseArgs(argv) {
284
308
  command !== "update") {
285
309
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--hook-target may be used only with init or update.");
286
310
  }
311
+ if (checkAuth && command !== "doctor") {
312
+ throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--check-auth may be used only with doctor.");
313
+ }
314
+ if (reviewTargets.length > 0 && command !== "init") {
315
+ throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--review-target may be used only with init.");
316
+ }
287
317
  if (command === "task") {
288
318
  if (harness !== undefined ||
289
319
  profiles.length > 0 ||
@@ -345,9 +375,11 @@ export function parseArgs(argv) {
345
375
  ...(taskId === undefined ? {} : { taskId }),
346
376
  ...(targetVersion === undefined ? {} : { targetVersion }),
347
377
  ...(title === undefined ? {} : { title }),
378
+ ...(reviewTargets.length === 0 ? {} : { reviewTargets }),
348
379
  ...(criteria.length === 0 ? {} : { criteria }),
349
380
  ...(evidence.length === 0 ? {} : { evidence }),
350
381
  ...(sessionId === undefined ? {} : { sessionId }),
382
+ ...(checkAuth ? { checkAuth } : {}),
351
383
  dryRun,
352
384
  json,
353
385
  yes
@@ -29,6 +29,9 @@ import { formatInstallPlan, runInitCommand } from "./commands/init.js";
29
29
  import { formatUninstallPlan, runUninstallCommand } from "./commands/uninstall.js";
30
30
  import { runTaskCommand } from "./commands/task.js";
31
31
  import { runReviewCommand } from "./commands/review.js";
32
+ import { createReviewExecutor } from "../../../runtime/src/review/execute.js";
33
+ import { probeReviewTarget } from "../../../runtime/src/review/probe.js";
34
+ import { resolveReviewRole } from "../../../runtime/src/review/roles.js";
32
35
  import { runTrustCommand } from "./commands/trust.js";
33
36
  import { runVerifyCommand } from "./commands/verify.js";
34
37
  import { formatUpdatePlan, runUpdateCommand } from "./commands/update.js";
@@ -139,8 +142,12 @@ else {
139
142
  sources: await hookSources(root, args.scope === "user" ? "user" : "project")
140
143
  }),
141
144
  repositoryTrust: async () => repositoryTrustStatus(await repositoryTrust(root, config, CLI_VERSION)),
142
- smokeAvailability: () => smokeAvailabilityStatus(config)
143
- }
145
+ smokeAvailability: () => smokeAvailabilityStatus(config),
146
+ reviewTarget: async (target, deep) => await probeReviewTarget(target, { cwd: root, deep })
147
+ },
148
+ ...(args.checkAuth === true
149
+ ? { checkReviewTargetAuth: true }
150
+ : {})
144
151
  });
145
152
  }
146
153
  if (args.command === "uninstall") {
@@ -179,9 +186,37 @@ else {
179
186
  });
180
187
  }
181
188
  if (args.command === "review") {
189
+ const reviewSessionId = process.env.AGENT_OPS_SESSION_ID;
190
+ const reviewConfig = (await loadEffectiveConfig(root, args.scope === "user" ? "user" : "project")).config;
191
+ const reviewRole = resolveReviewRole("independent-review", reviewConfig.reviewRoles ?? []);
182
192
  return await runReviewCommand({
183
193
  args,
184
- authorized: args.yes
194
+ authorized: args.yes,
195
+ tasks: taskService,
196
+ ...(reviewSessionId === undefined
197
+ ? {}
198
+ : { sessionId: reviewSessionId }),
199
+ ...(reviewConfig.reviewRoles === undefined
200
+ ? {}
201
+ : { roles: reviewConfig.reviewRoles }),
202
+ execute: createReviewExecutor({
203
+ targets: reviewRole?.targets ?? [],
204
+ cwd: root,
205
+ ...(reviewRole?.model === undefined
206
+ ? {}
207
+ : { model: reviewRole.model }),
208
+ ...(reviewRole?.effort === undefined
209
+ ? {}
210
+ : { effort: reviewRole.effort }),
211
+ ...(reviewRole?.timeoutMs === undefined
212
+ ? {}
213
+ : { timeoutMs: reviewRole.timeoutMs }),
214
+ onProgress: (line) => {
215
+ if (!args.json) {
216
+ process.stderr.write(`${line}\n`);
217
+ }
218
+ }
219
+ })
185
220
  });
186
221
  }
187
222
  if (args.command === "config") {
@@ -2,6 +2,7 @@ import { CliArgumentError, parseArgs } from "./args.js";
2
2
  import { AgentOpsError } from "../../../runtime/src/fs/paths.js";
3
3
  import { errorEnvelope, okEnvelope, writeEnvelope } from "./output.js";
4
4
  import { completeInitChoices } from "./wizard.js";
5
+ import { probeReviewTarget } from "../../../runtime/src/review/probe.js";
5
6
  import { BANNER } from "./ui.js";
6
7
  export function renderWelcome(color) {
7
8
  const cyan = color ? "\u001b[36m" : "";
@@ -32,6 +33,10 @@ Options:
32
33
  --harness <all|both|claude|codex|opencode|comma-separated> Init/update
33
34
  --hook-target <harness=surface-id> Repeatable advanced init/update option
34
35
  --profile <core|advisory|guardrails|loop> Repeatable
36
+ --review-target <codex|agy|claude> Repeatable init option; external review
37
+ targets in fallback-chain order
38
+ --check-auth Doctor only: probe each review target's
39
+ authentication with one real call
35
40
  --task <id>
36
41
  --target-version <version> Update target version (offline-capable)
37
42
  --title <text>
@@ -76,7 +81,13 @@ export async function runCli(argv, io, services) {
76
81
  }
77
82
  try {
78
83
  if (args.command === "init") {
79
- args = await completeInitChoices(args, args.json ? { ...io, isTTY: false } : io);
84
+ args = await completeInitChoices(args, args.json ? { ...io, isTTY: false } : io, {
85
+ probeReviewTarget: async (target) => (await probeReviewTarget(target, {
86
+ cwd: process.cwd(),
87
+ deep: true
88
+ })) === "ok",
89
+ warn: (message) => io.writeStderr(`${message}\n`)
90
+ });
80
91
  }
81
92
  const execute = args.command === "help" || args.command === "version"
82
93
  ? services.execute
@@ -63,7 +63,10 @@ export async function runInitCommand(options) {
63
63
  : { hookRuntimePath: options.hookRuntimePath }),
64
64
  ...((options.hookTargets ?? args.hookTargets) === undefined
65
65
  ? {}
66
- : { hookTargets: options.hookTargets ?? args.hookTargets })
66
+ : { hookTargets: options.hookTargets ?? args.hookTargets }),
67
+ ...(args.reviewTargets === undefined
68
+ ? {}
69
+ : { reviewTargets: args.reviewTargets })
67
70
  });
68
71
  if (args.dryRun) {
69
72
  return okEnvelope("INIT_PLAN_READY", {
@@ -3,18 +3,78 @@ import { runIndependentReview } from "../../../../runtime/src/review/runner.js";
3
3
  import { resolveReviewRole } from "../../../../runtime/src/review/roles.js";
4
4
  import { okEnvelope } from "../output.js";
5
5
  /**
6
- * Review runs against one harness. Argument parsing already rejects a
6
+ * Review runs against one target. Argument parsing already rejects a
7
7
  * multi-harness selection here, so the first entry is the whole selection.
8
+ * `opencode` is not a review target — it has no read-only flag — so selecting
9
+ * it leaves the target unresolved and the configured chain decides.
8
10
  */
9
11
  function harness(value) {
10
- return value?.[0] ?? "codex";
12
+ const selected = value?.[0];
13
+ return selected === undefined || selected === "opencode"
14
+ ? undefined
15
+ : selected;
11
16
  }
12
- export async function runReviewCommand(options) {
13
- const ids = options.args.criteria ?? [];
14
- const criteria = ids.map((id) => ({
15
- id,
16
- description: id
17
+ /**
18
+ * Criterion descriptions come from the task store, never from the id. A
19
+ * reviewer handed `criterion: tests` cannot review anything, so a review with
20
+ * no task context is reported as not run rather than run meaninglessly.
21
+ */
22
+ async function taskContext(options) {
23
+ const tasks = options.tasks;
24
+ if (tasks === undefined) {
25
+ return undefined;
26
+ }
27
+ const query = options.taskId !== undefined
28
+ ? { taskId: options.taskId }
29
+ : options.sessionId === undefined
30
+ ? undefined
31
+ : { sessionId: options.sessionId };
32
+ if (query === undefined) {
33
+ return undefined;
34
+ }
35
+ let record;
36
+ try {
37
+ record = await tasks.status(query);
38
+ }
39
+ catch {
40
+ return undefined;
41
+ }
42
+ const requested = options.args.criteria ?? [];
43
+ const criteria = record.task.criteria
44
+ .filter((criterion) => requested.length === 0 || requested.includes(criterion.id))
45
+ .map((criterion) => ({
46
+ id: criterion.id,
47
+ description: criterion.description,
48
+ verifierIds: [...criterion.verifierIds]
17
49
  }));
50
+ if (criteria.length === 0 ||
51
+ (requested.length > 0 && criteria.length !== requested.length)) {
52
+ return undefined;
53
+ }
54
+ return {
55
+ taskId: record.task.id,
56
+ active: record.status === "active",
57
+ criteria
58
+ };
59
+ }
60
+ function notRunEnvelope(result) {
61
+ const message = "Independent review was not run.";
62
+ return {
63
+ code: "REVIEW_NOT_RUN",
64
+ status: "error",
65
+ data: {
66
+ message,
67
+ result,
68
+ text: [message, `Reason: ${result.reason ?? "unknown"}.`, ""].join("\n")
69
+ },
70
+ errors: [{ code: "REVIEW_NOT_RUN", message }]
71
+ };
72
+ }
73
+ export async function runReviewCommand(options) {
74
+ const role = resolveReviewRole(options.role ?? "independent-review", options.roles ?? []);
75
+ const selectedHarness = harness(options.args.harness);
76
+ const target = role?.targets[0] ?? selectedHarness ?? "codex";
77
+ const context = await taskContext(options);
18
78
  const evidenceRequirements = (options.args.evidence ?? []).map((value) => {
19
79
  const separator = value.indexOf("=");
20
80
  return {
@@ -22,11 +82,22 @@ export async function runReviewCommand(options) {
22
82
  requirement: separator < 0 ? value : value.slice(separator + 1)
23
83
  };
24
84
  });
25
- const selectedHarness = harness(options.args.harness);
26
- const role = resolveReviewRole(options.role ?? "independent-review", options.roles ?? []);
85
+ if (options.tasks !== undefined && context === undefined) {
86
+ return notRunEnvelope({
87
+ status: "NOT_RUN",
88
+ reason: "no-task-context",
89
+ harness: target,
90
+ model: role?.model ?? options.model ?? "configured",
91
+ effort: role?.effort ?? options.effort ?? "configured",
92
+ prompt: ""
93
+ });
94
+ }
95
+ const criteria = context?.criteria !== undefined
96
+ ? [...context.criteria]
97
+ : (options.args.criteria ?? []).map((id) => ({ id, description: id }));
27
98
  const result = await runIndependentReview({
28
99
  invocation: {
29
- harness: role?.harness ?? selectedHarness,
100
+ harness: target,
30
101
  model: role?.model ?? options.model ?? "configured",
31
102
  effort: role?.effort ?? options.effort ?? "configured",
32
103
  packet: buildReviewPacket({
@@ -42,6 +113,17 @@ export async function runReviewCommand(options) {
42
113
  reason: "missing-cli"
43
114
  }))
44
115
  });
116
+ // Evidence is only appended while the task is active: a completed record
117
+ // must stay exactly as it was verified.
118
+ if (options.tasks !== undefined &&
119
+ context !== undefined &&
120
+ context.active &&
121
+ result.results !== undefined) {
122
+ await options.tasks.recordEvidence(context.taskId, Object.fromEntries(result.results.map((item) => [
123
+ item.criterionId,
124
+ item.evidence.map((reference) => `review:${target}:${reference}`)
125
+ ])));
126
+ }
45
127
  const message = result.status === "PASS"
46
128
  ? "Independent review passed."
47
129
  : result.status === "FAIL"
@@ -55,6 +137,11 @@ export async function runReviewCommand(options) {
55
137
  `Status: ${result.status}`,
56
138
  `Harness: ${result.harness}; model: ${result.model}; effort: ${result.effort}.`,
57
139
  ...(result.reason === undefined ? [] : [`Reason: ${result.reason}.`]),
140
+ ...(result.status === "NOT_RUN"
141
+ ? [
142
+ "Run: agent-ops doctor --check-auth to verify target authentication."
143
+ ]
144
+ : []),
58
145
  ...(result.results === undefined
59
146
  ? []
60
147
  : result.results.map((item) => `${item.criterionId}: ${item.status} [${item.evidence.join(", ")}]`)),
@@ -1,3 +1,3 @@
1
1
  // Single source of the published CLI version. Release preparation updates
2
2
  // this constant; bin.ts and the hook entry both read it from here.
3
- export const CLI_VERSION = "0.1.6";
3
+ export const CLI_VERSION = "0.1.7";
@@ -1,9 +1,49 @@
1
1
  import { CliArgumentError } from "./args.js";
2
+ import { DEFAULT_REVIEW_TARGETS } from "../../../runtime/src/review/roles.js";
2
3
  import { HARNESS_IDS, resolveHarnessSelection } from "../../../runtime/src/install/harness.js";
3
4
  import { selectOption, selectOptions } from "./ui.js";
4
5
  const SCOPES = new Set(["project", "user"]);
5
6
  const PROFILES = new Set(["advisory", "core", "guardrails", "loop"]);
6
7
  const DEFAULT_HARNESS = [];
8
+ const REVIEW_TARGET_SET = new Set(DEFAULT_REVIEW_TARGETS);
9
+ const REVIEW_TARGET_CHOICES = DEFAULT_REVIEW_TARGETS.map((id) => ({
10
+ label: id,
11
+ value: id,
12
+ description: id === "codex"
13
+ ? "Runs with -s read-only; stdout is the bare final message."
14
+ : id === "agy"
15
+ ? "Antigravity CLI; runs with --sandbox --mode plan."
16
+ : "Runs with --permission-mode plan; tried last when it is the host."
17
+ }));
18
+ function selectReviewTargets(raw) {
19
+ const values = raw
20
+ .split(",")
21
+ .map((value) => value.trim())
22
+ .filter((value) => value.length > 0);
23
+ for (const value of values) {
24
+ if (!REVIEW_TARGET_SET.has(value)) {
25
+ throw new CliArgumentError("CLI_INVALID_VALUE", `Invalid review target: ${value}`, "--review-target");
26
+ }
27
+ }
28
+ // Declared order wins: the chain order is the option list, not click order.
29
+ return DEFAULT_REVIEW_TARGETS.filter((target) => values.includes(target));
30
+ }
31
+ function affirmative(raw) {
32
+ return /^(y|yes)$/i.test(raw.trim());
33
+ }
34
+ async function probeReviewTargets(targets, setup) {
35
+ const probe = setup.probeReviewTarget;
36
+ if (probe === undefined) {
37
+ return;
38
+ }
39
+ for (const target of targets) {
40
+ if (!(await probe(target))) {
41
+ setup.warn?.(`${target} is not usable yet (missing or unauthenticated). ` +
42
+ `Install it or run: ${target} login, ` +
43
+ "then: agent-ops doctor --check-auth");
44
+ }
45
+ }
46
+ }
7
47
  const SCOPE_CHOICES = [
8
48
  { label: "project", value: "project" },
9
49
  { label: "user", value: "user" }
@@ -83,7 +123,7 @@ function selectProfiles(raw) {
83
123
  }
84
124
  return values;
85
125
  }
86
- export async function completeInitChoices(args, io) {
126
+ export async function completeInitChoices(args, io, setup = {}) {
87
127
  if (args.command !== "init" ||
88
128
  (args.scope !== undefined &&
89
129
  args.harness !== undefined &&
@@ -117,11 +157,25 @@ export async function completeInitChoices(args, io) {
117
157
  selectAllLabel: "Select all",
118
158
  selectAllDescription: "Enable core, advisory, guardrails, and loop together."
119
159
  });
160
+ const enabled = args.reviewTargets !== undefined ||
161
+ (await selectOption("External review: call another agent CLI to review your work?", [
162
+ { label: "no", value: false, description: "Default. Nothing is spawned." },
163
+ {
164
+ label: "yes",
165
+ value: true,
166
+ description: "Pick target CLIs; each is probed for authentication."
167
+ }
168
+ ], selectorIo));
169
+ const reviewTargets = args.reviewTargets ?? (enabled
170
+ ? await selectOptions("Review targets (multi-select: tried in listed order)", REVIEW_TARGET_CHOICES, selectorIo, [])
171
+ : []);
172
+ await probeReviewTargets(reviewTargets, setup);
120
173
  return {
121
174
  ...args,
122
175
  scope,
123
176
  harness,
124
- profiles
177
+ profiles,
178
+ ...(reviewTargets.length === 0 ? {} : { reviewTargets })
125
179
  };
126
180
  }
127
181
  const session = await createPromptSession(io);
@@ -133,11 +187,16 @@ export async function completeInitChoices(args, io) {
133
187
  const profiles = args.profiles.length > 0
134
188
  ? args.profiles
135
189
  : selectProfiles(await session.question("Profiles (core,advisory,guardrails,loop) [core]: "));
190
+ const reviewTargets = args.reviewTargets ?? (affirmative(await session.question("Enable external review by another agent CLI? [y/N]: "))
191
+ ? selectReviewTargets(await session.question(`Review targets (${DEFAULT_REVIEW_TARGETS.join(",")}): `))
192
+ : []);
193
+ await probeReviewTargets(reviewTargets, setup);
136
194
  return {
137
195
  ...args,
138
196
  scope,
139
197
  harness,
140
- profiles
198
+ profiles,
199
+ ...(reviewTargets.length === 0 ? {} : { reviewTargets })
141
200
  };
142
201
  }
143
202
  finally {
@@ -107,6 +107,7 @@ export function mergeConfigLayers(inputLayers) {
107
107
  const commands = new Map();
108
108
  const mappings = new Map();
109
109
  const exceptions = new Map();
110
+ const reviewRoles = new Map();
110
111
  let schemaVersion;
111
112
  let features;
112
113
  for (const layer of layers) {
@@ -128,6 +129,12 @@ export function mergeConfigLayers(inputLayers) {
128
129
  }
129
130
  mappings.set(key, effective(mapping, layer));
130
131
  }
132
+ // Keyed by role so a project can override one role without inheriting the
133
+ // rest. Review targets are a capability choice, not a guardrail, so no
134
+ // monotonic restriction applies.
135
+ for (const reviewRole of layer.config.reviewRoles ?? []) {
136
+ reviewRoles.set(reviewRole.role, effective(reviewRole, layer));
137
+ }
131
138
  for (const securityException of layer.config.securityExceptions) {
132
139
  const key = exceptionKey(securityException);
133
140
  const existing = exceptions.get(key);
@@ -150,7 +157,8 @@ export function mergeConfigLayers(inputLayers) {
150
157
  profiles: [...profiles.values()],
151
158
  verificationCommands: [...commands.values()],
152
159
  pathMappings: [...mappings.values()],
153
- securityExceptions: [...exceptions.values()]
160
+ securityExceptions: [...exceptions.values()],
161
+ reviewRoles: [...reviewRoles.values()]
154
162
  };
155
163
  const config = {
156
164
  schemaVersion: schemaVersion.value,
@@ -160,7 +168,14 @@ export function mergeConfigLayers(inputLayers) {
160
168
  },
161
169
  features: provenance.features.value,
162
170
  pathMappings: provenance.pathMappings.map(({ value }) => value),
163
- securityExceptions: provenance.securityExceptions.map(({ value }) => value)
171
+ securityExceptions: provenance.securityExceptions.map(({ value }) => value),
172
+ // Absent, not empty: an empty array would read as "configured with no
173
+ // targets" rather than "external review disabled".
174
+ ...(provenance.reviewRoles.length === 0
175
+ ? {}
176
+ : {
177
+ reviewRoles: provenance.reviewRoles.map(({ value }) => value)
178
+ })
164
179
  };
165
180
  const validation = validateConfig(config);
166
181
  if (!validation.ok) {
@@ -338,6 +338,46 @@ async function checkRegistrationDrift(root, manifest, config) {
338
338
  return check("registration-drift", "UNKNOWN", "Hook registration drift could not be assessed safely.");
339
339
  }
340
340
  }
341
+ /**
342
+ * Guidance lives in `message` rather than a `remediation` field: as of this
343
+ * check, `remediation` does not exist on DoctorCheck. Because target
344
+ * authentication failures surface as one unexplained review failure — the
345
+ * chain deliberately does not sniff stderr for "not logged in" — this text is
346
+ * the operator's only route out, so it names the exact command.
347
+ */
348
+ async function checkReviewTargets(config, probe, checkAuth) {
349
+ const targets = config?.reviewRoles?.find((role) => role.role === "independent-review")?.targets ?? [];
350
+ if (targets.length === 0) {
351
+ return check("review-targets", "PASS", "External review disabled. Re-run agent-ops init to enable.");
352
+ }
353
+ if (probe === undefined) {
354
+ return check("review-targets", "PASS", `External review targets: ${targets.join(", ")}. ` +
355
+ "Login state unverified; run: agent-ops doctor --check-auth");
356
+ }
357
+ for (const target of targets) {
358
+ const result = await probe(target, checkAuth);
359
+ if (result === "missing-executable") {
360
+ return check("review-targets", "FAIL", `${target} not found. Install it, or remove "${target}" from ` +
361
+ "reviewRoles[].targets.", "UPDATE_REQUIRED");
362
+ }
363
+ if (result === "ineligible") {
364
+ return check("review-targets", "FAIL", `${target} has no read-only mode and cannot review. Remove ` +
365
+ `"${target}" from reviewRoles[].targets.`, "UPDATE_REQUIRED");
366
+ }
367
+ if (result === "timeout") {
368
+ return check("review-targets", "FAIL", `${target} did not answer in time. Re-run: ` +
369
+ "agent-ops doctor --check-auth", "UPDATE_REQUIRED");
370
+ }
371
+ if (checkAuth && result !== "ok") {
372
+ return check("review-targets", "FAIL", `${target} is installed but not authenticated, or it rejected the ` +
373
+ `call. Run: ${target} login`, "UPDATE_REQUIRED");
374
+ }
375
+ }
376
+ return check("review-targets", "PASS", checkAuth
377
+ ? `External review targets authenticated: ${targets.join(", ")}.`
378
+ : `External review targets: ${targets.join(", ")}. ` +
379
+ "Login state unverified; run: agent-ops doctor --check-auth");
380
+ }
341
381
  export async function doctorInstallation(options) {
342
382
  const manifest = await checkManifest(options.root);
343
383
  const config = await checkConfig(options.root);
@@ -355,7 +395,8 @@ export async function doctorInstallation(options) {
355
395
  await checkProbe("hook-registration", options.probes?.hookRegistration),
356
396
  checkLifecycleSummary(manifest.manifest, config.config),
357
397
  await checkProbe("repository-trust", options.probes?.repositoryTrust),
358
- await checkProbe("smoke-availability", options.probes?.smokeAvailability)
398
+ await checkProbe("smoke-availability", options.probes?.smokeAvailability),
399
+ await checkReviewTargets(config.config, options.probes?.reviewTarget, options.checkReviewTargetAuth === true)
359
400
  ];
360
401
  return {
361
402
  checks,
@@ -40,7 +40,12 @@ async function readCurrentFile(root, path) {
40
40
  throw error;
41
41
  }
42
42
  }
43
- function formatConfig(profiles, existing) {
43
+ function formatConfig(profiles, existing, reviewTargets = []) {
44
+ // Absent reviewRoles means external review is disabled; an empty selection
45
+ // must therefore omit the field rather than write an empty array.
46
+ const reviewRoles = reviewTargets.length > 0
47
+ ? [{ role: "independent-review", targets: [...reviewTargets] }]
48
+ : existing?.reviewRoles;
44
49
  return `${JSON.stringify({
45
50
  schemaVersion: CONFIG_SCHEMA_VERSION,
46
51
  profiles,
@@ -51,10 +56,11 @@ function formatConfig(profiles, existing) {
51
56
  }
52
57
  },
53
58
  pathMappings: existing?.pathMappings ?? [],
54
- securityExceptions: existing?.securityExceptions ?? []
59
+ securityExceptions: existing?.securityExceptions ?? [],
60
+ ...(reviewRoles === undefined ? {} : { reviewRoles })
55
61
  }, null, 2)}\n`;
56
62
  }
57
- async function planConfig(root, profiles, existingManifest, suppliedConfig) {
63
+ async function planConfig(root, profiles, existingManifest, suppliedConfig, reviewTargets = []) {
58
64
  const current = await readCurrentFile(root, CONFIG_PATH);
59
65
  const owned = findOwnedArtifact(existingManifest, CONFIG_PATH);
60
66
  if (current !== null && owned === undefined) {
@@ -86,7 +92,7 @@ async function planConfig(root, profiles, existingManifest, suppliedConfig) {
86
92
  }
87
93
  existingConfig = result.value;
88
94
  }
89
- const content = formatConfig(profiles, existingConfig);
95
+ const content = formatConfig(profiles, existingConfig, reviewTargets);
90
96
  return {
91
97
  operation: {
92
98
  kind: "write",
@@ -413,7 +419,7 @@ export async function createInstallPlan(options) {
413
419
  : [];
414
420
  const operations = [];
415
421
  const artifacts = [];
416
- const config = await planConfig(options.root, resolved.profiles, existing?.manifest ?? null, options.existingConfig);
422
+ const config = await planConfig(options.root, resolved.profiles, existing?.manifest ?? null, options.existingConfig, options.reviewTargets ?? []);
417
423
  operations.push(config.operation);
418
424
  artifacts.push(config.record);
419
425
  for (const artifact of contribution.artifacts) {