@kylecheng3146/agent-ops 0.5.3 → 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 (93) hide show
  1. package/README.md +48 -0
  2. package/dist/packages/cli/src/args.js +165 -2
  3. package/dist/packages/cli/src/bin.js +64 -17
  4. package/dist/packages/cli/src/cli.js +33 -6
  5. package/dist/packages/cli/src/commands/advance.js +23 -3
  6. package/dist/packages/cli/src/commands/review-show.js +1 -1
  7. package/dist/packages/cli/src/commands/review.js +100 -9
  8. package/dist/packages/cli/src/commands/run.js +111 -0
  9. package/dist/packages/cli/src/commands/task.js +61 -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/parallel/finish.js +85 -22
  26. package/dist/runtime/src/parallel/guard.js +5 -1
  27. package/dist/runtime/src/parallel/integrate.js +288 -10
  28. package/dist/runtime/src/parallel/manage.js +4 -1
  29. package/dist/runtime/src/parallel/receipt.js +71 -5
  30. package/dist/runtime/src/parallel/service.js +41 -1
  31. package/dist/runtime/src/review/attestation.js +36 -11
  32. package/dist/runtime/src/review/execute.js +24 -1
  33. package/dist/runtime/src/review/packet.js +35 -1
  34. package/dist/runtime/src/review/runner.js +12 -1
  35. package/dist/runtime/src/review/scope.js +18 -1
  36. package/dist/runtime/src/review/slots.js +35 -10
  37. package/dist/runtime/src/run/authorization.js +60 -0
  38. package/dist/runtime/src/run/budget.js +30 -0
  39. package/dist/runtime/src/run/controls.js +139 -0
  40. package/dist/runtime/src/run/convergence.js +177 -0
  41. package/dist/runtime/src/run/hosts/claude.js +180 -0
  42. package/dist/runtime/src/run/hosts/codex.js +279 -0
  43. package/dist/runtime/src/run/hosts/index.js +4 -0
  44. package/dist/runtime/src/run/hosts/protocol.js +254 -0
  45. package/dist/runtime/src/run/hosts/types.js +1 -0
  46. package/dist/runtime/src/run/hosts/util.js +136 -0
  47. package/dist/runtime/src/run/integration.js +204 -0
  48. package/dist/runtime/src/run/macOS.js +189 -0
  49. package/dist/runtime/src/run/ownership.js +22 -0
  50. package/dist/runtime/src/run/planning.js +25 -0
  51. package/dist/runtime/src/run/policy-transition.js +1076 -0
  52. package/dist/runtime/src/run/policy.js +176 -0
  53. package/dist/runtime/src/run/ratchet.js +71 -0
  54. package/dist/runtime/src/run/scheduler.js +260 -0
  55. package/dist/runtime/src/run/service.js +425 -0
  56. package/dist/runtime/src/run/supervisor.js +635 -0
  57. package/dist/runtime/src/run/transport.js +159 -0
  58. package/dist/runtime/src/run/types.js +1 -0
  59. package/dist/runtime/src/run/usage.js +81 -0
  60. package/dist/runtime/src/run/verification.js +64 -0
  61. package/dist/runtime/src/run/worker-gate.js +48 -0
  62. package/dist/runtime/src/schema/acceptance.js +91 -0
  63. package/dist/runtime/src/schema/validate.js +67 -7
  64. package/dist/runtime/src/task/completion.js +14 -2
  65. package/dist/runtime/src/task/contract.js +17 -0
  66. package/dist/runtime/src/task/pin-finding.js +81 -0
  67. package/dist/runtime/src/task/service.js +316 -13
  68. package/dist/runtime/src/task/store.js +57 -7
  69. package/dist/runtime/src/verify/acceptance-coverage.js +85 -0
  70. package/dist/runtime/src/verify/acceptance-protocol.js +252 -0
  71. package/dist/runtime/src/verify/acceptance-replay.js +239 -0
  72. package/dist/runtime/src/verify/adapters/index.js +5 -0
  73. package/dist/runtime/src/verify/adapters/jest.js +142 -0
  74. package/dist/runtime/src/verify/adapters/node-reporter.js +20 -0
  75. package/dist/runtime/src/verify/adapters/node.js +254 -0
  76. package/dist/runtime/src/verify/adapters/pytest.js +188 -0
  77. package/dist/runtime/src/verify/adapters/rust.js +84 -0
  78. package/dist/runtime/src/verify/adapters/shared.js +82 -0
  79. package/dist/runtime/src/verify/adapters/vitest.js +218 -0
  80. package/dist/runtime/src/verify/evidence.js +5 -2
  81. package/dist/runtime/src/verify/service.js +24 -8
  82. package/dist/runtime/src/verify/source-fingerprint.js +1 -1
  83. package/dist/runtime/src/verify/spawn.js +13 -2
  84. package/docs/en/spec/acceptance-and-evidence.md +48 -0
  85. package/docs/en/spec/loop-engineering.md +49 -0
  86. package/docs/zh-TW/spec/acceptance-and-evidence.md +21 -1
  87. package/docs/zh-TW/spec/loop-engineering.md +22 -0
  88. package/package.json +1 -1
  89. package/schemas/config.schema.json +221 -19
  90. package/schemas/evidence.schema.json +129 -6
  91. package/schemas/task.schema.json +265 -6
  92. package/templates/acceptance/agent_ops_acceptance.rs +36 -0
  93. 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,
