@kylecheng3146/agent-ops 0.2.2 → 0.2.4

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
@@ -301,12 +301,12 @@ the project configuration defines those workflows.
301
301
 
302
302
  ### External review
303
303
 
304
- `agent-ops review` can hand the review to another agent CLI, so the work is not
305
- judged by the agent that produced it. Enable it during `agent-ops init` (the
306
- default is off). A bare review applies the built-in `change-quality` criterion
307
- to a deterministic worktree (or `--base`) scope. `--task` reviews keep their
308
- task criteria and require current verification evidence. The detailed report
309
- is displayed; a minimal source-fingerprint attestation is persisted after PASS.
304
+ `agent-ops review --task <id> --yes` can hand a complete task-bound review to
305
+ agent CLIs, so the work is not judged only by the agent that produced it.
306
+ Enable review targets during `agent-ops init` (the default is off). Every task
307
+ criterion and its original description are reviewed; partial criteria,
308
+ generic reviews, and missing `--yes` are rejected. Current verification
309
+ evidence is required before any reviewer starts.
310
310
 
311
311
  Each attempt uses a fresh temporary session and disposable repository clone, a
312
312
  small allowlisted environment, and a target-native read-only mode. Claude
@@ -315,14 +315,28 @@ persistence, while Agy runs in sandboxed plan mode. Codex and Agy preserve their
315
315
  existing login environment, so their context isolation is intentionally weaker
316
316
  than Claude's. `opencode` is not a review target.
317
317
 
318
- The chain prefers a reviewer CLI different from the hosting CLI. If none is
319
- usable, a fresh same-target session is allowed but is rendered as
320
- `DEGRADED: isolated self-review`; a development session is never resumed.
321
-
322
- The first valid `PASS` or `FAIL` is final. Attempts that produce no verdict —
323
- including login failures and unparseable output — advance to the next target
324
- and remain visible in the result's `attempts`. `--yes` is still required for
325
- every run, since each run spends another provider's quota.
318
+ Exactly two fresh sessions are planned. With three configured targets, the
319
+ explicit `AGENT_OPS_HOST` is excluded and `agy` is primary when available; with
320
+ two targets both run in configured order; with one target the same CLI is run
321
+ twice. The first session is the necessary review. Only its PASS starts the
322
+ second adversarial session, which receives the first full redacted report as
323
+ untrusted data. The first FAIL or NOT_RUN stops; a second FAIL is final FAIL,
324
+ and a missing second verdict is NOT_RUN. A same-target pair is still
325
+ independent because the sessions and clones are fresh.
326
+
327
+ Host network or loopback restrictions return `REVIEW_NOT_RUN` before any
328
+ reviewer is started. The managed instructions require a trusted outer host
329
+ runner to start the exact same command with both capabilities on its first
330
+ invocation; an in-sandbox retry is not an elevation. The result remains
331
+ `NOT_RUN` if that host is unavailable. There is no repository-level permission
332
+ bypass.
333
+ Reviewer attempts and preflight diagnostics remain visible in JSON. `--yes` is
334
+ required because a complete run spends provider quota twice.
335
+
336
+ After a complete PASS, the full redacted primary and adversarial reports are
337
+ stored privately under `.agent-ops/reviews/`; the source-fingerprint attestation
338
+ stores only the task, reviewer/session identities, report digests, and artifact
339
+ reference. Any artifact or attestation write failure is NOT_RUN, never PASS.
326
340
 
327
341
  Authentication is diagnosed, never guessed:
328
342
 
