@kylecheng3146/agent-ops 0.3.7 → 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.
Files changed (46) hide show
  1. package/README.md +35 -0
  2. package/dist/packages/cli/src/args.js +93 -9
  3. package/dist/packages/cli/src/bin.js +151 -104
  4. package/dist/packages/cli/src/cli.js +54 -5
  5. package/dist/packages/cli/src/commands/batch.js +106 -0
  6. package/dist/packages/cli/src/commands/hook.js +4 -1
  7. package/dist/packages/cli/src/commands/init.js +1 -0
  8. package/dist/packages/cli/src/commands/parallel.js +16 -3
  9. package/dist/packages/cli/src/commands/review.js +22 -0
  10. package/dist/packages/cli/src/commands/uninstall.js +3 -2
  11. package/dist/packages/cli/src/commands/update.js +1 -0
  12. package/dist/packages/cli/src/context.js +28 -3
  13. package/dist/packages/cli/src/hook-process.js +14 -3
  14. package/dist/packages/cli/src/public-plan.js +32 -2
  15. package/dist/runtime/src/adapters/claude/config.js +1 -1
  16. package/dist/runtime/src/adapters/claude/input.js +17 -0
  17. package/dist/runtime/src/adapters/claude/output.js +9 -0
  18. package/dist/runtime/src/hooks/codex-loop.js +67 -7
  19. package/dist/runtime/src/hooks/completion-gate.js +27 -2
  20. package/dist/runtime/src/hooks/dispatch.js +5 -0
  21. package/dist/runtime/src/install/apply.js +2 -0
  22. package/dist/runtime/src/install/doctor.js +8 -1
  23. package/dist/runtime/src/install/harness.js +17 -6
  24. package/dist/runtime/src/install/plan.js +9 -0
  25. package/dist/runtime/src/install/preauth.js +276 -0
  26. package/dist/runtime/src/install/probes.js +32 -0
  27. package/dist/runtime/src/install/uninstall.js +29 -11
  28. package/dist/runtime/src/install/update.js +1 -0
  29. package/dist/runtime/src/parallel/enter.js +43 -0
  30. package/dist/runtime/src/parallel/finish.js +2 -1
  31. package/dist/runtime/src/parallel/guard.js +20 -1
  32. package/dist/runtime/src/parallel/manage.js +38 -0
  33. package/dist/runtime/src/parallel/service.js +3 -1
  34. package/dist/runtime/src/review/batch-guard.js +11 -0
  35. package/dist/runtime/src/review/batch-select.js +37 -0
  36. package/dist/runtime/src/review/batch.js +202 -0
  37. package/dist/runtime/src/review/execute.js +10 -7
  38. package/dist/runtime/src/review/host-sandbox.js +9 -0
  39. package/dist/runtime/src/review/invocation.js +15 -2
  40. package/dist/runtime/src/review/probe.js +51 -15
  41. package/dist/runtime/src/review/render.js +4 -0
  42. package/docs/en/guides/configuration.md +51 -1
  43. package/docs/en/spec/review.md +70 -0
  44. package/docs/zh-TW/guides/configuration.md +41 -0
  45. package/docs/zh-TW/spec/review.md +71 -1
  46. package/package.json +1 -1
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
  ];
@@ -17,7 +18,7 @@ const SCOPES = new Set(["project", "user"]);
17
18
  const PROFILES = new Set(["advisory", "core", "guardrails", "loop"]);
18
19
  // opencode is absent: it has no read-only flag, so it cannot review.
19
20
  const REVIEW_TARGETS = new Set(["agy", "claude", "codex"]);
