@kylecheng3146/agent-ops 0.4.0 → 0.4.1

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
@@ -345,6 +345,41 @@ agent-ops doctor # presence only: no tokens, no network
345
345
  agent-ops doctor --check-auth # one real print call per configured target
346
346
  ```
347
347
 
348
+ ### Batch review
349
+
350
+ A change split into subtasks needs one verify and one review per subtask.
351
+ `agent-ops batch --parent <task-id> --yes` runs them together for the parent's
352
+ active subtasks and the parent itself, instead of one `review` at a time:
353
+
354
+ ```bash
355
+ agent-ops batch --parent <task-id> --yes --output batch.json
356
+ agent-ops batch --parent <task-id> --yes --base <ref> --parent-base <ref> --width 3
357
+ ```
358
+
359
+ - Each task is verified and reviewed against its own base: `--base` for the
360
+ subtasks and `--parent-base` for the parent, otherwise the base recorded by
361
+ its last PASS review, otherwise the worktree base.
362
+ - A task that already has fresh PASS verification evidence skips verify. The
363
+ rest are verified one at a time, and each task's review starts as soon as its
364
+ own verify passes, so the next verify overlaps it. A task whose verify fails
365
+ gets no review; the others continue.
366
+ - At most `--width` reviews run at once (default 2). A transient `NOT_RUN`
367
+ (`probe-failed`, `stalled`, `timeout`, `quota-exhausted`,
368
+ `network-unreachable`) lowers the width to 1 for the rest and earns that task
369
+ one retry, alone, after the others finish. Other results are final.
370
+ - Each review target is probed once per batch while the probe succeeds; a
371
+ failed probe is forgotten so the retry probes again.
372
+ - Nothing may commit or edit the worktree while it runs. HEAD is part of every
373
+ fingerprint, so a change aborts the batch and reports the unfinished tasks
374
+ as `aborted`.
375
+ - Exit code 0 means every task passed, 1 means a review or verify failed, and 2
376
+ means the rest could not run and may be retried. The JSON envelope lists each
377
+ task; the full reports stay under `.agent-ops/reviews/`.
378
+
379
+ `batch` is started exactly like `review`: through the trusted outer host runner
380
+ as its first and only invocation, with `--output` instead of a shell redirect.
381
+ `agent-ops init` and `update` pre-authorize it beside `review`.
382
+
348
383
  See [Configuration](docs/en/guides/configuration.md) for the full contract.
349
384
 
350
385
  ## Project principles
@@ -9,6 +9,7 @@ export const COMMAND_NAMES = [
9
9
  "task",
10
10
  "verify",
11
11
  "review",
12
+ "batch",
12
13
  "allow-stop",
13
14
  "worktree"
14
15
  ];
@@ -71,6 +72,8 @@ export function parseArgs(argv) {
71
72
  let title;
72
73
  let sessionId;
73
74
  let base;
75
+ let parentBase;
76
+ let width;
74
77
  const profiles = [];
75
78
  const reviewTargets = [];
76
79
  const criteria = [];
@@ -210,6 +213,26 @@ export function parseArgs(argv) {
210
213
  index += 1;
211
214
  break;
212
215
  }
216
+ case "--parent-base": {
217
+ if (parentBase !== undefined) {
218
+ duplicate(token);
219
+ }
220
+ parentBase = readOptionValue(argv, index, token);
221
+ index += 1;
222
+ break;
223
+ }
224
+ case "--width": {
225
+ if (width !== undefined) {
226
+ duplicate(token);
227
+ }
228
+ const value = readOptionValue(argv, index, token);
229
+ if (!/^[1-9][0-9]{0,2}$/u.test(value)) {
230
+ invalidValue(token, value);
231
+ }
232
+ width = Number(value);
233
+ index += 1;
234
+ break;
235
+ }
213
236
  case "--check-auth":
214
237
  if (checkAuth) {
215
238
  duplicate(token);
@@ -416,6 +439,8 @@ export function parseArgs(argv) {
416
439
  completionGate !== undefined ||
417
440
  sessionId !== undefined ||
418
441
  base !== undefined ||
442
+ parentBase !== undefined ||
443
+ width !== undefined ||
419
444
  checkAuth ||
420
445
  checkAuthTargets.length > 0 ||
421
446
  dryRun ||
@@ -465,15 +490,18 @@ export function parseArgs(argv) {
465
490
  (hasTaskTargetOptions || hasTaskMutationOptions)) {
466
491
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Task target options may be used only with task or verify.");
467
492
  }
468
- if (command !== "task" && parentTaskId !== undefined) {
469
- throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--parent may be used only with task create or task status.");
493
+ if (command !== "task" && command !== "batch" && parentTaskId !== undefined) {
494
+ throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--parent may be used only with task create, task status or batch.");
470
495
  }
471
496
  if (command !== "update" && targetVersion !== undefined) {
472
497
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--target-version may be used only with update.");
473
498
  }
474
499
  if (base !== undefined && command !== "verify" && command !== "review" &&
475
- !(command === "task" && action === "complete")) {
476
- throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--base may be used only with verify, review or task complete.");
500
+ command !== "batch" && !(command === "task" && action === "complete")) {
501
+ throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--base may be used only with verify, review, batch or task complete.");
502
+ }
503
+ if ((parentBase !== undefined || width !== undefined) && command !== "batch") {
504
+ throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", `${parentBase !== undefined ? "--parent-base" : "--width"} may be used only with batch.`);
477
505
  }
478
506
  if (hookTargets.length > 0 &&
479
507
  command !== "init" &&
@@ -566,6 +594,16 @@ export function parseArgs(argv) {
566
594
  if (command === "review" && (criteria.length > 0 || evidence.length > 0)) {
567
595
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Complete review covers every task criterion; omit --criterion and --evidence.");
568
596
  }
597
+ if (command === "batch" && parentTaskId === undefined) {
598
+ throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Batch requires --parent to name the task whose subtasks it covers.");
599
+ }
600
+ if (command === "batch" && !yes) {
601
+ throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Batch requires --yes to authorize the reviewer sessions.");
602
+ }
603
+ if (command === "batch" &&
604
+ (harness !== undefined || profiles.length > 0 || dryRun || rerun)) {
605
+ throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Batch accepts parent, base, parent-base, width, json, output and yes options.");
606
+ }
569
607
  if (command === "allow-stop" &&
570
608
  (sessionId === undefined ||
571
609
  scope !== undefined ||
@@ -589,8 +627,8 @@ export function parseArgs(argv) {
589
627
  rerun)) {
590
628
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "worktree accepts only a name, --session, --from, --target-branch, --message and --json.");
591
629
  }
592
- if (output !== undefined && command !== "review" && command !== "doctor") {
593
- throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--output may be used only with review or doctor.", "--output");
630
+ if (output !== undefined && command !== "review" && command !== "batch" && command !== "doctor") {
631
+ throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--output may be used only with review, batch or doctor.", "--output");
594
632
  }
595
633
  if (output !== undefined && (output.trim() === "" || output.includes("\0"))) {
596
634
  throw new CliArgumentError("CLI_INVALID_VALUE", "Invalid value for --output.", "--output");
@@ -636,6 +674,8 @@ export function parseArgs(argv) {
636
674
  ...(evidence.length === 0 ? {} : { evidence }),
637
675
  ...(sessionId === undefined ? {} : { sessionId }),
638
676
  ...(base === undefined ? {} : { base }),
677
+ ...(parentBase === undefined ? {} : { parentBase }),
678
+ ...(width === undefined ? {} : { width }),
639
679
  ...(worktreeFrom === undefined ? {} : { worktreeFrom }),
640
680
  ...(worktreeMessage === undefined ? {} : { worktreeMessage }),
641
681
  ...(output === undefined ? {} : { output }),
@@ -18,6 +18,7 @@ import { FileEvidenceStore } from "../../../runtime/src/verify/evidence.js";
18
18
  import { calculateSourceFingerprint } from "../../../runtime/src/verify/source-fingerprint.js";
19
19
  import { VerificationService } from "../../../runtime/src/verify/service.js";
20
20
  import { NodeVerificationProcessRunner } from "../../../runtime/src/verify/spawn.js";
21
+ import { COMMAND_NAMES, parseArgs } from "./args.js";
21
22
  import { runCli } from "./cli.js";
22
23
  import { loadEffectiveConfig, repositoryTrust, repositoryTrustBinding } from "./context.js";
23
24
  import { runHookProcess } from "./hook-process.js";
@@ -30,7 +31,10 @@ import { runDoctorCommand } from "./commands/doctor.js";
30
31
  import { formatInstallPlan, runInitCommand } from "./commands/init.js";
31
32
  import { formatUninstallPlan, runUninstallCommand } from "./commands/uninstall.js";
32
33
  import { runTaskCommand } from "./commands/task.js";
33
- import { runReviewCommand } from "./commands/review.js";
34
+ import { hasFreshVerification, runReviewCommand } from "./commands/review.js";
35
+ import { runBatchCommand } from "./commands/batch.js";
36
+ import { createSourceGuard } from "../../../runtime/src/review/batch-guard.js";
37
+ import { memoizePreflight } from "../../../runtime/src/review/batch.js";
34
38
  import { createReviewExecutor, ReviewInterruptedError } from "../../../runtime/src/review/execute.js";
35
39
  import { probeReviewTargetDetailed } from "../../../runtime/src/review/probe.js";
36
40
  import { resolveReviewScope } from "../../../runtime/src/review/scope.js";
@@ -43,7 +47,7 @@ import { formatUpdatePlan, runUpdateCommand } from "./commands/update.js";
43
47
  import { errorEnvelope } from "./output.js";
44
48
  import { runAgyHeadless } from "./agy-headless.js";
45
49
  import { runWorktreeCommand } from "./commands/parallel.js";
46
- import { ensureSessionWorktree, resolveCheckouts } from "../../../runtime/src/parallel/service.js";
50
+ import { ensureSessionWorktree, readWorktreeRecord, resolveCheckouts } from "../../../runtime/src/parallel/service.js";
47
51
  import { listWorktrees, branchLockDoctorResult, worktreeDoctorResult } from "../../../runtime/src/parallel/manage.js";
48
52
  import { detectGhostFiles, ghostFilesDoctorResult } from "../../../runtime/src/install/doctor.js";
49
53
  const HOOK_RUNTIME_PATH = fileURLToPath(new URL("./hook-entry.js", import.meta.url));
@@ -207,19 +211,10 @@ else {
207
211
  writeStderr: (value) => process.stderr.write(value)
208
212
  }, {
209
213
  version: CLI_VERSION,
210
- registry: createCommandRegistry(Object.fromEntries([
211
- "init",
212
- "config",
213
- "trust",
214
- "doctor",
215
- "update",
216
- "uninstall",
217
- "task",
218
- "verify",
219
- "review",
220
- "allow-stop",
221
- "worktree"
222
- ].map((command) => [command, async (args) => {
214
+ registry: createCommandRegistry(Object.fromEntries(
215
+ // Every command the parser knows is registered: a hand-kept list here
216
+ // once left a parsed command answering CLI_COMMAND_UNAVAILABLE.
217
+ COMMAND_NAMES.map((command) => [command, async (args) => {
223
218
  const root = args.scope === "user"
224
219
  ? process.env.AGENT_OPS_HOME ?? homedir()
225
220
  : process.cwd();
@@ -442,12 +437,68 @@ else {
442
437
  : {})
443
438
  });
444
439
  }
445
- if (args.command === "review") {
440
+ const probeTarget = async (target, budget) => await probeReviewTargetDetailed(target, {
441
+ cwd: root,
442
+ deep: true,
443
+ // The probe answers within the chain's remaining budget
444
+ // or not at all; its own default would outlast it.
445
+ ...(budget === undefined ? {} : { timeoutMs: budget.timeoutMs })
446
+ });
447
+ // One place builds a review's options, so `review` and each task of
448
+ // a `batch` are reviewed identically.
449
+ const buildReviewOptions = async (reviewArgs, signal, preflightTarget = probeTarget, progressPrefix = "") => {
446
450
  const reviewSessionId = process.env.AGENT_OPS_SESSION_ID;
447
451
  const reviewGit = gitRunner(root);
448
- const reviewConfig = (await loadEffectiveConfig(root, args.scope === "user" ? "user" : "project")).config;
452
+ const reviewConfig = (await loadEffectiveConfig(root, reviewArgs.scope === "user" ? "user" : "project")).config;
449
453
  const reviewRole = resolveReviewRole("independent-review", reviewConfig.reviewRoles ?? []);
450
454
  const configuredReviewTargets = reviewRole?.targets ?? [];
455
+ return {
456
+ args: reviewArgs,
457
+ authorized: reviewArgs.yes,
458
+ tasks: taskService,
459
+ ...(reviewArgs.taskId === undefined ? {} : { taskId: reviewArgs.taskId }),
460
+ ...(reviewSessionId === undefined
461
+ ? {}
462
+ : { sessionId: reviewSessionId }),
463
+ ...(reviewConfig.reviewRoles === undefined
464
+ ? {}
465
+ : { roles: reviewConfig.reviewRoles }),
466
+ targets: configuredReviewTargets,
467
+ root,
468
+ gitRunner: reviewGit,
469
+ policyConfigHash: calculateConfigHash(reviewConfig),
470
+ currentPolicyConfigHash: async () => calculateConfigHash((await loadEffectiveConfig(root, reviewArgs.scope === "user" ? "user" : "project")).config),
471
+ config: reviewConfig,
472
+ evidenceStore: new FileEvidenceStore(root, root),
473
+ execute: createReviewExecutor({
474
+ targets: configuredReviewTargets,
475
+ cwd: root,
476
+ ...(reviewRole?.model === undefined
477
+ ? {}
478
+ : { model: reviewRole.model }),
479
+ ...(reviewRole?.effort === undefined
480
+ ? {}
481
+ : { effort: reviewRole.effort }),
482
+ ...(reviewRole?.timeoutMs === undefined
483
+ ? {}
484
+ : { timeoutMs: reviewRole.timeoutMs }),
485
+ preflightTarget,
486
+ verifySourceFingerprint: async (expected) => {
487
+ const currentScope = await resolveReviewScope({
488
+ root,
489
+ runner: reviewGit,
490
+ ...(reviewArgs.base === undefined ? {} : { base: reviewArgs.base })
491
+ });
492
+ return await calculateSourceFingerprint(root, currentScope, reviewGit) === expected;
493
+ },
494
+ signal,
495
+ onProgress: (line) => {
496
+ process.stderr.write(`${progressPrefix}${line}\n`);
497
+ }
498
+ })
499
+ };
500
+ };
501
+ if (args.command === "review") {
451
502
  const controller = new AbortController();
452
503
  let interruptedBy;
453
504
  const interrupt = (signal) => {
@@ -459,59 +510,7 @@ else {
459
510
  process.once("SIGINT", onSigint);
460
511
  process.once("SIGTERM", onSigterm);
461
512
  try {
462
- return await runReviewCommand({
463
- args,
464
- authorized: args.yes,
465
- tasks: taskService,
466
- ...(args.taskId === undefined ? {} : { taskId: args.taskId }),
467
- ...(reviewSessionId === undefined
468
- ? {}
469
- : { sessionId: reviewSessionId }),
470
- ...(reviewConfig.reviewRoles === undefined
471
- ? {}
472
- : { roles: reviewConfig.reviewRoles }),
473
- targets: configuredReviewTargets,
474
- root,
475
- gitRunner: reviewGit,
476
- policyConfigHash: calculateConfigHash(reviewConfig),
477
- currentPolicyConfigHash: async () => calculateConfigHash((await loadEffectiveConfig(root, args.scope === "user" ? "user" : "project")).config),
478
- config: reviewConfig,
479
- evidenceStore: new FileEvidenceStore(root, root),
480
- execute: createReviewExecutor({
481
- targets: configuredReviewTargets,
482
- cwd: root,
483
- ...(reviewRole?.model === undefined
484
- ? {}
485
- : { model: reviewRole.model }),
486
- ...(reviewRole?.effort === undefined
487
- ? {}
488
- : { effort: reviewRole.effort }),
489
- ...(reviewRole?.timeoutMs === undefined
490
- ? {}
491
- : { timeoutMs: reviewRole.timeoutMs }),
492
- preflightTarget: async (target, budget) => await probeReviewTargetDetailed(target, {
493
- cwd: root,
494
- deep: true,
495
- // The probe answers within the chain's remaining budget
496
- // or not at all; its own default would outlast it.
497
- ...(budget === undefined
498
- ? {}
499
- : { timeoutMs: budget.timeoutMs })
500
- }),
501
- verifySourceFingerprint: async (expected) => {
502
- const currentScope = await resolveReviewScope({
503
- root,
504
- runner: reviewGit,
505
- ...(args.base === undefined ? {} : { base: args.base })
506
- });
507
- return await calculateSourceFingerprint(root, currentScope, reviewGit) === expected;
508
- },
509
- signal: controller.signal,
510
- onProgress: (line) => {
511
- process.stderr.write(`${line}\n`);
512
- }
513
- })
514
- });
513
+ return await runReviewCommand(await buildReviewOptions(args, controller.signal));
515
514
  }
516
515
  catch (error) {
517
516
  if (error instanceof ReviewInterruptedError &&
@@ -525,6 +524,72 @@ else {
525
524
  process.removeListener("SIGTERM", onSigterm);
526
525
  }
527
526
  }
527
+ if (args.command === "batch") {
528
+ const controller = new AbortController();
529
+ let interruptedBy;
530
+ const interrupt = (signal) => {
531
+ interruptedBy ??= signal;
532
+ controller.abort(signal);
533
+ };
534
+ const onSigint = () => interrupt("SIGINT");
535
+ const onSigterm = () => interrupt("SIGTERM");
536
+ process.once("SIGINT", onSigint);
537
+ process.once("SIGTERM", onSigterm);
538
+ try {
539
+ const batchScope = args.scope === "user" ? "user" : "project";
540
+ const batchConfig = (await loadEffectiveConfig(root, batchScope)).config;
541
+ const batchGit = gitRunner(root);
542
+ const trusted = await repositoryTrust(root, batchConfig, CLI_VERSION) === "TRUSTED";
543
+ const guard = await createSourceGuard(batchGit);
544
+ const preflight = memoizePreflight(probeTarget);
545
+ const taskArgs = (taskId, base) => parseArgs(["review", "--task", taskId, "--base", base, "--yes"]);
546
+ const worktreeRecord = await readWorktreeRecord(root);
547
+ const envelope = await runBatchCommand({
548
+ args,
549
+ tasks: taskService,
550
+ ...(worktreeRecord === null ? {} : { worktreeBase: worktreeRecord.base }),
551
+ guard,
552
+ isVerified: async (taskId, base) => await hasFreshVerification(await buildReviewOptions(taskArgs(taskId, base), controller.signal, preflight), taskId, base),
553
+ verify: async (taskId, base) => {
554
+ const report = await new VerificationService({
555
+ root,
556
+ scope: batchScope,
557
+ config: batchConfig,
558
+ gitRunner: batchGit,
559
+ processRunner: new NodeVerificationProcessRunner(),
560
+ taskService,
561
+ evidenceStore: new FileEvidenceStore(root, root),
562
+ trusted,
563
+ base
564
+ }).verify(taskId);
565
+ return report.status === "PASS" ? "PASS" : "FAIL";
566
+ },
567
+ review: async (taskId, base, signal) => {
568
+ const reviewed = await runReviewCommand(await buildReviewOptions(taskArgs(taskId, base), signal, preflight, `${taskId.slice(5, 13)}: `));
569
+ const result = reviewed.data?.result;
570
+ return result === undefined
571
+ ? { status: "NOT_RUN", reason: reviewed.errors[0]?.code ?? "review-error" }
572
+ : {
573
+ status: result.status,
574
+ ...(result.reason === undefined ? {} : { reason: result.reason }),
575
+ ...(result.reused === true ? { reused: true } : {})
576
+ };
577
+ },
578
+ signal: controller.signal,
579
+ onProgress: (line) => {
580
+ process.stderr.write(`batch: ${line}\n`);
581
+ }
582
+ });
583
+ if (interruptedBy !== undefined) {
584
+ process.exit(interruptedBy === "SIGINT" ? 130 : 143);
585
+ }
586
+ return envelope;
587
+ }
588
+ finally {
589
+ process.removeListener("SIGINT", onSigint);
590
+ process.removeListener("SIGTERM", onSigterm);
591
+ }
592
+ }
528
593
  if (args.command === "config") {
529
594
  return explainConfigCommand(await loadEffectiveConfig(root, args.scope === "user" ? "user" : "project"));
530
595
  }
@@ -29,6 +29,7 @@ Commands:
29
29
  Manage independent task acceptance state
30
30
  verify Run configured verification
31
31
  review Run an independent review
32
+ batch Verify and review a parent task's subtasks together
32
33
  allow-stop Grant one fingerprint-bound completion-gate Stop permit (requires --session)
33
34
  agy-run Run headless agy with a process-exit completion recheck
34
35
  worktree <add|finish|list|resume|remove>
@@ -47,13 +48,16 @@ Options:
47
48
  --check-auth-target <target> Repeatable doctor filter with --check-auth
48
49
  --task <id>
49
50
  --parent <task-id> Task create: record a subtask of this
50
- task; task status: list its subtasks
51
+ task; task status: list its subtasks;
52
+ batch: the task whose subtasks it covers
53
+ --parent-base <git-ref> Batch only: base for the parent task
54
+ --width <n> Batch only: reviews allowed at once (default 2)
51
55
  --target-version <version> Update target version (offline-capable)
52
56
  --title <text>
53
57
  --criterion <json> Repeatable
54
58
  --evidence <criterion-id=reference> Repeatable
55
59
  --session <id>
56
- --base <git-ref> Verify/review/complete a clean committed range
60
+ --base <git-ref> Verify/review/batch/complete a clean committed range
57
61
  --dry-run
58
62
  --json
59
63
  --yes
@@ -181,6 +185,26 @@ Options:
181
185
  one summary line, so no shell redirect is needed
182
186
  --json
183
187
  --yes Required authorization for both reviewer sessions
188
+ `,
189
+ batch: `Usage: agent-ops batch --parent <task-id> --yes [options]
190
+
191
+ Verify and review the active subtasks of a parent task, and the parent itself,
192
+ together. Verify runs one task at a time and skips a task that already has
193
+ fresh PASS evidence; each review starts as soon as its own verify passes.
194
+ Nothing may commit or edit the worktree while it runs.
195
+
196
+ Options:
197
+ --parent <task-id> Required task whose active subtasks and itself are covered
198
+ --base <git-ref> Base for the subtasks (default: each task's recorded
199
+ review base, then the worktree base)
200
+ --parent-base <git-ref>
201
+ Base for the parent task (same default)
202
+ --width <n> Reviews allowed at once (default 2); a transient
203
+ NOT_RUN lowers it to 1 and earns one retry
204
+ --output <file> Write the JSON envelope to <file> (mode 0600) and print
205
+ one summary line, so no shell redirect is needed
206
+ --json
207
+ --yes Required authorization for the reviewer sessions
184
208
  `,