@@ -74,6 +74,7 @@ export function parseArgs(argv) {
74
74
  const criteria = [];
75
75
  const evidence = [];
76
76
  let checkAuth = false;
77
+ const checkAuthTargets = [];
77
78
  let completionGate;
78
79
  let dryRun = false;
79
80
  let json = false;
@@ -205,6 +206,18 @@ export function parseArgs(argv) {
205
206
  }
206
207
  checkAuth = true;
207
208
  break;
209
+ case "--check-auth-target": {
210
+ const value = readOptionValue(argv, index, token);
211
+ if (!REVIEW_TARGETS.has(value)) {
212
+ invalidValue(token, value);
213
+ }
214
+ if (checkAuthTargets.includes(value)) {
215
+ duplicate(`${token} ${value}`);
216
+ }
217
+ checkAuthTargets.push(value);
218
+ index += 1;
219
+ break;
220
+ }
208
221
  case "--completion-gate":
209
222
  if (completionGate !== undefined) {
210
223
  duplicate(token);
@@ -322,6 +335,7 @@ export function parseArgs(argv) {
322
335
  sessionId !== undefined ||
323
336
  base !== undefined ||
324
337
  checkAuth ||
338
+ checkAuthTargets.length > 0 ||
325
339
  dryRun ||
326
340
  yes) {
327
341
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Only --json may be combined with global help or version.");
@@ -367,9 +381,15 @@ export function parseArgs(argv) {
367
381
  if (checkAuth && command !== "doctor") {
368
382
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--check-auth may be used only with doctor.");
369
383
  }
384
+ if (checkAuthTargets.length > 0 && command !== "doctor") {
385
+ throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--check-auth-target may be used only with doctor.");
386
+ }
387
+ if (checkAuthTargets.length > 0 && !checkAuth) {
388
+ throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--check-auth-target requires --check-auth.");
389
+ }
370
390
  if (reviewTargets.length > 0 && command !== "init") {
371
391
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", command === "review"
372
- ? "--review-target configures init; for one review run, use --harness <target>."
392
+ ? "--review-target configures init; review uses the configured reviewer pair."
373
393
  : "--review-target may be used only with init.");
374
394
  }
375
395
  if (completionGate !== undefined && command !== "init") {
@@ -425,18 +445,20 @@ export function parseArgs(argv) {
425
445
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Verify accepts only scope, task or session, base, and json options.");
426
446
  }
427
447
  if (command === "review" &&
428
- (title !== undefined || sessionId !== undefined)) {
429
- throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Review accepts harness, criteria, evidence, scope, dry-run, json, and yes options.");
448
+ (title !== undefined || sessionId !== undefined || dryRun)) {
449
+ throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Review accepts task, base, json, and yes options.");
430
450
  }
431
- if (command === "review" &&
432
- taskId === undefined &&
433
- (criteria.length > 0 || evidence.length > 0)) {
434
- throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Review criteria and evidence require --task.");
451
+ if (command === "review" && taskId === undefined) {
452
+ throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Review requires --task for the complete task-bound review.");
435
453
  }
436
- if (command === "review" &&
437
- harness !== undefined &&
438
- (harness.length !== 1 || !REVIEW_TARGETS.has(harness[0] ?? ""))) {
439
- invalidValue("--harness", harness.join(","));
454
+ if (command === "review" && !yes) {
455
+ throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Review requires --yes to authorize both reviewer sessions.");
456
+ }
457
+ if (command === "review" && harness !== undefined) {
458
+ throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Complete review uses the configured reviewer pair; omit --harness.");
459
+ }
460
+ if (command === "review" && (criteria.length > 0 || evidence.length > 0)) {
461
+ throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Complete review covers every task criterion; omit --criterion and --evidence.");
440
462
  }
441
463
  if (command === "allow-stop" &&
442
464
  (sessionId === undefined ||
@@ -469,6 +491,7 @@ export function parseArgs(argv) {
469
491
  ...(sessionId === undefined ? {} : { sessionId }),
470
492
  ...(base === undefined ? {} : { base }),
471
493
  ...(checkAuth ? { checkAuth } : {}),
494
+ ...(checkAuthTargets.length === 0 ? {} : { checkAuthTargets }),
472
495
  dryRun,
473
496
  json,
474
497
  yes
@@ -15,6 +15,7 @@ import { FileTrustStore } from "../../../runtime/src/security/trust.js";
15
15
  import { localStatePaths } from "../../../runtime/src/security/permissions.js";
16
16
  import { calculateConfigHash } from "../../../runtime/src/config/hash.js";
17
17
  import { FileEvidenceStore } from "../../../runtime/src/verify/evidence.js";
18
+ import { calculateSourceFingerprint } from "../../../runtime/src/verify/source-fingerprint.js";
18
19
  import { VerificationService } from "../../../runtime/src/verify/service.js";
19
20
  import { NodeVerificationProcessRunner } from "../../../runtime/src/verify/spawn.js";
20
21
  import { runCli } from "./cli.js";
@@ -31,7 +32,8 @@ import { runTaskCommand } from "./commands/task.js";
31
32
  import { runReviewCommand } from "./commands/review.js";
32
33
  import { createReviewExecutor, ReviewInterruptedError } from "../../../runtime/src/review/execute.js";
33
34
  import { probeReviewTarget } from "../../../runtime/src/review/probe.js";
34
- import { detectHostTarget, orderChain, resolveReviewRole } from "../../../runtime/src/review/roles.js";
35
+ import { resolveReviewScope } from "../../../runtime/src/review/scope.js";
36
+ import { resolveReviewRole } from "../../../runtime/src/review/roles.js";
35
37
  import { runTrustCommand } from "./commands/trust.js";
36
38
  import { runVerifyCommand } from "./commands/verify.js";
37
39
  import { runAllowStopCommand } from "./commands/allow-stop.js";
@@ -308,7 +310,10 @@ else {
308
310
  },
309
311
  ...(args.checkAuth === true
310
312
  ? { checkReviewTargetAuth: true }
311
- : {})
313
+ : {}),
314
+ ...(args.checkAuthTargets === undefined
315
+ ? {}
316
+ : { checkAuthTargets: args.checkAuthTargets })
312
317
  });
313
318
  }
314
319
  if (args.command === "uninstall") {
@@ -386,12 +391,10 @@ else {
386
391
  }
387
392
  if (args.command === "review") {
388
393
  const reviewSessionId = process.env.AGENT_OPS_SESSION_ID;
394
+ const reviewGit = gitRunner(root);
389
395
  const reviewConfig = (await loadEffectiveConfig(root, args.scope === "user" ? "user" : "project")).config;
390
396
  const reviewRole = resolveReviewRole("independent-review", reviewConfig.reviewRoles ?? []);
391
- const selectedReviewTarget = args.harness?.[0];
392
- const plannedReviewTargets = orderChain(selectedReviewTarget === undefined
393
- ? reviewRole?.targets ?? []
394
- : [selectedReviewTarget], detectHostTarget(process.env));
397
+ const configuredReviewTargets = reviewRole?.targets ?? [];
395
398
  const controller = new AbortController();
396
399
  let interruptedBy;
397
400
  const interrupt = (signal) => {
@@ -414,15 +417,15 @@ else {
414
417
  ...(reviewConfig.reviewRoles === undefined
415
418
  ? {}
416
419
  : { roles: reviewConfig.reviewRoles }),
417
- targets: plannedReviewTargets,
420
+ targets: configuredReviewTargets,
418
421
  root,
419
- gitRunner: gitRunner(root),
422
+ gitRunner: reviewGit,
420
423
  policyConfigHash: calculateConfigHash(reviewConfig),
421
424
  currentPolicyConfigHash: async () => calculateConfigHash((await loadEffectiveConfig(root, args.scope === "user" ? "user" : "project")).config),
422
425
  config: reviewConfig,
423
426
  evidenceStore: new FileEvidenceStore(root, root),
424
427
  execute: createReviewExecutor({
425
- targets: plannedReviewTargets,
428
+ targets: configuredReviewTargets,
426
429
  cwd: root,
427
430
  ...(reviewRole?.model === undefined
428
431
  ? {}
@@ -433,6 +436,15 @@ else {
433
436
  ...(reviewRole?.timeoutMs === undefined
434
437
  ? {}
435
438
  : { timeoutMs: reviewRole.timeoutMs }),
439
+ preflightTarget: async (target) => await probeReviewTarget(target, { cwd: root, deep: true }),
440
+ verifySourceFingerprint: async (expected) => {
441
+ const currentScope = await resolveReviewScope({
442
+ root,
443
+ runner: reviewGit,
444
+ ...(args.base === undefined ? {} : { base: args.base })
445
+ });
446
+ return await calculateSourceFingerprint(root, currentScope, reviewGit) === expected;
447
+ },
436
448
  signal: controller.signal,
437
449
  onProgress: (line) => {
438
450
  process.stderr.write(`${line}\n`);
@@ -32,14 +32,14 @@ Commands:
32
32
 
33
33
  Options:
34
34
  --scope <project|user>
35
- --harness <all|both|agy|claude|codex|opencode|comma-separated> Init/update/uninstall; review accepts one configured target
35
+ --harness <all|both|agy|claude|codex|opencode|comma-separated> Init/update/uninstall; review uses configured targets
36
36
  --hook-target <harness=surface-id> Repeatable advanced init/update option
37
37
  --profile <core|advisory|guardrails|loop> Repeatable
38
- --review-target <codex|agy|claude> Repeatable init option; external review
39
- targets in fallback-chain order
38
+ --review-target <codex|agy|claude> Repeatable init option; review pair order
40
39
  --completion-gate Init only: enable the project-loop completion gate
41
- --check-auth Doctor only: probe each review target's
40
+ --check-auth Doctor only: probe selected review targets'
42
41
  authentication with one real call
42
+ --check-auth-target <target> Repeatable doctor filter with --check-auth
43
43
  --task <id>
44
44
  --parent <task-id> Task create: record a subtask of this
45
45
  task; task status: list its subtasks
@@ -159,18 +159,16 @@ Options:
159
159
  --base <git-ref> Verify a clean committed range
160
160
  --json
161
161
  `,
162
- review: `Usage: agent-ops review [options]
162
+ review: `Usage: agent-ops review --task <id> --yes [options]
163
163
 
164
- Run one independent read-only review against the configured target chain.
164
+ Run the complete task-bound review: one necessary reviewer followed by one
165
+ fresh adversarial reviewer from the configured pair.
165
166
 
166
167
  Options:
167
- --task <id>
168
- --session <id>
169
- --criterion <id> Repeatable: review only these task criteria
170
- --harness <target> One configured review target
171
- --base <git-ref>
168
+ --task <id> Required task whose original criteria are reviewed
169
+ --base <git-ref> Review a clean committed range
172
170
  --json
173
- --yes Authorize the review call
171
+ --yes Required authorization for both reviewer sessions
174
172
  `,
175
173
  "allow-stop": `Usage: agent-ops allow-stop --session <id> [options]
176
174
 
@@ -234,7 +232,11 @@ export async function runCli(argv, io, services) {
234
232
  return writeAndReturn(io, errorEnvelope("CLI_COMMAND_UNAVAILABLE", `Command is not implemented yet: ${args.command}`), args.json, 1);
235
233
  }
236
234
  const result = await execute(args);
237
- return writeAndReturn(io, result, args.json, result.status === "ok" ? 0 : 1);
235
+ return writeAndReturn(io, result, args.json, result.status === "ok"
236
+ ? 0
237
+ : result.code === "REVIEW_NOT_RUN"
238
+ ? 2
239
+ : 1);
238
240
  }
239
241
  catch (error) {
240
242
  if (error instanceof CliArgumentError) {
@@ -1,8 +1,8 @@
1
1
  import { buildReviewPacket } from "../../../../runtime/src/review/packet.js";
2
2
  import { runIndependentReview } from "../../../../runtime/src/review/runner.js";
3
3
  import { renderReviewResult } from "../../../../runtime/src/review/render.js";
4
- import { invalidateReviewAttestation, saveReviewAttestation } from "../../../../runtime/src/review/attestation.js";
5
- import { resolveReviewRole } from "../../../../runtime/src/review/roles.js";
4
+ import { invalidateReviewAttestation, reviewReportDigest, saveReviewAttestation, saveReviewReportArtifact } from "../../../../runtime/src/review/attestation.js";
5
+ import { detectHostTarget, planReviewTargets, resolveReviewRole } from "../../../../runtime/src/review/roles.js";
6
6
  import { AgentOpsError } from "../../../../runtime/src/fs/paths.js";
7
7
  import { assertSafeSupportingPaths, isReviewerPolicyPath, resolveReviewScope, reviewScopeSignature } from "../../../../runtime/src/review/scope.js";
8
8
  import { calculateSourceFingerprint } from "../../../../runtime/src/verify/source-fingerprint.js";
@@ -222,6 +222,12 @@ function sourceChangedResult(result) {
222
222
  ...(result.plannedTargets === undefined
223
223
  ? {}
224
224
  : { plannedTargets: result.plannedTargets }),
225
+ ...(result.sourceFingerprint === undefined
226
+ ? {}
227
+ : { sourceFingerprint: result.sourceFingerprint }),
228
+ ...(result.hostTarget === undefined
229
+ ? {}
230
+ : { hostTarget: result.hostTarget }),
225
231
  ...(result.scope === undefined ? {} : { scope: result.scope }),
226
232
  ...(result.independence === undefined
227
233
  ? {}
@@ -230,11 +236,90 @@ function sourceChangedResult(result) {
230
236
  ? {}
231
237
  : { sessionIsolation: result.sessionIsolation }),
232
238
  ...(result.attempts === undefined ? {} : { attempts: result.attempts }),
239
+ ...(result.report === undefined ? {} : { report: result.report }),
240
+ ...(result.adversarial === undefined
241
+ ? {}
242
+ : { adversarial: result.adversarial }),
233
243
  ...(result.verification === undefined
234
244
  ? {}
235
245
  : { verification: result.verification })
236
246
  };
237
247
  }
248
+ function hasCompleteReviewEvidence(result) {
249
+ const attempts = result.attempts;
250
+ const plannedTargets = result.plannedTargets;
251
+ return result.status === "PASS" &&
252
+ result.report !== undefined &&
253
+ result.adversarial !== undefined &&
254
+ result.adversarial.refuted === false &&
255
+ result.sessionIsolation === "fresh" &&
256
+ plannedTargets !== undefined &&
257
+ plannedTargets.length === 2 &&
258
+ result.harness === plannedTargets[0] &&
259
+ result.adversarial.target === plannedTargets[1] &&
260
+ attempts !== undefined &&
261
+ attempts.length === 2 &&
262
+ attempts.every((attempt, index) => attempt.target === plannedTargets[index] &&
263
+ attempt.status === "PASS" &&
264
+ attempt.sessionId !== undefined);
265
+ }
266
+ async function persistReviewEvidence(options, result, sourceFingerprint, context) {
267
+ if (options.root === undefined || sourceFingerprint === undefined) {
268
+ return result;
269
+ }
270
+ const complete = hasCompleteReviewEvidence(result);
271
+ const artifactResult = result.status === "PASS" && !complete
272
+ ? {
273
+ ...result,
274
+ status: "NOT_RUN",
275
+ reason: "adversarial-review-missing"
276
+ }
277
+ : result;
278
+ let reportArtifact;
279
+ try {
280
+ reportArtifact = await saveReviewReportArtifact(options.root, artifactResult, sourceFingerprint, context?.taskId);
281
+ }
282
+ catch {
283
+ return {
284
+ ...result,
285
+ status: "NOT_RUN",
286
+ reason: "evidence-write-failed"
287
+ };
288
+ }
289
+ if (!complete) {
290
+ return artifactResult;
291
+ }
292
+ if (result.status !== "PASS" || context?.allCriteriaReviewed === false) {
293
+ return result;
294
+ }
295
+ try {
296
+ const attempts = result.attempts;
297
+ const plannedTargets = result.plannedTargets;
298
+ await saveReviewAttestation(options.root, {
299
+ schemaVersion: 2,
300
+ ...(context === undefined ? {} : { taskId: context.taskId }),
301
+ harness: result.harness,
302
+ status: "PASS",
303
+ sourceFingerprint,
304
+ reviewTargets: [plannedTargets[0], plannedTargets[1]],
305
+ reviewSessionIds: [attempts[0].sessionId, attempts[1].sessionId],
306
+ sessionIsolation: "fresh",
307
+ primaryReportDigest: reviewReportDigest(result.report),
308
+ adversarialReportDigest: reviewReportDigest(result.adversarial.report),
309
+ reportArtifact,
310
+ ...(result.hostTarget === undefined ? {} : { hostTarget: result.hostTarget }),
311
+ createdAt: new Date().toISOString()
312
+ });
313
+ }
314
+ catch {
315
+ return {
316
+ ...result,
317
+ status: "NOT_RUN",
318
+ reason: "evidence-write-failed"
319
+ };
320
+ }
321
+ return result;
322
+ }
238
323
  export async function runReviewCommand(options) {
239
324
  const role = resolveReviewRole(options.role ?? "independent-review", options.roles ?? []);
240
325
  const selectedHarness = harness(options.args.harness);
@@ -243,17 +328,30 @@ export async function runReviewCommand(options) {
243
328
  !configuredTargets.includes(selectedHarness)) {
244
329
  throw new AgentOpsError("REVIEW_TARGET_NOT_CONFIGURED", `${selectedHarness} is not enabled for independent-review. Configure it with init --review-target ${selectedHarness}.`);
245
330
  }
246
- const plannedTargets = selectedHarness === undefined
331
+ const configuredSelection = selectedHarness === undefined
247
332
  ? options.targets ?? configuredTargets
248
333
  : [selectedHarness];
334
+ const targetPlan = planReviewTargets(configuredSelection, detectHostTarget(options.env ?? process.env));
335
+ const hostTarget = detectHostTarget(options.env ?? process.env);
336
+ const plannedTargets = targetPlan.reason === undefined
337
+ ? targetPlan.targets
338
+ : configuredSelection;
249
339
  const target = plannedTargets[0] ?? selectedHarness ?? "codex";
250
340
  const resultBase = {
251
341
  harness: target,
252
342
  plannedTargets,
253
343
  model: role?.model ?? options.model ?? "configured",
254
344
  effort: role?.effort ?? options.effort ?? "configured",
255
- prompt: ""
345
+ prompt: "",
346
+ ...(hostTarget === undefined ? {} : { hostTarget })
256
347
  };
348
+ if (targetPlan.reason !== undefined) {
349
+ return notRunEnvelope({
350
+ ...resultBase,
351
+ status: "NOT_RUN",
352
+ reason: targetPlan.reason
353
+ });
354
+ }
257
355
  const context = await taskContext(options);
258
356
  const evidenceRequirements = (options.args.evidence ?? []).map((value) => {
259
357
  const separator = value.indexOf("=");
@@ -358,6 +456,8 @@ export async function runReviewCommand(options) {
358
456
  invocation: {
359
457
  harness: target,
360
458
  plannedTargets,
459
+ ...(sourceFingerprint === undefined ? {} : { sourceFingerprint }),
460
+ ...(hostTarget === undefined ? {} : { hostTarget }),
361
461
  model: role?.model ?? options.model ?? "configured",
362
462
  effort: role?.effort ?? options.effort ?? "configured",
363
463
  packet,
@@ -408,55 +508,46 @@ export async function runReviewCommand(options) {
408
508
  if (reviewScopeSignature(scope) !== reviewScopeSignature(postflight) ||
409
509
  (options.policyConfigHash !== undefined && currentHash !== options.policyConfigHash) ||
410
510
  postflightFingerprint !== sourceFingerprint) {
411
- return notRunEnvelope(sourceChangedResult(result));
511
+ const changed = await persistReviewEvidence(options, sourceChangedResult(result), sourceFingerprint, context);
512
+ return notRunEnvelope(changed);
412
513
  }
413
514
  }
414
515
  catch {
415
- return notRunEnvelope(sourceChangedResult(result));
516
+ const changed = await persistReviewEvidence(options, sourceChangedResult(result), sourceFingerprint, context);
517
+ return notRunEnvelope(changed);
416
518
  }
417
519
  }
520
+ const persisted = await persistReviewEvidence(options, result, sourceFingerprint, context);
521
+ if (persisted.status === "NOT_RUN") {
522
+ return notRunEnvelope(persisted);
523
+ }
524
+ const finalResult = persisted;
418
525
  // Evidence is only appended while the task is active: a completed record
419
526
  // must stay exactly as it was verified.
420
527
  if (context !== undefined &&
421
528
  options.tasks !== undefined &&
422
- result.status === "PASS" &&
529
+ finalResult.status === "PASS" &&
423
530
  context.active &&
424
- result.results !== undefined) {
425
- await options.tasks.recordEvidence(context.taskId, Object.fromEntries(result.results.map((item) => [
531
+ finalResult.results !== undefined) {
532
+ await options.tasks.recordEvidence(context.taskId, Object.fromEntries(finalResult.results.map((item) => [
426
533
  item.criterionId,
427
- item.evidence.map((reference) => `review:${result.harness}:${reference}`)
534
+ item.evidence.map((reference) => `review:${finalResult.harness}:${reference}`)
428
535
  ])));
429
536
  }
430
- // The attestation is what a Stop gate reads. It is keyed by the verified
431
- // source fingerprint, so it stops satisfying the gate the moment the tree
432
- // changes again.
433
- if (options.root !== undefined &&
434
- result.status === "PASS" &&
435
- sourceFingerprint !== undefined &&
436
- (context === undefined || context.allCriteriaReviewed)) {
437
- await saveReviewAttestation(options.root, {
438
- schemaVersion: 1,
439
- ...(context === undefined ? {} : { taskId: context.taskId }),
440
- harness: result.harness,
441
- status: "PASS",
442
- sourceFingerprint,
443
- createdAt: new Date().toISOString()
444
- });
445
- }
446
- const message = result.status === "PASS"
537
+ const message = finalResult.status === "PASS"
447
538
  ? "Independent review passed."
448
- : result.status === "FAIL"
539
+ : finalResult.status === "FAIL"
449
540
  ? "Independent review failed."
450
541
  : "Independent review was not run.";
451
542
  const data = {
452
543
  message,
453
- result,
454
- text: renderReviewResult(result)
544
+ result: finalResult,
545
+ text: renderReviewResult(finalResult)
455
546
  };
456
- if (result.status === "PASS") {
547
+ if (finalResult.status === "PASS") {
457
548
  return okEnvelope("REVIEW_RESULT", data);
458
549
  }
459
- const code = result.status === "FAIL" ? "REVIEW_FAILED" : "REVIEW_NOT_RUN";
550
+ const code = finalResult.status === "FAIL" ? "REVIEW_FAILED" : "REVIEW_NOT_RUN";
460
551
  return {
461
552
  code,
462
553
  status: "error",
@@ -40,7 +40,8 @@ export function claudeHookOutput(event, result) {
40
40
  decision: "block",
41
41
  reason: `agent-ops: ${result.code} reported FAIL for ${failed.join(", ")}. ` +
42
42
  "Resolve every failing item — an unsatisfied independent-review " +
43
- "gate is cleared by running `agent-ops review` to a PASS — then " +
43
+ "gate is cleared by running `agent-ops review --task <task-id> --yes` " +
44
+ "to a PASS — then " +
44
45
  "stop again.",
45
46
  evidence: result.evidence
46
47
  });
@@ -4,7 +4,7 @@ import { sha256 } from "../fs/hash.js";
4
4
  import { parseInstallManifest, PROJECT_MANIFEST_PATH } from "../fs/manifest.js";
5
5
  import { resolveContainedPath } from "../fs/paths.js";
6
6
  import { validateConfig } from "../schema/validate.js";
7
- import { BIND_DEPENDENT_TARGETS, detectHostRestriction } from "../review/host-sandbox.js";
7
+ import { detectHostRestriction } from "../review/host-sandbox.js";
8
8
  import { assertExpectedManagedBlock, assertSupportedManifestOwnership } from "./ownership.js";
9
9
  import { isOpencodeManagedPlugin } from "../adapters/opencode/config.js";
10
10
  import { harnessDescriptor, managedRules } from "./harness.js";
@@ -409,14 +409,20 @@ async function checkHostSandbox(detect) {
409
409
  "target can answer. Any authentication verdict below is unreliable.", undefined, "Run agent-ops outside the sandbox, or grant it escalated execution.");
410
410
  }
411
411
  if (restriction === "bind-blocked") {
412
- return check("host-sandbox", "DEGRADED", `This process cannot open a loopback listener, so review targets that ` +
413
- `need one (${BIND_DEPENDENT_TARGETS.join(", ")}) run last and may be ` +
414
- "unable to answer.", undefined, "Run agent-ops outside the sandbox, or grant it escalated execution.");
412
+ return check("host-sandbox", "DEGRADED", "This process cannot open a loopback listener, so review fails closed " +
413
+ "with host-required before any reviewer starts.", undefined, "Run agent-ops outside the sandbox, or grant it escalated execution.");
415
414
  }
416
415
  return check("host-sandbox", "PASS", "No host sandbox restriction affects review targets.");
417
416
  }
418
- async function checkReviewTargets(config, probe, checkAuth) {
419
- const targets = config?.reviewRoles?.find((role) => role.role === "independent-review")?.targets ?? [];
417
+ async function checkReviewTargets(config, probe, checkAuth, selectedTargets = []) {
418
+ const configuredTargets = config?.reviewRoles?.find((role) => role.role === "independent-review")?.targets ?? [];
419
+ const unconfigured = selectedTargets.filter((target) => !configuredTargets.includes(target));
420
+ if (unconfigured.length > 0) {
421
+ return check("review-targets", "FAIL", `Review target selection is not configured: ${unconfigured.join(", ")}.`, undefined, "Configure the target with agent-ops init --review-target, then retry.");
422
+ }
423
+ const targets = selectedTargets.length > 0
424
+ ? selectedTargets
425
+ : configuredTargets;
420
426
  if (targets.length === 0) {
421
427
  return check("review-targets", "PASS", "External review disabled. Re-run agent-ops init to enable.");
422
428
  }
@@ -445,6 +451,10 @@ async function checkReviewTargets(config, probe, checkAuth) {
445
451
  failures.push(check("review-targets", "FAIL", `${target} did not answer in time.`, undefined, "Re-run: agent-ops doctor --check-auth"));
446
452
  continue;
447
453
  }
454
+ if (result === "capability-unavailable") {
455
+ failures.push(check("review-targets", "FAIL", `${target} could not complete its read-only capability probe.`, undefined, "Run agent-ops review on a host with network and loopback permission, then retry."));
456
+ continue;
457
+ }
448
458
  if (checkAuth && result !== "ok") {
449
459
  failures.push(check("review-targets", "FAIL", `${target} is installed but not authenticated, or it rejected the call.`, undefined, `Run: ${target} login`));
450
460
  }
@@ -483,7 +493,7 @@ export async function doctorInstallation(options) {
483
493
  : []),
484
494
  await checkProbe("repository-trust", options.probes?.repositoryTrust),
485
495
  await checkProbe("smoke-availability", options.probes?.smokeAvailability),
486
- await checkReviewTargets(config.config, options.probes?.reviewTarget, options.checkReviewTargetAuth === true),
496
+ await checkReviewTargets(config.config, options.probes?.reviewTarget, options.checkReviewTargetAuth === true, options.checkAuthTargets),
487
497
  await checkHostSandbox(options.probes?.hostRestriction),
488
498
  checkVerificationCommands(config.config)
489
499
  ];
@@ -296,7 +296,7 @@ export function managedRules(descriptor, context) {
296
296
  ""
297
297
  ];
298
298
  if (context.capabilities.includes("rules")) {
299
- lines.push("For every change:", "", "1. Define two to five mechanically verifiable acceptance criteria.", "2. Inspect the smallest relevant scope and preserve unrelated changes.", "3. Apply the smallest safe change.", "4. Run evidence-producing verification for every criterion.", "5. Obtain independent review before claiming completion, via", " `agent-ops review --yes` (or the CLI's equivalent invocation). Never call a", " review-target CLI (agy, codex, claude) directly — direct calls skip", " the enforced read-only sandbox flags and can hang or fail on command", " permission prompts.", " Set `AGENT_OPS_HOST` to the current CLI id when invoking review so", " agent-ops tries a different CLI first and uses isolated self-review", " only when no other configured reviewer is usable.", "", "Treat `.agent-ops/config.json` as verifier authority. Discovery output is", "only a proposal until a user confirms it. Repository commands require an", "exact matching trust record. Confirmed project init/update grants it", "automatically when verification commands are configured.", "");
299
+ lines.push("For every change:", "", "1. Define two to five mechanically verifiable acceptance criteria.", "2. Inspect the smallest relevant scope and preserve unrelated changes.", "3. Apply the smallest safe change.", "4. Run evidence-producing verification for every criterion.", "5. Obtain complete independent review before claiming completion, via", " `agent-ops review --task <task-id> --yes` (or the CLI's equivalent invocation).", " Never call a", " review-target CLI (agy, codex, claude) directly — direct calls skip", " the enforced read-only sandbox flags and can hang or fail on command", " permission prompts.", " Start this command through the trusted outer host runner as its first", " and only invocation. On Codex, the outer tool request must use", " `sandbox_permissions: \"require_escalated\"` so network and loopback", " are available; in that same request, unset", " `CODEX_SANDBOX_NETWORK_DISABLED` and set `AGENT_OPS_HOST` when three", " targets are configured. The Codex shell form is", " `env -u CODEX_SANDBOX_NETWORK_DISABLED AGENT_OPS_HOST=<current-host> agent-ops review --task <task-id> --yes`.", " Do not run it once in the restricted sandbox and retry; changing the", " variable inside that sandbox is not elevation.", " Review runs exactly two fresh sessions: with three configured targets,", " set `AGENT_OPS_HOST` to the current CLI id so it is excluded; with two", " targets both run in configured order; with one target it runs twice.", " The first session is necessary; a PASS then requires an adversarial", " re-check carrying the first report. A first FAIL or NOT_RUN stops.", "", "Treat `.agent-ops/config.json` as verifier authority. Discovery output is", "only a proposal until a user confirms it. Repository commands require an", "exact matching trust record. Confirmed project init/update grants it", "automatically when verification commands are configured.", "");
300
300
  }
301
301
  if (context.capabilities.includes("task")) {
302
302
  lines.push("Split a change that exceeds five acceptance criteria into subtasks:", "`agent-ops task create --parent <task-id>` records one, and", "`agent-ops task status --parent <task-id>` lists them. Each subtask", "carries its own criteria, verification, and independent review;", "completing one never completes its parent.", "");