@kylecheng3146/agent-ops 0.5.3 → 0.7.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 (124) hide show
  1. package/README.md +103 -1
  2. package/dist/packages/cli/src/args.js +183 -3
  3. package/dist/packages/cli/src/bin.js +76 -19
  4. package/dist/packages/cli/src/cli.js +53 -8
  5. package/dist/packages/cli/src/codex-loop-process.js +19 -2
  6. package/dist/packages/cli/src/commands/advance.js +42 -5
  7. package/dist/packages/cli/src/commands/init.js +2 -0
  8. package/dist/packages/cli/src/commands/office.js +20 -0
  9. package/dist/packages/cli/src/commands/review-show.js +1 -1
  10. package/dist/packages/cli/src/commands/review.js +100 -9
  11. package/dist/packages/cli/src/commands/run.js +115 -0
  12. package/dist/packages/cli/src/commands/task.js +61 -17
  13. package/dist/packages/cli/src/commands/update.js +8 -0
  14. package/dist/packages/cli/src/commands/verify.js +1 -0
  15. package/dist/packages/cli/src/context.js +64 -1
  16. package/dist/packages/cli/src/hook-entry.js +2 -1
  17. package/dist/packages/cli/src/hook-process.js +54 -3
  18. package/dist/packages/cli/src/loop-entry.js +2 -1
  19. package/dist/packages/cli/src/office-entry.js +230 -0
  20. package/dist/packages/cli/src/owned-run-step.js +65 -0
  21. package/dist/packages/cli/src/parallel-deps.js +18 -5
  22. package/dist/packages/cli/src/run-deps.js +321 -0
  23. package/dist/packages/cli/src/run-entry.js +952 -0
  24. package/dist/packages/cli/src/run-setup-entry.js +32 -0
  25. package/dist/packages/cli/src/run-step-entry.js +58 -0
  26. package/dist/packages/cli/src/wizard.js +14 -2
  27. package/dist/runtime/src/adapters/agy/config.js +4 -3
  28. package/dist/runtime/src/adapters/agy/events.js +9 -0
  29. package/dist/runtime/src/adapters/claude/config.js +3 -3
  30. package/dist/runtime/src/adapters/claude/events.js +8 -0
  31. package/dist/runtime/src/adapters/codex/config.js +3 -3
  32. package/dist/runtime/src/adapters/codex/events.js +8 -0
  33. package/dist/runtime/src/adapters/opencode/config.js +5 -4
  34. package/dist/runtime/src/adapters/opencode/events.js +8 -0
  35. package/dist/runtime/src/config/explain.js +11 -0
  36. package/dist/runtime/src/config/hash.js +1 -1
  37. package/dist/runtime/src/config/merge.js +9 -1
  38. package/dist/runtime/src/contracts.js +2 -2
  39. package/dist/runtime/src/hooks/codex-loop.js +4 -0
  40. package/dist/runtime/src/hooks/completion-gate.js +8 -2
  41. package/dist/runtime/src/install/doctor.js +2 -0
  42. package/dist/runtime/src/install/harness.js +44 -1
  43. package/dist/runtime/src/install/plan.js +16 -11
  44. package/dist/runtime/src/install/preauth.js +44 -18
  45. package/dist/runtime/src/install/profiles.js +8 -2
  46. package/dist/runtime/src/install/update.js +1 -0
  47. package/dist/runtime/src/office/collect.js +233 -0
  48. package/dist/runtime/src/office/page.js +873 -0
  49. package/dist/runtime/src/office/scene.js +168 -0
  50. package/dist/runtime/src/office/server.js +139 -0
  51. package/dist/runtime/src/office/sessions.js +115 -0
  52. package/dist/runtime/src/office/snapshot.js +107 -0
  53. package/dist/runtime/src/parallel/finish.js +85 -22
  54. package/dist/runtime/src/parallel/guard.js +12 -2
  55. package/dist/runtime/src/parallel/integrate.js +288 -10
  56. package/dist/runtime/src/parallel/manage.js +4 -1
  57. package/dist/runtime/src/parallel/receipt.js +71 -5
  58. package/dist/runtime/src/parallel/service.js +41 -1
  59. package/dist/runtime/src/review/attestation.js +36 -11
  60. package/dist/runtime/src/review/execute.js +24 -1
  61. package/dist/runtime/src/review/packet.js +35 -1
  62. package/dist/runtime/src/review/runner.js +12 -1
  63. package/dist/runtime/src/review/scope.js +18 -1
  64. package/dist/runtime/src/review/slots.js +35 -10
  65. package/dist/runtime/src/run/authorization.js +60 -0
  66. package/dist/runtime/src/run/budget.js +30 -0
  67. package/dist/runtime/src/run/controls.js +139 -0
  68. package/dist/runtime/src/run/convergence.js +177 -0
  69. package/dist/runtime/src/run/hosts/claude.js +180 -0
  70. package/dist/runtime/src/run/hosts/codex.js +279 -0
  71. package/dist/runtime/src/run/hosts/index.js +4 -0
  72. package/dist/runtime/src/run/hosts/protocol.js +254 -0
  73. package/dist/runtime/src/run/hosts/types.js +1 -0
  74. package/dist/runtime/src/run/hosts/util.js +136 -0
  75. package/dist/runtime/src/run/integration.js +204 -0
  76. package/dist/runtime/src/run/macOS.js +189 -0
  77. package/dist/runtime/src/run/ownership.js +22 -0
  78. package/dist/runtime/src/run/phase.js +37 -0
  79. package/dist/runtime/src/run/planning.js +25 -0
  80. package/dist/runtime/src/run/policy-transition.js +1076 -0
  81. package/dist/runtime/src/run/policy.js +176 -0
  82. package/dist/runtime/src/run/ratchet.js +71 -0
  83. package/dist/runtime/src/run/scheduler.js +260 -0
  84. package/dist/runtime/src/run/service.js +433 -0
  85. package/dist/runtime/src/run/supervisor.js +635 -0
  86. package/dist/runtime/src/run/transport.js +159 -0
  87. package/dist/runtime/src/run/types.js +1 -0
  88. package/dist/runtime/src/run/usage.js +81 -0
  89. package/dist/runtime/src/run/verification.js +64 -0
  90. package/dist/runtime/src/run/worker-gate.js +48 -0
  91. package/dist/runtime/src/schema/acceptance.js +91 -0
  92. package/dist/runtime/src/schema/validate.js +80 -9
  93. package/dist/runtime/src/task/completion.js +14 -2
  94. package/dist/runtime/src/task/contract.js +17 -0
  95. package/dist/runtime/src/task/pin-finding.js +81 -0
  96. package/dist/runtime/src/task/service.js +316 -13
  97. package/dist/runtime/src/task/store.js +57 -7
  98. package/dist/runtime/src/verify/acceptance-coverage.js +85 -0
  99. package/dist/runtime/src/verify/acceptance-protocol.js +252 -0
  100. package/dist/runtime/src/verify/acceptance-replay.js +239 -0
  101. package/dist/runtime/src/verify/adapters/index.js +5 -0
  102. package/dist/runtime/src/verify/adapters/jest.js +142 -0
  103. package/dist/runtime/src/verify/adapters/node-reporter.js +20 -0
  104. package/dist/runtime/src/verify/adapters/node.js +254 -0
  105. package/dist/runtime/src/verify/adapters/pytest.js +188 -0
  106. package/dist/runtime/src/verify/adapters/rust.js +84 -0
  107. package/dist/runtime/src/verify/adapters/shared.js +82 -0
  108. package/dist/runtime/src/verify/adapters/vitest.js +218 -0
  109. package/dist/runtime/src/verify/evidence.js +5 -2
  110. package/dist/runtime/src/verify/service.js +24 -8
  111. package/dist/runtime/src/verify/source-fingerprint.js +1 -1
  112. package/dist/runtime/src/verify/spawn.js +13 -2
  113. package/docs/en/spec/acceptance-and-evidence.md +48 -0
  114. package/docs/en/spec/harness-adapters.md +113 -1
  115. package/docs/en/spec/loop-engineering.md +49 -0
  116. package/docs/zh-TW/spec/acceptance-and-evidence.md +21 -1
  117. package/docs/zh-TW/spec/harness-adapters.md +93 -1
  118. package/docs/zh-TW/spec/loop-engineering.md +22 -0
  119. package/package.json +1 -1
  120. package/schemas/config.schema.json +230 -19
  121. package/schemas/evidence.schema.json +129 -6
  122. package/schemas/task.schema.json +265 -6
  123. package/templates/acceptance/agent_ops_acceptance.rs +36 -0
  124. package/templates/acceptance/agent_ops_pytest.py +46 -0
