@kylecheng3146/agent-ops 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +27 -0
  2. package/dist/packages/cli/src/args.js +47 -0
  3. package/dist/packages/cli/src/bin.js +74 -25
  4. package/dist/packages/cli/src/cli.js +13 -1
  5. package/dist/packages/cli/src/commands/init.js +4 -1
  6. package/dist/packages/cli/src/commands/review.js +371 -27
  7. package/dist/packages/cli/src/commands/task.js +4 -1
  8. package/dist/packages/cli/src/commands/verify.js +13 -1
  9. package/dist/packages/cli/src/version.js +1 -1
  10. package/dist/packages/cli/src/wizard.js +62 -3
  11. package/dist/runtime/src/config/merge.js +17 -2
  12. package/dist/runtime/src/contracts.js +1 -1
  13. package/dist/runtime/src/install/doctor.js +42 -1
  14. package/dist/runtime/src/install/plan.js +11 -5
  15. package/dist/runtime/src/review/execute.js +180 -0
  16. package/dist/runtime/src/review/extract.js +69 -0
  17. package/dist/runtime/src/review/invocation.js +116 -0
  18. package/dist/runtime/src/review/packet.js +42 -5
  19. package/dist/runtime/src/review/probe.js +72 -0
  20. package/dist/runtime/src/review/render.js +62 -0
  21. package/dist/runtime/src/review/report.js +183 -0
  22. package/dist/runtime/src/review/result.js +2 -2
  23. package/dist/runtime/src/review/roles.js +35 -0
  24. package/dist/runtime/src/review/runner.js +98 -12
  25. package/dist/runtime/src/review/scope.js +123 -0
  26. package/dist/runtime/src/schema/validate.js +80 -0
  27. package/dist/runtime/src/task/service.js +46 -1
  28. package/dist/runtime/src/task/store.js +16 -4
  29. package/dist/runtime/src/verify/change-surface.js +38 -2
  30. package/dist/runtime/src/verify/command-executor.js +4 -1
  31. package/dist/runtime/src/verify/evidence.js +36 -0
  32. package/dist/runtime/src/verify/scope.js +1 -2
  33. package/dist/runtime/src/verify/service.js +66 -9
  34. package/dist/runtime/src/verify/source-fingerprint.js +49 -0
  35. package/dist/runtime/src/verify/spawn.js +9 -3
  36. package/docs/en/guides/configuration.md +68 -0
  37. package/docs/en/spec/review.md +37 -4
  38. package/docs/zh-TW/guides/configuration.md +60 -0
  39. package/docs/zh-TW/spec/review.md +33 -3
  40. package/package.json +1 -1
  41. package/schemas/config.schema.json +29 -0
  42. package/schemas/evidence.schema.json +16 -1
  43. package/schemas/review-report.schema.json +48 -0
@@ -1,20 +1,222 @@
1
1
  import { buildReviewPacket } from "../../../../runtime/src/review/packet.js";
2
2
  import { runIndependentReview } from "../../../../runtime/src/review/runner.js";
3
+ import { renderReviewResult } from "../../../../runtime/src/review/render.js";
3
4
  import { resolveReviewRole } from "../../../../runtime/src/review/roles.js";
5
+ import { AgentOpsError } from "../../../../runtime/src/fs/paths.js";
6
+ import { assertSafeSupportingPaths, isReviewerPolicyPath, resolveReviewScope, reviewScopeSignature } from "../../../../runtime/src/review/scope.js";
7
+ import { calculateSourceFingerprint } from "../../../../runtime/src/verify/source-fingerprint.js";
8
+ import { calculateConfigHash, isPassingVerificationEvidence } from "../../../../runtime/src/verify/evidence.js";
9
+ import { validateEvidence } from "../../../../runtime/src/schema/validate.js";
4
10
  import { okEnvelope } from "../output.js";
5
11
  /**
6
- * Review runs against one harness. Argument parsing already rejects a
12
+ * Review runs against one target. Argument parsing already rejects a
7
13
  * multi-harness selection here, so the first entry is the whole selection.
14
+ * `opencode` is not a review target — it has no read-only flag — so selecting
15
+ * it leaves the target unresolved and the configured chain decides.
8
16
  */
9
17
  function harness(value) {
10
- return value?.[0] ?? "codex";
18
+ const selected = value?.[0];
19
+ return selected === undefined || selected === "opencode"
20
+ ? undefined
21
+ : selected;
11
22
  }
