@fitlab-ai/agent-infra 0.11.5 → 0.11.6

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 (42) hide show
  1. package/dist/lib/merge.js +62 -10
  2. package/dist/lib/platform/github-provider.js +9 -14
  3. package/dist/lib/platform/github-release-notes.js +80 -11
  4. package/dist/lib/platform/none-provider.js +1 -1
  5. package/dist/lib/platform/provider-contract.d.ts +39 -16
  6. package/dist/lib/platform/provider-contract.js +1 -1
  7. package/dist/lib/platform/provider-validation.js +83 -13
  8. package/dist/lib/platform/release-notes.js +7 -8
  9. package/dist/lib/sandbox/capture.js +1 -1
  10. package/dist/lib/sandbox/commands/create.js +46 -1
  11. package/dist/lib/sandbox/commands/enter.js +1 -1
  12. package/dist/lib/sandbox/commands/start.js +1 -1
  13. package/dist/lib/sandbox/config.js +10 -0
  14. package/dist/lib/sandbox/control/lifecycle.js +18 -13
  15. package/dist/lib/sandbox/task-resolver.js +62 -2
  16. package/dist/lib/task/resolve-ref.js +3 -3
  17. package/dist/lib/task/write.js +121 -1
  18. package/dist/lifecycle-build-manifest.json +3 -3
  19. package/lib/merge.ts +59 -10
  20. package/lib/platform/github-provider.ts +11 -18
  21. package/lib/platform/github-release-notes.ts +89 -14
  22. package/lib/platform/none-provider.ts +1 -1
  23. package/lib/platform/provider-contract.ts +38 -12
  24. package/lib/platform/provider-validation.ts +83 -12
  25. package/lib/platform/release-notes.ts +11 -12
  26. package/lib/sandbox/capture.ts +1 -1
  27. package/lib/sandbox/commands/create.ts +52 -1
  28. package/lib/sandbox/commands/enter.ts +1 -1
  29. package/lib/sandbox/commands/start.ts +1 -1
  30. package/lib/sandbox/config.ts +12 -0
  31. package/lib/sandbox/control/lifecycle.ts +22 -13
  32. package/lib/sandbox/task-resolver.ts +51 -2
  33. package/lib/task/resolve-ref.ts +3 -3
  34. package/lib/task/write.ts +115 -1
  35. package/package.json +1 -1
  36. package/templates/.agents/rules/evidence-reporting.en.md +1 -2
  37. package/templates/.agents/rules/evidence-reporting.zh-CN.md +1 -2
  38. package/templates/.agents/skills/archive-tasks/SKILL.en.md +2 -2
  39. package/templates/.agents/skills/archive-tasks/SKILL.zh-CN.md +2 -2
  40. package/templates/.agents/skills/archive-tasks/scripts/archive-tasks.sh +109 -6
  41. package/templates/.agents/skills/create-release-note/SKILL.en.md +17 -11
  42. package/templates/.agents/skills/create-release-note/SKILL.zh-CN.md +17 -11
@@ -923,30 +923,23 @@ function createGitHubOperations(client: GitHubClient): Pick<PlatformProvider, 'i
923
923
  if (collected.status === 'failed' || collected.status === 'blocked' || !('pullRequests' in collected)) {
924
924
  return { ok: false, error: collected.error || { code: 'RELEASE_NOTES_COLLECTION_FAILED', message: 'Release notes could not be collected', retryable: false } };
925
925
  }
926
- const mergedPullRequests = collected.pullRequests.map((item: any): ChangeRequestSnapshot => ({
927
- id: String(item.number), identity: { kind: 'number', value: Number(item.number) }, number: Number(item.number),
928
- state: 'closed', title: String(item.title || ''), body: String(item.body || ''),
926
+ const mergedPullRequests = collected.pullRequests.map((item: any) => ({
927
+ id: String(item.number), identity: { kind: 'number' as const, value: Number(item.number) }, number: Number(item.number),
928
+ title: String(item.title || ''), body: String(item.body || ''),
929
929
  mergedAt: new Date(String(item.mergedAt)).toISOString(), displayUrl: String(item.url || ''),
930
930
  labels: Array.isArray(item.labels) ? item.labels.map((label: any) => String(label.name || label)) : [],
931
- assignees: [], mergeCommitSha: null
932
- }));
933
- const closingIssues: IssueSnapshot[] = collected.pullRequests.flatMap((item: any) =>
934
- Array.isArray(item.closingIssuesReferences) ? item.closingIssuesReferences.map((issue: any) => ({
931
+ author: item.author,
932
+ closingIssues: item.closingIssues.map((issue: any) => ({
935
933
  id: String(issue.number), identity: { kind: 'number', value: Number(issue.number) }, number: Number(issue.number),
936
- state: 'closed' as const, title: String(issue.title || ''), body: '', labels: Array.isArray(issue.labels) ? issue.labels.map((label: any) => String(label.name || label)) : [],
937
- assignees: [], milestone: null, fields: {}, displayUrl: String(issue.url || '')
938
- })) : []
939
- );
940
- const actors = [...collected.authors.values()].flatMap((items) => items.map((item: any) => ({
941
- ...(item.login ? { id: String(item.login) } : {}), ...(item.name ? { name: String(item.name) } : {})
942
- })));
934
+ title: String(issue.title || ''), displayUrl: String(issue.url || ''), author: issue.author
935
+ }))
936
+ }));
943
937
  return {
944
938
  ok: true,
945
939
  value: {
946
- history: commitOids.map((sha) => ({ sha, message: '', authoredAt: toTime, author: actors.find((actor) => actor.id) || null })),
940
+ history: collected.history,
941
+ commits: collected.commits,
947
942
  mergedPullRequests,
948
- closingIssues,
949
- actors
950
943
  }
951
944
  };
