@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.
@@ -71,7 +71,9 @@ async function taskContext(options) {
71
71
  }
72
72
  return {
73
73
  taskId: record.task.id,
74
- title: record.task.title,
74
+ title: record.task.intent === undefined
75
+ ? record.task.title
76
+ : `${record.task.title}\nModification intent: ${record.task.intent}`,
75
77
  active: record.status === "active",
76
78
  policyConfigHash: record.policyConfigHash,
77
79
  evidence: record.evidence,
@@ -80,6 +82,51 @@ async function taskContext(options) {
80
82
  allCriteriaReviewed: criteria.length === record.task.criteria.length
81
83
  };
82
84
  }
85
+ async function treeContext(options) {
86
+ if (options.tasks === undefined || options.taskId === undefined) {
87
+ throw new AgentOpsError("REVIEW_TREE_TASK_REQUIRED", "Tree review requires a parent task.");
88
+ }
89
+ const all = await options.tasks.list();
90
+ const root = all.find(({ task }) => task.id === options.taskId);
91
+ if (root === undefined || root.status === "archived") {
92
+ throw new AgentOpsError("REVIEW_TREE_TASK_REQUIRED", "The selected review tree does not exist.");
93
+ }
94
+ const selected = new Set([root.task.id]);
95
+ for (let changed = true; changed;) {
96
+ changed = false;
97
+ for (const record of all) {
98
+ if (record.status !== "archived" && record.task.parentTaskId !== undefined &&
99
+ selected.has(record.task.parentTaskId) && !selected.has(record.task.id)) {
100
+ selected.add(record.task.id);
101
+ changed = true;
102
+ }
103
+ }
104
+ }
105
+ const treeTasks = all.filter(({ task }) => selected.has(task.id));
106
+ const owners = new Map();
107
+ const evidence = {};
108
+ const criteria = treeTasks.flatMap((record) => record.task.criteria.map((criterion) => {
109
+ const id = `${record.task.id}:${criterion.id}`;
110
+ owners.set(id, { taskId: record.task.id, criterionId: criterion.id });
111
+ evidence[id] = record.evidence[criterion.id] ?? [];
112
+ return { id, description: `${record.task.title}: ${criterion.description}`, verifierIds: [...criterion.verifierIds] };
113
+ }));
114
+ if (treeTasks.some(({ policyConfigHash }) => policyConfigHash !== root.policyConfigHash)) {
115
+ throw new AgentOpsError("REVIEW_TREE_POLICY_MISMATCH", "Tree tasks must share one verifier policy.");
116
+ }
117
+ return {
118
+ taskId: root.task.id,
119
+ title: [root.task.title, ...treeTasks.map(({ task }) => `Task ${task.id}${task.parentTaskId === undefined ? "" : ` under ${task.parentTaskId}`}: ${task.title}\nModification intent: ${task.intent ?? task.title}`)].join("\n\n"),
120
+ active: treeTasks.every(({ status }) => status === "active"),
121
+ policyConfigHash: root.policyConfigHash,
122
+ evidence,
123
+ failureFingerprint: treeTasks.find(({ failureFingerprint }) => failureFingerprint !== null)?.failureFingerprint ?? null,
124
+ criteria,
125
+ allCriteriaReviewed: true,
126
+ treeTasks,
127
+ criterionOwners: owners
128
+ };
129
+ }
83
130
  function newestEvidence(values) {
84
131
  return [...values].sort((left, right) => {
85
132
  const leftTime = Date.parse(left.evidence.finishedAt);
@@ -111,8 +158,9 @@ async function currentEvidence(options, context, criterionId, commandId, configH
111
158
  continue;
112
159
  }
113
160
  const evidence = validation.value;
114
- if (evidence.taskId !== context.taskId ||
115
- evidence.criterionId !== criterionId ||
161
+ const owner = context.criterionOwners?.get(criterionId);
162
+ if (evidence.taskId !== (owner?.taskId ?? context.taskId) ||
163
+ evidence.criterionId !== (owner?.criterionId ?? criterionId) ||
116
164
  evidence.commandId !== commandId ||
117
165
  evidence.configHash !== configHash ||
118
166
  evidence.sourceFingerprint !== sourceFingerprint) {
@@ -307,6 +355,12 @@ async function reusableReview(options, sourceFingerprint, context) {
307
355
  if (attestation === null || attestation.taskId !== context?.taskId) {
308
356
  return null;
309
357
  }
358
+ if (context?.treeTasks !== undefined &&
359
+ (attestation.tree?.rootTaskId !== context.taskId ||
360
+ JSON.stringify(attestation.tree.taskIds) !== JSON.stringify(context.treeTasks.map(({ task }) => task.id)) ||
361
+ JSON.stringify(attestation.tree.criterionIds) !== JSON.stringify(context.criteria.map(({ id }) => id)))) {
362
+ return null;
363
+ }
310
364
  // The task's own policy baseline must still be the active one. This is
311
365
  // checked before this point too; repeating it here keeps the reuse path
312
366
  // safe wherever it is called from.
@@ -355,9 +409,19 @@ async function persistReviewEvidence(options, result, sourceFingerprint, context
355
409
  reason: "adversarial-review-missing"
356
410
  }
357
411
  : result;
358
- let reportArtifact;
412
+ const taskIds = complete && context?.treeTasks !== undefined
413
+ ? context.treeTasks.map(({ task }) => task.id)
414
+ : [context?.taskId];
415
+ const tree = context?.treeTasks === undefined ? undefined : {
416
+ rootTaskId: context.taskId,
417
+ taskIds: context.treeTasks.map(({ task }) => task.id),
418
+ criterionIds: context.criteria.map(({ id }) => id)
419
+ };
420
+ const reportArtifacts = [];
359
421
  try {
360
- reportArtifact = await saveReviewReportArtifact(options.root, artifactResult, sourceFingerprint, context?.taskId);
422
+ for (const taskId of taskIds) {
423
+ reportArtifacts.push({ taskId, path: await saveReviewReportArtifact(options.root, artifactResult, sourceFingerprint, taskId, complete ? tree : undefined) });
424
+ }
361
425
  }
362
426
  catch {
363
427
  return {
@@ -375,21 +439,24 @@ async function persistReviewEvidence(options, result, sourceFingerprint, context
375
439
  try {
376
440
  const attempts = result.attempts;
377
441
  const plannedTargets = result.plannedTargets;
378
- await saveReviewAttestation(options.root, {
379
- schemaVersion: 2,
380
- ...(context === undefined ? {} : { taskId: context.taskId }),
381
- harness: result.harness,
382
- status: "PASS",
383
- sourceFingerprint,
384
- reviewTargets: [plannedTargets[0], plannedTargets[1]],
385
- reviewSessionIds: [attempts[0].sessionId, attempts[1].sessionId],
386
- sessionIsolation: "fresh",
387
- primaryReportDigest: reviewReportDigest(result.report),
388
- adversarialReportDigest: reviewReportDigest(result.adversarial.report),
389
- reportArtifact,
390
- ...(result.hostTarget === undefined ? {} : { hostTarget: result.hostTarget }),
391
- createdAt: new Date().toISOString()
392
- });
442
+ for (const artifact of reportArtifacts) {
443
+ await saveReviewAttestation(options.root, {
444
+ schemaVersion: 2,
445
+ ...(artifact.taskId === undefined ? {} : { taskId: artifact.taskId }),
446
+ ...(tree === undefined ? {} : { tree }),
447
+ harness: result.harness,
448
+ status: "PASS",
449
+ sourceFingerprint,
450
+ reviewTargets: [plannedTargets[0], plannedTargets[1]],
451
+ reviewSessionIds: [attempts[0].sessionId, attempts[1].sessionId],
452
+ sessionIsolation: "fresh",
453
+ primaryReportDigest: reviewReportDigest(result.report),
454
+ adversarialReportDigest: reviewReportDigest(result.adversarial.report),
455
+ reportArtifact: artifact.path,
456
+ ...(result.hostTarget === undefined ? {} : { hostTarget: result.hostTarget }),
457
+ createdAt: new Date().toISOString()
458
+ });
459
+ }
393
460
  }
394
461
  catch {
395
462
  return {
@@ -432,7 +499,9 @@ export async function runReviewCommand(options) {
432
499
  reason: targetPlan.reason
433
500
  });
434
501
  }
435
- const context = await taskContext(options);
502
+ const context = options.args.tree === true
503
+ ? await treeContext(options)
504
+ : await taskContext(options);
436
505
  const evidenceRequirements = (options.args.evidence ?? []).map((value) => {
437
506
  const separator = value.indexOf("=");
438
507
  return {
@@ -505,7 +574,7 @@ export async function runReviewCommand(options) {
505
574
  // source was reviewed; it says nothing about the policy in force now or
506
575
  // about verification that has failed since, and both of those are
507
576
  // decided above.
508
- const reused = options.args.rerun
577
+ const reused = options.args.rerun || context?.treeTasks !== undefined
509
578
  ? null
510
579
  : await reusableReview(options, sourceFingerprint, context);
511
580
  if (reused !== null) {
@@ -518,13 +587,18 @@ export async function runReviewCommand(options) {
518
587
  })
519
588
  });
520
589
  }
521
- await invalidateReviewAttestation(options.root, sourceFingerprint, context?.taskId);
590
+ for (const taskId of context?.treeTasks?.map(({ task }) => task.id) ?? [context?.taskId]) {
591
+ await invalidateReviewAttestation(options.root, sourceFingerprint, taskId);
592
+ }
522
593
  pendingInvalidation = false;
523
594
  }
524
595
  }
525
596
  const criteria = [
526
597
  ...(context?.criteria ?? GENERIC_CRITERIA)
527
598
  ];
599
+ if (context?.treeTasks !== undefined && criteria.length > 128) {
600
+ return notRunEnvelope({ ...resultBase, status: "NOT_RUN", reason: "scope-too-large" });
601
+ }
528
602
  let packet;
529
603
  try {
530
604
  packet = buildReviewPacket({
@@ -632,12 +706,14 @@ export async function runReviewCommand(options) {
632
706
  finalResult.status === "PASS" &&
633
707
  context.active &&
634
708
  finalResult.results !== undefined) {
635
- await options.tasks.recordEvidence(context.taskId, Object.fromEntries(finalResult.results.map((item) => [
636
- item.criterionId,
637
- item.evidence.map((reference) => `review:${finalResult.harness}:${reference}`)
638
- ])),
639
- // What `worktree finish` completes this task against.
640
- scope?.mode === "base" ? scope.resolvedBase : null);
709
+ for (const record of context.treeTasks ?? [await options.tasks.status({ taskId: context.taskId })]) {
710
+ await options.tasks.recordEvidence(record.task.id, Object.fromEntries(record.task.criteria.map((criterion) => {
711
+ const id = context.treeTasks === undefined
712
+ ? criterion.id : `${record.task.id}:${criterion.id}`;
713
+ const result = finalResult.results?.find((item) => item.criterionId === id);
714
+ return [criterion.id, (result?.evidence ?? []).map((reference) => `review:${finalResult.harness}:${reference}`)];
715
+ })), scope?.mode === "base" ? scope.resolvedBase : null);
716
+ }
641
717
  }
642
718
  const message = finalResult.status === "PASS"
643
719
  ? "Independent review passed."
@@ -92,6 +92,7 @@ export async function runTaskCommand(options) {
92
92
  : await options.sessionWorktree(sessionId);
93
93
  const record = await (worktree?.service ?? options.service).create({
94
94
  title: options.args.title,
95
+ ...(options.args.intent === undefined ? {} : { intent: options.args.intent }),
95
96
  criteria: (options.args.criteria ?? []).map(parseCriterion),
96
97
  ...(options.policyConfigHash === undefined
97
98
  ? {}
@@ -322,7 +322,7 @@ export function managedRules(descriptor, context) {
322
322
  lines.push("For every change:", "", "1. Define two to five mechanically verifiable acceptance criteria.", "2. Inspect the smallest relevant scope and preserve unrelated changes.", "3. Apply the smallest safe change.", "4. Run evidence-producing verification for every criterion.", "5. Obtain complete independent review before claiming completion, via", " `agent-ops review --task <task-id> --yes` (or the CLI's equivalent invocation).", " Never call a", " review-target CLI (agy, codex, claude) directly — direct calls skip", " the enforced read-only sandbox flags and can hang or fail on command", " permission prompts.", " Start this command through the trusted outer host runner as its first", " and only invocation. On Codex, the outer tool request must use", " `sandbox_permissions: \"require_escalated\"` so network and loopback", " are available; in that same request, unset", " `CODEX_SANDBOX_NETWORK_DISABLED` and set `AGENT_OPS_HOST` when three", " targets are configured. The Codex shell form is", " `env -u CODEX_SANDBOX_NETWORK_DISABLED AGENT_OPS_HOST=<current-host> agent-ops review --task <task-id> --yes`.", " Keep it a plain command line: to keep the output add `--output <file>`", " (the JSON envelope is written there and one line is printed) instead of", " a shell redirect, which the host can only allow by matching the whole", " command string, task id included.", " Do not run it once in the restricted sandbox and retry; changing the", " variable inside that sandbox is not elevation.", " Review runs exactly two fresh sessions: with three configured targets,", " set `AGENT_OPS_HOST` to the current CLI id so it is excluded; with two", " targets both run in configured order; with one target it runs twice.", " The first session is necessary; a PASS then requires an adversarial", " re-check carrying the first report. A first FAIL or NOT_RUN stops.", "", "Treat `.agent-ops/config.json` as verifier authority. Discovery output is", "only a proposal until a user confirms it. Repository commands require an", "exact matching trust record. Confirmed project init/update grants it", "automatically when verification commands are configured.", "");
323
323
  }
324
324
  if (context.capabilities.includes("task")) {
325
- lines.push("Split a change that exceeds five acceptance criteria into subtasks:", "`agent-ops task create --parent <task-id>` records one, and", "`agent-ops task status --parent <task-id>` lists them. Each subtask", "carries its own criteria, verification, and independent review;", "completing one never completes its parent.", "", "To review every active subtask of a parent together, run", "`agent-ops batch --parent <task-id> --yes` instead of one review per", "subtask. It needs what review needs: start it through the trusted outer", "host runner as its first and only invocation, with the same Codex", "elevation, and keep `--output <file>` instead of a shell redirect. It", "verifies, one task at a time, each task that lacks fresh evidence, and", "reviews a few tasks at once. Do not commit or edit the worktree while it", "runs: a moved HEAD voids every verify and review in the batch.", "", "Delegate a subtask whose scope spans unknown files, broad scans, or an", "independent workstream instead of doing it inline. Name the bounded", "scope, the artifact to return, and the acceptance criteria it serves.", subagentInstruction(descriptor), "Read every returned artifact back yourself: integration, verification,", "and review stay with the coordinator.", "", "One writer at a time. `agent-ops verify` and `agent-ops review`", "fingerprint the working tree and report UNKNOWN when anything else", "writes it during the run, so delegate edits to one subagent at a time,", "or give each writer its own Git worktree and merge before verifying.", "Read-only delegation has no such limit and may run in parallel.", "", "Parallel conversations in one repository each need their own worktree.", "When `.agent-ops/config.json` sets `worktree.mode` to `auto`, run", "`agent-ops task create` from the main checkout before your first edit:", "it creates this session's worktree and puts the task there (`agent-ops", "worktree add <name> --session <session-id>` does the same without a", "task). Work only inside the printed path", "(Claude Code: EnterWorktree with that path; otherwise use it as every", "command's working directory). Commit, then run verify and review for", "each task with the printed `--base`; do not run `task complete` in the", "worktree. Leave it (Claude Code: ExitWorktree with action keep) and run", "`agent-ops worktree finish <name>` from the main checkout: it completes", "every task in the worktree, subtasks first, then merges. A finish that", "reports an incomplete task names it; verify or review that task again", "in the worktree and finish again. A finish that reports a conflict is", "resolved once, as a new task whose criteria cover both intents; if its", "verification or review fails, reset as instructed and report.", "");
325
+ lines.push("Split a change that exceeds five acceptance criteria into subtasks:", "`agent-ops task create --parent <task-id>` records one, and", "`agent-ops task status --parent <task-id>` lists them. Each subtask", "carries its own criteria and verification; final review covers all", "tasks together when their worktrees are integrated;", "completing one never completes its parent.", "", "To review every active subtask of a parent together, run", "`agent-ops batch --parent <task-id> --yes` instead of one review per", "subtask. It needs what review needs: start it through the trusted outer", "host runner as its first and only invocation, with the same Codex", "elevation, and keep `--output <file>` instead of a shell redirect. It", "verifies, one task at a time, each task that lacks fresh evidence, and", "reviews a few tasks at once. Do not commit or edit the worktree while it", "runs: a moved HEAD voids every verify and review in the batch.", "For subagents in separate worktrees, use `task advance` instead.", "", "Delegate a subtask whose scope spans unknown files, broad scans, or an", "independent workstream instead of doing it inline. Name the bounded", "scope, the artifact to return, and the acceptance criteria it serves.", subagentInstruction(descriptor), "Read every returned artifact back yourself: integration, verification,", "and review stay with the coordinator.", "", "One writer at a time. `agent-ops verify` and `agent-ops review`", "fingerprint the working tree and report UNKNOWN when anything else", "writes it during the run, so delegate edits to one subagent at a time,", "or give each writer its own Git worktree and merge before verifying.", "Read-only delegation has no such limit and may run in parallel.", "", "Parallel conversations in one repository each need their own worktree.", "When `.agent-ops/config.json` sets `worktree.mode` to `auto`, run", "`agent-ops task create` from the main checkout before your first edit:", "it creates this session's worktree and puts the task there (`agent-ops", "worktree add <name> --session <session-id>` does the same without a", "task). Work only inside the printed path", "(Claude Code: EnterWorktree with that path; otherwise use it as every", "command's working directory). Commit, then run verify and review for", "each task with the printed `--base`; do not run `task complete` in the", "worktree. Leave it (Claude Code: ExitWorktree with action keep) and run", "`agent-ops worktree finish <name>` from the main checkout: it completes", "every task in the worktree, subtasks first, then merges. A finish that", "reports an incomplete task names it; verify or review that task again", "in the worktree and finish again. A finish that reports a conflict is", "resolved once, as a new task whose criteria cover both intents; if its", "verification or review fails, reset as instructed and report.", "", "For a session with writing subagents, record each child task's complete", "modification intent with `task create --intent <text>` before editing.", "Each child commits and runs local `verify --task` in its own worktree.", "From the main checkout run `agent-ops task advance --task <parent-id>", "--session <session-id> --yes` through the trusted outer host runner,", "unsetting `CODEX_SANDBOX_NETWORK_DISABLED` on Codex. Advance integrates", "child commits and intent into the coordinator worktree, verifies every", "task on the final candidate, then runs one two-round tree review.", "Only `NOT_RUN / scope-too-large` falls back to per-task two-round review;", "a substantive FAIL must be fixed. After PASS, advance writes a local", "receipt under the main checkout's Git common directory and calls finish.", "If the target moves, it retries the final gate once. An unresolved", "failure preserves worktrees and cannot be reported as finished.", "");
326
326
  }
327
327
  if (context.capabilities.includes("lifecycle-summary")) {
328
328
  lines.push("Advisory lifecycle summaries and local logs are informational. Advisory", "failures must remain fail-open and cannot become verification evidence.", "");
@@ -24,11 +24,13 @@ export const CLAUDE_PREAUTH_ALLOW = [
24
24
  "Bash(npx lint-staged *)"
25
25
  ];
26
26
  /**
27
- * review and batch talk to the reviewer's API, which the sandbox withholds;
27
+ * review, batch and task advance talk to the reviewer's API, which the sandbox withholds;
28
28
  * worktree commit and lint-staged write the main .git, which a worktree's
29
29
  * sandbox cannot.
30
30
  */
31
31
  export const CLAUDE_PREAUTH_UNSANDBOXED = [
32
+ "agent-ops task advance",
33
+ "agent-ops task advance *",
32
34
  "agent-ops review",
33
35
  "agent-ops review *",
34
36
  "agent-ops batch",
@@ -110,6 +112,13 @@ export const CODEX_RULES_CONTENT = `${[
110
112
  match: `${env} batch --parent task-x --yes --output batch.json`,
111
113
  notMatch: `${env} trust grant --scope project --yes`
112
114
  }),
115
+ rule({
116
+ withHost,
117
+ command: ["task", "advance"],
118
+ justification: "agent-ops task advance runs the final reviewer chain and finish outside the sandbox.",
119
+ match: `${env} task advance --task task-x --session session-x --yes`,
120
+ notMatch: `${env} trust grant --scope project --yes`
121
+ }),
113
122
  rule({
114
123
  withHost,
115
124
  command: ["doctor", "--check-auth"],
@@ -5,6 +5,8 @@ import { AgentOpsError, resolveContainedPath } from "../fs/paths.js";
5
5
  import { currentGateFingerprint } from "../hooks/completion-gate.js";
6
6
  import { collectChangeSurface } from "../verify/change-surface.js";
7
7
  import { aggregateVerificationStatus, executeConfiguredCommand } from "../verify/command-executor.js";
8
+ import { prepareFinishReceipt } from "./receipt.js";
9
+ import { listWorktrees } from "./manage.js";
8
10
  import { assertWorktreeName, NOTES_REF, noteSessionLine, parseWorktreeListPorcelain, readWorktreeRecord, removeCheckout, resolveCheckouts, worktreePath } from "./service.js";
9
11
  const LOCK_NAME = "agent-ops-finish.lock";
10
12
  const LOCK_POLL_MS = 2_000;
@@ -184,6 +186,7 @@ function noteText(record, tasks) {
184
186
  const indent = " ".repeat(depth);
185
187
  return [
186
188
  `${indent}agent-ops task ${task.task.id}: ${task.task.title}`,
189
+ ...(task.task.intent === undefined ? [] : [`${indent}intent: ${task.task.intent}`]),
187
190
  `${indent}criteria:`,
188
191
  ...task.task.criteria.map(({ id, description }) => `${indent}- ${id}: ${description}`)
189
192
  ];
@@ -206,6 +209,30 @@ export async function finishWorktree(deps, options) {
206
209
  throw finishError("WORKTREE_NOT_FOUND", `No agent-ops worktree named ${name}; see agent-ops worktree list.`);
207
210
  }
208
211
  return await withFinishLock(deps, commonDir, async () => {
212
+ const sessionChildren = (await listWorktrees(deps, mainRoot))
213
+ .map(({ record: child }) => child)
214
+ .filter((child) => child.sessionId === record.sessionId && child.agentId !== undefined);
215
+ if (options.finalProof === undefined && sessionChildren.length > 0) {
216
+ throw finishError("WORKTREE_CHILDREN_REQUIRE_ADVANCE", "This session has child worktrees; run task advance for integrated final evidence before finish.");
217
+ }
218
+ if (options.finalProof !== undefined &&
219
+ (sessionChildren.length !== options.finalProof.children.length ||
220
+ sessionChildren.some((child) => !options.finalProof.children.some(({ name }) => name === child.name)))) {
221
+ throw finishError("WORKTREE_CHILDREN_CHANGED", "The child worktree set changed after final review.");
222
+ }
223
+ const assertPinnedChild = async (child) => {
224
+ const expected = options.finalProof?.children.find(({ name }) => name === child.name)?.commit;
225
+ const head = await git(deps, child.path, ["rev-parse", "HEAD"], "WORKTREE_CHILDREN_CHANGED", `Cannot read child ${child.name}.`);
226
+ if (expected === undefined || head !== expected ||
227
+ (await collectChangeSurface(runner(deps, child.path))).paths.length > 0) {
228
+ throw finishError("WORKTREE_CHILDREN_CHANGED", `Child ${child.name} changed after integration; preserve its worktree.`);
229
+ }
230
+ return head;
231
+ };
232
+ if (options.finalProof !== undefined) {
233
+ for (const child of sessionChildren)
234
+ await assertPinnedChild(child);
235
+ }
209
236
  const mainRunner = runner(deps, mainRoot);
210
237
  const worktreeRunner = runner(deps, record.path);
211
238
  const onBranch = (await deps.git(mainRoot, ["symbolic-ref", "--short", "-q", "HEAD"])).stdout.trim();
@@ -231,37 +258,60 @@ export async function finishWorktree(deps, options) {
231
258
  throw finishError("WORKTREE_DIRTY", `Commit or discard the uncommitted changes in ${record.path} first.`);
232
259
  }
233
260
  const target = await git(deps, mainRoot, ["rev-parse", "--verify", `${record.targetBranch}^{commit}`], "WORKTREE_TARGET_MISSING", `${record.targetBranch} does not resolve to a commit.`);
261
+ if (options.finalProof !== undefined && target !== options.finalProof.target) {
262
+ throw finishError("WORKTREE_TARGET_MOVED", "The target branch moved after final review; rerun the final evidence gate.");
263
+ }
234
264
  const ahead = Number(await git(deps, record.path, ["rev-list", "--count", `${target}..HEAD`], "WORKTREE_LOG_FAILED", "Git could not compare the branch with its target."));
235
265
  if (ahead === 0) {
236
266
  throw finishError("WORKTREE_NOTHING_TO_MERGE", `${record.branch} has no commits beyond ${record.targetBranch}; remove the worktree instead.`);
237
267
  }
238
268
  const worktreeConfig = await deps.loadConfig(record.path);
239
- // The whole branch merges, so every task in the worktree completes first,
240
- // subtasks before their parents, each over the range its review covered.
241
- // Before any rebase: rebasing rewrites the commits those fingerprints name.
269
+ const headBeforeCompletion = await git(deps, record.path, ["rev-parse", "HEAD"], "WORKTREE_LOG_FAILED", "Git could not read HEAD.");
270
+ if (options.finalProof !== undefined) {
271
+ if (headBeforeCompletion !== options.finalProof.head) {
272
+ throw finishError("WORKTREE_SOURCE_MOVED", "The candidate changed after final review.");
273
+ }
274
+ if ((await deps.git(record.path, ["merge-base", "--is-ancestor", target, "HEAD"])).exitCode !== 0) {
275
+ throw finishError("WORKTREE_TARGET_MOVED", "The final candidate is not based on the target; rerun the final evidence gate.");
276
+ }
277
+ }
278
+ // Direct finish completes before merge. A final-proof finish checks all
279
+ // evidence first, then completes only after the target's fast-forward:
280
+ // a target that moves before that point can still be re-reviewed once.
242
281
  const tree = taskTree((await deps.tasks(record.path).list()).filter(({ status }) => status !== "archived"));
243
282
  if (tree.length === 0) {
244
283
  throw finishError("WORKTREE_TASK_INCOMPLETE", "The worktree has no task; create one, verify and review it before finishing.");
245
284
  }
246
- for (const { task } of [...tree].sort((left, right) => right.depth - left.depth)) {
247
- if (task.status === "complete")
248
- continue;
249
- const base = task.reviewBase ?? record.base;
250
- try {
251
- await deps.tasks(record.path, base).complete(task.task.id, {});
285
+ const completeTasks = async () => {
286
+ for (const { task } of [...tree].sort((left, right) => right.depth - left.depth)) {
287
+ if (task.status === "complete")
288
+ continue;
289
+ const base = options.finalProof === undefined ? task.reviewBase ?? record.base : target;
290
+ try {
291
+ await deps.tasks(record.path, base).complete(task.task.id, {});
292
+ }
293
+ catch (error) {
294
+ throw finishError("WORKTREE_TASK_INCOMPLETE", `Task ${task.task.id} (${task.task.title}) could not be completed against ${base}: ${error instanceof Error ? error.message : String(error)} ` +
295
+ "A task another session left here is not this session's to archive: its owner finishes or archives it, and finish never does.");
296
+ }
252
297
  }
253
- catch (error) {
254
- throw finishError("WORKTREE_TASK_INCOMPLETE", `Task ${task.task.id} (${task.task.title}) could not be completed against ${base}: ${error instanceof Error ? error.message : String(error)} ` +
255
- "A task another session left here is not this session's to archive: its owner finishes or archives it, and finish never does.");
298
+ };
299
+ const validateGate = async () => {
300
+ const worktreeGate = await deps.gate(record.path, worktreeConfig);
301
+ const problem = await worktreeGate.validate(record.sessionId);
302
+ if (problem !== null) {
303
+ throw finishError("WORKTREE_TASK_INCOMPLETE", `The worktree's task is not ready to merge (${problem.code}). ${problem.remedy ?? ""}`.trim());
256
304
  }
257
- }
258
- const worktreeGate = await deps.gate(record.path, worktreeConfig);
259
- const problem = await worktreeGate.validate(record.sessionId);
260
- if (problem !== null) {
261
- throw finishError("WORKTREE_TASK_INCOMPLETE", `The worktree's task is not ready to merge (${problem.code}). ${problem.remedy ?? ""}`.trim());
305
+ };
306
+ if (options.finalProof === undefined) {
307
+ await completeTasks();
308
+ await validateGate();
262
309
  }
263
310
  const head = await git(deps, record.path, ["rev-parse", "HEAD"], "WORKTREE_LOG_FAILED", "Git could not read HEAD.");
264
311
  const fastForward = (await deps.git(record.path, ["merge-base", "--is-ancestor", target, "HEAD"])).exitCode === 0;
312
+ if (options.finalProof !== undefined && !fastForward) {
313
+ throw finishError("WORKTREE_TARGET_MOVED", "The target branch moved after final review.");
314
+ }
265
315
  if (!fastForward) {
266
316
  const before = await patchHash(deps, record.path, target);
267
317
  const rebase = await deps.git(record.path, ["rebase", "--no-autostash", target]);
@@ -287,19 +337,64 @@ export async function finishWorktree(deps, options) {
287
337
  throw finishError("WORKTREE_REVERIFY_FAILED", `After rebasing onto ${target}, verification failed: ${failed.join(", ")}. The branch was reset; fix it as a new task and finish again.`);
288
338
  }
289
339
  }
340
+ const receiptOptions = options.finalProof === undefined ? undefined : {
341
+ commonDir,
342
+ record,
343
+ candidateHead: head,
344
+ targetCommit: target,
345
+ expectedFingerprint: options.finalProof.sourceFingerprint,
346
+ children: options.finalProof.children,
347
+ config: worktreeConfig,
348
+ gitRunner: worktreeRunner
349
+ };
350
+ let receipt = receiptOptions === undefined ? undefined : await prepareFinishReceipt({
351
+ ...receiptOptions,
352
+ tasks: await deps.tasks(record.path).list().then((tasks) => tasks.filter(({ status }) => status !== "archived"))
353
+ });
290
354
  const mainConfig = await deps.loadConfig(mainRoot);
291
355
  const gateEnabled = mainConfig.features.completionGate.enabled;
292
356
  const before = gateEnabled ? await currentGateFingerprint(mainRoot, mainRunner) : "";
293
357
  let mergedHead;
294
358
  if (onTarget) {
295
- await git(deps, mainRoot, ["merge", "--ff-only", record.branch], "WORKTREE_MERGE_FAILED", `Git could not fast-forward ${record.targetBranch}.`);
359
+ if (options.finalProof !== undefined &&
360
+ await git(deps, mainRoot, ["rev-parse", "HEAD"], "WORKTREE_LOG_FAILED", "Git could not read HEAD.") !== target) {
361
+ throw finishError("WORKTREE_TARGET_MOVED", "The target changed after the final proof was prepared.");
362
+ }
363
+ const merge = await deps.git(mainRoot, ["merge", "--ff-only", record.branch]);
364
+ if (merge.exitCode !== 0) {
365
+ if (options.finalProof !== undefined &&
366
+ await git(deps, mainRoot, ["rev-parse", "HEAD"], "WORKTREE_LOG_FAILED", "Git could not read HEAD.") !== target) {
367
+ throw finishError("WORKTREE_TARGET_MOVED", "The target moved during finish; rerun the final evidence gate.");
368
+ }
369
+ throw finishError("WORKTREE_MERGE_FAILED", `Git could not fast-forward ${record.targetBranch}. ${merge.stderr.trim()}`);
370
+ }
296
371
  mergedHead = await git(deps, mainRoot, ["rev-parse", "HEAD"], "WORKTREE_LOG_FAILED", "Git could not read HEAD.");
297
372
  }
298
373
  else {
299
374
  // Compare-and-swap on the target's value read above: a target that moved
300
375
  // since fails here instead of being overwritten.
301
376
  mergedHead = await git(deps, mainRoot, ["rev-parse", "--verify", `${record.branch}^{commit}`], "WORKTREE_LOG_FAILED", `Git could not read ${record.branch}.`);
302
- await git(deps, mainRoot, ["update-ref", "-m", `agent-ops finish ${record.branch}`, `refs/heads/${record.targetBranch}`, mergedHead, target], "WORKTREE_MERGE_FAILED", `Git could not fast-forward ${record.targetBranch}.`);
377
+ const update = await deps.git(mainRoot, ["update-ref", "-m", `agent-ops finish ${record.branch}`, `refs/heads/${record.targetBranch}`, mergedHead, target]);
378
+ if (update.exitCode !== 0) {
379
+ if (options.finalProof !== undefined &&
380
+ await git(deps, mainRoot, ["rev-parse", `${record.targetBranch}^{commit}`], "WORKTREE_LOG_FAILED", "Git could not read the target.") !== target) {
381
+ throw finishError("WORKTREE_TARGET_MOVED", "The target moved during finish; rerun the final evidence gate.");
382
+ }
383
+ throw finishError("WORKTREE_MERGE_FAILED", `Git could not fast-forward ${record.targetBranch}. ${update.stderr.trim()}`);
384
+ }
385
+ }
386
+ if (receiptOptions !== undefined) {
387
+ try {
388
+ await completeTasks();
389
+ await validateGate();
390
+ receipt = await prepareFinishReceipt({
391
+ ...receiptOptions,
392
+ tasks: await deps.tasks(record.path).list().then((tasks) => tasks.filter(({ status }) => status !== "archived"))
393
+ });
394
+ }
395
+ catch (error) {
396
+ throw finishError("WORKTREE_FINISH_PARTIAL", `Code merged at ${mergedHead}; final task state or receipt failed: ${error instanceof Error ? error.message : String(error)}`);
397
+ }
303
398
  }
304
399
  // The merge happened; everything below is cleanup, reported, never undone.
305
400
  const warnings = [];
@@ -308,10 +403,15 @@ export async function finishWorktree(deps, options) {
308
403
  await step();
309
404
  }
310
405
  catch (error) {
311
- warnings.push(`${label}: ${error instanceof Error ? error.message : String(error)}`);
406
+ const message = `${label}: ${error instanceof Error ? error.message : String(error)}`;
407
+ if (options.finalProof !== undefined) {
408
+ throw finishError("WORKTREE_FINISH_PARTIAL", `Code merged at ${mergedHead} with receipt ${receipt?.path}; ${message}`);
409
+ }
410
+ warnings.push(message);
312
411
  }
313
412
  };
314
- await attempt("note", async () => await git(deps, mainRoot, ["notes", `--ref=${NOTES_REF}`, "add", "-f", "-m", noteText(record, tree), mergedHead], "WORKTREE_NOTE_FAILED", "Git could not write the agent-ops note."));
413
+ const finishNote = `${noteText(record, tree)}${receipt === undefined ? "" : `\nreceipt: ${receipt.path}\nreceipt-sha256: ${receipt.digest}`}`;
414
+ await attempt("note", async () => await git(deps, mainRoot, ["notes", `--ref=${NOTES_REF}`, "add", "-f", "-m", finishNote, mergedHead], "WORKTREE_NOTE_FAILED", "Git could not write the agent-ops note."));
315
415
  if (gateEnabled) {
316
416
  await attempt("gate", async () => {
317
417
  const mainGate = await deps.gate(mainRoot, mainConfig);
@@ -321,10 +421,25 @@ export async function finishWorktree(deps, options) {
321
421
  // this session began would leave it reading the merge as its own change.
322
422
  // Only this worktree leaves the session; its other roots stay covered.
323
423
  await mainGate.afterFinish(record.sessionId, record.path, after);
424
+ if (options.finalProof !== undefined) {
425
+ for (const child of sessionChildren) {
426
+ await mainGate.afterFinish(record.sessionId, child.path, after);
427
+ }
428
+ }
324
429
  });
325
430
  }
326
431
  await attempt("trust", async () => await deps.trust.revoke(record.path, worktreeConfig));
327
- await attempt("remove", async () => await removeCheckout(deps, record, true));
328
- return { record, mergedHead, rebased: !fastForward, taskIds: tree.map(({ task }) => task.task.id), warnings };
432
+ if (options.finalProof !== undefined) {
433
+ for (const child of sessionChildren) {
434
+ await attempt(`child ${child.name}`, async () => {
435
+ const pinnedHead = await assertPinnedChild(child);
436
+ await deps.trust.revoke(child.path, await deps.loadConfig(child.path));
437
+ await removeCheckout(deps, child, false, pinnedHead);
438
+ });
439
+ }
440
+ }
441
+ await attempt("remove", async () => await removeCheckout(deps, record, options.finalProof === undefined, options.finalProof?.head));
442
+ return { record, mergedHead, rebased: !fastForward, taskIds: tree.map(({ task }) => task.task.id), warnings,
443
+ ...(receipt === undefined ? {} : { receipt: receipt.path }) };
329
444
  });
330
445
  }