@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.
@@ -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, ["branch", force ? "-D" : "-d", record.branch], "WORKTREE_REMOVE_FAILED", `Git could not delete ${record.branch}.`);
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 { root, config, sourceFingerprint, evidenceStore } = options;
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, verification and review.
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`. The subagent commits and runs `agent-ops verify` and `agent-ops review` in its worktree. At most two reviews run at once across all worktrees of the repository; the rest wait. The coordinator runs `agent-ops worktree finish <name>` for each worktree in turn, and Stop is allowed only when every worktree of the session has a completed task.
49
- - Evidence: `agent-ops worktree list` shows an `agent:` line for each subagent worktree, writing into another writer's worktree is refused with `WORKTREE_OWNED_BY_OTHER_AGENT`, and each finish reports a fast-forward merge.
50
- - Positive: `Two subagents implement independent tasks in .worktrees/session-ab12cd34-a9230363 and .worktrees/session-ab12cd34-b1111111; each reviews itself and the coordinator finishes them one after the other.`
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.`
@@ -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: Omit implementation rationale, hidden reasoning, raw logs, and credentials.
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 only.`
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.