@aviaratech/ai-delivery 0.1.0 → 0.2.1

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 (58) hide show
  1. package/CONTRIBUTING.md +1 -1
  2. package/README.md +124 -36
  3. package/dist/agent.d.ts +4 -1
  4. package/dist/agent.js +2 -1
  5. package/dist/agent.js.map +1 -1
  6. package/dist/cli.js +19 -5
  7. package/dist/cli.js.map +1 -1
  8. package/dist/config/deliveryConfig.d.ts +102 -4
  9. package/dist/config/deliveryConfig.js +120 -31
  10. package/dist/config/deliveryConfig.js.map +1 -1
  11. package/dist/config/deliveryConfig.test.js +9 -7
  12. package/dist/config/deliveryConfig.test.js.map +1 -1
  13. package/dist/dispatch.d.ts +1 -0
  14. package/dist/dispatch.js +50 -24
  15. package/dist/dispatch.js.map +1 -1
  16. package/dist/fixtures/discovery.d.ts +74 -0
  17. package/dist/fixtures/discovery.js +94 -0
  18. package/dist/fixtures/discovery.js.map +1 -0
  19. package/dist/git.d.ts +1 -1
  20. package/dist/git.js +13 -9
  21. package/dist/git.js.map +1 -1
  22. package/dist/github/client.d.ts +1 -1
  23. package/dist/github/commandIdentityPolicy.d.ts +1 -1
  24. package/dist/github/discovery.d.ts +22 -0
  25. package/dist/github/discovery.js +226 -0
  26. package/dist/github/discovery.js.map +1 -0
  27. package/dist/github/discovery.test.d.ts +1 -0
  28. package/dist/github/discovery.test.js +195 -0
  29. package/dist/github/discovery.test.js.map +1 -0
  30. package/dist/github/projectDelivery.d.ts +5 -0
  31. package/dist/github/projectDelivery.js +48 -8
  32. package/dist/github/projectDelivery.js.map +1 -1
  33. package/dist/github/repo.d.ts +3 -2
  34. package/dist/github/repo.js +24 -7
  35. package/dist/github/repo.js.map +1 -1
  36. package/dist/issue.d.ts +6 -1
  37. package/dist/issue.js +23 -3
  38. package/dist/issue.js.map +1 -1
  39. package/dist/lifecycle.test.js +416 -33
  40. package/dist/lifecycle.test.js.map +1 -1
  41. package/dist/mcp/index.js +6 -3
  42. package/dist/mcp/index.js.map +1 -1
  43. package/dist/mcp/tools.d.ts +1 -1
  44. package/dist/pr.js +25 -13
  45. package/dist/pr.js.map +1 -1
  46. package/dist/pr.test.js +3 -0
  47. package/dist/pr.test.js.map +1 -1
  48. package/dist/services/deliveryAdmission.d.ts +5 -2
  49. package/dist/services/deliveryAdmission.js +5 -4
  50. package/dist/services/deliveryAdmission.js.map +1 -1
  51. package/dist/verification.d.ts +3 -0
  52. package/dist/verification.js +153 -22
  53. package/dist/verification.js.map +1 -1
  54. package/dist/worktree.d.ts +4 -0
  55. package/dist/worktree.js +9 -7
  56. package/dist/worktree.js.map +1 -1
  57. package/package.json +1 -1
  58. package/plugins/ai-delivery/.claude-plugin/plugin.json +2 -2
@@ -1,18 +1,21 @@
1
1
  import assert from 'node:assert/strict';
2
- import { execFileSync } from 'node:child_process';
2
+ import { execFileSync, spawn } from 'node:child_process';
3
3
  import { createHash } from 'node:crypto';
4
4
  import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, readdirSync, realpathSync, rmSync, symlinkSync, unlinkSync, writeFileSync, } from 'node:fs';
5
5
  import { tmpdir } from 'node:os';
6
6
  import { dirname, join } from 'node:path';
7
- import { test } from 'vitest';
7
+ import { test, vi } from 'vitest';
8
+ import { syntheticDiscoveryClients, syntheticOverrides } from './fixtures/discovery.js';
9
+ import { Octokit } from '@octokit/rest';
8
10
  import { Client } from '@modelcontextprotocol/sdk/client/index.js';
9
11
  import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
10
12
  import { AI_DELIVERY_MCP_TOOLS } from './mcp/tools.js';
11
13
  import { createAiDeliveryMcpServer } from './mcp/index.js';
12
- import { loadDeliveryConfig } from './config/deliveryConfig.js';
14
+ import { loadDeliveryConfig, parseDeliveryConfig } from './config/deliveryConfig.js';
13
15
  import { digestValue } from './delivery/index.js';
14
16
  import { executeTool, resumedIssueUpdate, startTrackedIssue } from './dispatch.js';
15
- import { gitCommonDir } from './git.js';
17
+ import { defaultBaseRef, gitCommonDir } from './git.js';
18
+ import * as githubClient from './github/client.js';
16
19
  import { createIssue, developIssue, listIssueSubissues, readyCheck, resumeCreatedIssue, updateIssue, } from './issue.js';
17
20
  import { checkoutPr, finishIssue, listPrs, mergePr, prChecks, publishPr, submitFormalReview } from './pr.js';
18
21
  import { updateIssueWorktreeDelivery } from './services/worktreeRegistry.js';
