@kylecheng3146/agent-ops 0.4.0 → 0.5.0

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/LICENSE CHANGED
@@ -1,21 +1,91 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Kyle Cheng
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ Required Notice: Copyright (c) 2026 Kyle Cheng (https://github.com/kylecheng3146/agent-ops)
2
+
3
+ # PolyForm Shield License 1.0.0
4
+
5
+ <https://polyformproject.org/licenses/shield/1.0.0>
6
+
7
+ ## Acceptance
8
+
9
+ In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses.
10
+
11
+ ## Copyright License
12
+
13
+ The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license).
14
+
15
+ ## Distribution License
16
+
17
+ The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license).
18
+
19
+ ## Notices
20
+
21
+ You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example:
22
+
23
+ > Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
24
+
25
+ ## Changes and New Works License
26
+
27
+ The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose.
28
+
29
+ ## Patent License
30
+
31
+ The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software.
32
+
33
+ ## Noncompete
34
+
35
+ Any purpose is a permitted purpose, except for providing any product that competes with the software or any product the licensor or any of its affiliates provides using the software.
36
+
37
+ ## Competition
38
+
39
+ Goods and services compete even when they provide functionality through different kinds of interfaces or for different technical platforms. Applications can compete with services, libraries with plugins, frameworks with development tools, and so on, even if they're written in different programming languages or for different computer architectures. Goods and services compete even when provided free of charge. If you market a product as a practical substitute for the software or another product, it definitely competes.
40
+
41
+ ## New Products
42
+
43
+ If you are using the software to provide a product that does not compete, but the licensor or any of its affiliates brings your product into competition by providing a new version of the software or another product using the software, you may continue using versions of the software available under these terms beforehand to provide your competing product, but not any later versions.
44
+
45
+ ## Discontinued Products
46
+
47
+ You may begin using the software to compete with a product or service that the licensor or any of its affiliates has stopped providing, unless the licensor includes a plain-text line beginning with `Licensor Line of Business:` with the software that mentions that line of business. For example:
48
+
49
+ > Licensor Line of Business: YoyodyneCMS Content Management System (http://example.com/cms)
50
+
51
+ ## Sales of Business
52
+
53
+ If the licensor or any of its affiliates sells a line of business developing the software or using the software to provide a product, the buyer can also enforce [Noncompete](#noncompete) for that product.
54
+
55
+ ## Fair Use
56
+
57
+ You may have "fair use" rights for the software under the law. These terms do not limit them.
58
+
59
+ ## No Other Rights
60
+
61
+ These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses.
62
+
63
+ ## Patent Defense
64
+
65
+ If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
66
+
67
+ ## Violations
68
+
69
+ The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately.
70
+
71
+ ## No Liability
72
+
73
+ ***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.***
74
+
75
+ ## Definitions
76
+
77
+ The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms.
78
+
79
+ A **product** can be a good or service, or a combination of them.
80
+
81
+ **You** refers to the individual or entity agreeing to these terms.
82
+
83
+ **Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all its affiliates.
84
+
85
+ **Affiliates** means the other organizations than an organization has control over, is under the control of, or is under common control with.
86
+
87
+ **Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
88
+
89
+ **Your licenses** are all the licenses granted to you for the software under these terms.
90
+
91
+ **Use** means anything you do with the software requiring one of your licenses.
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
@@ -377,4 +412,7 @@ Documentation:
377
412
 
378
413
  ## License
379
414
 
380
- Copyright (c) 2026 Kyle Cheng. Released under the [MIT License](LICENSE).
415
+ Copyright (c) 2026 Kyle Cheng. Released under the
416
+ [PolyForm Shield License 1.0.0](LICENSE): free to use, change and share,
417
+ including inside a company, but not to build a product or service that competes
418
+ with agent-ops. Versions up to 0.4.1 were released under, and remain under, the MIT License.
@@ -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) {