@kylecheng3146/agent-ops 0.5.2 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -347,6 +347,22 @@ agent-ops doctor --check-auth # one real print call per configured target
347
347
 
348
348
  ### Batch review
349
349
 
350
+ For writing subagents in separate worktrees, record each child's complete
351
+ modification intent at task creation (`--intent`), then commit and run its
352
+ local verifier. The coordinator runs `agent-ops task advance --task <parent-id>
353
+ --session <session-id> --yes` from the main checkout through the same trusted
354
+ outer host runner used for review. Advance integrates the committed children,
355
+ verifies every parent and child criterion on the integrated candidate, and
356
+ runs one two-round tree review. Only `NOT_RUN / scope-too-large` falls back to
357
+ per-task two-round reviews; FAIL requires a fix and a fresh final gate. A
358
+ passing gate saves the complete local receipt in `.git/agent-ops/receipts/`
359
+ before finish. A moved target gets at most one automatic rebase and repeat.
360
+ Run `agent-ops review show --task <parent-id>` from the main checkout to read
361
+ both full recorded reports for the latest candidate without invoking a reviewer.
362
+ `task advance` prints a short summary of nonblocking findings and residual risks.
363
+
364
+ `batch` remains the per-task review command for tasks already in one worktree.
365
+
350
366
  A change split into subtasks needs one verify and one review per subtask.
351
367
  `agent-ops batch --parent <task-id> --yes` runs them together for the parent's
352
368
  active subtasks and the parent itself, instead of one `review` at a time:
