@kylecheng3146/agent-ops 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/README.md +64 -0
  2. package/dist/packages/cli/src/args.js +214 -10
  3. package/dist/packages/cli/src/bin.js +77 -17
  4. package/dist/packages/cli/src/cli.js +42 -10
  5. package/dist/packages/cli/src/commands/advance.js +173 -0
  6. package/dist/packages/cli/src/commands/review-show.js +189 -0
  7. package/dist/packages/cli/src/commands/review.js +201 -34
  8. package/dist/packages/cli/src/commands/run.js +111 -0
  9. package/dist/packages/cli/src/commands/task.js +62 -17
  10. package/dist/packages/cli/src/commands/verify.js +1 -0
  11. package/dist/packages/cli/src/context.js +63 -1
  12. package/dist/packages/cli/src/hook-process.js +31 -1
  13. package/dist/packages/cli/src/owned-run-step.js +65 -0
  14. package/dist/packages/cli/src/parallel-deps.js +18 -5
  15. package/dist/packages/cli/src/run-deps.js +314 -0
  16. package/dist/packages/cli/src/run-entry.js +943 -0
  17. package/dist/packages/cli/src/run-setup-entry.js +32 -0
  18. package/dist/packages/cli/src/run-step-entry.js +58 -0
  19. package/dist/runtime/src/config/explain.js +6 -0
  20. package/dist/runtime/src/config/hash.js +1 -1
  21. package/dist/runtime/src/config/merge.js +9 -1
  22. package/dist/runtime/src/contracts.js +2 -2
  23. package/dist/runtime/src/hooks/completion-gate.js +8 -2
  24. package/dist/runtime/src/install/doctor.js +2 -0
  25. package/dist/runtime/src/install/harness.js +1 -1
  26. package/dist/runtime/src/install/preauth.js +10 -1
  27. package/dist/runtime/src/parallel/finish.js +217 -39
  28. package/dist/runtime/src/parallel/guard.js +5 -1
  29. package/dist/runtime/src/parallel/integrate.js +416 -0
  30. package/dist/runtime/src/parallel/manage.js +4 -1
  31. package/dist/runtime/src/parallel/receipt.js +153 -0
  32. package/dist/runtime/src/parallel/service.js +45 -3
  33. package/dist/runtime/src/review/attestation.js +60 -11
  34. package/dist/runtime/src/review/execute.js +24 -1
  35. package/dist/runtime/src/review/packet.js +35 -1
  36. package/dist/runtime/src/review/render.js +71 -0
  37. package/dist/runtime/src/review/runner.js +12 -1
  38. package/dist/runtime/src/review/scope.js +18 -1
  39. package/dist/runtime/src/review/slots.js +35 -10
  40. package/dist/runtime/src/run/authorization.js +60 -0
  41. package/dist/runtime/src/run/budget.js +30 -0
  42. package/dist/runtime/src/run/controls.js +139 -0
  43. package/dist/runtime/src/run/convergence.js +177 -0
  44. package/dist/runtime/src/run/hosts/claude.js +180 -0
  45. package/dist/runtime/src/run/hosts/codex.js +279 -0
  46. package/dist/runtime/src/run/hosts/index.js +4 -0
  47. package/dist/runtime/src/run/hosts/protocol.js +254 -0
  48. package/dist/runtime/src/run/hosts/types.js +1 -0
  49. package/dist/runtime/src/run/hosts/util.js +136 -0
  50. package/dist/runtime/src/run/integration.js +204 -0
  51. package/dist/runtime/src/run/macOS.js +189 -0
  52. package/dist/runtime/src/run/ownership.js +22 -0
  53. package/dist/runtime/src/run/planning.js +25 -0
  54. package/dist/runtime/src/run/policy-transition.js +1076 -0
  55. package/dist/runtime/src/run/policy.js +176 -0
  56. package/dist/runtime/src/run/ratchet.js +71 -0
  57. package/dist/runtime/src/run/scheduler.js +260 -0
  58. package/dist/runtime/src/run/service.js +425 -0
  59. package/dist/runtime/src/run/supervisor.js +635 -0
  60. package/dist/runtime/src/run/transport.js +159 -0
  61. package/dist/runtime/src/run/types.js +1 -0
  62. package/dist/runtime/src/run/usage.js +81 -0
  63. package/dist/runtime/src/run/verification.js +64 -0
  64. package/dist/runtime/src/run/worker-gate.js +48 -0
  65. package/dist/runtime/src/schema/acceptance.js +91 -0
  66. package/dist/runtime/src/schema/validate.js +72 -7
  67. package/dist/runtime/src/task/completion.js +26 -6
  68. package/dist/runtime/src/task/contract.js +17 -0
  69. package/dist/runtime/src/task/pin-finding.js +81 -0
  70. package/dist/runtime/src/task/render.js +3 -0
  71. package/dist/runtime/src/task/service.js +361 -10
  72. package/dist/runtime/src/task/store.js +57 -7
  73. package/dist/runtime/src/verify/acceptance-coverage.js +85 -0
  74. package/dist/runtime/src/verify/acceptance-protocol.js +252 -0
  75. package/dist/runtime/src/verify/acceptance-replay.js +239 -0
  76. package/dist/runtime/src/verify/adapters/index.js +5 -0
  77. package/dist/runtime/src/verify/adapters/jest.js +142 -0
  78. package/dist/runtime/src/verify/adapters/node-reporter.js +20 -0
  79. package/dist/runtime/src/verify/adapters/node.js +254 -0
  80. package/dist/runtime/src/verify/adapters/pytest.js +188 -0
  81. package/dist/runtime/src/verify/adapters/rust.js +84 -0
  82. package/dist/runtime/src/verify/adapters/shared.js +82 -0
  83. package/dist/runtime/src/verify/adapters/vitest.js +218 -0
  84. package/dist/runtime/src/verify/evidence.js +5 -2
  85. package/dist/runtime/src/verify/service.js +24 -8
  86. package/dist/runtime/src/verify/source-fingerprint.js +1 -1
  87. package/dist/runtime/src/verify/spawn.js +13 -2
  88. package/docs/en/guides/configuration.md +10 -0
  89. package/docs/en/spec/acceptance-and-evidence.md +58 -0
  90. package/docs/en/spec/delegation.md +4 -4
  91. package/docs/en/spec/loop-engineering.md +49 -0
  92. package/docs/en/spec/review.md +24 -2
  93. package/docs/zh-TW/guides/configuration.md +9 -0
  94. package/docs/zh-TW/spec/acceptance-and-evidence.md +31 -1
  95. package/docs/zh-TW/spec/delegation.md +4 -4
  96. package/docs/zh-TW/spec/loop-engineering.md +22 -0
  97. package/docs/zh-TW/spec/review.md +24 -2
  98. package/package.json +1 -1
  99. package/schemas/config.schema.json +221 -19
  100. package/schemas/evidence.schema.json +129 -6
  101. package/schemas/task.schema.json +268 -6
  102. package/templates/acceptance/agent_ops_acceptance.rs +36 -0
  103. package/templates/acceptance/agent_ops_pytest.py +46 -0
