@aviaratech/ai-delivery 0.3.5 → 0.3.7

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 (38) hide show
  1. package/README.md +65 -1
  2. package/dist/agent.d.ts +1 -1
  3. package/dist/agent.js +1 -1
  4. package/dist/agent.js.map +1 -1
  5. package/dist/cli.js +35 -1
  6. package/dist/cli.js.map +1 -1
  7. package/dist/dispatch.js +21 -1
  8. package/dist/dispatch.js.map +1 -1
  9. package/dist/github/commandIdentityPolicy.js +1 -0
  10. package/dist/github/commandIdentityPolicy.js.map +1 -1
  11. package/dist/issue.d.ts +1 -0
  12. package/dist/issue.js +22 -10
  13. package/dist/issue.js.map +1 -1
  14. package/dist/lifecycle.test.js +470 -4
  15. package/dist/lifecycle.test.js.map +1 -1
  16. package/dist/mcp/index.js +1 -1
  17. package/dist/mcp/tools.d.ts +31 -0
  18. package/dist/mcp/tools.js +27 -0
  19. package/dist/mcp/tools.js.map +1 -1
  20. package/dist/pr.d.ts +1 -1
  21. package/dist/services/worktreeRegistry.d.ts +12 -0
  22. package/dist/services/worktreeRegistry.js +146 -4
  23. package/dist/services/worktreeRegistry.js.map +1 -1
  24. package/dist/verification.d.ts +34 -0
  25. package/dist/verification.js +488 -64
  26. package/dist/verification.js.map +1 -1
  27. package/dist/worktree.d.ts +3 -0
  28. package/dist/worktree.js +38 -8
  29. package/dist/worktree.js.map +1 -1
  30. package/dist/worktreeTransition.d.ts +173 -0
  31. package/dist/worktreeTransition.js +1012 -0
  32. package/dist/worktreeTransition.js.map +1 -0
  33. package/dist/worktreeTransition.test.d.ts +1 -0
  34. package/dist/worktreeTransition.test.js +1179 -0
  35. package/dist/worktreeTransition.test.js.map +1 -0
  36. package/package.json +1 -1
  37. package/plugins/ai-delivery/.claude-plugin/plugin.json +1 -1
  38. package/plugins/ai-delivery/skills/worktree-lifecycle/SKILL.md +6 -0
