@kylecheng3146/agent-ops 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -0
- package/dist/packages/cli/src/args.js +214 -10
- package/dist/packages/cli/src/bin.js +77 -17
- package/dist/packages/cli/src/cli.js +42 -10
- package/dist/packages/cli/src/commands/advance.js +173 -0
- package/dist/packages/cli/src/commands/review-show.js +189 -0
- package/dist/packages/cli/src/commands/review.js +201 -34
- package/dist/packages/cli/src/commands/run.js +111 -0
- package/dist/packages/cli/src/commands/task.js +62 -17
- package/dist/packages/cli/src/commands/verify.js +1 -0
- package/dist/packages/cli/src/context.js +63 -1
- package/dist/packages/cli/src/hook-process.js +31 -1
- package/dist/packages/cli/src/owned-run-step.js +65 -0
- package/dist/packages/cli/src/parallel-deps.js +18 -5
- package/dist/packages/cli/src/run-deps.js +314 -0
- package/dist/packages/cli/src/run-entry.js +943 -0
- package/dist/packages/cli/src/run-setup-entry.js +32 -0
- package/dist/packages/cli/src/run-step-entry.js +58 -0
- package/dist/runtime/src/config/explain.js +6 -0
- package/dist/runtime/src/config/hash.js +1 -1
- package/dist/runtime/src/config/merge.js +9 -1
- package/dist/runtime/src/contracts.js +2 -2
- package/dist/runtime/src/hooks/completion-gate.js +8 -2
- package/dist/runtime/src/install/doctor.js +2 -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 +217 -39
- package/dist/runtime/src/parallel/guard.js +5 -1
- package/dist/runtime/src/parallel/integrate.js +416 -0
- package/dist/runtime/src/parallel/manage.js +4 -1
- package/dist/runtime/src/parallel/receipt.js +153 -0
- package/dist/runtime/src/parallel/service.js +45 -3
- package/dist/runtime/src/review/attestation.js +60 -11
- package/dist/runtime/src/review/execute.js +24 -1
- package/dist/runtime/src/review/packet.js +35 -1
- package/dist/runtime/src/review/render.js +71 -0
- package/dist/runtime/src/review/runner.js +12 -1
- package/dist/runtime/src/review/scope.js +18 -1
- package/dist/runtime/src/review/slots.js +35 -10
- package/dist/runtime/src/run/authorization.js +60 -0
- package/dist/runtime/src/run/budget.js +30 -0
- package/dist/runtime/src/run/controls.js +139 -0
- package/dist/runtime/src/run/convergence.js +177 -0
- package/dist/runtime/src/run/hosts/claude.js +180 -0
- package/dist/runtime/src/run/hosts/codex.js +279 -0
- package/dist/runtime/src/run/hosts/index.js +4 -0
- package/dist/runtime/src/run/hosts/protocol.js +254 -0
- package/dist/runtime/src/run/hosts/types.js +1 -0
- package/dist/runtime/src/run/hosts/util.js +136 -0
- package/dist/runtime/src/run/integration.js +204 -0
- package/dist/runtime/src/run/macOS.js +189 -0
- package/dist/runtime/src/run/ownership.js +22 -0
- package/dist/runtime/src/run/planning.js +25 -0
- package/dist/runtime/src/run/policy-transition.js +1076 -0
- package/dist/runtime/src/run/policy.js +176 -0
- package/dist/runtime/src/run/ratchet.js +71 -0
- package/dist/runtime/src/run/scheduler.js +260 -0
- package/dist/runtime/src/run/service.js +425 -0
- package/dist/runtime/src/run/supervisor.js +635 -0
- package/dist/runtime/src/run/transport.js +159 -0
- package/dist/runtime/src/run/types.js +1 -0
- package/dist/runtime/src/run/usage.js +81 -0
- package/dist/runtime/src/run/verification.js +64 -0
- package/dist/runtime/src/run/worker-gate.js +48 -0
- package/dist/runtime/src/schema/acceptance.js +91 -0
- package/dist/runtime/src/schema/validate.js +72 -7
- package/dist/runtime/src/task/completion.js +26 -6
- package/dist/runtime/src/task/contract.js +17 -0
- package/dist/runtime/src/task/pin-finding.js +81 -0
- package/dist/runtime/src/task/render.js +3 -0
- package/dist/runtime/src/task/service.js +361 -10
- package/dist/runtime/src/task/store.js +57 -7
- package/dist/runtime/src/verify/acceptance-coverage.js +85 -0
- package/dist/runtime/src/verify/acceptance-protocol.js +252 -0
- package/dist/runtime/src/verify/acceptance-replay.js +239 -0
- package/dist/runtime/src/verify/adapters/index.js +5 -0
- package/dist/runtime/src/verify/adapters/jest.js +142 -0
- package/dist/runtime/src/verify/adapters/node-reporter.js +20 -0
- package/dist/runtime/src/verify/adapters/node.js +254 -0
- package/dist/runtime/src/verify/adapters/pytest.js +188 -0
- package/dist/runtime/src/verify/adapters/rust.js +84 -0
- package/dist/runtime/src/verify/adapters/shared.js +82 -0
- package/dist/runtime/src/verify/adapters/vitest.js +218 -0
- package/dist/runtime/src/verify/evidence.js +5 -2
- package/dist/runtime/src/verify/service.js +24 -8
- package/dist/runtime/src/verify/source-fingerprint.js +1 -1
- package/dist/runtime/src/verify/spawn.js +13 -2
- package/docs/en/guides/configuration.md +10 -0
- package/docs/en/spec/acceptance-and-evidence.md +58 -0
- package/docs/en/spec/delegation.md +4 -4
- package/docs/en/spec/loop-engineering.md +49 -0
- 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 +31 -1
- package/docs/zh-TW/spec/delegation.md +4 -4
- package/docs/zh-TW/spec/loop-engineering.md +22 -0
- package/docs/zh-TW/spec/review.md +24 -2
- package/package.json +1 -1
- package/schemas/config.schema.json +221 -19
- package/schemas/evidence.schema.json +129 -6
- package/schemas/task.schema.json +268 -6
- package/templates/acceptance/agent_ops_acceptance.rs +36 -0
- package/templates/acceptance/agent_ops_pytest.py +46 -0
|
@@ -25,10 +25,11 @@ Commands:
|
|
|
25
25
|
doctor Diagnose an installation
|
|
26
26
|
update Update managed artifacts
|
|
27
27
|
uninstall Remove managed artifacts
|
|
28
|
-
task <create|status|attach|complete|archive|export>
|
|
28
|
+
task <create|status|attach|revise|replan|pin-finding|advance|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
|
+
run Start or control a supervised Claude/Codex native goal
|
|
32
33
|
batch Verify and review a parent task's subtasks together
|
|
33
34
|
allow-stop Grant one fingerprint-bound completion-gate Stop permit (requires --session)
|
|
34
35
|
agy-run Run headless agy with a process-exit completion recheck
|
|
@@ -71,6 +72,14 @@ Options:
|
|
|
71
72
|
* rejected.
|
|
72
73
|
*/
|
|
73
74
|
export const COMMAND_HELP_TEXT = {
|
|
75
|
+
run: `Usage: agent-ops run [start] "<goal>" --host <claude|codex> [--jobs <1|2>] [--time-budget <60m>] [--wait] [--json]
|
|
76
|
+
agent-ops run [start] --goal-file <path> --host <claude|codex>
|
|
77
|
+
agent-ops run <status|logs|resume|stop> <run-id> [--json]
|
|
78
|
+
agent-ops run respond <run-id> --question-id <id> <--answer <text>|--answer-file <path>> [--json]
|
|
79
|
+
|
|
80
|
+
Native goal state is observational. Completion requires current verification,
|
|
81
|
+
two fresh reviews, task completion and a target-bound integration receipt.
|
|
82
|
+
`,
|
|
74
83
|
init: `Usage: agent-ops init [options]
|
|
75
84
|
|
|
76
85
|
Plan or install agent-ops into this repository.
|
|
@@ -141,27 +150,47 @@ Options:
|
|
|
141
150
|
--json
|
|
142
151
|
--yes
|
|
143
152
|
`,
|
|
144
|
-
task: `Usage: agent-ops task <create|status|attach|complete|archive|export> [options]
|
|
153
|
+
task: `Usage: agent-ops task <create|status|attach|revise|replan|pin-finding|advance|complete|archive|export> [options]
|
|
145
154
|
|
|
146
155
|
Manage independent task acceptance state. Task commands accept none of
|
|
147
|
-
--harness, --profile
|
|
156
|
+
--harness, --profile or --dry-run. Only advance accepts --yes.
|
|
148
157
|
|
|
149
158
|
Options:
|
|
150
159
|
--title <text> create
|
|
160
|
+
--intent <text> create: intended behavior and constraints before editing
|
|
151
161
|
--criterion <json> create, repeatable, two to five total
|
|
162
|
+
--criterion-file <path> create: one criterion object, repeatable; pin-finding: exactly one
|
|
163
|
+
--criteria-file <path> revise: replacement criterion array
|
|
164
|
+
--plan-file <path> replan: split-task array with requirement mappings
|
|
165
|
+
--expected-contract <hash> revise, pin-finding: current task contract from status
|
|
166
|
+
--expected-tree-contract <hash> replan: current tree contract from status
|
|
167
|
+
--reason <text> required for revise/replan; optional for pin-finding
|
|
168
|
+
--baseline <git-ref> create/revise: baseline for new mechanical requirements
|
|
152
169
|
--parent <task-id> create: record a subtask; status: list subtasks
|
|
153
|
-
--task <id> status, attach, complete, archive, export
|
|
154
|
-
--session <id> attach, status
|
|
170
|
+
--task <id> status, attach, revise, replan, pin-finding, complete, archive, export, advance
|
|
171
|
+
--session <id> create, attach, status, advance
|
|
155
172
|
--evidence <criterion-id=reference> complete, repeatable
|
|
156
173
|
--base <git-ref> complete: a clean committed range
|
|
174
|
+
--yes advance: authorize the two review sessions
|
|
157
175
|
--json
|
|
158
176
|
|
|
159
|
-
Each --criterion
|
|
177
|
+
Each --criterion or --criterion-file supplies one criterion object. Legacy example:
|
|
160
178
|
|
|
161
179
|
{"id":"kebab-id","description":"what must hold","verifierIds":["node-test"]}
|
|
162
180
|
|
|
163
|
-
|
|
164
|
-
command id configured in .agent-ops/config.json.
|
|
181
|
+
Each legacy criterion needs at least one verifierIds entry naming a verification
|
|
182
|
+
command id configured in .agent-ops/config.json. Typed criteria additionally
|
|
183
|
+
accept an acceptance object: mode (behavioral, invariant or review-only),
|
|
184
|
+
bindings and optional baselineCommit/reviewOnlyReason. Mechanical bindings name
|
|
185
|
+
configured acceptance runner IDs, checkIds, committed materials and optional
|
|
186
|
+
redCheckIds; tasks cannot supply commands. Creation freezes the baseline.
|
|
187
|
+
Review-only example (mandatory repository policy commands still run):
|
|
188
|
+
|
|
189
|
+
{"id":"judgment","description":"Meets the original goal","verifierIds":[],"acceptance":{"mode":"review-only","bindings":[],"reviewOnlyReason":"Requires independent judgment"}}
|
|
190
|
+
|
|
191
|
+
Pin a saved finding with task pin-finding --task <id> <report-digest:index>
|
|
192
|
+
--expected-contract <hash> --criterion-file <path>. Revise/replan preserve the
|
|
193
|
+
original goal and immutable existing baselines; obtain new verify and review.
|
|
165
194
|
`,
|
|
166
195
|
verify: `Usage: agent-ops verify [options]
|
|
167
196
|
|
|
@@ -174,12 +203,15 @@ Options:
|
|
|
174
203
|
--json
|
|
175
204
|
`,
|
|
176
205
|
review: `Usage: agent-ops review --task <id> --yes [options]
|
|
206
|
+
agent-ops review show --task <id> [--json]
|
|
177
207
|
|
|
178
208
|
Run the complete task-bound review: one necessary reviewer followed by one
|
|
179
|
-
fresh adversarial reviewer from the configured pair.
|
|
209
|
+
fresh adversarial reviewer from the configured pair. Show reads recorded
|
|
210
|
+
reports without running a reviewer, including from the main checkout.
|
|
180
211
|
|
|
181
212
|
Options:
|
|
182
213
|
--task <id> Required task whose original criteria are reviewed
|
|
214
|
+
--tree Cover this task and every non-archived descendant in one two-round review
|
|
183
215
|
--base <git-ref> Review a clean committed range
|
|
184
216
|
--output <file> Write the JSON envelope to <file> (mode 0600) and print
|
|
185
217
|
one summary line, so no shell redirect is needed
|
|
@@ -0,0 +1,173 @@
|
|
|
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
|
+
import { prepareRunIntegration, readRunIntegrationProof, abandonPreparedRunIntegration } from "../../../../runtime/src/run/integration.js";
|
|
13
|
+
/** The child process runs the existing CLI in the candidate checkout. */
|
|
14
|
+
export async function runAdvanceStep(cwd, args) {
|
|
15
|
+
const bin = process.argv[1];
|
|
16
|
+
if (bin === undefined)
|
|
17
|
+
throw new AgentOpsError("ADVANCE_CLI_MISSING", "Cannot locate the agent-ops CLI entry point.");
|
|
18
|
+
return await new Promise((resolveStep, reject) => {
|
|
19
|
+
const child = spawn(process.execPath, [resolve(bin), ...args, "--json"], {
|
|
20
|
+
cwd, env: process.env, stdio: ["ignore", "pipe", "inherit"]
|
|
21
|
+
});
|
|
22
|
+
const chunks = [];
|
|
23
|
+
let bytes = 0;
|
|
24
|
+
child.stdout.on("data", (chunk) => {
|
|
25
|
+
bytes += chunk.length;
|
|
26
|
+
if (bytes > 4 * 1024 * 1024)
|
|
27
|
+
child.kill();
|
|
28
|
+
else
|
|
29
|
+
chunks.push(chunk);
|
|
30
|
+
});
|
|
31
|
+
child.once("error", reject);
|
|
32
|
+
child.once("close", () => {
|
|
33
|
+
try {
|
|
34
|
+
const value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
35
|
+
if (value.status !== "ok" && value.status !== "error")
|
|
36
|
+
throw new Error("Invalid CLI envelope.");
|
|
37
|
+
resolveStep(value);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
reject(new AgentOpsError("ADVANCE_STEP_UNREADABLE", `Could not read ${args[0]} result from the candidate checkout.`));
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
function runner(deps, cwd) {
|
|
46
|
+
return { run: async (args) => {
|
|
47
|
+
const result = await deps.git(cwd, args);
|
|
48
|
+
return { exitCode: result.exitCode, stdout: Buffer.from(result.stdout, "utf8") };
|
|
49
|
+
} };
|
|
50
|
+
}
|
|
51
|
+
async function git(deps, cwd, args) {
|
|
52
|
+
const result = await deps.git(cwd, args);
|
|
53
|
+
if (result.exitCode !== 0)
|
|
54
|
+
throw new AgentOpsError("ADVANCE_GIT_FAILED", `${args.join(" ")}: ${result.stderr.trim()}`);
|
|
55
|
+
return result.stdout.trim();
|
|
56
|
+
}
|
|
57
|
+
function requirePass(step, label) {
|
|
58
|
+
if (step.status === "ok" && (step.data?.result?.status === "PASS" || step.data?.report?.status === "PASS"))
|
|
59
|
+
return;
|
|
60
|
+
const result = step.data?.result;
|
|
61
|
+
const details = result?.report !== undefined || result?.attempts !== undefined
|
|
62
|
+
? `\n${renderReviewDetails([{ taskId: result.taskId ?? label, title: label, artifact: result }], "active")}`
|
|
63
|
+
: step.data?.text ?? "";
|
|
64
|
+
throw new AgentOpsError(step.code, `${label}: ${step.errors?.[0]?.message ?? result?.reason ?? "proof did not pass"}${details ? `\n${details}` : ""}`);
|
|
65
|
+
}
|
|
66
|
+
export async function reviewFinalTree(step, cwd, parentTaskId, taskIds, base) {
|
|
67
|
+
const reviewed = await step(cwd, ["review", "--task", parentTaskId, "--tree", "--base", base, "--yes"]);
|
|
68
|
+
if (reviewed.status === "error" && reviewed.data?.result?.status === "NOT_RUN" &&
|
|
69
|
+
reviewed.data.result.reason === "scope-too-large") {
|
|
70
|
+
for (const taskId of taskIds) {
|
|
71
|
+
requirePass(await step(cwd, ["review", "--task", taskId, "--base", base, "--yes"]), `Review ${taskId}`);
|
|
72
|
+
}
|
|
73
|
+
return "per-task-fallback";
|
|
74
|
+
}
|
|
75
|
+
requirePass(reviewed, "Tree review");
|
|
76
|
+
return "tree";
|
|
77
|
+
}
|
|
78
|
+
export async function runAdvanceCommand(options) {
|
|
79
|
+
const { deps, cwd } = options;
|
|
80
|
+
if (options.sessionId === undefined || options.parentTaskId === undefined) {
|
|
81
|
+
throw new AgentOpsError("ADVANCE_TARGET_REQUIRED", "task advance requires --task and a current or explicit --session.");
|
|
82
|
+
}
|
|
83
|
+
const { mainRoot, currentRoot, commonDir } = await resolveCheckouts(deps, cwd);
|
|
84
|
+
if (mainRoot !== currentRoot)
|
|
85
|
+
throw new AgentOpsError("WORKTREE_NESTED", `Run task advance from ${mainRoot}.`);
|
|
86
|
+
const name = sessionWorktreeName(options.sessionId);
|
|
87
|
+
const record = await readWorktreeRecord(worktreePath(mainRoot, name));
|
|
88
|
+
if (record === null || record.sessionId !== options.sessionId || record.agentId !== undefined) {
|
|
89
|
+
throw new AgentOpsError("ADVANCE_WORKTREE_MISSING", `No coordinator worktree for session ${options.sessionId}.`);
|
|
90
|
+
}
|
|
91
|
+
const savedProof = await readRunIntegrationProof(commonDir, record);
|
|
92
|
+
if (savedProof !== null && await git(deps, mainRoot, ["rev-parse", `${record.targetBranch}^{commit}`]) === savedProof.head) {
|
|
93
|
+
const finished = await finishWorktree(deps, { cwd: mainRoot, name, finalProof: { ...savedProof, recovery: true } });
|
|
94
|
+
return okEnvelope("TASK_ADVANCED", { ...finished, text: "Recovered the sealed integration without moving the target again." });
|
|
95
|
+
}
|
|
96
|
+
if (savedProof !== null && await git(deps, mainRoot, ["rev-parse", `${record.targetBranch}^{commit}`]) !== savedProof.target)
|
|
97
|
+
await abandonPreparedRunIntegration(commonDir, record, deps.git);
|
|
98
|
+
const parent = await deps.tasks(record.path).status({ taskId: options.parentTaskId });
|
|
99
|
+
if (parent.status !== "active" || parent.task.parentTaskId !== undefined) {
|
|
100
|
+
throw new AgentOpsError("ADVANCE_PARENT_INVALID", "Advance requires an active root task in the coordinator worktree.");
|
|
101
|
+
}
|
|
102
|
+
const children = await integrateSessionChildren(deps, record, parent.task.id);
|
|
103
|
+
const step = options.step ?? runAdvanceStep;
|
|
104
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
105
|
+
const target = await git(deps, mainRoot, ["rev-parse", `${record.targetBranch}^{commit}`]);
|
|
106
|
+
const branchHead = await git(deps, record.path, ["rev-parse", "HEAD"]);
|
|
107
|
+
if ((await deps.git(record.path, ["merge-base", "--is-ancestor", target, "HEAD"])).exitCode !== 0) {
|
|
108
|
+
const rebase = await deps.git(record.path, ["rebase", "--rebase-merges", "--no-autostash", target]);
|
|
109
|
+
if (rebase.exitCode !== 0) {
|
|
110
|
+
const files = (await deps.git(record.path, ["diff", "--name-only", "--diff-filter=U"])).stdout.trim();
|
|
111
|
+
await deps.git(record.path, ["rebase", "--abort"]);
|
|
112
|
+
throw new AgentOpsError("WORKTREE_REBASE_CONFLICT", `Target moved; rebase conflicted in ${files || "unknown files"}. Candidate remains at ${branchHead}.`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const head = await git(deps, record.path, ["rev-parse", "HEAD"]);
|
|
116
|
+
const all = (await deps.tasks(record.path).list()).filter(({ status }) => status !== "archived");
|
|
117
|
+
const selected = new Set([parent.task.id]);
|
|
118
|
+
for (let changed = true; changed;) {
|
|
119
|
+
changed = false;
|
|
120
|
+
for (const item of all) {
|
|
121
|
+
if (item.task.parentTaskId !== undefined && selected.has(item.task.parentTaskId) && !selected.has(item.task.id)) {
|
|
122
|
+
selected.add(item.task.id);
|
|
123
|
+
changed = true;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (selected.size !== all.length || all.some(({ task, status }) => !selected.has(task.id) || status !== "active")) {
|
|
128
|
+
throw new AgentOpsError("ADVANCE_TASK_TREE_INVALID", "The candidate has unrelated or already completed tasks; final proof must cover one active tree.");
|
|
129
|
+
}
|
|
130
|
+
const ordered = all.filter(({ task }) => selected.has(task.id));
|
|
131
|
+
const noChangePaths = head === target ? parent.noChangePaths : undefined;
|
|
132
|
+
if (noChangePaths !== undefined)
|
|
133
|
+
for (const task of ordered)
|
|
134
|
+
await deps.tasks(record.path).recordEvidence(task.task.id, {}, undefined, noChangePaths);
|
|
135
|
+
for (const task of ordered) {
|
|
136
|
+
requirePass(await step(record.path, ["verify", "--task", task.task.id, "--base", target]), `Verify ${task.task.id}`);
|
|
137
|
+
}
|
|
138
|
+
const reviewMode = await reviewFinalTree(step, record.path, parent.task.id, ordered.map(({ task }) => task.id), target);
|
|
139
|
+
const scope = await resolveReviewScope({ root: record.path, runner: runner(deps, record.path), base: target,
|
|
140
|
+
...(noChangePaths === undefined ? {} : { noChangePaths }) });
|
|
141
|
+
const sourceFingerprint = await calculateSourceFingerprint(record.path, scope, runner(deps, record.path));
|
|
142
|
+
const finalProof = { target, head, sourceFingerprint, children,
|
|
143
|
+
...(noChangePaths === undefined ? {} : { noChange: { sourceCommit: head,
|
|
144
|
+
contractDigest: await deps.tasks(record.path).treeContract(parent.task.id), reviewScope: scope,
|
|
145
|
+
artifactRefs: Object.values((await deps.tasks(record.path).status({ taskId: parent.task.id })).evidence).flat() } }) };
|
|
146
|
+
await prepareRunIntegration(commonDir, record, finalProof);
|
|
147
|
+
try {
|
|
148
|
+
const finished = await finishWorktree(deps, {
|
|
149
|
+
cwd: mainRoot, name,
|
|
150
|
+
finalProof
|
|
151
|
+
});
|
|
152
|
+
if (finished.receipt === undefined)
|
|
153
|
+
throw new AgentOpsError("WORKTREE_RECEIPT_MISSING", "Finish returned without a final review receipt.");
|
|
154
|
+
const review = await readFinishedReview(deps, mainRoot, commonDir, finished.receipt);
|
|
155
|
+
const highlights = renderReviewHighlights(review.entries);
|
|
156
|
+
return okEnvelope("TASK_ADVANCED", {
|
|
157
|
+
parentTaskId: parent.task.id,
|
|
158
|
+
reviewMode,
|
|
159
|
+
sourceFingerprint,
|
|
160
|
+
...finished,
|
|
161
|
+
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}`
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
catch (failure) {
|
|
165
|
+
if (failure instanceof AgentOpsError && failure.code === "WORKTREE_TARGET_MOVED" && attempt === 0) {
|
|
166
|
+
await abandonPreparedRunIntegration(commonDir, record, deps.git);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
throw failure;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
throw new AgentOpsError("WORKTREE_TARGET_MOVED", "The target moved again; preserve the candidate and rerun after coordination.");
|
|
173
|
+
}
|
|
@@ -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 (![1, 2].includes(artifact.schemaVersion) || 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
|
+
}
|