185
209
  "allow-stop": `Usage: agent-ops allow-stop --session <id> [options]
186
210
 
@@ -289,7 +313,7 @@ export async function runCli(argv, io, services) {
289
313
  const result = await execute(args);
290
314
  const exitCode = result.status === "ok"
291
315
  ? 0
292
- : result.code === "REVIEW_NOT_RUN"
316
+ : result.code === "REVIEW_NOT_RUN" || result.code === "BATCH_NOT_RUN"
293
317
  ? 2
294
318
  : 1;
295
319
  if (args.output !== undefined) {
@@ -0,0 +1,106 @@
1
+ import { AgentOpsError } from "../../../../runtime/src/fs/paths.js";
2
+ import { DEFAULT_BATCH_WIDTH, runBatch } from "../../../../runtime/src/review/batch.js";
3
+ import { resolveBatchBase, selectBatchTasks } from "../../../../runtime/src/review/batch-select.js";
4
+ import { redactSecrets } from "../../../../runtime/src/security/redact.js";
5
+ import { safeTaskText } from "../../../../runtime/src/task/render.js";
6
+ import { errorEnvelope } from "../output.js";
7
+ function outcomeLabel(task) {
8
+ if (task.aborted) {
9
+ return "aborted";
10
+ }
11
+ if (task.review === null) {
12
+ return task.verify === "FAIL" ? "no review (verify failed)" : "not reviewed";
13
+ }
14
+ const reason = task.review.reason === undefined ? "" : ` ${task.review.reason}`;
15
+ return `${task.review.status}${reason}${task.review.reused === true ? " (reused)" : ""}${task.retried ? " (retried)" : ""}`;
16
+ }
17
+ function renderBatch(data) {
18
+ const lines = [
19
+ `Batch of ${data.tasks.length} task(s) under ${data.parentTaskId}, width ${data.width}.`,
20
+ "",
21
+ ...data.tasks.map((task) => `- ${task.taskId} verify ${task.verify} review ${outcomeLabel(task)} base ${task.base.slice(0, 12)} (${task.baseSource}) ${safeTaskText(redactSecrets(task.title))}`)
22
+ ];
23
+ if (data.tasks.some((task) => task.aborted)) {
24
+ lines.push("", "The source changed or the run was interrupted; unfinished tasks were aborted. Run the batch again.");
25
+ }
26
+ return lines.join("\n");
27
+ }
28
+ function isPass(task) {
29
+ return !task.aborted && task.review?.status === "PASS";
30
+ }
31
+ /**
32
+ * The envelope's code follows the worst outcome that needs a human: a failed
33
+ * verify or review (fix the code) before a run that could not finish (run it
34
+ * again), so one defect is never hidden behind an unrelated NOT_RUN.
35
+ */
36
+ function classify(tasks) {
37
+ if (tasks.every(isPass)) {
38
+ return "BATCH_RESULT";
39
+ }
40
+ return tasks.some((task) => task.verify === "FAIL" || task.review?.status === "FAIL")
41
+ ? "BATCH_FAILED"
42
+ : "BATCH_NOT_RUN";
43
+ }
44
+ export async function runBatchCommand(options) {
45
+ const { args } = options;
46
+ const parentTaskId = args.parentTaskId;
47
+ if (parentTaskId === undefined) {
48
+ throw new AgentOpsError("BATCH_PARENT_REQUIRED", "Batch requires --parent.");
49
+ }
50
+ const selected = selectBatchTasks(await options.tasks.list(), parentTaskId);
51
+ const plan = selected.map((record) => ({
52
+ record,
53
+ ...resolveBatchBase(record, record.task.id === parentTaskId, {
54
+ ...(args.base === undefined ? {} : { base: args.base }),
55
+ ...(args.parentBase === undefined ? {} : { parentBase: args.parentBase }),
56
+ ...(options.worktreeBase === undefined ? {} : { worktreeBase: options.worktreeBase })
57
+ })
58
+ }));
59
+ const baseOf = new Map(plan.map((item) => [item.record.task.id, item.base]));
60
+ const base = (id) => baseOf.get(id);
61
+ // The first answer is the clean-tree check: a batch reviews committed ranges.
62
+ if (!await options.guard()) {
63
+ return {
64
+ ...errorEnvelope("BATCH_DIRTY_WORKTREE", "Batch needs a clean working tree. Commit or discard your changes first."),
65
+ data: null
66
+ };
67
+ }
68
+ const width = args.width ?? DEFAULT_BATCH_WIDTH;
69
+ const outcomes = await runBatch(plan.map((item) => item.record.task.id), {
70
+ isVerified: (id) => options.isVerified(id, base(id)),
71
+ verify: (id, signal) => options.verify(id, base(id), signal),
72
+ review: (id, signal) => options.review(id, base(id), signal),
73
+ guard: options.guard
74
+ }, {
75
+ width,
76
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
77
+ ...(options.onProgress === undefined ? {} : { onProgress: options.onProgress })
78
+ });
79
+ const byId = new Map(outcomes.map((outcome) => [outcome.id, outcome]));
80
+ const tasks = plan.map((item) => {
81
+ const outcome = byId.get(item.record.task.id);
82
+ return {
83
+ taskId: item.record.task.id,
84
+ title: item.record.task.title,
85
+ base: item.base,
86
+ baseSource: item.source,
87
+ verify: outcome.verify,
88
+ review: outcome.review,
89
+ retried: outcome.retried,
90
+ aborted: outcome.aborted
91
+ };
92
+ });
93
+ const code = classify(tasks);
94
+ const passed = tasks.filter(isPass).length;
95
+ const message = code === "BATCH_RESULT"
96
+ ? `Batch passed: ${passed} of ${tasks.length} task(s).`
97
+ : code === "BATCH_FAILED"
98
+ ? `Batch failed: ${passed} of ${tasks.length} task(s) passed.`
99
+ : `Batch was not completed: ${passed} of ${tasks.length} task(s) passed.`;
100
+ const summary = { message, parentTaskId, width, tasks };
101
+ const data = { ...summary, text: `${message}\n\n${renderBatch(summary)}` };
102
+ if (code === "BATCH_RESULT") {
103
+ return { code, status: "ok", data, errors: [] };
104
+ }
105
+ return { code, status: "error", data, errors: [{ code, message }] };
106
+ }
@@ -196,6 +196,28 @@ async function preflightReview(options, context, sourceFingerprint) {
196
196
  summary: { status: "PASS", sourceFingerprint, commands }
197
197
  };
198
198
  }
199
+ /**
200
+ * Whether the task already holds current PASS verification evidence for the
201
+ * source a review of `base` (or the working tree) would measure. This is the
202
+ * same preflight `review` runs before it spawns, so a batch that skips verify
203
+ * on a true answer never reaches a review that then reports stale evidence.
204
+ */
205
+ export async function hasFreshVerification(options, taskId, base) {
206
+ if (options.root === undefined || options.gitRunner === undefined) {
207
+ return false;
208
+ }
209
+ const context = await taskContext({ ...options, taskId });
210
+ if (context === undefined) {
211
+ return false;
212
+ }
213
+ const scope = await resolveReviewScope({
214
+ root: options.root,
215
+ runner: options.gitRunner,
216
+ ...(base === undefined ? {} : { base })
217
+ });
218
+ const fingerprint = await calculateSourceFingerprint(options.root, scope, options.gitRunner);
219
+ return (await preflightReview(options, context, fingerprint)).ok;
220
+ }
199
221
  function scopeReason(error) {
200
222
  if (!(error instanceof AgentOpsError)) {
201
223
  return undefined;
@@ -322,7 +322,7 @@ export function managedRules(descriptor, context) {
322
322
  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`.", " Keep it a plain command line: to keep the output add `--output <file>`", " (the JSON envelope is written there and one line is printed) instead of", " a shell redirect, which the host can only allow by matching the whole", " command string, task id included.", " 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.", "");
