@kylecheng3146/agent-ops 0.2.1 → 0.2.3

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,26 @@ 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. A trusted outer host runner may rerun the exact same
329
+ command once with both capabilities; the result remains NOT_RUN if that host
330
+ is unavailable. There is no automatic permission bypass inside the repository.
331
+ Reviewer attempts and preflight diagnostics remain visible in JSON. `--yes` is
332
+ required because a complete run spends provider quota twice.
333
+
334
+ After a complete PASS, the full redacted primary and adversarial reports are
335
+ stored privately under `.agent-ops/reviews/`; the source-fingerprint attestation
336
+ stores only the task, reviewer/session identities, report digests, and artifact
337
+ reference. Any artifact or attestation write failure is NOT_RUN, never PASS.
326
338
 
327
339
  Authentication is diagnosed, never guessed:
328
340
 
@@ -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
  });
@@ -53,6 +54,15 @@ export function claudeHookOutput(event, result) {
53
54
  if (result.action === "continue" && result.status === "PASS") {
54
55
  return { exitCode: 0, stdout: "", stderr: "" };
55
56
  }
57
+ // An advisory PreToolUse result carries nothing the user can act on: the
58
+ // command runs either way. Saying so on every unparsed command — a heredoc,
59
+ // a substitution — trains the reader to ignore the hook that also refuses
60
+ // stops. A result with a remedy still speaks.
61
+ if (event === "PreToolUse" &&
62
+ result.action === "continue" &&
63
+ result.remedy === undefined) {
64
+ return { exitCode: 0, stdout: "", stderr: "" };
65
+ }
56
66
  if (event === "PreToolUse" && result.action === "block") {
57
67
  return json({
58
68
  hookSpecificOutput: {
@@ -3,7 +3,7 @@ import { sha256 } from "../fs/hash.js";
3
3
  import { AgentOpsError } from "../fs/paths.js";
4
4
  import { readPrivateFile, withPrivateFileLock, writePrivateFile } from "../security/permissions.js";
5
5
  import { checkTaskCompletionEvidence, findIncompleteSubtask } from "../task/completion.js";
6
- import { collectChangeSurface } from "../verify/change-surface.js";
6
+ import { collectBaseChangePaths, collectChangeSurface } from "../verify/change-surface.js";
7
7
  import { calculateSourceFingerprint } from "../verify/source-fingerprint.js";
8
8
  const FINGERPRINT = /^[a-f0-9]{64}$/u;
9
9
  const SESSION = /^[^\0\r\n]{1,256}$/u;
@@ -76,6 +76,38 @@ export class CompletionGateService {
76
76
  const surface = await collectChangeSurface(this.#options.gitRunner);
77
77
  return await calculateSourceFingerprint(this.#options.root, { mode: "worktree", changedFiles: surface.paths }, this.#options.gitRunner);
78
78
  }
79
+ /**
80
+ * The fingerprint the task's evidence should carry. Committed work leaves an
81
+ * empty worktree surface: there is nothing left to measure there, and the
82
+ * evidence names the `--base` range `task complete` was given instead. That
83
+ * range is recomputed here rather than trusted, so evidence for a range that
84
+ * no longer ends at HEAD still fails.
85
+ */
86
+ async #evidenceFingerprint(completionBase) {
87
+ const worktree = await this.#fingerprint();
88
+ if (completionBase === null) {
89
+ return worktree;
90
+ }
91
+ const surface = await collectChangeSurface(this.#options.gitRunner);
92
+ if (surface.paths.length > 0) {
93
+ return worktree;
94
+ }
95
+ try {
96
+ const changedFiles = await collectBaseChangePaths(this.#options.gitRunner, completionBase);
97
+ if (changedFiles.length === 0) {
98
+ return worktree;
99
+ }
100
+ return await calculateSourceFingerprint(this.#options.root, {
101
+ mode: "base",
102
+ baseRef: completionBase,
103
+ resolvedBase: completionBase,
104
+ changedFiles
105
+ }, this.#options.gitRunner);
106
+ }
107
+ catch {
108
+ return worktree;
109
+ }
110
+ }
79
111
  async initialize(sessionId) {
80
112
  const fingerprint = await this.#fingerprint();
81
113
  const state = await this.#store.mutate(sessionId, (current) => current ?? {
@@ -103,7 +135,7 @@ export class CompletionGateService {
103
135
  event.event === "command-batch" ? event.commands : [];
104
136
  return commands.some(({ command, args }) => [command, ...args].includes("allow-stop"));
105
137
  }
106
- async #validateTask(sessionId, sourceFingerprint) {
138
+ async #validateTask(sessionId) {
107
139
  let stored;
108
140
  try {
109
141
  stored = await this.#options.taskService.status({ sessionId });
@@ -120,7 +152,11 @@ export class CompletionGateService {
120
152
  if (unfinished !== undefined) {
121
153
  return gateResult("block", "FAIL", "COMPLETION_GATE_SUBTASK_INCOMPLETE", `Complete subtask ${unfinished.task.id} before its parent.`);
122
154
  }
123
- const problem = await checkTaskCompletionEvidence(stored, { ...this.#options, sourceFingerprint });
155
+ const sourceFingerprint = await this.#evidenceFingerprint(stored.completionBase);
156
+ const problem = await checkTaskCompletionEvidence(stored, {
157
+ ...this.#options,
158
+ sourceFingerprint
159
+ });
124
160
  if (problem !== null) {
125
161
  return gateResult("block", problem.status, `COMPLETION_GATE_${problem.code}`, problem.remedy);
126
162
  }
@@ -154,7 +190,7 @@ export class CompletionGateService {
154
190
  return gateResult("block", "UNKNOWN", "COMPLETION_GATE_NOT_INITIALIZED", "The session baseline is unavailable; continue once so PreInvocation can initialize it.");
155
191
  }
156
192
  if (state.baselineFingerprint !== fingerprint && state.permitFingerprint !== fingerprint) {
157
- const failure = await this.#validateTask(sessionId, fingerprint);
193
+ const failure = await this.#validateTask(sessionId);
158
194
  if (failure !== null)
159
195
  return failure;
160
196
  }