12
- export async function runReviewCommand(options) {
13
- const ids = options.args.criteria ?? [];
14
- const criteria = ids.map((id) => ({
15
- id,
16
- description: id
23
+ /**
24
+ * Criterion descriptions come from the task store, never from the id. A
25
+ * reviewer handed `criterion: tests` cannot review anything, so a review with
26
+ * no task context is reported as not run rather than run meaninglessly.
27
+ */
28
+ async function taskContext(options) {
29
+ const tasks = options.tasks;
30
+ if (tasks === undefined) {
31
+ return undefined;
32
+ }
33
+ const query = options.taskId !== undefined
34
+ ? { taskId: options.taskId }
35
+ : options.sessionId === undefined
36
+ ? undefined
37
+ : { sessionId: options.sessionId };
38
+ if (query === undefined) {
39
+ return undefined;
40
+ }
41
+ let record;
42
+ try {
43
+ record = await tasks.status(query);
44
+ }
45
+ catch {
46
+ return undefined;
47
+ }
48
+ const requested = options.args.criteria ?? [];
49
+ const criteria = record.task.criteria
50
+ .filter((criterion) => requested.length === 0 || requested.includes(criterion.id))
51
+ .map((criterion) => ({
52
+ id: criterion.id,
53
+ description: criterion.description,
54
+ verifierIds: [...criterion.verifierIds]
17
55
  }));
56
+ if (criteria.length === 0 ||
57
+ (requested.length > 0 && criteria.length !== requested.length)) {
58
+ return undefined;
59
+ }
60
+ return {
61
+ taskId: record.task.id,
62
+ title: record.task.title,
63
+ active: record.status === "active",
64
+ policyConfigHash: record.policyConfigHash,
65
+ evidence: record.evidence,
66
+ failureFingerprint: record.failureFingerprint,
67
+ criteria
68
+ };
69
+ }
70
+ function newestEvidence(values) {
71
+ return [...values].sort((left, right) => {
72
+ const leftTime = Date.parse(left.evidence.finishedAt);
73
+ const rightTime = Date.parse(right.evidence.finishedAt);
74
+ return rightTime - leftTime || left.reference.localeCompare(right.reference);
75
+ })[0];
76
+ }
77
+ async function currentEvidence(options, context, criterionId, commandId, configHash, sourceFingerprint) {
78
+ const current = [];
79
+ let hasReference = false;
80
+ let unreadable = false;
81
+ let stale = false;
82
+ for (const reference of context.evidence[criterionId] ?? []) {
83
+ if (reference.startsWith("review:")) {
84
+ continue;
85
+ }
86
+ hasReference = true;
87
+ let stored;
88
+ try {
89
+ stored = await options.evidenceStore?.load(reference) ?? null;
90
+ }
91
+ catch {
92
+ unreadable = true;
93
+ continue;
94
+ }
95
+ const validation = validateEvidence(stored);
96
+ if (!validation.ok) {
97
+ unreadable = true;
98
+ continue;
99
+ }
100
+ const evidence = validation.value;
101
+ if (evidence.schemaVersion !== 2 ||
102
+ evidence.taskId !== context.taskId ||
103
+ evidence.criterionId !== criterionId ||
104
+ evidence.commandId !== commandId ||
105
+ evidence.configHash !== configHash ||
106
+ evidence.sourceFingerprint !== sourceFingerprint) {
107
+ stale = true;
108
+ continue;
109
+ }
110
+ current.push({ reference, evidence });
111
+ }
112
+ return { current, hasReference, unreadable, stale };
113
+ }
114
+ async function preflightReview(options, context, scope) {
115
+ if (options.config === undefined ||
116
+ options.evidenceStore === undefined ||
117
+ options.root === undefined ||
118
+ options.gitRunner === undefined ||
119
+ context.failureFingerprint !== null) {
120
+ return { ok: false, reason: "stale-verification" };
121
+ }
122
+ const configHash = calculateConfigHash(options.config);
123
+ const sourceFingerprint = await calculateSourceFingerprint(options.root, scope, options.gitRunner);
124
+ const commands = [];
125
+ for (const criterion of context.criteria) {
126
+ for (const commandId of criterion.verifierIds ?? []) {
127
+ const command = options.config.verification.commands.find((candidate) => candidate.id === commandId);
128
+ if (command === undefined) {
129
+ return { ok: false, reason: "missing-verification-evidence" };
130
+ }
131
+ const found = await currentEvidence(options, context, criterion.id, commandId, configHash, sourceFingerprint);
132
+ const selected = newestEvidence(found.current);
133
+ if (command.required !== true) {
134
+ if (selected !== undefined) {
135
+ commands.push({
136
+ criterionId: criterion.id,
137
+ commandId,
138
+ required: false,
139
+ status: selected.evidence.status,
140
+ evidenceReference: selected.reference
141
+ });
142
+ }
143
+ continue;
144
+ }
145
+ if (selected === undefined) {
146
+ return {
147
+ ok: false,
148
+ reason: found.unreadable
149
+ ? "unreadable-verification-evidence"
150
+ : found.stale
151
+ ? "stale-verification"
152
+ : found.hasReference
153
+ ? "missing-verification-evidence"
154
+ : "missing-verification-evidence"
155
+ };
156
+ }
157
+ if (!isPassingVerificationEvidence(command, selected.evidence)) {
158
+ return { ok: false, reason: "verification-not-passed" };
159
+ }
160
+ commands.push({
161
+ criterionId: criterion.id,
162
+ commandId,
163
+ required: true,
164
+ status: "PASS",
165
+ evidenceReference: selected.reference
166
+ });
167
+ }
168
+ }
169
+ return {
170
+ ok: true,
171
+ summary: { status: "PASS", sourceFingerprint, commands }
172
+ };
173
+ }
174
+ function scopeReason(error) {
175
+ if (!(error instanceof AgentOpsError)) {
176
+ return undefined;
177
+ }
178
+ return {
179
+ REVIEW_UNSAFE_PATH: "unsafe-review-path",
180
+ REVIEW_NO_CHANGE_SURFACE: "no-change-surface",
181
+ REVIEW_DIRTY_WORKTREE: "dirty-worktree",
182
+ REVIEW_INVALID_BASE: "invalid-base"
183
+ }[error.code];
184
+ }
185
+ function notRunEnvelope(result) {
186
+ const message = "Independent review was not run.";
187
+ return {
188
+ code: "REVIEW_NOT_RUN",
189
+ status: "error",
190
+ data: {
191
+ message,
192
+ result,
193
+ text: renderReviewResult(result)
194
+ },
195
+ errors: [{ code: "REVIEW_NOT_RUN", message }]
196
+ };
197
+ }
198
+ function sourceChangedResult(result) {
199
+ return {
200
+ status: "NOT_RUN",
201
+ reason: "source-changed-during-review",
202
+ harness: result.harness,
203
+ model: result.model,
204
+ effort: result.effort,
205
+ prompt: result.prompt,
206
+ ...(result.scope === undefined ? {} : { scope: result.scope }),
207
+ ...(result.independence === undefined
208
+ ? {}
209
+ : { independence: result.independence }),
210
+ ...(result.verification === undefined
211
+ ? {}
212
+ : { verification: result.verification })
213
+ };
214
+ }
215
+ export async function runReviewCommand(options) {
216
+ const role = resolveReviewRole(options.role ?? "independent-review", options.roles ?? []);
217
+ const selectedHarness = harness(options.args.harness);
218
+ const target = role?.targets[0] ?? selectedHarness ?? "codex";
219
+ const context = await taskContext(options);
18
220
  const evidenceRequirements = (options.args.evidence ?? []).map((value) => {
19
221
  const separator = value.indexOf("=");
20
222
  return {
@@ -22,19 +224,115 @@ export async function runReviewCommand(options) {
22
224
  requirement: separator < 0 ? value : value.slice(separator + 1)
23
225
  };
24
226
  });
25
- const selectedHarness = harness(options.args.harness);
26
- const role = resolveReviewRole(options.role ?? "independent-review", options.roles ?? []);
227
+ if (context === undefined) {
228
+ return notRunEnvelope({
229
+ status: "NOT_RUN",
230
+ reason: "no-task-context",
231
+ harness: target,
232
+ model: role?.model ?? options.model ?? "configured",
233
+ effort: role?.effort ?? options.effort ?? "configured",
234
+ prompt: ""
235
+ });
236
+ }
237
+ let scope;
238
+ let verification;
239
+ if (options.root !== undefined && options.gitRunner !== undefined) {
240
+ try {
241
+ scope = await resolveReviewScope({
242
+ root: options.root,
243
+ runner: options.gitRunner,
244
+ ...(options.args.base === undefined ? {} : { base: options.args.base })
245
+ });
246
+ }
247
+ catch (error) {
248
+ const reason = scopeReason(error);
249
+ if (reason !== undefined) {
250
+ return notRunEnvelope({
251
+ status: "NOT_RUN",
252
+ reason,
253
+ harness: target,
254
+ model: role?.model ?? options.model ?? "configured",
255
+ effort: role?.effort ?? options.effort ?? "configured",
256
+ prompt: ""
257
+ });
258
+ }
259
+ throw error;
260
+ }
261
+ if (scope.changedFiles.some(isReviewerPolicyPath)) {
262
+ return notRunEnvelope({
263
+ status: "NOT_RUN",
264
+ reason: "reviewer-policy-changed",
265
+ harness: target,
266
+ model: role?.model ?? options.model ?? "configured",
267
+ effort: role?.effort ?? options.effort ?? "configured",
268
+ prompt: "",
269
+ scope
270
+ });
271
+ }
272
+ if (options.policyConfigHash !== undefined) {
273
+ if (context.policyConfigHash === null) {
274
+ return notRunEnvelope({
275
+ status: "NOT_RUN", reason: "reviewer-policy-baseline-missing",
276
+ harness: target, model: role?.model ?? options.model ?? "configured",
277
+ effort: role?.effort ?? options.effort ?? "configured", prompt: "", scope
278
+ });
279
+ }
280
+ if (context.policyConfigHash !== options.policyConfigHash) {
281
+ return notRunEnvelope({
282
+ status: "NOT_RUN", reason: "reviewer-policy-changed",
283
+ harness: target, model: role?.model ?? options.model ?? "configured",
284
+ effort: role?.effort ?? options.effort ?? "configured", prompt: "", scope
285
+ });
286
+ }
287
+ }
288
+ const preflight = await preflightReview(options, context, scope);
289
+ if (!preflight.ok) {
290
+ return notRunEnvelope({
291
+ status: "NOT_RUN", reason: preflight.reason,
292
+ harness: target, model: role?.model ?? options.model ?? "configured",
293
+ effort: role?.effort ?? options.effort ?? "configured", prompt: "", scope
294
+ });
295
+ }
296
+ verification = preflight.summary;
297
+ }
298
+ const criteria = [...context.criteria];
299
+ let packet;
300
+ try {
301
+ packet = buildReviewPacket({
302
+ request: context.title,
303
+ criteria,
304
+ artifactRefs: scope?.changedFiles ?? [],
305
+ evidenceRequirements
306
+ });
307
+ }
308
+ catch (error) {
309
+ if (error instanceof AgentOpsError) {
310
+ const reason = error.code === "REVIEW_SENSITIVE_INPUT"
311
+ ? "sensitive-review-input"
312
+ : error.code === "REVIEW_SCOPE_TOO_LARGE"
313
+ ? "scope-too-large"
314
+ : undefined;
315
+ if (reason !== undefined) {
316
+ return notRunEnvelope({
317
+ status: "NOT_RUN",
318
+ reason,
319
+ harness: target,
320
+ model: role?.model ?? options.model ?? "configured",
321
+ effort: role?.effort ?? options.effort ?? "configured",
322
+ prompt: ""
323
+ });
324
+ }
325
+ }
326
+ throw error;
327
+ }
27
328
  const result = await runIndependentReview({
28
329
  invocation: {
29
- harness: role?.harness ?? selectedHarness,
330
+ harness: target,
30
331
  model: role?.model ?? options.model ?? "configured",
31
332
  effort: role?.effort ?? options.effort ?? "configured",
32
- packet: buildReviewPacket({
33
- request: "Review the requested implementation.",
34
- criteria,
35
- artifactRefs: [],
36
- evidenceRequirements
37
- })
333
+ packet,
334
+ ...(scope === undefined ? {} : { scope }),
335
+ ...(verification === undefined ? {} : { verification })
38
336
  },
39
337
  authorized: options.authorized,
40
338
  execute: options.execute ?? (async () => ({
@@ -42,6 +340,62 @@ export async function runReviewCommand(options) {
42
340
  reason: "missing-cli"
43
341
  }))
44
342
  });
343
+ if (scope !== undefined && result.report !== undefined && options.root !== undefined) {
344
+ try {
345
+ if (result.report.changedFilesInspected.length !== scope.changedFiles.length ||
346
+ result.report.changedFilesInspected.some((path) => !scope?.changedFiles.includes(path))) {
347
+ return notRunEnvelope({
348
+ ...result,
349
+ status: "NOT_RUN",
350
+ reason: "incomplete-scope"
351
+ });
352
+ }
353
+ try {
354
+ await assertSafeSupportingPaths(options.root, result.report.supportingFilesInspected);
355
+ }
356
+ catch (error) {
357
+ if (scopeReason(error) === "unsafe-review-path") {
358
+ return notRunEnvelope({
359
+ ...result,
360
+ status: "NOT_RUN",
361
+ reason: "unsafe-review-path"
362
+ });
363
+ }
364
+ throw error;
365
+ }
366
+ const postflight = await resolveReviewScope({
367
+ root: options.root,
368
+ runner: options.gitRunner,
369
+ ...(options.args.base === undefined ? {} : { base: options.args.base })
370
+ });
371
+ const currentHash = options.currentPolicyConfigHash === undefined
372
+ ? options.policyConfigHash
373
+ : await options.currentPolicyConfigHash();
374
+ const postflightFingerprint = verification === undefined
375
+ ? undefined
376
+ : await calculateSourceFingerprint(options.root, postflight, options.gitRunner);
377
+ if (reviewScopeSignature(scope) !== reviewScopeSignature(postflight) ||
378
+ (options.policyConfigHash !== undefined && currentHash !== options.policyConfigHash) ||
379
+ (verification !== undefined &&
380
+ postflightFingerprint !== verification.sourceFingerprint)) {
381
+ return notRunEnvelope(sourceChangedResult(result));
382
+ }
383
+ }
384
+ catch {
385
+ return notRunEnvelope(sourceChangedResult(result));
386
+ }
387
+ }
388
+ // Evidence is only appended while the task is active: a completed record
389
+ // must stay exactly as it was verified.
390
+ if (options.tasks !== undefined &&
391
+ result.status === "PASS" &&
392
+ context.active &&
393
+ result.results !== undefined) {
394
+ await options.tasks.recordEvidence(context.taskId, Object.fromEntries(result.results.map((item) => [
395
+ item.criterionId,
396
+ item.evidence.map((reference) => `review:${result.harness}:${reference}`)
397
+ ])));
398
+ }
45
399
  const message = result.status === "PASS"
46
400
  ? "Independent review passed."
47
401
  : result.status === "FAIL"
@@ -50,17 +404,7 @@ export async function runReviewCommand(options) {
50
404
  const data = {
51
405
  message,
52
406
  result,
53
- text: [
54
- message,
55
- `Status: ${result.status}`,
56
- `Harness: ${result.harness}; model: ${result.model}; effort: ${result.effort}.`,
57
- ...(result.reason === undefined ? [] : [`Reason: ${result.reason}.`]),
58
- ...(result.results === undefined
59
- ? []
60
- : result.results.map((item) => `${item.criterionId}: ${item.status} [${item.evidence.join(", ")}]`)),
61
- result.prompt,
62
- ""
63
- ].join("\n")
407
+ text: renderReviewResult(result)
64
408
  };
65
409
  if (result.status === "PASS") {
66
410
  return okEnvelope("REVIEW_RESULT", data);
@@ -89,7 +89,10 @@ export async function runTaskCommand(options) {
89
89
  }
90
90
  const record = await options.service.create({
91
91
  title: options.args.title,
92
- criteria: (options.args.criteria ?? []).map(parseCriterion)
92
+ criteria: (options.args.criteria ?? []).map(parseCriterion),
93
+ ...(options.policyConfigHash === undefined
94
+ ? {}
95
+ : { policyConfigHash: options.policyConfigHash })
93
96
  });
94
97
  return taskEnvelope(action, "TASK_CREATED", `Created task ${record.task.id}.`, record);
95
98
  }
@@ -41,7 +41,19 @@ function publicReport(report) {
41
41
  testCount: result.testCount,
42
42
  evidenceReferences: result.evidenceReferences.map(redactSecrets)
43
43
  })),
44
- signal: report.signal
44
+ signal: report.signal,
45
+ reviewScope: report.reviewScope.mode === "base"
46
+ ? {
47
+ mode: "base",
48
+ baseRef: redactSecrets(report.reviewScope.baseRef),
49
+ resolvedBase: report.reviewScope.resolvedBase,
50
+ changedFiles: report.reviewScope.changedFiles.map(redactSecrets)
51
+ }
52
+ : {
53
+ mode: "worktree",
54
+ changedFiles: report.reviewScope.changedFiles.map(redactSecrets)
55
+ },
56
+ sourceFingerprint: report.sourceFingerprint
45
57
  };
46
58
  }
47
59
  function formatResult(result) {
@@ -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
+ ? "Retained for future isolation support; currently not auto-run."
14
+ : id === "agy"
15
+ ? "Retained for future isolation support; currently not auto-run."
16
+ : "Runs in fresh safe mode with context isolation."
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) {
@@ -1,6 +1,6 @@
1
1
  export const CONFIG_SCHEMA_VERSION = 2;
2
2
  export const TASK_SCHEMA_VERSION = 1;
3
- export const EVIDENCE_SCHEMA_VERSION = 1;
3
+ export const EVIDENCE_SCHEMA_VERSION = 2;
4
4
  /** @deprecated Use the document-specific schema version constants. */
5
5
  export const SCHEMA_VERSION = CONFIG_SCHEMA_VERSION;
6
6
  /**