@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 +22 -0
- package/dist/packages/cli/src/args.js +32 -0
- package/dist/packages/cli/src/bin.js +38 -3
- package/dist/packages/cli/src/cli.js +12 -1
- package/dist/packages/cli/src/commands/init.js +4 -1
- package/dist/packages/cli/src/commands/review.js +97 -10
- package/dist/packages/cli/src/version.js +1 -1
- package/dist/packages/cli/src/wizard.js +62 -3
- package/dist/runtime/src/config/merge.js +17 -2
- package/dist/runtime/src/install/doctor.js +42 -1
- package/dist/runtime/src/install/plan.js +11 -5
- package/dist/runtime/src/review/execute.js +120 -0
- package/dist/runtime/src/review/extract.js +71 -0
- package/dist/runtime/src/review/invocation.js +52 -0
- package/dist/runtime/src/review/probe.js +48 -0
- package/dist/runtime/src/review/result.js +2 -2
- package/dist/runtime/src/review/roles.js +35 -0
- package/dist/runtime/src/review/runner.js +38 -4
- package/dist/runtime/src/schema/validate.js +62 -0
- package/dist/runtime/src/task/service.js +40 -0
- package/docs/en/guides/configuration.md +60 -0
- package/docs/en/spec/review.md +37 -4
- package/docs/zh-TW/guides/configuration.md +53 -0
- package/docs/zh-TW/spec/review.md +33 -3
- package/package.json +1 -1
- package/schemas/config.schema.json +29 -0
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
|
|
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
|
-
|
|
12
|
+
const selected = value?.[0];
|
|
13
|
+
return selected === undefined || selected === "opencode"
|
|
14
|
+
? undefined
|
|
15
|
+
: selected;
|
|
11
16
|
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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:
|
|
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,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) {
|