@aviaratech/ai-delivery 0.1.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 (140) hide show
  1. package/CONTRIBUTING.md +47 -0
  2. package/LICENSE +21 -0
  3. package/README.md +84 -0
  4. package/dist/agent.d.ts +17 -0
  5. package/dist/agent.js +16 -0
  6. package/dist/agent.js.map +1 -0
  7. package/dist/cli.d.ts +2 -0
  8. package/dist/cli.js +314 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/cliResult.d.ts +2 -0
  11. package/dist/cliResult.js +12 -0
  12. package/dist/cliResult.js.map +1 -0
  13. package/dist/cliResult.test.d.ts +1 -0
  14. package/dist/cliResult.test.js +30 -0
  15. package/dist/cliResult.test.js.map +1 -0
  16. package/dist/config/deliveryConfig.d.ts +113 -0
  17. package/dist/config/deliveryConfig.js +261 -0
  18. package/dist/config/deliveryConfig.js.map +1 -0
  19. package/dist/config/deliveryConfig.test.d.ts +1 -0
  20. package/dist/config/deliveryConfig.test.js +355 -0
  21. package/dist/config/deliveryConfig.test.js.map +1 -0
  22. package/dist/delivery/common.d.ts +28 -0
  23. package/dist/delivery/common.js +117 -0
  24. package/dist/delivery/common.js.map +1 -0
  25. package/dist/delivery/delivery.test.d.ts +1 -0
  26. package/dist/delivery/delivery.test.js +234 -0
  27. package/dist/delivery/delivery.test.js.map +1 -0
  28. package/dist/delivery/evidence.d.ts +92 -0
  29. package/dist/delivery/evidence.js +126 -0
  30. package/dist/delivery/evidence.js.map +1 -0
  31. package/dist/delivery/index.d.ts +8 -0
  32. package/dist/delivery/index.js +5 -0
  33. package/dist/delivery/index.js.map +1 -0
  34. package/dist/delivery/policy.d.ts +237 -0
  35. package/dist/delivery/policy.js +246 -0
  36. package/dist/delivery/policy.js.map +1 -0
  37. package/dist/delivery/stage.d.ts +125 -0
  38. package/dist/delivery/stage.js +218 -0
  39. package/dist/delivery/stage.js.map +1 -0
  40. package/dist/dispatch.d.ts +15 -0
  41. package/dist/dispatch.js +397 -0
  42. package/dist/dispatch.js.map +1 -0
  43. package/dist/errors.d.ts +4 -0
  44. package/dist/errors.js +9 -0
  45. package/dist/errors.js.map +1 -0
  46. package/dist/genericCompatibility.test.d.ts +1 -0
  47. package/dist/genericCompatibility.test.js +92 -0
  48. package/dist/genericCompatibility.test.js.map +1 -0
  49. package/dist/git.d.ts +13 -0
  50. package/dist/git.js +65 -0
  51. package/dist/git.js.map +1 -0
  52. package/dist/github/client.d.ts +34 -0
  53. package/dist/github/client.js +143 -0
  54. package/dist/github/client.js.map +1 -0
  55. package/dist/github/commandIdentityPolicy.d.ts +13 -0
  56. package/dist/github/commandIdentityPolicy.js +46 -0
  57. package/dist/github/commandIdentityPolicy.js.map +1 -0
  58. package/dist/github/nativeIssueMetadata.d.ts +78 -0
  59. package/dist/github/nativeIssueMetadata.js +249 -0
  60. package/dist/github/nativeIssueMetadata.js.map +1 -0
  61. package/dist/github/projectDelivery.d.ts +68 -0
  62. package/dist/github/projectDelivery.js +540 -0
  63. package/dist/github/projectDelivery.js.map +1 -0
  64. package/dist/github/relationships.d.ts +69 -0
  65. package/dist/github/relationships.js +395 -0
  66. package/dist/github/relationships.js.map +1 -0
  67. package/dist/github/repo.d.ts +8 -0
  68. package/dist/github/repo.js +34 -0
  69. package/dist/github/repo.js.map +1 -0
  70. package/dist/index.d.ts +2 -0
  71. package/dist/index.js +3 -0
  72. package/dist/index.js.map +1 -0
  73. package/dist/issue.d.ts +86 -0
  74. package/dist/issue.js +347 -0
  75. package/dist/issue.js.map +1 -0
  76. package/dist/lifecycle.test.d.ts +1 -0
  77. package/dist/lifecycle.test.js +1118 -0
  78. package/dist/lifecycle.test.js.map +1 -0
  79. package/dist/logger.d.ts +21 -0
  80. package/dist/logger.js +86 -0
  81. package/dist/logger.js.map +1 -0
  82. package/dist/mcp/index.d.ts +5 -0
  83. package/dist/mcp/index.js +46 -0
  84. package/dist/mcp/index.js.map +1 -0
  85. package/dist/mcp/tools.d.ts +162 -0
  86. package/dist/mcp/tools.js +154 -0
  87. package/dist/mcp/tools.js.map +1 -0
  88. package/dist/pr.d.ts +146 -0
  89. package/dist/pr.js +972 -0
  90. package/dist/pr.js.map +1 -0
  91. package/dist/pr.test.d.ts +1 -0
  92. package/dist/pr.test.js +252 -0
  93. package/dist/pr.test.js.map +1 -0
  94. package/dist/review.d.ts +104 -0
  95. package/dist/review.js +228 -0
  96. package/dist/review.js.map +1 -0
  97. package/dist/review.test.d.ts +1 -0
  98. package/dist/review.test.js +327 -0
  99. package/dist/review.test.js.map +1 -0
  100. package/dist/services/agentReadinessService.d.ts +17 -0
  101. package/dist/services/agentReadinessService.js +225 -0
  102. package/dist/services/agentReadinessService.js.map +1 -0
  103. package/dist/services/deliveryAdmission.d.ts +33 -0
  104. package/dist/services/deliveryAdmission.js +122 -0
  105. package/dist/services/deliveryAdmission.js.map +1 -0
  106. package/dist/services/deliveryRecordService.d.ts +47 -0
  107. package/dist/services/deliveryRecordService.js +199 -0
  108. package/dist/services/deliveryRecordService.js.map +1 -0
  109. package/dist/services/legacyIssueMigration.d.ts +46 -0
  110. package/dist/services/legacyIssueMigration.js +313 -0
  111. package/dist/services/legacyIssueMigration.js.map +1 -0
  112. package/dist/services/worktreeRegistry.d.ts +67 -0
  113. package/dist/services/worktreeRegistry.js +545 -0
  114. package/dist/services/worktreeRegistry.js.map +1 -0
  115. package/dist/terminalMessages.d.ts +13 -0
  116. package/dist/terminalMessages.js +43 -0
  117. package/dist/terminalMessages.js.map +1 -0
  118. package/dist/utils/atomicJson.d.ts +6 -0
  119. package/dist/utils/atomicJson.js +78 -0
  120. package/dist/utils/atomicJson.js.map +1 -0
  121. package/dist/utils/filesystem.d.ts +9 -0
  122. package/dist/utils/filesystem.js +19 -0
  123. package/dist/utils/filesystem.js.map +1 -0
  124. package/dist/utils/lockfile.d.ts +12 -0
  125. package/dist/utils/lockfile.js +78 -0
  126. package/dist/utils/lockfile.js.map +1 -0
  127. package/dist/verification.d.ts +24 -0
  128. package/dist/verification.js +227 -0
  129. package/dist/verification.js.map +1 -0
  130. package/dist/worktree.d.ts +44 -0
  131. package/dist/worktree.js +255 -0
  132. package/dist/worktree.js.map +1 -0
  133. package/package.json +82 -0
  134. package/plugins/ai-delivery/.claude-plugin/plugin.json +6 -0
  135. package/plugins/ai-delivery/.mcp.json +8 -0
  136. package/plugins/ai-delivery/README.md +5 -0
  137. package/plugins/ai-delivery/dist/mcp-launcher.js +22 -0
  138. package/plugins/ai-delivery/skills/intake-create/SKILL.md +26 -0
  139. package/plugins/ai-delivery/skills/pr-handoff/SKILL.md +28 -0
  140. package/plugins/ai-delivery/skills/worktree-lifecycle/SKILL.md +24 -0