@@ -0,0 +1,32 @@
1
+ import { FileRunRepository } from "../../../runtime/src/run/service.js";
2
+ import { readRunPolicy, runRuntimeHash } from "../../../runtime/src/run/policy.js";
3
+ import { activeWallTimeMs } from "../../../runtime/src/run/scheduler.js";
4
+ import { runVerificationCommand, NodeVerificationProcessRunner } from "../../../runtime/src/verify/spawn.js";
5
+ import { redactSecrets } from "../../../runtime/src/security/redact.js";
6
+ import { join } from "node:path";
7
+ import { realpath } from "node:fs/promises";
8
+ import { execFileSync } from "node:child_process";
9
+ // This entry is released by run-step-entry only after its process group is registered.
10
+ const [commonDir, runId, root, index, expectedPolicy] = process.argv.slice(2);
11
+ if ([commonDir, runId, root, index, expectedPolicy].some(v => v === undefined))
12
+ throw new Error("Incomplete run setup identity.");
13
+ const repository = new FileRunRepository(join(commonDir, "agent-ops/runs"), commonDir);
14
+ const state = await repository.read(runId);
15
+ const group = process.env.AGENT_OPS_RUN_PROOF_PID;
16
+ const actualCommon = execFileSync("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd: root, encoding: "utf8" }).trim();
17
+ if (state === null || state.status !== "active" || state.disableRestart || state.proofProcess?.processId !== Number(group) ||
18
+ state.policyBinding?.artifactDigest !== expectedPolicy || await realpath(actualCommon) !== await realpath(state.commonDir))
19
+ throw new Error("Stale run setup authorization.");
20
+ const policy = await readRunPolicy(state, await runRuntimeHash());
21
+ const step = /^(?:0|[1-9][0-9]*)$/u.test(index) ? policy?.config.worktree?.setup?.[Number(index)] : undefined;
22
+ if (step === undefined)
23
+ throw new Error("Setup must select an authorized fixed policy command.");
24
+ const remaining = state.budget.limitMs - activeWallTimeMs(state.budget.activeIntervals, Date.now());
25
+ if (remaining <= 0)
26
+ throw new Error("Run budget is exhausted.");
27
+ const result = await runVerificationCommand({ id: "run-setup", ...step, cwd: ".", required: true,
28
+ timeoutMs: Math.min(step.timeoutMs ?? 600000, remaining), evidence: { kind: "exit-code" } }, {
29
+ cwd: root, runner: new NodeVerificationProcessRunner({ processGroupId: Number(group) })
30
+ });
31
+ process.stdout.write(JSON.stringify({ exitCode: result.status === "PASS" ? 0 : result.exitCode ?? 1,
32
+ output: redactSecrets(result.stdout + result.stderr) }) + "\n");
@@ -0,0 +1,58 @@
1
+ import { spawn } from "node:child_process";
2
+ import { fileURLToPath } from "node:url";
3
+ /** Dormant process group: the supervisor records this PID before releasing the CLI. */
4
+ const cli = fileURLToPath(new URL("./bin.js", import.meta.url));
5
+ const setup = fileURLToPath(new URL("./run-setup-entry.js", import.meta.url));
6
+ const target = process.argv[2];
7
+ if (target !== cli && target !== setup)
8
+ throw new Error("Run proof entry must use its own installed execution entry.");
9
+ let input = "";
10
+ let started = false;
11
+ process.stdin.setEncoding("utf8");
12
+ process.stdin.on("data", chunk => {
13
+ if (started)
14
+ return;
15
+ input += chunk;
16
+ if (input.length > 16 || (input.includes("\n") && input !== "start\n"))
17
+ process.exit(1);
18
+ if (input !== "start\n")
19
+ return;
20
+ started = true;
21
+ // Keep this identity alive until the CLI finishes its signal cleanup.
22
+ process.on("SIGTERM", () => { });
23
+ process.on("SIGINT", () => { });
24
+ process.stdin.pause();
25
+ process.env.AGENT_OPS_RUN_PROOF_PID = String(process.pid);
26
+ const child = spawn(process.execPath, [target, ...process.argv.slice(3)], { env: process.env, stdio: ["ignore", "inherit", "inherit"] });
27
+ child.once("error", () => process.exit(1));
28
+ child.once("close", (code, signal) => {
29
+ const deadline = Date.now() + 3000;
30
+ const reconcile = () => {
31
+ const listing = spawn("ps", ["-axo", "pid=,pgid="], { detached: true, stdio: ["ignore", "pipe", "ignore"] });
32
+ let output = "";
33
+ listing.stdout.on("data", chunk => { output += chunk.toString(); });
34
+ listing.once("error", () => process.kill(-process.pid, "SIGKILL"));
35
+ listing.once("close", status => {
36
+ if (status !== 0)
37
+ process.kill(-process.pid, "SIGKILL");
38
+ const remaining = output.split("\n").some(line => {
39
+ const [pid, group] = line.trim().split(/\s+/u).map(Number);
40
+ return group === process.pid && pid !== process.pid;
41
+ });
42
+ if (remaining && Date.now() < deadline) {
43
+ setTimeout(reconcile, 50);
44
+ return;
45
+ }
46
+ if (remaining)
47
+ process.kill(-process.pid, "SIGKILL");
48
+ else if (signal !== null)
49
+ process.exit(1);
50
+ else
51
+ process.exit(code ?? 1);
52
+ });
53
+ };
54
+ reconcile();
55
+ });
56
+ });
57
+ process.stdin.on("end", () => { if (!started)
58
+ process.exit(1); });
@@ -29,6 +29,12 @@ export function explainConfig(merged) {
29
29
  source: entry.source,
30
30
  sourcePath: entry.sourcePath
31
31
  })),