@@ -20,6 +23,24 @@ import { assertDeliveryRuntimeAdmitted } from './services/deliveryAdmission.js';
20
23
  import { getDeliveryRecords } from './services/deliveryRecordService.js';
21
24
  import { createIssuePhaseEvidence, verifyIssue } from './verification.js';
22
25
  import { cleanupNonIssueWorktree, prepareIssueWorktree, preparePrWorktree, prepareStandaloneWorktree, } from './worktree.js';
26
+ vi.mock('./github/client.js', async (importOriginal) => {
27
+ const actual = await importOriginal();
28
+ return {
29
+ ...actual,
30
+ createDeliveryGitHubClients: async (input) => ({
31
+ ...syntheticDiscoveryClients(),
32
+ authSource: 'app',
33
+ role: input.role,
34
+ rest: new Octokit({
35
+ request: {
36
+ fetch: async () => {
37
+ throw new Error('Unexpected network request in synthetic lifecycle test.');
38
+ },
39
+ },
40
+ }),
41
+ }),
42
+ };
43
+ });
23
44
  function git(root, ...args) {
24
45
  return execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim();
25
46
  }
@@ -43,8 +64,9 @@ function directoryHash(path) {
43
64
  visit(path, '');
44
65
  return digestValue(entries);
45
66
  }
46
- function fixture(options = {}) {
67
+ async function fixture(options = {}) {
47
68
  const root = realpathSync(mkdtempSync(join(tmpdir(), 'ai-delivery-lifecycle-')));
69
+ const remoteName = options.remote ?? 'origin';
48
70
  const counter = join(root, '.git', 'stage-count.txt');
49
71
  const secondCounter = join(root, '.git', 'second-stage-count.txt');
50
72
  const failSecond = join(root, '.git', 'fail-second-stage');
@@ -53,7 +75,7 @@ function fixture(options = {}) {
53
75
  git(root, 'config', 'user.name', 'Synthetic Delivery');
54
76
  git(root, 'config', 'user.email', 'delivery@example.test');
55
77
  git(root, 'init', '--bare', '-q', join(root, '.git', 'remote.git'));
56
- git(root, 'remote', 'add', 'origin', join(root, '.git', 'remote.git'));
78
+ git(root, 'remote', 'add', remoteName, join(root, '.git', 'remote.git'));
57
79
  writeFileSync(join(root, '.gitignore'), '.issue-cli/\n.worktrees/\n');
58
80
  writeFileSync(join(root, 'artifact.txt'), 'synthetic proof\n');
59
81
  const config = {
@@ -95,9 +117,9 @@ function fixture(options = {}) {
95
117
  identity: 'synthetic-reviewer',
96
118
  },
97
119
  },
98
- schemaVersion: 'ai-delivery.config@1',
120
+ schemaVersion: 'ai-delivery.config@2',
99
121
  };
100
- writeFileSync(join(root, 'ai-delivery.config.json'), `${JSON.stringify(config)}\n`);
122
+ writeFileSync(join(root, 'ai-delivery.config.json'), `${JSON.stringify({ ...syntheticOverrides(parseDeliveryConfig(config)), ...(options.divergentOrigin ? { remote: remoteName } : {}) })}\n`);
101
123
  writeFileSync(join(root, 'policy.mjs'), `
102
124
  import { createHash } from 'node:crypto';
103
125
  import { readFileSync } from 'node:fs';
@@ -109,6 +131,7 @@ const stable = value => Array.isArray(value) ? '[' + value.map(stable).join(',')
109
131
  const digest = value => 'sha256:' + createHash('sha256').update(stable(value)).digest('hex');
110
132
  const bytes = value => 'sha256:' + createHash('sha256').update(value).digest('hex');
111
133
  const policyDigest = bytes(readFileSync(fileURLToPath(import.meta.url)));
134
+ export const deliverySettings = ${JSON.stringify({ roles: config.roles, commandPolicy: config.commandPolicy })};
112
135
  export default {
113
136
  schemaVersion: 'RepositoryDeliveryPolicy@1',
114
137
  classifyExactRange(input) {
@@ -121,7 +144,7 @@ export default {
121
144
  return { policyDigest, policyEvidence: { ...content, evidenceId: digest(content) },
122
145
  requiredStages: [{ id: 'check', dependsOn: [], semanticInputKeys: ['source'],
123
146
  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
147
+ ${JSON.stringify(options.firstStageScript ?? `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
148
  ? `, { id: 'second', dependsOn: ['check'], semanticInputKeys: ['source'],
126
149
  resourceClass: 'source_only', commands: [{ label: 'retry', argv: [process.execPath, '-e',
127
150
  ${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);`)}] }] }`
@@ -140,8 +163,16 @@ export default {
140
163
  `);
141
164
  git(root, 'add', '.');
142
165
  git(root, 'commit', '-qm', 'synthetic base');
143
- git(root, 'push', '-q', 'origin', 'main');
144
- git(root, 'fetch', '-q', 'origin', 'main');
166
+ git(root, 'push', '-q', remoteName, 'main');
167
+ git(root, 'fetch', '-q', remoteName, 'main');
168
+ git(root, 'config', `url.${join(root, '.git', 'remote.git')}.insteadOf`, 'https://github.com/example/widget.git');
169
+ git(root, 'remote', 'set-url', remoteName, 'https://github.com/example/widget.git');
170
+ if (options.divergentOrigin) {
171
+ git(root, 'remote', 'add', 'origin', 'https://github.com/other/unrelated.git');
172
+ const unrelated = git(root, 'commit-tree', 'HEAD^{tree}', '-m', 'Unrelated origin root');
173
+ git(root, 'update-ref', 'refs/remotes/origin/main', unrelated);
174
+ git(root, 'symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main');
175
+ }
145
176
  const runtimeRoot = join(root, '.git', 'synthetic-runtime');
