@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
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { calculateConfigHash } from "../config/hash.js";
|
|
3
|
+
import { AgentOpsError } from "../fs/paths.js";
|
|
4
|
+
import { resolveReviewScope } from "../review/scope.js";
|
|
5
|
+
import { readPrivateFile, writePrivateFile } from "../security/permissions.js";
|
|
6
|
+
import { checkTaskVerificationEvidence } from "../task/completion.js";
|
|
7
|
+
import { FileEvidenceStore } from "../verify/evidence.js";
|
|
8
|
+
import { calculateSourceFingerprint } from "../verify/source-fingerprint.js";
|
|
9
|
+
import { collectChangeSurface } from "../verify/change-surface.js";
|
|
10
|
+
import { listWorktrees } from "./manage.js";
|
|
11
|
+
function error(code, message) {
|
|
12
|
+
return new AgentOpsError(code, message);
|
|
13
|
+
}
|
|
14
|
+
async function git(deps, cwd, args) {
|
|
15
|
+
const result = await deps.git(cwd, args);
|
|
16
|
+
if (result.exitCode !== 0)
|
|
17
|
+
throw error("WORKTREE_INTEGRATION_FAILED", `${args.join(" ")}: ${result.stderr.trim()}`);
|
|
18
|
+
return result.stdout.trim();
|
|
19
|
+
}
|
|
20
|
+
function runner(deps, cwd) {
|
|
21
|
+
return { run: async (args) => {
|
|
22
|
+
const result = await deps.git(cwd, args);
|
|
23
|
+
return { exitCode: result.exitCode, stdout: Buffer.from(result.stdout, "utf8") };
|
|
24
|
+
} };
|
|
25
|
+
}
|
|
26
|
+
function markerPath(record) {
|
|
27
|
+
return join(record.path, ".agent-ops", "tasks", "integrated-children.json");
|
|
28
|
+
}
|
|
29
|
+
async function previous(record) {
|
|
30
|
+
const source = await readPrivateFile(markerPath(record), record.path);
|
|
31
|
+
if (source === null)
|
|
32
|
+
return [];
|
|
33
|
+
let value;
|
|
34
|
+
try {
|
|
35
|
+
value = JSON.parse(source);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
value = null;
|
|
39
|
+
}
|
|
40
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "object" || item === null ||
|
|
41
|
+
typeof item.name !== "string" || typeof item.commit !== "string" ||
|
|
42
|
+
!/^[a-f0-9]{40,64}$/u.test(item.commit) ||
|
|
43
|
+
!Array.isArray(item.taskIds) || item.taskIds.some((id) => typeof id !== "string"))) {
|
|
44
|
+
throw error("WORKTREE_INTEGRATION_STATE_INVALID", "Child integration record is invalid.");
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
/** Integrate only this session's agent-owned branches; local proof is never promoted as final proof. */
|
|
49
|
+
export async function integrateSessionChildren(deps, coordinator, parentTaskId) {
|
|
50
|
+
const statuses = await listWorktrees(deps, coordinator.mainRoot);
|
|
51
|
+
const children = statuses.map(({ record }) => record)
|
|
52
|
+
.filter((record) => record.sessionId === coordinator.sessionId && record.agentId !== undefined)
|
|
53
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
54
|
+
const integrated = await previous(coordinator);
|
|
55
|
+
if (children.length === 0)
|
|
56
|
+
return integrated;
|
|
57
|
+
const config = await deps.loadConfig(coordinator.path);
|
|
58
|
+
const configHash = calculateConfigHash(config);
|
|
59
|
+
const deliveries = [];
|
|
60
|
+
const existing = new Map(integrated.map((item) => [item.name, item]));
|
|
61
|
+
for (const record of children) {
|
|
62
|
+
if (record.mainRoot !== coordinator.mainRoot || record.targetBranch !== coordinator.targetBranch ||
|
|
63
|
+
record.name === coordinator.name ||
|
|
64
|
+
await git(deps, record.path, ["symbolic-ref", "--short", "HEAD"]) !== record.branch) {
|
|
65
|
+
throw error("WORKTREE_CHILD_MISMATCH", `Child ${record.name} does not belong to this candidate branch.`);
|
|
66
|
+
}
|
|
67
|
+
if ((await collectChangeSurface(runner(deps, record.path))).paths.length > 0) {
|
|
68
|
+
throw error("WORKTREE_CHILD_DIRTY", `Commit the changes in child ${record.name} before integration.`);
|
|
69
|
+
}
|
|
70
|
+
const head = await git(deps, record.path, ["rev-parse", "HEAD"]);
|
|
71
|
+
const prior = existing.get(record.name);
|
|
72
|
+
if (prior !== undefined) {
|
|
73
|
+
if (prior.commit !== head)
|
|
74
|
+
throw error("WORKTREE_CHILD_MOVED", `Child ${record.name} changed after delivery; keep its worktree and replan integration.`);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if ((await deps.git(record.path, ["merge-base", "--is-ancestor", record.base, head])).exitCode !== 0 ||
|
|
78
|
+
Number(await git(deps, record.path, ["rev-list", "--count", `${record.base}..${head}`])) === 0) {
|
|
79
|
+
throw error("WORKTREE_CHILD_EMPTY", `Child ${record.name} has no committed delivery.`);
|
|
80
|
+
}
|
|
81
|
+
const childConfig = await deps.loadConfig(record.path);
|
|
82
|
+
if (calculateConfigHash(childConfig) !== configHash) {
|
|
83
|
+
throw error("WORKTREE_CHILD_CONFIG_CHANGED", `Child ${record.name} uses a different verifier policy.`);
|
|
84
|
+
}
|
|
85
|
+
const tasks = await deps.tasks(record.path).list();
|
|
86
|
+
const roots = tasks.filter(({ task }) => task.parentTaskId === undefined);
|
|
87
|
+
if (roots.length !== 1 || tasks.some(({ status }) => status === "archived")) {
|
|
88
|
+
throw error("WORKTREE_CHILD_TASK_INVALID", `Child ${record.name} must deliver one active task tree.`);
|
|
89
|
+
}
|
|
90
|
+
const scope = await resolveReviewScope({ root: record.path, runner: runner(deps, record.path), base: record.base });
|
|
91
|
+
const fingerprint = await calculateSourceFingerprint(record.path, scope, runner(deps, record.path));
|
|
92
|
+
for (const task of tasks) {
|
|
93
|
+
if (task.task.intent === undefined || task.task.intent.trim() === "") {
|
|
94
|
+
throw error("WORKTREE_CHILD_INTENT_MISSING", `Child task ${task.task.id} needs its pre-work modification intent.`);
|
|
95
|
+
}
|
|
96
|
+
const problem = await checkTaskVerificationEvidence(task, {
|
|
97
|
+
root: record.path, config: childConfig, sourceFingerprint: fingerprint,
|
|
98
|
+
evidenceStore: new FileEvidenceStore(record.path, record.path)
|
|
99
|
+
});
|
|
100
|
+
if (problem !== null) {
|
|
101
|
+
throw error("WORKTREE_CHILD_UNVERIFIED", `Child task ${task.task.id}: ${problem.remedy}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
deliveries.push({ record, head, tasks });
|
|
105
|
+
}
|
|
106
|
+
if (deliveries.length === 0)
|
|
107
|
+
return integrated;
|
|
108
|
+
const before = await git(deps, coordinator.path, ["rev-parse", "HEAD"]);
|
|
109
|
+
if ((await collectChangeSurface(runner(deps, coordinator.path))).paths.length > 0) {
|
|
110
|
+
throw error("WORKTREE_DIRTY", "Commit the candidate worktree before integrating children.");
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
for (const delivery of deliveries) {
|
|
114
|
+
if ((await deps.git(coordinator.path, ["merge-base", "--is-ancestor", delivery.head, "HEAD"])).exitCode !== 0) {
|
|
115
|
+
const merge = await deps.git(coordinator.path, ["merge", "--no-ff", "--no-edit", delivery.head]);
|
|
116
|
+
if (merge.exitCode !== 0) {
|
|
117
|
+
const files = (await deps.git(coordinator.path, ["diff", "--name-only", "--diff-filter=U"])).stdout.trim();
|
|
118
|
+
await deps.git(coordinator.path, ["merge", "--abort"]);
|
|
119
|
+
throw error("WORKTREE_INTEGRATION_CONFLICT", `Child ${delivery.record.name} conflicts in ${files || "unknown files"}. Resolve both recorded intents before retrying.`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (await git(deps, delivery.record.path, ["rev-parse", "HEAD"]) !== delivery.head) {
|
|
123
|
+
throw error("WORKTREE_CHILD_MOVED", `Child ${delivery.record.name} moved during integration.`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
await deps.tasks(coordinator.path).importDelivery(parentTaskId, deliveries.flatMap(({ tasks }) => tasks));
|
|
127
|
+
const next = [...integrated, ...deliveries.map(({ record, head, tasks }) => ({
|
|
128
|
+
name: record.name, commit: head, taskIds: tasks.map(({ task }) => task.id)
|
|
129
|
+
}))];
|
|
130
|
+
await writePrivateFile(markerPath(coordinator), `${JSON.stringify(next, null, 2)}\n`, coordinator.path);
|
|
131
|
+
return next;
|
|
132
|
+
}
|
|
133
|
+
catch (failure) {
|
|
134
|
+
await deps.git(coordinator.path, ["merge", "--abort"]);
|
|
135
|
+
await deps.git(coordinator.path, ["reset", "--hard", before]);
|
|
136
|
+
throw failure;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { sha256 } from "../fs/hash.js";
|
|
3
|
+
import { AgentOpsError } from "../fs/paths.js";
|
|
4
|
+
import { findReviewAttestation, readReviewReportArtifact } from "../review/attestation.js";
|
|
5
|
+
import { resolveReviewScope } from "../review/scope.js";
|
|
6
|
+
import { readPrivateFile, writePrivateFile } from "../security/permissions.js";
|
|
7
|
+
import { checkTaskCompletionEvidence } from "../task/completion.js";
|
|
8
|
+
import { FileEvidenceStore } from "../verify/evidence.js";
|
|
9
|
+
import { calculateSourceFingerprint } from "../verify/source-fingerprint.js";
|
|
10
|
+
function seal(value) {
|
|
11
|
+
return { digest: sha256(JSON.stringify(value)), value };
|
|
12
|
+
}
|
|
13
|
+
function validSeal(value) {
|
|
14
|
+
return value.digest === sha256(JSON.stringify(value.value));
|
|
15
|
+
}
|
|
16
|
+
function receiptPath(commonDir, record, head) {
|
|
17
|
+
return join(commonDir, "agent-ops", "receipts", `${sha256(record.sessionId)}-${head}.json`);
|
|
18
|
+
}
|
|
19
|
+
/** Complete local proof is copied into the Git common directory before the branch moves. */
|
|
20
|
+
export async function prepareFinishReceipt(options) {
|
|
21
|
+
const { record, candidateHead, targetCommit, tasks, config } = options;
|
|
22
|
+
const scope = await resolveReviewScope({ root: record.path, runner: options.gitRunner, base: targetCommit });
|
|
23
|
+
const sourceFingerprint = await calculateSourceFingerprint(record.path, scope, options.gitRunner);
|
|
24
|
+
if (sourceFingerprint !== options.expectedFingerprint) {
|
|
25
|
+
throw new AgentOpsError("WORKTREE_FINAL_SOURCE_CHANGED", "The final candidate no longer matches the reviewed fingerprint.");
|
|
26
|
+
}
|
|
27
|
+
const evidenceStore = new FileEvidenceStore(record.path, record.path);
|
|
28
|
+
const verification = {};
|
|
29
|
+
const reviews = {};
|
|
30
|
+
const residualRisks = new Set();
|
|
31
|
+
const modes = new Set();
|
|
32
|
+
for (const task of tasks) {
|
|
33
|
+
const problem = await checkTaskCompletionEvidence(task, {
|
|
34
|
+
root: record.path, config, sourceFingerprint, evidenceStore
|
|
35
|
+
});
|
|
36
|
+
if (problem !== null) {
|
|
37
|
+
throw new AgentOpsError("WORKTREE_FINAL_EVIDENCE_MISSING", `Task ${task.task.id}: ${problem.remedy}`);
|
|
38
|
+
}
|
|
39
|
+
for (const references of Object.values(task.evidence)) {
|
|
40
|
+
for (const reference of references) {
|
|
41
|
+
if (reference.startsWith("review:"))
|
|
42
|
+
continue;
|
|
43
|
+
const value = await evidenceStore.load(reference);
|
|
44
|
+
if (value === null)
|
|
45
|
+
throw new AgentOpsError("WORKTREE_FINAL_EVIDENCE_MISSING", `Missing ${reference}.`);
|
|
46
|
+
verification[reference] = seal(value);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const attestation = await findReviewAttestation(record.path, sourceFingerprint, task.task.id);
|
|
50
|
+
const report = await readReviewReportArtifact(record.path, sourceFingerprint, task.task.id);
|
|
51
|
+
if (attestation === null || report === null || report.taskId !== task.task.id) {
|
|
52
|
+
throw new AgentOpsError("WORKTREE_FINAL_EVIDENCE_MISSING", `Missing review for ${task.task.id}.`);
|
|
53
|
+
}
|
|
54
|
+
modes.add(attestation.tree === undefined ? "per-task-fallback" : "tree");
|
|
55
|
+
for (const risk of [...(report.report?.residualRisks ?? []), ...(report.adversarial?.report.residualRisks ?? [])]) {
|
|
56
|
+
residualRisks.add(risk);
|
|
57
|
+
}
|
|
58
|
+
reviews[task.task.id] = { attestation: seal(attestation), report: seal(report) };
|
|
59
|
+
}
|
|
60
|
+
if (modes.size !== 1 || (modes.has("tree") && new Set(Object.values(reviews)
|
|
61
|
+
.map(({ attestation }) => JSON.stringify(attestation.value.tree))).size !== 1)) {
|
|
62
|
+
throw new AgentOpsError("WORKTREE_FINAL_REVIEW_MISMATCH", "Final tasks do not share one complete review mode and tree scope.");
|
|
63
|
+
}
|
|
64
|
+
const receipt = {
|
|
65
|
+
schemaVersion: 1,
|
|
66
|
+
sessionId: record.sessionId,
|
|
67
|
+
candidateHead,
|
|
68
|
+
targetCommit,
|
|
69
|
+
sourceFingerprint,
|
|
70
|
+
reviewMode: modes.has("tree") ? "tree" : "per-task-fallback",
|
|
71
|
+
tasks,
|
|
72
|
+
children: options.children,
|
|
73
|
+
verification,
|
|
74
|
+
reviews,
|
|
75
|
+
residualRisks: [...residualRisks],
|
|
76
|
+
createdAt: new Date().toISOString()
|
|
77
|
+
};
|
|
78
|
+
const content = `${JSON.stringify(receipt, null, 2)}\n`;
|
|
79
|
+
const path = receiptPath(options.commonDir, record, candidateHead);
|
|
80
|
+
await writePrivateFile(path, content, options.commonDir);
|
|
81
|
+
const reread = await readPrivateFile(path, options.commonDir);
|
|
82
|
+
if (reread !== content || Object.values(verification).some((item) => !validSeal(item)) ||
|
|
83
|
+
Object.values(reviews).some(({ attestation, report }) => !validSeal(attestation) || !validSeal(report))) {
|
|
84
|
+
throw new AgentOpsError("WORKTREE_RECEIPT_INVALID", "The local finish receipt could not be read back intact.");
|
|
85
|
+
}
|
|
86
|
+
return { path, digest: sha256(content), receipt };
|
|
87
|
+
}
|
|
@@ -231,9 +231,11 @@ export async function bindSession(deps, record, mainConfig, baselineFingerprint)
|
|
|
231
231
|
else
|
|
232
232
|
await mainGate.addRoot(record.sessionId, record.path);
|
|
233
233
|
}
|
|
234
|
-
export async function removeCheckout(deps, record, force) {
|
|
234
|
+
export async function removeCheckout(deps, record, force, expectedHead) {
|
|
235
235
|
await git(deps, record.mainRoot, ["worktree", "remove", ...(force ? ["--force"] : []), record.path], "WORKTREE_REMOVE_FAILED", `Git could not remove ${record.path}.`);
|
|
236
|
-
await git(deps, record.mainRoot,
|
|
236
|
+
await git(deps, record.mainRoot, expectedHead === undefined
|
|
237
|
+
? ["branch", force ? "-D" : "-d", record.branch]
|
|
238
|
+
: ["update-ref", "-d", `refs/heads/${record.branch}`, expectedHead], "WORKTREE_REMOVE_FAILED", `Git could not delete ${record.branch}.`);
|
|
237
239
|
}
|
|
238
240
|
/**
|
|
239
241
|
* Creates `.worktrees/<name>` on `agent-ops/<name>` from `from` and makes it a
|
|
@@ -55,11 +55,12 @@ function artifactPath(root, fingerprint, taskId) {
|
|
|
55
55
|
export function reviewReportDigest(report) {
|
|
56
56
|
return sha256(JSON.stringify(redactReviewReport(report)));
|
|
57
57
|
}
|
|
58
|
-
function reportArtifact(result, sourceFingerprint, taskId) {
|
|
58
|
+
function reportArtifact(result, sourceFingerprint, taskId, tree) {
|
|
59
59
|
return {
|
|
60
60
|
schemaVersion: 1,
|
|
61
61
|
sourceFingerprint,
|
|
62
62
|
...(taskId === undefined ? {} : { taskId }),
|
|
63
|
+
...(tree === undefined ? {} : { tree }),
|
|
63
64
|
status: result.status,
|
|
64
65
|
harness: result.harness,
|
|
65
66
|
plannedTargets: [...(result.plannedTargets ?? [result.harness])],
|
|
@@ -105,6 +106,7 @@ function matchingReportArtifact(value, attestation) {
|
|
|
105
106
|
record.harness !== attestation.harness ||
|
|
106
107
|
record.harness !== attestation.reviewTargets[0] ||
|
|
107
108
|
record.taskId !== attestation.taskId ||
|
|
109
|
+
JSON.stringify(record.tree) !== JSON.stringify(attestation.tree) ||
|
|
108
110
|
record.hostTarget !== attestation.hostTarget ||
|
|
109
111
|
!Array.isArray(plannedTargets) ||
|
|
110
112
|
plannedTargets.length !== 2 ||
|
|
@@ -126,6 +128,16 @@ function matchingReportArtifact(value, attestation) {
|
|
|
126
128
|
return false;
|
|
127
129
|
}
|
|
128
130
|
const adversarialRecord = adversarial;
|
|
131
|
+
if (attestation.tree !== undefined) {
|
|
132
|
+
const expected = new Set(attestation.tree.criterionIds);
|
|
133
|
+
for (const report of [record.report, adversarialRecord.report]) {
|
|
134
|
+
const results = report?.results;
|
|
135
|
+
if (!Array.isArray(results) || results.length !== expected.size ||
|
|
136
|
+
results.some((item) => !expected.has(item.criterionId)) ||
|
|
137
|
+
new Set(results.map((item) => item.criterionId)).size !== expected.size)
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
129
141
|
return (adversarialRecord.target === attestation.reviewTargets[1] &&
|
|
130
142
|
adversarialRecord.refuted === false &&
|
|
131
143
|
typeof adversarialRecord.report === "object" &&
|
|
@@ -149,14 +161,14 @@ async function hasMatchingReportArtifact(root, attestation) {
|
|
|
149
161
|
return false;
|
|
150
162
|
}
|
|
151
163
|
}
|
|
152
|
-
export async function saveReviewReportArtifact(root, result, sourceFingerprint, taskId) {
|
|
164
|
+
export async function saveReviewReportArtifact(root, result, sourceFingerprint, taskId, tree) {
|
|
153
165
|
if (!FINGERPRINT_PATTERN.test(sourceFingerprint)) {
|
|
154
166
|
throw new AgentOpsError("REVIEW_REPORT_ARTIFACT_INVALID", "Review report artifact has an invalid source fingerprint.");
|
|
155
167
|
}
|
|
156
168
|
if (taskId !== undefined && !TASK_ID_PATTERN.test(taskId)) {
|
|
157
169
|
throw new AgentOpsError("REVIEW_REPORT_ARTIFACT_INVALID", "Review report artifact has an invalid task id.");
|
|
158
170
|
}
|
|
159
|
-
const value = reportArtifact(result, sourceFingerprint, taskId);
|
|
171
|
+
const value = reportArtifact(result, sourceFingerprint, taskId, tree);
|
|
160
172
|
const serialized = `${JSON.stringify(value, null, 2)}\n`;
|
|
161
173
|
if (Buffer.byteLength(serialized, "utf8") > REPORT_ARTIFACT_MAX_BYTES) {
|
|
162
174
|
throw new AgentOpsError("REVIEW_REPORT_ARTIFACT_TOO_LARGE", "Review report artifact exceeds the capture limit.");
|
|
@@ -173,11 +185,22 @@ function parseAttestation(value) {
|
|
|
173
185
|
return null;
|
|
174
186
|
}
|
|
175
187
|
const record = value;
|
|
188
|
+
const tree = record.tree;
|
|
176
189
|
if (record.schemaVersion !== 2 ||
|
|
177
190
|
record.status !== "PASS" ||
|
|
178
191
|
(record.taskId !== undefined &&
|
|
179
192
|
(typeof record.taskId !== "string" ||
|
|
180
193
|
!TASK_ID_PATTERN.test(record.taskId))) ||
|
|
194
|
+
(tree !== undefined &&
|
|
195
|
+
(typeof tree !== "object" || tree === null ||
|
|
196
|
+
typeof tree.rootTaskId !== "string" || !TASK_ID_PATTERN.test(tree.rootTaskId) ||
|
|
197
|
+
!Array.isArray(tree.taskIds) || tree.taskIds.length === 0 ||
|
|
198
|
+
tree.taskIds.some((id) => typeof id !== "string" || !TASK_ID_PATTERN.test(id)) ||
|
|
199
|
+
new Set(tree.taskIds).size !== tree.taskIds.length ||
|
|
200
|
+
!tree.taskIds.includes(record.taskId) ||
|
|
201
|
+
!Array.isArray(tree.criterionIds) || tree.criterionIds.length === 0 ||
|
|
202
|
+
tree.criterionIds.some((id) => typeof id !== "string" || id.length > 256) ||
|
|
203
|
+
new Set(tree.criterionIds).size !== tree.criterionIds.length)) ||
|
|
181
204
|
!isReviewTarget(record.harness) ||
|
|
182
205
|
typeof record.sourceFingerprint !== "string" ||
|
|
183
206
|
!FINGERPRINT_PATTERN.test(record.sourceFingerprint) ||
|
|
@@ -208,6 +231,7 @@ function parseAttestation(value) {
|
|
|
208
231
|
return {
|
|
209
232
|
schemaVersion: 2,
|
|
210
233
|
...(record.taskId === undefined ? {} : { taskId: record.taskId }),
|
|
234
|
+
...(tree === undefined ? {} : { tree: tree }),
|
|
211
235
|
harness: record.harness,
|
|
212
236
|
status: "PASS",
|
|
213
237
|
sourceFingerprint: record.sourceFingerprint,
|
|
@@ -173,3 +173,74 @@ export function renderReviewResult(result) {
|
|
|
173
173
|
lines.push("", "Supporting files inspected:", ...lineList(report.supportingFilesInspected));
|
|
174
174
|
return `${lines.join("\n")}\n`;
|
|
175
175
|
}
|
|
176
|
+
function fullReportLines(report) {
|
|
177
|
+
const lines = ["Summary:", safe(report.summary), "", "Criteria:"];
|
|
178
|
+
for (const item of report.results) {
|
|
179
|
+
lines.push(`- ${safe(item.criterionId)}: ${item.status} — ${safe(item.summary)}`);
|
|
180
|
+
lines.push(...item.evidence.map((evidence) => ` - ${safe(evidence)}`));
|
|
181
|
+
}
|
|
182
|
+
lines.push("", "Findings:");
|
|
183
|
+
if (report.findings.length === 0)
|
|
184
|
+
lines.push("- none");
|
|
185
|
+
for (const finding of report.findings) {
|
|
186
|
+
lines.push(`- [${finding.severity}] ${finding.blocking ? "blocking" : "non-blocking"}: ${safe(finding.title)}`);
|
|
187
|
+
lines.push(` Criteria: ${finding.criterionIds.length === 0 ? "none" : finding.criterionIds.map(safe).join(", ")}`);
|
|
188
|
+
lines.push(` ${safe(finding.details)}`);
|
|
189
|
+
lines.push(` Recommendation: ${safe(finding.recommendation)}`);
|
|
190
|
+
lines.push(...finding.locations.map((location) => ` Location: ${safe(location.path)}${location.line === undefined ? "" : `:${location.line}`}`));
|
|
191
|
+
lines.push(...finding.evidence.map((evidence) => ` Evidence: ${safe(evidence)}`));
|
|
192
|
+
}
|
|
193
|
+
lines.push("", "Residual risks:", ...lineList(report.residualRisks));
|
|
194
|
+
lines.push("", "Changed files inspected:", ...lineList(report.changedFilesInspected));
|
|
195
|
+
lines.push("", "Supporting files inspected:", ...lineList(report.supportingFilesInspected));
|
|
196
|
+
return lines;
|
|
197
|
+
}
|
|
198
|
+
/** Full, already-redacted reports, with unrun rounds explicitly named. */
|
|
199
|
+
export function renderReviewDetails(entries, mode) {
|
|
200
|
+
const lines = [`Recorded review (${mode}):`];
|
|
201
|
+
for (const { taskId, title, artifact } of entries) {
|
|
202
|
+
lines.push("", `Task ${safe(taskId)}: ${safe(title)}`);
|
|
203
|
+
if (artifact === null) {
|
|
204
|
+
lines.push("No review for the current candidate.", "Round 1 — primary: NOT_RUN.", "Round 2 — adversarial: NOT_RUN.");
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const first = artifact.attempts?.[0];
|
|
208
|
+
lines.push(`Round 1 — primary (${safe(first?.target ?? artifact.harness ?? "unassigned")}): ${first?.status ?? "NOT_RUN"}.`);
|
|
209
|
+
if (artifact.report === undefined)
|
|
210
|
+
lines.push(`Reason: ${safe(first?.reason ?? artifact.reason ?? "no report")}.`);
|
|
211
|
+
else
|
|
212
|
+
lines.push(...fullReportLines(artifact.report));
|
|
213
|
+
const second = artifact.attempts?.[1];
|
|
214
|
+
lines.push("", `Round 2 — adversarial (${safe(second?.target ?? artifact.adversarial?.target ?? "unassigned")}): ${second?.status ?? "NOT_RUN"}.`);
|
|
215
|
+
if (artifact.adversarial !== undefined)
|
|
216
|
+
lines.push(artifact.adversarial.refuted ? "Outcome: refuted the primary PASS." : "Outcome: upheld the primary PASS.");
|
|
217
|
+
if (artifact.adversarial?.report === undefined) {
|
|
218
|
+
lines.push(`Not run: ${safe(second?.reason ?? (artifact.report === undefined ? "first round did not run" :
|
|
219
|
+
first?.status === "FAIL" ? "first round failed" : artifact.reason ?? "no second-round report"))}.`);
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
lines.push(...fullReportLines(artifact.adversarial.report));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return `${lines.join("\n")}\n`;
|
|
226
|
+
}
|
|
227
|
+
/** Short finish output still names every concrete non-blocking issue and risk. */
|
|
228
|
+
export function renderReviewHighlights(entries) {
|
|
229
|
+
const lines = [];
|
|
230
|
+
for (const { title, artifact } of entries) {
|
|
231
|
+
if (artifact === null)
|
|
232
|
+
continue;
|
|
233
|
+
const rounds = [artifact.report, artifact.adversarial?.report];
|
|
234
|
+
for (const [index, report] of rounds.entries()) {
|
|
235
|
+
if (report === undefined)
|
|
236
|
+
continue;
|
|
237
|
+
lines.push(`${entries.length > 1 ? `${safe(title)} — ` : ""}Round ${index + 1}: ${safe(report.summary)}`);
|
|
238
|
+
for (const finding of report.findings.filter(({ blocking }) => !blocking)) {
|
|
239
|
+
lines.push(`- [${finding.severity}] ${safe(finding.title)}: ${safe(finding.details)}`);
|
|
240
|
+
}
|
|
241
|
+
for (const risk of report.residualRisks)
|
|
242
|
+
lines.push(`- Risk: ${safe(risk)}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return lines.length === 0 ? "No review report is available.\n" : `${lines.join("\n")}\n`;
|
|
246
|
+
}
|
|
@@ -523,7 +523,7 @@ function validateCriterion(value, path) {
|
|
|
523
523
|
return success(value);
|
|
524
524
|
}
|
|
525
525
|
export function validateTask(value) {
|
|
526
|
-
const root = validateRoot(value, ["criteria", "id", "parentTaskId", "schemaVersion", "title"], TASK_SCHEMA_VERSION);
|
|
526
|
+
const root = validateRoot(value, ["criteria", "id", "intent", "parentTaskId", "schemaVersion", "title"], TASK_SCHEMA_VERSION);
|
|
527
527
|
if (isFailure(root)) {
|
|
528
528
|
return root;
|
|
529
529
|
}
|
|
@@ -539,6 +539,11 @@ export function validateTask(value) {
|
|
|
539
539
|
if (!isNonEmptyString(root.title)) {
|
|
540
540
|
return failure("INVALID_TITLE", "$.title", "Task title is required.");
|
|
541
541
|
}
|
|
542
|
+
if (root.intent !== undefined &&
|
|
543
|
+
(typeof root.intent !== "string" || root.intent.trim() === "" ||
|
|
544
|
+
root.intent.includes("\0") || Buffer.byteLength(root.intent, "utf8") > 8192)) {
|
|
545
|
+
return failure("INVALID_INTENT", "$.intent", "Task intent must be non-empty and at most 8 KiB.");
|
|
546
|
+
}
|
|
542
547
|
if (!Array.isArray(root.criteria) ||
|
|
543
548
|
root.criteria.length < 2 ||
|
|
544
549
|
root.criteria.length > 5) {
|
|
@@ -3,7 +3,19 @@ import { findReviewAttestation } from "../review/attestation.js";
|
|
|
3
3
|
import { validateEvidence, validateTaskAgainstConfig } from "../schema/validate.js";
|
|
4
4
|
import { isPassingVerificationEvidence } from "../verify/evidence.js";
|
|
5
5
|
export async function checkTaskCompletionEvidence(stored, options) {
|
|
6
|
-
const
|
|
6
|
+
const problem = await checkTaskVerificationEvidence(stored, options);
|
|
7
|
+
if (problem !== null)
|
|
8
|
+
return problem;
|
|
9
|
+
const attestation = await findReviewAttestation(options.root, options.sourceFingerprint, stored.task.id);
|
|
10
|
+
if (attestation === null || attestation.taskId !== stored.task.id ||
|
|
11
|
+
(attestation.tree !== undefined && stored.task.criteria.some((criterion) => !attestation.tree.criterionIds.includes(`${stored.task.id}:${criterion.id}`)))) {
|
|
12
|
+
return { status: "FAIL", code: "REVIEW_REQUIRED", remedy: `Run agent-ops review --task ${stored.task.id} --yes for the whole task and current source.` };
|
|
13
|
+
}
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
/** The same mechanical proof check used by final completion and child delivery. */
|
|
17
|
+
export async function checkTaskVerificationEvidence(stored, options) {
|
|
18
|
+
const { config, sourceFingerprint, evidenceStore } = options;
|
|
7
19
|
if (stored.failureFingerprint !== null) {
|
|
8
20
|
return { status: "FAIL", code: "VERIFICATION_FAILED", remedy: "Resolve the latest verification failure before completing the task." };
|
|
9
21
|
}
|
|
@@ -40,10 +52,6 @@ export async function checkTaskCompletionEvidence(stored, options) {
|
|
|
40
52
|
}
|
|
41
53
|
}
|
|
42
54
|
}
|
|
43
|
-
const attestation = await findReviewAttestation(root, sourceFingerprint, stored.task.id);
|
|
44
|
-
if (attestation === null || attestation.taskId !== stored.task.id) {
|
|
45
|
-
return { status: "FAIL", code: "REVIEW_REQUIRED", remedy: `Run agent-ops review --task ${stored.task.id} --yes for the whole task and current source.` };
|
|
46
|
-
}
|
|
47
55
|
return null;
|
|
48
56
|
}
|
|
49
57
|
/** Include descendants so legacy completed children cannot hide unfinished work. */
|
|
@@ -26,6 +26,9 @@ export function renderTaskMarkdown(record) {
|
|
|
26
26
|
...(record.archivedAt === null
|
|
27
27
|
? []
|
|
28
28
|
: [`Archived: ${record.archivedAt}`]),
|
|
29
|
+
...(record.task.intent === undefined
|
|
30
|
+
? []
|
|
31
|
+
: ["", "## Modification intent", "", safeTaskText(record.task.intent)]),
|
|
29
32
|
"",
|
|
30
33
|
"## Acceptance criteria",
|
|
31
34
|
""
|
|
@@ -90,6 +90,7 @@ export class TaskService {
|
|
|
90
90
|
schemaVersion: TASK_SCHEMA_VERSION,
|
|
91
91
|
id: this.#generateId(),
|
|
92
92
|
title: input.title,
|
|
93
|
+
...(input.intent === undefined ? {} : { intent: input.intent }),
|
|
93
94
|
criteria: [...input.criteria],
|
|
94
95
|
...(input.parentTaskId === undefined
|
|
95
96
|
? {}
|
|
@@ -153,6 +154,53 @@ export class TaskService {
|
|
|
153
154
|
return cloneRecord(record);
|
|
154
155
|
});
|
|
155
156
|
}
|
|
157
|
+
/** Import a child worktree's intent and criteria, never its source-bound proof. */
|
|
158
|
+
async importDelivery(parentTaskId, delivered) {
|
|
159
|
+
if (delivered.length === 0 || delivered.some(({ status, task }) => status === "archived" || task.intent === undefined || task.intent.trim() === "")) {
|
|
160
|
+
throw taskError("TASK_DELIVERY_INVALID", "Every delivered task needs a recorded intent and must not be archived.");
|
|
161
|
+
}
|
|
162
|
+
await this.#store.mutate((state) => {
|
|
163
|
+
const parent = findTask(state, parentTaskId);
|
|
164
|
+
if (parent.status !== "active") {
|
|
165
|
+
throw taskError("TASK_PARENT_NOT_ACTIVE", "Only an active parent can accept delivery.");
|
|
166
|
+
}
|
|
167
|
+
const ids = new Set(delivered.map(({ task }) => task.id));
|
|
168
|
+
if (ids.size !== delivered.length) {
|
|
169
|
+
throw taskError("TASK_DELIVERY_INVALID", "Delivered task IDs must be unique.");
|
|
170
|
+
}
|
|
171
|
+
for (const record of delivered) {
|
|
172
|
+
const task = {
|
|
173
|
+
...record.task,
|
|
174
|
+
...(record.task.parentTaskId === undefined ? { parentTaskId } : {})
|
|
175
|
+
};
|
|
176
|
+
if (task.parentTaskId !== parentTaskId && !ids.has(task.parentTaskId)) {
|
|
177
|
+
throw taskError("TASK_DELIVERY_INVALID", `Task ${task.id} has a parent outside this delivery.`);
|
|
178
|
+
}
|
|
179
|
+
if (record.policyConfigHash !== parent.policyConfigHash || !validateTask(task).ok) {
|
|
180
|
+
throw taskError("TASK_DELIVERY_INVALID", `Task ${task.id} has an incompatible policy or definition.`);
|
|
181
|
+
}
|
|
182
|
+
const existing = state.tasks.find((item) => item.task.id === task.id);
|
|
183
|
+
if (existing !== undefined) {
|
|
184
|
+
if (JSON.stringify(existing.task) !== JSON.stringify(task)) {
|
|
185
|
+
throw taskError("TASK_ID_CONFLICT", `Task ${task.id} conflicts with an existing task.`);
|
|
186
|
+
}
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
state.tasks.push({
|
|
190
|
+
task,
|
|
191
|
+
status: "active",
|
|
192
|
+
evidence: {},
|
|
193
|
+
createdAt: record.createdAt,
|
|
194
|
+
updatedAt: this.#now(),
|
|
195
|
+
completedAt: null,
|
|
196
|
+
archivedAt: null,
|
|
197
|
+
failureFingerprint: null,
|
|
198
|
+
policyConfigHash: record.policyConfigHash,
|
|
199
|
+
completionBase: null
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
}
|
|
156
204
|
async list(filter = {}) {
|
|
157
205
|
const state = await this.#store.read();
|
|
158
206
|
return state.tasks
|
|
@@ -265,6 +265,16 @@ evidence. Give each editing conversation its own worktree instead:
|
|
|
265
265
|
Creating a subtask keeps the session attached to the top of its tree, and
|
|
266
266
|
each task keeps its own review record, so a parent and a subtask reviewed
|
|
267
267
|
on the same source do not overwrite each other.
|
|
268
|
+
- When writing subagents use separate worktrees, create each child task with
|
|
269
|
+
`--intent` before editing, then commit and verify locally. From the main
|
|
270
|
+
checkout run `agent-ops task advance --task <parent-id> --session <id> --yes`
|
|
271
|
+
through the trusted outer host runner. It integrates all child commits and
|
|
272
|
+
intents into the coordinator worktree, reruns each task's required verifier
|
|
273
|
+
on the same candidate, and performs one two-round tree review. Only an
|
|
274
|
+
explicit `NOT_RUN / scope-too-large` falls back to per-task two-round review.
|
|
275
|
+
After PASS it saves a complete receipt under `.git/agent-ops/receipts/` and
|
|
276
|
+
calls finish; a moved target may trigger one repeat of the final gate.
|
|
277
|
+
A direct finish of one child while siblings exist is refused.
|
|
268
278
|
- `agent-ops worktree finish <name>` merges by fast-forward only, one finish
|
|
269
279
|
at a time. When the main checkout is on the target branch the merge updates
|
|
270
280
|
its files, so uncommitted changes there block it. When the main checkout is
|
|
@@ -10,6 +10,16 @@ Every acceptance criterion MUST map to observable evidence.
|
|
|
10
10
|
- Positive: `criterion tests → npm test (354 passing tests)`.
|
|
11
11
|
- Negative: `The implementation looks correct, so no evidence is needed.`
|
|
12
12
|
|
|
13
|
+
## EVIDENCE-INTENT-001
|
|
14
|
+
|
|
15
|
+
Each child worktree MUST record its modification intent before editing.
|
|
16
|
+
|
|
17
|
+
- Trigger: A subagent will edit in its own worktree.
|
|
18
|
+
- Action: Use `task create --intent <text>` to record the user-approved behavior, constraints and integration points. The coordinator imports the intent with the child task and re-runs verification on the integrated candidate.
|
|
19
|
+
- Evidence: The child task and final receipt contain the intent, with final verifier evidence bound to the integrated candidate.
|
|
20
|
+
- Positive: `A child records its planned API change, commits it, and the final receipt includes that intent and integrated verification.`
|
|
21
|
+
- Negative: `A child edits first and supplies a retrospective one-line intent at delivery.`
|
|
22
|
+
|
|
13
23
|
## EVIDENCE-AGGREGATE-001
|
|
14
24
|
|
|
15
25
|
The verifier MUST return FAIL when a criterion is missing, duplicated, unknown, or has empty evidence.
|
|
@@ -42,10 +42,10 @@ Parallel editing conversations in one repository MUST each work in their own age
|
|
|
42
42
|
|
|
43
43
|
## DELEGATE-ISOLATION-003
|
|
44
44
|
|
|
45
|
-
Subagents that write files in one session MUST each work in their own agent-ops worktree, with their own task,
|
|
45
|
+
Subagents that write files in one session MUST each work in their own agent-ops worktree, with their own task, pre-work modification intent and local verification.
|
|
46
46
|
|
|
47
47
|
- Trigger: `worktree.mode` is `auto` and a subagent writes a file. Hook calls made inside a subagent carry its `agent_id` under the parent's `session_id`; a payload without `agent_id` is treated as the session's main thread.
|
|
48
|
-
- Action: The first blocked write allocates a worktree owned by that session and agent; edit there using absolute paths, because the hook's `cwd` does not follow a `cd`.
|
|
49
|
-
- Evidence: `agent-ops worktree list` shows an `agent:` line for each
|
|
50
|
-
- Positive: `Two subagents
|
|
48
|
+
- Action: The first blocked write allocates a worktree owned by that session and agent; edit there using absolute paths, because the hook's `cwd` does not follow a `cd`. Each subagent records `task create --intent` before editing, then commits and runs `agent-ops verify --task` locally. The coordinator runs `agent-ops task advance --task <parent-id> --session <id> --yes` from the main checkout. It integrates every child into the session candidate, verifies all criteria on that candidate, reviews the whole tree in two fresh sessions, archives proof locally and finishes. A direct finish of one child while siblings exist is refused.
|
|
49
|
+
- Evidence: `agent-ops worktree list` shows an `agent:` line for each child; the final receipt under `.git/agent-ops/receipts/` contains the integrated task tree, intents, verifier evidence and review reports.
|
|
50
|
+
- Positive: `Two subagents commit and verify separate worktrees; one task advance integrates them and completes the final gate before finish.`
|
|
51
51
|
- Negative: `A subagent edits the coordinator's worktree, or two subagents edit one worktree, so one review covers two tasks' changes.`
|
package/docs/en/spec/review.md
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
An independent reviewer MUST receive a minimal packet containing the request, criteria, artifact references, and evidence requirements.
|
|
6
6
|
|
|
7
7
|
- Trigger: A multi-step change reaches a review checkpoint.
|
|
8
|
-
- Action:
|
|
8
|
+
- Action: Include user-approved task intent in the request when recorded; omit hidden reasoning, raw logs, and credentials.
|
|
9
9
|
- Evidence: The packet keys and artifact references are visible without sensitive content.
|
|
10
|
-
- Positive: `Packet contains criteria, files, and evidence requirements
|
|
10
|
+
- Positive: `Packet contains recorded task intent, criteria, files, and evidence requirements.`
|
|
11
11
|
- Negative: `Forward the complete session transcript and environment.`
|
|
12
12
|
|
|
13
13
|
## REVIEW-RESULT-001
|
|
@@ -126,6 +126,28 @@ FAIL.
|
|
|
126
126
|
- Positive: `NOT_RUN: unparseable-output; one criterion was missing.`
|
|
127
127
|
- Negative: `Record a failed review because the model's JSON was malformed.`
|
|
128
128
|
|
|
129
|
+
## REVIEW-TREE-001
|
|
130
|
+
|
|
131
|
+
`task advance` MUST prove one integrated candidate against every parent and descendant criterion before it calls finish.
|
|
132
|
+
|
|
133
|
+
- Trigger: A session has a parent task and one or more agent-owned child worktrees.
|
|
134
|
+
- Action: Import each child task's pre-work intent and committed code into the coordinator candidate. Run every task's required verifier against the same final base and source fingerprint. Run `review --task <parent-id> --tree --base <final-base> --yes`, which sends all uniquely qualified criteria and intents through the existing two fresh reviewer sessions. Save a task-bound alias for every task, all pointing to that complete tree report.
|
|
135
|
+
- Evidence: The final receipt contains the complete task tree, intents, source fingerprint, verifier evidence, two reviewer reports, review mode, and residual risks.
|
|
136
|
+
- Positive: `Two child worktrees with three criteria each and a parent with two criteria receive one two-round final review covering all eight criteria.`
|
|
137
|
+
- Negative: `Review the parent alone, then finish children whose criteria were never checked against the integrated code.`
|
|
138
|
+
|
|
139
|
+
Only `NOT_RUN / scope-too-large` may fall back to a separate complete two-round review for each task. A substantive FAIL, missing verifier evidence, reviewer unavailability, or any other NOT_RUN blocks finish. `task advance` may rebase onto a moved target and repeat the entire final gate once. The receipt is written under the main checkout's `.git/agent-ops/receipts/` before the target branch moves; failure to write or read it back blocks the merge. A late target move rejects the merge so final proof can be repeated. A post-merge cleanup failure is reported as partial, not as successful finish.
|
|
140
|
+
|
|
141
|
+
## REVIEW-SHOW-001
|
|
142
|
+
|
|
143
|
+
The user MUST be able to inspect both recorded reviewer reports for the latest candidate from the main checkout without starting another review.
|
|
144
|
+
|
|
145
|
+
- Trigger: `review show --task <id>` for an active or finished task.
|
|
146
|
+
- Action: Display the full redacted structured primary and adversarial reports, including every criterion, finding, inspected file, and residual risk. For a successful tree review, display the shared report once; for a per-task fallback, group both rounds by task. Validate a finished receipt against its Git note digest. If the candidate changed since review, state that the current candidate has no review instead of showing an old verdict. A failed or unavailable round names the missing report explicitly. `task advance` summarizes concrete nonblocking findings and risks on PASS and shows available report details on failure.
|
|
147
|
+
- Evidence: The read-only command invokes no reviewer and its output is bound to the active fingerprint or the finished receipt.
|
|
148
|
+
- Positive: `After finish, review show from main displays both full reports and residual risks for the final candidate.`
|
|
149
|
+
- Negative: `A new commit still displays the preceding candidate's PASS as current.`
|
|
150
|
+
|
|
129
151
|
## REVIEW-BATCH-001
|
|
130
152
|
|
|
131
153
|
A batch review MUST cover exactly the active subtasks of the named parent and the parent itself.
|