@@ -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";
@@ -35,6 +36,8 @@ export async function canonicalPath(path) {
35
36
  }
36
37
  }
37
38
  }
39
+ /** The gitignored goal file the `run` profile writes in the main checkout. */
40
+ export const RUN_GOAL_FILE = join(".agent-ops", "state", "run-goal.md");
38
41
  /**
39
42
  * In worktree auto mode the main checkout is shared by every session, so no
40
43
  * session writes it directly: each edit belongs in that session's own
@@ -45,16 +48,23 @@ export async function evaluateWorktreeWrite(mainRoot, paths, sessionId,
45
48
  /** Creates or reuses this writer's worktree and returns its path. */
46
49
  ensureWorktree,
47
50
  /** The subagent making the write, when there is one: it has a worktree of its own. */
48
- agentId) {
51
+ agentId,
52
+ /** The `auto-run` capability: `agent-ops run` starts from the main checkout. */
53
+ autoRun = false) {
49
54
  for (const path of paths) {
50
55
  const target = await canonicalPath(path);
56
+ if (autoRun && target === join(mainRoot, RUN_GOAL_FILE))
57
+ continue;
51
58
  const inMain = target === mainRoot || target.startsWith(`${mainRoot}${sep}`);
52
59
  if (inMain && insideWorktreeDirectory(mainRoot, target) && sessionId !== undefined) {
53
60
  // A worktree belongs to the session that made it: writing into another
54
61
  // session's mixes two tasks' changes into one review and one merge.
55
62
  const name = relative(join(mainRoot, WORKTREE_DIRECTORY), target).split(sep)[0] ?? "";
56
63
  const record = await readWorktreeRecord(join(mainRoot, WORKTREE_DIRECTORY, name));
57
- if (record !== null && !mayUseWorktree(record, sessionId, agentId)) {
64
+ const permitted = record === null || (record.runId === undefined
65
+ ? mayUseWorktree(record, sessionId, agentId)
66
+ : await registeredRunWriter(record, sessionId, agentId));
67
+ if (record !== null && !permitted) {
58
68
  const owner = record.sessionId;
59
69
  const sameSession = owner === sessionId;
60
70
  let own = "";
@@ -1,10 +1,15 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readdir } from "node:fs/promises";
1
3
  import { join } from "node:path";
2
4
  import { calculateConfigHash } from "../config/hash.js";
3
5
  import { AgentOpsError } from "../fs/paths.js";
6
+ import { sha256 } from "../fs/hash.js";
4
7
  import { resolveReviewScope } from "../review/scope.js";
5
8
  import { readPrivateFile, writePrivateFile } from "../security/permissions.js";
6
9
  import { checkTaskVerificationEvidence } from "../task/completion.js";
10
+ import { validateEvidence } from "../schema/validate.js";
7
11
  import { FileEvidenceStore } from "../verify/evidence.js";
12
+ import { REVIEW_ATTESTATION_DIRECTORY } from "../review/attestation.js";
8
13
  import { calculateSourceFingerprint } from "../verify/source-fingerprint.js";
9
14
  import { collectChangeSurface } from "../verify/change-surface.js";
10
15
  import { listWorktrees } from "./manage.js";
@@ -26,6 +31,111 @@ function runner(deps, cwd) {
26
31
  function markerPath(record) {
27
32
  return join(record.path, ".agent-ops", "tasks", "integrated-children.json");
28
33
  }
34
+ function journalPath(record) {
35
+ return join(record.path, ".agent-ops", "tasks", "integration-journal.json");
36
+ }
37
+ function deliveryPath(record) {
38
+ return join(record.path, ".agent-ops", "tasks", "delivery.json");
39
+ }
40
+ function deliveryArchivePath(record, child) {
41
+ return join(record.path, ".agent-ops", "tasks", "deliveries", `${child.name}.json`);
42
+ }
43
+ async function readJournal(record) {
44
+ const source = await readPrivateFile(journalPath(record), record.path);
45
+ if (source === null)
46
+ return null;
47
+ try {
48
+ const value = JSON.parse(source);
49
+ if (value.schemaVersion !== 1 || typeof value.transactionId !== "string" || value.transactionId.length === 0 || value.transactionId.length > 256 ||
50
+ !/^[a-f0-9]{40,64}$/u.test(value.expectedTarget ?? "") ||
51
+ !/^[a-f0-9]{40,64}$/u.test(value.coordinatorBefore ?? "") ||
52
+ !["prepared", "merging", "importing", "recorded", "rolled-back"].includes(String(value.status)) ||
53
+ !Array.isArray(value.steps) || value.steps.some((step) => typeof step !== "object" || step === null ||
54
+ typeof step.name !== "string" ||
55
+ !/^[a-f0-9]{40,64}$/u.test(step.commit ?? "") ||
56
+ !["changed", "no-change"].includes(String(step.kind)) ||
57
+ !["prepared", "merged", "imported", "recorded"].includes(String(step.status)) ||
58
+ !Array.isArray(step.taskIds) ||
59
+ !Array.isArray(step.sourceArtifacts))) {
60
+ throw new Error("invalid integration journal");
61
+ }
62
+ return value;
63
+ }
64
+ catch (cause) {
65
+ throw error("WORKTREE_INTEGRATION_JOURNAL_INVALID", "Integration journal is invalid; preserve the worktree for recovery.");
66
+ }
67
+ }
68
+ /** Read the coordinator's durable integration transaction for finish/recovery. */
69
+ export async function readIntegrationJournal(record) {
70
+ return await readJournal(record);
71
+ }
72
+ async function writeJournal(record, journal) {
73
+ await mkdir(join(record.path, ".agent-ops", "tasks"), { recursive: true });
74
+ await writePrivateFile(journalPath(record), `${JSON.stringify(journal, null, 2)}\n`, record.path);
75
+ }
76
+ /** Stable digest used by finish receipts to bind journal bookkeeping. */
77
+ export function integrationJournalDigest(journal) {
78
+ return sha256(JSON.stringify(journal));
79
+ }
80
+ /** Persist a coordinator journal update so recovery can resume the same transaction. */
81
+ export async function writeIntegrationJournal(record, journal) {
82
+ await writeJournal(record, journal);
83
+ }
84
+ async function readNoChangeDelivery(record) {
85
+ const source = await readPrivateFile(deliveryPath(record), record.path);
86
+ if (source === null)
87
+ return null;
88
+ try {
89
+ const value = JSON.parse(source);
90
+ if (value.schemaVersion !== 1 || value.deliveryKind !== "no-change" ||
91
+ typeof value.sourceCommit !== "string" || !/^[a-f0-9]{40,64}$/u.test(value.sourceCommit) ||
92
+ typeof value.deliveryDigest !== "string" || !/^[a-f0-9]{40,64}$/u.test(value.deliveryDigest) ||
93
+ typeof value.contractDigest !== "string" || !/^[a-f0-9]{40,64}$/u.test(value.contractDigest) ||
94
+ typeof value.reviewScope !== "string" || value.reviewScope.length === 0 ||
95
+ typeof value.runId !== "string" || typeof value.workerId !== "string" ||
96
+ !Number.isSafeInteger(value.generation) || value.generation < 1 ||
97
+ !Array.isArray(value.artifactRefs) || value.artifactRefs.some((ref) => typeof ref !== "string" || ref.length === 0 || ref.length > 4096 || ref.includes("\0"))) {
98
+ throw new Error("invalid no-change delivery");
99
+ }
100
+ return value;
101
+ }
102
+ catch {
103
+ throw error("WORKTREE_NO_CHANGE_DELIVERY_INVALID", `Child ${record.name} has an invalid no-change delivery manifest.`);
104
+ }
105
+ }
106
+ function parseNoChangeReviewScope(value) {
107
+ let parsed;
108
+ try {
109
+ parsed = JSON.parse(value);
110
+ }
111
+ catch {
112
+ throw error("WORKTREE_NO_CHANGE_SCOPE_INVALID", "No-change review scope is not valid JSON.");
113
+ }
114
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
115
+ throw error("WORKTREE_NO_CHANGE_SCOPE_INVALID", "No-change review scope is invalid.");
116
+ const scope = parsed;
117
+ const safePaths = (paths) => Array.isArray(paths) && paths.length > 0 && paths.every((path) => {
118
+ if (typeof path !== "string" || path.length === 0 || path.includes("\\") || path.startsWith("/") || path.includes("\0"))
119
+ return false;
120
+ return path.split("/").every((segment) => segment.length > 0 && segment !== "." && segment !== "..");
121
+ });
122
+ const noChange = scope.noChange === true;
123
+ if (scope.mode === "worktree" && safePaths(scope.changedFiles)) {
124
+ return { mode: "worktree", changedFiles: scope.changedFiles, ...(noChange ? { noChange: true } : {}) };
125
+ }
126
+ if (scope.mode === "base" && typeof scope.baseRef === "string" && typeof scope.resolvedBase === "string" &&
127
+ /^[a-f0-9]{40,64}$/u.test(scope.resolvedBase) && safePaths(scope.changedFiles)) {
128
+ return { mode: "base", baseRef: scope.baseRef, resolvedBase: scope.resolvedBase, changedFiles: scope.changedFiles, ...(noChange ? { noChange: true } : {}) };
129
+ }
130
+ throw error("WORKTREE_NO_CHANGE_SCOPE_INVALID", "No-change review scope must list at least one safe supporting path.");
131
+ }
132
+ /** Persist a coordinator-issued no-change handoff before the worker is fenced. */
133
+ export async function writeNoChangeDelivery(record, delivery) {
134
+ if (delivery.runId !== record.runId || delivery.workerId !== record.workerId || delivery.generation !== record.workerGeneration) {
135
+ throw error("WORKTREE_NO_CHANGE_OWNERSHIP", `No-change delivery does not match worktree ${record.name}.`);
136
+ }
137
+ await writePrivateFile(deliveryPath(record), `${JSON.stringify(delivery, null, 2)}\n`, record.path);
138
+ }
29
139
  async function previous(record) {
30
140
  const source = await readPrivateFile(markerPath(record), record.path);
31
141
  if (source === null)
@@ -46,10 +156,12 @@ async function previous(record) {
46
156
  return value;
47
157
  }
48
158
  /** Integrate only this session's agent-owned branches; local proof is never promoted as final proof. */
49
- export async function integrateSessionChildren(deps, coordinator, parentTaskId) {
159
+ export async function integrateSessionChildren(deps, coordinator, parentTaskId, eligibleRunWorkerIds) {
50
160
  const statuses = await listWorktrees(deps, coordinator.mainRoot);
51
161
  const children = statuses.map(({ record }) => record)
52
- .filter((record) => record.sessionId === coordinator.sessionId && record.agentId !== undefined)
162
+ .filter((record) => record.name !== coordinator.name && record.sessionId === coordinator.sessionId &&
163
+ (record.agentId !== undefined || (coordinator.runId !== undefined && record.runId === coordinator.runId)))
164
+ .filter(record => eligibleRunWorkerIds === undefined || (record.workerId !== undefined && eligibleRunWorkerIds.includes(record.workerId)))
53
165
  .sort((a, b) => a.name.localeCompare(b.name));
54
166
  const integrated = await previous(coordinator);
55
167
  if (children.length === 0)
@@ -74,9 +186,18 @@ export async function integrateSessionChildren(deps, coordinator, parentTaskId)
74
186
  throw error("WORKTREE_CHILD_MOVED", `Child ${record.name} changed after delivery; keep its worktree and replan integration.`);
75
187
  continue;
76
188
  }
77
- if ((await deps.git(record.path, ["merge-base", "--is-ancestor", record.base, head])).exitCode !== 0 ||
78
- Number(await git(deps, record.path, ["rev-list", "--count", `${record.base}..${head}`])) === 0) {
189
+ const ahead = Number(await git(deps, record.path, ["rev-list", "--count", `${record.base}..${head}`]));
190
+ const noChange = ahead === 0;
191
+ const manifest = noChange ? await readNoChangeDelivery(record) : null;
192
+ if (noChange && manifest === null)
79
193
  throw error("WORKTREE_CHILD_EMPTY", `Child ${record.name} has no committed delivery.`);
194
+ if (coordinator.runId !== undefined &&
195
+ (record.runId !== coordinator.runId || record.workerId === undefined || record.ownerSessionId === undefined || record.workerGeneration === undefined ||
196
+ (coordinator.coordinatorId !== undefined && record.coordinatorId !== coordinator.coordinatorId))) {
197
+ throw error("WORKTREE_CHILD_RUN_OWNERSHIP", `Child ${record.name} is not explicitly registered to run ${coordinator.runId}.`);
198
+ }
199
+ if (manifest !== null && (manifest.runId !== coordinator.runId || manifest.workerId !== record.workerId || manifest.generation !== record.workerGeneration || manifest.sourceCommit !== head)) {
200
+ throw error("WORKTREE_NO_CHANGE_OWNERSHIP", `Child ${record.name} no-change manifest does not match its registered worker and source.`);
80
201
  }
81
202
  const childConfig = await deps.loadConfig(record.path);
82
203
  if (calculateConfigHash(childConfig) !== configHash) {
@@ -84,12 +205,18 @@ export async function integrateSessionChildren(deps, coordinator, parentTaskId)
84
205
  }
85
206
  const tasks = await deps.tasks(record.path).list();
86
207
  const roots = tasks.filter(({ task }) => task.parentTaskId === undefined);
87
- if (roots.length !== 1 || tasks.some(({ status }) => status === "archived")) {
208
+ if (roots.length !== 1 || tasks.some(record => record.status === "archived" && record.supersededBy === undefined)) {
88
209
  throw error("WORKTREE_CHILD_TASK_INVALID", `Child ${record.name} must deliver one active task tree.`);
89
210
  }
90
- const scope = await resolveReviewScope({ root: record.path, runner: runner(deps, record.path), base: record.base });
211
+ const suppliedScope = manifest === null ? undefined : parseNoChangeReviewScope(manifest.reviewScope);
212
+ const scope = manifest === null
213
+ ? await resolveReviewScope({ root: record.path, runner: runner(deps, record.path), base: record.base })
214
+ : await resolveReviewScope({ root: record.path, runner: runner(deps, record.path), base: head,
215
+ noChangePaths: suppliedScope.changedFiles });
91
216
  const fingerprint = await calculateSourceFingerprint(record.path, scope, runner(deps, record.path));
92
217
  for (const task of tasks) {
218
+ if (task.supersededBy !== undefined)
219
+ continue;
93
220
  if (task.task.intent === undefined || task.task.intent.trim() === "") {
94
221
  throw error("WORKTREE_CHILD_INTENT_MISSING", `Child task ${task.task.id} needs its pre-work modification intent.`);
95
222
  }
@@ -101,17 +228,65 @@ export async function integrateSessionChildren(deps, coordinator, parentTaskId)
101
228
  throw error("WORKTREE_CHILD_UNVERIFIED", `Child task ${task.task.id}: ${problem.remedy}`);
102
229
  }
103
230
  }
104
- deliveries.push({ record, head, tasks });
231
+ deliveries.push({
232
+ record,
233
+ head,
234
+ tasks,
235
+ kind: noChange ? "no-change" : "changed",
236
+ sourceArtifacts: manifest?.artifactRefs ?? [],
237
+ reviewScope: manifest?.reviewScope ?? null,
238
+ deliveryDigest: manifest?.deliveryDigest ?? null,
239
+ contractDigest: manifest?.contractDigest ?? null,
240
+ runId: manifest?.runId ?? record.runId ?? null,
241
+ workerId: manifest?.workerId ?? record.workerId ?? null,
242
+ workerGeneration: manifest?.generation ?? record.workerGeneration ?? null
243
+ });
105
244
  }
106
245
  if (deliveries.length === 0)
107
246
  return integrated;
108
247
  const before = await git(deps, coordinator.path, ["rev-parse", "HEAD"]);
248
+ const expectedTarget = await git(deps, coordinator.mainRoot, ["rev-parse", "--verify", `${coordinator.targetBranch}^{commit}`]);
249
+ const priorJournal = await readJournal(coordinator);
250
+ if (priorJournal !== null && priorJournal.status !== "rolled-back" &&
251
+ priorJournal.expectedTarget !== expectedTarget && priorJournal.status !== "recorded") {
252
+ throw error("WORKTREE_INTEGRATION_TARGET_MOVED", "The integration target moved while a journal is pending; re-run final proof.");
253
+ }
254
+ let journal = priorJournal !== null && priorJournal.status !== "rolled-back"
255
+ ? priorJournal
256
+ : {
257
+ schemaVersion: 1,
258
+ transactionId: `integration-${randomUUID()}`,
259
+ expectedTarget,
260
+ coordinatorBefore: before,
261
+ status: "prepared",
262
+ steps: [],
263
+ updatedAt: new Date().toISOString()
264
+ };
265
+ await writeJournal(coordinator, journal);
109
266
  if ((await collectChangeSurface(runner(deps, coordinator.path))).paths.length > 0) {
110
267
  throw error("WORKTREE_DIRTY", "Commit the candidate worktree before integrating children.");
111
268
  }
112
269
  try {
113
270
  for (const delivery of deliveries) {
114
- if ((await deps.git(coordinator.path, ["merge-base", "--is-ancestor", delivery.head, "HEAD"])).exitCode !== 0) {
271
+ const priorStep = journal.steps.find((step) => step.name === delivery.record.name);
272
+ if (priorStep !== undefined && priorStep.commit !== delivery.head)
273
+ throw error("WORKTREE_CHILD_MOVED", `Child ${delivery.record.name} changed after its journaled delivery.`);
274
+ if (priorStep?.status === "recorded")
275
+ continue;
276
+ const step = {
277
+ name: delivery.record.name,
278
+ commit: delivery.head,
279
+ kind: delivery.kind,
280
+ status: "prepared",
281
+ taskIds: delivery.tasks.map(({ task }) => task.id),
282
+ sourceArtifacts: delivery.sourceArtifacts,
283
+ reviewScope: delivery.reviewScope,
284
+ deliveryDigest: delivery.deliveryDigest,
285
+ contractDigest: delivery.contractDigest
286
+ };
287
+ journal = { ...journal, status: "merging", steps: [...journal.steps.filter((candidate) => candidate.name !== delivery.record.name), step], updatedAt: new Date().toISOString() };
288
+ await writeJournal(coordinator, journal);
289
+ if (delivery.kind === "changed" && (await deps.git(coordinator.path, ["merge-base", "--is-ancestor", delivery.head, "HEAD"])).exitCode !== 0) {
115
290
  const merge = await deps.git(coordinator.path, ["merge", "--no-ff", "--no-edit", delivery.head]);
116
291
  if (merge.exitCode !== 0) {
117
292
  const files = (await deps.git(coordinator.path, ["diff", "--name-only", "--diff-filter=U"])).stdout.trim();
@@ -122,10 +297,48 @@ export async function integrateSessionChildren(deps, coordinator, parentTaskId)
122
297
  if (await git(deps, delivery.record.path, ["rev-parse", "HEAD"]) !== delivery.head) {
123
298
  throw error("WORKTREE_CHILD_MOVED", `Child ${delivery.record.name} moved during integration.`);
124
299
  }
300
+ journal = {
301
+ ...journal,
302
+ status: "merging",
303
+ steps: journal.steps.map((candidate) => candidate.name === delivery.record.name ? { ...candidate, status: "merged" } : candidate),
304
+ updatedAt: new Date().toISOString()
305
+ };
306
+ await writeJournal(coordinator, journal);
125
307
  }
308
+ journal = { ...journal, status: "importing", updatedAt: new Date().toISOString() };
309
+ await writeJournal(coordinator, journal);
310
+ for (const delivery of deliveries)
311
+ await copyDeliveryHistory(delivery.record, coordinator, delivery.tasks);
126
312
  await deps.tasks(coordinator.path).importDelivery(parentTaskId, deliveries.flatMap(({ tasks }) => tasks));
127
- const next = [...integrated, ...deliveries.map(({ record, head, tasks }) => ({
128
- name: record.name, commit: head, taskIds: tasks.map(({ task }) => task.id)
313
+ for (const delivery of deliveries) {
314
+ if (delivery.sourceArtifacts.length > 0) {
315
+ const manifest = await readPrivateFile(deliveryPath(delivery.record), delivery.record.path);
316
+ if (manifest !== null) {
317
+ await mkdir(join(coordinator.path, ".agent-ops", "tasks", "deliveries"), { recursive: true });
318
+ await writePrivateFile(deliveryArchivePath(coordinator, delivery.record), manifest, coordinator.path);
319
+ }
320
+ }
321
+ }
322
+ journal = { ...journal, status: "recorded", steps: journal.steps.map((step) => ({ ...step, status: "recorded" })), updatedAt: new Date().toISOString() };
323
+ await writeJournal(coordinator, journal);
324
+ const next = [...integrated, ...deliveries.map(({ record, head, tasks, kind, sourceArtifacts, reviewScope, deliveryDigest, contractDigest, runId, workerId, workerGeneration }) => ({
325
+ name: record.name,
326
+ commit: head,
327
+ taskIds: tasks.map(({ task }) => task.id),
328
+ ...(kind === "no-change" ? {
329
+ deliveryKind: kind,
330
+ ...(sourceArtifacts.length === 0 ? {} : { sourceArtifacts }),
331
+ ...(reviewScope === null ? {} : { reviewScope }),
332
+ ...(deliveryDigest === null ? {} : { deliveryDigest }),
333
+ ...(contractDigest === null ? {} : { contractDigest }),
334
+ ...(runId === null ? {} : { runId }),
335
+ ...(workerId === null ? {} : { workerId }),
336
+ ...(workerGeneration === null ? {} : { workerGeneration })
337
+ } : {
338
+ ...(runId === null ? {} : { runId }),
339
+ ...(workerId === null ? {} : { workerId }),
340
+ ...(workerGeneration === null ? {} : { workerGeneration })
341
+ })
129
342
  }))];
130
343
  await writePrivateFile(markerPath(coordinator), `${JSON.stringify(next, null, 2)}\n`, coordinator.path);
131
344
  return next;
@@ -133,6 +346,71 @@ export async function integrateSessionChildren(deps, coordinator, parentTaskId)
133
346
  catch (failure) {
134
347
  await deps.git(coordinator.path, ["merge", "--abort"]);
135
348
  await deps.git(coordinator.path, ["reset", "--hard", before]);
349
+ await writeJournal(coordinator, { ...journal, status: "rolled-back", updatedAt: new Date().toISOString() });
136
350
  throw failure;
137
351
  }
138
352
  }
353
+ /** Preserve immutable local proof for historical review; import never promotes it to final proof. */
354
+ async function copyDeliveryHistory(child, coordinator, tasks) {
355
+ const source = new FileEvidenceStore(child.path, child.path);
356
+ const target = new FileEvidenceStore(coordinator.path, coordinator.path);
357
+ const references = new Set(tasks.flatMap(record => [record.evidence,
358
+ ...(record.revisions ?? []).map(revision => revision.previousEvidence)].flatMap(evidence => Object.values(evidence).flat())));
359
+ for (const reference of references) {
360
+ if (reference.startsWith("review:"))
361
+ continue;
362
+ const checked = validateEvidence(await source.load(reference));
363
+ if (!checked.ok || !tasks.some(record => record.task.id === checked.value.taskId))
364
+ throw error("WORKTREE_CHILD_HISTORY_INVALID", "Historical evidence must resolve to the delivered task tree.");
365
+ const saved = await target.save(checked.value);
366
+ if (saved !== reference)
367
+ throw error("WORKTREE_CHILD_HISTORY_INVALID", "Historical evidence has a noncanonical identity.");
368
+ const artifact = checked.value.acceptance?.executionArtifact;
369
+ if (artifact === undefined)
370
+ continue;
371
+ const content = await readPrivateFile(join(child.path, artifact), child.path);
372
+ if (content === null || Buffer.byteLength(content) > 4 * 1024 * 1024 ||
373
+ artifact !== ".agent-ops/tasks/acceptance/" + sha256(content) + ".json")
374
+ throw error("WORKTREE_CHILD_HISTORY_INVALID", "Acceptance execution history is missing or changed.");
375
+ const destination = join(coordinator.path, artifact);
376
+ const existing = await readPrivateFile(destination, coordinator.path);
377
+ if (existing !== null && existing !== content)
378
+ throw error("WORKTREE_CHILD_HISTORY_INVALID", "Acceptance artifact collision.");
379
+ if (existing === null)
380
+ await writePrivateFile(destination, content, coordinator.path);
381
+ }
382
+ const directory = join(child.path, REVIEW_ATTESTATION_DIRECTORY);
383
+ let names;
384
+ try {
385
+ names = await readdir(directory);
386
+ }
387
+ catch (cause) {
388
+ if (cause.code !== "ENOENT")
389
+ throw cause;
390
+ names = [];
391
+ }
392
+ for (const name of names.filter(name => /^[a-f0-9]{64}\.[a-z][a-z0-9-]{0,127}\.reports\.json$/u.test(name))) {
393
+ const content = await readPrivateFile(join(directory, name), child.path);
394
+ if (content === null || Buffer.byteLength(content) > 512 * 1024)
395
+ continue;
396
+ let value;
397
+ try {
398
+ value = JSON.parse(content);
399
+ }
400
+ catch {
401
+ continue;
402
+ }
403
+ if (typeof value !== "object" || value === null || Array.isArray(value))
404
+ continue;
405
+ const artifact = value;
406
+ if (artifact.status !== "FAIL" || !tasks.some(task => task.task.id === artifact.taskId) ||
407
+ name !== artifact.sourceFingerprint + "." + artifact.taskId + ".reports.json")
408
+ continue;
409
+ const destination = join(coordinator.path, REVIEW_ATTESTATION_DIRECTORY, name);
410
+ const existing = await readPrivateFile(destination, coordinator.path);
411
+ if (existing !== null && existing !== content)
412
+ throw error("WORKTREE_CHILD_HISTORY_INVALID", "Failed review history collision.");
413
+ if (existing === null)
414
+ await writePrivateFile(destination, content, coordinator.path);
415
+ }
416
+ }