146
177
  const runtimeEntryPath = join(runtimeRoot, 'package', 'dist', 'cli.js');
147
178
  const packagePath = join(runtimeRoot, 'package', 'package.json');
@@ -153,12 +184,12 @@ export default {
153
184
  writeFileSync(runtimeEntryPath, 'synthetic CLI bytes\n');
154
185
  writeFileSync(packagePath, JSON.stringify({ name: '@aviaratech/ai-delivery', version: '0.1.0' }));
155
186
  writeFileSync(mcpLauncherPath, 'synthetic MCP launcher bytes\n');
156
- writeFileSync(pluginManifestPath, JSON.stringify({ name: 'ai-delivery', packageVersion: '0.1.0', deliveryCapabilityVersion: 1 }));
187
+ writeFileSync(pluginManifestPath, JSON.stringify({ name: 'ai-delivery', packageVersion: '0.1.0', deliveryCapabilityVersion: 2 }));
157
188
  const content = {
158
- capability: { cli: 1, mcp: 1 },
189
+ capability: { cli: 2, mcp: 2 },
159
190
  cliPath: runtimeEntryPath,
160
191
  cliSha256: sha256(runtimeEntryPath),
161
- configDigest: loadDeliveryConfig(root).configDigest,
192
+ configDigest: (await loadDeliveryConfig(root)).configDigest,
162
193
  mcpLauncherPath,
163
194
  mcpLauncherSha256: sha256(mcpLauncherPath),
164
195
  packageVersion: '0.1.0',
@@ -167,7 +198,7 @@ export default {
167
198
  pluginManifestPath,
168
199
  pluginManifestSha256: sha256(pluginManifestPath),
169
200
  repository: 'example/widget',
170
- schemaVersion: 'ai-delivery.runtime-admission@1',
201
+ schemaVersion: 'ai-delivery.runtime-admission@2',
171
202
  sourceArchiveSha256: digestValue('synthetic archive'),
172
203
  sourceCommit: git(root, 'rev-parse', 'HEAD'),
173
204
  };
@@ -182,15 +213,15 @@ export default {
182
213
  }
183
214
  }
184
215
  test('installed CLI and MCP admission fails before a worktree mutation on source or capability drift', async () => {
185
- const { root, runtimeEntryPath } = fixture();
216
+ const { root, runtimeEntryPath } = await fixture();
186
217
  try {
187
- const input = { repoRoot: root, runtimeEntryPath };
218
+ const input = { repoRoot: root, runtimeEntryPath, identity: 'synthetic-author' };
188
219
  const path = join(root, '.git', 'ai-delivery', 'runtime-admission.json');
189
220
  const original = readFileSync(path, 'utf8');
190
- assert.equal(assertDeliveryRuntimeAdmitted(input).capability.mcp, 1);
221
+ assert.equal((await assertDeliveryRuntimeAdmitted(input)).capability.mcp, 2);
191
222
  const changedModule = join(dirname(runtimeEntryPath), 'mutated.js');
192
223
  writeFileSync(changedModule, 'unadmitted module bytes\n');
193
- assert.throws(() => assertDeliveryRuntimeAdmitted(input), /source, capability or repository admission/u);
224
+ await assert.rejects(assertDeliveryRuntimeAdmitted(input), /source, capability or repository admission/u);
194
225
  rmSync(changedModule);
195
226
  rmSync(path);
196
227
  await assert.rejects(executeTool('issue_worktree_create', { name: 'scratch-admission', branch: 'scratch/admission' }, input), /runtime admission/u);
@@ -204,14 +235,122 @@ test('installed CLI and MCP admission fails before a worktree mutation on source
204
235
  const { admissionId: _old, ...content } = admission;
205
236
  const mismatched = { ...content, mcpLauncherSha256: sha256(launcherPath), capability: { cli: 1, mcp: 2 } };
206
237
  writeFileSync(path, JSON.stringify({ ...mismatched, admissionId: digestValue(mismatched) }));
207
- assert.throws(() => assertDeliveryRuntimeAdmitted(input), /source, capability or repository admission/u);
238
+ await assert.rejects(assertDeliveryRuntimeAdmitted(input), /source, capability or repository admission/u);
239
+ }
240
+ finally {
241
+ rmSync(root, { recursive: true, force: true });
242
+ }
243
+ });
244
+ test('effective policy settings drift invalidates admission even when the policy wrapper is unchanged', async () => {
245
+ const { root, runtimeEntryPath } = await fixture();
246
+ const policyPath = join(root, 'policy.mjs');
247
+ const authorEnv = 'AI_DELIVERY_TEST_DYNAMIC_AUTHOR';
248
+ const checkEnv = 'AI_DELIVERY_TEST_DYNAMIC_CHECK';
249
+ const previousAuthor = process.env[authorEnv];
250
+ const previousCheck = process.env[checkEnv];
251
+ try {
252
+ delete process.env[authorEnv];
253
+ delete process.env[checkEnv];
254
+ writeFileSync(policyPath, readFileSync(policyPath, 'utf8') +
255
+ `
256
+ Object.defineProperty(deliverySettings.roles.author, 'identity', { get: () => process.env.${authorEnv} ?? 'synthetic-author' });
257
+ Object.defineProperty(deliverySettings.commandPolicy.checks, 'test', { get: () => process.env.${checkEnv} ?? 'REQUIRED' });
258
+ `);
259
+ const admissionPath = join(root, '.git', 'ai-delivery', 'runtime-admission.json');
260
+ const { admissionId: _id, ...content } = JSON.parse(readFileSync(admissionPath, 'utf8'));
261
+ const baseline = await loadDeliveryConfig(root);
262
+ const admitted = { ...content, configDigest: baseline.configDigest };
263
+ writeFileSync(admissionPath, JSON.stringify({ ...admitted, admissionId: digestValue(admitted) }));
264
+ await assertDeliveryRuntimeAdmitted({ repoRoot: root, runtimeEntryPath });
265
+ const originalBytes = readFileSync(policyPath, 'utf8');
266
+ for (const [key, value] of [
267
+ [authorEnv, 'changed-author'],
268
+ [checkEnv, 'SKIP'],
269
+ ]) {
270
+ process.env[key] = value;
271
+ assert.notEqual((await loadDeliveryConfig(root)).configDigest, baseline.configDigest);
272
+ await assert.rejects(assertDeliveryRuntimeAdmitted({ repoRoot: root, runtimeEntryPath }), /source, capability or repository admission/u);
273
+ delete process.env[key];
274
+ }
275
+ assert.equal(readFileSync(policyPath, 'utf8'), originalBytes);
276
+ }
277
+ finally {
278
+ if (previousAuthor === undefined)
279
+ delete process.env[authorEnv];
280
+ else
281
+ process.env[authorEnv] = previousAuthor;
282
+ if (previousCheck === undefined)
283
+ delete process.env[checkEnv];
284
+ else
285
+ process.env[checkEnv] = previousCheck;
286
+ rmSync(root, { recursive: true, force: true });
287
+ }
288
+ });
289
+ test('a changed discovered Project identity invalidates the installed admission', async () => {
290
+ const { root, runtimeEntryPath } = await fixture();
291
+ const base = syntheticDiscoveryClients();
292
+ const graphql = async (query, variables) => {
293
+ const result = await base.graphql(query, variables);
294
+ if (query.includes('query ProjectDeliveryConfiguration')) {
295
+ result.organization.projectV2.id = 'REPLACED-PROJECT';
296
+ }
297
+ return result;
298
+ };
299
+ const mocked = vi.spyOn(githubClient, 'createDeliveryGitHubClients').mockResolvedValueOnce({
300
+ authSource: 'app',
301
+ role: 'author',
302
+ rest: new Octokit(),
303
+ graphql: graphql,
304
+ });
305
+ try {
306
+ await assert.rejects(assertDeliveryRuntimeAdmitted({ repoRoot: root, runtimeEntryPath }), /source, capability or repository admission/u);
307
+ assert.equal(existsSync(join(root, '.worktrees')), false);
308
+ }
309
+ finally {
310
+ mocked.mockRestore();
311
+ rmSync(root, { recursive: true, force: true });
312
+ }
313
+ });
314
+ test('CLI dispatch and MCP reject ambiguous discovery with the same error before mutation', async () => {
315
+ const { root, runtimeEntryPath } = await fixture();
316
+ const path = join(root, 'ai-delivery.config.json');
317
+ const overrides = JSON.parse(readFileSync(path, 'utf8'));
318
+ delete overrides.project;
319
+ writeFileSync(path, JSON.stringify(overrides));
320
+ const base = syntheticDiscoveryClients();
321
+ const graphql = async (query, variables) => {
322
+ if (query.includes('projectsV2(first:'))
323
+ return { repository: { projectsV2: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } } };
324
+ return base.graphql(query, variables);
325
+ };
326
+ const mocked = vi.spyOn(githubClient, 'createDeliveryGitHubClients').mockResolvedValue({
327
+ authSource: 'app',
328
+ role: 'author',
329
+ rest: new Octokit(),
330
+ graphql: graphql,
331
+ });
332
+ const execution = { repoRoot: root, runtimeEntryPath, identity: 'synthetic-author' };
333
+ const server = createAiDeliveryMcpServer(execution);
334
+ const client = new Client({ name: 'synthetic-discovery-client', version: '1.0.0' });
335
+ try {
336
+ await assert.rejects(executeTool('issue_create', { title: 'Ambiguous destination' }, execution), /Discovered 0 compatible linked Projects/u);
337
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
338
+ await server.connect(serverTransport);
339
+ await client.connect(clientTransport);
340
+ const result = await client.callTool({ name: 'issue_create', arguments: { title: 'Ambiguous destination' } });
341
+ assert.equal(result.isError, true);
342
+ assert.match(JSON.stringify(result.content), /Discovered 0 compatible linked Projects/u);
343
+ assert.equal(existsSync(join(root, '.worktrees')), false);
208
344
  }
209
345
  finally {
346
+ await client.close();
347
+ await server.close();
348
+ mocked.mockRestore();
210
349
  rmSync(root, { recursive: true, force: true });
211
350
  }
212
351
  });
213
352
  test('an old active issue remains pinned while a new row uses the same canonical registry', async () => {
214
- const { root } = fixture();
353
+ const { root } = await fixture();
215
354
  try {
216
355
  const oldPath = join(root, '.worktrees', 'issue-17');
217
356
  git(root, 'worktree', 'add', '-b', 'issue/17', oldPath, 'main');
@@ -244,7 +383,7 @@ test('an old active issue remains pinned while a new row uses the same canonical
244
383
  const relationshipContext = {
245
384
  root,
246
385
  repo: { owner: 'example', repo: 'widget' },
247
- config: loadDeliveryConfig(root).config,
386
+ config: (await loadDeliveryConfig(root)).config,
248
387
  clients: {
249
388
  rest: {
250
389
  issues: {
@@ -289,7 +428,7 @@ test('an old active issue remains pinned while a new row uses the same canonical
289
428
  }
290
429
  });
291
430
  test('synthetic cutover restores exact pre-write state and resumes an incomplete verification stage', async () => {
292
- const { root, counter, secondCounter, failSecond } = fixture({ twoStages: true });
431
+ const { root, counter, secondCounter, failSecond } = await fixture({ twoStages: true });
293
432
  const backup = mkdtempSync(join(tmpdir(), 'ai-delivery-cutover-backup-'));
294
433
  try {
295
434
  const row = await prepareIssueWorktree({
@@ -313,19 +452,19 @@ test('synthetic cutover restores exact pre-write state and resumes an incomplete
313
452
  const evidenceBefore = directoryHash(evidencePath);
314
453
  const pointerBefore = readlinkSync(current);
315
454
  const currentCli = join(current, 'package', 'dist', 'cli.js');
316
- assert.equal(assertDeliveryRuntimeAdmitted({ repoRoot: root, runtimeEntryPath: currentCli }).capability.cli, 1);
455
+ assert.equal((await assertDeliveryRuntimeAdmitted({ repoRoot: root, runtimeEntryPath: currentCli })).capability.cli, 2);
317
456
  cpSync(oldRelease, newRelease, { recursive: true });
318
457
  writeFileSync(join(newRelease, 'package', 'dist', 'cli.js'), 'unadmitted CLI bytes\n');
319
458
  unlinkSync(current);
320
459
  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);
460
+ await assert.rejects(executeTool('issue_worktree_create', { branch: 'scratch/cutover', name: 'scratch-cutover' }, { repoRoot: root, runtimeEntryPath: currentCli, identity: 'synthetic-author' }), /source, capability or repository admission/u);
322
461
  assert.equal(existsSync(join(root, '.worktrees', 'scratch-cutover')), false);
323
462
  assert.equal(directoryHash(registryPath), registryBefore);
324
463
  assert.equal(directoryHash(evidencePath), evidenceBefore);
325
464
  unlinkSync(current);
326
465
  symlinkSync(pointerBefore, current);
327
466
  assert.equal(readlinkSync(current), pointerBefore);
328
- assertDeliveryRuntimeAdmitted({ repoRoot: root, runtimeEntryPath: currentCli });
467
+ await assertDeliveryRuntimeAdmitted({ repoRoot: root, runtimeEntryPath: currentCli });
329
468
  const uninterrupted = await verifyIssue({ issueNumber: 17, repoRoot: row.path });
330
469
  assert.equal(readFileSync(counter, 'utf8'), '1');
331
470
  assert.equal(readFileSync(secondCounter, 'utf8'), '1');
@@ -386,7 +525,7 @@ test('synthetic cutover restores exact pre-write state and resumes an incomplete
386
525
  }
387
526
  });
388
527
  test('issue worktree verification resumes a completed stage and rejects changed inputs', async () => {
389
- const { root, counter, runtimeEntryPath } = fixture();
528
+ const { root, counter, runtimeEntryPath } = await fixture();
390
529
  try {
391
530
  await assert.rejects(executeTool('issue_create', { title: 'Wrong repository', repo: 'other/widget' }, { repoRoot: root }), /Repository selector must match/u);
392
531
  const row = await prepareIssueWorktree({
@@ -468,8 +607,242 @@ test('issue worktree verification resumes a completed stage and rejects changed
468
607
  rmSync(root, { recursive: true, force: true });
469
608
  }
470
609
  });
610
+ test('a progressing stage remains observable beyond a former short deadline', async () => {
611
+ const marker = join(tmpdir(), `ai-delivery-stage-start-${process.pid}-${Date.now()}`);
612
+ const { root } = await fixture({
613
+ firstStageScript: `const fs=require('fs');fs.writeFileSync(${JSON.stringify(marker)},'started');process.stdout.write('begin');setTimeout(()=>{process.stdout.write('end');},180);`,
614
+ });
615
+ try {
616
+ const row = await prepareIssueWorktree({
617
+ baseRef: 'main',
618
+ identity: 'synthetic-author',
619
+ issueNumber: 17,
620
+ repoRoot: root,
621
+ });
622
+ writeFileSync(join(row.path, 'change.txt'), 'feature\n');
623
+ git(row.path, 'add', 'change.txt');
624
+ git(row.path, 'commit', '-qm', 'synthetic progressing stage');
625
+ const progressSpy = vi.spyOn(process.stderr, 'write');
626
+ let completed = false;
627
+ const run = verifyIssue({ issueNumber: 17, repoRoot: row.path }).then((value) => {
628
+ completed = true;
629
+ return value;
630
+ });
631
+ const markerDeadline = Date.now() + 2_000;
632
+ while (!existsSync(marker) && Date.now() < markerDeadline)
633
+ await new Promise((resolve) => setTimeout(resolve, 10));
634
+ assert.equal(existsSync(marker), true);
635
+ assert.equal(completed, false, 'stage must remain observable while its process runs');
636
+ assert.ok(progressSpy.mock.calls.some(([chunk]) => String(chunk).includes('"state":"running"') && String(chunk).includes('"stageId":"check"')));
637
+ await new Promise((resolve) => setTimeout(resolve, 40));
638
+ assert.equal(completed, false, 'useful work continues beyond the injected former deadline');
639
+ const result = await run;
640
+ assert.equal(result.stageReceipts.length, 1);
641
+ }
642
+ finally {
643
+ vi.restoreAllMocks();
644
+ rmSync(marker, { force: true });
645
+ rmSync(root, { recursive: true, force: true });
646
+ }
647
+ });
648
+ test('cancelling verification stops its owned descendant and preserves an unrelated process', async () => {
649
+ const marker = join(tmpdir(), `ai-delivery-stage-child-${process.pid}-${Date.now()}`);
650
+ const heartbeat = `${marker}-heartbeat`;
651
+ const unrelatedHeartbeat = `${marker}-unrelated`;
652
+ const { root } = await fixture({
653
+ firstStageScript: `const fs=require('fs');const {spawn}=require('child_process');const child=spawn(process.execPath,['-e',${JSON.stringify(`const fs=require('fs');setInterval(()=>fs.appendFileSync(${JSON.stringify(heartbeat)},'x'),20);`)}],{stdio:'ignore'});child.unref();fs.writeFileSync(${JSON.stringify(marker)},String(child.pid));setTimeout(()=>{},400);`,
654
+ });
655
+ let descendantPid;
656
+ const unrelated = spawn(process.execPath, ['-e', `const fs=require('fs');setInterval(()=>fs.appendFileSync(${JSON.stringify(unrelatedHeartbeat)},'x'),20);`], {
657
+ detached: true,
658
+ stdio: 'ignore',
659
+ });
660
+ unrelated.unref();
661
+ try {
662
+ const row = await prepareIssueWorktree({
663
+ baseRef: 'main',
664
+ identity: 'synthetic-author',
665
+ issueNumber: 17,
666
+ repoRoot: root,
667
+ });
668
+ writeFileSync(join(row.path, 'change.txt'), 'feature\n');
669
+ git(row.path, 'add', 'change.txt');
670
+ git(row.path, 'commit', '-qm', 'synthetic cancellable stage');
671
+ const controller = new AbortController();
672
+ const run = verifyIssue({
673
+ issueNumber: 17,
674
+ repoRoot: row.path,
675
+ signal: controller.signal,
676
+ });
677
+ const markerDeadline = Date.now() + 2_000;
678
+ while (!existsSync(marker) && Date.now() < markerDeadline)
679
+ await new Promise((resolve) => setTimeout(resolve, 10));
680
+ assert.equal(existsSync(marker), true);
681
+ descendantPid = Number(readFileSync(marker, 'utf8'));
682
+ controller.abort();
683
+ await assert.rejects(run, /cancelled/u);
684
+ assert.equal(existsSync(join(root, '.git', 'ai-delivery', 'verification@1', 'stages', 'check')), false);
685
+ await new Promise((resolve) => setTimeout(resolve, 60));
686
+ const stoppedSize = existsSync(heartbeat) ? readFileSync(heartbeat).length : 0;
687
+ const unrelatedSize = existsSync(unrelatedHeartbeat) ? readFileSync(unrelatedHeartbeat).length : 0;
688
+ await new Promise((resolve) => setTimeout(resolve, 80));
689
+ assert.equal(existsSync(heartbeat) ? readFileSync(heartbeat).length : 0, stoppedSize);
690
+ assert.ok(readFileSync(unrelatedHeartbeat).length > unrelatedSize);
691
+ }
692
+ finally {
693
+ if (descendantPid !== undefined) {
694
+ try {
695
+ process.kill(descendantPid, 'SIGKILL');
696
+ }
697
+ catch {
698
+ /* already stopped */
699
+ }
700
+ }
701
+ if (unrelated.pid !== undefined) {
702
+ try {
703
+ process.kill(unrelated.pid, 'SIGKILL');
704
+ }
705
+ catch {
706
+ /* already stopped */
707
+ }
708
+ }
709
+ rmSync(marker, { force: true });
710
+ rmSync(heartbeat, { force: true });
711
+ rmSync(unrelatedHeartbeat, { force: true });
712
+ rmSync(root, { recursive: true, force: true });
713
+ }
714
+ });
715
+ test.each([
716
+ { name: 'nonzero exit', terminate: 'process.exit(7)', expected: /exit 7/u },
717
+ { name: 'signal', terminate: "process.kill(process.pid,'SIGTERM')", expected: /signal SIGTERM/u },
718
+ ])('a stage $name stops descendants holding its output pipes open', async ({ terminate, expected }) => {
719
+ const marker = join(tmpdir(), `ai-delivery-failed-stage-${process.pid}-${Date.now()}`);
720
+ const heartbeat = `${marker}-heartbeat`;
721
+ const { root } = await fixture({
722
+ firstStageScript: `const fs=require('fs');const {spawn}=require('child_process');const child=spawn(process.execPath,['-e',${JSON.stringify(`const fs=require('fs');setInterval(()=>fs.appendFileSync(${JSON.stringify(heartbeat)},'x'),20);`)}],{stdio:['ignore','inherit','inherit']});child.unref();fs.writeFileSync(${JSON.stringify(marker)},String(child.pid));${terminate};`,
723
+ });
724
+ let descendantPid;
725
+ let timeout;
726
+ try {
727
+ const row = await prepareIssueWorktree({
728
+ baseRef: 'main',
729
+ identity: 'synthetic-author',
730
+ issueNumber: 17,
731
+ repoRoot: root,
732
+ });
733
+ writeFileSync(join(row.path, 'change.txt'), 'feature\n');
734
+ git(row.path, 'add', 'change.txt');
735
+ git(row.path, 'commit', '-qm', 'synthetic failing stage');
736
+ const controller = new AbortController();
737
+ const run = verifyIssue({ issueNumber: 17, repoRoot: row.path, signal: controller.signal });
738
+ const rejection = assert.rejects(run, expected);
739
+ const markerDeadline = Date.now() + 2_000;
740
+ while (!existsSync(marker) && Date.now() < markerDeadline)
741
+ await new Promise((resolve) => setTimeout(resolve, 10));
742
+ assert.equal(existsSync(marker), true);
743
+ descendantPid = Number(readFileSync(marker, 'utf8'));
744
+ timeout = setTimeout(() => controller.abort(), 500);
745
+ await rejection;
746
+ assert.equal(existsSync(join(root, '.git', 'ai-delivery', 'verification@1', 'stages', 'check')), false);
747
+ await new Promise((resolve) => setTimeout(resolve, 60));
748
+ const stoppedSize = existsSync(heartbeat) ? readFileSync(heartbeat).length : 0;
749
+ await new Promise((resolve) => setTimeout(resolve, 80));
750
+ assert.equal(existsSync(heartbeat) ? readFileSync(heartbeat).length : 0, stoppedSize);
751
+ }
752
+ finally {
753
+ if (timeout !== undefined)
754
+ clearTimeout(timeout);
755
+ if (descendantPid !== undefined) {
756
+ try {
757
+ process.kill(descendantPid, 'SIGKILL');
758
+ }
759
+ catch {
760
+ /* already stopped */
761
+ }
762
+ }
763
+ rmSync(marker, { force: true });
764
+ rmSync(heartbeat, { force: true });
765
+ rmSync(root, { recursive: true, force: true });
766
+ }
767
+ });
768
+ test('MCP request cancellation stops verification and leaves no stage receipt', async () => {
769
+ const marker = join(tmpdir(), `ai-delivery-mcp-cancel-${process.pid}-${Date.now()}`);
770
+ const heartbeat = `${marker}-heartbeat`;
771
+ const { root, runtimeEntryPath } = await fixture({
772
+ firstStageScript: `const fs=require('fs');const {spawn}=require('child_process');const child=spawn(process.execPath,['-e',${JSON.stringify(`const fs=require('fs');setInterval(()=>fs.appendFileSync(${JSON.stringify(heartbeat)},'x'),20);`)}],{stdio:'ignore'});child.unref();fs.writeFileSync(${JSON.stringify(marker)},String(child.pid));setTimeout(()=>{},1000);`,
773
+ });
774
+ const server = createAiDeliveryMcpServer({ repoRoot: root, runtimeEntryPath, identity: 'synthetic-author' });
775
+ const client = new Client({ name: 'synthetic-cancelling-client', version: '1.0.0' });
776
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
777
+ let descendantPid;
778
+ try {
779
+ const row = await prepareIssueWorktree({
780
+ baseRef: 'main',
781
+ identity: 'synthetic-author',
782
+ issueNumber: 17,
783
+ repoRoot: root,
784
+ });
785
+ writeFileSync(join(row.path, 'change.txt'), 'feature\n');
786
+ git(row.path, 'add', 'change.txt');
787
+ git(row.path, 'commit', '-qm', 'synthetic MCP cancellable stage');
788
+ await server.connect(serverTransport);
789
+ await client.connect(clientTransport);
790
+ const controller = new AbortController();
791
+ const call = client.callTool({ name: 'issue_verify', arguments: { issueNumber: 17 } }, undefined, {
792
+ signal: controller.signal,
793
+ });
794
+ const markerDeadline = Date.now() + 2_000;
795
+ while (!existsSync(marker) && Date.now() < markerDeadline)
796
+ await new Promise((resolve) => setTimeout(resolve, 10));
797
+ assert.equal(existsSync(marker), true);
798
+ descendantPid = Number(readFileSync(marker, 'utf8'));
799
+ controller.abort();
800
+ await assert.rejects(call, /abort/iu);
801
+ await new Promise((resolve) => setTimeout(resolve, 100));
802
+ const stoppedSize = existsSync(heartbeat) ? readFileSync(heartbeat).length : 0;
803
+ await new Promise((resolve) => setTimeout(resolve, 100));
804
+ assert.equal(existsSync(heartbeat) ? readFileSync(heartbeat).length : 0, stoppedSize);
805
+ assert.equal(existsSync(join(root, '.git', 'ai-delivery', 'verification@1', 'stages', 'check')), false);
806
+ }
807
+ finally {
808
+ await client.close();
809
+ await server.close();
810
+ if (descendantPid !== undefined) {
811
+ try {
812
+ process.kill(descendantPid, 'SIGKILL');
813
+ }
814
+ catch {
815
+ /* already stopped */
816
+ }
817
+ }
818
+ rmSync(marker, { force: true });
819
+ rmSync(heartbeat, { force: true });
820
+ rmSync(root, { recursive: true, force: true });
821
+ }
822
+ });
823
+ test('the stage output bound covers both streams and publishes no checkpoint on overflow', async () => {
824
+ const { root } = await fixture({
825
+ firstStageScript: `process.stdout.write(Buffer.alloc(4*1024*1024));process.stderr.write(Buffer.alloc(4*1024*1024+1));`,
826
+ });
827
+ try {
828
+ const row = await prepareIssueWorktree({
829
+ baseRef: 'main',
830
+ identity: 'synthetic-author',
831
+ issueNumber: 17,
832
+ repoRoot: root,
833
+ });
834
+ writeFileSync(join(row.path, 'change.txt'), 'feature\n');
835
+ git(row.path, 'add', 'change.txt');
836
+ git(row.path, 'commit', '-qm', 'synthetic output bound');
837
+ await assert.rejects(verifyIssue({ issueNumber: 17, repoRoot: row.path }), /captured output exceeded 8 MiB/u);
838
+ assert.equal(existsSync(join(root, '.git', 'ai-delivery', 'verification@1', 'stages', 'check')), false);
839
+ }
840
+ finally {
841
+ rmSync(root, { recursive: true, force: true });
842
+ }
843
+ });
471
844
  test('scratch start derives safe names and reports the requested worktree', async () => {
472
- const { root, runtimeEntryPath } = fixture();
845
+ const { root, runtimeEntryPath } = await fixture();
473
846
  try {
474
847
  const first = (await executeTool('issue_start', { scratch: true, request: 'Investigate button layout' }, { repoRoot: root, runtimeEntryPath, identity: 'synthetic-author' }));
475
848
  assert.equal(first.mode, 'scratch');
@@ -543,10 +916,17 @@ test('MCP request rejects unsupported legacy options before lifecycle dispatch',
543
916
  await server.close();
544
917
  }
545
918
  });
546
- test('synthetic issue traverses native intake, readiness, receipt-bound App review, exact merge and finish', async () => {
547
- const { root } = fixture();
919
+ test.each([
920
+ { remote: 'origin', divergentOrigin: false },
921
+ { remote: 'upstream', divergentOrigin: false },
922
+ { remote: 'upstream', divergentOrigin: true },
923
+ ])('synthetic lifecycle finishes with remote $remote and divergent origin $divergentOrigin', async (routing) => {
924
+ const { root } = await fixture(routing);
925
+ const remoteName = routing.remote;
926
+ assert.equal(defaultBaseRef(root, remoteName), `refs/remotes/${remoteName}/main`);
548
927
  const remote = join(root, '.git', 'remote.git');
549
- const config = loadDeliveryConfig(root).config;
928
+ const configuration = await loadDeliveryConfig(root);
929
+ const config = configuration.config;
550
930
  const baseSha = git(root, 'rev-parse', 'main');
551
931
  const issueNumber = 17;
552
932
  const prNumber = 23;
@@ -639,6 +1019,7 @@ test('synthetic issue traverses native intake, readiness, receipt-bound App revi
639
1019
  options: [],
640
1020
  issueField: {
641
1021
  __typename: 'IssueFieldSingleSelect',
1022
+ id: 'ISSUE-FIELD-101',
642
1023
  fullDatabaseId: '101',
643
1024
  name: 'Estimate',
644
1025
  options: ['1', '2', '4'].map((name) => ({ id: name, name })),
@@ -652,6 +1033,7 @@ test('synthetic issue traverses native intake, readiness, receipt-bound App revi
652
1033
  options: [],
653
1034
  issueField: {
654
1035
  __typename: 'IssueFieldSingleSelect',
1036
+ id: 'ISSUE-FIELD-102',
655
1037
  fullDatabaseId: '102',
656
1038
  name: 'Urgency',
657
1039
  options: ['High', 'Low'].map((name) => ({ id: name, name })),
@@ -853,7 +1235,7 @@ test('synthetic issue traverses native intake, readiness, receipt-bound App revi
853
1235
  createCommit: async (input) => {
854
1236
  calls.push('git:createCommit');
855
1237
  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`);
1238
+ git(root, 'push', '-q', remoteName, `${mergeSha}:refs/heads/merge-object`);
857
1239
  return { data: { sha: mergeSha } };
858
1240
  },
859
1241
  getCommit: async () => ({
@@ -881,6 +1263,7 @@ test('synthetic issue traverses native intake, readiness, receipt-bound App revi
881
1263
  root,
882
1264
  repo: { owner: 'example', repo: 'widget' },
883
1265
  config,
1266
+ configuration,
884
1267
  clients: {
885
1268
  authSource: 'app',
886
1269
  role: 'author',
@@ -967,7 +1350,7 @@ test('synthetic issue traverses native intake, readiness, receipt-bound App revi
967
1350
  await assert.rejects(publishPr(context, { issueNumber }), /Git push requires the selected author GitHub App installation token/);
968
1351
  // The public push requires a real author App installation token. Bind a synthetic
969
1352
  // publication at that external boundary so the remaining public phases run offline.
970
- git(row.path, 'push', '-q', 'origin', `${headSha}:refs/heads/issue/17`);
1353
+ git(row.path, 'push', '-q', remoteName, `${headSha}:refs/heads/issue/17`);
971
1354
  const publishEvidence = await createIssuePhaseEvidence({ issueNumber, phase: 'publish', repoRoot: row.path });
972
1355
  const publicationContent = {
973
1356
  baseSha,