20
- export const WORKTREE_ACTIONS = ["add", "finish", "list", "resume", "remove"];
21
+ export const WORKTREE_ACTIONS = ["add", "commit", "finish", "list", "resume", "remove"];
21
22
  export class CliArgumentError extends Error {
22
23
  code;
23
24
  option;
@@ -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 = [];
@@ -87,6 +90,8 @@ export function parseArgs(argv) {
87
90
  let worktreeName;
88
91
  let force = false;
89
92
  let worktreeFrom;
93
+ let worktreeMessage;
94
+ let output;
90
95
  let worktreeTargetBranch;
91
96
  let worktree;
92
97
  for (let index = 0; index < argv.length; index += 1) {
@@ -208,6 +213,26 @@ export function parseArgs(argv) {
208
213
  index += 1;
209
214
  break;
210
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
+ }
211
236
  case "--check-auth":
212
237
  if (checkAuth) {
213
238
  duplicate(token);
@@ -276,6 +301,23 @@ export function parseArgs(argv) {
276
301
  index += 1;
277
302
  break;
278
303
  }
304
+ case "-m":
305
+ case "--message": {
306
+ if (worktreeMessage !== undefined) {
307
+ duplicate(token);
308
+ }
309
+ worktreeMessage = readOptionValue(argv, index, token);
310
+ index += 1;
311
+ break;
312
+ }
313
+ case "--output": {
314
+ if (output !== undefined) {
315
+ duplicate(token);
316
+ }
317
+ output = readOptionValue(argv, index, token);
318
+ index += 1;
319
+ break;
320
+ }
279
321
  case "--target-branch": {
280
322
  if (worktreeTargetBranch !== undefined) {
281
323
  duplicate(token);
@@ -339,7 +381,11 @@ export function parseArgs(argv) {
339
381
  action = token;
340
382
  break;
341
383
  }
342
- if (command === "worktree" && action !== undefined && action !== "list" && worktreeName === undefined) {
384
+ if (command === "worktree" &&
385
+ action !== undefined &&
386
+ action !== "list" &&
387
+ action !== "commit" &&
388
+ worktreeName === undefined) {
343
389
  worktreeName = token;
344
390
  break;
345
391
  }
@@ -393,6 +439,8 @@ export function parseArgs(argv) {
393
439
  completionGate !== undefined ||
394
440
  sessionId !== undefined ||
395
441
  base !== undefined ||
442
+ parentBase !== undefined ||
443
+ width !== undefined ||
396
444
  checkAuth ||
397
445
  checkAuthTargets.length > 0 ||
398
446
  dryRun ||
@@ -400,6 +448,8 @@ export function parseArgs(argv) {
400
448
  rerun ||
401
449
  worktree !== undefined ||
402
450
  worktreeFrom !== undefined ||
451
+ worktreeMessage !== undefined ||
452
+ output !== undefined ||
403
453
  worktreeTargetBranch !== undefined ||
404
454
  force) {
405
455
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Only --json may be combined with global help or version.");
@@ -415,9 +465,15 @@ export function parseArgs(argv) {
415
465
  if (command === "worktree" && action === undefined) {
416
466
  throw new CliArgumentError("CLI_ACTION_REQUIRED", `The worktree command requires one of: ${WORKTREE_ACTIONS.join(", ")}.`);
417
467
  }
418
- if (command === "worktree" && action !== "list" && worktreeName === undefined) {
468
+ if (command === "worktree" && action !== "list" && action !== "commit" && worktreeName === undefined) {
419
469
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", `worktree ${action} requires a worktree name.`);
420
470
  }
471
+ if (command === "worktree" && action === "commit" && (worktreeMessage === undefined || worktreeMessage.trim() === "")) {
472
+ throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "worktree commit requires --message (-m).", "--message");
473
+ }
474
+ if (worktreeMessage !== undefined && !(command === "worktree" && action === "commit")) {
475
+ throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--message may be used only with worktree commit.", "--message");
476
+ }
421
477
  if (command === "task" && action === undefined) {
422
478
  throw new CliArgumentError("CLI_ACTION_REQUIRED", "The task command requires one of: create, status, attach, complete, archive, export.");
423
479
  }
@@ -434,15 +490,18 @@ export function parseArgs(argv) {
434
490
  (hasTaskTargetOptions || hasTaskMutationOptions)) {
435
491
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Task target options may be used only with task or verify.");
436
492
  }
437
- if (command !== "task" && parentTaskId !== undefined) {
438
- 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.");
439
495
  }
440
496
  if (command !== "update" && targetVersion !== undefined) {
441
497
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--target-version may be used only with update.");
442
498
  }
443
499
  if (base !== undefined && command !== "verify" && command !== "review" &&
444
- !(command === "task" && action === "complete")) {
445
- 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.`);
446
505
  }
447
506
  if (hookTargets.length > 0 &&
448
507
  command !== "init" &&
@@ -535,6 +594,16 @@ export function parseArgs(argv) {
535
594
  if (command === "review" && (criteria.length > 0 || evidence.length > 0)) {
536
595
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Complete review covers every task criterion; omit --criterion and --evidence.");
537
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
+ }
538
607
  if (command === "allow-stop" &&
539
608
  (sessionId === undefined ||
540
609
  scope !== undefined ||
@@ -556,7 +625,13 @@ export function parseArgs(argv) {
556
625
  dryRun ||
557
626
  yes ||
558
627
  rerun)) {
559
- throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "worktree accepts only a name, --session, --from, --target-branch and --json.");
628
+ throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "worktree accepts only a name, --session, --from, --target-branch, --message and --json.");
629
+ }
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");
632
+ }
633
+ if (output !== undefined && (output.trim() === "" || output.includes("\0"))) {
634
+ throw new CliArgumentError("CLI_INVALID_VALUE", "Invalid value for --output.", "--output");
560
635
  }
561
636
  if (force && !(command === "worktree" && action === "remove")) {
562
637
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--force may be used only with worktree remove.");
@@ -572,7 +647,12 @@ export function parseArgs(argv) {
572
647
  !/^[A-Za-z0-9._/-]{1,128}$/u.test(worktreeTargetBranch)) {
573
648
  throw new CliArgumentError("CLI_INVALID_VALUE", `Invalid value for --target-branch: ${worktreeTargetBranch}`, "--target-branch");
574
649
  }
575
- if (command === "worktree" && action !== "add" && action !== "resume" && sessionId !== undefined) {
650
+ // commit acts as a session (it refuses another's worktree), so it may be told which.
651
+ if (command === "worktree" &&
652
+ action !== "add" &&
653
+ action !== "resume" &&
654
+ action !== "commit" &&
655
+ sessionId !== undefined) {
576
656
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", `worktree ${action} takes the session from the worktree; omit --session.`);
577
657
  }
578
658
  return {
@@ -594,7 +674,11 @@ export function parseArgs(argv) {
594
674
  ...(evidence.length === 0 ? {} : { evidence }),
595
675
  ...(sessionId === undefined ? {} : { sessionId }),
596
676
  ...(base === undefined ? {} : { base }),
677
+ ...(parentBase === undefined ? {} : { parentBase }),
678
+ ...(width === undefined ? {} : { width }),
597
679
  ...(worktreeFrom === undefined ? {} : { worktreeFrom }),
680
+ ...(worktreeMessage === undefined ? {} : { worktreeMessage }),
681
+ ...(output === undefined ? {} : { output }),
598
682
  ...(worktreeTargetBranch === undefined ? {} : { worktreeTargetBranch }),
599
683
  ...(checkAuth ? { checkAuth } : {}),
600
684
  ...(checkAuthTargets.length === 0 ? {} : { checkAuthTargets }),
@@ -6,9 +6,10 @@ import { join } from "node:path";
6
6
  import { execFileSync, spawn } from "node:child_process";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { commonHarnessAdapters, harnessHookPath, HARNESS_IDS } from "../../../runtime/src/install/harness.js";
9
- import { agyRuntimeStatus, agyVersionSupported, hookRegistrationDrift, repositoryTrustStatus, smokeAvailabilityStatus } from "../../../runtime/src/install/probes.js";
9
+ import { agyRuntimeStatus, agyVersionSupported, hookRegistrationDrift, repositoryTrustProbe, smokeAvailabilityStatus } from "../../../runtime/src/install/probes.js";
10
10
  import { parseInstallManifest } from "../../../runtime/src/fs/manifest.js";
11
- import { readRecordedSessionId } from "../../../runtime/src/hooks/codex-loop.js";
11
+ import { readRecordedSessionId, resolveCommandSessionId, sessionIdFromEnvironment } from "../../../runtime/src/hooks/codex-loop.js";
12
+ import { codexHomeDirectory } from "../../../runtime/src/install/preauth.js";
12
13
  import { NpmRegistryClient } from "../../../runtime/src/registry/npm.js";
13
14
  import { TaskService } from "../../../runtime/src/task/service.js";
14
15
  import { FileTaskStore } from "../../../runtime/src/task/store.js";
@@ -17,6 +18,7 @@ import { FileEvidenceStore } from "../../../runtime/src/verify/evidence.js";
17
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";
21
+ import { COMMAND_NAMES, parseArgs } from "./args.js";
20
22
  import { runCli } from "./cli.js";
21
23
  import { loadEffectiveConfig, repositoryTrust, repositoryTrustBinding } from "./context.js";
22
24
  import { runHookProcess } from "./hook-process.js";
@@ -29,9 +31,12 @@ import { runDoctorCommand } from "./commands/doctor.js";
29
31
  import { formatInstallPlan, runInitCommand } from "./commands/init.js";
30
32
  import { formatUninstallPlan, runUninstallCommand } from "./commands/uninstall.js";
31
33
  import { runTaskCommand } from "./commands/task.js";
32
- 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";
33
38
  import { createReviewExecutor, ReviewInterruptedError } from "../../../runtime/src/review/execute.js";
34
- import { probeReviewTarget } from "../../../runtime/src/review/probe.js";
39
+ import { probeReviewTargetDetailed } from "../../../runtime/src/review/probe.js";
35
40
  import { resolveReviewScope } from "../../../runtime/src/review/scope.js";
36
41
  import { resolveReviewRole } from "../../../runtime/src/review/roles.js";
37
42
  import { runTrustCommand } from "./commands/trust.js";
@@ -42,7 +47,7 @@ import { formatUpdatePlan, runUpdateCommand } from "./commands/update.js";
42
47
  import { errorEnvelope } from "./output.js";
43
48
  import { runAgyHeadless } from "./agy-headless.js";
44
49
  import { runWorktreeCommand } from "./commands/parallel.js";
45
- import { ensureSessionWorktree, resolveCheckouts } from "../../../runtime/src/parallel/service.js";
50
+ import { ensureSessionWorktree, readWorktreeRecord, resolveCheckouts } from "../../../runtime/src/parallel/service.js";
46
51
  import { listWorktrees, branchLockDoctorResult, worktreeDoctorResult } from "../../../runtime/src/parallel/manage.js";
47
52
  import { detectGhostFiles, ghostFilesDoctorResult } from "../../../runtime/src/install/doctor.js";
48
53
  const HOOK_RUNTIME_PATH = fileURLToPath(new URL("./hook-entry.js", import.meta.url));
@@ -206,19 +211,10 @@ else {
206
211
  writeStderr: (value) => process.stderr.write(value)
207
212
  }, {
208
213
  version: CLI_VERSION,
209
- registry: createCommandRegistry(Object.fromEntries([
210
- "init",
211
- "config",
212
- "trust",
213
- "doctor",
214
- "update",
215
- "uninstall",
216
- "task",
217
- "verify",
218
- "review",
219
- "allow-stop",
220
- "worktree"
221
- ].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) => {
222
218
  const root = args.scope === "user"
223
219
  ? process.env.AGENT_OPS_HOME ?? homedir()
224
220
  : process.cwd();
@@ -234,6 +230,7 @@ else {
234
230
  isTTY,
235
231
  toolkitVersion: CLI_VERSION,
236
232
  hookRuntimePath: HOOK_RUNTIME_PATH,
233
+ codexHome: codexHomeDirectory(),
237
234
  agyWarning: () => {
238
235
  try {
239
236
  const output = runAgy(["--version"], { timeout: 5_000 });
@@ -304,34 +301,7 @@ else {
304
301
  };
305
302
  },
306
303
  repositoryTrust: async () => {
307
- const trust = await repositoryTrust(root, config, CLI_VERSION);
308
- const status = repositoryTrustStatus(trust);
309
- if (trust === "STALE") {
310
- return {
311
- status,
312
- message: "Repository trust binding is stale.",
313
- code: "TRUST_REQUIRED",
314
- remediation: "Run `agent-ops trust grant`."
315
- };
316
- }
317
- if (trust === "UNTRUSTED") {
318
- const verificationConfigured = config.verification.commands.length > 0;
319
- return {
320
- status,
321
- message: verificationConfigured
322
- ? "Repository is not trusted; Stop verification will not run."
323
- : "Repository is not trusted.",
324
- ...(verificationConfigured
325
- ? {
326
- code: "TRUST_REQUIRED",
327
- remediation: "Run `agent-ops trust grant`."
328
- }
329
- : {
330
- remediation: "No action needed; trust is only required once verification.commands is set."
331
- })
332
- };
333
- }
334
- return { status };
304
+ return repositoryTrustProbe(await repositoryTrust(root, config, CLI_VERSION), config.verification.commands.length > 0);
335
305
  },
336
306
  smokeAvailability: () => {
337
307
  const status = smokeAvailabilityStatus(config);
@@ -343,7 +313,7 @@ else {
343
313
  }
344
314
  : { status };
345
315
  },
346
- reviewTarget: async (target, deep) => await probeReviewTarget(target, { cwd: root, deep }),
316
+ reviewTarget: async (target, deep) => await probeReviewTargetDetailed(target, { cwd: root, deep }),
347
317
  worktrees: async () => await worktreeDoctorProbe(root),
348
318
  worktreeBranchLock: async () => await worktreeBranchLockProbe(root),
349
319
  rootGhostFiles: async () => await rootGhostFilesProbe(root)
@@ -362,6 +332,7 @@ else {
362
332
  args,
363
333
  root,
364
334
  isTTY,
335
+ codexHome: codexHomeDirectory(),
365
336
  trustStore: store,
366
337
  calculateTrustBinding: async () => await plannedTrustBinding(root),
367
338
  confirm: async (plan, trust) => await confirmPlan(formatUninstallPlan(plan, trust))
@@ -387,6 +358,7 @@ else {
387
358
  isTTY,
388
359
  toolkitVersion: CLI_VERSION,
389
360
  hookRuntimePath: HOOK_RUNTIME_PATH,
361
+ codexHome: codexHomeDirectory(),
390
362
  ...(updateArgs.hookTargets === undefined
391
363
  ? {}
392
364
  : { hookTargets: updateArgs.hookTargets }),
@@ -422,8 +394,13 @@ else {
422
394
  // Explicit `--session` wins, then an injected identity, then the
423
395
  // id a SessionStart hook recorded for this checkout: a command
424
396
  // run inside a session is never told which session it is in.
425
- const sessionId = process.env.AGENT_OPS_SESSION_ID ??
426
- await readRecordedSessionId(root);
397
+ // Only create, attach and status act as a session; a contested
398
+ // record refuses those rather than guess, and leaves the rest.
399
+ const actsAsSession = args.action === "create" ||
400
+ args.action === "attach" || args.action === "status";
401
+ const sessionId = args.sessionId ?? (actsAsSession
402
+ ? await resolveCommandSessionId(root)
403
+ : sessionIdFromEnvironment() ?? await readRecordedSessionId(root));
427
404
  const createConfig = args.action === "create"
428
405
  ? (await loadEffectiveConfig(root, args.scope === "user" ? "user" : "project")).config
429
406
  : undefined;
@@ -460,12 +437,68 @@ else {
460
437
  : {})
461
438
  });
462
439
  }
463
- 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 = "") => {
464
450
  const reviewSessionId = process.env.AGENT_OPS_SESSION_ID;
465
451
  const reviewGit = gitRunner(root);
466
- const reviewConfig = (await loadEffectiveConfig(root, args.scope === "user" ? "user" : "project")).config;
452
+ const reviewConfig = (await loadEffectiveConfig(root, reviewArgs.scope === "user" ? "user" : "project")).config;
467
453
  const reviewRole = resolveReviewRole("independent-review", reviewConfig.reviewRoles ?? []);
468
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") {
469
502
  const controller = new AbortController();
470
503
  let interruptedBy;
471
504
  const interrupt = (signal) => {
@@ -477,59 +510,7 @@ else {
477
510
  process.once("SIGINT", onSigint);
478
511
  process.once("SIGTERM", onSigterm);
479
512
  try {
480
- return await runReviewCommand({
481
- args,
482
- authorized: args.yes,
483
- tasks: taskService,
484
- ...(args.taskId === undefined ? {} : { taskId: args.taskId }),
485
- ...(reviewSessionId === undefined
486
- ? {}
487
- : { sessionId: reviewSessionId }),
488
- ...(reviewConfig.reviewRoles === undefined
489
- ? {}
490
- : { roles: reviewConfig.reviewRoles }),
491
- targets: configuredReviewTargets,
492
- root,
493
- gitRunner: reviewGit,
494
- policyConfigHash: calculateConfigHash(reviewConfig),
495
- currentPolicyConfigHash: async () => calculateConfigHash((await loadEffectiveConfig(root, args.scope === "user" ? "user" : "project")).config),
496
- config: reviewConfig,
497
- evidenceStore: new FileEvidenceStore(root, root),
498
- execute: createReviewExecutor({
499
- targets: configuredReviewTargets,
500
- cwd: root,
501
- ...(reviewRole?.model === undefined
502
- ? {}
503
- : { model: reviewRole.model }),
504
- ...(reviewRole?.effort === undefined
505
- ? {}
506
- : { effort: reviewRole.effort }),
507
- ...(reviewRole?.timeoutMs === undefined
508
- ? {}
509
- : { timeoutMs: reviewRole.timeoutMs }),
510
- preflightTarget: async (target, budget) => await probeReviewTarget(target, {
511
- cwd: root,
512
- deep: true,
513
- // The probe answers within the chain's remaining budget
514
- // or not at all; its own default would outlast it.
515
- ...(budget === undefined
516
- ? {}
517
- : { timeoutMs: budget.timeoutMs })
518
- }),
519
- verifySourceFingerprint: async (expected) => {
520
- const currentScope = await resolveReviewScope({
521
- root,
522
- runner: reviewGit,
523
- ...(args.base === undefined ? {} : { base: args.base })
524
- });
525
- return await calculateSourceFingerprint(root, currentScope, reviewGit) === expected;
526
- },
527
- signal: controller.signal,
528
- onProgress: (line) => {
529
- process.stderr.write(`${line}\n`);
530
- }
531
- })
532
- });
513
+ return await runReviewCommand(await buildReviewOptions(args, controller.signal));
533
514
  }
534
515
  catch (error) {
535
516
  if (error instanceof ReviewInterruptedError &&
@@ -543,6 +524,72 @@ else {
543
524
  process.removeListener("SIGTERM", onSigterm);
544
525
  }
545
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
+ }
546
593
  if (args.command === "config") {
547
594
  return explainConfigCommand(await loadEffectiveConfig(root, args.scope === "user" ? "user" : "project"));
548
595
  }