323
323
  }
324
324
  if (context.capabilities.includes("task")) {
325
- 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.", "", "Delegate a subtask whose scope spans unknown files, broad scans, or an", "independent workstream instead of doing it inline. Name the bounded", "scope, the artifact to return, and the acceptance criteria it serves.", subagentInstruction(descriptor), "Read every returned artifact back yourself: integration, verification,", "and review stay with the coordinator.", "", "One writer at a time. `agent-ops verify` and `agent-ops review`", "fingerprint the working tree and report UNKNOWN when anything else", "writes it during the run, so delegate edits to one subagent at a time,", "or give each writer its own Git worktree and merge before verifying.", "Read-only delegation has no such limit and may run in parallel.", "", "Parallel conversations in one repository each need their own worktree.", "When `.agent-ops/config.json` sets `worktree.mode` to `auto`, run", "`agent-ops task create` from the main checkout before your first edit:", "it creates this session's worktree and puts the task there (`agent-ops", "worktree add <name> --session <session-id>` does the same without a", "task). Work only inside the printed path", "(Claude Code: EnterWorktree with that path; otherwise use it as every", "command's working directory). Commit, then run verify and review for", "each task with the printed `--base`; do not run `task complete` in the", "worktree. Leave it (Claude Code: ExitWorktree with action keep) and run", "`agent-ops worktree finish <name>` from the main checkout: it completes", "every task in the worktree, subtasks first, then merges. A finish that", "reports an incomplete task names it; verify or review that task again", "in the worktree and finish again. A finish that reports a conflict is", "resolved once, as a new task whose criteria cover both intents; if its", "verification or review fails, reset as instructed and report.", "");
325
+ 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.", "", "To review every active subtask of a parent together, run", "`agent-ops batch --parent <task-id> --yes` instead of one review per", "subtask. It needs what review needs: start it through the trusted outer", "host runner as its first and only invocation, with the same Codex", "elevation, and keep `--output <file>` instead of a shell redirect. It", "verifies, one task at a time, each task that lacks fresh evidence, and", "reviews a few tasks at once. Do not commit or edit the worktree while it", "runs: a moved HEAD voids every verify and review in the batch.", "", "Delegate a subtask whose scope spans unknown files, broad scans, or an", "independent workstream instead of doing it inline. Name the bounded", "scope, the artifact to return, and the acceptance criteria it serves.", subagentInstruction(descriptor), "Read every returned artifact back yourself: integration, verification,", "and review stay with the coordinator.", "", "One writer at a time. `agent-ops verify` and `agent-ops review`", "fingerprint the working tree and report UNKNOWN when anything else", "writes it during the run, so delegate edits to one subagent at a time,", "or give each writer its own Git worktree and merge before verifying.", "Read-only delegation has no such limit and may run in parallel.", "", "Parallel conversations in one repository each need their own worktree.", "When `.agent-ops/config.json` sets `worktree.mode` to `auto`, run", "`agent-ops task create` from the main checkout before your first edit:", "it creates this session's worktree and puts the task there (`agent-ops", "worktree add <name> --session <session-id>` does the same without a", "task). Work only inside the printed path", "(Claude Code: EnterWorktree with that path; otherwise use it as every", "command's working directory). Commit, then run verify and review for", "each task with the printed `--base`; do not run `task complete` in the", "worktree. Leave it (Claude Code: ExitWorktree with action keep) and run", "`agent-ops worktree finish <name>` from the main checkout: it completes", "every task in the worktree, subtasks first, then merges. A finish that", "reports an incomplete task names it; verify or review that task again", "in the worktree and finish again. A finish that reports a conflict is", "resolved once, as a new task whose criteria cover both intents; if its", "verification or review fails, reset as instructed and report.", "");
326
326
  }