@@ -1,4 +1,5 @@
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";
@@ -6,7 +7,10 @@ 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";
8
9
  import { prepareFinishReceipt } from "./receipt.js";
10
+ import { readRunIntegrationProof, readRunIntegrationReceiptBinding, markRunIntegration } from "../run/integration.js";
11
+ import { canonicalJson } from "../config/hash.js";
9
12
  import { listWorktrees } from "./manage.js";
13
+ import { withPrivateFileLock } from "../security/permissions.js";
10
14
  import { assertWorktreeName, NOTES_REF, noteSessionLine, parseWorktreeListPorcelain, readWorktreeRecord, removeCheckout, resolveCheckouts, worktreePath } from "./service.js";
11
15
  const LOCK_NAME = "agent-ops-finish.lock";
12
16
  const LOCK_POLL_MS = 2_000;
@@ -97,30 +101,50 @@ export async function withFinishLock(deps, commonDir, action) {
97
101
  const path = join(commonDir, LOCK_NAME);
98
102
  const deadline = Date.now() + (deps.lockWaitMs ?? LOCK_WAIT_MS);
99
103
  const sleep = deps.sleep ?? (async (ms) => await new Promise((resolve) => setTimeout(resolve, ms)));
104
+ const token = randomUUID();
100
105
  for (;;) {
106
+ let claimed = false;
101
107
  try {
102
- await mkdir(path);
103
- await writeFile(join(path, "owner.json"), JSON.stringify({ pid: process.pid, at: Date.now() }));
104
- 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
+ });
105
124
  }
106
125
  catch (error) {
107
- if (error.code !== "EEXIST")
126
+ if (!(error instanceof AgentOpsError) || error.code !== "PRIVATE_STATE_LOCK_TIMEOUT")
108
127
  throw error;
109
- if (await staleLock(path)) {
110
- await rm(path, { recursive: true, force: true });
111
- continue;
112
- }
113
- if (Date.now() >= deadline) {
114
- throw finishError("WORKTREE_FINISH_BUSY", "Another worktree finish is still running; try again later.");
115
- }
116
- await sleep(LOCK_POLL_MS);
117
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);
118
134
  }
119
135
  try {
120
136
  return await action();
121
137
  }
122
138
  finally {
123
- 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
+ });
124
148
  }
125
149
  }