32
+ ...(merged.provenance.acceptanceRunners === undefined ? {} : {
33
+ acceptanceRunners: merged.provenance.acceptanceRunners.map(entry => ({
34
+ id: entry.value.id, adapter: entry.value.adapter,
35
+ source: entry.source, sourcePath: entry.sourcePath
36
+ }))
37
+ }),
32
38
  pathMappings: merged.provenance.pathMappings.map((entry) => ({
33
39
  path: entry.value.path,
34
40
  verifierIds: [...entry.value.verifierIds],
@@ -1,6 +1,6 @@
1
1
  import { sha256 } from "../fs/hash.js";
2
2
  import { AgentOpsError } from "../fs/paths.js";
3
- function canonicalJson(value) {
3
+ export function canonicalJson(value) {
4
4
  if (value === null ||
5
5
  typeof value === "boolean" ||
6
6
  typeof value === "number" ||
@@ -105,6 +105,7 @@ export function mergeConfigLayers(inputLayers) {
105
105
  const layers = [...inputLayers].sort((left, right) => SOURCE_RANK[left.source] - SOURCE_RANK[right.source]);
106
106
  const profiles = new Map();
107
107
  const commands = new Map();
108
+ const acceptanceRunners = new Map();
108
109
  const mappings = new Map();
109
110
  const exceptions = new Map();
110
111
  const reviewRoles = new Map();
@@ -128,6 +129,9 @@ export function mergeConfigLayers(inputLayers) {
128
129
  }
129
130
  commands.set(command.id, effective(command, layer));
130
131
  }
132
+ for (const runner of layer.config.verification.acceptanceRunners ?? []) {
133
+ acceptanceRunners.set(runner.id, effective(runner, layer));
134
+ }
131
135
  for (const mapping of layer.config.pathMappings) {
132
136
  const key = mappingKey(mapping);
133
137
  if (layer.source === "project") {
@@ -162,6 +166,7 @@ export function mergeConfigLayers(inputLayers) {
162
166
  features,
163
167
  profiles: [...profiles.values()],
164
168
  verificationCommands: [...commands.values()],
169
+ ...(acceptanceRunners.size === 0 ? {} : { acceptanceRunners: [...acceptanceRunners.values()] }),
165
170
  pathMappings: [...mappings.values()],
166
171
  securityExceptions: [...exceptions.values()],
167
172
  reviewRoles: [...reviewRoles.values()],
@@ -171,7 +176,10 @@ export function mergeConfigLayers(inputLayers) {
171
176
  schemaVersion: schemaVersion.value,
172
177
  profiles: provenance.profiles.map(({ value }) => value),
173
178
  verification: {
174
- commands: provenance.verificationCommands.map(({ value }) => value)
179
+ commands: provenance.verificationCommands.map(({ value }) => value),
180
+ ...(provenance.acceptanceRunners === undefined ? {} : {
181
+ acceptanceRunners: provenance.acceptanceRunners.map(({ value }) => value)
182
+ })
175
183
  },
176
184
  features: provenance.features.value,
177
185
  pathMappings: provenance.pathMappings.map(({ value }) => value),
@@ -1,6 +1,6 @@
1
1
  export const CONFIG_SCHEMA_VERSION = 3;
2
- export const TASK_SCHEMA_VERSION = 1;
3
- export const EVIDENCE_SCHEMA_VERSION = 3;
2
+ export const TASK_SCHEMA_VERSION = 2;
3
+ export const EVIDENCE_SCHEMA_VERSION = 4;
4
4
  /** @deprecated Use the document-specific schema version constants. */
5
5
  export const SCHEMA_VERSION = CONFIG_SCHEMA_VERSION;
6
6
  /**
@@ -6,6 +6,7 @@ import { readPrivateFile, withPrivateFileLock, writePrivateFile } from "../secur
6
6
  import { checkTaskCompletionEvidence, findIncompleteSubtask } from "../task/completion.js";
7
7
  import { collectBaseChangePaths, collectChangeSurface } from "../verify/change-surface.js";
8
8
  import { calculateSourceFingerprint } from "../verify/source-fingerprint.js";
9
+ import { resolveReviewScope } from "../review/scope.js";
9
10
  import { NOTES_REF, noteSessionLine, readWorktreeRecord } from "../parallel/service.js";
10
11
  const FINGERPRINT = /^[a-f0-9]{64}$/u;
11
12
  const SESSION = /^[^\0\r\n]{1,256}$/u;
@@ -192,7 +193,7 @@ export class CompletionGateService {
192
193
  * range is recomputed here rather than trusted, so evidence for a range that
193
194
  * no longer ends at HEAD still fails.
194
195
  */
195
- async #evidenceFingerprint(completionBase) {
196
+ async #evidenceFingerprint(completionBase, noChangePaths) {
196
197
  const worktree = await this.#fingerprint();
197
198
  if (completionBase === null) {
198
199
  return worktree;
@@ -202,6 +203,11 @@ export class CompletionGateService {
202
203
  return worktree;
203
204
  }
204
205
  try {
206
+ if (noChangePaths !== undefined) {
207
+ const scope = await resolveReviewScope({ root: this.#options.root, runner: this.#options.gitRunner,
208
+ base: completionBase, noChangePaths });
209
+ return await calculateSourceFingerprint(this.#options.root, scope, this.#options.gitRunner);
210
+ }
205
211
  const changedFiles = await collectBaseChangePaths(this.#options.gitRunner, completionBase);
206
212
  if (changedFiles.length === 0) {
207
213
  return worktree;
@@ -367,7 +373,7 @@ export class CompletionGateService {
367
373
  if (unfinished !== undefined) {
368
374
  return gateResult("block", "FAIL", "COMPLETION_GATE_SUBTASK_INCOMPLETE", `Complete subtask ${unfinished.task.id} before its parent.`);
369
375
  }
370
- const sourceFingerprint = await this.#evidenceFingerprint(stored.completionBase);
376
+ const sourceFingerprint = await this.#evidenceFingerprint(stored.completionBase, stored.noChangePaths);
371
377
  const problem = await checkTaskCompletionEvidence(stored, {
372
378
  ...this.#options,
373
379
  sourceFingerprint
@@ -548,6 +548,8 @@ export async function doctorInstallation(options) {
548
548
  ? []
549
549
  : [await checkProbe("root-ghost-files", options.probes.rootGhostFiles)])
550
550
  ];
551
+ if (options.probes?.acceptanceCoverage !== undefined)
552
+ checks.push(await checkProbe("acceptance-coverage", options.probes.acceptanceCoverage));
551
553
  return {
552
554
  checks,
553
555
  surfaces: surfaceInventory.surfaces,
@@ -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"],
@@ -1,10 +1,16 @@
1
1
  import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { join } from "node:path";
3
4
  import { sha256 } from "../fs/hash.js";
4
5
  import { AgentOpsError, resolveContainedPath } from "../fs/paths.js";
5
6
  import { currentGateFingerprint } from "../hooks/completion-gate.js";
6
7
  import { collectChangeSurface } from "../verify/change-surface.js";
7
8
  import { aggregateVerificationStatus, executeConfiguredCommand } from "../verify/command-executor.js";
9
+ import { prepareFinishReceipt } from "./receipt.js";
10
+ import { readRunIntegrationProof, readRunIntegrationReceiptBinding, markRunIntegration } from "../run/integration.js";
11
+ import { canonicalJson } from "../config/hash.js";
12
+ import { listWorktrees } from "./manage.js";
13
+ import { withPrivateFileLock } from "../security/permissions.js";
8
14
  import { assertWorktreeName, NOTES_REF, noteSessionLine, parseWorktreeListPorcelain, readWorktreeRecord, removeCheckout, resolveCheckouts, worktreePath } from "./service.js";
9
15
  const LOCK_NAME = "agent-ops-finish.lock";
10
16
  const LOCK_POLL_MS = 2_000;
@@ -95,30 +101,50 @@ export async function withFinishLock(deps, commonDir, action) {
95
101
  const path = join(commonDir, LOCK_NAME);
96
102
  const deadline = Date.now() + (deps.lockWaitMs ?? LOCK_WAIT_MS);
97
103
  const sleep = deps.sleep ?? (async (ms) => await new Promise((resolve) => setTimeout(resolve, ms)));
104
+ const token = randomUUID();
98
105
  for (;;) {
106
+ let claimed = false;
99
107
  try {
100
- await mkdir(path);
101
- await writeFile(join(path, "owner.json"), JSON.stringify({ pid: process.pid, at: Date.now() }));
102
- break;
108
+ claimed = await withPrivateFileLock(path + ".state", commonDir, async () => {
109
+ for (;;) {
110
+ try {
111
+ await mkdir(path);
112
+ await writeFile(join(path, "owner.json"), JSON.stringify({ pid: process.pid, at: Date.now(), token }));
113
+ return true;
114
+ }
115
+ catch (error) {
116
+ if (error.code !== "EEXIST")
117
+ throw error;
118
+ if (!await staleLock(path))
119
+ return false;
120
+ await rm(path, { recursive: true, force: true });
121
+ }
122
+ }
123
+ });
103
124
  }
104
125
  catch (error) {
105
- if (error.code !== "EEXIST")
126
+ if (!(error instanceof AgentOpsError) || error.code !== "PRIVATE_STATE_LOCK_TIMEOUT")
106
127
  throw error;
107
- if (await staleLock(path)) {
108
- await rm(path, { recursive: true, force: true });
109
- continue;
110
- }
111
- if (Date.now() >= deadline) {
112
- throw finishError("WORKTREE_FINISH_BUSY", "Another worktree finish is still running; try again later.");
113
- }
114
- await sleep(LOCK_POLL_MS);
115
128
  }
129
+ if (claimed)
130
+ break;
131
+ if (Date.now() >= deadline)
132
+ throw finishError("WORKTREE_FINISH_BUSY", "Another worktree finish is still running; try again later.");
133
+ await sleep(LOCK_POLL_MS);
116
134
  }
117
135
  try {
118
136
  return await action();
119
137
  }
120
138
  finally {
121
- await rm(path, { recursive: true, force: true });
139
+ await withPrivateFileLock(path + ".state", commonDir, async () => {
140
+ const owner = await readFile(join(path, "owner.json"), "utf8").catch(error => {
141
+ if (error.code === "ENOENT")
142
+ return null;
143
+ throw error;
144
+ });
145
+ if (owner !== null && JSON.parse(owner)?.token === token)
146
+ await rm(path, { recursive: true, force: true });
147
+ });
122
148
  }
123
149
  }
124
150
  /**
@@ -184,6 +210,7 @@ function noteText(record, tasks) {
184
210
  const indent = " ".repeat(depth);
185
211
  return [
186
212
  `${indent}agent-ops task ${task.task.id}: ${task.task.title}`,
213
+ ...(task.task.intent === undefined ? [] : [`${indent}intent: ${task.task.intent}`]),
187
214
  `${indent}criteria:`,
188
215
  ...task.task.criteria.map(({ id, description }) => `${indent}- ${id}: ${description}`)
189
216
  ];
@@ -206,6 +233,31 @@ export async function finishWorktree(deps, options) {
206
233
  throw finishError("WORKTREE_NOT_FOUND", `No agent-ops worktree named ${name}; see agent-ops worktree list.`);
207
234
  }
208
235
  return await withFinishLock(deps, commonDir, async () => {
236
+ const sessionChildren = (await listWorktrees(deps, mainRoot))
237
+ .map(({ record: child }) => child)
238
+ .filter((child) => child.path !== record.path && child.sessionId === record.sessionId &&
239
+ (child.agentId !== undefined || (record.runId !== undefined && child.runId === record.runId)));
240
+ if (options.finalProof === undefined && sessionChildren.length > 0) {
241
+ throw finishError("WORKTREE_CHILDREN_REQUIRE_ADVANCE", "This session has child worktrees; run task advance for integrated final evidence before finish.");
242
+ }
243
+ if (options.finalProof !== undefined && options.finalProof.recovery !== true &&
244
+ (sessionChildren.length !== options.finalProof.children.length ||
245
+ sessionChildren.some((child) => !options.finalProof.children.some(({ name }) => name === child.name)))) {
246
+ throw finishError("WORKTREE_CHILDREN_CHANGED", "The child worktree set changed after final review.");
247
+ }
248
+ const assertPinnedChild = async (child) => {
249
+ const expected = options.finalProof?.children.find(({ name }) => name === child.name)?.commit;
250
+ const head = await git(deps, child.path, ["rev-parse", "HEAD"], "WORKTREE_CHILDREN_CHANGED", `Cannot read child ${child.name}.`);
251
+ if (expected === undefined || head !== expected ||
252
+ (await collectChangeSurface(runner(deps, child.path))).paths.length > 0) {
253
+ throw finishError("WORKTREE_CHILDREN_CHANGED", `Child ${child.name} changed after integration; preserve its worktree.`);
254
+ }
255
+ return head;
256
+ };
257
+ if (options.finalProof !== undefined) {
258
+ for (const child of sessionChildren)
259
+ await assertPinnedChild(child);
260
+ }
209
261
  const mainRunner = runner(deps, mainRoot);
210
262
  const worktreeRunner = runner(deps, record.path);
211
263
  const onBranch = (await deps.git(mainRoot, ["symbolic-ref", "--short", "-q", "HEAD"])).stdout.trim();
@@ -230,38 +282,74 @@ export async function finishWorktree(deps, options) {
230
282
  if ((await collectChangeSurface(worktreeRunner)).paths.length > 0) {
231
283
  throw finishError("WORKTREE_DIRTY", `Commit or discard the uncommitted changes in ${record.path} first.`);
232
284
  }
233
- const target = await git(deps, mainRoot, ["rev-parse", "--verify", `${record.targetBranch}^{commit}`], "WORKTREE_TARGET_MISSING", `${record.targetBranch} does not resolve to a commit.`);
285
+ const actualTarget = await git(deps, mainRoot, ["rev-parse", "--verify", `${record.targetBranch}^{commit}`], "WORKTREE_TARGET_MISSING", `${record.targetBranch} does not resolve to a commit.`);
286
+ const recovering = options.finalProof?.recovery === true;
287
+ if (recovering) {
288
+ const sealed = await readRunIntegrationProof(commonDir, record);
289
+ const { recovery: _recovery, ...requested } = options.finalProof;
290
+ if (sealed === null || canonicalJson(sealed) !== canonicalJson(requested) || actualTarget !== sealed.head)
291
+ throw finishError("RUN_INTEGRATION_PROOF_CHANGED", "Recovery requires the exact sealed candidate already at the target.");
292
+ }
293
+ const target = recovering ? options.finalProof.target : actualTarget;
294
+ if (options.finalProof !== undefined && actualTarget !== options.finalProof.target && !recovering) {
295
+ throw finishError("WORKTREE_TARGET_MOVED", "The target branch moved after final review; rerun the final evidence gate.");
296
+ }
234
297
  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
- if (ahead === 0) {
298
+ const noChangeProof = options.finalProof?.noChange;
299
+ if (ahead === 0 && noChangeProof === undefined && !recovering) {
236
300
  throw finishError("WORKTREE_NOTHING_TO_MERGE", `${record.branch} has no commits beyond ${record.targetBranch}; remove the worktree instead.`);
237
301
  }
238
302
  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.
303
+ const headBeforeCompletion = await git(deps, record.path, ["rev-parse", "HEAD"], "WORKTREE_LOG_FAILED", "Git could not read HEAD.");
304
+ if (options.finalProof !== undefined) {
305
+ if (headBeforeCompletion !== options.finalProof.head) {
306
+ throw finishError("WORKTREE_SOURCE_MOVED", "The candidate changed after final review.");
307
+ }
308
+ if ((await deps.git(record.path, ["merge-base", "--is-ancestor", target, "HEAD"])).exitCode !== 0) {
309
+ throw finishError("WORKTREE_TARGET_MOVED", "The final candidate is not based on the target; rerun the final evidence gate.");
310
+ }
311
+ if (noChangeProof !== undefined && noChangeProof.sourceCommit !== headBeforeCompletion) {
312
+ throw finishError("WORKTREE_NO_CHANGE_SOURCE_MISMATCH", "The verified-no-change proof does not match the candidate commit.");
313
+ }
314
+ }
315
+ // Direct finish completes before merge. A final-proof finish checks all
316
+ // evidence first, then completes only after the target's fast-forward:
317
+ // a target that moves before that point can still be re-reviewed once.
242
318
  const tree = taskTree((await deps.tasks(record.path).list()).filter(({ status }) => status !== "archived"));
243
319
  if (tree.length === 0) {
244
320
  throw finishError("WORKTREE_TASK_INCOMPLETE", "The worktree has no task; create one, verify and review it before finishing.");
245
321
  }
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, {});
322
+ const completeTasks = async () => {
323
+ const noChangePaths = noChangeProof?.reviewScope.changedFiles;
324
+ for (const { task } of [...tree].sort((left, right) => right.depth - left.depth)) {
325
+ if (task.status === "complete")
326
+ continue;
327
+ const base = options.finalProof === undefined ? task.reviewBase ?? record.base : target;
328
+ try {
329
+ await deps.tasks(record.path, base, noChangePaths).complete(task.task.id, {});
330
+ }
331
+ catch (error) {
332
+ 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)} ` +
333
+ "A task another session left here is not this session's to archive: its owner finishes or archives it, and finish never does.");
334
+ }
252
335
  }
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.");
336
+ };
337
+ const validateGate = async () => {
338
+ const worktreeGate = await deps.gate(record.path, worktreeConfig);
339
+ const problem = await worktreeGate.validate(record.sessionId);
340
+ if (problem !== null) {
341
+ throw finishError("WORKTREE_TASK_INCOMPLETE", `The worktree's task is not ready to merge (${problem.code}). ${problem.remedy ?? ""}`.trim());
256
342
  }
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());
343
+ };
344
+ if (options.finalProof === undefined) {
345
+ await completeTasks();
346
+ await validateGate();
262
347
  }
263
348
  const head = await git(deps, record.path, ["rev-parse", "HEAD"], "WORKTREE_LOG_FAILED", "Git could not read HEAD.");
264
349
  const fastForward = (await deps.git(record.path, ["merge-base", "--is-ancestor", target, "HEAD"])).exitCode === 0;
350
+ if (options.finalProof !== undefined && !fastForward) {
351
+ throw finishError("WORKTREE_TARGET_MOVED", "The target branch moved after final review.");
352
+ }
265
353
  if (!fastForward) {
266
354
  const before = await patchHash(deps, record.path, target);
267
355
  const rebase = await deps.git(record.path, ["rebase", "--no-autostash", target]);
@@ -287,19 +375,84 @@ export async function finishWorktree(deps, options) {
287
375
  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
376
  }
289
377
  }
378
+ const sealedIntegration = options.finalProof === undefined || record.runId === undefined
379
+ ? null
380
+ : await readRunIntegrationReceiptBinding(commonDir, record, { target, candidate: head });
381
+ const requestedIntegration = options.finalProof?.integrationJournal;
382
+ if (requestedIntegration !== undefined && sealedIntegration !== null &&
383
+ canonicalJson(requestedIntegration) !== canonicalJson(sealedIntegration)) {
384
+ throw finishError("RUN_INTEGRATION_PROOF_CHANGED", "The requested receipt binding differs from the sealed integration transaction.");
385
+ }
386
+ const integrationJournal = sealedIntegration ?? requestedIntegration;
387
+ const receiptOptions = options.finalProof === undefined ? undefined : {
388
+ commonDir,
389
+ record,
390
+ candidateHead: head,
391
+ targetCommit: target,
392
+ expectedFingerprint: options.finalProof.sourceFingerprint,
393
+ children: options.finalProof.children,
394
+ config: worktreeConfig,
395
+ gitRunner: worktreeRunner,
396
+ ...(noChangeProof === undefined ? {} : { noChange: noChangeProof }),
397
+ ...(integrationJournal === undefined ? {} : { integrationJournal })
398
+ };
399
+ let receipt = receiptOptions === undefined ? undefined : await prepareFinishReceipt({
400
+ ...receiptOptions,
401
+ tasks: await deps.tasks(record.path).list().then((tasks) => tasks.filter(({ status }) => status !== "archived"))
402
+ });
290
403
  const mainConfig = await deps.loadConfig(mainRoot);
291
404
  const gateEnabled = mainConfig.features.completionGate.enabled;
292
405
  const before = gateEnabled ? await currentGateFingerprint(mainRoot, mainRunner) : "";
293
406
  let mergedHead;
294
- if (onTarget) {
295
- await git(deps, mainRoot, ["merge", "--ff-only", record.branch], "WORKTREE_MERGE_FAILED", `Git could not fast-forward ${record.targetBranch}.`);
407
+ if (noChangeProof !== undefined || recovering) {
408
+ // A verified-no-change finish records proof and task state while leaving
409
+ // the target ref untouched. The ordinary finish path still rejects an
410
+ // empty branch, preserving its public behavior.
411
+ mergedHead = actualTarget;
412
+ }
413
+ else if (onTarget) {
414
+ if (options.finalProof !== undefined &&
415
+ await git(deps, mainRoot, ["rev-parse", "HEAD"], "WORKTREE_LOG_FAILED", "Git could not read HEAD.") !== target) {
416
+ throw finishError("WORKTREE_TARGET_MOVED", "The target changed after the final proof was prepared.");
417
+ }
418
+ const merge = await deps.git(mainRoot, ["merge", "--ff-only", record.branch]);
419
+ if (merge.exitCode !== 0) {
420
+ if (options.finalProof !== undefined &&
421
+ await git(deps, mainRoot, ["rev-parse", "HEAD"], "WORKTREE_LOG_FAILED", "Git could not read HEAD.") !== target) {
422
+ throw finishError("WORKTREE_TARGET_MOVED", "The target moved during finish; rerun the final evidence gate.");
423
+ }
424
+ throw finishError("WORKTREE_MERGE_FAILED", `Git could not fast-forward ${record.targetBranch}. ${merge.stderr.trim()}`);
425
+ }
296
426
  mergedHead = await git(deps, mainRoot, ["rev-parse", "HEAD"], "WORKTREE_LOG_FAILED", "Git could not read HEAD.");
297
427
  }
298
428
  else {
299
429
  // Compare-and-swap on the target's value read above: a target that moved
300
430
  // since fails here instead of being overwritten.
301
431
  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}.`);
432
+ const update = await deps.git(mainRoot, ["update-ref", "-m", `agent-ops finish ${record.branch}`, `refs/heads/${record.targetBranch}`, mergedHead, target]);
433
+ if (update.exitCode !== 0) {
434
+ if (options.finalProof !== undefined &&
435
+ await git(deps, mainRoot, ["rev-parse", `${record.targetBranch}^{commit}`], "WORKTREE_LOG_FAILED", "Git could not read the target.") !== target) {
436
+ throw finishError("WORKTREE_TARGET_MOVED", "The target moved during finish; rerun the final evidence gate.");
437
+ }
438
+ throw finishError("WORKTREE_MERGE_FAILED", `Git could not fast-forward ${record.targetBranch}. ${update.stderr.trim()}`);
439
+ }
440
+ }
441
+ if (receiptOptions !== undefined) {
442
+ await markRunIntegration(commonDir, record, "target-moved", "target");
443
+ try {
444
+ await completeTasks();
445
+ await validateGate();
446
+ await markRunIntegration(commonDir, record, "tasks-completed", "tasks");
447
+ receipt = await prepareFinishReceipt({
448
+ ...receiptOptions,
449
+ tasks: await deps.tasks(record.path).list().then((tasks) => tasks.filter(({ status }) => status !== "archived"))
450
+ });
451
+ await markRunIntegration(commonDir, record, "receipt-written", "receipt", receipt);
452
+ }
453
+ catch (error) {
454
+ throw finishError("WORKTREE_FINISH_PARTIAL", `Code merged at ${mergedHead}; final task state or receipt failed: ${error instanceof Error ? error.message : String(error)}`);
455
+ }
303
456
  }
304
457
  // The merge happened; everything below is cleanup, reported, never undone.
305
458
  const warnings = [];
@@ -308,10 +461,17 @@ export async function finishWorktree(deps, options) {
308
461
  await step();
309
462
  }
310
463
  catch (error) {
311
- warnings.push(`${label}: ${error instanceof Error ? error.message : String(error)}`);
464
+ const message = `${label}: ${error instanceof Error ? error.message : String(error)}`;
465
+ if (options.finalProof !== undefined) {
466
+ throw finishError("WORKTREE_FINISH_PARTIAL", `Code merged at ${mergedHead} with receipt ${receipt?.path}; ${message}`);
467
+ }
468
+ warnings.push(message);
312
469
  }
313
470
  };
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."));
471
+ const finishNote = `${noteText(record, tree)}${noChangeProof === undefined ? "" : "\nverified-no-change: true"}${receipt === undefined ? "" : `\nreceipt: ${receipt.path}\nreceipt-sha256: ${receipt.digest}`}`;
472
+ 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."));
473
+ if (receiptOptions !== undefined)
474
+ await markRunIntegration(commonDir, record, "receipt-written", "note");
315
475
  if (gateEnabled) {
316
476
  await attempt("gate", async () => {
317
477
  const mainGate = await deps.gate(mainRoot, mainConfig);
@@ -321,10 +481,28 @@ export async function finishWorktree(deps, options) {
321
481
  // this session began would leave it reading the merge as its own change.
322
482
  // Only this worktree leaves the session; its other roots stay covered.
323
483
  await mainGate.afterFinish(record.sessionId, record.path, after);
484
+ if (options.finalProof !== undefined) {
485
+ for (const child of sessionChildren) {
486
+ await mainGate.afterFinish(record.sessionId, child.path, after);
487
+ }
488
+ }
324
489
  });
325
490
  }
326
491
  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 };
492
+ if (options.finalProof !== undefined) {
493
+ for (const child of sessionChildren) {
494
+ await attempt(`child ${child.name}`, async () => {
495
+ const pinnedHead = await assertPinnedChild(child);
496
+ await deps.trust.revoke(child.path, await deps.loadConfig(child.path));
497
+ await removeCheckout(deps, child, false, pinnedHead);
498
+ await markRunIntegration(commonDir, record, "receipt-written", "child:" + child.name);
499
+ });
500
+ }
501
+ }
502
+ await attempt("remove", async () => await removeCheckout(deps, record, options.finalProof === undefined, options.finalProof?.head));
503
+ if (receiptOptions !== undefined)
504
+ await markRunIntegration(commonDir, record, "cleaned", "coordinator");
505
+ return { record, mergedHead, rebased: !fastForward, taskIds: tree.map(({ task }) => task.task.id), warnings,
506
+ ...(receipt === undefined ? {} : { receipt: receipt.path }) };
329
507
  });
330
508
  }