@@ -70,6 +70,7 @@ export function parseArgs(argv) {
70
70
  let parentTaskId;
71
71
  let targetVersion;
72
72
  let title;
73
+ let intent;
73
74
  let sessionId;
74
75
  let base;
75
76
  let parentBase;
@@ -85,6 +86,7 @@ export function parseArgs(argv) {
85
86
  let json = false;
86
87
  let yes = false;
87
88
  let rerun = false;
89
+ let tree = false;
88
90
  let helpSeen = false;
89
91
  let versionSeen = false;
90
92
  let worktreeName;
@@ -187,6 +189,19 @@ export function parseArgs(argv) {
187
189
  index += 1;
188
190
  break;
189
191
  }
192
+ case "--intent": {
193
+ if (intent !== undefined)
194
+ duplicate(token);
195
+ intent = readOptionValue(argv, index, token);
196
+ index += 1;
197
+ break;
198
+ }
199
+ case "--tree": {
200
+ if (tree)
201
+ duplicate(token);
202
+ tree = true;
203
+ break;
204
+ }
190
205
  case "--criterion": {
191
206
  criteria.push(readOptionValue(argv, index, token));
192
207
  index += 1;
@@ -365,6 +380,7 @@ export function parseArgs(argv) {
365
380
  if (command === "task" &&
366
381
  action === undefined &&
367
382
  [
383
+ "advance",
368
384
  "archive",
369
385
  "attach",
370
386
  "complete",
@@ -375,6 +391,10 @@ export function parseArgs(argv) {
375
391
  action = token;
376
392
  break;
377
393
  }
394
+ if (command === "review" && action === undefined && token === "show") {
395
+ action = "show";
396
+ break;
397
+ }
378
398
  if (command === "worktree" &&
379
399
  action === undefined &&
380
400
  WORKTREE_ACTIONS.includes(token)) {
@@ -433,6 +453,7 @@ export function parseArgs(argv) {
433
453
  parentTaskId !== undefined ||
434
454
  targetVersion !== undefined ||
435
455
  title !== undefined ||
456
+ intent !== undefined ||
436
457
  criteria.length > 0 ||
437
458
  evidence.length > 0 ||
438
459
  reviewTargets.length > 0 ||
@@ -446,6 +467,7 @@ export function parseArgs(argv) {
446
467
  dryRun ||
447
468
  yes ||
448
469
  rerun ||
470
+ tree ||
449
471
  worktree !== undefined ||
450
472
  worktreeFrom !== undefined ||
451
473
  worktreeMessage !== undefined ||
@@ -475,11 +497,12 @@ export function parseArgs(argv) {
475
497
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--message may be used only with worktree commit.", "--message");
476
498
  }
477
499
  if (command === "task" && action === undefined) {
478
- throw new CliArgumentError("CLI_ACTION_REQUIRED", "The task command requires one of: create, status, attach, complete, archive, export.");
500
+ throw new CliArgumentError("CLI_ACTION_REQUIRED", "The task command requires one of: create, status, attach, complete, archive, export, advance.");
479
501
  }
480
502
  const hasTaskTargetOptions = taskId !== undefined ||
481
503
  sessionId !== undefined;
482
504
  const hasTaskMutationOptions = title !== undefined ||
505
+ intent !== undefined ||
483
506
  criteria.length > 0 ||
484
507
  evidence.length > 0;
485
508
  if (command !== "task" &&
@@ -532,7 +555,7 @@ export function parseArgs(argv) {
532
555
  if (harness !== undefined ||
533
556
  profiles.length > 0 ||
534
557
  dryRun ||
535
- yes ||
558
+ (yes && action !== "advance") ||
536
559
  rerun) {
537
560
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Task commands do not accept harness, profile, dry-run, or yes options.");
538
561
  }
@@ -540,7 +563,7 @@ export function parseArgs(argv) {
540
563
  (taskId !== undefined ||
541
564
  evidence.length > 0)) ||
542
565
  (action === "status" &&
543
- (title !== undefined ||
566
+ (title !== undefined || intent !== undefined ||
544
567
  criteria.length > 0 ||
545
568
  evidence.length > 0 ||
546
569
  // --parent filters the listing, so it cannot name a single target.
@@ -548,21 +571,25 @@ export function parseArgs(argv) {
548
571
  (taskId !== undefined || sessionId !== undefined)) ||
549
572
  (taskId !== undefined && sessionId !== undefined))) ||
550
573
  (action === "attach" &&
551
- (title !== undefined ||
574
+ (title !== undefined || intent !== undefined ||
552
575
  criteria.length > 0 ||
553
576
  evidence.length > 0 ||
554
577
  parentTaskId !== undefined)) ||
555
578
  (action === "complete" &&
556
- (title !== undefined ||
579
+ (title !== undefined || intent !== undefined ||
557
580
  criteria.length > 0 ||
558
581
  sessionId !== undefined ||
559
582
  parentTaskId !== undefined)) ||
560
583
  ((action === "archive" || action === "export") &&
561
- (title !== undefined ||
584
+ (title !== undefined || intent !== undefined ||
562
585
  criteria.length > 0 ||
563
586
  evidence.length > 0 ||
564
587
  sessionId !== undefined ||
565
- parentTaskId !== undefined));
588
+ parentTaskId !== undefined)) ||
589
+ (action === "advance" &&
590
+ (taskId === undefined || title !== undefined || intent !== undefined ||
591
+ criteria.length > 0 || evidence.length > 0 || parentTaskId !== undefined ||
592
+ !yes));
566
593
  if (taskOptionInvalid) {
567
594
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", `Unsupported option for task ${action}.`);
568
595
  }
@@ -585,7 +612,19 @@ export function parseArgs(argv) {
585
612
  if (command === "review" && taskId === undefined) {
586
613
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Review requires --task for the complete task-bound review.");
587
614
  }
588
- if (command === "review" && !yes) {
615
+ if (command === "review" && action === "show" &&
616
+ (yes || rerun || tree || base !== undefined || output !== undefined ||
617
+ scope !== undefined || harness !== undefined || profiles.length > 0 ||
618
+ criteria.length > 0 || evidence.length > 0)) {
619
+ throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "review show accepts only --task and optional --json; it never runs a reviewer.");
620
+ }
621
+ if (tree && command !== "review") {
622
+ throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--tree may be used only with review.");
623
+ }
624
+ if (intent !== undefined && !(command === "task" && action === "create")) {
625
+ throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "--intent may be used only with task create.");
626
+ }
627
+ if (command === "review" && action !== "show" && !yes) {
589
628
  throw new CliArgumentError("CLI_OPTION_NOT_ALLOWED", "Review requires --yes to authorize both reviewer sessions.");
590
629
  }
591
630
  if (command === "review" && harness !== undefined) {
@@ -668,6 +707,7 @@ export function parseArgs(argv) {
668
707
  ...(parentTaskId === undefined ? {} : { parentTaskId }),
669
708
  ...(targetVersion === undefined ? {} : { targetVersion }),
670
709
  ...(title === undefined ? {} : { title }),
710
+ ...(intent === undefined ? {} : { intent }),
671
711
  ...(reviewTargets.length === 0 ? {} : { reviewTargets }),
672
712
  ...(completionGate === undefined ? {} : { completionGate }),
673
713
  ...(criteria.length === 0 ? {} : { criteria }),
@@ -683,6 +723,7 @@ export function parseArgs(argv) {
683
723
  ...(checkAuth ? { checkAuth } : {}),
684
724
  ...(checkAuthTargets.length === 0 ? {} : { checkAuthTargets }),
685
725
  rerun,
726
+ ...(tree ? { tree } : {}),
686
727
  dryRun,
687
728
  json,
688
729
  yes,
@@ -42,6 +42,8 @@ import { resolveReviewScope } from "../../../runtime/src/review/scope.js";
42
42
  import { resolveReviewRole } from "../../../runtime/src/review/roles.js";
43
43
  import { runTrustCommand } from "./commands/trust.js";
44
44
  import { runVerifyCommand } from "./commands/verify.js";
45
+ import { runAdvanceCommand } from "./commands/advance.js";
46
+ import { runReviewShowCommand } from "./commands/review-show.js";
45
47
  import { runAllowStopCommand } from "./commands/allow-stop.js";
46
48
  import { CompletionGateService } from "../../../runtime/src/hooks/completion-gate.js";
47
49
  import { formatUpdatePlan, runUpdateCommand } from "./commands/update.js";
@@ -402,6 +404,14 @@ else {
402
404
  const sessionId = args.sessionId ?? (actsAsSession
403
405
  ? await resolveCommandSessionId(root)
404
406
  : sessionIdFromEnvironment() ?? await readRecordedSessionId(root));
407
+ if (args.action === "advance") {
408
+ return await runAdvanceCommand({
409
+ cwd: root,
410
+ sessionId: sessionId ?? await resolveCommandSessionId(root),
411
+ parentTaskId: args.taskId,
412
+ deps: worktreeDependencies()
413
+ });
414
+ }
405
415
  const createConfig = args.action === "create"
406
416
  ? (await loadEffectiveConfig(root, args.scope === "user" ? "user" : "project")).config
407
417
  : undefined;
@@ -519,6 +529,9 @@ else {
519
529
  };
520
530
  };
521
531
  if (args.command === "review") {
532
+ if (args.action === "show") {
533
+ return await runReviewShowCommand({ cwd: root, taskId: args.taskId, deps: worktreeDependencies() });
534
+ }
522
535
  const controller = new AbortController();
523
536
  let interruptedBy;
524
537
  const interrupt = (signal) => {
@@ -28,7 +28,7 @@ Commands:
28
28
  task <create|status|attach|complete|archive|export>
29
29
  Manage independent task acceptance state
30
30
  verify Run configured verification
31
- review Run an independent review
31
+ review Run an independent review or show recorded reports
32
32
  batch Verify and review a parent task's subtasks together
33
33
  allow-stop Grant one fingerprint-bound completion-gate Stop permit (requires --session)
34
34
  agy-run Run headless agy with a process-exit completion recheck
@@ -141,19 +141,21 @@ Options:
141
141
  --json
142
142
  --yes
143
143
  `,
144
- task: `Usage: agent-ops task <create|status|attach|complete|archive|export> [options]
144
+ task: `Usage: agent-ops task <create|status|attach|complete|archive|export|advance> [options]
145
145
 
146
146
  Manage independent task acceptance state. Task commands accept none of
147
- --harness, --profile, --dry-run or --yes.
147
+ --harness, --profile or --dry-run. Only advance accepts --yes.
148
148
 
149
149
  Options:
150
150
  --title <text> create
151
+ --intent <text> create: intended behavior and constraints before editing
151
152
  --criterion <json> create, repeatable, two to five total
152
153
  --parent <task-id> create: record a subtask; status: list subtasks
153
- --task <id> status, attach, complete, archive, export
154
- --session <id> attach, status
154
+ --task <id> status, attach, complete, archive, export, advance
155
+ --session <id> create, attach, status, advance
155
156
  --evidence <criterion-id=reference> complete, repeatable
156
157
  --base <git-ref> complete: a clean committed range
158
+ --yes advance: authorize the two review sessions
157
159
  --json
158
160
 
159
161
  Each --criterion is one JSON object with exactly these keys:
@@ -174,12 +176,15 @@ Options:
174
176
  --json
175
177
  `,
176
178
  review: `Usage: agent-ops review --task <id> --yes [options]
179
+ agent-ops review show --task <id> [--json]
177
180
 
178
181
  Run the complete task-bound review: one necessary reviewer followed by one
179
- fresh adversarial reviewer from the configured pair.
182
+ fresh adversarial reviewer from the configured pair. Show reads recorded
183
+ reports without running a reviewer, including from the main checkout.
180
184
 
181
185
  Options:
182
186
  --task <id> Required task whose original criteria are reviewed
187
+ --tree Cover this task and every non-archived descendant in one two-round review
183
188
  --base <git-ref> Review a clean committed range
184
189
  --output <file> Write the JSON envelope to <file> (mode 0600) and print
185
190
  one summary line, so no shell redirect is needed
@@ -0,0 +1,153 @@
1
+ import { spawn } from "node:child_process";
2
+ import { resolve } from "node:path";
3
+ import { AgentOpsError } from "../../../../runtime/src/fs/paths.js";
4
+ import { finishWorktree } from "../../../../runtime/src/parallel/finish.js";
5
+ import { integrateSessionChildren } from "../../../../runtime/src/parallel/integrate.js";
6
+ import { readWorktreeRecord, resolveCheckouts, sessionWorktreeName, worktreePath } from "../../../../runtime/src/parallel/service.js";
7
+ import { resolveReviewScope } from "../../../../runtime/src/review/scope.js";
8
+ import { renderReviewDetails, renderReviewHighlights } from "../../../../runtime/src/review/render.js";
9
+ import { calculateSourceFingerprint } from "../../../../runtime/src/verify/source-fingerprint.js";
10
+ import { okEnvelope } from "../output.js";
11
+ import { readFinishedReview } from "./review-show.js";
12
+ /** The child process runs the existing CLI in the candidate checkout. */
13
+ export async function runAdvanceStep(cwd, args) {
14
+ const bin = process.argv[1];
15
+ if (bin === undefined)
16
+ throw new AgentOpsError("ADVANCE_CLI_MISSING", "Cannot locate the agent-ops CLI entry point.");
17
+ return await new Promise((resolveStep, reject) => {
18
+ const child = spawn(process.execPath, [resolve(bin), ...args, "--json"], {
19
+ cwd, env: process.env, stdio: ["ignore", "pipe", "inherit"]
20
+ });
21
+ const chunks = [];
22
+ let bytes = 0;
23
+ child.stdout.on("data", (chunk) => {
24
+ bytes += chunk.length;
25
+ if (bytes > 4 * 1024 * 1024)
26
+ child.kill();
27
+ else
28
+ chunks.push(chunk);
29
+ });
30
+ child.once("error", reject);
31
+ child.once("close", () => {
32
+ try {
33
+ const value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
34
+ if (value.status !== "ok" && value.status !== "error")
35
+ throw new Error("Invalid CLI envelope.");
36
+ resolveStep(value);
37
+ }
38
+ catch {
39
+ reject(new AgentOpsError("ADVANCE_STEP_UNREADABLE", `Could not read ${args[0]} result from the candidate checkout.`));
40
+ }
41
+ });
42
+ });
43
+ }
44
+ function runner(deps, cwd) {
45
+ return { run: async (args) => {
46
+ const result = await deps.git(cwd, args);
47
+ return { exitCode: result.exitCode, stdout: Buffer.from(result.stdout, "utf8") };
48
+ } };
49
+ }
50
+ async function git(deps, cwd, args) {
51
+ const result = await deps.git(cwd, args);
52
+ if (result.exitCode !== 0)
53
+ throw new AgentOpsError("ADVANCE_GIT_FAILED", `${args.join(" ")}: ${result.stderr.trim()}`);
54
+ return result.stdout.trim();
55
+ }
56
+ function requirePass(step, label) {
57
+ if (step.status === "ok" && (step.data?.result?.status === "PASS" || step.data?.report?.status === "PASS"))
58
+ return;
59
+ const result = step.data?.result;
60
+ const details = result?.report !== undefined || result?.attempts !== undefined
61
+ ? `\n${renderReviewDetails([{ taskId: result.taskId ?? label, title: label, artifact: result }], "active")}`
62
+ : step.data?.text ?? "";
63
+ throw new AgentOpsError(step.code, `${label}: ${step.errors?.[0]?.message ?? result?.reason ?? "proof did not pass"}${details ? `\n${details}` : ""}`);
64
+ }
65
+ export async function reviewFinalTree(step, cwd, parentTaskId, taskIds, base) {
66
+ const reviewed = await step(cwd, ["review", "--task", parentTaskId, "--tree", "--base", base, "--yes"]);
67
+ if (reviewed.status === "error" && reviewed.data?.result?.status === "NOT_RUN" &&
68
+ reviewed.data.result.reason === "scope-too-large") {
69
+ for (const taskId of taskIds) {
70
+ requirePass(await step(cwd, ["review", "--task", taskId, "--base", base, "--yes"]), `Review ${taskId}`);
71
+ }
72
+ return "per-task-fallback";
73
+ }
74
+ requirePass(reviewed, "Tree review");
75
+ return "tree";
76
+ }
77
+ export async function runAdvanceCommand(options) {
78
+ const { deps, cwd } = options;
79
+ if (options.sessionId === undefined || options.parentTaskId === undefined) {
80
+ throw new AgentOpsError("ADVANCE_TARGET_REQUIRED", "task advance requires --task and a current or explicit --session.");
81
+ }
82
+ const { mainRoot, currentRoot, commonDir } = await resolveCheckouts(deps, cwd);
83
+ if (mainRoot !== currentRoot)
84
+ throw new AgentOpsError("WORKTREE_NESTED", `Run task advance from ${mainRoot}.`);
85
+ const name = sessionWorktreeName(options.sessionId);
86
+ const record = await readWorktreeRecord(worktreePath(mainRoot, name));
87
+ if (record === null || record.sessionId !== options.sessionId || record.agentId !== undefined) {
88
+ throw new AgentOpsError("ADVANCE_WORKTREE_MISSING", `No coordinator worktree for session ${options.sessionId}.`);
89
+ }
90
+ const parent = await deps.tasks(record.path).status({ taskId: options.parentTaskId });
91
+ if (parent.status !== "active" || parent.task.parentTaskId !== undefined) {
92
+ throw new AgentOpsError("ADVANCE_PARENT_INVALID", "Advance requires an active root task in the coordinator worktree.");
93
+ }
94
+ const children = await integrateSessionChildren(deps, record, parent.task.id);
95
+ const step = options.step ?? runAdvanceStep;
96
+ for (let attempt = 0; attempt < 2; attempt += 1) {
97
+ const target = await git(deps, mainRoot, ["rev-parse", `${record.targetBranch}^{commit}`]);
98
+ const branchHead = await git(deps, record.path, ["rev-parse", "HEAD"]);
99
+ if ((await deps.git(record.path, ["merge-base", "--is-ancestor", target, "HEAD"])).exitCode !== 0) {
100
+ const rebase = await deps.git(record.path, ["rebase", "--rebase-merges", "--no-autostash", target]);
101
+ if (rebase.exitCode !== 0) {
102
+ const files = (await deps.git(record.path, ["diff", "--name-only", "--diff-filter=U"])).stdout.trim();
103
+ await deps.git(record.path, ["rebase", "--abort"]);
104
+ throw new AgentOpsError("WORKTREE_REBASE_CONFLICT", `Target moved; rebase conflicted in ${files || "unknown files"}. Candidate remains at ${branchHead}.`);
105
+ }
106
+ }
107
+ const head = await git(deps, record.path, ["rev-parse", "HEAD"]);
108
+ const all = (await deps.tasks(record.path).list()).filter(({ status }) => status !== "archived");
109
+ const selected = new Set([parent.task.id]);
110
+ for (let changed = true; changed;) {
111
+ changed = false;
112
+ for (const item of all) {
113
+ if (item.task.parentTaskId !== undefined && selected.has(item.task.parentTaskId) && !selected.has(item.task.id)) {
114
+ selected.add(item.task.id);
115
+ changed = true;
116
+ }
117
+ }
118
+ }
119
+ if (selected.size !== all.length || all.some(({ task, status }) => !selected.has(task.id) || status !== "active")) {
120
+ throw new AgentOpsError("ADVANCE_TASK_TREE_INVALID", "The candidate has unrelated or already completed tasks; final proof must cover one active tree.");
121
+ }
122
+ const ordered = all.filter(({ task }) => selected.has(task.id));
123
+ for (const task of ordered) {
124
+ requirePass(await step(record.path, ["verify", "--task", task.task.id, "--base", target]), `Verify ${task.task.id}`);
125
+ }
126
+ const reviewMode = await reviewFinalTree(step, record.path, parent.task.id, ordered.map(({ task }) => task.id), target);
127
+ const scope = await resolveReviewScope({ root: record.path, runner: runner(deps, record.path), base: target });
128
+ const sourceFingerprint = await calculateSourceFingerprint(record.path, scope, runner(deps, record.path));
129
+ try {
130
+ const finished = await finishWorktree(deps, {
131
+ cwd: mainRoot, name,
132
+ finalProof: { target, head, sourceFingerprint, children }
133
+ });
134
+ if (finished.receipt === undefined)
135
+ throw new AgentOpsError("WORKTREE_RECEIPT_MISSING", "Finish returned without a final review receipt.");
136
+ const review = await readFinishedReview(deps, mainRoot, commonDir, finished.receipt);
137
+ const highlights = renderReviewHighlights(review.entries);
138
+ return okEnvelope("TASK_ADVANCED", {
139
+ parentTaskId: parent.task.id,
140
+ reviewMode,
141
+ sourceFingerprint,
142
+ ...finished,
143
+ text: `Final candidate ${head} passed all task verifiers and ${reviewMode} review, then finished into ${record.targetBranch}. Receipt: ${finished.receipt}.\n${highlights}Full reports: agent-ops review show --task ${parent.task.id}`
144
+ });
145
+ }
146
+ catch (failure) {
147
+ if (failure instanceof AgentOpsError && failure.code === "WORKTREE_TARGET_MOVED" && attempt === 0)
148
+ continue;
149
+ throw failure;
150
+ }
151
+ }
152
+ throw new AgentOpsError("WORKTREE_TARGET_MOVED", "The target moved again; preserve the candidate and rerun after coordination.");
153
+ }
@@ -0,0 +1,189 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { sha256 } from "../../../../runtime/src/fs/hash.js";
4
+ import { AgentOpsError } from "../../../../runtime/src/fs/paths.js";
5
+ import { listWorktrees } from "../../../../runtime/src/parallel/manage.js";
6
+ import { NOTES_REF, resolveCheckouts } from "../../../../runtime/src/parallel/service.js";
7
+ import { REVIEW_ATTESTATION_DIRECTORY, REVIEW_REPORT_ARTIFACT_SUFFIX } from "../../../../runtime/src/review/attestation.js";
8
+ import { renderReviewDetails } from "../../../../runtime/src/review/render.js";
9
+ import { validateReviewReport } from "../../../../runtime/src/review/report.js";
10
+ import { resolveReviewScope } from "../../../../runtime/src/review/scope.js";
11
+ import { readPrivateFile } from "../../../../runtime/src/security/permissions.js";
12
+ import { calculateSourceFingerprint } from "../../../../runtime/src/verify/source-fingerprint.js";
13
+ import { okEnvelope } from "../output.js";
14
+ function invalid(message) {
15
+ throw new AgentOpsError("REVIEW_RECORD_INVALID", message);
16
+ }
17
+ function checkedArtifact(value, fingerprint, taskId) {
18
+ if (typeof value !== "object" || value === null)
19
+ invalid("Review artifact is unreadable.");
20
+ const artifact = value;
21
+ if (artifact.schemaVersion !== 1 || artifact.sourceFingerprint !== fingerprint ||
22
+ artifact.taskId !== taskId || !["PASS", "FAIL", "NOT_RUN"].includes(artifact.status) ||
23
+ !Array.isArray(artifact.attempts) ||
24
+ (artifact.report !== undefined && !validReport(artifact.report, artifact)) ||
25
+ (artifact.adversarial !== undefined && !validReport(artifact.adversarial.report, artifact))) {
26
+ invalid(`Review artifact for ${taskId} does not match the candidate.`);
27
+ }
28
+ return artifact;
29
+ }
30
+ function validReport(report, artifact) {
31
+ const ids = artifact.tree?.criterionIds ?? report?.results?.map(({ criterionId }) => criterionId);
32
+ return ids !== undefined && validateReviewReport(report, ids).ok;
33
+ }
34
+ function entriesFromReceipt(receipt) {
35
+ const records = receipt.reviewMode === "tree"
36
+ ? [receipt.tasks.find(({ task }) => task.parentTaskId === undefined) ?? receipt.tasks[0]]
37
+ : receipt.tasks;
38
+ return records.map(({ task }) => {
39
+ const sealed = receipt.reviews[task.id]?.report;
40
+ if (sealed === undefined || sha256(JSON.stringify(sealed.value)) !== sealed.digest) {
41
+ invalid(`Review evidence for ${task.id} is missing or changed.`);
42
+ }
43
+ return { taskId: task.id, title: task.title,
44
+ artifact: checkedArtifact(sealed.value, receipt.sourceFingerprint, task.id) };
45
+ });
46
+ }
47
+ /** The note binds the private receipt to the merged commit and its exact bytes. */
48
+ export async function readFinishedReview(deps, mainRoot, commonDir, path) {
49
+ if (dirname(path) !== join(commonDir, "agent-ops", "receipts"))
50
+ invalid("Final review receipt path is outside the receipt directory.");
51
+ const source = await readPrivateFile(path, commonDir);
52
+ if (source === null)
53
+ invalid("Final review receipt is missing.");
54
+ let receipt;
55
+ try {
56
+ receipt = JSON.parse(source);
57
+ }
58
+ catch {
59
+ return invalid("Final review receipt is malformed.");
60
+ }
61
+ if (receipt.schemaVersion !== 1 || !/^[a-f0-9]{40,64}$/u.test(receipt.candidateHead) ||
62
+ !Array.isArray(receipt.tasks) || receipt.tasks.length === 0 ||
63
+ !["tree", "per-task-fallback"].includes(receipt.reviewMode) ||
64
+ typeof receipt.reviews !== "object" || receipt.reviews === null) {
65
+ invalid("Final review receipt is invalid.");
66
+ }
67
+ const note = await deps.git(mainRoot, ["notes", `--ref=${NOTES_REF}`, "show", receipt.candidateHead]);
68
+ const binding = `\nreceipt: ${path}\nreceipt-sha256: ${sha256(source)}`;
69
+ if (note.exitCode !== 0 || !note.stdout.trimEnd().endsWith(binding)) {
70
+ invalid("Final review receipt does not match its Git note.");
71
+ }
72
+ return { mode: receipt.reviewMode, candidateHead: receipt.candidateHead,
73
+ sourceFingerprint: receipt.sourceFingerprint, entries: entriesFromReceipt(receipt), receipt: path };
74
+ }
75
+ async function finishedPath(commonDir, taskId) {
76
+ const directory = join(commonDir, "agent-ops", "receipts");
77
+ let names;
78
+ try {
79
+ names = await readdir(directory);
80
+ }
81
+ catch (error) {
82
+ if (error.code === "ENOENT")
83
+ return null;
84
+ throw error;
85
+ }
86
+ let latest = null;
87
+ for (const name of names.filter((item) => /^[a-f0-9]{64}-[a-f0-9]{40,64}\.json$/u.test(item))) {
88
+ const path = join(directory, name);
89
+ const source = await readPrivateFile(path, commonDir);
90
+ if (source === null)
91
+ continue;
92
+ try {
93
+ const receipt = JSON.parse(source);
94
+ if (!Array.isArray(receipt.tasks) || !receipt.tasks.some(({ task }) => task.id === taskId))
95
+ continue;
96
+ if (latest === null || receipt.createdAt > latest.createdAt)
97
+ latest = { path, createdAt: receipt.createdAt };
98
+ }
99
+ catch { /* An unrelated malformed receipt cannot name this task. */ }
100
+ }
101
+ return latest?.path ?? null;
102
+ }
103
+ function runner(deps, cwd) {
104
+ return { run: async (args) => {
105
+ const result = await deps.git(cwd, args);
106
+ return { exitCode: result.exitCode, stdout: Buffer.from(result.stdout, "utf8") };
107
+ } };
108
+ }
109
+ async function activeReview(deps, mainRoot, taskId) {
110
+ const matching = [];
111
+ for (const { record } of await listWorktrees(deps, mainRoot)) {
112
+ const records = await deps.tasks(record.path).list();
113
+ if (records.some(({ task }) => task.id === taskId))
114
+ matching.push({ record, records });
115
+ }
116
+ if (matching.length > 1)
117
+ invalid(`Task ${taskId} exists in more than one active worktree.`);
118
+ const current = matching[0];
119
+ if (current === undefined)
120
+ return null;
121
+ const { record, records } = current;
122
+ const headResult = await deps.git(record.path, ["rev-parse", "HEAD"]);
123
+ if (headResult.exitCode !== 0)
124
+ invalid("Cannot read the active candidate HEAD.");
125
+ const candidateHead = headResult.stdout.trim();
126
+ const selected = new Set([taskId]);
127
+ for (let changed = true; changed;) {
128
+ changed = false;
129
+ for (const { task } of records) {
130
+ if (task.parentTaskId !== undefined && selected.has(task.parentTaskId) && !selected.has(task.id)) {
131
+ selected.add(task.id);
132
+ changed = true;
133
+ }
134
+ }
135
+ }
136
+ const tree = [records.find(({ task }) => task.id === taskId),
137
+ ...records.filter(({ task }) => selected.has(task.id) && task.id !== taskId)];
138
+ const target = await deps.git(mainRoot, ["rev-parse", `${record.targetBranch}^{commit}`]);
139
+ if (target.exitCode !== 0)
140
+ invalid("Cannot read the target branch for the active candidate.");
141
+ let fingerprint;
142
+ try {
143
+ const scope = await resolveReviewScope({ root: record.path, runner: runner(deps, record.path), base: target.stdout.trim() });
144
+ fingerprint = await calculateSourceFingerprint(record.path, scope, runner(deps, record.path));
145
+ }
146
+ catch (error) {
147
+ if (error instanceof AgentOpsError && error.code === "REVIEW_DIRTY_WORKTREE") {
148
+ try {
149
+ const scope = await resolveReviewScope({ root: record.path, runner: runner(deps, record.path) });
150
+ fingerprint = await calculateSourceFingerprint(record.path, scope, runner(deps, record.path));
151
+ }
152
+ catch { /* No report for this source state. */ }
153
+ }
154
+ }
155
+ const entries = [];
156
+ for (const { task } of tree) {
157
+ let artifact = null;
158
+ if (fingerprint !== undefined) {
159
+ const source = await readPrivateFile(join(record.path, REVIEW_ATTESTATION_DIRECTORY, `${fingerprint}.${task.id}${REVIEW_REPORT_ARTIFACT_SUFFIX}`), record.path);
160
+ if (source !== null) {
161
+ try {
162
+ artifact = checkedArtifact(JSON.parse(source), fingerprint, task.id);
163
+ }
164
+ catch (error) {
165
+ if (error instanceof AgentOpsError)
166
+ throw error;
167
+ invalid(`Review artifact for ${task.id} is malformed.`);
168
+ }
169
+ }
170
+ }
171
+ entries.push({ taskId: task.id, title: task.title, artifact });
172
+ }
173
+ // A failed whole-tree review writes one parent artifact; its criterion IDs
174
+ // already cover the whole tree, so do not print empty child aliases.
175
+ const first = entries[0];
176
+ const wholeTree = first?.artifact?.report?.results.some(({ criterionId }) => criterionId.includes(":"));
177
+ return { mode: "active", candidateHead, ...(fingerprint === undefined ? {} : { sourceFingerprint: fingerprint }),
178
+ entries: wholeTree ? [first] : entries };
179
+ }
180
+ export async function runReviewShowCommand(options) {
181
+ const { mainRoot, commonDir } = await resolveCheckouts(options.deps, options.cwd);
182
+ const active = await activeReview(options.deps, mainRoot, options.taskId);
183
+ const path = active === null ? await finishedPath(commonDir, options.taskId) : null;
184
+ const view = active ?? (path === null ? null : await readFinishedReview(options.deps, mainRoot, commonDir, path));
185
+ if (view === null)
186
+ throw new AgentOpsError("REVIEW_TASK_NOT_FOUND", `No active or finished review task ${options.taskId}.`);
187
+ return okEnvelope("REVIEW_SHOWN", { ...view,
188
+ text: `Candidate: ${view.candidateHead}\n${renderReviewDetails(view.entries, view.mode)}` });
189
+ }