@@ -0,0 +1,1118 @@
1
+ import assert from 'node:assert/strict';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { createHash } from 'node:crypto';
4
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, readdirSync, realpathSync, rmSync, symlinkSync, unlinkSync, writeFileSync, } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { dirname, join } from 'node:path';
7
+ import { test } from 'vitest';
8
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
9
+ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
10
+ import { AI_DELIVERY_MCP_TOOLS } from './mcp/tools.js';
11
+ import { createAiDeliveryMcpServer } from './mcp/index.js';
12
+ import { loadDeliveryConfig } from './config/deliveryConfig.js';
13
+ import { digestValue } from './delivery/index.js';
14
+ import { executeTool, resumedIssueUpdate, startTrackedIssue } from './dispatch.js';
15
+ import { gitCommonDir } from './git.js';
16
+ import { createIssue, developIssue, listIssueSubissues, readyCheck, resumeCreatedIssue, updateIssue, } from './issue.js';
17
+ import { checkoutPr, finishIssue, listPrs, mergePr, prChecks, publishPr, submitFormalReview } from './pr.js';
18
+ import { updateIssueWorktreeDelivery } from './services/worktreeRegistry.js';
19
+ import { assertDeliveryRuntimeAdmitted } from './services/deliveryAdmission.js';
20
+ import { getDeliveryRecords } from './services/deliveryRecordService.js';
21
+ import { createIssuePhaseEvidence, verifyIssue } from './verification.js';
22
+ import { cleanupNonIssueWorktree, prepareIssueWorktree, preparePrWorktree, prepareStandaloneWorktree, } from './worktree.js';
23
+ function git(root, ...args) {
24
+ return execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim();
25
+ }
26
+ function sha256(path) {
27
+ return `sha256:${createHash('sha256').update(readFileSync(path)).digest('hex')}`;
28
+ }
29
+ function directoryHash(path) {
30
+ const entries = [];
31
+ const visit = (directory, prefix) => {
32
+ for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
33
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
34
+ const child = join(directory, entry.name);
35
+ if (entry.isDirectory())
36
+ visit(child, relative);
37
+ else if (entry.isFile())
38
+ entries.push({ path: relative, sha256: sha256(child) });
39
+ else
40
+ throw new Error('Synthetic backup contains an unsupported file type.');
41
+ }
42
+ };
43
+ visit(path, '');
44
+ return digestValue(entries);
45
+ }
46
+ function fixture(options = {}) {
47
+ const root = realpathSync(mkdtempSync(join(tmpdir(), 'ai-delivery-lifecycle-')));
48
+ const counter = join(root, '.git', 'stage-count.txt');
49
+ const secondCounter = join(root, '.git', 'second-stage-count.txt');
50
+ const failSecond = join(root, '.git', 'fail-second-stage');
51
+ try {
52
+ git(root, 'init', '-q', '-b', 'main');
53
+ git(root, 'config', 'user.name', 'Synthetic Delivery');
54
+ git(root, 'config', 'user.email', 'delivery@example.test');
55
+ git(root, 'init', '--bare', '-q', join(root, '.git', 'remote.git'));
56
+ git(root, 'remote', 'add', 'origin', join(root, '.git', 'remote.git'));
57
+ writeFileSync(join(root, '.gitignore'), '.issue-cli/\n.worktrees/\n');
58
+ writeFileSync(join(root, 'artifact.txt'), 'synthetic proof\n');
59
+ const config = {
60
+ commandPolicy: {
61
+ checks: { format: 'REQUIRED', gitClean: 'REQUIRED', lint: 'REQUIRED', test: 'REQUIRED', typecheck: 'REQUIRED' },
62
+ timeoutsMs: { lint: 60000, test: 60000, typecheck: 60000 },
63
+ },
64
+ native: {
65
+ issueTypes: ['Task'],
66
+ milestones: 'repository',
67
+ organization: 'example',
68
+ points: { databaseId: '101', name: 'Estimate', values: ['1', '2', '4'] },
69
+ priority: { databaseId: '102', name: 'Urgency', values: ['High', 'Low'] },
70
+ project: {
71
+ number: 1,
72
+ statuses: { blocked: 'Waiting', done: 'Shipped', inProgress: 'Active', todo: 'Queued' },
73
+ statusField: 'Flow',
74
+ title: 'Delivery',
75
+ },
76
+ relationships: { blockedBy: 'native', parent: 'native' },
77
+ },
78
+ policy: { contract: 'RepositoryDeliveryPolicy@1', module: './policy.mjs' },
79
+ repository: 'example/widget',
80
+ roles: {
81
+ author: {
82
+ credentialEnv: {
83
+ appId: 'AUTHOR_APP_ID',
84
+ installationId: 'AUTHOR_INSTALLATION_ID',
85
+ privateKeyPath: 'AUTHOR_KEY_PATH',
86
+ },
87
+ identity: 'synthetic-author',
88
+ },
89
+ reviewer: {
90
+ credentialEnv: {
91
+ appId: 'REVIEWER_APP_ID',
92
+ installationId: 'REVIEWER_INSTALLATION_ID',
93
+ privateKeyPath: 'REVIEWER_KEY_PATH',
94
+ },
95
+ identity: 'synthetic-reviewer',
96
+ },
97
+ },
98
+ schemaVersion: 'ai-delivery.config@1',
99
+ };
100
+ writeFileSync(join(root, 'ai-delivery.config.json'), `${JSON.stringify(config)}\n`);
101
+ writeFileSync(join(root, 'policy.mjs'), `
102
+ import { createHash } from 'node:crypto';
103
+ import { readFileSync } from 'node:fs';
104
+ import { fileURLToPath } from 'node:url';
105
+ const stable = value => Array.isArray(value) ? '[' + value.map(stable).join(',') + ']'
106
+ : value !== null && typeof value === 'object'
107
+ ? '{' + Object.keys(value).sort().map(key => JSON.stringify(key) + ':' + stable(value[key])).join(',') + '}'
108
+ : JSON.stringify(value);
109
+ const digest = value => 'sha256:' + createHash('sha256').update(stable(value)).digest('hex');
110
+ const bytes = value => 'sha256:' + createHash('sha256').update(value).digest('hex');
111
+ const policyDigest = bytes(readFileSync(fileURLToPath(import.meta.url)));
112
+ export default {
113
+ schemaVersion: 'RepositoryDeliveryPolicy@1',
114
+ classifyExactRange(input) {
115
+ const content = { artifacts: [{ digest: bytes(readFileSync(new URL('./artifact.txt', import.meta.url))),
116
+ path: 'artifact.txt', producer: 'synthetic' }],
117
+ base: { sha: input.baseSha, tree: input.baseTree }, configDigest: input.configDigest,
118
+ head: { sha: input.headSha, tree: input.headTree }, opaquePayload: 'synthetic proof',
119
+ policyDigest, producer: 'synthetic', repository: input.repository,
120
+ schemaVersion: 'ai-delivery.policy-evidence@1' };
121
+ return { policyDigest, policyEvidence: { ...content, evidenceId: digest(content) },
122
+ requiredStages: [{ id: 'check', dependsOn: [], semanticInputKeys: ['source'],
123
+ resourceClass: 'source_only', commands: [{ label: 'count', argv: [process.execPath, '-e',
124
+ ${JSON.stringify(`const fs=require('fs');const p=${JSON.stringify(counter)};fs.writeFileSync(p,String(Number(fs.existsSync(p)?fs.readFileSync(p,'utf8'):0)+1));`)}] }] }${options.twoStages
125
+ ? `, { id: 'second', dependsOn: ['check'], semanticInputKeys: ['source'],
126
+ resourceClass: 'source_only', commands: [{ label: 'retry', argv: [process.execPath, '-e',
127
+ ${JSON.stringify(`const fs=require('fs');const p=${JSON.stringify(secondCounter)};fs.writeFileSync(p,String(Number(fs.existsSync(p)?fs.readFileSync(p,'utf8'):0)+1));if(fs.existsSync(${JSON.stringify(failSecond)}))process.exit(7);`)}] }] }`
128
+ : ''}],
129
+ risk: 'standard' };
130
+ },
131
+ validateBoundary(input) {
132
+ const content = { additionalConstraints: { exactBaseHeadLease: true, requiredAttestationIds: [] },
133
+ classificationReceiptId: input.classificationReceiptId, configDigest: input.configDigest,
134
+ currentBase: input.currentBase, currentHead: input.currentHead, phase: input.phase,
135
+ policyEvidenceId: input.policyEvidence.evidenceId, schemaVersion: 'ai-delivery.policy-boundary@1',
136
+ stageReceiptSetHash: digest(input.stageReceiptIds) };
137
+ return { ...content, boundaryId: digest(content) };
138
+ },
139
+ };
140
+ `);
141
+ git(root, 'add', '.');
142
+ git(root, 'commit', '-qm', 'synthetic base');
143
+ git(root, 'push', '-q', 'origin', 'main');
144
+ git(root, 'fetch', '-q', 'origin', 'main');
145
+ const runtimeRoot = join(root, '.git', 'synthetic-runtime');
146
+ const runtimeEntryPath = join(runtimeRoot, 'package', 'dist', 'cli.js');
147
+ const packagePath = join(runtimeRoot, 'package', 'package.json');
148
+ const mcpLauncherPath = join(runtimeRoot, 'plugin', 'dist', 'mcp-launcher.js');
149
+ const pluginManifestPath = join(runtimeRoot, 'plugin', '.claude-plugin', 'plugin.json');
150
+ for (const path of [runtimeEntryPath, packagePath, mcpLauncherPath, pluginManifestPath]) {
151
+ mkdirSync(dirname(path), { recursive: true });
152
+ }
153
+ writeFileSync(runtimeEntryPath, 'synthetic CLI bytes\n');
154
+ writeFileSync(packagePath, JSON.stringify({ name: '@aviaratech/ai-delivery', version: '0.1.0' }));
155
+ writeFileSync(mcpLauncherPath, 'synthetic MCP launcher bytes\n');
156
+ writeFileSync(pluginManifestPath, JSON.stringify({ name: 'ai-delivery', packageVersion: '0.1.0', deliveryCapabilityVersion: 1 }));
157
+ const content = {
158
+ capability: { cli: 1, mcp: 1 },
159
+ cliPath: runtimeEntryPath,
160
+ cliSha256: sha256(runtimeEntryPath),
161
+ configDigest: loadDeliveryConfig(root).configDigest,
162
+ mcpLauncherPath,
163
+ mcpLauncherSha256: sha256(mcpLauncherPath),
164
+ packageVersion: '0.1.0',
165
+ packageDistSha256: digestValue([{ path: 'cli.js', sha256: sha256(runtimeEntryPath) }]),
166
+ packageManifestSha256: sha256(packagePath),
167
+ pluginManifestPath,
168
+ pluginManifestSha256: sha256(pluginManifestPath),
169
+ repository: 'example/widget',
170
+ schemaVersion: 'ai-delivery.runtime-admission@1',
171
+ sourceArchiveSha256: digestValue('synthetic archive'),
172
+ sourceCommit: git(root, 'rev-parse', 'HEAD'),
173
+ };
174
+ const admissionPath = join(root, '.git', 'ai-delivery', 'runtime-admission.json');
175
+ mkdirSync(dirname(admissionPath), { recursive: true });
176
+ writeFileSync(admissionPath, JSON.stringify({ ...content, admissionId: digestValue(content) }), { mode: 0o600 });
177
+ return { root, counter, secondCounter, failSecond, runtimeEntryPath };
178
+ }
179
+ catch (error) {
180
+ rmSync(root, { recursive: true, force: true });
181
+ throw error;
182
+ }
183
+ }
184
+ test('installed CLI and MCP admission fails before a worktree mutation on source or capability drift', async () => {
185
+ const { root, runtimeEntryPath } = fixture();
186
+ try {
187
+ const input = { repoRoot: root, runtimeEntryPath };
188
+ const path = join(root, '.git', 'ai-delivery', 'runtime-admission.json');
189
+ const original = readFileSync(path, 'utf8');
190
+ assert.equal(assertDeliveryRuntimeAdmitted(input).capability.mcp, 1);
191
+ const changedModule = join(dirname(runtimeEntryPath), 'mutated.js');
192
+ writeFileSync(changedModule, 'unadmitted module bytes\n');
193
+ assert.throws(() => assertDeliveryRuntimeAdmitted(input), /source, capability or repository admission/u);
194
+ rmSync(changedModule);
195
+ rmSync(path);
196
+ await assert.rejects(executeTool('issue_worktree_create', { name: 'scratch-admission', branch: 'scratch/admission' }, input), /runtime admission/u);
197
+ assert.equal(existsSync(join(root, '.worktrees', 'scratch-admission')), false);
198
+ writeFileSync(path, original, { mode: 0o600 });
199
+ const launcherPath = join(root, '.git', 'synthetic-runtime', 'plugin', 'dist', 'mcp-launcher.js');
200
+ writeFileSync(launcherPath, 'different MCP bytes\n');
201
+ await assert.rejects(executeTool('issue_worktree_create', { name: 'scratch-admission', branch: 'scratch/admission' }, input), /source, capability or repository admission/u);
202
+ assert.equal(existsSync(join(root, '.worktrees', 'scratch-admission')), false);
203
+ const admission = JSON.parse(original);
204
+ const { admissionId: _old, ...content } = admission;
205
+ const mismatched = { ...content, mcpLauncherSha256: sha256(launcherPath), capability: { cli: 1, mcp: 2 } };
206
+ writeFileSync(path, JSON.stringify({ ...mismatched, admissionId: digestValue(mismatched) }));
207
+ assert.throws(() => assertDeliveryRuntimeAdmitted(input), /source, capability or repository admission/u);
208
+ }
209
+ finally {
210
+ rmSync(root, { recursive: true, force: true });
211
+ }
212
+ });
213
+ test('an old active issue remains pinned while a new row uses the same canonical registry', async () => {
214
+ const { root } = fixture();
215
+ try {
216
+ const oldPath = join(root, '.worktrees', 'issue-17');
217
+ git(root, 'worktree', 'add', '-b', 'issue/17', oldPath, 'main');
218
+ const old = {
219
+ branch: 'issue/17',
220
+ createdAt: '2026-01-01T00:00:00.000Z',
221
+ identity: 'synthetic-author',
222
+ issueNumber: 17,
223
+ path: oldPath,
224
+ status: 'active',
225
+ type: 'issue',
226
+ updatedAt: '2026-01-01T00:00:00.000Z',
227
+ };
228
+ const registryPath = join(root, '.issue-cli', 'worktrees.json');
229
+ mkdirSync(dirname(registryPath), { recursive: true });
230
+ const futureBytes = JSON.stringify({
231
+ worktrees: [{ ...old, schemaVersion: 'future.registry-row@99', hold: 'unrecognized' }],
232
+ });
233
+ writeFileSync(registryPath, futureBytes);
234
+ await assert.rejects(prepareStandaloneWorktree({
235
+ branch: 'scratch/new',
236
+ identity: 'synthetic-author',
237
+ name: 'scratch-new',
238
+ repoRoot: root,
239
+ }), /unsupported fields/u);
240
+ assert.equal(readFileSync(registryPath, 'utf8'), futureBytes);
241
+ assert.equal(existsSync(join(root, '.worktrees', 'scratch-new')), false);
242
+ writeFileSync(registryPath, JSON.stringify({ worktrees: [old] }));
243
+ let issueWrites = 0;
244
+ const relationshipContext = {
245
+ root,
246
+ repo: { owner: 'example', repo: 'widget' },
247
+ config: loadDeliveryConfig(root).config,
248
+ clients: {
249
+ rest: {
250
+ issues: {
251
+ get: async () => ({ data: { id: 17, number: 17, node_id: 'PARENT-17', state: 'open' } }),
252
+ create: async () => {
253
+ issueWrites += 1;
254
+ return { data: {} };
255
+ },
256
+ update: async () => {
257
+ issueWrites += 1;
258
+ return { data: {} };
259
+ },
260
+ },
261
+ },
262
+ graphql: async () => ({
263
+ repository: {
264
+ issue: {
265
+ parent: { id: 'PARENT-17', number: 17 },
266
+ blockedBy: { nodes: [], pageInfo: { endCursor: null, hasNextPage: false } },
267
+ },
268
+ },
269
+ }),
270
+ },
271
+ };
272
+ await assert.rejects(createIssue(relationshipContext, { title: 'Synthetic child', parentIssueNumber: 17 }), /ownership witness/u);
273
+ await assert.rejects(updateIssue(relationshipContext, { issueNumber: 19, parentIssueNumber: null }), /ownership witness/u);
274
+ assert.equal(issueWrites, 0);
275
+ await assert.rejects(prepareIssueWorktree({ identity: 'synthetic-author', issueNumber: 17, repoRoot: root }), /ownership witness/u);
276
+ const row = await prepareStandaloneWorktree({
277
+ branch: 'scratch/new',
278
+ identity: 'synthetic-author',
279
+ name: 'scratch-new',
280
+ repoRoot: root,
281
+ });
282
+ await cleanupNonIssueWorktree({ name: 'scratch-new', repoRoot: root });
283
+ assert.equal(existsSync(row.path), false);
284
+ assert.deepEqual(JSON.parse(readFileSync(registryPath, 'utf8')), { worktrees: [old] });
285
+ assert.equal(existsSync(oldPath), true);
286
+ }
287
+ finally {
288
+ rmSync(root, { recursive: true, force: true });
289
+ }
290
+ });
291
+ test('synthetic cutover restores exact pre-write state and resumes an incomplete verification stage', async () => {
292
+ const { root, counter, secondCounter, failSecond } = fixture({ twoStages: true });
293
+ const backup = mkdtempSync(join(tmpdir(), 'ai-delivery-cutover-backup-'));
294
+ try {
295
+ const row = await prepareIssueWorktree({
296
+ baseRef: 'main',
297
+ identity: 'synthetic-author',
298
+ issueNumber: 17,
299
+ repoRoot: root,
300
+ });
301
+ writeFileSync(join(row.path, 'change.txt'), 'feature\n');
302
+ git(row.path, 'add', 'change.txt');
303
+ git(row.path, 'commit', '-qm', 'synthetic cutover source');
304
+ const registryPath = join(root, '.issue-cli');
305
+ const evidencePath = join(root, '.git', 'ai-delivery');
306
+ const oldRelease = join(root, '.git', 'synthetic-runtime');
307
+ const newRelease = join(root, '.git', 'unadmitted-runtime');
308
+ const current = join(root, '.git', 'current-runtime');
309
+ symlinkSync(oldRelease, current);
310
+ cpSync(registryPath, join(backup, 'registry'), { recursive: true });
311
+ cpSync(evidencePath, join(backup, 'evidence'), { recursive: true });
312
+ const registryBefore = directoryHash(registryPath);
313
+ const evidenceBefore = directoryHash(evidencePath);
314
+ const pointerBefore = readlinkSync(current);
315
+ const currentCli = join(current, 'package', 'dist', 'cli.js');
316
+ assert.equal(assertDeliveryRuntimeAdmitted({ repoRoot: root, runtimeEntryPath: currentCli }).capability.cli, 1);
317
+ cpSync(oldRelease, newRelease, { recursive: true });
318
+ writeFileSync(join(newRelease, 'package', 'dist', 'cli.js'), 'unadmitted CLI bytes\n');
319
+ unlinkSync(current);
320
+ symlinkSync(newRelease, current);
321
+ await assert.rejects(executeTool('issue_worktree_create', { branch: 'scratch/cutover', name: 'scratch-cutover' }, { repoRoot: root, runtimeEntryPath: currentCli }), /source, capability or repository admission/u);
322
+ assert.equal(existsSync(join(root, '.worktrees', 'scratch-cutover')), false);
323
+ assert.equal(directoryHash(registryPath), registryBefore);
324
+ assert.equal(directoryHash(evidencePath), evidenceBefore);
325
+ unlinkSync(current);
326
+ symlinkSync(pointerBefore, current);
327
+ assert.equal(readlinkSync(current), pointerBefore);
328
+ assertDeliveryRuntimeAdmitted({ repoRoot: root, runtimeEntryPath: currentCli });
329
+ const uninterrupted = await verifyIssue({ issueNumber: 17, repoRoot: row.path });
330
+ assert.equal(readFileSync(counter, 'utf8'), '1');
331
+ assert.equal(readFileSync(secondCounter, 'utf8'), '1');
332
+ const semanticOutputs = uninterrupted.stageReceipts.map((receipt) => ({
333
+ stageId: receipt.input.stageId,
334
+ artifacts: receipt.artifacts,
335
+ commands: receipt.commands.map((command) => ({ label: command.label, outputDigest: command.outputDigest })),
336
+ }));
337
+ rmSync(registryPath, { recursive: true });
338
+ rmSync(evidencePath, { recursive: true });
339
+ cpSync(join(backup, 'registry'), registryPath, { recursive: true });
340
+ cpSync(join(backup, 'evidence'), evidencePath, { recursive: true });
341
+ assert.equal(directoryHash(registryPath), registryBefore);
342
+ assert.equal(directoryHash(evidencePath), evidenceBefore);
343
+ assert.equal(readlinkSync(current), pointerBefore);
344
+ rmSync(counter);
345
+ rmSync(secondCounter);
346
+ writeFileSync(failSecond, 'interrupt after first stage\n');
347
+ await assert.rejects(verifyIssue({ issueNumber: 17, repoRoot: row.path }), /Selected policy stage command failed/u);
348
+ assert.equal(readFileSync(counter, 'utf8'), '1');
349
+ assert.equal(readFileSync(secondCounter, 'utf8'), '1');
350
+ const firstStageDirectory = join(evidencePath, 'verification@1', 'stages', 'check');
351
+ const checkpoint = join(firstStageDirectory, readdirSync(firstStageDirectory)[0]);
352
+ const completedCheckpointHash = sha256(checkpoint);
353
+ rmSync(failSecond);
354
+ const resumed = await verifyIssue({ issueNumber: 17, repoRoot: row.path });
355
+ assert.equal(readFileSync(counter, 'utf8'), '1');
356
+ assert.equal(readFileSync(secondCounter, 'utf8'), '2');
357
+ assert.equal(sha256(checkpoint), completedCheckpointHash);
358
+ assert.equal(resumed.classification.receiptId, uninterrupted.classification.receiptId);
359
+ assert.equal(resumed.aggregate.result, uninterrupted.aggregate.result);
360
+ assert.deepEqual(resumed.stageReceipts.map((receipt) => ({
361
+ stageId: receipt.input.stageId,
362
+ artifacts: receipt.artifacts,
363
+ commands: receipt.commands.map((command) => ({ label: command.label, outputDigest: command.outputDigest })),
364
+ })), semanticOutputs);
365
+ writeFileSync(checkpoint, 'corrupt checkpoint\n');
366
+ await assert.rejects(verifyIssue({ issueNumber: 17, repoRoot: row.path }));
367
+ assert.equal(readFileSync(counter, 'utf8'), '1');
368
+ rmSync(registryPath, { recursive: true });
369
+ rmSync(evidencePath, { recursive: true });
370
+ cpSync(join(backup, 'registry'), registryPath, { recursive: true });
371
+ cpSync(join(backup, 'evidence'), evidencePath, { recursive: true });
372
+ assert.equal(directoryHash(registryPath), registryBefore);
373
+ assert.equal(directoryHash(evidencePath), evidenceBefore);
374
+ assert.equal(readlinkSync(current), pointerBefore);
375
+ console.log('SYNTHETIC_CUTOVER_RECEIPT', JSON.stringify({
376
+ backupRegistrySha256: registryBefore,
377
+ backupEvidenceSha256: evidenceBefore,
378
+ completedCheckpointSha256: completedCheckpointHash,
379
+ classificationReceiptId: resumed.classification.receiptId,
380
+ semanticOutputSha256: digestValue(semanticOutputs),
381
+ }));
382
+ }
383
+ finally {
384
+ rmSync(backup, { recursive: true, force: true });
385
+ rmSync(root, { recursive: true, force: true });
386
+ }
387
+ });
388
+ test('issue worktree verification resumes a completed stage and rejects changed inputs', async () => {
389
+ const { root, counter, runtimeEntryPath } = fixture();
390
+ try {
391
+ await assert.rejects(executeTool('issue_create', { title: 'Wrong repository', repo: 'other/widget' }, { repoRoot: root }), /Repository selector must match/u);
392
+ const row = await prepareIssueWorktree({
393
+ baseRef: 'main',
394
+ identity: 'synthetic-author',
395
+ issueNumber: 17,
396
+ repoRoot: root,
397
+ });
398
+ const resumedRow = await prepareIssueWorktree({
399
+ identity: 'synthetic-author',
400
+ issueNumber: 17,
401
+ repoRoot: row.path,
402
+ });
403
+ assert.equal(resumedRow.path, row.path);
404
+ const scratch = await prepareStandaloneWorktree({
405
+ branch: 'scratch/synthetic',
406
+ identity: 'synthetic-author',
407
+ name: 'scratch-synthetic',
408
+ repoRoot: row.path,
409
+ });
410
+ assert.equal(scratch.path, join(dirname(row.path), 'scratch-synthetic'));
411
+ const prHead = git(root, 'rev-parse', 'HEAD');
412
+ const prRow = await preparePrWorktree({
413
+ headSha: prHead,
414
+ identity: 'synthetic-author',
415
+ prNumber: 23,
416
+ repoRoot: row.path,
417
+ });
418
+ assert.equal(prRow.type, 'pr');
419
+ assert.equal((await preparePrWorktree({ headSha: prHead, identity: 'synthetic-author', prNumber: 23, repoRoot: root })).path, prRow.path);
420
+ await assert.rejects(preparePrWorktree({ headSha: 'f'.repeat(40), identity: 'synthetic-author', prNumber: 23, repoRoot: root }), /head object is unavailable/u);
421
+ const status = JSON.parse(execFileSync(process.execPath, [join(process.cwd(), 'dist', 'cli.js'), '--repo-root', root, 'worktrees:status'], {
422
+ encoding: 'utf8',
423
+ }));
424
+ assert.equal(status.length, 3);
425
+ assert.equal(status.find((entry) => entry.type === 'issue')?.present, true);
426
+ await assert.rejects(cleanupNonIssueWorktree({ name: 'issue-17', repoRoot: root }), /no exact registry owner/u);
427
+ writeFileSync(join(scratch.path, 'unpublished.txt'), 'unpublished synthetic work\n');
428
+ git(scratch.path, 'add', 'unpublished.txt');
429
+ git(scratch.path, 'commit', '-qm', 'Synthetic unpublished work');
430
+ await assert.rejects(cleanupNonIssueWorktree({ name: 'scratch-synthetic', repoRoot: root }), /unpublished commit/u);
431
+ assert.equal(existsSync(scratch.path), true);
432
+ git(scratch.path, 'reset', '--hard', 'main');
433
+ await cleanupNonIssueWorktree({ name: 'scratch-synthetic', repoRoot: root });
434
+ await cleanupNonIssueWorktree({ prNumber: 23, repoRoot: root });
435
+ assert.equal(existsSync(scratch.path), false);
436
+ assert.equal(existsSync(prRow.path), false);
437
+ const listed = JSON.parse(execFileSync(process.execPath, [join(process.cwd(), 'dist', 'cli.js'), '--repo-root', root, 'worktrees:list'], {
438
+ encoding: 'utf8',
439
+ }));
440
+ assert.equal(listed.length, 1);
441
+ const reopenedPr = await preparePrWorktree({
442
+ headSha: prHead,
443
+ identity: 'synthetic-author',
444
+ prNumber: 23,
445
+ repoRoot: root,
446
+ });
447
+ assert.equal(reopenedPr.path, prRow.path);
448
+ await cleanupNonIssueWorktree({ prNumber: 23, repoRoot: root });
449
+ writeFileSync(join(row.path, 'change.txt'), 'feature\n');
450
+ git(row.path, 'add', 'change.txt');
451
+ git(row.path, 'commit', '-qm', 'synthetic change');
452
+ git(root, 'branch', '-f', 'pr/23', git(row.path, 'rev-parse', 'HEAD'));
453
+ await assert.rejects(preparePrWorktree({ headSha: prHead, identity: 'synthetic-author', prNumber: 23, repoRoot: root }), /Retained PR branch differs/u);
454
+ const first = await verifyIssue({ issueNumber: 17, repoRoot: row.path });
455
+ assert.equal(readFileSync(counter, 'utf8'), '1');
456
+ const second = await verifyIssue({ issueNumber: 17, repoRoot: row.path });
457
+ assert.equal(second.stageReceipts[0]?.receiptId, first.stageReceipts[0]?.receiptId);
458
+ assert.equal(readFileSync(counter, 'utf8'), '1');
459
+ const dispatched = await executeTool('issue_verify', { issueNumber: 17 }, { repoRoot: row.path, runtimeEntryPath });
460
+ assert.equal(dispatched.classificationReceiptId, first.classification.receiptId);
461
+ assert.equal(readFileSync(counter, 'utf8'), '1');
462
+ const evidence = await createIssuePhaseEvidence({ issueNumber: 17, phase: 'verify', repoRoot: row.path });
463
+ assert.equal(evidence.classificationReceiptId, first.classification.receiptId);
464
+ writeFileSync(join(row.path, 'artifact.txt'), 'corrupt');
465
+ await assert.rejects(verifyIssue({ issueNumber: 17, repoRoot: row.path }));
466
+ }
467
+ finally {
468
+ rmSync(root, { recursive: true, force: true });
469
+ }
470
+ });
471
+ test('scratch start derives safe names and reports the requested worktree', async () => {
472
+ const { root, runtimeEntryPath } = fixture();
473
+ try {
474
+ const first = (await executeTool('issue_start', { scratch: true, request: 'Investigate button layout' }, { repoRoot: root, runtimeEntryPath, identity: 'synthetic-author' }));
475
+ assert.equal(first.mode, 'scratch');
476
+ assert.equal(first.title, 'Investigate button layout');
477
+ assert.equal(first.branch, 'scratch/investigate-button-layout');
478
+ assert.ok(first.worktreePath.endsWith('/.worktrees/scratch-investigate-button-layout'));
479
+ const overridden = (await executeTool('issue_start', { scratch: true, request: 'Work on parser', branch: 'scratch/custom/parser' }, { repoRoot: root, runtimeEntryPath, identity: 'synthetic-author' }));
480
+ assert.equal(overridden.branch, 'scratch/custom/parser');
481
+ assert.equal(overridden.worktreePath, join(dirname(first.worktreePath), 'scratch-scratch-custom-parser'));
482
+ }
483
+ finally {
484
+ rmSync(root, { recursive: true, force: true });
485
+ }
486
+ });
487
+ test('MCP exposes one implementation surface for native lifecycle commands', () => {
488
+ assert.deepEqual(AI_DELIVERY_MCP_TOOLS.map((tool) => tool.name), [
489
+ 'issue_create',
490
+ 'issue_start',
491
+ 'issue_update',
492
+ 'issue_info',
493
+ 'issue_ready_check',
494
+ 'issue_develop',
495
+ 'issue_verify',
496
+ 'issue_pr_create',
497
+ 'issue_pr_info',
498
+ 'issue_pr_review',
499
+ 'issue_pr_merge',
500
+ 'issue_finish',
501
+ 'issue_worktree_create',
502
+ ]);
503
+ const validInputs = {
504
+ issue_create: { title: 'Synthetic tracking parent' },
505
+ issue_ready_check: { issueNumber: 17 },
506
+ issue_develop: { issueNumber: 17 },
507
+ issue_verify: { issueNumber: 17 },
508
+ issue_pr_create: { issueNumber: 17, draft: true },
509
+ issue_pr_review: { issueNumber: 17, prNumber: 23, artifact: '{}' },
510
+ issue_pr_merge: { issueNumber: 17, prNumber: 23, strategy: 'merge' },
511
+ issue_finish: { issueNumber: 17, prNumber: 23, strategy: 'merge' },
512
+ };
513
+ for (const [name, input] of Object.entries(validInputs)) {
514
+ const tool = AI_DELIVERY_MCP_TOOLS.find((candidate) => candidate.name === name);
515
+ assert.ok(tool, `${name} has an MCP definition`);
516
+ assert.equal(tool.inputSchema.safeParse(input).success, true, `${name} accepts its lifecycle input`);
517
+ assert.equal(tool.inputSchema.safeParse({ ...input, unexpected: true }).success, false, `${name} rejects unrelated fields`);
518
+ }
519
+ });
520
+ test('MCP request rejects unsupported legacy options before lifecycle dispatch', async () => {
521
+ const server = createAiDeliveryMcpServer({ repoRoot: '/missing-synthetic-repository' });
522
+ const client = new Client({ name: 'synthetic-test', version: '1.0.0' });
523
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
524
+ try {
525
+ await server.connect(serverTransport);
526
+ await client.connect(clientTransport);
527
+ const result = await client.callTool({
528
+ name: 'issue_create',
529
+ arguments: {
530
+ body: 'tracked work',
531
+ issueType: 'Task',
532
+ points: 1,
533
+ priority: 'High',
534
+ title: 'Synthetic change',
535
+ unsupportedLegacyOption: true,
536
+ },
537
+ });
538
+ assert.equal(result.isError, true);
539
+ assert.match(JSON.stringify(result.content), /unsupportedLegacyOption/u);
540
+ }
541
+ finally {
542
+ await client.close();
543
+ await server.close();
544
+ }
545
+ });
546
+ test('synthetic issue traverses native intake, readiness, receipt-bound App review, exact merge and finish', async () => {
547
+ const { root } = fixture();
548
+ const remote = join(root, '.git', 'remote.git');
549
+ const config = loadDeliveryConfig(root).config;
550
+ const baseSha = git(root, 'rev-parse', 'main');
551
+ const issueNumber = 17;
552
+ const prNumber = 23;
553
+ const issue = {
554
+ id: 170,
555
+ number: issueNumber,
556
+ node_id: 'ISSUE-17',
557
+ title: 'Synthetic change',
558
+ body: `## Outcome\nDeliver a verified synthetic change to the local repository.\n\n## Scope\n- \`artifact.txt\`\n\n## Acceptance Criteria\n- [ ] Change is verified\n- [ ] Review and merge are recorded\n\n## Verification\nRun \`node --version\`.`,
559
+ state: 'open',
560
+ html_url: 'https://example.test/issues/17',
561
+ type: { name: 'Task' },
562
+ };
563
+ let issuePoints;
564
+ let parentPoints = 1;
565
+ let parentIssueNumber = null;
566
+ let parentChildren = [];
567
+ let issuePriority;
568
+ let projectStatus = null;
569
+ let blockerOpen = false;
570
+ let trackingParent = false;
571
+ let failFirstBlockerLink = true;
572
+ let failNextProjectSync = false;
573
+ let checksPassed = true;
574
+ let prDraft = true;
575
+ let prState = 'open';
576
+ let mergedAt = '';
577
+ let headSha = '';
578
+ let mergeSha = '';
579
+ let stalePrReadbacks = 0;
580
+ let review = null;
581
+ const calls = [];
582
+ const createPayloads = [];
583
+ const statuses = { Queued: 'todo', Active: 'active', Waiting: 'blocked', Shipped: 'done' };
584
+ const statusName = (option) => Object.entries(statuses).find(([, id]) => id === option)?.[0];
585
+ const pageInfo = { endCursor: null, hasNextPage: false };
586
+ const issueFieldValues = (number = issueNumber) => [
587
+ ...((number === 11 ? parentPoints : issuePoints) === undefined
588
+ ? []
589
+ : [
590
+ {
591
+ data_type: 'single_select',
592
+ issue_field_id: 101,
593
+ issue_field_name: 'Estimate',
594
+ value: String(number === 11 ? parentPoints : issuePoints),
595
+ },
596
+ ]),
597
+ ...(issuePriority === undefined
598
+ ? []
599
+ : [{ data_type: 'single_select', issue_field_id: 102, issue_field_name: 'Urgency', value: issuePriority }]),
600
+ ];
601
+ const pr = (stale = false) => ({
602
+ number: prNumber,
603
+ node_id: 'PR-23',
604
+ title: 'Synthetic change',
605
+ html_url: 'https://example.test/pulls/23',
606
+ state: stale ? 'open' : prState,
607
+ draft: prDraft,
608
+ merged_at: !stale && prState === 'closed' ? mergedAt : null,
609
+ merge_commit_sha: mergeSha || null,
610
+ mergeable: true,
611
+ mergeable_state: 'clean',
612
+ user: { login: 'synthetic-author' },
613
+ head: { sha: headSha, ref: 'issue/17' },
614
+ base: { sha: baseSha, ref: 'main' },
615
+ });
616
+ const graphql = async (query, variables = {}) => {
617
+ if (query.includes('ProjectDeliveryConfiguration'))
618
+ return {
619
+ organization: {
620
+ projectV2: {
621
+ id: 'PROJECT-1',
622
+ number: 1,
623
+ title: 'Delivery',
624
+ viewerCanUpdate: true,
625
+ fields: {
626
+ nodes: [
627
+ {
628
+ __typename: 'ProjectV2SingleSelectField',
629
+ id: 'FIELD-FLOW',
630
+ name: 'Flow',
631
+ isIssueField: false,
632
+ options: Object.entries(statuses).map(([name, id]) => ({ id, name })),
633
+ },
634
+ {
635
+ __typename: 'ProjectV2SingleSelectField',
636
+ id: 'FIELD-POINTS',
637
+ name: 'Estimate',
638
+ isIssueField: true,
639
+ options: [],
640
+ issueField: {
641
+ __typename: 'IssueFieldSingleSelect',
642
+ fullDatabaseId: '101',
643
+ name: 'Estimate',
644
+ options: ['1', '2', '4'].map((name) => ({ id: name, name })),
645
+ },
646
+ },
647
+ {
648
+ __typename: 'ProjectV2SingleSelectField',
649
+ id: 'FIELD-PRIORITY',
650
+ name: 'Urgency',
651
+ isIssueField: true,
652
+ options: [],
653
+ issueField: {
654
+ __typename: 'IssueFieldSingleSelect',
655
+ fullDatabaseId: '102',
656
+ name: 'Urgency',
657
+ options: ['High', 'Low'].map((name) => ({ id: name, name })),
658
+ },
659
+ },
660
+ ],
661
+ pageInfo,
662
+ },
663
+ },
664
+ },
665
+ };
666
+ if (query.includes('ProjectDeliveryItems'))
667
+ return {
668
+ organization: {
669
+ projectV2: {
670
+ items: {
671
+ nodes: projectStatus === null
672
+ ? []
673
+ : [
674
+ {
675
+ id: 'ITEM-17',
676
+ isArchived: false,
677
+ content: { id: issue.node_id },
678
+ fieldValueByName: {
679
+ name: projectStatus,
680
+ optionId: statuses[projectStatus],
681
+ },
682
+ },
683
+ ],
684
+ pageInfo,
685
+ },
686
+ },
687
+ },
688
+ };
689
+ if (query.includes('AddProjectDeliveryItem')) {
690
+ if (failNextProjectSync) {
691
+ failNextProjectSync = false;
692
+ throw new Error('synthetic Project interruption');
693
+ }
694
+ return { addProjectV2ItemById: { item: { id: 'ITEM-17' } } };
695
+ }
696
+ if (query.includes('UpdateProjectDeliveryStatus')) {
697
+ projectStatus = statusName(String(variables.optionId)) ?? null;
698
+ return { updateProjectV2ItemFieldValue: { projectV2Item: { id: 'ITEM-17' } } };
699
+ }
700
+ if (query.includes('ProjectDeliveryItemReadback'))
701
+ return {
702
+ node: {
703
+ id: 'ITEM-17',
704
+ isArchived: false,
705
+ project: { id: 'PROJECT-1', number: 1 },
706
+ content: { id: issue.node_id },
707
+ fieldValueByName: { name: projectStatus, optionId: statuses[projectStatus] },
708
+ },
709
+ };
710
+ if (query.includes('blockedBy(first:'))
711
+ return {
712
+ repository: {
713
+ issue: {
714
+ parent: parentIssueNumber === null ? null : { id: 'PARENT-11', number: parentIssueNumber },
715
+ blockedBy: {
716
+ nodes: blockerOpen ? [{ id: 'BLOCKER-9', number: 9, state: 'OPEN', title: 'Blocker' }] : [],
717
+ pageInfo,
718
+ },
719
+ },
720
+ },
721
+ };
722
+ if (query.includes('addBlockedBy')) {
723
+ if (failFirstBlockerLink) {
724
+ failFirstBlockerLink = false;
725
+ throw new Error('synthetic interruption');
726
+ }
727
+ blockerOpen = true;
728
+ return { addBlockedBy: { issue: { number: issueNumber } } };
729
+ }
730
+ if (query.includes('RepositoryId'))
731
+ return { repository: { id: 'REPO-1' } };
732
+ if (query.includes('UpdateExactRefs')) {
733
+ const input = variables.input;
734
+ assert.equal(git(remote, 'rev-parse', 'refs/heads/main'), baseSha);
735
+ git(remote, 'update-ref', 'refs/heads/main', input.refUpdates[0].afterOid, baseSha);
736
+ prState = 'closed';
737
+ mergedAt = new Date().toISOString();
738
+ stalePrReadbacks = 2;
739
+ return { updateRefs: { clientMutationId: input.clientMutationId } };
740
+ }
741
+ if (query.includes('markPullRequestReadyForReview')) {
742
+ prDraft = false;
743
+ return { markPullRequestReadyForReview: { pullRequest: { id: 'PR-23', isDraft: false } } };
744
+ }
745
+ throw new Error(`Unexpected GraphQL call: ${query.slice(0, 100)}`);
746
+ };
747
+ const rest = {
748
+ request: async (route, parameters) => {
749
+ if (route === 'GET /orgs/{org}/issue-fields')
750
+ return {
751
+ data: [
752
+ {
753
+ id: 101,
754
+ name: 'Estimate',
755
+ data_type: 'single_select',
756
+ options: ['1', '2', '4'].map((name) => ({ name })),
757
+ },
758
+ {
759
+ id: 102,
760
+ name: 'Urgency',
761
+ data_type: 'single_select',
762
+ options: ['High', 'Low'].map((name) => ({ name })),
763
+ },
764
+ ],
765
+ };
766
+ if (route === 'POST /repos/{owner}/{repo}/issues/{issue_number}/issue-field-values') {
767
+ for (const value of parameters.issue_field_values) {
768
+ if (value.field_id === 101)
769
+ issuePoints = Number(value.value);
770
+ if (value.field_id === 102)
771
+ issuePriority = value.value;
772
+ }
773
+ return { data: issueFieldValues() };
774
+ }
775
+ if (route === 'GET /repos/{owner}/{repo}/issues/{issue_number}/issue-field-values') {
776
+ return { data: issueFieldValues(Number(parameters.issue_number)) };
777
+ }
778
+ if (route === 'DELETE /repos/{owner}/{repo}/issues/{issue_number}/issue-field-values/{issue_field_id}') {
779
+ assert.equal(parameters.issue_number, 11);
780
+ assert.equal(parameters.issue_field_id, 101);
781
+ parentPoints = undefined;
782
+ return { data: null };
783
+ }
784
+ throw new Error(`Unexpected REST route: ${route}`);
785
+ },
786
+ issues: {
787
+ create: async (input) => {
788
+ calls.push('issue:create');
789
+ createPayloads.push(input);
790
+ return { data: issue };
791
+ },
792
+ get: async (input) => ({
793
+ data: input.issue_number === 9
794
+ ? { number: 9, node_id: 'BLOCKER-9', state: 'open', title: 'Blocker' }
795
+ : input.issue_number === 11
796
+ ? { id: 110, number: 11, node_id: 'PARENT-11', state: 'open', title: 'Parent' }
797
+ : issue,
798
+ }),
799
+ listSubIssues: async (input) => ({
800
+ data: input.issue_number === 11
801
+ ? parentChildren.map((number) => ({ number }))
802
+ : trackingParent
803
+ ? [{ number: 18 }]
804
+ : [],
805
+ }),
806
+ addSubIssue: async (input) => {
807
+ assert.equal(input.issue_number, 11);
808
+ assert.equal(input.sub_issue_id, 170);
809
+ parentChildren = [issueNumber];
810
+ parentIssueNumber = 11;
811
+ return { data: {} };
812
+ },
813
+ update: async (input) => {
814
+ if (input.state !== undefined)
815
+ issue.state = input.state;
816
+ return { data: issue };
817
+ },
818
+ },
819
+ paginate: async (_method, input) => input.state !== undefined
820
+ ? [pr()]
821
+ : input.issue_number === 11
822
+ ? parentChildren.map((number) => ({ number }))
823
+ : trackingParent
824
+ ? [{ number: 18 }]
825
+ : [],
826
+ pulls: {
827
+ get: async () => {
828
+ const stale = stalePrReadbacks > 0;
829
+ if (stale)
830
+ stalePrReadbacks -= 1;
831
+ return { data: pr(stale) };
832
+ },
833
+ list: async () => ({ data: [pr()] }),
834
+ listReviews: async () => ({ data: review ? [review] : [] }),
835
+ createReview: async (input) => {
836
+ calls.push('review:create');
837
+ assert.equal(input.event, 'APPROVE');
838
+ review = {
839
+ id: 31,
840
+ body: input.body,
841
+ state: 'APPROVED',
842
+ commit_id: input.commit_id,
843
+ user: { login: 'synthetic-reviewer' },
844
+ html_url: 'https://example.test/pulls/23#review-31',
845
+ };
846
+ return { data: review };
847
+ },
848
+ },
849
+ git: {
850
+ getRef: async (input) => ({
851
+ data: { object: { sha: git(remote, 'rev-parse', `refs/${input.ref}`) } },
852
+ }),
853
+ createCommit: async (input) => {
854
+ calls.push('git:createCommit');
855
+ mergeSha = git(root, 'commit-tree', input.tree, '-p', input.parents[0], '-p', input.parents[1], '-m', input.message);
856
+ git(root, 'push', '-q', 'origin', `${mergeSha}:refs/heads/merge-object`);
857
+ return { data: { sha: mergeSha } };
858
+ },
859
+ getCommit: async () => ({
860
+ data: {
861
+ sha: mergeSha,
862
+ tree: { sha: git(root, 'rev-parse', `${mergeSha}^{tree}`) },
863
+ parents: [{ sha: baseSha }, { sha: headSha }],
864
+ },
865
+ }),
866
+ },
867
+ checks: {
868
+ listForRef: async () => ({
869
+ data: {
870
+ total_count: 1,
871
+ check_runs: [
872
+ { status: checksPassed ? 'completed' : 'in_progress', conclusion: checksPassed ? 'success' : null },
873
+ ],
874
+ },
875
+ }),
876
+ },
877
+ repos: { getCombinedStatusForRef: async () => ({ data: { statuses: [], state: 'success' } }) },
878
+ };
879
+ let authenticatedAuthor = 'synthetic-author[bot]';
880
+ const context = {
881
+ root,
882
+ repo: { owner: 'example', repo: 'widget' },
883
+ config,
884
+ clients: {
885
+ authSource: 'app',
886
+ role: 'author',
887
+ graphql,
888
+ rest,
889
+ authenticatedAuthor: async () => ({
890
+ actorLogin: authenticatedAuthor,
891
+ credentialIdentity: 'app:201:installation:301',
892
+ }),
893
+ },
894
+ };
895
+ try {
896
+ await assert.rejects(startTrackedIssue(context, { request: 'Unready task', develop: true }), /New issue is not ready/u);
897
+ assert.equal(calls.filter((call) => call === 'issue:create').length, 0);
898
+ const unsized = await createIssue(context, { title: 'Synthetic child', parentIssueNumber: 11 });
899
+ assert.equal(unsized.created.number, issueNumber);
900
+ assert.equal(createPayloads[0]?.body, '');
901
+ assert.equal('type' in createPayloads[0], false);
902
+ assert.equal(parentPoints, undefined);
903
+ assert.equal(issuePoints, undefined);
904
+ assert.equal(parentIssueNumber, 11);
905
+ assert.deepEqual(await listIssueSubissues(context, 11), [issueNumber]);
906
+ parentPoints = 1;
907
+ parentChildren = [];
908
+ parentIssueNumber = null;
909
+ await updateIssue(context, { issueNumber, parentIssueNumber: 11 });
910
+ assert.equal(parentPoints, undefined);
911
+ parentPoints = 1;
912
+ parentChildren = [];
913
+ parentIssueNumber = null;
914
+ projectStatus = null;
915
+ calls.length = 0;
916
+ await assert.rejects(startTrackedIssue(context, { issueNumber, resumeCreated: true }), /requires issueNumber and develop=true/u);
917
+ await assert.rejects(createIssue(context, {
918
+ body: issue.body,
919
+ blockedBy: [9],
920
+ issueType: 'Task',
921
+ points: 1,
922
+ priority: 'High',
923
+ title: issue.title,
924
+ }), /created but native tracking did not complete/u);
925
+ assert.equal(calls.filter((call) => call === 'issue:create').length, 1);
926
+ const resumedInput = resumedIssueUpdate({
927
+ issueNumber,
928
+ body: issue.body,
929
+ blockedBy: [9],
930
+ issueType: 'Task',
931
+ labels: ['area:delivery'],
932
+ milestone: 1,
933
+ points: 1,
934
+ priority: 'High',
935
+ title: issue.title,
936
+ });
937
+ assert.deepEqual(resumedInput.blockedBy, [9]);
938
+ assert.deepEqual(resumedInput.labels, ['area:delivery']);
939
+ assert.equal(resumedInput.milestone, 1);
940
+ assert.equal(resumedIssueUpdate({ issueNumber, parentIssueNumber: 11 }).parentIssueNumber, 11);
941
+ const resumed = await resumeCreatedIssue(context, resumedInput);
942
+ assert.deepEqual(resumed.blockedBy, [9]);
943
+ assert.equal(projectStatus, 'Waiting');
944
+ assert.equal((await readyCheck(context, issueNumber)).ready, false);
945
+ await assert.rejects(developIssue(context, issueNumber), /not ready/);
946
+ blockerOpen = false;
947
+ trackingParent = true;
948
+ assert.equal((await readyCheck(context, issueNumber)).ready, false);
949
+ await assert.rejects(developIssue(context, issueNumber), /tracking parent/u);
950
+ trackingParent = false;
951
+ issue.state = 'closed';
952
+ await assert.rejects(developIssue(context, issueNumber), /closed/u);
953
+ issue.state = 'open';
954
+ assert.equal((await readyCheck(context, issueNumber)).ready, true);
955
+ const started = await startTrackedIssue(context, { issueNumber });
956
+ assert.equal(started.mode, 'existing-issue');
957
+ assert.equal(started.issueNumber, issueNumber);
958
+ assert.equal(started.title, issue.title);
959
+ const row = started;
960
+ assert.equal(projectStatus, 'Active');
961
+ writeFileSync(join(row.path, 'change.txt'), 'feature\n');
962
+ git(row.path, 'add', 'change.txt');
963
+ git(row.path, 'commit', '-qm', 'synthetic change');
964
+ headSha = git(row.path, 'rev-parse', 'HEAD');
965
+ const run = await verifyIssue({ issueNumber, repoRoot: row.path });
966
+ await assert.rejects(publishPr(context, { issueNumber, body: 'Closes #17' }), /Delivery Impact/);
967
+ await assert.rejects(publishPr(context, { issueNumber }), /Git push requires the selected author GitHub App installation token/);
968
+ // The public push requires a real author App installation token. Bind a synthetic
969
+ // publication at that external boundary so the remaining public phases run offline.
970
+ git(row.path, 'push', '-q', 'origin', `${headSha}:refs/heads/issue/17`);
971
+ const publishEvidence = await createIssuePhaseEvidence({ issueNumber, phase: 'publish', repoRoot: row.path });
972
+ const publicationContent = {
973
+ baseSha,
974
+ evidenceId: publishEvidence.evidenceId,
975
+ headSha,
976
+ issueNumber,
977
+ prNumber,
978
+ schemaVersion: 'ai-delivery.publication@1',
979
+ };
980
+ const publicationPath = join(gitCommonDir(row.path), 'ai-delivery', 'publications', String(issueNumber), `${headSha}.json`);
981
+ mkdirSync(dirname(publicationPath), { recursive: true });
982
+ writeFileSync(publicationPath, JSON.stringify({ ...publicationContent, publicationId: digestValue(publicationContent) }), { mode: 0o600 });
983
+ await updateIssueWorktreeDelivery({
984
+ branch: row.branch,
985
+ issueNumber,
986
+ path: row.path,
987
+ prNumber,
988
+ projectRoot: root,
989
+ status: 'pr-published',
990
+ });
991
+ assert.equal((await listPrs(context)).pullRequests[0]?.number, prNumber);
992
+ assert.equal((await prChecks(context, prNumber)).combinedStatus, 'success');
993
+ await assert.rejects(checkoutPr(context, prNumber), /open in-repository branch/u);
994
+ assert.equal(git(remote, 'rev-parse', 'refs/heads/issue/17'), headSha);
995
+ await assert.rejects(mergePr(context, { issueNumber, prNumber }), /submitted independent review/);
996
+ const artifactContent = {
997
+ authorIdentity: 'synthetic-author',
998
+ checks: ['verified exact diff'],
999
+ diffScopeHash: digestValue(run.classification.changedPaths),
1000
+ elapsedMs: 100,
1001
+ findings: [],
1002
+ head: run.classification.head,
1003
+ issueNumber,
1004
+ prNumber,
1005
+ readOnly: true,
1006
+ requestedEffort: 'xhigh',
1007
+ requestedModel: 'gpt-6-astra',
1008
+ effectiveEffort: 'xhigh',
1009
+ effectiveModel: 'gpt-6-astra',
1010
+ reviewerIdentity: 'synthetic-reviewer',
1011
+ schemaVersion: 'ai-delivery.review-artifact@1',
1012
+ summary: 'Independent approval',
1013
+ verdict: 'approve',
1014
+ };
1015
+ const artifact = JSON.stringify({ ...artifactContent, artifactId: digestValue(artifactContent) });
1016
+ const reviewer = {
1017
+ ...context,
1018
+ clients: { ...context.clients, role: 'reviewer', appActorLogin: async () => 'synthetic-reviewer' },
1019
+ };
1020
+ const personalReviewer = {
1021
+ ...reviewer,
1022
+ clients: { ...reviewer.clients, authSource: 'personal' },
1023
+ };
1024
+ await assert.rejects(submitFormalReview(personalReviewer, { artifact, issueNumber, prNumber }), /reviewer GitHub App role/);
1025
+ const reviewResult = await submitFormalReview(reviewer, { artifact, issueNumber, prNumber });
1026
+ assert.equal(reviewResult.githubReviewId, 31);
1027
+ await publishPr(context, { issueNumber, draft: false });
1028
+ assert.equal(prDraft, false);
1029
+ checksPassed = false;
1030
+ await assert.rejects(mergePr(context, { issueNumber, prNumber }), /failed or pending checks/);
1031
+ checksPassed = true;
1032
+ blockerOpen = true;
1033
+ await assert.rejects(mergePr(context, { issueNumber, prNumber }), /unresolved native blockers/);
1034
+ blockerOpen = false;
1035
+ issuePoints = 4;
1036
+ await assert.rejects(finishIssue(context, { issueNumber, prNumber, strategy: 'merge' }), /did not report the exact merge commit/u);
1037
+ assert.equal(calls.filter((call) => call === 'git:createCommit').length, 1);
1038
+ const attemptPath = join(gitCommonDir(root), 'ai-delivery', 'merge-attempts', String(issueNumber), `${headSha}.json`);
1039
+ const attempt = JSON.parse(readFileSync(attemptPath, 'utf8'));
1040
+ assert.equal(attempt.operation, 'updateRefs');
1041
+ assert.equal(attempt.actor, 'synthetic-author');
1042
+ assert.equal(attempt.actorLogin, 'synthetic-author[bot]');
1043
+ authenticatedAuthor = 'different-author[bot]';
1044
+ await assert.rejects(finishIssue(context, { issueNumber, prNumber, strategy: 'merge' }), /Merge attempt disagrees with its exact source, operation or author/u);
1045
+ authenticatedAuthor = 'synthetic-author[bot]';
1046
+ assert.equal(calls.filter((call) => call === 'git:createCommit').length, 1);
1047
+ await assert.rejects(finishIssue(context, { issueNumber, prNumber, strategy: 'merge' }), /readback is unresolved/u);
1048
+ assert.equal(calls.filter((call) => call === 'git:createCommit').length, 1);
1049
+ const resultPath = join(gitCommonDir(root), 'ai-delivery', 'merge-results', String(issueNumber), `${headSha}.json`);
1050
+ const originalResult = readFileSync(resultPath, 'utf8');
1051
+ const result = JSON.parse(originalResult);
1052
+ const { resultId: _resultId, ...resultContent } = result;
1053
+ const wrongResult = { ...resultContent, mergeSha: 'a'.repeat(40) };
1054
+ writeFileSync(resultPath, JSON.stringify({ ...wrongResult, resultId: digestValue(wrongResult) }), { mode: 0o600 });
1055
+ await assert.rejects(finishIssue(context, { issueNumber, prNumber, strategy: 'merge' }), /exact prepared merge intent/u);
1056
+ writeFileSync(resultPath, originalResult, { mode: 0o600 });
1057
+ const reviewedHead = headSha;
1058
+ headSha = 'b'.repeat(40);
1059
+ await assert.rejects(finishIssue(context, { issueNumber, prNumber, strategy: 'merge' }), /exact prepared merge intent/u);
1060
+ headSha = reviewedHead;
1061
+ assert.equal(calls.filter((call) => call === 'git:createCommit').length, 1);
1062
+ const blockedRecordPath = join(gitCommonDir(root), 'ai-delivery', 'deliveries.json');
1063
+ mkdirSync(blockedRecordPath);
1064
+ await assert.rejects(finishIssue(context, { issueNumber, prNumber, strategy: 'merge' }), /Delivery record store is invalid/u);
1065
+ assert.equal(existsSync(row.path), false);
1066
+ rmSync(blockedRecordPath, { recursive: true });
1067
+ const finished = await finishIssue(context, { issueNumber, prNumber, strategy: 'merge' });
1068
+ assert.equal(finished.issueClosed, true);
1069
+ assert.equal(finished.cleaned, true);
1070
+ assert.equal(projectStatus, 'Shipped');
1071
+ assert.equal(git(remote, 'rev-parse', 'refs/heads/main'), finished.mergeSha);
1072
+ assert.equal(existsSync(row.path), false);
1073
+ assert.equal(getDeliveryRecords(root).length, 1);
1074
+ assert.equal(getDeliveryRecords(root)[0]?.points, 4);
1075
+ assert.deepEqual(calls, ['issue:create', 'review:create', 'git:createCommit']);
1076
+ assert.deepEqual(await finishIssue(context, { issueNumber, prNumber, strategy: 'merge' }), finished);
1077
+ assert.equal(getDeliveryRecords(root).length, 1);
1078
+ await assert.rejects(finishIssue(context, { issueNumber, prNumber: 24 }), /exact terminal merge receipts/u);
1079
+ const newlyStarted = await startTrackedIssue(context, { request: 'New synthetic task' });
1080
+ assert.equal(newlyStarted.mode, 'created-issue');
1081
+ assert.equal(newlyStarted.issueNumber, issueNumber);
1082
+ assert.equal(createPayloads.at(-1)?.body, 'New synthetic task');
1083
+ assert.equal(issuePoints, 2);
1084
+ issue.state = 'closed';
1085
+ const beforeDevelopment = calls.filter((call) => call === 'issue:create').length;
1086
+ const developmentFailure = (await startTrackedIssue(context, {
1087
+ title: issue.title,
1088
+ body: issue.body,
1089
+ points: 2,
1090
+ develop: true,
1091
+ }));
1092
+ assert.equal(developmentFailure.status, 'created-not-started');
1093
+ assert.equal(developmentFailure.failure.phase, 'development');
1094
+ assert.equal(calls.filter((call) => call === 'issue:create').length, beforeDevelopment + 1);
1095
+ issue.state = 'open';
1096
+ const resumedDevelopment = await startTrackedIssue(context, developmentFailure.safeResume.arguments);
1097
+ assert.equal(resumedDevelopment.status, 'started');
1098
+ assert.equal(calls.filter((call) => call === 'issue:create').length, beforeDevelopment + 1);
1099
+ projectStatus = null;
1100
+ failNextProjectSync = true;
1101
+ const beforeTracking = calls.filter((call) => call === 'issue:create').length;
1102
+ const trackingFailure = (await startTrackedIssue(context, {
1103
+ title: issue.title,
1104
+ body: issue.body,
1105
+ points: 2,
1106
+ develop: true,
1107
+ }));
1108
+ assert.equal(trackingFailure.status, 'created-not-started');
1109
+ assert.equal(trackingFailure.failure.phase, 'tracking');
1110
+ const resumedTracking = await startTrackedIssue(context, trackingFailure.safeResume.arguments);
1111
+ assert.equal(resumedTracking.status, 'started');
1112
+ assert.equal(calls.filter((call) => call === 'issue:create').length, beforeTracking + 1);
1113
+ }
1114
+ finally {
1115
+ rmSync(root, { recursive: true, force: true });
1116
+ }
1117
+ });
1118
+ //# sourceMappingURL=lifecycle.test.js.map