952
945
  }
@@ -1116,7 +1109,7 @@ function createGitHubProvider(
1116
1109
  ): PlatformProvider {
1117
1110
  return {
1118
1111
  type: input.providerType,
1119
- contractVersion: 1,
1112
+ contractVersion: 2,
1120
1113
  identity: { issue: 'number', 'pull-request': 'number', comment: 'number', release: 'key' },
1121
1114
  ...createGitHubOperations(client),
1122
1115
  context: {
@@ -9,7 +9,6 @@ type GitHubActorInput = {
9
9
 
10
10
  type ReleaseNoteActor = {
11
11
  name: string;
12
- email: string | null;
13
12
  login: string | null;
14
13
  bot: boolean;
15
14
  resolution: 'platform-user' | 'platform-noreply' | 'unresolved';
@@ -27,13 +26,23 @@ function normalizeGitHubActor(actor: GitHubActorInput): ReleaseNoteActor {
27
26
  const login = platformLogin || noReplyLogin;
28
27
  return {
29
28
  name,
30
- email,
31
29
  login,
32
30
  bot: Boolean(login?.endsWith('[bot]')),
33
31
  resolution: platformLogin ? 'platform-user' : noReplyLogin ? 'platform-noreply' : 'unresolved'
34
32
  };
35
33
  }
36
34
 
35
+ function normalizePlatformAuthor(author: { login?: string | null } | null | undefined): ReleaseNoteActor | null {
36
+ if (!author) return null;
37
+ const login = author.login?.trim().toLowerCase() || null;
38
+ return {
39
+ name: login || '',
40
+ login,
41
+ bot: Boolean(login?.endsWith('[bot]')),
42
+ resolution: login ? 'platform-user' : 'unresolved'
43
+ };
44
+ }
45
+
37
46
  function failure(error: { code: string; message: string; retryable: boolean }) {
38
47
  return {
39
48
  status: error.retryable ? 'blocked' as const : 'failed' as const,
@@ -89,7 +98,7 @@ function fetchGitHubReleaseNoteData(
89
98
  { cwd: options.cwd }
90
99
  );
91
100
  if (!releases.ok) return failure(releases.error);
92
- const history = [];
101
+ const history: Array<{ tag: string; body: string; url: string | null }> = [];
93
102
  for (const item of releases.value.filter((entry) => !entry.isDraft && !entry.isPrerelease).slice(0, input.historyLimit)) {
94
103
  const viewed = client.json<{ body?: string; url?: string }>(
95
104
  ['release', 'view', String(item.tagName), '--repo', input.repository, '--json', 'body,url'],
@@ -99,33 +108,99 @@ function fetchGitHubReleaseNoteData(
99
108
  history.push({ tag: String(item.tagName), body: String(viewed.value.body || ''), url: viewed.value.url || null });
100
109
  }
101
110
  const prs = client.json<Array<Record<string, unknown>>>(
102
- ['pr', 'list', '--repo', input.repository, '--state', 'merged', '--base', input.branch, '--limit', '1000', '--json', 'number,title,body,url,mergedAt,labels,author,closingIssuesReferences'],
111
+ ['pr', 'list', '--repo', input.repository, '--state', 'merged', '--base', input.branch, '--limit', '1000', '--json', 'number,title,body,url,mergedAt,labels,author'],
103
112
  { cwd: options.cwd }
104
113
  );
105
114
  if (!prs.ok) return failure(prs.error);
106
115
  const authors = new Map<string, ReleaseNoteActor[]>();
116
+ const fromTime = Date.parse(input.fromTime);
117
+ const toTime = Date.parse(input.toTime);
118
+ const pullRequests = prs.value.filter((item) => {
119
+ const mergedAt = Date.parse(String(item.mergedAt || ''));
120
+ return Number.isFinite(mergedAt) && mergedAt > fromTime && mergedAt <= toTime;
121
+ });
122
+ const eligiblePullRequestNumbers = new Set(pullRequests.map((item) => Number(item.number)));
123
+ const pullRequestNumbersByCommit = new Map<string, number[]>();
107
124
  for (const oid of input.commitOids) {
108
- const query = `query($owner:String!,$name:String!,$oid:GitObjectID!){repository(owner:$owner,name:$name){object(oid:$oid){... on Commit{authors(first:100){nodes{name email user{login}} pageInfo{hasNextPage}}}}}}`;
125
+ const query = `query($owner:String!,$name:String!,$oid:GitObjectID!){repository(owner:$owner,name:$name){object(oid:$oid){... on Commit{authors(first:100){nodes{name email user{login}} pageInfo{hasNextPage}} associatedPullRequests(first:100){nodes{number} pageInfo{hasNextPage}}}}}}`;
109
126
  const [owner, name] = input.repository.split('/');
110
127
  const result = client.json<{
111
- data?: { repository?: { object?: { authors?: { nodes?: GitHubActorInput[]; pageInfo?: { hasNextPage?: boolean } } } } };
128
+ data?: { repository?: { object?: {
129
+ authors?: { nodes?: GitHubActorInput[]; pageInfo?: { hasNextPage?: boolean } };
130
+ associatedPullRequests?: {
131
+ nodes?: Array<{ number?: number | null }>;
132
+ pageInfo?: { hasNextPage?: boolean };
133
+ };
134
+ } } };
112
135
  }>([
113
136
  'api', 'graphql', '-f', `query=${query}`, '-F', `owner=${owner}`, '-F', `name=${name}`, '-F', `oid=${oid}`
114
137
  ], { cwd: options.cwd });
115
138
  if (!result.ok) return failure(result.error);
116
- const connection = result.value.data?.repository?.object?.authors;
139
+ const object = result.value.data?.repository?.object;
140
+ const connection = object?.authors;
117
141
  if (connection?.pageInfo?.hasNextPage) {
118
142
  return failure({ code: 'RELEASE_NOTES_AUTHORS_TRUNCATED', message: `Commit authors exceeded the supported page size for ${oid}`, retryable: false });
119
143
  }
120
144
  authors.set(oid, (connection?.nodes || []).map(normalizeGitHubActor));
145
+ const associatedPullRequests = object?.associatedPullRequests;
146
+ if (!associatedPullRequests || !Array.isArray(associatedPullRequests.nodes) || !associatedPullRequests.pageInfo || typeof associatedPullRequests.pageInfo.hasNextPage !== 'boolean') {
147
+ return failure({ code: 'INVALID_PLATFORM_RESPONSE', message: `Associated pull request data is incomplete for commit ${oid}`, retryable: false });
148
+ }
149
+ if (associatedPullRequests.pageInfo.hasNextPage) {
150
+ return failure({ code: 'RELEASE_NOTES_ASSOCIATED_PULL_REQUESTS_TRUNCATED', message: `Associated pull requests exceeded the supported page size for ${oid}`, retryable: false });
151
+ }
152
+ const numbers: number[] = [];
153
+ for (const association of associatedPullRequests.nodes) {
154
+ if (typeof association?.number !== 'number' || !Number.isSafeInteger(association.number)) {
155
+ return failure({ code: 'INVALID_PLATFORM_RESPONSE', message: `Associated pull request identity is incomplete for commit ${oid}`, retryable: false });
156
+ }
157
+ if (eligiblePullRequestNumbers.has(association.number)) numbers.push(association.number);
158
+ }
159
+ pullRequestNumbersByCommit.set(oid, [...new Set(numbers)]);
121
160
  }
122
- const fromTime = Date.parse(input.fromTime);
123
- const toTime = Date.parse(input.toTime);
124
- const pullRequests = prs.value.filter((item) => {
125
- const mergedAt = Date.parse(String(item.mergedAt || ''));
126
- return Number.isFinite(mergedAt) && mergedAt > fromTime && mergedAt <= toTime;
127
- });
128
- return { status: 'no-op' as const, changed: false, history, pullRequests, authors, error: null };
161
+ const resolvedPullRequests = [];
162
+ const [owner, name] = input.repository.split('/');
163
+ const closingIssuesQuery = 'query($owner:String!,$name:String!,$number:Int!,$cursor:String){repository(owner:$owner,name:$name){pullRequest(number:$number){closingIssuesReferences(first:100,after:$cursor){nodes{number title url author{login}} pageInfo{hasNextPage endCursor}}}}}';
164
+ for (const item of pullRequests) {
165
+ const closingIssues = [];
166
+ let cursor: string | null = null;
167
+ for (;;) {
168
+ const args = ['api', 'graphql', '-f', `query=${closingIssuesQuery}`, '-F', `owner=${owner}`, '-F', `name=${name}`, '-F', `number=${String(item.number)}`];
169
+ if (cursor) args.push('-F', `cursor=${cursor}`);
170
+ const response = client.json<{
171
+ data?: { repository?: { pullRequest?: { closingIssuesReferences?: {
172
+ nodes?: Array<{ number?: number; title?: string; url?: string; author?: { login?: string | null } | null }>;
173
+ pageInfo?: { hasNextPage?: boolean; endCursor?: string | null };
174
+ } } } };
175
+ }>(args, { cwd: options.cwd });
176
+ if (!response.ok) return failure(response.error);
177
+ const connection = response.value.data?.repository?.pullRequest?.closingIssuesReferences;
178
+ if (!connection || !Array.isArray(connection.nodes) || !connection.pageInfo || typeof connection.pageInfo.hasNextPage !== 'boolean') {
179
+ return failure({ code: 'INVALID_PLATFORM_RESPONSE', message: `Closing Issue data is incomplete for pull request ${String(item.number)}`, retryable: false });
180
+ }
181
+ closingIssues.push(...connection.nodes.map((issue) => ({
182
+ number: Number(issue.number), title: String(issue.title || ''), url: String(issue.url || ''),
183
+ author: normalizePlatformAuthor(issue.author)
184
+ })));
185
+ if (!connection.pageInfo.hasNextPage) break;
186
+ if (!connection.pageInfo.endCursor || connection.pageInfo.endCursor === cursor) {
187
+ return failure({ code: 'PAGINATION_INVALID', message: `Closing Issue pagination is incomplete for pull request ${String(item.number)}`, retryable: false });
188
+ }
189
+ cursor = connection.pageInfo.endCursor;
190
+ }
191
+ resolvedPullRequests.push({
192
+ ...item,
193
+ author: normalizePlatformAuthor((item.author as { login?: string | null } | null) || null),
194
+ closingIssues
195
+ });
196
+ }
197
+ const commits = input.commitOids.map((sha) => ({
198
+ sha,
199
+ url: `https://github.com/${input.repository}/commit/${sha}`,
200
+ pullRequestNumbers: pullRequestNumbersByCommit.get(sha) || [],
201
+ authors: authors.get(sha) || []
202
+ }));
203
+ return { status: 'no-op' as const, changed: false, history, pullRequests: resolvedPullRequests, commits, error: null };
129
204
  }
130
205
 
131
206
  export { fetchGitHubReleaseNoteData, normalizeGitHubActor, publishGitHubReleaseNotes };
@@ -8,7 +8,7 @@ import type {
8
8
  function createNoneProvider(input: PlatformProviderFactoryInput): PlatformProvider {
9
9
  return {
10
10
  type: input.providerType,
11
- contractVersion: 1,
11
+ contractVersion: 2,
12
12
  context: {
13
13
  async resolve(): Promise<ProviderResult<PlatformContextSnapshot>> {
14
14
  return {
@@ -221,16 +221,39 @@ type RepositoryMetadataSnapshot = {
221
221
  }>;
222
222
  };
223
223
 
224
+ type ReleaseNoteAuthor = {
225
+ name: string;
226
+ login: string | null;
227
+ bot: boolean;
228
+ resolution: 'platform-user' | 'platform-noreply' | 'unresolved';
229
+ };
230
+
231
+ type ReleaseNoteIssue = {
232
+ id: string;
233
+ identity: ResourceIdentity;
234
+ number: number;
235
+ title: string;
236
+ displayUrl: string;
237
+ author: ReleaseNoteAuthor | null;
238
+ };
239
+
240
+ type ReleaseNotePullRequest = {
241
+ id: string;
242
+ identity: ResourceIdentity;
243
+ number: number;
244
+ title: string;
245
+ body: string;
246
+ mergedAt: string;
247
+ displayUrl: string;
248
+ labels: string[];
249
+ author: ReleaseNoteAuthor | null;
250
+ closingIssues: ReleaseNoteIssue[];
251
+ };
252
+
224
253
  type ReleaseNotesFacts = {
225
- history: Array<{
226
- sha: string;
227
- message: string;
228
- authoredAt: string;
229
- author: { id?: string; name?: string } | null;
230
- }>;
231
- mergedPullRequests: Array<ChangeRequestSnapshot>;
232
- closingIssues: Array<IssueSnapshot>;
233
- actors: Array<{ id?: string; name?: string }>;
254
+ history: Array<{ tag: string; body: string; url: string | null }>;
255
+ commits: Array<{ sha: string; url: string | null; pullRequestNumbers: number[]; authors: ReleaseNoteAuthor[] }>;
256
+ mergedPullRequests: ReleaseNotePullRequest[];
234
257
  };
235
258
 
236
259
  type MilestoneReconciliation = {
@@ -249,14 +272,14 @@ type VerificationRemoteFacts = {
249
272
 
250
273
  type PlatformProviderFactoryInput = {
251
274
  providerType: string;
252
- contractVersion: 1;
275
+ contractVersion: 2;
253
276
  repositoryRoot: string;
254
277
  config: Readonly<Record<string, JsonValue>>;
255
278
  };
256
279
 
257
280
  type PlatformProvider = {
258
281
  type: string;
259
- contractVersion: 1;
282
+ contractVersion: 2;
260
283
  identity?: ProviderIdentityDeclaration;
261
284
  context: {
262
285
  resolve(input: {
@@ -466,7 +489,7 @@ type PlatformProviderFactory = (
466
489
  input: PlatformProviderFactoryInput
467
490
  ) => Promise<PlatformProvider>;
468
491
 
469
- const PLATFORM_PROVIDER_CONTRACT_VERSION = 1 as const;
492
+ const PLATFORM_PROVIDER_CONTRACT_VERSION = 2 as const;
470
493
 
471
494
  function isRecord(value: unknown): value is Record<string, unknown> {
472
495
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
@@ -605,6 +628,9 @@ export type {
605
628
  RequiredCheckSnapshot,
606
629
  RepositoryMetadataSnapshot,
607
630
  ReleaseNotesFacts,
631
+ ReleaseNoteAuthor,
632
+ ReleaseNoteIssue,
633
+ ReleaseNotePullRequest,
608
634
  ResourceIdentity,
609
635
  SecurityAlertKind,
610
636
  SecurityAlertSnapshot,
@@ -16,6 +16,8 @@ import type {
16
16
  PlatformError,
17
17
  PlatformProvider,
18
18
  ProviderResult,
19
+ ReleaseNoteAuthor,
20
+ ReleaseNoteIssue,
19
21
  ReleaseNotesFacts,
20
22
  ReleaseSnapshot,
21
23
  RemoteCommentSnapshot,
@@ -109,6 +111,15 @@ function arrayValue(value: unknown, label: string): unknown[] {
109
111
  return value;
110
112
  }
111
113
 
114
+ function positiveNumberValue(value: unknown, label: string): number {
115
+ if (!Number.isSafeInteger(value) || Number(value) <= 0) throw new Error(`${label} must be a positive integer`);
116
+ return Number(value);
117
+ }
118
+
119
+ function stringArray(value: unknown, label: string): string[] {
120
+ return arrayValue(value, label).map((entry, index) => stringValue(entry, `${label}[${index}]`));
121
+ }
122
+
112
123
  function stableUnique<T>(values: T[], key: (value: T) => string, label: string): T[] {
113
124
  const seen = new Set<string>();
114
125
  for (const value of values) {
@@ -119,6 +130,16 @@ function stableUnique<T>(values: T[], key: (value: T) => string, label: string):
119
130
  return [...values].sort((left, right) => key(left).localeCompare(key(right)));
120
131
  }
121
132
 
133
+ function uniqueInOrder<T>(values: T[], key: (value: T) => string, label: string): T[] {
134
+ const seen = new Set<string>();
135
+ for (const value of values) {
136
+ const identityKey = key(value);
137
+ if (seen.has(identityKey)) throw new Error(`${label} contains duplicate identities`);
138
+ seen.add(identityKey);
139
+ }
140
+ return values;
141
+ }
142
+
122
143
  function declaredIdentityKind(
123
144
  declaration: ProviderIdentityDeclaration | undefined,
124
145
  resourceKind: 'issue' | 'pull-request' | 'comment' | 'release'
@@ -389,20 +410,70 @@ function validateMetadata(value: unknown): RepositoryMetadataSnapshot {
389
410
 
390
411
  function validateReleaseNotes(value: unknown, declaration?: ProviderIdentityDeclaration): ReleaseNotesFacts {
391
412
  const item = record(value, 'release notes');
392
- exactKeys(item, ['history', 'mergedPullRequests', 'closingIssues', 'actors'], 'release notes');
413
+ exactKeys(item, ['history', 'commits', 'mergedPullRequests'], 'release notes');
414
+ const releaseAuthor = (raw: unknown, label: string): ReleaseNoteAuthor | null => {
415
+ if (raw === null) return null;
416
+ const entry = record(raw, label);
417
+ exactKeys(entry, ['name', 'login', 'bot', 'resolution'], label);
418
+ if (typeof entry.bot !== 'boolean') throw new Error(`${label}.bot must be boolean`);
419
+ if (!['platform-user', 'platform-noreply', 'unresolved'].includes(String(entry.resolution))) {
420
+ throw new Error(`${label}.resolution is invalid`);
421
+ }
422
+ return {
423
+ name: stringValue(entry.name, `${label}.name`, true),
424
+ login: nullableString(entry.login, `${label}.login`),
425
+ bot: entry.bot,
426
+ resolution: entry.resolution as ReleaseNoteAuthor['resolution']
427
+ };
428
+ };
429
+ const issue = (raw: unknown, label: string): ReleaseNoteIssue => {
430
+ const entry = record(raw, label);
431
+ exactKeys(entry, ['id', 'identity', 'number', 'title', 'displayUrl', 'author'], label);
432
+ return {
433
+ id: stringValue(entry.id, `${label}.id`),
434
+ identity: identity(entry.identity, `${label}.identity`, declaredIdentityKind(declaration, 'issue')),
435
+ number: positiveNumberValue(entry.number, `${label}.number`),
436
+ title: stringValue(entry.title, `${label}.title`, true),
437
+ displayUrl: stringValue(entry.displayUrl, `${label}.displayUrl`, true),
438
+ author: releaseAuthor(entry.author, `${label}.author`)
439
+ };
440
+ };
393
441
  return {
394
- history: stableUnique(arrayValue(item.history, 'release notes.history').map((raw) => {
442
+ history: uniqueInOrder(arrayValue(item.history, 'release notes.history').map((raw) => {
395
443
  const entry = record(raw, 'release notes.history[]');
396
- exactKeys(entry, ['sha', 'message', 'authoredAt', 'author'], 'release notes.history[]');
397
- return { sha: stringValue(entry.sha, 'history.sha'), message: stringValue(entry.message, 'history.message', true), authoredAt: utcTimestamp(entry.authoredAt, 'history.authoredAt'), author: author(entry.author, 'history.author') };
398
- }), (entry) => entry.sha, 'release notes.history'),
399
- mergedPullRequests: stableUnique(arrayValue(item.mergedPullRequests, 'release notes.mergedPullRequests').map((entry) => validateChangeRequest(entry, declaredIdentityKind(declaration, 'pull-request'), true)), (entry) => serializeResourceIdentity(entry.identity!), 'release notes.mergedPullRequests'),
400
- closingIssues: stableUnique(arrayValue(item.closingIssues, 'release notes.closingIssues').map((entry) => validateIssue(entry, declaredIdentityKind(declaration, 'issue'), true)), (entry) => serializeResourceIdentity(entry.identity!), 'release notes.closingIssues'),
401
- actors: stableUnique(arrayValue(item.actors, 'release notes.actors').map((entry) => {
402
- const actor = author(entry, 'release notes.actor');
403
- if (!actor) throw new Error('release notes.actor must identify an actor');
404
- return actor;
405
- }), (entry) => entry.id || entry.name || '', 'release notes.actors')
444
+ exactKeys(entry, ['tag', 'body', 'url'], 'release notes.history[]');
445
+ return { tag: stringValue(entry.tag, 'history.tag'), body: stringValue(entry.body, 'history.body', true), url: nullableString(entry.url, 'history.url') };
446
+ }), (entry) => entry.tag, 'release notes.history'),
447
+ commits: stableUnique(arrayValue(item.commits, 'release notes.commits').map((raw) => {
448
+ const entry = record(raw, 'release notes.commits[]');
449
+ exactKeys(entry, ['sha', 'url', 'pullRequestNumbers', 'authors'], 'release notes.commits[]');
450
+ return {
451
+ sha: stringValue(entry.sha, 'commits.sha'),
452
+ url: nullableString(entry.url, 'commits.url'),
453
+ pullRequestNumbers: stableUnique(arrayValue(entry.pullRequestNumbers, 'commits.pullRequestNumbers').map((number) => positiveNumberValue(number, 'commits.pullRequestNumbers[]')), (number) => String(number), 'commits.pullRequestNumbers'),
454
+ authors: arrayValue(entry.authors, 'commits.authors').map((rawAuthor) => {
455
+ const normalized = releaseAuthor(rawAuthor, 'commits.author');
456
+ if (!normalized) throw new Error('commits.author must identify an author');
457
+ return normalized;
458
+ })
459
+ };
460
+ }), (entry) => entry.sha, 'release notes.commits'),
461
+ mergedPullRequests: stableUnique(arrayValue(item.mergedPullRequests, 'release notes.mergedPullRequests').map((raw) => {
462
+ const entry = record(raw, 'release notes.mergedPullRequests[]');
463
+ exactKeys(entry, ['id', 'identity', 'number', 'title', 'body', 'mergedAt', 'displayUrl', 'labels', 'author', 'closingIssues'], 'release notes.mergedPullRequests[]');
464
+ return {
465
+ id: stringValue(entry.id, 'pullRequest.id'),
466
+ identity: identity(entry.identity, 'pullRequest.identity', declaredIdentityKind(declaration, 'pull-request')),
467
+ number: positiveNumberValue(entry.number, 'pullRequest.number'),
468
+ title: stringValue(entry.title, 'pullRequest.title', true),
469
+ body: stringValue(entry.body, 'pullRequest.body', true),
470
+ mergedAt: utcTimestamp(entry.mergedAt, 'pullRequest.mergedAt'),
471
+ displayUrl: stringValue(entry.displayUrl, 'pullRequest.displayUrl', true),
472
+ labels: stringArray(entry.labels, 'pullRequest.labels'),
473
+ author: releaseAuthor(entry.author, 'pullRequest.author'),
474
+ closingIssues: stableUnique(arrayValue(entry.closingIssues, 'pullRequest.closingIssues').map((entry) => issue(entry, 'pullRequest.closingIssues[]')), (entry) => serializeResourceIdentity(entry.identity), 'pullRequest.closingIssues')
475
+ };
476
+ }), (entry) => serializeResourceIdentity(entry.identity), 'release notes.mergedPullRequests')
406
477
  };
407
478
  }
408
479
 
@@ -7,6 +7,7 @@ import { resolvePlatformProviderContext } from './context.ts';
7
7
  import type { PlatformClient } from './context.ts';
8
8
  import { providerError, providerOperationContext, providerStatus, unsupportedProviderOperation } from './provider-bridge.ts';
9
9
  import { resourceIdentityNumber } from './resource-identity.ts';
10
+ import type { ReleaseNoteAuthor } from './provider-contract.ts';
10
11
  import type { ResourceIdentity } from './resource-identity.ts';
11
12
 
12
13
  type ReleaseNoteOptions = {
@@ -15,10 +16,9 @@ type ReleaseNoteOptions = {
15
16
  client?: PlatformClient;
16
17
  };
17
18
 
18
- type ReleaseNoteActor = { id?: string; name?: string };
19
- type ReleaseNoteCommit = { oid: string; title: string; authors: ReleaseNoteActor[] };
20
- type ReleaseNoteIssue = { number: number; identity?: ResourceIdentity; title: string; url: string; labels: string[]; author: { login: string; bot: boolean } | null };
21
- type ReleaseNotePullRequest = { number: number; identity?: ResourceIdentity; title: string; body: string; url: string; mergedAt: string; labels: string[]; author: { login: string; bot: boolean } | null; closingIssues: ReleaseNoteIssue[] };
19
+ type ReleaseNoteCommit = { oid: string; title: string; url: string | null; pullRequestNumbers: number[]; authors: ReleaseNoteAuthor[] };
20
+ type ReleaseNoteIssue = { number: number; identity?: ResourceIdentity; title: string; url: string; author: ReleaseNoteAuthor | null };
21
+ type ReleaseNotePullRequest = { number: number; identity?: ResourceIdentity; title: string; body: string; url: string; mergedAt: string; labels: string[]; author: ReleaseNoteAuthor | null; closingIssues: ReleaseNoteIssue[] };
22
22
 
23
23
  const unsupportedError = {
24
24
  code: 'PLATFORM_RELEASE_NOTES_UNSUPPORTED',
@@ -180,15 +180,15 @@ async function releaseNoteContext(
180
180
  if (!platform.ok) return { ...baseResult(platform.error.retryable ? 'blocked' : 'failed'), error: providerError(platform.error, 'PLATFORM_PROVIDER_OPERATION_FAILED') };
181
181
  const commits = commitFacts.map((commit) => {
182
182
  const seen = new Set<string>();
183
- const authors = platform.value.history.filter((item) => item.sha === commit.oid).flatMap((item) => item.author ? [item.author] : [])
184
- .concat(platform.value.actors)
183
+ const facts = platform.value.commits.find((item) => item.sha === commit.oid);
184
+ const authors = (facts?.authors || [])
185
185
  .filter((actor) => {
186
- const key = actor.id || actor.name || '';
186
+ const key = actor.login || actor.name;
187
187
  if (seen.has(key)) return false;
188
188
  seen.add(key);
189
189
  return true;
190
190
  });
191
- return { ...commit, authors };
191
+ return { ...commit, url: facts?.url || null, pullRequestNumbers: facts?.pullRequestNumbers || [], authors };
192
192
  });
193
193
  const pullRequests: ReleaseNotePullRequest[] = platform.value.mergedPullRequests
194
194
  .map((item) => ({
@@ -199,14 +199,13 @@ async function releaseNoteContext(
199
199
  url: item.displayUrl || '',
200
200
  mergedAt: item.mergedAt || '',
201
201
  labels: item.labels || [],
202
- author: item.author ? { login: item.author.name || item.author.id || '', bot: false } : null,
203
- closingIssues: platform.value.closingIssues.filter((issue) => issue.id === item.id).map((issue) => ({
202
+ author: item.author,
203
+ closingIssues: item.closingIssues.map((issue) => ({
204
204
  number: issue.number || resourceIdentityNumber(issue.identity) || 0,
205
205
  ...(issue.identity ? { identity: issue.identity } : {}),
206
206
  title: issue.title,
207
207
  url: issue.displayUrl || '',
208
- labels: issue.labels || [],
209
- author: issue.author ? { login: issue.author.name || issue.author.id || '', bot: false } : null
208
+ author: issue.author
210
209
  }))
211
210
  }))
212
211
  .sort((left, right) => left.number - right.number);
@@ -219,7 +219,7 @@ export async function runInSandbox(
219
219
  allowRecreate: request.recreate,
220
220
  recreate: async () => {
221
221
  const { create } = await import('./commands/create.ts');
222
- await create([request.taskRef, '--no-refresh']);
222
+ await create([request.taskRef, '--no-refresh'], { runProjectInitCommand: false });
223
223
  }
224
224
  })).container;
225
225
  } else if (!found.running) {
@@ -79,6 +79,7 @@ import {
79
79
  recoverSandboxControlReplacement,
80
80
  readSandboxControlManifest
81
81
  } from '../control/lifecycle.ts';
82
+ import { atomicWriteJson } from '../control/state.ts';
82
83
  import { inspectSandboxControlContainer } from '../control/container-identity.ts';
83
84
  import { hostJoin, toEnginePath, volumeArg } from '../engines/wsl2-paths.ts';
84
85
  import { sandboxCoreBindMounts } from '../mounts.ts';
@@ -1008,7 +1009,27 @@ function readImageLabels(config: Pick<SandboxCreateConfig, 'imageName'> & Pick<S
1008
1009
  ]));
1009
1010
  }
1010
1011
 
1011
- export async function create(args: string[]): Promise<void> {
1012
+ function readProjectInitCommandDigest(controlRoot: string): string | null {
1013
+ const markerPath = path.join(controlRoot, 'project-init.json');
1014
+ if (!fs.existsSync(markerPath)) return null;
1015
+ try {
1016
+ const marker = JSON.parse(fs.readFileSync(markerPath, 'utf8')) as { version?: unknown; commandSha256?: unknown };
1017
+ return marker.version === 1 && typeof marker.commandSha256 === 'string' && /^[a-f0-9]{64}$/.test(marker.commandSha256)
1018
+ ? marker.commandSha256
1019
+ : null;
1020
+ } catch {
1021
+ return null;
1022
+ }
1023
+ }
1024
+
1025
+ function writeProjectInitCommandDigest(controlRoot: string, commandSha256: string): void {
1026
+ atomicWriteJson(path.join(controlRoot, 'project-init.json'), { version: 1, commandSha256 });
1027
+ }
1028
+
1029
+ export async function create(
1030
+ args: string[],
1031
+ { runProjectInitCommand = true }: { runProjectInitCommand?: boolean } = {}
1032
+ ): Promise<void> {
1012
1033
  const { values, positionals } = parseArgs({
1013
1034
  args,
1014
1035
  allowPositionals: true,
@@ -1079,6 +1100,7 @@ export async function create(args: string[]): Promise<void> {
1079
1100
  identity: target.workspace
1080
1101
  });
1081
1102
  const worktree = worktreeCandidates.find((candidate) => fs.existsSync(candidate)) ?? worktreeCandidates[0] ?? '';
1103
+ let worktreeCreated = false;
1082
1104
  const shareCommon = shareCommonDir(effectiveConfig);
1083
1105
  const shareBranch = shareBranchDir(effectiveConfig, branch);
1084
1106
  const preparedDockerfile = prepareDockerfile(effectiveConfig, capabilityPlan.image);
@@ -1203,6 +1225,7 @@ export async function create(args: string[]): Promise<void> {
1203
1225
  toEnginePath(engine, worktree),
1204
1226
  branch
1205
1227
  ]);
1228
+ worktreeCreated = true;
1206
1229
  } else {
1207
1230
  message(`Creating branch '${branch}' from '${baseBranch}'...`);
1208
1231
  runEngineTaskCommand(engine, 'git', [
@@ -1215,6 +1238,7 @@ export async function create(args: string[]): Promise<void> {
1215
1238
  toEnginePath(engine, worktree),
1216
1239
  baseBranch
1217
1240
  ]);
1241
+ worktreeCreated = true;
1218
1242
  }
1219
1243
 
1220
1244
  return `Worktree ready at ${worktree}`;
@@ -1272,6 +1296,7 @@ export async function create(args: string[]): Promise<void> {
1272
1296
  let replacementCutover: ReturnType<typeof beginSandboxControlReplacement> | null = null;
1273
1297
  try {
1274
1298
  const recoveryResult = await recoverSandboxControlReplacement(controlPaths.root, replacementLease);
1299
+ const previousProjectInitCommandDigest = readProjectInitCommandDigest(controlPaths.root);
1275
1300
  const hadExistingControlRoot = hadControlRootBeforeAcquire || recoveryResult === 'restored';
1276
1301
  const previousManifest = fs.existsSync(controlPaths.manifestPath)
1277
1302
  ? readSandboxControlManifest(controlPaths.manifestPath)
@@ -1728,6 +1753,32 @@ export async function create(args: string[]): Promise<void> {
1728
1753
  );
1729
1754
  }
1730
1755
 
1756
+ const initCommand = effectiveConfig.initCommand;
1757
+ const initCommandDigest = initCommand === null ? null : createHash('sha256').update(initCommand).digest('hex');
1758
+ const shouldRunProjectInitCommand = runProjectInitCommand
1759
+ && initCommand !== null
1760
+ && initCommandDigest !== null
1761
+ && (worktreeCreated || previousProjectInitCommandDigest !== initCommandDigest);
1762
+ let projectInitCompleted = !worktreeCreated
1763
+ && initCommandDigest !== null
1764
+ && previousProjectInitCommandDigest === initCommandDigest;
1765
+
1766
+ if (shouldRunProjectInitCommand && initCommand !== null) {
1767
+ try {
1768
+ runVerboseEngine(engine, 'docker', [
1769
+ 'exec', '--workdir', '/workspace', container, 'bash', '-lc', initCommand
1770
+ ]);
1771
+ projectInitCompleted = true;
1772
+ } catch (error) {
1773
+ throw new Error(
1774
+ `Project initialization failed for '${branch}': ${error instanceof Error ? error.message : String(error)}`
1775
+ );
1776
+ }
1777
+ }
1778
+ if (projectInitCompleted && initCommandDigest !== null) {
1779
+ writeProjectInitCommandDigest(controlPaths.root, initCommandDigest);
1780
+ }
1781
+
1731
1782
  replacementLease.release();
1732
1783
  replacementLeaseHeld = false;
1733
1784
  return 'Container started';
@@ -169,7 +169,7 @@ export async function enter(args: string[]): Promise<number> {
169
169
  allowRecreate: parsed.recreate,
170
170
  recreate: async () => {
171
171
  const { create } = await import('./create.ts');
172
- await create([target.requestedRef, '--no-refresh']);
172
+ await create([target.requestedRef, '--no-refresh'], { runProjectInitCommand: false });
173
173
  }
174
174
  });
175
175
  const container = ready.container;
@@ -86,7 +86,7 @@ export async function start(args: string[]): Promise<void> {
86
86
  forceRecreate: parsed.recreate,
87
87
  recreate: async () => {
88
88
  const { create } = await import('./create.ts');
89
- await create([target.requestedRef, '--no-refresh']);
89
+ await create([target.requestedRef, '--no-refresh'], { runProjectInitCommand: false });
90
90
  }
91
91
  });
92
92
  const message = ready.path === 'healthy'