@@ -0,0 +1,1012 @@
1
+ import { lstatSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
2
+ import { dirname, join, relative, resolve } from 'node:path';
3
+ import { gunzipSync } from 'node:zlib';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { z } from 'zod';
6
+ import { assertPrivateFile, DigestSchema, digestBytes, digestValue, ShaSchema, stableJson, writeCreateOnly, } from './delivery/common.js';
7
+ import { DeliveryError } from './errors.js';
8
+ import { assertClean, defaultBaseRef, git, gitCommonDir, gitExitCode, gitRoot, primaryGitRoot } from './git.js';
9
+ import { createDeliveryGitHubClients } from './github/client.js';
10
+ import { assertDeliveryRuntimeAdmitted, RuntimeAdmissionSchema, validateRuntimeAdmission, } from './services/deliveryAdmission.js';
11
+ import { assertWorktreeTransitionWriterQuiescent, readWorktreeTransitionRun, retainedWorktreeTransitionProducerDigest, retainedWorktreeTransitionDependencyManifests, } from './verification.js';
12
+ import { assertTransitionOwnerWitness, listWorktreesStrict, withWorktreeTransitionRegistry, } from './services/worktreeRegistry.js';
13
+ import { withWorktreeTransitionWriterAbsent, withWorktreeTransitionWriterLease } from './verification.js';
14
+ import { removeMergedSourceForTransition } from './worktree.js';
15
+ const EvidenceFileSchema = z.strictObject({
16
+ path: z.string().min(1),
17
+ source: z.string().min(1),
18
+ digest: DigestSchema,
19
+ size: z.number().int().nonnegative().safe(),
20
+ });
21
+ const RowSchema = z.strictObject({
22
+ branch: z.string().min(1),
23
+ createdAt: z.string().min(1),
24
+ identity: z.string().min(1),
25
+ issueNumber: z.number().int().positive(),
26
+ path: z.string().min(1),
27
+ prNumber: z.number().int().positive().optional(),
28
+ status: z.enum(['active', 'error', 'merged', 'pr-published', 'stale']),
29
+ type: z.literal('issue'),
30
+ updatedAt: z.string().min(1),
31
+ });
32
+ const NativePrSchema = z.strictObject({
33
+ prNumber: z.number().int().positive(),
34
+ headSha: ShaSchema,
35
+ headBranch: z.string().min(1),
36
+ baseSha: ShaSchema,
37
+ baseBranch: z.string().min(1),
38
+ mergeSha: ShaSchema.nullable(),
39
+ merged: z.boolean(),
40
+ authorLogin: z.string().min(1),
41
+ });
42
+ export const WorktreeTransitionPlanSchema = z
43
+ .strictObject({
44
+ schemaVersion: z.literal('ai-delivery.worktree-transition-plan@1'),
45
+ planId: DigestSchema,
46
+ repository: z.string().min(1),
47
+ repoRoot: z.string().min(1),
48
+ row: RowSchema,
49
+ purpose: z.enum(['active-resume', 'merged-cleanup']),
50
+ disposition: z.enum(['retain', 'remove']),
51
+ head: z.strictObject({ sha: ShaSchema, tree: ShaSchema }),
52
+ terminalPrNumber: z.number().int().positive().nullable(),
53
+ lineage: z.array(NativePrSchema),
54
+ retainedHoldCommentIds: z.array(z.number().int().positive()),
55
+ remoteRefs: z.strictObject({ headSha: ShaSchema.nullable(), baseSha: ShaSchema }).nullable(),
56
+ holdEvidence: z.array(z.strictObject({ commentId: z.number().int().positive(), digest: DigestSchema })),
57
+ inventory: z.array(EvidenceFileSchema),
58
+ inventoryId: DigestSchema,
59
+ historicalProducer: z.literal('UNKNOWN'),
60
+ closure: z.strictObject({
61
+ family: z.literal('public-ai-delivery-0.3.5-posix@1'),
62
+ admissionPath: z.string().min(1),
63
+ archivePath: z.string().min(1),
64
+ admission: RuntimeAdmissionSchema,
65
+ admissionBytesDigest: DigestSchema,
66
+ producerDigest: DigestSchema,
67
+ runManifestIds: z.array(DigestSchema).min(1),
68
+ }),
69
+ currentRuntime: RuntimeAdmissionSchema,
70
+ policyDigest: DigestSchema,
71
+ operator: z.strictObject({
72
+ actorLogin: z.string().min(1),
73
+ credentialIdentity: z.string().regex(/^user:[1-9]\d*$/u),
74
+ }),
75
+ reviewerActor: z.string().min(1),
76
+ })
77
+ .superRefine((plan, context) => {
78
+ const { planId, ...content } = plan;
79
+ if (planId !== digestValue(content) ||
80
+ plan.inventoryId !== digestValue(plan.inventory) ||
81
+ (plan.disposition === 'remove' &&
82
+ (plan.purpose !== 'merged-cleanup' || plan.retainedHoldCommentIds.length !== 0)) ||
83
+ (plan.purpose === 'active-resume' && plan.terminalPrNumber !== null) ||
84
+ (plan.purpose === 'merged-cleanup' && plan.terminalPrNumber === null) ||
85
+ (plan.disposition === 'remove' && plan.remoteRefs === null))
86
+ context.addIssue({ code: 'custom', message: 'Transition plan identity or disposition is invalid.' });
87
+ });
88
+ // A finite, audited producer family. An arbitrary caller-supplied hash never expands this allowlist.
89
+ const PUBLIC_PRODUCER_ARCHIVE = 'sha256:c561a94b7727ad5dfc520cbdcad803b9eaddfd49d334ccbc9253edeede26994e';
90
+ function retainedProducer(context, admissionPath, archivePath) {
91
+ if (!context.configuration)
92
+ throw new DeliveryError('Transition requires the current repository configuration.');
93
+ const bytes = assertPrivateFile(admissionPath);
94
+ const admission = RuntimeAdmissionSchema.parse(JSON.parse(bytes.toString('utf8')));
95
+ if (admission.packageVersion !== '0.3.5' || admission.sourceArchiveSha256 !== PUBLIC_PRODUCER_ARCHIVE)
96
+ throw new DeliveryError('Retained operational producer is outside the supported public 0.3.5 family.');
97
+ validateRuntimeAdmission(admission, { ...context.configuration, configDigest: admission.configDigest }, admission.cliPath);
98
+ const archive = evidenceBytes(archivePath);
99
+ if (digestBytes(archive) !== PUBLIC_PRODUCER_ARCHIVE)
100
+ throw new DeliveryError('Retained official public archive bytes disagree.');
101
+ // The archive is authenticated by the fixed distribution digest before its finite regular-file tar inventory is read.
102
+ const tar = gunzipSync(archive, { maxOutputLength: 8 * 1024 * 1024 });
103
+ const packageRoot = dirname(dirname(admission.cliPath));
104
+ const inventory = [];
105
+ const record = (source, path) => {
106
+ const bytes = evidenceBytes(source);
107
+ inventory.push({ source, path, digest: digestBytes(bytes), size: bytes.length });
108
+ };
109
+ let count = 0;
110
+ for (let offset = 0; offset + 512 <= tar.length;) {
111
+ const header = tar.subarray(offset, offset + 512);
112
+ if (header.every((byte) => byte === 0))
113
+ break;
114
+ const name = header.subarray(0, 100).toString('utf8').replace(/\0.*$/su, '');
115
+ const size = Number.parseInt(header.subarray(124, 136).toString('ascii').replace(/\0.*$/su, '').trim(), 8);
116
+ if (header[156] !== 48 ||
117
+ !/^package\/(?!.*(?:^|\/)\.\.\/)[A-Za-z0-9_./-]+$/u.test(name) ||
118
+ !Number.isSafeInteger(size) ||
119
+ size < 0)
120
+ throw new DeliveryError('Retained public archive inventory is unsupported.');
121
+ const archived = tar.subarray(offset + 512, offset + 512 + size);
122
+ const installedPath = resolve(packageRoot, name.slice('package/'.length));
123
+ const installed = evidenceBytes(installedPath);
124
+ if (archived.length !== size || !installed.equals(archived))
125
+ throw new DeliveryError(`Retained producer bytes drifted: ${name}`);
126
+ count += 1;
127
+ record(installedPath, `retained/${name}`);
128
+ offset += 512 + Math.ceil(size / 512) * 512;
129
+ }
130
+ if (count !== 155)
131
+ throw new DeliveryError('Retained public producer inventory is incomplete.');
132
+ record(admissionPath, 'retained/admission.json');
133
+ record(archivePath, 'retained/public-archive.tar.gz');
134
+ const dependencies = retainedWorktreeTransitionDependencyManifests(dirname(admission.cliPath));
135
+ for (const file of dependencies)
136
+ record(file.source, `retained/dependency-manifests/${digestValue(file.source).slice(7)}.json`);
137
+ inventory.sort((left, right) => left.path.localeCompare(right.path));
138
+ return {
139
+ admissionPath,
140
+ archivePath,
141
+ admission,
142
+ admissionBytesDigest: digestBytes(bytes),
143
+ producerDigest: retainedWorktreeTransitionProducerDigest(dirname(admission.cliPath)),
144
+ inventory,
145
+ };
146
+ }
147
+ function requireSupportedRunClosure(root, row, inventory, producerDigest, head) {
148
+ const runs = inventory
149
+ .filter((file) => file.path.startsWith('git/ai-delivery/runs@2/'))
150
+ .map((file) => readWorktreeTransitionRun(evidenceBytes(file.source)));
151
+ if (runs.length === 0 ||
152
+ !runs.some((run) => run.classification.head.sha === head.sha && run.classification.head.tree === head.tree) ||
153
+ runs.some((run) => run.schemaVersion !== 'ai-delivery.run@3' ||
154
+ run.writer?.worktreeDigest !== digestValue(row.path) ||
155
+ run.writer.producerDigest !== producerDigest ||
156
+ run.classification.repository === '' ||
157
+ run.classification.requiredStages.some((stage) => !['source_only', 'focused_node', 'canonical_verify'].includes(stage.resourceClass)) ||
158
+ (run.classification.requiredStages.some((stage) => stage.commands.length > 0) && run.resources === undefined)))
159
+ throw new DeliveryError('Complete operational run inventory lacks supported producer-bound closure for the exact retained source head/tree.');
160
+ for (const run of runs)
161
+ for (const receipt of run.stageReceipts) {
162
+ const version = receipt.input.schemaVersion === 'ai-delivery.stage-input@2' ? 2 : 1;
163
+ const checkpoint = join(gitCommonDir(root), 'ai-delivery', `verification@${String(version)}`, 'stages', receipt.input.stageId, `${receipt.input.inputId.slice(7)}.json`);
164
+ if (stableJson(JSON.parse(assertPrivateFile(checkpoint).toString('utf8'))) !== stableJson(receipt))
165
+ throw new DeliveryError('Operational run inventory has a missing or conflicting stage checkpoint.');
166
+ }
167
+ return runs.map((run) => run.manifestId).sort();
168
+ }
169
+ function present(path) {
170
+ try {
171
+ lstatSync(path);
172
+ return true;
173
+ }
174
+ catch (error) {
175
+ if (error.code === 'ENOENT')
176
+ return false;
177
+ throw error;
178
+ }
179
+ }
180
+ function evidenceBytes(path) {
181
+ const metadata = lstatSync(path);
182
+ if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.nlink !== 1 || realpathSync(path) !== path)
183
+ throw new DeliveryError('Historical evidence requires canonical regular files without links.');
184
+ return readFileSync(path);
185
+ }
186
+ /** Read-only, deterministic inventory. Unknown historical schemas remain opaque bytes. */
187
+ export function inventoryTransitionEvidence(root, row, removed = false) {
188
+ const common = gitCommonDir(root);
189
+ const files = new Map();
190
+ const heads = new Set();
191
+ if (present(row.path))
192
+ heads.add(git(row.path, 'rev-parse', 'HEAD'));
193
+ else if (removed)
194
+ heads.add(git(root, 'rev-parse', row.branch));
195
+ const collect = (path, label) => {
196
+ if (!present(path))
197
+ return;
198
+ const metadata = lstatSync(path);
199
+ if (metadata.isSymbolicLink() || realpathSync(path) !== path)
200
+ throw new DeliveryError('Historical evidence inventory contains a noncanonical path.');
201
+ if (metadata.isDirectory()) {
202
+ for (const name of readdirSync(path).sort())
203
+ collect(join(path, name), `${label}/${name}`);
204
+ return;
205
+ }
206
+ const bytes = evidenceBytes(path);
207
+ files.set(label, { path: label, source: path, digest: digestBytes(bytes), size: bytes.length });
208
+ if (label.endsWith('.json')) {
209
+ let value;
210
+ try {
211
+ value = JSON.parse(bytes.toString('utf8'));
212
+ }
213
+ catch {
214
+ return;
215
+ }
216
+ if (value && typeof value.headSha === 'string' && /^[a-f0-9]{40}$/u.test(value.headSha))
217
+ heads.add(value.headSha);
218
+ if (value?.schemaVersion === 'issue-cli.terminal-evidence@1') {
219
+ if (!Array.isArray(value.files))
220
+ throw new DeliveryError('Historical terminal evidence manifest is incomplete.');
221
+ for (const raw of value.files) {
222
+ if (typeof raw.name !== 'string' || !/^[A-Za-z0-9_.-]+$/u.test(raw.name) || typeof raw.sha256 !== 'string')
223
+ throw new DeliveryError('Historical terminal evidence inventory is invalid.');
224
+ const original = evidenceBytes(join(dirname(path), raw.name));
225
+ if (original.length !== raw.size || digestBytes(original) !== `sha256:${raw.sha256}`)
226
+ throw new DeliveryError('Historical terminal evidence manifest disagrees with original bytes.');
227
+ }
228
+ }
229
+ if (label.startsWith(`git/ai-delivery/runs@2/`) && value?.schemaVersion === 'ai-delivery.run@3') {
230
+ const run = readWorktreeTransitionRun(bytes);
231
+ const head = run.classification.head.sha;
232
+ heads.add(head);
233
+ for (const receipt of run.stageReceipts) {
234
+ const version = receipt.input.schemaVersion === 'ai-delivery.stage-input@2' ? 2 : 1;
235
+ const checkpoint = `ai-delivery/verification@${String(version)}/stages/${receipt.input.stageId}/${receipt.input.inputId.slice(7)}.json`;
236
+ collect(join(common, checkpoint), `git/${checkpoint}`);
237
+ const resource = `ai-delivery/resource-stages@1/${receipt.input.inputId.slice(7)}.json`;
238
+ collect(join(common, resource), `git/${resource}`);
239
+ for (const command of receipt.commands) {
240
+ const output = `ai-delivery/verification@1/command-output/${command.outputDigest.slice(7)}.bin`;
241
+ const outputBytes = evidenceBytes(join(common, output));
242
+ if (digestBytes(outputBytes) !== command.outputDigest)
243
+ throw new DeliveryError('Historical command output is corrupt.');
244
+ collect(join(common, output), `git/${output}`);
245
+ }
246
+ for (const artifact of receipt.artifacts) {
247
+ if (removed)
248
+ continue;
249
+ const path = resolve(row.path, artifact.path);
250
+ if (relative(row.path, path).startsWith('..') || digestBytes(evidenceBytes(path)) !== artifact.digest)
251
+ throw new DeliveryError('Historical stage artifact is missing or corrupt.');
252
+ collect(path, `worktree/${artifact.path}`);
253
+ }
254
+ }
255
+ if (run.resources?.outputBaselineId) {
256
+ const baseline = `ai-delivery/output-baselines@1/${run.resources.outputBaselineId.slice(7)}`;
257
+ collect(join(common, `${baseline}.json`), `git/${baseline}.json`);
258
+ collect(join(common, `${baseline}.state.json`), `git/${baseline}.state.json`);
259
+ }
260
+ }
261
+ }
262
+ };
263
+ collect(join(root, '.issue-cli', 'issues', String(row.issueNumber)), `repository/.issue-cli/issues/${String(row.issueNumber)}`);
264
+ if (!removed)
265
+ collect(join(row.path, '.issue-cli'), 'worktree/.issue-cli');
266
+ const worktreeId = digestValue(resolve(row.path)).slice(7);
267
+ collect(join(common, 'ai-delivery', 'runs@2', worktreeId), `git/ai-delivery/runs@2/${worktreeId}`);
268
+ collect(join(common, 'ai-delivery', 'writers@1', `${worktreeId}.json`), `git/ai-delivery/writers@1/${worktreeId}.json`);
269
+ // Legacy receipts are selected by their original exact-head filenames, never rewritten as current proof.
270
+ const receipts = join(root, '.issue-cli', 'receipts');
271
+ const collectReceipts = (path) => {
272
+ if (!present(path))
273
+ return;
274
+ const metadata = lstatSync(path);
275
+ if (metadata.isSymbolicLink())
276
+ throw new DeliveryError('Historical receipt inventory contains a link.');
277
+ if (metadata.isDirectory()) {
278
+ for (const name of readdirSync(path).sort())
279
+ collectReceipts(join(path, name));
280
+ }
281
+ else if ([...heads].some((head) => path.includes(head)))
282
+ collect(path, `repository/${relative(root, path)}`);
283
+ };
284
+ collectReceipts(receipts);
285
+ for (const family of ['publications', 'reviews', 'merges', 'merge-intents', 'merge-attempts', 'merge-results'])
286
+ collect(join(common, 'ai-delivery', family, String(row.issueNumber)), `git/ai-delivery/${family}/${String(row.issueNumber)}`);
287
+ for (const head of [...heads].sort())
288
+ collect(join(common, 'ai-delivery', 'runs', `${head}.json`), `git/ai-delivery/runs/${head}.json`);
289
+ return [...files.values()].sort((left, right) => left.path.localeCompare(right.path));
290
+ }
291
+ const TransitionIntentSchema = z
292
+ .strictObject({
293
+ schemaVersion: z.literal('ai-delivery.worktree-transition-intent@1'),
294
+ intentId: DigestSchema,
295
+ plan: WorktreeTransitionPlanSchema,
296
+ relinquishmentCommentId: z.number().int().positive(),
297
+ acceptanceCommentId: z.number().int().positive(),
298
+ originalRegistryDigest: DigestSchema,
299
+ originalWriterDigest: DigestSchema.nullable(),
300
+ })
301
+ .superRefine((intent, context) => {
302
+ const { intentId, ...content } = intent;
303
+ if (intentId !== digestValue(content))
304
+ context.addIssue({ code: 'custom', message: 'Transition intent is corrupt.' });
305
+ });
306
+ const TransitionStepSchema = z.strictObject({
307
+ schemaVersion: z.literal('ai-delivery.worktree-transition-step@1'),
308
+ planId: DigestSchema,
309
+ intentId: DigestSchema,
310
+ attemptId: z.uuid(),
311
+ event: z.enum(['authority', 'claim', 'claimed', 'release', 'released', 'row', 'owner', 'removal-intent', 'removed']),
312
+ sequence: z.number().int().positive().safe(),
313
+ predecessorId: DigestSchema.nullable(),
314
+ data: z.record(z.string(), z.unknown()),
315
+ stepId: DigestSchema,
316
+ });
317
+ const TransitionCompletionSchema = z
318
+ .strictObject({
319
+ schemaVersion: z.literal('ai-delivery.worktree-transition-completion@1'),
320
+ completionId: DigestSchema,
321
+ planId: DigestSchema,
322
+ intentId: DigestSchema,
323
+ completed: z.literal(true),
324
+ writerReleased: z.literal(true),
325
+ result: z.enum(['active-resumed', 'retained', 'removed']),
326
+ rowAfter: RowSchema.nullable(),
327
+ inventoryId: DigestSchema,
328
+ attempts: z.array(DigestSchema).min(1),
329
+ completedAt: z.iso.datetime(),
330
+ })
331
+ .superRefine((completion, context) => {
332
+ const { completionId, ...content } = completion;
333
+ if (completionId !== digestValue(content))
334
+ context.addIssue({ code: 'custom', message: 'Transition completion is corrupt.' });
335
+ });
336
+ function transitionLocations(plan) {
337
+ const intent = join(gitCommonDir(plan.repoRoot), 'ai-delivery', 'worktree-owners', `issue-${String(plan.row.issueNumber)}.transition.json`);
338
+ return { intent, directory: `${intent}.evidence`, blobs: join(`${intent}.evidence`, 'bytes') };
339
+ }
340
+ function immutableJson(path, value) {
341
+ writeCreateOnly(path, Buffer.from(stableJson(value)));
342
+ }
343
+ function preserveBytes(directory, bytes) {
344
+ const digest = digestBytes(bytes);
345
+ writeCreateOnly(join(directory, `${digest.slice(7)}.bin`), bytes, digest);
346
+ return digest;
347
+ }
348
+ function requirePreservedBytes(directory, digest) {
349
+ const bytes = assertPrivateFile(join(directory, `${DigestSchema.parse(digest).slice(7)}.bin`));
350
+ if (digestBytes(bytes) !== digest)
351
+ throw new DeliveryError('Preserved transition evidence is corrupt.');
352
+ return bytes;
353
+ }
354
+ function transitionWriterId(plan) {
355
+ const id = digestValue({ planId: plan.planId, purpose: 'transition-writer' }).slice(7);
356
+ return `${id.slice(0, 8)}-${id.slice(8, 12)}-5${id.slice(13, 16)}-8${id.slice(17, 20)}-${id.slice(20, 32)}`;
357
+ }
358
+ function readSteps(directory, intent) {
359
+ if (!present(directory))
360
+ return [];
361
+ let predecessorId = null;
362
+ return readdirSync(directory)
363
+ .sort((left, right) => Number(left.split('.')[0]) - Number(right.split('.')[0]))
364
+ .map((name, index) => {
365
+ if (!/^[1-9]\d*\.[a-f0-9-]{36}\.(?:authority|claim|claimed|release|released|row|owner|removal-intent|removed)\.json$/u.test(name))
366
+ throw new DeliveryError('Transition attempt inventory contains an unsupported record.');
367
+ const step = TransitionStepSchema.parse(JSON.parse(assertPrivateFile(join(directory, name)).toString('utf8')));
368
+ const { stepId, ...content } = step;
369
+ if (stepId !== digestValue(content) ||
370
+ step.intentId !== intent.intentId ||
371
+ step.planId !== intent.plan.planId ||
372
+ step.sequence !== index + 1 ||
373
+ step.predecessorId !== predecessorId ||
374
+ name !== `${String(step.sequence)}.${step.attemptId}.${step.event}.json`)
375
+ throw new DeliveryError('Transition attempt record is corrupt or belongs to another intent.');
376
+ predecessorId = stepId;
377
+ return step;
378
+ });
379
+ }
380
+ function permittedWriterStates(intent, steps) {
381
+ let permitted = [intent.originalWriterDigest];
382
+ let claim;
383
+ let releaseAttempt;
384
+ for (const step of steps) {
385
+ if (step.event === 'claim') {
386
+ const bytesDigest = DigestSchema.parse(step.data.bytesDigest);
387
+ const previous = step.data.previousBytesDigest === null ? null : DigestSchema.parse(step.data.previousBytesDigest);
388
+ if (!permitted.includes(previous))
389
+ throw new DeliveryError('Transition claim predecessor is outside its exact writer lineage.');
390
+ permitted = [previous, bytesDigest];
391
+ claim = { attemptId: step.attemptId, bytesDigest };
392
+ releaseAttempt = undefined;
393
+ }
394
+ else if (step.event === 'claimed' || step.event === 'release') {
395
+ if (!claim || step.attemptId !== claim.attemptId || step.data.bytesDigest !== claim.bytesDigest)
396
+ throw new DeliveryError('Transition claim/release acknowledgement belongs to another writer lineage.');
397
+ permitted = step.event === 'claimed' ? [claim.bytesDigest] : [claim.bytesDigest, null];
398
+ if (step.event === 'release')
399
+ releaseAttempt = step.attemptId;
400
+ }
401
+ else if (step.event === 'released') {
402
+ if (releaseAttempt !== step.attemptId || step.data.absent !== true)
403
+ throw new DeliveryError('Transition absence lacks its exact latest release intent.');
404
+ permitted = [null];
405
+ }
406
+ }
407
+ return permitted;
408
+ }
409
+ function requireAttemptBytes(directory, intent, steps) {
410
+ if (intent.originalWriterDigest !== null)
411
+ requirePreservedBytes(directory, intent.originalWriterDigest);
412
+ for (const step of steps) {
413
+ if (['authority', 'claim', 'claimed', 'release'].includes(step.event))
414
+ requirePreservedBytes(directory, DigestSchema.parse(step.data.bytesDigest));
415
+ }
416
+ }
417
+ async function revalidateTransition(context, plan, originalInventory, removalAuthorized, runtimeEntryPath) {
418
+ if (!context.configuration || context.root !== plan.repoRoot || context.config.repository !== plan.repository)
419
+ throw new DeliveryError('Transition repository or current configuration drifted.');
420
+ const admitted = await assertDeliveryRuntimeAdmitted({
421
+ repoRoot: plan.repoRoot,
422
+ configuration: context.configuration,
423
+ ...(runtimeEntryPath === undefined ? {} : { runtimeEntryPath }),
424
+ });
425
+ if (stableJson(admitted) !== stableJson(plan.currentRuntime) ||
426
+ digestBytes(evidenceBytes(context.configuration.policyModulePath)) !== plan.policyDigest)
427
+ throw new DeliveryError('Transition current runtime/configuration/policy drifted.');
428
+ const { inventory: producerInventory, ...producer } = retainedProducer(context, plan.closure.admissionPath, plan.closure.archivePath);
429
+ const { family: _family, runManifestIds: _runs, ...expectedProducer } = plan.closure;
430
+ if (stableJson(producer) !== stableJson(expectedProducer))
431
+ throw new DeliveryError('Retained operational producer drifted.');
432
+ for (const family of ['v1', 'v2'])
433
+ if (present(join(gitCommonDir(plan.repoRoot), 'issue-cli', 'verification-stages', family)))
434
+ throw new DeliveryError(`Unsupported operative issue-cli verification-stages/${family}; supported closure is missing.`);
435
+ const removed = !present(plan.row.path);
436
+ if (removed && (!removalAuthorized || plan.disposition !== 'remove'))
437
+ throw new DeliveryError('Transition source disappeared without recorded removal intent.');
438
+ if (!removed) {
439
+ if (gitRoot(plan.row.path) !== plan.row.path ||
440
+ gitCommonDir(plan.row.path) !== gitCommonDir(plan.repoRoot) ||
441
+ git(plan.row.path, 'branch', '--show-current') !== plan.row.branch)
442
+ throw new DeliveryError('Transition worktree ownership or branch drifted.');
443
+ assertClean(plan.row.path);
444
+ }
445
+ const source = removed ? plan.repoRoot : plan.row.path;
446
+ const ref = removed ? plan.row.branch : 'HEAD';
447
+ if (git(source, 'rev-parse', ref) !== plan.head.sha || git(source, 'rev-parse', `${ref}^{tree}`) !== plan.head.tree)
448
+ throw new DeliveryError('Transition exact source coordinates drifted.');
449
+ const lineage = await readTransitionLineage(context, plan.row, plan.terminalPrNumber);
450
+ if (stableJson(lineage) !== stableJson(plan.lineage))
451
+ throw new DeliveryError('Transition authenticated native PR lineage or complete PR set drifted.');
452
+ if (plan.disposition === 'remove') {
453
+ const terminal = plan.lineage.find((pr) => pr.prNumber === plan.terminalPrNumber);
454
+ if (!terminal ||
455
+ stableJson(await nativeRemoteRefs(context, plan.row.branch, terminal.baseBranch)) !==
456
+ stableJson(plan.remoteRefs) ||
457
+ git(plan.repoRoot, 'rev-parse', defaultBaseRef(plan.repoRoot, context.configuration.remote)) !==
458
+ plan.remoteRefs?.baseSha)
459
+ throw new DeliveryError('Transition native remote branch readback or local retaining ref drifted.');
460
+ if (producerInventory.some((file) => {
461
+ const rel = relative(plan.row.path, file.source);
462
+ return rel === '' || (!rel.startsWith('..') && !rel.startsWith('/'));
463
+ }))
464
+ throw new DeliveryError('Retained operational producer must remain outside the source selected for removal.');
465
+ }
466
+ const issue = (await context.clients.rest.issues.get({ ...context.repo, issue_number: plan.row.issueNumber })).data;
467
+ if (issue.state !== (plan.purpose === 'merged-cleanup' ? 'closed' : 'open'))
468
+ throw new DeliveryError('Transition native issue state drifted.');
469
+ for (const hold of plan.holdEvidence) {
470
+ const comment = (await context.clients.rest.issues.getComment({ ...context.repo, comment_id: hold.commentId }))
471
+ .data;
472
+ if (digestBytes(Buffer.from(stableJson(comment))) !== hold.digest)
473
+ throw new DeliveryError('Retained native hold evidence drifted.');
474
+ }
475
+ const actual = [
476
+ ...inventoryTransitionEvidence(plan.repoRoot, plan.row, removed),
477
+ ...producerInventory,
478
+ ].sort((left, right) => left.path.localeCompare(right.path));
479
+ assertTransitionPurpose(plan.repoRoot, plan.row, plan.purpose, plan.terminalPrNumber, plan.head, lineage, issue.state, actual);
480
+ const runManifestIds = requireSupportedRunClosure(plan.repoRoot, plan.row, actual, producer.producerDigest, plan.head);
481
+ if (stableJson(runManifestIds) !== stableJson(plan.closure.runManifestIds))
482
+ throw new DeliveryError('Operational run inventory IDs drifted.');
483
+ if (actual
484
+ .filter((file) => file.path.startsWith('git/ai-delivery/runs@2/'))
485
+ .some((file) => readWorktreeTransitionRun(evidenceBytes(file.source)).classification.repository !== plan.repository))
486
+ throw new DeliveryError('Operational run inventory belongs to another repository.');
487
+ const exclude = (files) => files.filter((file) => !file.path.startsWith('git/ai-delivery/writers@1/') && !(removed && file.path.startsWith('worktree/')));
488
+ if (stableJson(exclude(actual)) !== stableJson(exclude(plan.inventory)))
489
+ throw new DeliveryError('Transition complete historical writer/run/stage inventory drifted.');
490
+ if (originalInventory &&
491
+ actual.some((file) => file.path.startsWith('git/ai-delivery/writers@1/') &&
492
+ !plan.inventory.some((expected) => stableJson(expected) === stableJson(file))))
493
+ throw new DeliveryError('Original transition writer evidence drifted.');
494
+ }
495
+ export async function applyWorktreeTransition(context, input) {
496
+ if (input.authority !== 'worktree:transition')
497
+ throw new DeliveryError('Explicit exact-plan worktree transition authority is required.');
498
+ const plan = WorktreeTransitionPlanSchema.parse(JSON.parse(evidenceBytes(realpathSync(input.planPath)).toString('utf8')));
499
+ if (plan.planId !== input.expectedPlanId)
500
+ throw new DeliveryError('Transition expected plan identity disagrees.');
501
+ const locations = transitionLocations(plan);
502
+ const completionPath = join(locations.directory, 'completion.json');
503
+ const stepsDirectory = join(locations.directory, 'attempts');
504
+ let intent;
505
+ if (present(locations.intent)) {
506
+ intent = TransitionIntentSchema.parse(JSON.parse(assertPrivateFile(locations.intent).toString('utf8')));
507
+ if (stableJson(intent.plan) !== stableJson(plan) ||
508
+ intent.relinquishmentCommentId !== input.relinquishmentCommentId ||
509
+ intent.acceptanceCommentId !== input.acceptanceCommentId)
510
+ throw new DeliveryError('Pending transition requires its original exact plan and native authority IDs.');
511
+ }
512
+ const result = plan.purpose === 'active-resume' ? 'active-resumed' : plan.disposition === 'retain' ? 'retained' : 'removed';
513
+ if (present(completionPath)) {
514
+ if (!intent)
515
+ throw new DeliveryError('Transition completion lacks its durable intent.');
516
+ const completion = TransitionCompletionSchema.parse(JSON.parse(assertPrivateFile(completionPath).toString('utf8')));
517
+ const attempts = readSteps(stepsDirectory, intent);
518
+ if (completion.planId !== plan.planId ||
519
+ completion.intentId !== intent.intentId ||
520
+ completion.result !== result ||
521
+ completion.inventoryId !== plan.inventoryId ||
522
+ stableJson(completion.attempts) !== stableJson(attempts.map((record) => record.stepId)) ||
523
+ stableJson(permittedWriterStates(intent, attempts)) !== stableJson([null]))
524
+ throw new DeliveryError('Transition completion is corrupt.');
525
+ for (const file of plan.inventory)
526
+ requirePreservedBytes(locations.blobs, file.digest);
527
+ requirePreservedBytes(locations.blobs, intent.originalRegistryDigest);
528
+ requireAttemptBytes(locations.blobs, intent, attempts);
529
+ for (const hold of plan.holdEvidence)
530
+ requirePreservedBytes(locations.blobs, hold.digest);
531
+ await revalidateTransition(context, plan, false, readSteps(stepsDirectory, intent).some((record) => record.event === 'removal-intent'), input.runtimeEntryPath);
532
+ await assertWorktreeTransitionNativeAuthority(context, plan, input);
533
+ await withWorktreeTransitionWriterAbsent(plan.repoRoot, plan.row.path, () => withWorktreeTransitionRegistry(plan, async ({ current }) => {
534
+ if (stableJson(current ?? null) !== stableJson(completion.rowAfter))
535
+ throw new DeliveryError('Sealed transition registry postcondition drifted.');
536
+ assertTransitionOwnerWitness(plan);
537
+ if (result === 'removed') {
538
+ if (present(plan.row.path) || current !== undefined)
539
+ throw new DeliveryError('Removed transition source was recreated; terminal absence postcondition drifted.');
540
+ }
541
+ else {
542
+ if (!current || !present(plan.row.path))
543
+ throw new DeliveryError('Sealed transition source or owner witness disappeared.');
544
+ }
545
+ }));
546
+ return { planId: plan.planId, result, replayed: true, receiptPath: completionPath };
547
+ }
548
+ const attemptId = randomUUID();
549
+ let steps = intent === undefined ? [] : readSteps(stepsDirectory, intent);
550
+ const step = (event, data) => {
551
+ if (!intent)
552
+ throw new DeliveryError('Transition step lacks durable intent.');
553
+ const content = {
554
+ schemaVersion: 'ai-delivery.worktree-transition-step@1',
555
+ planId: plan.planId,
556
+ intentId: intent.intentId,
557
+ attemptId,
558
+ event,
559
+ data,
560
+ sequence: steps.length + 1,
561
+ predecessorId: steps.at(-1)?.stepId ?? null,
562
+ };
563
+ const record = TransitionStepSchema.parse({ ...content, stepId: digestValue(content) });
564
+ permittedWriterStates(intent, [...steps, record]);
565
+ immutableJson(join(stepsDirectory, `${String(record.sequence)}.${attemptId}.${event}.json`), record);
566
+ steps.push(record);
567
+ };
568
+ const priorSteps = [...steps];
569
+ let previousBytesDigest = null;
570
+ let claimedBytesDigest;
571
+ let operationComplete = false;
572
+ let rowAfter = null;
573
+ await withWorktreeTransitionWriterLease({
574
+ repoRoot: plan.repoRoot,
575
+ worktreePath: plan.row.path,
576
+ writerId: transitionWriterId(plan),
577
+ beforeClaim: async ({ previousBytes }) => {
578
+ await withWorktreeTransitionRegistry(plan, async ({ current }) => {
579
+ if (intent !== undefined)
580
+ steps = readSteps(stepsDirectory, intent);
581
+ const removalAuthorized = priorSteps.some((record) => record.event === 'removal-intent');
582
+ if (current === undefined && !removalAuthorized)
583
+ throw new DeliveryError('Transition row disappeared without recorded removal intent.');
584
+ await revalidateTransition(context, plan, intent === undefined, removalAuthorized, input.runtimeEntryPath);
585
+ const authority = await assertWorktreeTransitionNativeAuthority(context, plan, input);
586
+ if (intent === undefined) {
587
+ const writerFile = plan.inventory.find((file) => file.path.startsWith('git/ai-delivery/writers@1/'));
588
+ if ((previousBytes === undefined) !== (writerFile === undefined) ||
589
+ (previousBytes !== undefined && digestBytes(previousBytes) !== writerFile?.digest))
590
+ throw new DeliveryError('Original transition writer bytes or absence drifted.');
591
+ const originalRegistryDigest = preserveBytes(locations.blobs, evidenceBytes(join(plan.repoRoot, '.issue-cli', 'worktrees.json')));
592
+ const content = {
593
+ schemaVersion: 'ai-delivery.worktree-transition-intent@1',
594
+ plan,
595
+ relinquishmentCommentId: input.relinquishmentCommentId,
596
+ acceptanceCommentId: input.acceptanceCommentId,
597
+ originalRegistryDigest,
598
+ originalWriterDigest: previousBytes === undefined ? null : digestBytes(previousBytes),
599
+ };
600
+ intent = TransitionIntentSchema.parse({ ...content, intentId: digestValue(content) });
601
+ immutableJson(locations.intent, intent);
602
+ preserveTransitionEvidence(locations.blobs, plan.inventory);
603
+ }
604
+ else {
605
+ for (const file of plan.inventory) {
606
+ const preserved = join(locations.blobs, `${file.digest.slice(7)}.bin`);
607
+ if (!present(preserved)) {
608
+ if (file.path.startsWith('git/ai-delivery/writers@1/')) {
609
+ if (previousBytes === undefined || digestBytes(previousBytes) !== file.digest)
610
+ throw new DeliveryError('Original writer was lost before preservation.');
611
+ preserveBytes(locations.blobs, previousBytes);
612
+ }
613
+ else
614
+ preserveTransitionEvidence(locations.blobs, [file]);
615
+ }
616
+ requirePreservedBytes(locations.blobs, file.digest);
617
+ }
618
+ const actualDigest = previousBytes === undefined ? null : digestBytes(previousBytes);
619
+ if (!permittedWriterStates(intent, steps).includes(actualDigest))
620
+ throw new DeliveryError('Writer bytes/absence are outside the latest exact transition claim/release lineage.');
621
+ if (actualDigest !== null)
622
+ requirePreservedBytes(locations.blobs, actualDigest);
623
+ }
624
+ previousBytesDigest = previousBytes === undefined ? null : digestBytes(previousBytes);
625
+ requirePreservedBytes(locations.blobs, intent.originalRegistryDigest);
626
+ requireAttemptBytes(locations.blobs, intent, steps);
627
+ step('authority', {
628
+ bytesDigest: preserveBytes(locations.blobs, Buffer.from(stableJson(authority))),
629
+ relinquishmentCommentId: input.relinquishmentCommentId,
630
+ acceptanceCommentId: input.acceptanceCommentId,
631
+ });
632
+ for (const hold of plan.holdEvidence) {
633
+ const comment = (await context.clients.rest.issues.getComment({ ...context.repo, comment_id: hold.commentId })).data;
634
+ if (preserveBytes(locations.blobs, Buffer.from(stableJson(comment))) !== hold.digest)
635
+ throw new DeliveryError('Retained hold changed during preservation.');
636
+ }
637
+ });
638
+ },
639
+ recordClaim: (bytes) => {
640
+ claimedBytesDigest = preserveBytes(locations.blobs, bytes);
641
+ step('claim', { bytesDigest: claimedBytesDigest, previousBytesDigest });
642
+ },
643
+ afterClaim: (bytes) => {
644
+ if (digestBytes(bytes) !== claimedBytesDigest)
645
+ throw new DeliveryError('Actual writer claim bytes disagree with their recorded intent.');
646
+ step('claimed', { bytesDigest: claimedBytesDigest });
647
+ },
648
+ recordRelease: (bytes) => {
649
+ if (digestBytes(bytes) !== claimedBytesDigest)
650
+ throw new DeliveryError('Writer changed before its exact recorded release.');
651
+ step('release', { bytesDigest: preserveBytes(locations.blobs, bytes) });
652
+ },
653
+ afterRelease: async () => {
654
+ step('released', { absent: true });
655
+ if (!operationComplete || !intent)
656
+ return;
657
+ await withWorktreeTransitionRegistry(plan, async ({ current }) => {
658
+ if (stableJson(current ?? null) !== stableJson(rowAfter))
659
+ throw new DeliveryError('Transition row changed before completion sealing.');
660
+ for (const file of plan.inventory)
661
+ requirePreservedBytes(locations.blobs, file.digest);
662
+ const attempts = readSteps(stepsDirectory, intent);
663
+ requireAttemptBytes(locations.blobs, intent, attempts);
664
+ const content = {
665
+ schemaVersion: 'ai-delivery.worktree-transition-completion@1',
666
+ planId: plan.planId,
667
+ intentId: intent.intentId,
668
+ completed: true,
669
+ writerReleased: true,
670
+ result,
671
+ rowAfter,
672
+ inventoryId: plan.inventoryId,
673
+ attempts: attempts.map((record) => record.stepId),
674
+ completedAt: new Date().toISOString(),
675
+ };
676
+ immutableJson(completionPath, { ...content, completionId: digestValue(content) });
677
+ });
678
+ },
679
+ }, async () => {
680
+ await withWorktreeTransitionRegistry(plan, async ({ current, writeOwner, commitRow, removeRow }) => {
681
+ const removalAuthorized = priorSteps.some((record) => record.event === 'removal-intent');
682
+ await revalidateTransition(context, plan, false, removalAuthorized, input.runtimeEntryPath);
683
+ await assertWorktreeTransitionNativeAuthority(context, plan, input);
684
+ if (current !== undefined) {
685
+ writeOwner();
686
+ step('owner', { exactWitness: true });
687
+ rowAfter = commitRow();
688
+ step('row', { rowDigest: digestValue(rowAfter) });
689
+ }
690
+ if (plan.disposition === 'remove') {
691
+ step('removal-intent', { head: plan.head, path: plan.row.path });
692
+ removeMergedSourceForTransition(plan, context.configuration?.remote);
693
+ if (present(plan.row.path))
694
+ throw new DeliveryError('Terminal source removal readback is not absent.');
695
+ step('removed', { absent: true });
696
+ removeRow();
697
+ rowAfter = null;
698
+ }
699
+ operationComplete = true;
700
+ });
701
+ });
702
+ return { planId: plan.planId, result, replayed: priorSteps.length > 0, receiptPath: completionPath };
703
+ }
704
+ async function transitionActors(context) {
705
+ if (context.clients.authSource !== 'personal' ||
706
+ context.clients.role !== 'author' ||
707
+ !context.clients.authenticatedAuthor)
708
+ throw new DeliveryError('Worktree relinquishment requires the configured authenticated personal operator.');
709
+ const operator = await context.clients.authenticatedAuthor();
710
+ if (!/^user:[1-9]\d*$/u.test(operator.credentialIdentity))
711
+ throw new DeliveryError('Operator user identity is unavailable.');
712
+ const reviewer = await createDeliveryGitHubClients({
713
+ config: context.config,
714
+ identity: context.config.roles.reviewer.identity,
715
+ role: 'reviewer',
716
+ selectedAuthor: context.clients,
717
+ });
718
+ if (reviewer.authSource !== 'app' || !reviewer.appActorLogin)
719
+ throw new DeliveryError('Configured reviewer App identity is unavailable.');
720
+ const reviewerActor = await reviewer.appActorLogin();
721
+ if (reviewerActor.toLowerCase() === operator.actorLogin.toLowerCase())
722
+ throw new DeliveryError('Transition acceptance requires a distinct configured reviewer App.');
723
+ return { operator, reviewerActor, reviewer };
724
+ }
725
+ export function worktreeRelinquishmentBody(plan) {
726
+ return stableJson({
727
+ schemaVersion: 'ai-delivery.worktree-relinquishment@1',
728
+ plan: nativeCommentPlan(plan),
729
+ acceptedScope: 'the-complete-content-addressed-plan-and-every-original-in-its-inventory',
730
+ authority: 'relinquish-only-the-inventoried-public-posix-writers',
731
+ operationalClosure: 'the-inventory-is-the-complete-remaining-launcher-writer-and-managed-process-set-all-are-quiescent-no-unknown-resources',
732
+ maintainedExclusion: 'all-old-launchers-and-writers-remain-excluded-through-apply-including-interruption-and-exact-plan-replay',
733
+ historicalAuthority: 'preservation-only-no-current-verification-review-or-retirement-authority',
734
+ sourceDisposition: plan.disposition === 'remove'
735
+ ? 'no-outstanding-holds-remove-only-clean-merged-source'
736
+ : 'retain-source-and-all-outstanding-holds',
737
+ exclusions: [
738
+ 'private-adoption',
739
+ 'hold-release',
740
+ 'launcher-changes',
741
+ 'shared-runtime-admission',
742
+ 'legacy-protocol-retirement',
743
+ ],
744
+ });
745
+ }
746
+ export function worktreeAcceptanceBody(plan, relinquishmentCommentId) {
747
+ return stableJson({
748
+ schemaVersion: 'ai-delivery.worktree-transition-acceptance@1',
749
+ plan: nativeCommentPlan(plan),
750
+ acceptedScope: 'independent-acceptance-of-the-complete-content-addressed-plan-and-inventory-including-complete-launcher-set-and-maintained-exclusion',
751
+ relinquishmentCommentId,
752
+ relinquishmentDigest: digestBytes(Buffer.from(worktreeRelinquishmentBody(plan))),
753
+ result: 'approved',
754
+ });
755
+ }
756
+ function nativeCommentPlan(raw) {
757
+ const plan = WorktreeTransitionPlanSchema.parse(raw);
758
+ return {
759
+ planId: plan.planId,
760
+ inventoryId: plan.inventoryId,
761
+ repository: plan.repository,
762
+ repoRoot: plan.repoRoot,
763
+ row: plan.row,
764
+ head: plan.head,
765
+ purpose: plan.purpose,
766
+ disposition: plan.disposition,
767
+ terminalPrNumber: plan.terminalPrNumber,
768
+ lineage: plan.lineage,
769
+ remoteRefs: plan.remoteRefs,
770
+ holdEvidence: plan.holdEvidence,
771
+ closure: plan.closure,
772
+ currentRuntimeId: plan.currentRuntime.admissionId,
773
+ currentConfigDigest: plan.currentRuntime.configDigest,
774
+ policyDigest: plan.policyDigest,
775
+ operator: plan.operator,
776
+ reviewerActor: plan.reviewerActor,
777
+ historicalProducer: plan.historicalProducer,
778
+ };
779
+ }
780
+ /** Whole native bodies and the configured live actors are authority; local digests alone are not. */
781
+ export async function assertWorktreeTransitionNativeAuthority(context, plan, ids) {
782
+ const actors = await transitionActors(context);
783
+ if (stableJson(actors.operator) !== stableJson(plan.operator) || actors.reviewerActor !== plan.reviewerActor)
784
+ throw new DeliveryError('Configured transition authority identities drifted.');
785
+ if (plan.lineage.some((pr) => pr.authorLogin.toLowerCase() === actors.reviewerActor.toLowerCase()))
786
+ throw new DeliveryError('Configured transition reviewer App is also the native PR author.');
787
+ const subject = plan.terminalPrNumber ?? plan.row.issueNumber;
788
+ const issueUrl = `https://api.github.com/repos/${plan.repository}/issues/${String(subject)}`;
789
+ const relinquishment = (await context.clients.rest.issues.getComment({ ...context.repo, comment_id: ids.relinquishmentCommentId })).data;
790
+ const acceptance = (await actors.reviewer.rest.issues.getComment({ ...context.repo, comment_id: ids.acceptanceCommentId })).data;
791
+ if (relinquishment.id !== ids.relinquishmentCommentId ||
792
+ relinquishment.issue_url?.toLowerCase() !== issueUrl.toLowerCase() ||
793
+ relinquishment.user?.login.toLowerCase() !== actors.operator.actorLogin.toLowerCase() ||
794
+ `user:${String(relinquishment.user?.id)}` !== actors.operator.credentialIdentity ||
795
+ relinquishment.body !== worktreeRelinquishmentBody(plan))
796
+ throw new DeliveryError('Native operator relinquishment is missing, changed or belongs to another subject.');
797
+ if (acceptance.id !== ids.acceptanceCommentId ||
798
+ acceptance.issue_url?.toLowerCase() !== issueUrl.toLowerCase() ||
799
+ acceptance.user?.type !== 'Bot' ||
800
+ acceptance.user.login.toLowerCase() !== actors.reviewerActor.toLowerCase() ||
801
+ acceptance.body !== worktreeAcceptanceBody(plan, ids.relinquishmentCommentId))
802
+ throw new DeliveryError('Native configured reviewer-App exact-plan acceptance is missing or changed.');
803
+ const revokedBody = stableJson({
804
+ schemaVersion: 'ai-delivery.worktree-relinquishment-revocation@1',
805
+ planId: plan.planId,
806
+ relinquishmentCommentId: ids.relinquishmentCommentId,
807
+ });
808
+ for (let page = 1;; page += 1) {
809
+ const comments = (await context.clients.rest.issues.listComments({ ...context.repo, issue_number: subject, per_page: 100, page })).data;
810
+ if (comments.some((comment) => comment.body === revokedBody &&
811
+ comment.user?.login.toLowerCase() === actors.operator.actorLogin.toLowerCase() &&
812
+ `user:${String(comment.user.id)}` === actors.operator.credentialIdentity))
813
+ throw new DeliveryError('Native operator relinquishment has been revoked.');
814
+ if (comments.length < 100)
815
+ break;
816
+ }
817
+ return { relinquishment, acceptance };
818
+ }
819
+ async function nativeLineage(context, issueNumber, prNumber) {
820
+ const pr = (await context.clients.rest.pulls.get({ ...context.repo, pull_number: prNumber })).data;
821
+ const result = await context.clients.graphql(`query WorktreeTransitionLineage($owner: String!, $repo: String!, $number: Int!) {
822
+ repository(owner: $owner, name: $repo) { pullRequest(number: $number) {
823
+ closingIssuesReferences(first: 100) { nodes { number repository { nameWithOwner } } pageInfo { hasNextPage } }
824
+ } }
825
+ }`, { ...context.repo, number: prNumber });
826
+ const closing = result.repository.pullRequest?.closingIssuesReferences;
827
+ if (pr.number !== prNumber ||
828
+ pr.head.repo?.full_name.toLowerCase() !== context.config.repository.toLowerCase() ||
829
+ pr.base.repo.full_name.toLowerCase() !== context.config.repository.toLowerCase() ||
830
+ !closing ||
831
+ closing.pageInfo.hasNextPage ||
832
+ !closing.nodes.some((issue) => issue.number === issueNumber &&
833
+ issue.repository.nameWithOwner.toLowerCase() === context.config.repository.toLowerCase()))
834
+ throw new DeliveryError('Native PR lineage does not establish the exact repository and closing issue.');
835
+ return NativePrSchema.parse({
836
+ prNumber,
837
+ headSha: pr.head.sha,
838
+ headBranch: pr.head.ref,
839
+ baseSha: pr.base.sha,
840
+ baseBranch: pr.base.ref,
841
+ mergeSha: pr.merge_commit_sha,
842
+ merged: pr.merged,
843
+ authorLogin: pr.user?.login,
844
+ });
845
+ }
846
+ async function readTransitionLineage(context, row, terminalPrNumber) {
847
+ const numbers = [
848
+ ...new Set([row.prNumber, terminalPrNumber].filter((number) => number !== undefined && number !== null)),
849
+ ];
850
+ return Promise.all(numbers.map((number) => nativeLineage(context, row.issueNumber, number)));
851
+ }
852
+ function assertTransitionPurpose(root, row, purpose, terminalPrNumber, head, lineage, issueState, inventory) {
853
+ if (purpose === 'active-resume') {
854
+ if (!['active', 'pr-published'].includes(row.status) ||
855
+ issueState !== 'open' ||
856
+ lineage.some((pr) => pr.merged || pr.headSha !== head.sha || pr.headBranch !== row.branch))
857
+ throw new DeliveryError('Active resume requires the exact open issue and unmerged source lineage.');
858
+ if (inventory.some((file) => /^git\/ai-delivery\/(?:publications|reviews|merges|merge-intents|merge-attempts|merge-results)\//u.test(file.path)))
859
+ throw new DeliveryError('Existing current publication/review/merge slots require separate authority reconciliation; active resume cannot overwrite them.');
860
+ }
861
+ else {
862
+ const terminal = lineage.find((pr) => pr.prNumber === terminalPrNumber);
863
+ if (!terminal ||
864
+ issueState !== 'closed' ||
865
+ !terminal.merged ||
866
+ terminal.headSha !== head.sha ||
867
+ terminal.headBranch !== row.branch ||
868
+ lineage.some((pr) => !pr.merged ||
869
+ !pr.mergeSha ||
870
+ pr.headBranch !== row.branch ||
871
+ pr.baseBranch !== terminal.baseBranch ||
872
+ gitExitCode(root, 'merge-base', '--is-ancestor', pr.mergeSha, pr.prNumber === terminal.prNumber ? terminal.mergeSha : head.sha) !== 0))
873
+ throw new DeliveryError('Terminal native PR lineage or merged-result ancestry does not bind the retained exact source.');
874
+ }
875
+ }
876
+ async function nativeRemoteRefs(context, headBranch, baseBranch) {
877
+ const read = async (branch) => {
878
+ try {
879
+ const result = (await context.clients.rest.repos.getBranch({ ...context.repo, branch })).data;
880
+ if (result.name !== branch)
881
+ throw new DeliveryError('Native branch readback belongs to another ref.');
882
+ return ShaSchema.parse(result.commit.sha);
883
+ }
884
+ catch (error) {
885
+ if (error.status === 404)
886
+ return null;
887
+ throw error;
888
+ }
889
+ };
890
+ const headSha = await read(headBranch);
891
+ const baseSha = await read(baseBranch);
892
+ if (baseSha === null)
893
+ throw new DeliveryError('Native retaining base branch is missing.');
894
+ return { headSha, baseSha };
895
+ }
896
+ export async function inspectWorktreeTransition(context, input) {
897
+ const blockers = [];
898
+ const root = primaryGitRoot(context.root);
899
+ const matching = listWorktreesStrict(root).filter((row) => row.type === 'issue' && row.issueNumber === input.issueNumber);
900
+ if (matching.length !== 1)
901
+ return { ready: false, blockers: ['Expected one canonical legacy issue row.'], plan: null, inventory: [] };
902
+ const inventory = [];
903
+ try {
904
+ const row = RowSchema.parse(matching[0]);
905
+ inventory.push(...inventoryTransitionEvidence(root, row));
906
+ if (!context.configuration || context.config.repository !== `${context.repo.owner}/${context.repo.repo}`)
907
+ throw new DeliveryError('Transition requires exact current repository configuration.');
908
+ if (row.path !== join(root, '.worktrees', `issue-${String(row.issueNumber)}`) ||
909
+ row.branch !== `issue/${String(row.issueNumber)}` ||
910
+ gitRoot(row.path) !== realpathSync(row.path) ||
911
+ gitCommonDir(row.path) !== gitCommonDir(root) ||
912
+ git(row.path, 'branch', '--show-current') !== row.branch)
913
+ throw new DeliveryError('Legacy row path, branch or Git ownership disagrees.');
914
+ assertClean(row.path);
915
+ for (const family of ['v1', 'v2']) {
916
+ if (present(join(gitCommonDir(root), 'issue-cli', 'verification-stages', family)))
917
+ blockers.push(`Unsupported operative issue-cli verification-stages/${family}; no authenticated supported operational closure is available.`);
918
+ }
919
+ if (!input.retainedAdmissionPath || !input.retainedArchivePath)
920
+ blockers.push('Missing retained supported public producer admission/archive and operational closure. Historical UNKNOWN is preservation-only.');
921
+ if (blockers.length > 0)
922
+ return { ready: false, blockers, plan: null, inventory };
923
+ const currentRuntime = await assertDeliveryRuntimeAdmitted({
924
+ repoRoot: root,
925
+ configuration: context.configuration,
926
+ ...(input.runtimeEntryPath === undefined ? {} : { runtimeEntryPath: input.runtimeEntryPath }),
927
+ });
928
+ const { inventory: producerInventory, ...producer } = retainedProducer(context, realpathSync(input.retainedAdmissionPath), realpathSync(input.retainedArchivePath));
929
+ inventory.push(...producerInventory);
930
+ const head = { sha: git(row.path, 'rev-parse', 'HEAD'), tree: git(row.path, 'rev-parse', 'HEAD^{tree}') };
931
+ const runManifestIds = requireSupportedRunClosure(root, row, inventory, producer.producerDigest, head);
932
+ if (inventory
933
+ .filter((file) => file.path.startsWith('git/ai-delivery/runs@2/'))
934
+ .some((file) => readWorktreeTransitionRun(evidenceBytes(file.source)).classification.repository !==
935
+ context.config.repository))
936
+ throw new DeliveryError('Operational run inventory belongs to another repository.');
937
+ if (input.disposition === 'remove' &&
938
+ producerInventory.some((file) => {
939
+ const rel = relative(row.path, file.source);
940
+ return rel === '' || (!rel.startsWith('..') && !rel.startsWith('/'));
941
+ }))
942
+ throw new DeliveryError('Retained operational producer must remain outside the source selected for removal.');
943
+ const writer = inventory.find((file) => file.path.startsWith('git/ai-delivery/writers@1/'));
944
+ assertWorktreeTransitionWriterQuiescent(row.path, writer === undefined ? undefined : evidenceBytes(writer.source));
945
+ const terminalPrNumber = input.purpose === 'merged-cleanup' ? (input.terminalPrNumber ?? row.prNumber ?? null) : null;
946
+ const lineage = await readTransitionLineage(context, row, terminalPrNumber);
947
+ const issue = (await context.clients.rest.issues.get({ ...context.repo, issue_number: row.issueNumber })).data;
948
+ assertTransitionPurpose(root, row, input.purpose, terminalPrNumber, head, lineage, issue.state, inventory);
949
+ const actors = await transitionActors(context);
950
+ if (lineage.some((pr) => pr.authorLogin.toLowerCase() === actors.reviewerActor.toLowerCase()))
951
+ throw new DeliveryError('Configured transition reviewer App is also the native PR author.');
952
+ const remoteRefs = input.disposition === 'remove'
953
+ ? await nativeRemoteRefs(context, row.branch, lineage.find((pr) => pr.prNumber === terminalPrNumber).baseBranch)
954
+ : null;
955
+ if (remoteRefs &&
956
+ ((remoteRefs.headSha !== null && remoteRefs.headSha !== head.sha) ||
957
+ git(root, 'rev-parse', defaultBaseRef(root, context.configuration.remote)) !== remoteRefs.baseSha))
958
+ throw new DeliveryError('Native remote branch readback disagrees with exact source or local retaining ref.');
959
+ const retainedHoldCommentIds = [...(input.retainedHoldCommentIds ?? [])].sort((a, b) => a - b);
960
+ if (new Set(retainedHoldCommentIds).size !== retainedHoldCommentIds.length)
961
+ throw new DeliveryError('Retained hold inventory has duplicates.');
962
+ const holdEvidence = [];
963
+ for (const id of retainedHoldCommentIds) {
964
+ const comment = (await context.clients.rest.issues.getComment({ ...context.repo, comment_id: id })).data;
965
+ if (!comment.body ||
966
+ comment.issue_url?.toLowerCase() !==
967
+ `https://api.github.com/repos/${context.config.repository}/issues/${String(terminalPrNumber ?? row.issueNumber)}`.toLowerCase())
968
+ throw new DeliveryError('Retained hold comment belongs to another subject or is unavailable.');
969
+ const bytes = Buffer.from(stableJson(comment));
970
+ holdEvidence.push({ commentId: id, digest: digestBytes(bytes) });
971
+ }
972
+ inventory.sort((left, right) => left.path.localeCompare(right.path));
973
+ const content = {
974
+ schemaVersion: 'ai-delivery.worktree-transition-plan@1',
975
+ repository: context.config.repository,
976
+ repoRoot: root,
977
+ row,
978
+ purpose: input.purpose,
979
+ disposition: input.disposition ?? 'retain',
980
+ head,
981
+ terminalPrNumber,
982
+ lineage,
983
+ remoteRefs,
984
+ retainedHoldCommentIds,
985
+ holdEvidence,
986
+ inventory,
987
+ inventoryId: digestValue(inventory),
988
+ historicalProducer: 'UNKNOWN',
989
+ closure: { family: 'public-ai-delivery-0.3.5-posix@1', ...producer, runManifestIds },
990
+ currentRuntime,
991
+ policyDigest: digestBytes(evidenceBytes(context.configuration.policyModulePath)),
992
+ operator: actors.operator,
993
+ reviewerActor: actors.reviewerActor,
994
+ };
995
+ const plan = WorktreeTransitionPlanSchema.parse({ ...content, planId: digestValue(content) });
996
+ return { ready: true, blockers, plan, inventory, relinquishmentBody: worktreeRelinquishmentBody(plan) };
997
+ }
998
+ catch (error) {
999
+ blockers.push(error instanceof Error ? error.message : String(error));
1000
+ return { ready: false, blockers, plan: null, inventory };
1001
+ }
1002
+ }
1003
+ /** Original copies are immutable and content addressed; every source is checked again before capture. */
1004
+ export function preserveTransitionEvidence(directory, inventory) {
1005
+ for (const file of inventory) {
1006
+ const bytes = evidenceBytes(file.source);
1007
+ if (bytes.length !== file.size || digestBytes(bytes) !== file.digest)
1008
+ throw new DeliveryError(`Historical evidence drifted: ${file.path}`);
1009
+ writeCreateOnly(join(directory, `${file.digest.slice(7)}.bin`), bytes, file.digest);
1010
+ }
1011
+ }
1012
+ //# sourceMappingURL=worktreeTransition.js.map