126
150
  /**
@@ -211,11 +235,12 @@ export async function finishWorktree(deps, options) {
211
235
  return await withFinishLock(deps, commonDir, async () => {
212
236
  const sessionChildren = (await listWorktrees(deps, mainRoot))
213
237
  .map(({ record: child }) => child)
214
- .filter((child) => child.sessionId === record.sessionId && child.agentId !== undefined);
238
+ .filter((child) => child.path !== record.path && child.sessionId === record.sessionId &&
239
+ (child.agentId !== undefined || (record.runId !== undefined && child.runId === record.runId)));
215
240
  if (options.finalProof === undefined && sessionChildren.length > 0) {
216
241
  throw finishError("WORKTREE_CHILDREN_REQUIRE_ADVANCE", "This session has child worktrees; run task advance for integrated final evidence before finish.");
217
242
  }
218
- if (options.finalProof !== undefined &&
243
+ if (options.finalProof !== undefined && options.finalProof.recovery !== true &&
219
244
  (sessionChildren.length !== options.finalProof.children.length ||
220
245
  sessionChildren.some((child) => !options.finalProof.children.some(({ name }) => name === child.name)))) {
221
246
  throw finishError("WORKTREE_CHILDREN_CHANGED", "The child worktree set changed after final review.");
@@ -257,12 +282,21 @@ export async function finishWorktree(deps, options) {
257
282
  if ((await collectChangeSurface(worktreeRunner)).paths.length > 0) {
258
283
  throw finishError("WORKTREE_DIRTY", `Commit or discard the uncommitted changes in ${record.path} first.`);
259
284
  }
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) {
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) {
262
295
  throw finishError("WORKTREE_TARGET_MOVED", "The target branch moved after final review; rerun the final evidence gate.");
263
296
  }
264
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."));
265
- if (ahead === 0) {
298
+ const noChangeProof = options.finalProof?.noChange;
299
+ if (ahead === 0 && noChangeProof === undefined && !recovering) {
266
300
  throw finishError("WORKTREE_NOTHING_TO_MERGE", `${record.branch} has no commits beyond ${record.targetBranch}; remove the worktree instead.`);
267
301
  }
268
302
  const worktreeConfig = await deps.loadConfig(record.path);
@@ -274,6 +308,9 @@ export async function finishWorktree(deps, options) {
274
308
  if ((await deps.git(record.path, ["merge-base", "--is-ancestor", target, "HEAD"])).exitCode !== 0) {
275
309
  throw finishError("WORKTREE_TARGET_MOVED", "The final candidate is not based on the target; rerun the final evidence gate.");
276
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
+ }
277
314
  }
278
315
  // Direct finish completes before merge. A final-proof finish checks all
279
316
  // evidence first, then completes only after the target's fast-forward:
@@ -283,12 +320,13 @@ export async function finishWorktree(deps, options) {
283
320
  throw finishError("WORKTREE_TASK_INCOMPLETE", "The worktree has no task; create one, verify and review it before finishing.");
284
321
  }
285
322
  const completeTasks = async () => {
323
+ const noChangePaths = noChangeProof?.reviewScope.changedFiles;
286
324
  for (const { task } of [...tree].sort((left, right) => right.depth - left.depth)) {
287
325
  if (task.status === "complete")
288
326
  continue;
289
327
  const base = options.finalProof === undefined ? task.reviewBase ?? record.base : target;
290
328
  try {
291
- await deps.tasks(record.path, base).complete(task.task.id, {});
329
+ await deps.tasks(record.path, base, noChangePaths).complete(task.task.id, {});
292
330
  }
293
331
  catch (error) {
294
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)} ` +
@@ -337,6 +375,15 @@ export async function finishWorktree(deps, options) {
337
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.`);
338
376
  }
339
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;
340
387
  const receiptOptions = options.finalProof === undefined ? undefined : {
341
388
  commonDir,
342
389
  record,
@@ -345,7 +392,9 @@ export async function finishWorktree(deps, options) {
345
392
  expectedFingerprint: options.finalProof.sourceFingerprint,
346
393
  children: options.finalProof.children,
347
394
  config: worktreeConfig,
348
- gitRunner: worktreeRunner
395
+ gitRunner: worktreeRunner,
396
+ ...(noChangeProof === undefined ? {} : { noChange: noChangeProof }),
397
+ ...(integrationJournal === undefined ? {} : { integrationJournal })
349
398
  };
350
399
  let receipt = receiptOptions === undefined ? undefined : await prepareFinishReceipt({
351
400
  ...receiptOptions,
@@ -355,7 +404,13 @@ export async function finishWorktree(deps, options) {
355
404
  const gateEnabled = mainConfig.features.completionGate.enabled;
356
405
  const before = gateEnabled ? await currentGateFingerprint(mainRoot, mainRunner) : "";
357
406
  let mergedHead;
358
- if (onTarget) {
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) {
359
414
  if (options.finalProof !== undefined &&
360
415
  await git(deps, mainRoot, ["rev-parse", "HEAD"], "WORKTREE_LOG_FAILED", "Git could not read HEAD.") !== target) {
361
416
  throw finishError("WORKTREE_TARGET_MOVED", "The target changed after the final proof was prepared.");
@@ -384,13 +439,16 @@ export async function finishWorktree(deps, options) {
384
439
  }
385
440
  }
386
441
  if (receiptOptions !== undefined) {
442
+ await markRunIntegration(commonDir, record, "target-moved", "target");
387
443
  try {
388
444
  await completeTasks();
389
445
  await validateGate();
446
+ await markRunIntegration(commonDir, record, "tasks-completed", "tasks");
390
447
  receipt = await prepareFinishReceipt({
391
448
  ...receiptOptions,
392
449
  tasks: await deps.tasks(record.path).list().then((tasks) => tasks.filter(({ status }) => status !== "archived"))
393
450
  });
451
+ await markRunIntegration(commonDir, record, "receipt-written", "receipt", receipt);
394
452
  }
395
453
  catch (error) {
396
454
  throw finishError("WORKTREE_FINISH_PARTIAL", `Code merged at ${mergedHead}; final task state or receipt failed: ${error instanceof Error ? error.message : String(error)}`);
@@ -410,8 +468,10 @@ export async function finishWorktree(deps, options) {
410
468
  warnings.push(message);
411
469
  }
412
470
  };
413
- const finishNote = `${noteText(record, tree)}${receipt === undefined ? "" : `\nreceipt: ${receipt.path}\nreceipt-sha256: ${receipt.digest}`}`;
471
+ const finishNote = `${noteText(record, tree)}${noChangeProof === undefined ? "" : "\nverified-no-change: true"}${receipt === undefined ? "" : `\nreceipt: ${receipt.path}\nreceipt-sha256: ${receipt.digest}`}`;
414
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");
415
475
  if (gateEnabled) {
416
476
  await attempt("gate", async () => {
417
477
  const mainGate = await deps.gate(mainRoot, mainConfig);
@@ -435,10 +495,13 @@ export async function finishWorktree(deps, options) {
435
495
  const pinnedHead = await assertPinnedChild(child);
436
496
  await deps.trust.revoke(child.path, await deps.loadConfig(child.path));
437
497
  await removeCheckout(deps, child, false, pinnedHead);
498
+ await markRunIntegration(commonDir, record, "receipt-written", "child:" + child.name);
438
499
  });
439
500
  }
440
501
  }
441
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");
442
505
  return { record, mergedHead, rebased: !fastForward, taskIds: tree.map(({ task }) => task.task.id), warnings,
443
506
  ...(receipt === undefined ? {} : { receipt: receipt.path }) };
444
507
  });
@@ -1,3 +1,4 @@
1
+ import { registeredRunWriter } from "../run/ownership.js";
1
2
  import { realpath } from "node:fs/promises";
2
3
  import { basename, dirname, join, relative, sep } from "node:path";
3
4
  import { insideWorktreeDirectory, mayUseWorktree, readWorktreeRecord, WORKTREE_DIRECTORY } from "./service.js";
@@ -54,7 +55,10 @@ agentId) {
54
55
  // session's mixes two tasks' changes into one review and one merge.
55
56
  const name = relative(join(mainRoot, WORKTREE_DIRECTORY), target).split(sep)[0] ?? "";
56
57
  const record = await readWorktreeRecord(join(mainRoot, WORKTREE_DIRECTORY, name));
57
- if (record !== null && !mayUseWorktree(record, sessionId, agentId)) {
58
+ const permitted = record === null || (record.runId === undefined
59
+ ? mayUseWorktree(record, sessionId, agentId)
60
+ : await registeredRunWriter(record, sessionId, agentId));
61
+ if (record !== null && !permitted) {
58
62
  const owner = record.sessionId;
59
63
  const sameSession = owner === sessionId;
60
64
  let own = "";