@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 +16 -0
- package/dist/packages/cli/src/args.js +49 -8
- package/dist/packages/cli/src/bin.js +13 -0
- package/dist/packages/cli/src/cli.js +11 -6
- package/dist/packages/cli/src/commands/advance.js +153 -0
- package/dist/packages/cli/src/commands/review-show.js +189 -0
- package/dist/packages/cli/src/commands/review.js +105 -29
- package/dist/packages/cli/src/commands/task.js +1 -0
- package/dist/runtime/src/install/harness.js +1 -1
- package/dist/runtime/src/install/preauth.js +10 -1
- package/dist/runtime/src/parallel/finish.js +138 -23
- package/dist/runtime/src/parallel/integrate.js +138 -0
- package/dist/runtime/src/parallel/receipt.js +87 -0
- package/dist/runtime/src/parallel/service.js +4 -2
- package/dist/runtime/src/review/attestation.js +27 -3
- package/dist/runtime/src/review/render.js +71 -0
- package/dist/runtime/src/schema/validate.js +6 -1
- package/dist/runtime/src/task/completion.js +13 -5
- package/dist/runtime/src/task/render.js +3 -0
- package/dist/runtime/src/task/service.js +48 -0
- package/docs/en/guides/configuration.md +10 -0
- package/docs/en/spec/acceptance-and-evidence.md +10 -0
- package/docs/en/spec/delegation.md +4 -4
- package/docs/en/spec/review.md +24 -2
- package/docs/zh-TW/guides/configuration.md +9 -0
- package/docs/zh-TW/spec/acceptance-and-evidence.md +10 -0
- package/docs/zh-TW/spec/delegation.md +4 -4
- package/docs/zh-TW/spec/review.md +24 -2
- package/package.json +1 -1
- package/schemas/task.schema.json +3 -0
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" &&
|
|
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
|
|
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
|
+
}
|