@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
@@ -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 = "";
@@ -0,0 +1,416 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readdir } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { calculateConfigHash } from "../config/hash.js";
5
+ import { AgentOpsError } from "../fs/paths.js";
6
+ import { sha256 } from "../fs/hash.js";
7
+ import { resolveReviewScope } from "../review/scope.js";
8
+ import { readPrivateFile, writePrivateFile } from "../security/permissions.js";
9
+ import { checkTaskVerificationEvidence } from "../task/completion.js";
10
+ import { validateEvidence } from "../schema/validate.js";
11
+ import { FileEvidenceStore } from "../verify/evidence.js";
12
+ import { REVIEW_ATTESTATION_DIRECTORY } from "../review/attestation.js";
13
+ import { calculateSourceFingerprint } from "../verify/source-fingerprint.js";
14
+ import { collectChangeSurface } from "../verify/change-surface.js";
15
+ import { listWorktrees } from "./manage.js";
16
+ function error(code, message) {
17
+ return new AgentOpsError(code, message);
18
+ }
19
+ async function git(deps, cwd, args) {
20
+ const result = await deps.git(cwd, args);
21
+ if (result.exitCode !== 0)
22
+ throw error("WORKTREE_INTEGRATION_FAILED", `${args.join(" ")}: ${result.stderr.trim()}`);
23
+ return result.stdout.trim();
24
+ }
25
+ function runner(deps, cwd) {
26
+ return { run: async (args) => {
27
+ const result = await deps.git(cwd, args);
28
+ return { exitCode: result.exitCode, stdout: Buffer.from(result.stdout, "utf8") };
29
+ } };
30
+ }
31
+ function markerPath(record) {
32
+ return join(record.path, ".agent-ops", "tasks", "integrated-children.json");
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
+ }
139
+ async function previous(record) {
140
+ const source = await readPrivateFile(markerPath(record), record.path);
141
+ if (source === null)
142
+ return [];
143
+ let value;
144
+ try {
145
+ value = JSON.parse(source);
146
+ }
147
+ catch {
148
+ value = null;
149
+ }
150
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "object" || item === null ||
151
+ typeof item.name !== "string" || typeof item.commit !== "string" ||
152
+ !/^[a-f0-9]{40,64}$/u.test(item.commit) ||
153
+ !Array.isArray(item.taskIds) || item.taskIds.some((id) => typeof id !== "string"))) {
154
+ throw error("WORKTREE_INTEGRATION_STATE_INVALID", "Child integration record is invalid.");
155
+ }
156
+ return value;
157
+ }
158
+ /** Integrate only this session's agent-owned branches; local proof is never promoted as final proof. */
159
+ export async function integrateSessionChildren(deps, coordinator, parentTaskId, eligibleRunWorkerIds) {
160
+ const statuses = await listWorktrees(deps, coordinator.mainRoot);
161
+ const children = statuses.map(({ record }) => record)
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)))
165
+ .sort((a, b) => a.name.localeCompare(b.name));
166
+ const integrated = await previous(coordinator);
167
+ if (children.length === 0)
168
+ return integrated;
169
+ const config = await deps.loadConfig(coordinator.path);
170
+ const configHash = calculateConfigHash(config);
171
+ const deliveries = [];
172
+ const existing = new Map(integrated.map((item) => [item.name, item]));
173
+ for (const record of children) {
174
+ if (record.mainRoot !== coordinator.mainRoot || record.targetBranch !== coordinator.targetBranch ||
175
+ record.name === coordinator.name ||
176
+ await git(deps, record.path, ["symbolic-ref", "--short", "HEAD"]) !== record.branch) {
177
+ throw error("WORKTREE_CHILD_MISMATCH", `Child ${record.name} does not belong to this candidate branch.`);
178
+ }
179
+ if ((await collectChangeSurface(runner(deps, record.path))).paths.length > 0) {
180
+ throw error("WORKTREE_CHILD_DIRTY", `Commit the changes in child ${record.name} before integration.`);
181
+ }
182
+ const head = await git(deps, record.path, ["rev-parse", "HEAD"]);
183
+ const prior = existing.get(record.name);
184
+ if (prior !== undefined) {
185
+ if (prior.commit !== head)
186
+ throw error("WORKTREE_CHILD_MOVED", `Child ${record.name} changed after delivery; keep its worktree and replan integration.`);
187
+ continue;
188
+ }
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)
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.`);
201
+ }
202
+ const childConfig = await deps.loadConfig(record.path);
203
+ if (calculateConfigHash(childConfig) !== configHash) {
204
+ throw error("WORKTREE_CHILD_CONFIG_CHANGED", `Child ${record.name} uses a different verifier policy.`);
205
+ }
206
+ const tasks = await deps.tasks(record.path).list();
207
+ const roots = tasks.filter(({ task }) => task.parentTaskId === undefined);
208
+ if (roots.length !== 1 || tasks.some(record => record.status === "archived" && record.supersededBy === undefined)) {
209
+ throw error("WORKTREE_CHILD_TASK_INVALID", `Child ${record.name} must deliver one active task tree.`);
210
+ }
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 });
216
+ const fingerprint = await calculateSourceFingerprint(record.path, scope, runner(deps, record.path));
217
+ for (const task of tasks) {
218
+ if (task.supersededBy !== undefined)
219
+ continue;
220
+ if (task.task.intent === undefined || task.task.intent.trim() === "") {
221
+ throw error("WORKTREE_CHILD_INTENT_MISSING", `Child task ${task.task.id} needs its pre-work modification intent.`);
222
+ }
223
+ const problem = await checkTaskVerificationEvidence(task, {
224
+ root: record.path, config: childConfig, sourceFingerprint: fingerprint,
225
+ evidenceStore: new FileEvidenceStore(record.path, record.path)
226
+ });
227
+ if (problem !== null) {
228
+ throw error("WORKTREE_CHILD_UNVERIFIED", `Child task ${task.task.id}: ${problem.remedy}`);
229
+ }
230
+ }
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
+ });
244
+ }
245
+ if (deliveries.length === 0)
246
+ return integrated;
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);
266
+ if ((await collectChangeSurface(runner(deps, coordinator.path))).paths.length > 0) {
267
+ throw error("WORKTREE_DIRTY", "Commit the candidate worktree before integrating children.");
268
+ }
269
+ try {
270
+ for (const delivery of deliveries) {
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) {
290
+ const merge = await deps.git(coordinator.path, ["merge", "--no-ff", "--no-edit", delivery.head]);
291
+ if (merge.exitCode !== 0) {
292
+ const files = (await deps.git(coordinator.path, ["diff", "--name-only", "--diff-filter=U"])).stdout.trim();
293
+ await deps.git(coordinator.path, ["merge", "--abort"]);
294
+ throw error("WORKTREE_INTEGRATION_CONFLICT", `Child ${delivery.record.name} conflicts in ${files || "unknown files"}. Resolve both recorded intents before retrying.`);
295
+ }
296
+ }
297
+ if (await git(deps, delivery.record.path, ["rev-parse", "HEAD"]) !== delivery.head) {
298
+ throw error("WORKTREE_CHILD_MOVED", `Child ${delivery.record.name} moved during integration.`);
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);
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);
312
+ await deps.tasks(coordinator.path).importDelivery(parentTaskId, deliveries.flatMap(({ tasks }) => tasks));
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
+ })
342
+ }))];
343
+ await writePrivateFile(markerPath(coordinator), `${JSON.stringify(next, null, 2)}\n`, coordinator.path);
344
+ return next;
345
+ }
346
+ catch (failure) {
347
+ await deps.git(coordinator.path, ["merge", "--abort"]);
348
+ await deps.git(coordinator.path, ["reset", "--hard", before]);
349
+ await writeJournal(coordinator, { ...journal, status: "rolled-back", updatedAt: new Date().toISOString() });
350
+ throw failure;
351
+ }
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
+ }
@@ -3,7 +3,7 @@ import { join, resolve } from "node:path";
3
3
  import { sha256 } from "../fs/hash.js";
4
4
  import { AgentOpsError } from "../fs/paths.js";
5
5
  import { collectChangeSurface } from "../verify/change-surface.js";
6
- import { assertSessionId, assertWorktreeName, bindSession, insideWorktreeDirectory, parseWorktreeListPorcelain, readWorktreeRecord, removeCheckout, resolveCheckouts, worktreePath, writeWorktreeRecord } from "./service.js";
6
+ import { assertSessionId, assertWorktreeName, bindSession, insideWorktreeDirectory, parseWorktreeListPorcelain, readWorktreeRecord, assertRunWorktreeOwnership, removeCheckout, resolveCheckouts, worktreePath, writeWorktreeRecord } from "./service.js";
7
7
  /**
8
8
  * A baseline no source state hashes to. A resumed session inherits whatever
9
9
  * the last one left in the worktree, so none of it counts as "unchanged".
@@ -180,6 +180,9 @@ export async function commitWorktree(deps, options) {
180
180
  if (record.sessionId !== options.sessionId) {
181
181
  throw manageError("WORKTREE_COMMIT_FOREIGN", `${record.path} belongs to session ${record.sessionId}, not ${options.sessionId}.`);
182
182
  }
183
+ if (options.runOwnership !== undefined) {
184
+ assertRunWorktreeOwnership(record, options.runOwnership);
185
+ }
183
186
  const step = async (args, code, message) => {
184
187
  const result = await deps.git(record.path, args);
185
188
  if (result.exitCode !== 0) {
@@ -0,0 +1,153 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { sha256 } from "../fs/hash.js";
4
+ import { AgentOpsError } from "../fs/paths.js";
5
+ import { findReviewAttestation, readReviewReportArtifact, REVIEW_ATTESTATION_DIRECTORY } from "../review/attestation.js";
6
+ import { resolveReviewScope } from "../review/scope.js";
7
+ import { readPrivateFile, writePrivateFile } from "../security/permissions.js";
8
+ import { checkTaskCompletionEvidence } from "../task/completion.js";
9
+ import { FileEvidenceStore } from "../verify/evidence.js";
10
+ import { validateEvidence } from "../schema/validate.js";
11
+ import { calculateSourceFingerprint } from "../verify/source-fingerprint.js";
12
+ import { validateRunIntegrationReceipt } from "../run/integration.js";
13
+ function seal(value) {
14
+ return { digest: sha256(JSON.stringify(value)), value };
15
+ }
16
+ function validSeal(value) {
17
+ return value.digest === sha256(JSON.stringify(value.value));
18
+ }
19
+ function receiptPath(commonDir, record, head) {
20
+ return join(commonDir, "agent-ops", "receipts", `${sha256(record.sessionId)}-${head}.json`);
21
+ }
22
+ /** Complete local proof is copied into the Git common directory before the branch moves. */
23
+ export async function prepareFinishReceipt(options) {
24
+ const { record, candidateHead, targetCommit, tasks, config } = options;
25
+ if (options.integrationJournal !== undefined) {
26
+ validateRunIntegrationReceipt(options.integrationJournal, {
27
+ candidateHead, targetCommit, integrationJournal: options.integrationJournal
28
+ });
29
+ }
30
+ const scope = options.noChange?.reviewScope ?? await resolveReviewScope({ root: record.path, runner: options.gitRunner, base: targetCommit });
31
+ if (options.noChange !== undefined && options.noChange.sourceCommit !== candidateHead) {
32
+ throw new AgentOpsError("WORKTREE_NO_CHANGE_SOURCE_MISMATCH", "Verified-no-change receipt must pin the current candidate commit.");
33
+ }
34
+ const sourceFingerprint = await calculateSourceFingerprint(record.path, scope, options.gitRunner);
35
+ if (sourceFingerprint !== options.expectedFingerprint) {
36
+ throw new AgentOpsError("WORKTREE_FINAL_SOURCE_CHANGED", "The final candidate no longer matches the reviewed fingerprint.");
37
+ }
38
+ const evidenceStore = new FileEvidenceStore(record.path, record.path);
39
+ const verification = {};
40
+ const executionArtifacts = {};
41
+ const reviews = {};
42
+ const residualRisks = new Set();
43
+ const modes = new Set();
44
+ for (const task of tasks) {
45
+ const problem = await checkTaskCompletionEvidence(task, {
46
+ root: record.path, config, sourceFingerprint, evidenceStore
47
+ });
48
+ if (problem !== null) {
49
+ throw new AgentOpsError("WORKTREE_FINAL_EVIDENCE_MISSING", `Task ${task.task.id}: ${problem.remedy}`);
50
+ }
51
+ for (const references of [task.evidence, ...(task.revisions ?? []).map(revision => revision.previousEvidence)]
52
+ .flatMap(evidence => Object.values(evidence))) {
53
+ for (const reference of references) {
54
+ if (reference.startsWith("review:"))
55
+ continue;
56
+ const value = await evidenceStore.load(reference);
57
+ if (value === null)
58
+ throw new AgentOpsError("WORKTREE_FINAL_EVIDENCE_MISSING", `Missing ${reference}.`);
59
+ verification[reference] = seal(value);
60
+ const checked = validateEvidence(value);
61
+ if (!checked.ok)
62
+ throw new AgentOpsError("WORKTREE_FINAL_EVIDENCE_MISSING", "Historical verification evidence is invalid.");
63
+ const artifact = checked.value.acceptance?.executionArtifact;
64
+ if (artifact !== undefined) {
65
+ const content = await readPrivateFile(join(record.path, artifact), record.path);
66
+ if (content === null || Buffer.byteLength(content) > 4 * 1024 * 1024 ||
67
+ artifact !== ".agent-ops/tasks/acceptance/" + sha256(content) + ".json")
68
+ throw new AgentOpsError("WORKTREE_FINAL_EVIDENCE_MISSING", "Paired execution artifact is missing or changed.");
69
+ executionArtifacts[artifact] = seal(content);
70
+ }
71
+ }
72
+ }
73
+ const attestation = await findReviewAttestation(record.path, sourceFingerprint, task.task.id);
74
+ const report = await readReviewReportArtifact(record.path, sourceFingerprint, task.task.id);
75
+ if (attestation === null || report === null || report.status !== "PASS" || report.taskId !== task.task.id) {
76
+ throw new AgentOpsError("WORKTREE_FINAL_EVIDENCE_MISSING", `Missing review for ${task.task.id}.`);
77
+ }
78
+ modes.add(attestation.tree === undefined ? "per-task-fallback" : "tree");
79
+ for (const risk of [...(report.report?.residualRisks ?? []), ...(report.adversarial?.report.residualRisks ?? [])]) {
80
+ residualRisks.add(risk);
81
+ }
82
+ reviews[task.task.id] = { attestation: seal(attestation), report: seal(report) };
83
+ }
84
+ if (modes.size !== 1 || (modes.has("tree") && new Set(Object.values(reviews)
85
+ .map(({ attestation }) => JSON.stringify(attestation.value.tree))).size !== 1)) {
86
+ throw new AgentOpsError("WORKTREE_FINAL_REVIEW_MISMATCH", "Final tasks do not share one complete review mode and tree scope.");
87
+ }
88
+ const historicalReviews = {};
89
+ let names;
90
+ try {
91
+ names = await readdir(join(record.path, REVIEW_ATTESTATION_DIRECTORY));
92
+ }
93
+ catch (cause) {
94
+ if (cause.code === "ENOENT")
95
+ names = [];
96
+ else
97
+ throw cause;
98
+ }
99
+ for (const name of names.filter(name => /^[a-f0-9]{64}\.[a-z][a-z0-9-]{0,127}\.reports\.json$/u.test(name))) {
100
+ const path = REVIEW_ATTESTATION_DIRECTORY + "/" + name;
101
+ const content = await readPrivateFile(join(record.path, path), record.path);
102
+ if (content === null || Buffer.byteLength(content) > 512 * 1024)
103
+ continue;
104
+ let artifact;
105
+ try {
106
+ artifact = JSON.parse(content);
107
+ }
108
+ catch {
109
+ continue;
110
+ }
111
+ if (artifact === null || typeof artifact !== "object" || artifact.status !== "FAIL" ||
112
+ !tasks.some(task => task.task.id === artifact.taskId) ||
113
+ name !== artifact.sourceFingerprint + "." + artifact.taskId + ".reports.json")
114
+ continue;
115
+ historicalReviews[path] = seal(content);
116
+ }
117
+ const receipt = {
118
+ schemaVersion: 1,
119
+ sessionId: record.sessionId,
120
+ candidateHead,
121
+ targetCommit,
122
+ sourceFingerprint,
123
+ reviewMode: modes.has("tree") ? "tree" : "per-task-fallback",
124
+ tasks,
125
+ children: options.children,
126
+ verification,
127
+ executionArtifacts,
128
+ historicalReviews,
129
+ reviews,
130
+ residualRisks: [...residualRisks],
131
+ createdAt: new Date().toISOString(),
132
+ ...(options.noChange === undefined ? {} : {
133
+ noChange: {
134
+ sourceCommit: options.noChange.sourceCommit,
135
+ contractDigest: options.noChange.contractDigest,
136
+ reviewScope: JSON.stringify(options.noChange.reviewScope),
137
+ artifactRefs: options.noChange.artifactRefs
138
+ }
139
+ }),
140
+ ...(options.integrationJournal === undefined ? {} : { integrationJournal: options.integrationJournal })
141
+ };
142
+ const content = `${JSON.stringify(receipt, null, 2)}\n`;
143
+ const path = receiptPath(options.commonDir, record, candidateHead);
144
+ await writePrivateFile(path, content, options.commonDir);
145
+ const reread = await readPrivateFile(path, options.commonDir);
146
+ if (reread !== content || Object.values(verification).some((item) => !validSeal(item)) ||
147
+ Object.values(executionArtifacts).some(item => !validSeal(item)) ||
148
+ Object.values(historicalReviews).some(item => !validSeal(item)) ||
149
+ Object.values(reviews).some(({ attestation, report }) => !validSeal(attestation) || !validSeal(report))) {
150
+ throw new AgentOpsError("WORKTREE_RECEIPT_INVALID", "The local finish receipt could not be read back intact.");
151
+ }
152
+ return { path, digest: sha256(content), receipt };
153
+ }