327
327
  if (context.capabilities.includes("lifecycle-summary")) {
328
328
  lines.push("Advisory lifecycle summaries and local logs are informational. Advisory", "failures must remain fail-open and cannot become verification evidence.", "");
@@ -15,13 +15,16 @@ export const CLAUDE_PREAUTH_ALLOW = [
15
15
  "Bash(agent-ops task *)",
16
16
  "Bash(agent-ops verify *)",
17
17
  "Bash(agent-ops review *)",
18
+ "Bash(agent-ops batch *)",
18
19
  "Bash(agent-ops worktree *)",
19
20
  "Bash(agent-ops doctor *)"
20
21
  ];
21
- /** review talks to the reviewer's API, which the sandbox withholds. */
22
+ /** review and batch talk to the reviewer's API, which the sandbox withholds. */
22
23
  export const CLAUDE_PREAUTH_UNSANDBOXED = [
23
24
  "agent-ops review",
24
- "agent-ops review *"
25
+ "agent-ops review *",
26
+ "agent-ops batch",
27
+ "agent-ops batch *"
25
28
  ];
26
29
  export const CODEX_RULES_PATH = "rules/agent-ops.rules";
27
30
  export const CODEX_RULES_MARKER = "# agent-ops managed: regenerated by agent-ops init and update; do not edit.";
@@ -51,8 +54,9 @@ function rule(options) {
51
54
  }
52
55
  /**
53
56
  * Prefix rules for the exact shapes `.agent-ops/AGENTS.md` tells Codex to run,
54
- * with and without AGENT_OPS_HOST, for review and the auth probe. Matching is
55
- * by argv prefix, so a shell redirect would defeat it: use `--output`.
57
+ * with and without AGENT_OPS_HOST, for review, batch and the auth probe.
58
+ * Matching is by argv prefix, so a shell redirect would defeat it: use
59
+ * `--output`.
56
60
  */
57
61
  export const CODEX_RULES_CONTENT = `${[
58
62
  CODEX_RULES_MARKER,
@@ -67,6 +71,13 @@ export const CODEX_RULES_CONTENT = `${[
67
71
  match: `${env} review --task task-x --yes --output review.json`,
68
72
  notMatch: `${env} trust grant --scope project --yes`
69
73
  }),
74
+ rule({
75
+ withHost,
76
+ command: ["batch"],
77
+ justification: "agent-ops batch runs reviews, which need network and loopback outside the sandbox.",
78
+ match: `${env} batch --parent task-x --yes --output batch.json`,
79
+ notMatch: `${env} trust grant --scope project --yes`
80
+ }),
70
81
  rule({
71
82
  withHost,
72
83
  command: ["doctor", "--check-auth"],
@@ -0,0 +1,11 @@
1
+ import { collectChangeSurface, resolveGitCommit } from "../verify/change-surface.js";
2
+ /**
3
+ * Remembers HEAD now and answers whether the batch may go on: HEAD is still
4
+ * that commit and the working tree has no change. The first answer doubles as
5
+ * the clean-tree check, since a batch reviews committed ranges only.
6
+ */
7
+ export async function createSourceGuard(runner) {
8
+ const head = await resolveGitCommit(runner, "HEAD");
9
+ return async () => await resolveGitCommit(runner, "HEAD") === head &&
10
+ (await collectChangeSurface(runner)).paths.length === 0;
11
+ }
@@ -0,0 +1,37 @@
1
+ import { AgentOpsError } from "../fs/paths.js";
2
+ /**
3
+ * The tasks one `batch --parent` covers: the parent's active subtasks in
4
+ * creation order, then the parent itself. Archived and completed tasks, and
5
+ * tasks under another parent, are left out.
6
+ */
7
+ export function selectBatchTasks(records, parentId) {
8
+ const parent = records.find((record) => record.task.id === parentId);
9
+ if (parent === undefined) {
10
+ throw new AgentOpsError("BATCH_PARENT_NOT_FOUND", `Parent task not found: ${parentId}`);
11
+ }
12
+ if (parent.status !== "active") {
13
+ throw new AgentOpsError("BATCH_PARENT_NOT_ACTIVE", `Parent task is not active: ${parentId}`);
14
+ }
15
+ const subtasks = records
16
+ .filter((record) => record.task.parentTaskId === parentId && record.status === "active")
17
+ .sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.task.id.localeCompare(right.task.id));
18
+ return [...subtasks, parent];
19
+ }
20
+ /**
21
+ * An explicit flag wins, then the commit the task's last PASS review measured
22
+ * against (so a rerun reproduces the same fingerprint), then the worktree base.
23
+ * A parent's base is a human choice that cannot be derived from the repository.
24
+ */
25
+ export function resolveBatchBase(record, isParent, input) {
26
+ const flag = isParent ? input.parentBase : input.base;
27
+ if (flag !== undefined) {
28
+ return { base: flag, source: "flag" };
29
+ }
30
+ if (record.reviewBase !== undefined) {
31
+ return { base: record.reviewBase, source: "recorded" };
32
+ }
33
+ if (input.worktreeBase !== undefined) {
34
+ return { base: input.worktreeBase, source: "worktree" };
35
+ }
36
+ throw new AgentOpsError("BATCH_BASE_UNKNOWN", `No base for task ${record.task.id}: pass ${isParent ? "--parent-base" : "--base"} or run from an agent-ops worktree.`);
37
+ }
@@ -0,0 +1,202 @@
1
+ import { AgentOpsError } from "../fs/paths.js";
2
+ /**
3
+ * NOT_RUN reasons that mean the reviewer was briefly unavailable. Anything
4
+ * else (a sandboxed host, stale verification, a changed policy) fails the same
5
+ * way on a second attempt, so it is reported instead of retried.
6
+ */
7
+ export const TRANSIENT_REVIEW_REASONS = new Set([
8
+ "probe-failed",
9
+ "stalled",
10
+ "timeout",
11
+ "quota-exhausted",
12
+ "network-unreachable"
13
+ ]);
14
+ /** Reviews allowed at once when the caller names no width. */
15
+ export const DEFAULT_BATCH_WIDTH = 2;
16
+ /**
17
+ * Verify runs one task at a time (a full test run per task; several at once
18
+ * starve each other of CPU and fake a failure). Each task's review starts as
19
+ * soon as its own verify has passed, so the next verify overlaps it. Reviews
20
+ * run up to `width` at a time; a transient NOT_RUN drops the width to 1 for
21
+ * good and that task gets one more try alone after everything else finished.
22
+ */
23
+ export async function runBatch(ids, deps, options) {
24
+ if (!Number.isInteger(options.width) || options.width < 1) {
25
+ throw new AgentOpsError("BATCH_INVALID_WIDTH", "Batch width must be a positive integer.");
26
+ }
27
+ const progress = options.onProgress ?? (() => { });
28
+ const controller = new AbortController();
29
+ const outer = options.signal;
30
+ if (outer?.aborted === true) {
31
+ controller.abort();
32
+ }
33
+ else {
34
+ outer?.addEventListener("abort", () => controller.abort(), { once: true });
35
+ }
36
+ const slots = new Map(ids.map((id) => [
37
+ id,
38
+ { id, verify: "not-run", review: null, retried: false }
39
+ ]));
40
+ const slot = (id) => slots.get(id);
41
+ const stop = (why) => {
42
+ if (!controller.signal.aborted) {
43
+ progress(why);
44
+ controller.abort();
45
+ }
46
+ };
47
+ const guard = async () => {
48
+ try {
49
+ return await deps.guard();
50
+ }
51
+ catch {
52
+ return false;
53
+ }
54
+ };
55
+ let width = options.width;
56
+ let running = 0;
57
+ let verifying = true;
58
+ const ready = [];
59
+ const retry = [];
60
+ let finish;
61
+ const done = new Promise((resolve) => {
62
+ finish = resolve;
63
+ });
64
+ const review = async (id, final) => {
65
+ if (!await guard()) {
66
+ stop("source changed; aborting the batch");
67
+ return;
68
+ }
69
+ const entry = slot(id);
70
+ if (final) {
71
+ entry.retried = true;
72
+ }
73
+ let result;
74
+ try {
75
+ result = await deps.review(id, controller.signal);
76
+ }
77
+ catch (error) {
78
+ progress(`review ${id} failed to run: ${error instanceof Error ? error.message : String(error)}`);
79
+ result = { status: "NOT_RUN", reason: "review-error" };
80
+ }
81
+ if (controller.signal.aborted) {
82
+ return;
83
+ }
84
+ entry.review = result;
85
+ if (!final &&
86
+ result.status === "NOT_RUN" &&
87
+ result.reason !== undefined &&
88
+ TRANSIENT_REVIEW_REASONS.has(result.reason)) {
89
+ if (width > 1) {
90
+ width = 1;
91
+ progress(`degrade: review width -> 1 (${id}: ${result.reason})`);
92
+ }
93
+ retry.push(id);
94
+ }
95
+ };
96
+ const pump = () => {
97
+ while (!controller.signal.aborted && running < width && ready.length > 0) {
98
+ const id = ready.shift();
99
+ running += 1;
100
+ void review(id, false).finally(() => {
101
+ running -= 1;
102
+ pump();
103
+ });
104
+ }
105
+ if (!verifying && running === 0 && (ready.length === 0 || controller.signal.aborted)) {
106
+ finish();
107
+ }
108
+ };
109
+ const fresh = await Promise.all(ids.map((id) => deps.isVerified(id).catch(() => false)));
110
+ const stale = [];
111
+ ids.forEach((id, index) => {
112
+ if (fresh[index] === true) {
113
+ slot(id).verify = "skipped";
114
+ ready.push(id);
115
+ }
116
+ else {
117
+ stale.push(id);
118
+ }
119
+ });
120
+ const verifyAll = async () => {
121
+ for (const id of stale) {
122
+ if (controller.signal.aborted) {
123
+ break;
124
+ }
125
+ if (!await guard()) {
126
+ stop("source changed; aborting the batch");
127
+ break;
128
+ }
129
+ let status;
130
+ try {
131
+ status = await deps.verify(id, controller.signal);
132
+ }
133
+ catch (error) {
134
+ progress(`verify ${id} failed to run: ${error instanceof Error ? error.message : String(error)}`);
135
+ status = "FAIL";
136
+ }
137
+ if (controller.signal.aborted) {
138
+ break;
139
+ }
140
+ slot(id).verify = status;
141
+ if (status === "PASS") {
142
+ ready.push(id);
143
+ pump();
144
+ }
145
+ }
146
+ verifying = false;
147
+ pump();
148
+ };
149
+ void verifyAll();
150
+ pump();
151
+ await done;
152
+ if (!controller.signal.aborted) {
153
+ for (const id of retry) {
154
+ if (controller.signal.aborted) {
155
+ break;
156
+ }
157
+ await review(id, true);
158
+ }
159
+ }
160
+ const interrupted = controller.signal.aborted;
161
+ return ids.map((id) => {
162
+ const entry = slot(id);
163
+ const pendingRetry = retry.includes(id) && !entry.retried;
164
+ const unfinished = entry.review === null
165
+ ? entry.verify !== "FAIL"
166
+ : pendingRetry;
167
+ return {
168
+ id,
169
+ verify: entry.verify,
170
+ review: entry.review,
171
+ retried: entry.retried,
172
+ aborted: interrupted && unfinished
173
+ };
174
+ });
175
+ }
176
+ /**
177
+ * One probe per target for a whole batch. Concurrent callers share the probe
178
+ * in flight and a success is kept, but a failure is forgotten so the retry of
179
+ * a task re-probes instead of inheriting the failure.
180
+ */
181
+ export function memoizePreflight(probe) {
182
+ const cache = new Map();
183
+ return (target, budget) => {
184
+ const cached = cache.get(target);
185
+ if (cached !== undefined) {
186
+ return cached;
187
+ }
188
+ const pending = probe(target, budget);
189
+ cache.set(target, pending);
190
+ const forget = () => {
191
+ if (cache.get(target) === pending) {
192
+ cache.delete(target);
193
+ }
194
+ };
195
+ pending.then((answer) => {
196
+ if ((typeof answer === "string" ? answer : answer.result) !== "ok") {
197
+ forget();
198
+ }
199
+ }, forget);
200
+ return pending;
201
+ };
202
+ }
@@ -161,11 +161,24 @@ export function buildTargetInvocation(request) {
161
161
  };
162
162
  }
163
163
  /** A deep doctor probe uses stdin but keeps its simple text response contract. */
164
+ /**
165
+ * What the probe runs with where it differs from a review. The probe asks for
166
+ * one word, not a review, and agy's plan mode tells the model to research the
167
+ * task first: on a real project it explores, runs a command the headless user
168
+ * never allowed, is soft-denied, and prints nothing, which reads as
169
+ * `probe-failed`. In 40 replays, plan mode failed 4 of 20 and without it all 20
170
+ * answered in exactly two model calls. Only the probe drops it; a review keeps
171
+ * `READ_ONLY_ARGS`, and the sandbox stays on for both.
172
+ */
173
+ const PROBE_ARGS = {
174
+ agy: ["--sandbox"]
175
+ };
164
176
  export function buildProbeInvocation(request) {
165
- const readOnly = READ_ONLY_ARGS[request.target];
166
- if (readOnly === undefined || readOnly.length === 0) {
177
+ const eligible = READ_ONLY_ARGS[request.target];
178
+ if (eligible === undefined || eligible.length === 0) {
167
179
  return undefined;
168
180
  }
181
+ const readOnly = PROBE_ARGS[request.target] ?? eligible;
169
182
  if (request.target === "codex") {
170
183
  return {
171
184
  command: "codex",
@@ -37,7 +37,20 @@ export function probeTimeoutMs(requested) {
37
37
  export async function probeReviewTarget(target, options) {
38
38
  return (await probeReviewTargetDetailed(target, options)).result;
39
39
  }
40
+ /**
41
+ * A deep probe is one model call, and a model call can fail for reasons that
42
+ * say nothing about the install. `probe-failed` is the answer for "it did not
43
+ * work and did not say why", so it gets one more try; every other answer is
44
+ * specific (not logged in, no network, timed out, not installed) and repeating
45
+ * it would only spend more of the budget on the same result.
46
+ */
40
47
  export async function probeReviewTargetDetailed(target, options) {
48
+ const first = await probeOnce(target, options);
49
+ return options.deep === true && first.result === "probe-failed"
50
+ ? await probeOnce(target, options)
51
+ : first;
52
+ }
53
+ async function probeOnce(target, options) {
41
54
  const deep = options.deep === true;
42
55
  const directory = deep
43
56
  ? await mkdtemp(join(tmpdir(), "agent-ops-review-probe-"))
@@ -125,3 +125,73 @@ FAIL.
125
125
  - Evidence: The result reason distinguishes a protocol violation from a verdict.
126
126
  - Positive: `NOT_RUN: unparseable-output; one criterion was missing.`
127
127
  - Negative: `Record a failed review because the model's JSON was malformed.`
128
+
129
+ ## REVIEW-BATCH-001
130
+
131
+ A batch review MUST cover exactly the active subtasks of the named parent and the parent itself.
132
+
133
+ - Trigger: Running `batch --parent <task-id>`.
134
+ - Action: Select the parent's active subtasks in creation order, then the parent. Leave out archived and completed tasks and tasks under another parent. Reject an unknown parent with `BATCH_PARENT_NOT_FOUND` and an inactive one with `BATCH_PARENT_NOT_ACTIVE`.
135
+ - Evidence: `data.tasks` lists exactly those task ids, in that order.
136
+ - Positive: `Two active subtasks and the parent are covered; the archived subtask is not.`
137
+ - Negative: `Cover every task in the worktree whatever its parent.`
138
+
139
+ ## REVIEW-BATCH-002
140
+
141
+ Each task's base MUST come from an explicit flag, then its recorded review base, then the worktree base.
142
+
143
+ - Trigger: Resolving the range a batch task is verified and reviewed against.
144
+ - Action: Use `--base` for subtasks and `--parent-base` for the parent; otherwise the task's recorded `reviewBase`; otherwise the worktree base. Report `BATCH_BASE_UNKNOWN` when none exists.
145
+ - Evidence: `data.tasks[].base` names the base each task used.
146
+ - Positive: `A rerun reuses each PASSed task's recorded base, so its fingerprint and attestation still match.`
147
+ - Negative: `Apply --base to the parent, or fall back to HEAD when no base is known.`
148
+
149
+ ## REVIEW-BATCH-003
150
+
151
+ Verification MUST run one task at a time, each task's review MUST start as soon as its own verification passes, and no more than `--width` reviews (default 2) MUST run at once.
152
+
153
+ - Trigger: Tasks in a batch need verification and review.
154
+ - Action: Skip verification for a task that already holds fresh PASS evidence, using the same check review preflight makes. Verify the rest serially. Start a task's review right after its verification passes. Give a task whose verification fails no review, and continue with the others.
155
+ - Evidence: `data.tasks[].verify` is `skipped`, `PASS` or `FAIL`, and a task that failed verification has no review.
156
+ - Positive: `Task B is verified while task A is being reviewed.`
157
+ - Negative: `Run every task's test suite at once.`
158
+
159
+ ## REVIEW-BATCH-004
160
+
161
+ A transient NOT_RUN MUST lower the review width to 1 for the rest of the batch and earn exactly one retry, and every other result MUST be final.
162
+
163
+ - Trigger: A review in a batch returns NOT_RUN.
164
+ - Action: When the reason is `probe-failed`, `stalled`, `timeout`, `quota-exhausted` or `network-unreachable`, set the width to 1 for every review not yet started and retry that task once, alone, after the rest have finished. Never retry another reason, never retry twice, and never raise the width again.
165
+ - Evidence: `review.retried` is true for a retried task, and a second transient NOT_RUN stays NOT_RUN.
166
+ - Positive: `probe-failed on task A drops the width to 1, and A runs once more after the others.`
167
+ - Negative: `Retry host-sandboxed until it passes.`
168
+
169
+ ## REVIEW-BATCH-005
170
+
171
+ A batch MUST stop when the source it started on changes or the process is interrupted.
172
+
173
+ - Trigger: HEAD or the working tree differs from the start of the batch, or the process receives SIGINT or SIGTERM.
174
+ - Action: Require a clean working tree before starting and check HEAD and the tree before every step. On a change, abort the reviews in flight, start nothing new, and report each unfinished task as `aborted`. A signal exits 130 or 143.
175
+ - Evidence: `data.tasks[].aborted` is true for every task that did not finish.
176
+ - Positive: `A commit during the batch aborts it and the unfinished tasks say aborted.`
177
+ - Negative: `Let in-flight reviews finish against a HEAD that no longer matches.`
178
+
179
+ ## REVIEW-BATCH-006
180
+
181
+ A batch MUST probe each review target at most once while the probe succeeds and MUST NOT cache a failed probe.
182
+
183
+ - Trigger: Reviews in a batch need target preflight.
184
+ - Action: Share the probe in flight between concurrent reviews and keep a successful answer for the rest of the batch. Forget a failed answer, so the retry of a task probes again.
185
+ - Evidence: A batch of several reviews makes one probe per target, and a retry after a failure makes a new one.
186
+ - Positive: `Five reviews share one successful agy probe.`
187
+ - Negative: `Cache probe-failed and refuse every remaining task.`
188
+
189
+ ## REVIEW-BATCH-007
190
+
191
+ A batch result MUST report every task's outcome and MUST exit 0 only when every task passed.
192
+
193
+ - Trigger: A batch ends.
194
+ - Action: Return `BATCH_RESULT`, `BATCH_FAILED` or `BATCH_NOT_RUN` with `data.tasks[]` holding each task's id, base, verify status, review status and reason, `reused`, `retried` and `aborted`, without the full reports, which stay under `.agent-ops/reviews/`. Exit 0 when every task passed, 1 when any review or verification failed, and 2 otherwise. `batch` is started like `review`: it needs `--yes` and trust, and REVIEW-HOST-001 applies to it.
195
+ - Evidence: The envelope code and the exit code agree with the per-task outcomes.
196
+ - Positive: `One FAIL and one NOT_RUN exit 1, so the defect is handled first.`
197
+ - Negative: `Exit 0 while a task is still NOT_RUN.`
@@ -1,6 +1,6 @@
1
1
  # 審查
2
2
 
3
- English source version: 2026-07-23. Revalidate: when the English specification changes.
3
+ English source version: 2026-09-30. Revalidate: when the English specification changes.
4
4
 
5
5
  ## REVIEW-INDEPENDENT-001
6
6
 
@@ -116,3 +116,73 @@ permission bypass。外部 host runner 不可用時,結果 MUST 保持 NOT_RUN
116
116
  - Evidence: 結果的 reason 能區分協議違規與判定結果。
117
117
  - Positive: `NOT_RUN:unparseable-output;缺少一條 criterion。`
118
118
  - Negative: `因為模型的 JSON 格式錯誤就記錄一次失敗的審查。`
119
+
120
+ ## REVIEW-BATCH-001
121
+
122
+ 批次審查 MUST 只涵蓋指定 parent 的 active 子 task 以及 parent 本身。
123
+
124
+ - Trigger: 執行 `batch --parent <task-id>`。
125
+ - Action: 依建立順序選出 parent 的 active 子 task,再加上 parent。排除 archived、已完成的 task 與屬於其他 parent 的 task。未知 parent 以 `BATCH_PARENT_NOT_FOUND` 拒絕,非 active 的以 `BATCH_PARENT_NOT_ACTIVE` 拒絕。
126
+ - Evidence: `data.tasks` 依序列出的正是這些 task id。
127
+ - Positive: `兩個 active 子 task 與 parent 被涵蓋;archived 的子 task 不在內。`
128
+ - Negative: `不論 parent 為何,涵蓋 worktree 內所有 task。`
129
+
130
+ ## REVIEW-BATCH-002
131
+
132
+ 每個 task 的 base MUST 依序取自明確旗標、該 task 已記錄的 review base、worktree base。
133
+
134
+ - Trigger: 決定批次中某個 task 驗證與審查所用的範圍。
135
+ - Action: 子 task 用 `--base`,parent 用 `--parent-base`;否則用該 task 已記錄的 `reviewBase`;再否則用 worktree base。都沒有時回報 `BATCH_BASE_UNKNOWN`。
136
+ - Evidence: `data.tasks[].base` 指出每個 task 使用的 base。
137
+ - Positive: `重跑時沿用每個已 PASS task 記錄的 base,fingerprint 與 attestation 仍然吻合。`
138
+ - Negative: `把 --base 套到 parent,或在不知道 base 時退回 HEAD。`
139
+
140
+ ## REVIEW-BATCH-003
141
+
142
+ 驗證 MUST 一次只跑一個 task,每個 task 的審查 MUST 在它自己的驗證通過後立刻開始,同時進行的審查 MUST NOT 超過 `--width`(預設 2)。
143
+
144
+ - Trigger: 批次中的 task 需要驗證與審查。
145
+ - Action: 已有 fresh PASS evidence 的 task 略過驗證,判斷方式與 review 預檢相同。其餘串行驗證。task 驗證通過後立刻開始它的審查。驗證失敗的 task 不開審查,其他 task 繼續。
146
+ - Evidence: `data.tasks[].verify` 為 `skipped`、`PASS` 或 `FAIL`,驗證失敗的 task 沒有 review。
147
+ - Positive: `task A 審查的同時驗證 task B。`
148
+ - Negative: `同時跑每個 task 的完整測試。`
149
+
150
+ ## REVIEW-BATCH-004
151
+
152
+ 暫時性的 NOT_RUN MUST 讓其餘審查的 width 降為 1 並只重試一次,其他結果 MUST 為最終結果。
153
+
154
+ - Trigger: 批次中的某個審查回傳 NOT_RUN。
155
+ - Action: 原因為 `probe-failed`、`stalled`、`timeout`、`quota-exhausted` 或 `network-unreachable` 時,讓所有尚未開始的審查 width 降為 1,並在其餘審查結束後,單獨重試該 task 一次。其他原因不重試,不重試第二次,width 也不再升回。
156
+ - Evidence: 被重試的 task `review.retried` 為 true,第二次暫時性 NOT_RUN 仍是 NOT_RUN。
157
+ - Positive: `task A 的 probe-failed 使 width 降為 1,A 在其他 task 之後再跑一次。`
158
+ - Negative: `對 host-sandboxed 不斷重試直到通過。`
159
+
160
+ ## REVIEW-BATCH-005
161
+
162
+ 來源變動或程序被中斷時,批次 MUST 停止。
163
+
164
+ - Trigger: HEAD 或工作樹與批次開始時不同,或程序收到 SIGINT 或 SIGTERM。
165
+ - Action: 開始前要求工作樹乾淨,每個步驟前檢查 HEAD 與工作樹。發現變動時,中止進行中的審查,不再開始新步驟,並把每個未完成的 task 回報為 `aborted`。訊號以 130 或 143 結束。
166
+ - Evidence: 每個未完成的 task 其 `data.tasks[].aborted` 為 true。
167
+ - Positive: `批次進行中發生 commit 就中止,未完成的 task 標示 aborted。`
168
+ - Negative: `讓進行中的審查對已不吻合的 HEAD 跑完。`
169
+
170
+ ## REVIEW-BATCH-006
171
+
172
+ 批次 MUST 在探測成功時對每個審查 target 最多探測一次,且 MUST NOT 快取失敗的探測。
173
+
174
+ - Trigger: 批次中的審查需要 target 預檢。
175
+ - Action: 同時進行的審查共用進行中的探測,成功的答案在批次剩餘期間保留。失敗的答案丟棄,讓 task 的重試再探測一次。
176
+ - Evidence: 多個審查的批次每個 target 只探測一次,失敗後的重試會產生新的探測。
177
+ - Positive: `五個審查共用一次成功的 agy 探測。`
178
+ - Negative: `快取 probe-failed 並拒絕其餘所有 task。`
179
+
180
+ ## REVIEW-BATCH-007
181
+
182
+ 批次結果 MUST 回報每個 task 的結果,且 MUST 僅在每個 task 都通過時以 0 結束。
183
+
184
+ - Trigger: 批次結束。
185
+ - Action: 回傳 `BATCH_RESULT`、`BATCH_FAILED` 或 `BATCH_NOT_RUN`,`data.tasks[]` 含每個 task 的 id、base、驗證狀態、審查狀態與原因、`reused`、`retried` 與 `aborted`,不含完整報告(報告留在 `.agent-ops/reviews/`)。全數通過以 0 結束,任一審查或驗證失敗以 1 結束,其餘以 2 結束。`batch` 的啟動方式與 `review` 相同:需要 `--yes` 與 trust,且適用 REVIEW-HOST-001。
186
+ - Evidence: envelope code 與 exit code 與各 task 的結果一致。
187
+ - Positive: `一個 FAIL 與一個 NOT_RUN 以 1 結束,先處理缺陷。`
188
+ - Negative: `仍有 task 是 NOT_RUN 卻以 0 結束。`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kylecheng3146/agent-ops",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Evidence-driven development loops for agy, Codex, Claude Code, and opencode",
5
5
  "type": "module",
6
6
  "license": "MIT",