@fitlab-ai/agent-infra 0.9.12 → 0.9.13

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 (239) hide show
  1. package/README.md +11 -11
  2. package/README.zh-CN.md +11 -11
  3. package/dist/bin/internal-cli.js +24 -19
  4. package/dist/lib/agent-clients/next-steps.js +6 -1
  5. package/dist/lib/decide.js +3 -3
  6. package/dist/lib/internal/platform-checks.js +3 -3
  7. package/dist/lib/internal/platform-comment.js +9 -9
  8. package/dist/lib/internal/platform-context.js +2 -2
  9. package/dist/lib/internal/platform-issue.js +8 -9
  10. package/dist/lib/internal/platform-pr-review.js +10 -10
  11. package/dist/lib/internal/platform-pr.js +63 -24
  12. package/dist/lib/internal/platform-release-notes.js +3 -3
  13. package/dist/lib/internal/pr-review-grade.js +11 -9
  14. package/dist/lib/internal/release-workflow.js +4 -4
  15. package/dist/lib/internal/sandbox-control.js +30 -4
  16. package/dist/lib/internal/task-activity.js +3 -3
  17. package/dist/lib/internal/task-artifact.js +76 -24
  18. package/dist/lib/internal/task-create.js +2 -2
  19. package/dist/lib/internal/task-event.js +6 -3
  20. package/dist/lib/internal/task-finalization.js +2 -2
  21. package/dist/lib/internal/task-invalidation.js +60 -0
  22. package/dist/lib/internal/task-ledger.js +9 -6
  23. package/dist/lib/internal/task-lifecycle.js +2 -2
  24. package/dist/lib/internal/task-override.js +4 -4
  25. package/dist/lib/internal/task-review.js +3 -3
  26. package/dist/lib/internal/task-verify.js +6 -6
  27. package/dist/lib/internal/task-warning.js +3 -3
  28. package/dist/lib/platform/adapters.js +1 -3
  29. package/dist/lib/platform/change-request-git-evidence.js +27 -7
  30. package/dist/lib/platform/completion-backfill.js +6 -5
  31. package/dist/lib/platform/context.js +70 -130
  32. package/dist/lib/platform/github-client.js +1 -1
  33. package/dist/lib/platform/github-provider.js +903 -0
  34. package/dist/lib/platform/in-label-sync.js +143 -0
  35. package/dist/lib/platform/issue-comments.js +94 -41
  36. package/dist/lib/platform/issues.js +335 -189
  37. package/dist/lib/platform/metadata-labels.js +6 -2
  38. package/dist/lib/platform/none-provider.js +28 -0
  39. package/dist/lib/platform/pr-change-report.js +551 -0
  40. package/dist/lib/platform/pr-checks.js +119 -104
  41. package/dist/lib/platform/pr-review.js +88 -72
  42. package/dist/lib/platform/pr-summary.js +520 -50
  43. package/dist/lib/platform/provider-bridge.js +45 -0
  44. package/dist/lib/platform/provider-contract.d.ts +487 -0
  45. package/dist/lib/platform/provider-contract.js +95 -0
  46. package/dist/lib/platform/provider-loader.js +251 -0
  47. package/dist/lib/platform/provider-validation.js +504 -0
  48. package/dist/lib/platform/pull-request-metadata.js +8 -6
  49. package/dist/lib/platform/pull-requests.js +783 -537
  50. package/dist/lib/platform/release-notes.js +78 -55
  51. package/dist/lib/platform/releases.js +79 -50
  52. package/dist/lib/platform/resource-identity.d.ts +26 -0
  53. package/dist/lib/platform/resource-identity.js +92 -0
  54. package/dist/lib/platform/task-identities.js +31 -0
  55. package/dist/lib/platform/verification-required.js +11 -12
  56. package/dist/lib/platform/verification-sync.js +112 -238
  57. package/dist/lib/pr-review/evidence-grading.js +19 -6
  58. package/dist/lib/run/index.js +63 -23
  59. package/dist/lib/sandbox/control/client.js +96 -15
  60. package/dist/lib/sandbox/control/executor.js +3 -3
  61. package/dist/lib/sandbox/control/protocol.js +77 -0
  62. package/dist/lib/sandbox/control/server.js +398 -25
  63. package/dist/lib/sandbox/control/state.js +204 -1
  64. package/dist/lib/server/protocol.js +38 -1
  65. package/dist/lib/task/activity-log.js +8 -1
  66. package/dist/lib/task/artifact-lifecycle.js +33 -5
  67. package/dist/lib/task/capabilities.js +239 -0
  68. package/dist/lib/task/commands/status.js +32 -0
  69. package/dist/lib/task/commit-operation.js +12 -1
  70. package/dist/lib/task/control-authority.js +8 -5
  71. package/dist/lib/task/create-service.js +8 -8
  72. package/dist/lib/task/current-contract.js +5 -1
  73. package/dist/lib/task/decision-details.js +1 -1
  74. package/dist/lib/task/events.js +297 -17
  75. package/dist/lib/task/finalization.js +37 -13
  76. package/dist/lib/task/human-override.js +58 -36
  77. package/dist/lib/task/invalidation-command.js +86 -0
  78. package/dist/lib/task/invalidation.js +260 -0
  79. package/dist/lib/task/ledger-intents.js +37 -0
  80. package/dist/lib/task/local-artifact-finalization.js +374 -0
  81. package/dist/lib/task/orchestration.js +49 -93
  82. package/dist/lib/task/pr-delivery-fact.js +102 -101
  83. package/dist/lib/task/review-fingerprint.js +24 -2
  84. package/dist/lib/task/rework-intent.js +138 -0
  85. package/dist/lib/task/skill-scope.js +23 -0
  86. package/dist/lib/task/task-execution-lock.js +27 -17
  87. package/dist/lib/task/verification-engine.js +43 -16
  88. package/dist/lib/task/verification.js +3 -3
  89. package/dist/lib/task/workflow-warning-intents.js +1 -1
  90. package/dist/lib/task/write.js +11 -0
  91. package/dist/lib/version.js +11 -1
  92. package/dist/lifecycle-build-manifest.json +3 -3
  93. package/lib/agent-clients/next-steps.ts +6 -1
  94. package/lib/decide.ts +3 -3
  95. package/lib/init.ts +1 -1
  96. package/lib/internal/platform-checks.ts +3 -3
  97. package/lib/internal/platform-comment.ts +8 -8
  98. package/lib/internal/platform-context.ts +2 -2
  99. package/lib/internal/platform-issue.ts +7 -8
  100. package/lib/internal/platform-pr-review.ts +9 -9
  101. package/lib/internal/platform-pr.ts +47 -22
  102. package/lib/internal/platform-release-notes.ts +3 -3
  103. package/lib/internal/pr-review-grade.ts +10 -8
  104. package/lib/internal/release-workflow.ts +4 -4
  105. package/lib/internal/sandbox-control.ts +27 -3
  106. package/lib/internal/task-activity.ts +3 -3
  107. package/lib/internal/task-artifact.ts +65 -22
  108. package/lib/internal/task-create.ts +2 -2
  109. package/lib/internal/task-event.ts +6 -3
  110. package/lib/internal/task-finalization.ts +3 -3
  111. package/lib/internal/task-invalidation.ts +39 -0
  112. package/lib/internal/task-ledger.ts +11 -8
  113. package/lib/internal/task-lifecycle.ts +2 -2
  114. package/lib/internal/task-override.ts +6 -6
  115. package/lib/internal/task-review.ts +3 -3
  116. package/lib/internal/task-verify.ts +6 -6
  117. package/lib/internal/task-warning.ts +3 -3
  118. package/lib/platform/adapters.ts +3 -2
  119. package/lib/platform/change-request-git-evidence.ts +36 -9
  120. package/lib/platform/completion-backfill.ts +9 -8
  121. package/lib/platform/context.ts +97 -134
  122. package/lib/platform/github-client.ts +2 -2
  123. package/lib/platform/github-provider.ts +910 -0
  124. package/lib/platform/in-label-sync.ts +208 -0
  125. package/lib/platform/issue-comments.ts +97 -47
  126. package/lib/platform/issues.ts +344 -174
  127. package/lib/platform/metadata-labels.ts +5 -2
  128. package/lib/platform/none-provider.ts +35 -0
  129. package/lib/platform/pr-change-report.ts +645 -0
  130. package/lib/platform/pr-checks.ts +126 -106
  131. package/lib/platform/pr-review.ts +88 -85
  132. package/lib/platform/pr-summary.ts +560 -53
  133. package/lib/platform/provider-bridge.ts +82 -0
  134. package/lib/platform/provider-contract.ts +536 -0
  135. package/lib/platform/provider-loader.ts +307 -0
  136. package/lib/platform/provider-validation.ts +529 -0
  137. package/lib/platform/pull-request-metadata.ts +10 -7
  138. package/lib/platform/pull-requests.ts +826 -543
  139. package/lib/platform/release-notes.ts +76 -59
  140. package/lib/platform/releases.ts +77 -43
  141. package/lib/platform/resource-identity.ts +121 -0
  142. package/lib/platform/task-identities.ts +40 -0
  143. package/lib/platform/types.ts +4 -2
  144. package/lib/platform/verification-required.ts +11 -13
  145. package/lib/platform/verification-sync.ts +112 -292
  146. package/lib/pr-review/evidence-grading.ts +25 -8
  147. package/lib/run/index.ts +57 -22
  148. package/lib/sandbox/control/client.ts +105 -15
  149. package/lib/sandbox/control/executor.ts +4 -4
  150. package/lib/sandbox/control/protocol.ts +113 -0
  151. package/lib/sandbox/control/server.ts +431 -23
  152. package/lib/sandbox/control/state.ts +251 -1
  153. package/lib/server/protocol.ts +36 -1
  154. package/lib/task/activity-log.ts +8 -0
  155. package/lib/task/artifact-lifecycle.ts +32 -6
  156. package/lib/task/capabilities.ts +262 -0
  157. package/lib/task/commands/status.ts +38 -0
  158. package/lib/task/commit-operation.ts +12 -1
  159. package/lib/task/control-authority.ts +11 -8
  160. package/lib/task/create-service.ts +8 -8
  161. package/lib/task/current-contract.ts +6 -1
  162. package/lib/task/decision-details.ts +6 -2
  163. package/lib/task/events.ts +296 -16
  164. package/lib/task/finalization.ts +50 -14
  165. package/lib/task/human-override.ts +70 -46
  166. package/lib/task/invalidation-command.ts +86 -0
  167. package/lib/task/invalidation.ts +332 -0
  168. package/lib/task/ledger-intents.ts +35 -1
  169. package/lib/task/local-artifact-finalization.ts +563 -0
  170. package/lib/task/orchestration.ts +52 -85
  171. package/lib/task/pr-delivery-fact.ts +131 -148
  172. package/lib/task/review-fingerprint.ts +23 -2
  173. package/lib/task/rework-intent.ts +165 -0
  174. package/lib/task/skill-scope.ts +24 -0
  175. package/lib/task/task-execution-lock.ts +23 -14
  176. package/lib/task/verification-engine.ts +52 -17
  177. package/lib/task/verification.ts +4 -4
  178. package/lib/task/workflow-warning-intents.ts +1 -1
  179. package/lib/task/write.ts +19 -0
  180. package/lib/update.ts +1 -1
  181. package/lib/version.ts +15 -1
  182. package/package.json +7 -1
  183. package/templates/.agents/rules/README.en.md +1 -1
  184. package/templates/.agents/rules/README.zh-CN.md +1 -1
  185. package/templates/.agents/rules/issue-pr-commands.en.md +1 -1
  186. package/templates/.agents/rules/issue-pr-commands.zh-CN.md +1 -1
  187. package/templates/.agents/rules/issue-sync.en.md +2 -0
  188. package/templates/.agents/rules/issue-sync.zh-CN.md +2 -0
  189. package/templates/.agents/rules/local-artifact-repair.en.md +9 -2
  190. package/templates/.agents/rules/local-artifact-repair.zh-CN.md +9 -2
  191. package/templates/.agents/rules/pr-sync.en.md +10 -4
  192. package/templates/.agents/rules/pr-sync.zh-CN.md +17 -4
  193. package/templates/.agents/rules/task-short-id.en.md +1 -4
  194. package/templates/.agents/rules/task-short-id.zh-CN.md +1 -3
  195. package/templates/.agents/skills/analyze-task/SKILL.en.md +13 -3
  196. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +10 -3
  197. package/templates/.agents/skills/check-task/SKILL.en.md +16 -37
  198. package/templates/.agents/skills/check-task/SKILL.zh-CN.md +16 -37
  199. package/templates/.agents/skills/code-task/SKILL.en.md +27 -8
  200. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +29 -10
  201. package/templates/.agents/skills/code-task/config/verify.en.json +1 -0
  202. package/templates/.agents/skills/code-task/config/verify.zh-CN.json +1 -0
  203. package/templates/.agents/skills/commit/reference/issue-metadata-sync.en.md +2 -0
  204. package/templates/.agents/skills/commit/reference/issue-metadata-sync.zh-CN.md +2 -0
  205. package/templates/.agents/skills/commit/reference/pr-summary-sync.en.md +3 -3
  206. package/templates/.agents/skills/commit/reference/pr-summary-sync.zh-CN.md +3 -3
  207. package/templates/.agents/skills/complete-manual-validation/SKILL.en.md +5 -3
  208. package/templates/.agents/skills/complete-manual-validation/SKILL.zh-CN.md +5 -3
  209. package/templates/.agents/skills/complete-manual-validation/reference/summary-update.en.md +4 -3
  210. package/templates/.agents/skills/complete-manual-validation/reference/summary-update.zh-CN.md +4 -3
  211. package/templates/.agents/skills/complete-task/SKILL.en.md +21 -3
  212. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +20 -3
  213. package/templates/.agents/skills/create-pr/SKILL.en.md +7 -6
  214. package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +7 -7
  215. package/templates/.agents/skills/create-pr/config/verify.json +1 -0
  216. package/templates/.agents/skills/create-pr/reference/change-report.en.md +16 -4
  217. package/templates/.agents/skills/create-pr/reference/change-report.zh-CN.md +16 -4
  218. package/templates/.agents/skills/create-pr/reference/comment-publish.en.md +5 -3
  219. package/templates/.agents/skills/create-pr/reference/comment-publish.zh-CN.md +5 -3
  220. package/templates/.agents/skills/create-pr/reference/pr-body-template.en.md +1 -1
  221. package/templates/.agents/skills/create-pr/reference/pr-body-template.zh-CN.md +1 -1
  222. package/templates/.agents/skills/create-pr/scripts/change-report.mjs +3 -0
  223. package/templates/.agents/skills/plan-task/SKILL.en.md +13 -3
  224. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +10 -3
  225. package/templates/.agents/skills/review-analysis/SKILL.en.md +3 -3
  226. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +3 -3
  227. package/templates/.agents/skills/review-code/SKILL.en.md +4 -4
  228. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +4 -4
  229. package/templates/.agents/skills/review-plan/SKILL.en.md +3 -3
  230. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +3 -3
  231. package/templates/.agents/skills/run-manual-validation/SKILL.en.md +4 -2
  232. package/templates/.agents/skills/run-manual-validation/SKILL.zh-CN.md +4 -2
  233. package/templates/.agents/skills/watch-pr/SKILL.en.md +2 -2
  234. package/templates/.agents/skills/watch-pr/SKILL.zh-CN.md +2 -2
  235. package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.en.md +6 -0
  236. package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.zh-CN.md +6 -0
  237. package/templates/.agents/templates/task.en.md +18 -1
  238. package/templates/.agents/templates/task.zh-CN.md +18 -1
  239. package/templates/.github/workflows/pr-label.yml +7 -26
@@ -6,17 +6,21 @@ import { parseTypedTaskFrontmatter } from '../task/frontmatter.ts';
6
6
  import { resolveTaskRef } from '../task/resolve-ref.ts';
7
7
  import { extractSection } from '../task/sections.ts';
8
8
  import { captureTaskWriteMetadata, writeTask } from '../task/write.ts';
9
- import {
10
- inspectPlatformChangeRequest,
11
- inspectPlatformIssueClosingChangeRequests,
12
- registerPlatformCapabilities
13
- } from './adapters.ts';
14
9
  import type { PlatformChangeRequestSnapshot } from './adapters.ts';
15
- import { parseGitHubRemote, resolvePlatformContext } from './context.ts';
10
+ import { parseGitHubRemote, resolvePlatformContext, resolvePlatformProviderContext } from './context.ts';
11
+ import type { PlatformClient } from './context.ts';
16
12
  import { createGitHubClient } from './github-client.ts';
17
- import type { GitHubClient } from './github-client.ts';
18
13
  import { inspectPlatformIssue } from './issues.ts';
14
+ import { inspectGitHubIssue } from './issues.ts';
15
+ import type { IssueSnapshot } from './issues.ts';
19
16
  import { planPullRequestMetadata } from './pull-request-metadata.ts';
17
+ import {
18
+ extractPullRequestFileNames,
19
+ planInLabelUpdate,
20
+ syncLabelDelta,
21
+ validateInLabelMapping,
22
+ validateRepositoryLabelPayload
23
+ } from './in-label-sync.ts';
20
24
  import { platformResult } from './types.ts';
21
25
  import type { PlatformOperation, PlatformResult } from './types.ts';
22
26
  import { inspectCompletionArtifacts } from '../task/finalization-artifacts.ts';
@@ -30,6 +34,19 @@ import {
30
34
  readPrDeliveryFact
31
35
  } from '../task/pr-delivery-fact.ts';
32
36
  import type { CreationOutcome, PrDeliveryBindingSource, PrDeliveryFact } from '../task/pr-delivery-fact.ts';
37
+ import {
38
+ providerError,
39
+ providerOperationContext,
40
+ providerStatus,
41
+ providerResourceToken,
42
+ providerResourceIdentity,
43
+ resourceIdentity,
44
+ unsupportedProviderOperation
45
+ } from './provider-bridge.ts';
46
+ import { isResourceIdentity, resourceIdentityEquals, resourceIdentityNumber } from './resource-identity.ts';
47
+ import type { ResourceIdentity } from './resource-identity.ts';
48
+ import { taskIssueIdentity, taskIssueIdentityError } from './task-identities.ts';
49
+ import type { ChangeRequestSnapshot as ProviderChangeRequestSnapshot } from './provider-contract.ts';
33
50
 
34
51
  type PullRequestSnapshot = PlatformChangeRequestSnapshot;
35
52
 
@@ -39,9 +56,19 @@ type PullRequestResult = PlatformResult & {
39
56
  result: 'pr_created' | 'pr_reused' | 'no_op' | 'pr_created_with_warnings' | 'pr_reused_with_warnings' | 'no_op_with_warnings' | 'failed' | 'blocked' | null;
40
57
  creation: CreationOutcome | null;
41
58
  warnings: readonly OperationWarning[];
59
+ evidence?: { kind: string; pullRequestFiles?: string[]; closingIssues?: number[] };
60
+ resources?: Array<{
61
+ kind: 'issue' | 'pull-request';
62
+ number: number;
63
+ before: string[];
64
+ expected: string[];
65
+ after: string[] | null;
66
+ effect: 'no-op' | 'applied' | 'unknown';
67
+ }>;
42
68
  };
43
- type InspectionOptions = { cwd?: string; client?: unknown };
44
- type SharedOptions = { cwd?: string; client?: GitHubClient };
69
+ type GitHubClient = PlatformClient;
70
+ type InspectionOptions = { cwd?: string; client?: PlatformClient; runtimeVersion?: string };
71
+ type SharedOptions = { cwd?: string; client?: PlatformClient; runtimeVersion?: string };
45
72
  type CreateOptions = SharedOptions & {
46
73
  agent: string;
47
74
  base: string;
@@ -52,11 +79,11 @@ type CreateOptions = SharedOptions & {
52
79
  dryRun?: boolean;
53
80
  phase?: { value: CreatePhase };
54
81
  };
55
- type BindOptions = SharedOptions & { agent: string; pr: number; dryRun?: boolean };
82
+ type BindOptions = SharedOptions & { agent: string; pr: string | number; dryRun?: boolean };
56
83
  type PullRequestPrimaryResult = 'pr_created' | 'pr_reused' | 'no_op';
57
84
  type SyncOptions = SharedOptions & { agent: string; metadata?: boolean; closingIssue?: boolean; dryRun?: boolean; primaryResult: PullRequestPrimaryResult };
58
- type ResolveExternalOptions = SharedOptions & { agent: string; pr?: number; dryRun?: boolean };
59
- type SkipFactOptions = { cwd?: string; agent: string; dryRun?: boolean };
85
+ type ResolveExternalOptions = SharedOptions & { agent: string; pr?: string | number; dryRun?: boolean };
86
+ type SkipFactOptions = { cwd?: string; agent: string; dryRun?: boolean; runtimeVersion?: string };
60
87
  type ExternalPullRequestSelection =
61
88
  | { status: 'normal'; candidates: PullRequestSnapshot[]; eligible: [] }
62
89
  | { status: 'selected'; source: 'unique' | 'explicit'; selected: PullRequestSnapshot; candidates: PullRequestSnapshot[]; eligible: PullRequestSnapshot[] }
@@ -222,9 +249,17 @@ function repositoryKey(repository: string): string {
222
249
  return repository.trim().toLowerCase();
223
250
  }
224
251
 
252
+ function isUsableResourceIdentity(identity: ResourceIdentity): boolean {
253
+ return isResourceIdentity(identity);
254
+ }
255
+
256
+ function identityLabel(identity: ResourceIdentity): string {
257
+ return identity.kind === 'number' ? `PR #${identity.value}` : `PR ${identity.kind}:${identity.value}`;
258
+ }
259
+
225
260
  function identitySignature(pullRequest: PullRequestSnapshot): string {
226
261
  return [
227
- pullRequest.repository, pullRequest.number, pullRequest.nodeId, pullRequest.url,
262
+ pullRequest.repository, JSON.stringify(pullRequestIdentity(pullRequest)), pullRequest.nodeId, pullRequest.url,
228
263
  pullRequest.head.repository, pullRequest.head.ref, pullRequest.head.sha,
229
264
  pullRequest.base.repository, pullRequest.base.ref, pullRequest.base.sha,
230
265
  pullRequest.mergedAt || '', pullRequest.mergeCommitSha || ''
@@ -237,34 +272,38 @@ function hasCompleteExternalIdentity(pullRequest: PullRequestSnapshot): boolean
237
272
  pullRequest.head.repository, pullRequest.head.ref, pullRequest.head.sha,
238
273
  pullRequest.base.repository, pullRequest.base.ref, pullRequest.base.sha
239
274
  ];
240
- return Number.isInteger(pullRequest.number) && pullRequest.number > 0 &&
241
- required.every((value) => Boolean(value?.trim())) &&
275
+ return isUsableResourceIdentity(pullRequestIdentity(pullRequest)) && required.every((value) => Boolean(value?.trim())) &&
242
276
  (pullRequest.state === 'open' || Boolean(pullRequest.mergedAt) === Boolean(pullRequest.mergeCommitSha));
243
277
  }
244
278
 
245
279
  function selectExternalPullRequest(
246
280
  candidates: PullRequestSnapshot[],
247
281
  repository: string,
248
- existingPrNumber: number | null,
249
- explicitPrNumber: number | null
282
+ existingPrIdentity: ResourceIdentity | number | null,
283
+ explicitPrIdentity: ResourceIdentity | number | null
250
284
  ): ExternalPullRequestSelection {
285
+ const existingIdentity = typeof existingPrIdentity === 'number'
286
+ ? resourceIdentity(existingPrIdentity) : existingPrIdentity;
287
+ const explicitIdentity = typeof explicitPrIdentity === 'number'
288
+ ? resourceIdentity(explicitPrIdentity) : explicitPrIdentity;
251
289
  const invalid = candidates.find((candidate) => !hasCompleteExternalIdentity(candidate));
252
290
  if (invalid) return {
253
291
  status: 'failed', code: 'PR_IDENTITY_INVALID',
254
292
  message: `Closing PR #${invalid.number} lacks required identity`, candidates, eligible: []
255
293
  };
256
- const byNumber = new Map<number, string>();
294
+ const byIdentity = new Map<string, string>();
257
295
  for (const candidate of candidates) {
258
296
  const signature = identitySignature(candidate);
259
- const prior = byNumber.get(candidate.number);
297
+ const identityKey = JSON.stringify(pullRequestIdentity(candidate));
298
+ const prior = byIdentity.get(identityKey);
260
299
  if (prior && prior !== signature) return {
261
300
  status: 'failed', code: 'PR_IDENTITY_INVALID',
262
301
  message: `Closing PR #${candidate.number} has conflicting identities`, candidates, eligible: []
263
302
  };
264
- byNumber.set(candidate.number, signature);
303
+ byIdentity.set(identityKey, signature);
265
304
  }
266
305
  const unique = candidates.filter((candidate, index) =>
267
- candidates.findIndex((other) => other.number === candidate.number) === index
306
+ candidates.findIndex((other) => resourceIdentityEquals(pullRequestIdentity(other), pullRequestIdentity(candidate))) === index
268
307
  );
269
308
  const wantedRepository = repositoryKey(repository);
270
309
  const eligible = unique.filter((candidate) =>
@@ -273,19 +312,19 @@ function selectExternalPullRequest(
273
312
  candidate.state === 'closed' &&
274
313
  Boolean(candidate.mergedAt && candidate.mergeCommitSha)
275
314
  );
276
- if (explicitPrNumber !== null) {
277
- const selected = unique.find((candidate) => candidate.number === explicitPrNumber);
278
- if (!selected) return { status: 'failed', code: 'PR_NOT_FOUND', message: `PR #${explicitPrNumber} is not a closing PR`, candidates, eligible };
279
- if (!eligible.some((candidate) => candidate.number === explicitPrNumber)) {
280
- return { status: 'failed', code: 'PR_IDENTITY_INVALID', message: `PR #${explicitPrNumber} is not an eligible merged PR`, candidates, eligible };
315
+ if (explicitIdentity !== null) {
316
+ const selected = unique.find((candidate) => resourceIdentityEquals(pullRequestIdentity(candidate), explicitIdentity));
317
+ if (!selected) return { status: 'failed', code: 'PR_NOT_FOUND', message: `PR ${identityLabel(explicitIdentity)} is not a closing PR`, candidates, eligible };
318
+ if (!eligible.some((candidate) => resourceIdentityEquals(pullRequestIdentity(candidate), explicitIdentity))) {
319
+ return { status: 'failed', code: 'PR_IDENTITY_INVALID', message: `PR ${identityLabel(explicitIdentity)} is not an eligible merged PR`, candidates, eligible };
281
320
  }
282
- if (existingPrNumber !== null && existingPrNumber !== explicitPrNumber) {
283
- return { status: 'failed', code: 'PR_BIND_CONFLICT', message: `Task is already bound to PR #${existingPrNumber}`, candidates, eligible };
321
+ if (existingIdentity !== null && !resourceIdentityEquals(existingIdentity, explicitIdentity)) {
322
+ return { status: 'failed', code: 'PR_BIND_CONFLICT', message: `Task is already bound to ${identityLabel(existingIdentity)}`, candidates, eligible };
284
323
  }
285
324
  return { status: 'selected', source: 'explicit', selected, candidates, eligible };
286
325
  }
287
- if (existingPrNumber !== null && eligible.length === 1 && existingPrNumber !== eligible[0]!.number) {
288
- return { status: 'failed', code: 'PR_BIND_CONFLICT', message: `Task is already bound to PR #${existingPrNumber}`, candidates, eligible };
326
+ if (existingIdentity !== null && eligible.length === 1 && !resourceIdentityEquals(existingIdentity, pullRequestIdentity(eligible[0]!))) {
327
+ return { status: 'failed', code: 'PR_BIND_CONFLICT', message: `Task is already bound to ${identityLabel(existingIdentity)}`, candidates, eligible };
289
328
  }
290
329
  if (eligible.length === 0) return { status: 'normal', candidates, eligible: [] };
291
330
  if (eligible.length > 1) return {
@@ -364,64 +403,8 @@ function selectPullRequest(remotes: RemotePullRequest[], repository: string, hea
364
403
  return { status: matches.length === 0 ? 'missing' : 'ambiguous', pullRequest: null };
365
404
  }
366
405
 
367
- function repositoryHead(cwd: string): string {
368
- return execFileSync('git', ['rev-parse', 'HEAD'], {
369
- cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe']
370
- }).trim();
371
- }
372
-
373
- function remoteBranchHead(cwd: string, repository: string, head: string):
374
- | { ok: true; value: string }
375
- | { ok: false; error: { code: string; message: string; retryable: boolean } } {
376
- const wanted = expectedHead(repository, head);
377
- if (!wanted.ref || !/^[A-Za-z0-9._/-]+$/.test(wanted.ref)) return {
378
- ok: false,
379
- error: { code: 'PR_HEAD_INVALID', message: 'Pull request head ref is invalid', retryable: false }
380
- };
381
- const remotes = configuredGitRemotes(cwd);
382
- const remote = remotes.find((item) => repositoryKey(item.repository) === repositoryKey(wanted.repository))
383
- ?? remotes.find((item) => item.name === 'origin');
384
- if (!remote) return {
385
- ok: false,
386
- error: { code: 'PR_REMOTE_BRANCH_MISSING', message: `No configured remote can verify ${wanted.repository}:${wanted.ref}`, retryable: false }
387
- };
388
- try {
389
- const output = execFileSync('git', ['ls-remote', '--refs', remote.name, `refs/heads/${wanted.ref}`], {
390
- cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe']
391
- }).trim();
392
- const match = output.split(/\r?\n/).map((line) => line.trim()).find((line) => line.endsWith(`refs/heads/${wanted.ref}`));
393
- const sha = match?.split(/\s+/)[0] || null;
394
- if (!sha || !/^[a-f0-9]{40}$/i.test(sha)) return {
395
- ok: false,
396
- error: { code: 'PR_REMOTE_BRANCH_MISSING', message: `Remote branch ${wanted.repository}:${wanted.ref} does not exist`, retryable: false }
397
- };
398
- return { ok: true, value: sha };
399
- } catch (error) {
400
- return {
401
- ok: false,
402
- error: { code: 'PR_REMOTE_BRANCH_UNAVAILABLE', message: error instanceof Error ? error.message : String(error), retryable: true }
403
- };
404
- }
405
- }
406
-
407
- function verifyCreateHead(base: ReturnType<typeof resolvedContext> & { ok: true }, head: string) {
408
- let localHead: string;
409
- try {
410
- localHead = repositoryHead(base.resolved.repoRoot);
411
- } catch (error) {
412
- return { ok: false as const, error: { code: 'PR_LOCAL_HEAD_UNAVAILABLE', message: error instanceof Error ? error.message : String(error), retryable: false } };
413
- }
414
- const remote = remoteBranchHead(base.resolved.repoRoot, base.context.platform.repository!, head);
415
- if (!remote.ok) return remote;
416
- if (remote.value !== localHead) return {
417
- ok: false as const,
418
- error: { code: 'PR_REMOTE_HEAD_MISMATCH', message: `Remote head ${remote.value} does not match local HEAD ${localHead}`, retryable: false }
419
- };
420
- return { ok: true as const, value: localHead };
421
- }
422
-
423
406
  function validateBoundPullRequest(
424
- base: ReturnType<typeof resolvedContext> & { ok: true },
407
+ base: Awaited<ReturnType<typeof resolvedContext>> & { ok: true },
425
408
  pullRequest: PullRequestSnapshot | null,
426
409
  head: string,
427
410
  baseRef: string,
@@ -464,7 +447,7 @@ function resolveTaskDeliveryTarget(repoRoot: string, frontmatter: Record<string,
464
447
  : { ok: false as const, error: { code: target.code, message: target.message, retryable: false } };
465
448
  }
466
449
 
467
- function taskDeliveryTarget(base: ReturnType<typeof resolvedContext> & { ok: true }) {
450
+ function taskDeliveryTarget(base: Awaited<ReturnType<typeof resolvedContext>> & { ok: true }) {
468
451
  return resolveTaskDeliveryTarget(base.resolved.repoRoot, base.frontmatter);
469
452
  }
470
453
 
@@ -496,7 +479,7 @@ function validateIdentityAgainstTarget(
496
479
  }
497
480
 
498
481
  function validateWriterIdentity(
499
- base: ReturnType<typeof resolvedContext> & { ok: true },
482
+ base: Awaited<ReturnType<typeof resolvedContext>> & { ok: true },
500
483
  pullRequest: PullRequestSnapshot | null,
501
484
  options: BindingIdentityOptions = {}
502
485
  ): { ok: true } | { ok: false; error: { code: string; message: string; retryable: boolean } } {
@@ -529,29 +512,103 @@ function validateExternalMergedEvidence(
529
512
  return { ok: true };
530
513
  }
531
514
 
532
- function resolvedContext(taskRef: string, options: InspectionOptions) {
515
+ async function resolvedContext(taskRef: string, options: InspectionOptions) {
533
516
  const resolved = resolveTaskRef(taskRef, options.cwd ? { repoRoot: options.cwd } : {});
534
517
  if (!resolved.ok) return { ok: false as const, output: result('failed', resolved.taskId, null, null, {
535
518
  error: { code: resolved.code, message: resolved.message, retryable: false }
536
519
  }) };
537
520
  const content = fs.readFileSync(resolved.taskMdPath, 'utf8');
538
521
  const frontmatter = parseTypedTaskFrontmatter(content);
539
- const factRead = readPrDeliveryFact(frontmatter);
522
+ const factRead = readPrDeliveryFact(frontmatter, options.runtimeVersion);
540
523
  if (factRead.status === 'invalid') return { ok: false as const, output: result('failed', resolved.taskId, null, null, {
541
- error: { code: 'PR_DELIVERY_FACT_INVALID', message: factRead.error.message, retryable: false }
524
+ error: { code: factRead.error.code, message: factRead.error.message, retryable: false }
542
525
  }) };
543
526
  const fact = factRead.status === 'valid' ? factRead.fact : null;
544
- const issue = Number(frontmatter.issue_number);
545
- const pr = fact?.state === 'bound' ? fact.identity.number : NaN;
546
- const issueNumber = Number.isInteger(issue) && issue > 0 ? issue : null;
547
- const prNumber = Number.isInteger(pr) && pr > 0 ? pr : null;
548
- const client = (options.client as GitHubClient | undefined) || createGitHubClient();
549
- const context = resolvePlatformContext({ cwd: resolved.repoRoot, client });
527
+ let issueIdentity: ResourceIdentity | null;
528
+ try { issueIdentity = taskIssueIdentity(frontmatter, undefined, options.runtimeVersion); }
529
+ catch (error) { return { ok: false as const, output: result('failed', resolved.taskId, null, null, { error: { ...taskIssueIdentityError(error), retryable: false } }) }; }
530
+ const issueNumber = resourceIdentityNumber(issueIdentity);
531
+ const prIdentity = fact?.state === 'bound' ? fact.identity.resource : null;
532
+ const prNumber = resourceIdentityNumber(prIdentity);
533
+ const client = options.client;
534
+ const loaded = await resolvePlatformProviderContext({ cwd: resolved.repoRoot, client });
535
+ const context = loaded.ok ? loaded.value.context : loaded.context;
550
536
  const usable = (context.status === 'no-op' || context.status === 'degraded') && context.platform.repository;
551
- if (!usable) return { ok: false as const, output: result(context.status, resolved.taskId, issueNumber, prNumber, {
537
+ if (!usable || !loaded.ok) return { ok: false as const, output: result(context.status, resolved.taskId, issueNumber, prNumber, {
552
538
  platform: context.platform, capabilities: context.capabilities, operations: context.operations, error: context.error
553
539
  }) };
554
- return { ok: true as const, resolved, content, frontmatter, fact, issueNumber, prNumber, client, context };
540
+ return { ok: true as const, resolved, content, frontmatter, fact, issueIdentity, issueNumber, prIdentity, prNumber, client, context, provider: loaded.value.provider, providerType: loaded.value.providerType, loadedContext: loaded.value };
541
+ }
542
+
543
+ function normalizeProviderPullRequest(
544
+ remote: ProviderChangeRequestSnapshot,
545
+ repository: string,
546
+ fallbackNumber: number,
547
+ fallback?: PullRequestSnapshot | null
548
+ ): PullRequestSnapshot {
549
+ const head = remote.head || fallback?.head || { repository, ref: '', sha: remote.headSha || '' };
550
+ const base = remote.base || fallback?.base || { repository, ref: '', sha: remote.baseSha || '' };
551
+ const identity = remote.identity || fallback?.identity || (remote.number ? { kind: 'number' as const, value: remote.number } : { kind: 'id' as const, value: remote.id });
552
+ const snapshot: PullRequestSnapshot = {
553
+ repository,
554
+ number: remote.number ?? resourceIdentityNumber(identity) ?? fallbackNumber,
555
+ nodeId: remote.id,
556
+ url: remote.displayUrl || fallback?.url || '',
557
+ state: remote.state === 'closed' ? 'closed' : 'open',
558
+ title: remote.title,
559
+ body: remote.body,
560
+ draft: remote.draft ?? fallback?.draft ?? false,
561
+ head: { ...head, sha: remote.headSha || head.sha },
562
+ base: { ...base, sha: remote.baseSha || base.sha },
563
+ mergedAt: remote.mergedAt ?? fallback?.mergedAt ?? null,
564
+ mergeCommitSha: remote.mergeCommitSha ?? fallback?.mergeCommitSha ?? null,
565
+ labels: [...(remote.labels || fallback?.labels || [])].sort(),
566
+ assignees: [...(remote.assignees || fallback?.assignees || [])].sort(),
567
+ milestone: remote.milestone ?? fallback?.milestone ?? null,
568
+ mergeability: remote.mergeability || fallback?.mergeability || { state: 'unknown', detail: null }
569
+ };
570
+ Object.defineProperty(snapshot, 'identity', { value: identity, enumerable: false, configurable: true });
571
+ return snapshot;
572
+ }
573
+
574
+ function pullRequestIdentity(pullRequest: PullRequestSnapshot): ResourceIdentity {
575
+ return pullRequest.identity || { kind: 'number', value: pullRequest.number };
576
+ }
577
+
578
+ function providerPullRequestError(
579
+ base: Awaited<ReturnType<typeof resolvedContext>> & { ok: true },
580
+ error: { code: string; message: string; retryable: boolean },
581
+ prNumber: number | null
582
+ ): PullRequestResult {
583
+ return result(error.code.startsWith('IN_LABEL_SYNC') ? 'blocked' : providerStatus(error), base.resolved.taskId, base.issueNumber, prNumber, {
584
+ changed: error.code.startsWith('IN_LABEL_SYNC'),
585
+ platform: base.context.platform,
586
+ capabilities: base.context.capabilities,
587
+ resource: { kind: 'pull-request', number: prNumber },
588
+ error: providerError(error, 'PLATFORM_PROVIDER_OPERATION_FAILED')
589
+ });
590
+ }
591
+
592
+ async function inspectExternalPullRequest(
593
+ base: Awaited<ReturnType<typeof resolvedContext>> & { ok: true },
594
+ prIdentity: ResourceIdentity
595
+ ): Promise<PullRequestResult> {
596
+ const prNumber = resourceIdentityNumber(prIdentity);
597
+ const fallback = base.fact?.state === 'bound' && resourceIdentityEquals(base.fact.identity.resource, prIdentity)
598
+ ? base.fact.identity as unknown as PullRequestSnapshot
599
+ : null;
600
+ const inspected = base.provider.changeRequests?.inspect
601
+ ? await base.provider.changeRequests.inspect({ context: providerOperationContext(base.loadedContext), target: prIdentity })
602
+ : unsupportedProviderOperation(base.provider, 'changeRequests.inspect');
603
+ if (!inspected.ok) return providerPullRequestError(base, inspected.error, prNumber);
604
+ const pullRequest = normalizeProviderPullRequest(inspected.value, base.context.platform.repository!, prNumber || 0, fallback);
605
+ return result('no-op', base.resolved.taskId, base.issueNumber, prNumber, {
606
+ platform: base.context.platform,
607
+ capabilities: base.context.capabilities,
608
+ resource: { kind: 'pull-request', number: prNumber, identity: prIdentity },
609
+ pullRequest,
610
+ error: null
611
+ });
555
612
  }
556
613
 
557
614
  function inspectGitHubPullRequest(client: GitHubClient, repository: string, number: number, cwd: string) {
@@ -563,203 +620,348 @@ function inspectGitHubPullRequest(client: GitHubClient, repository: string, numb
563
620
  : { ok: false as const, error: { code: 'PR_IDENTITY_INVALID', message: 'Remote resource is not a valid pull request', retryable: false } };
564
621
  }
565
622
 
566
- function configuredGitRemotes(cwd: string): Array<{ name: string; url: string; repository: string }> {
567
- try {
568
- return execFileSync('git', ['remote'], {
569
- cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe']
570
- }).split(/\r?\n/).filter(Boolean).flatMap((name) => {
571
- try {
572
- const url = execFileSync('git', ['config', '--get', `remote.${name}.url`], {
573
- cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe']
574
- }).trim();
575
- const repository = parseGitHubRemote(url);
576
- return repository ? [{ name, url, repository }] : [];
577
- } catch {
578
- return [];
579
- }
623
+ async function inspectPlatformPullRequest(taskRef: string, options: InspectionOptions = {}): Promise<PullRequestResult> {
624
+ const base = await resolvedContext(taskRef, options);
625
+ if (!base.ok) return base.output;
626
+ if (!base.fact) return result('no-op', base.resolved.taskId, base.issueNumber, null, {
627
+ error: { code: 'PR_DELIVERY_FACT_MISSING', message: 'Task has no pr_delivery_fact; repair the task metadata or create a new task', retryable: false }
628
+ });
629
+ if (!base.prIdentity) return result('no-op', base.resolved.taskId, base.issueNumber, null, {
630
+ platform: base.context.platform, capabilities: base.context.capabilities,
631
+ error: { code: 'PR_NOT_LINKED', message: 'Task has no verified bound pull request', retryable: false }
632
+ });
633
+ return inspectExternalPullRequest(base, base.prIdentity);
634
+ }
635
+
636
+ async function inspectPlatformPullRequestByNumber(prNumber: string | number, options: InspectionOptions = {}): Promise<PullRequestResult> {
637
+ const loaded = await resolvePlatformProviderContext({ cwd: options.cwd || process.cwd(), client: options.client });
638
+ const context = loaded.ok ? loaded.value.context : loaded.context;
639
+ const usable = (context.status === 'no-op' || context.status === 'degraded') && context.platform.repository;
640
+ if (!usable) {
641
+ return result(context.status, null, null, null, {
642
+ platform: context.platform, capabilities: context.capabilities, operations: context.operations, error: context.error
643
+ });
644
+ }
645
+ let identity: ResourceIdentity;
646
+ try { identity = loaded.ok ? providerResourceToken(loaded.value.provider, 'pull-request', String(prNumber)) : resourceIdentity(prNumber); }
647
+ catch (error) {
648
+ return result('failed', null, null, null, {
649
+ platform: context.platform, capabilities: context.capabilities,
650
+ error: { code: 'PLATFORM_IDENTITY_TOKEN_INVALID', message: error instanceof Error ? error.message : String(error), retryable: false }
580
651
  });
581
- } catch {
582
- return [];
583
652
  }
653
+ if (loaded.ok) {
654
+ const inspected = loaded.value.provider.changeRequests?.inspect
655
+ ? await loaded.value.provider.changeRequests.inspect({ context: providerOperationContext(loaded.value), target: identity })
656
+ : unsupportedProviderOperation(loaded.value.provider, 'changeRequests.inspect');
657
+ const number = resourceIdentityNumber(identity);
658
+ if (!inspected.ok) return result(providerStatus(inspected.error), null, null, number, {
659
+ platform: context.platform, capabilities: context.capabilities,
660
+ resource: { kind: 'pull-request', number, identity }, error: providerError(inspected.error, 'PLATFORM_PROVIDER_OPERATION_FAILED')
661
+ });
662
+ return result('no-op', null, null, number, {
663
+ platform: context.platform, capabilities: context.capabilities,
664
+ resource: { kind: 'pull-request', number, identity },
665
+ pullRequest: normalizeProviderPullRequest(inspected.value, context.platform.repository!, number || 0), error: null
666
+ });
667
+ }
668
+ return result('failed', null, null, null, {
669
+ platform: context.platform, capabilities: context.capabilities,
670
+ resource: { kind: 'pull-request', number: null },
671
+ error: { code: 'PLATFORM_PROVIDER_LOAD_FAILED', message: 'Selected provider failed to load', retryable: false }
672
+ });
584
673
  }
585
674
 
586
- function rewriteGitHubRemote(remote: string, repository: string): string | null {
587
- const match = remote.trim().match(
588
- /^(https?:\/\/github\.com\/|ssh:\/\/(?:git@)?github\.com\/|git@github\.com:)[^/\s]+\/[^/\s]+(?:\.git)?$/i
589
- );
590
- return match ? `${match[1]}${repository}.git` : null;
591
- }
675
+ const CLOSING_ISSUES_QUERY = 'query($owner:String!,$name:String!,$number:Int!,$cursor:String){repository(owner:$owner,name:$name){pullRequest(number:$number){closingIssuesReferences(first:100,after:$cursor){nodes{number} pageInfo{hasNextPage endCursor}}}}}';
592
676
 
593
- function resolveGitHubChangeRequestGitEvidence({
594
- cwd,
595
- repository,
596
- pullRequest
597
- }: {
598
- cwd: string;
599
- repository: string;
600
- pullRequest: PullRequestSnapshot;
601
- }) {
602
- if (
603
- pullRequest.repository !== repository ||
604
- pullRequest.base.repository !== repository ||
605
- pullRequest.number <= 0
606
- ) {
677
+ function readInLabelMapping(repoRoot: string): { ok: true; value: Record<string, unknown> } | { ok: false; error: { code: string; message: string; retryable: boolean } } {
678
+ try {
679
+ const config = JSON.parse(fs.readFileSync(path.join(repoRoot, '.agents', '.airc.json'), 'utf8')) as {
680
+ labels?: { in?: unknown };
681
+ };
682
+ const mapping = validateInLabelMapping(config.labels?.in);
683
+ return mapping.ok ? { ok: true, value: mapping.value } : mapping;
684
+ } catch (error) {
607
685
  return {
608
- ok: false as const,
609
- error: {
610
- code: 'PR_MERGE_EVIDENCE_SOURCE_UNAVAILABLE',
611
- message: 'Pull request repository identity is inconsistent',
612
- retryable: false
613
- }
686
+ ok: false,
687
+ error: { code: 'IN_LABEL_SYNC_CONFIG_INVALID', message: error instanceof Error ? error.message : String(error), retryable: false }
614
688
  };
615
689
  }
616
- const reviewedHeadRef = `refs/pull/${pullRequest.number}/head`;
617
- const targetHeadRef = `refs/heads/${pullRequest.base.ref}`;
618
- const refValid = (ref: string) => {
619
- try {
620
- execFileSync('git', ['check-ref-format', ref], {
621
- cwd, encoding: 'utf8', stdio: ['ignore', 'ignore', 'ignore']
622
- });
623
- return true;
624
- } catch {
625
- return false;
626
- }
690
+ }
691
+
692
+ function taskInLabelTarget(
693
+ repoRoot: string,
694
+ frontmatter: Record<string, unknown>,
695
+ currentLabels: readonly string[],
696
+ repositoryLabels: Set<string>
697
+ ) {
698
+ const baseRef = typeof frontmatter.delivery_base_ref === 'string' ? frontmatter.delivery_base_ref.trim() : '';
699
+ if (!baseRef) return {
700
+ ok: false as const,
701
+ error: { code: 'IN_LABEL_SYNC_BASE_MISSING', message: 'Task has no delivery_base_ref for in-label evidence', retryable: false }
627
702
  };
628
- if (!refValid(reviewedHeadRef) || !refValid(targetHeadRef)) {
703
+ let changedFiles: string[];
704
+ try {
705
+ changedFiles = execFileSync('git', ['diff', `${baseRef}...HEAD`, '--name-only'], {
706
+ cwd: repoRoot, encoding: 'utf8'
707
+ }).trim().split(/\r?\n/).filter(Boolean);
708
+ } catch (error) {
629
709
  return {
630
710
  ok: false as const,
631
- error: {
632
- code: 'PR_MERGE_EVIDENCE_SOURCE_UNAVAILABLE',
633
- message: 'Pull request Git refs are invalid',
634
- retryable: false
635
- }
711
+ error: { code: 'IN_LABEL_SYNC_EVIDENCE_UNAVAILABLE', message: error instanceof Error ? error.message : String(error), retryable: false }
636
712
  };
637
713
  }
714
+ const mapping = readInLabelMapping(repoRoot);
715
+ if (!mapping.ok) return mapping;
716
+ const planned = planInLabelUpdate({ changedFiles, currentLabels, mapping: mapping.value, repositoryLabels });
717
+ if (planned.error) return { ok: false as const, error: planned.error };
718
+ return { ok: true as const, value: planned.target };
719
+ }
638
720
 
639
- const remotes = configuredGitRemotes(cwd);
640
- const exact = remotes
641
- .filter((remote) => remote.repository === repository)
642
- .sort((left, right) => Number(right.name === 'origin') - Number(left.name === 'origin') ||
643
- left.name.localeCompare(right.name))[0];
644
- const origin = remotes.find((remote) => remote.name === 'origin');
645
- const remoteUrl = exact?.url || (origin ? rewriteGitHubRemote(origin.url, repository) : null);
646
- if (!remoteUrl) {
647
- return {
721
+ function inspectClosingIssueNumbers(client: GitHubClient, repository: string, pullRequest: number, cwd: string) {
722
+ const [owner, name] = repository.split('/');
723
+ if (!owner || !name) return {
724
+ ok: false as const,
725
+ error: { code: 'IN_LABEL_SYNC_REPOSITORY_INVALID', message: 'Repository identity is invalid', retryable: false }
726
+ };
727
+ let cursor: string | null = null;
728
+ const numbers: number[] = [];
729
+ for (;;) {
730
+ const args = ['api', 'graphql', '-f', `query=${CLOSING_ISSUES_QUERY}`, '-F', `owner=${owner}`, '-F', `name=${name}`, '-F', `number=${pullRequest}`];
731
+ if (cursor) args.push('-F', `cursor=${cursor}`);
732
+ const response = client.json<unknown>(args, { cwd });
733
+ if (!response.ok) return response;
734
+ const connection = (response.value as {
735
+ data?: { repository?: { pullRequest?: { closingIssuesReferences?: {
736
+ nodes?: unknown[]; pageInfo?: { hasNextPage?: boolean; endCursor?: string | null };
737
+ } } } };
738
+ })?.data?.repository?.pullRequest?.closingIssuesReferences;
739
+ if (!connection || !Array.isArray(connection.nodes) || !connection.pageInfo) return {
648
740
  ok: false as const,
649
- error: {
650
- code: 'PR_MERGE_EVIDENCE_SOURCE_UNAVAILABLE',
651
- message: `No GitHub remote can provide evidence for ${repository}`,
652
- retryable: false
653
- }
741
+ error: { code: 'IN_LABEL_SYNC_ASSOCIATION_INVALID', message: 'Closing Issue response is incomplete', retryable: false }
742
+ };
743
+ for (const node of connection.nodes) {
744
+ const issue = Number((node as { number?: unknown })?.number);
745
+ if (!Number.isSafeInteger(issue) || issue <= 0) return {
746
+ ok: false as const,
747
+ error: { code: 'IN_LABEL_SYNC_ASSOCIATION_INVALID', message: 'Closing Issue identity is invalid', retryable: false }
748
+ };
749
+ numbers.push(issue);
750
+ }
751
+ if (!connection.pageInfo.hasNextPage) return { ok: true as const, value: [...new Set(numbers)] };
752
+ if (!connection.pageInfo.endCursor || connection.pageInfo.endCursor === cursor) return {
753
+ ok: false as const,
754
+ error: { code: 'IN_LABEL_SYNC_ASSOCIATION_INVALID', message: 'Closing Issue pagination cursor is invalid', retryable: false }
654
755
  };
756
+ cursor = connection.pageInfo.endCursor;
655
757
  }
656
- return {
657
- ok: true as const,
658
- value: { remoteUrl, reviewedHeadRef, targetHeadRef }
758
+ }
759
+
760
+ function inspectRepositoryLabels(client: GitHubClient, repository: string, cwd: string) {
761
+ const response = client.json<unknown>(['api', '--paginate', '--slurp', `repos/${repository}/labels?per_page=100`], { cwd });
762
+ if (!response.ok) return response;
763
+ const labels = validateRepositoryLabelPayload(response.value);
764
+ return labels.ok ? { ok: true as const, value: new Set(labels.value) } : labels;
765
+ }
766
+
767
+ function inspectPullRequestFiles(client: GitHubClient, repository: string, pullRequest: number, cwd: string) {
768
+ const response = client.json<unknown>(['api', '--paginate', '--slurp', `repos/${repository}/pulls/${pullRequest}/files?per_page=100`], { cwd });
769
+ if (!response.ok) return response;
770
+ const files = extractPullRequestFileNames(response.value);
771
+ return files ? { ok: true as const, value: files } : {
772
+ ok: false as const,
773
+ error: { code: 'IN_LABEL_SYNC_FILES_INVALID', message: 'Pull request files response is incomplete', retryable: false }
659
774
  };
660
775
  }
661
776
 
662
- registerPlatformCapabilities('github', {
663
- inspectChangeRequest({ client, repository, number, cwd }) {
664
- return inspectGitHubPullRequest((client as GitHubClient | undefined) || createGitHubClient(), repository, number, cwd);
665
- },
666
- inspectIssueClosingChangeRequests({ client, repository, issueNumber, cwd }) {
667
- return inspectGitHubIssueClosingChangeRequests(
668
- (client as GitHubClient | undefined) || createGitHubClient(), repository, issueNumber, cwd
669
- );
670
- },
671
- resolveChangeRequestGitEvidence(context) {
672
- return resolveGitHubChangeRequestGitEvidence(context);
777
+ function inspectTaskInLabelTarget(
778
+ repoRoot: string,
779
+ frontmatter: Record<string, unknown>,
780
+ pullRequest: PullRequestSnapshot,
781
+ repository: string,
782
+ client: GitHubClient
783
+ ) {
784
+ const baseRef = typeof frontmatter.delivery_base_ref === 'string' ? frontmatter.delivery_base_ref.trim() : '';
785
+ if (!baseRef) return {
786
+ ok: false as const,
787
+ error: { code: 'IN_LABEL_SYNC_BASE_MISSING', message: 'Task has no delivery_base_ref for in-label evidence', retryable: false }
788
+ };
789
+ let changedFiles: string[];
790
+ try {
791
+ changedFiles = execFileSync('git', ['diff', `${baseRef}...HEAD`, '--name-only'], {
792
+ cwd: repoRoot, encoding: 'utf8'
793
+ }).trim().split(/\r?\n/).filter(Boolean);
794
+ } catch (error) {
795
+ return {
796
+ ok: false as const,
797
+ error: { code: 'IN_LABEL_SYNC_EVIDENCE_UNAVAILABLE', message: error instanceof Error ? error.message : String(error), retryable: false }
798
+ };
673
799
  }
674
- });
675
-
676
- function locatePullRequest(base: ReturnType<typeof resolvedContext> & { ok: true }, head: string, target: string, expectedSha?: string) {
677
- const repository = base.context.platform.repository!;
678
- const listed = base.client.json<RemotePullRequest[]>([
679
- 'api', `repos/${repository}/pulls?state=open&base=${encodeURIComponent(target)}&per_page=100`
680
- ], { cwd: base.resolved.repoRoot });
681
- if (!listed.ok) return listed;
682
- const selected = selectPullRequest(listed.value, repository, head, target, expectedSha);
683
- return selected.status === 'resolved'
684
- ? { ok: true as const, value: selected.pullRequest }
685
- : { ok: false as const, error: {
686
- code: selected.status === 'ambiguous' ? 'PR_IDENTITY_AMBIGUOUS'
687
- : selected.status === 'head-mismatch' ? 'PR_HEAD_SHA_MISMATCH' : 'PR_NOT_FOUND',
688
- message: selected.status === 'ambiguous' ? 'Multiple pull requests match the exact head/base identity'
689
- : selected.status === 'head-mismatch' ? 'A pull request matches head/base but not the expected head SHA'
690
- : 'No pull request matches the exact head/base identity',
691
- retryable: false
692
- } };
800
+ const mapping = readInLabelMapping(repoRoot);
801
+ if (!mapping.ok) return mapping;
802
+ const labels = inspectRepositoryLabels(client, repository, repoRoot);
803
+ if (!labels.ok) return labels;
804
+ const planned = planInLabelUpdate({
805
+ changedFiles, currentLabels: pullRequest.labels, mapping: mapping.value, repositoryLabels: labels.value
806
+ });
807
+ if (planned.error) return { ok: false as const, error: planned.error };
808
+ return { ok: true as const, value: { changedFiles, target: planned.target } };
693
809
  }
694
810
 
695
- function inspectPlatformPullRequest(taskRef: string, options: InspectionOptions = {}): PullRequestResult {
696
- const base = resolvedContext(taskRef, options);
697
- if (!base.ok) return base.output;
698
- if (!base.fact) return result('no-op', base.resolved.taskId, base.issueNumber, null, {
699
- error: { code: 'PR_DELIVERY_FACT_MISSING', message: 'Task has no pr_delivery_fact; repair the task metadata or create a new task', retryable: false }
811
+ function inLabelResource(
812
+ kind: 'issue' | 'pull-request',
813
+ number: number,
814
+ before: string[],
815
+ expected: string[],
816
+ after: string[] | null,
817
+ effect: 'no-op' | 'applied' | 'unknown'
818
+ ) {
819
+ return { kind, number, before: [...before].sort(), expected: [...expected].sort(), after: after ? [...after].sort() : null, effect };
820
+ }
821
+
822
+ async function syncPlatformPullRequestInLabels(prNumber: number, options: SharedOptions & { dryRun?: boolean } = {}): Promise<PullRequestResult> {
823
+ const cwd = path.resolve(options.cwd || process.cwd());
824
+ const client = options.client || createGitHubClient();
825
+ const context = await resolvePlatformContext({ cwd, client });
826
+ const usable = (context.status === 'no-op' || context.status === 'degraded') && context.platform.repository;
827
+ if (!usable) return result(context.status, null, null, prNumber, {
828
+ platform: context.platform, capabilities: context.capabilities, operations: context.operations, error: context.error
700
829
  });
701
- if (!base.prNumber) return result('no-op', base.resolved.taskId, base.issueNumber, null, {
702
- platform: base.context.platform, capabilities: base.context.capabilities,
703
- error: { code: 'PR_NOT_LINKED', message: 'Task has no verified bound pull request', retryable: false }
830
+ if (!Number.isSafeInteger(prNumber) || prNumber <= 0) return result('failed', null, null, prNumber, {
831
+ platform: context.platform, capabilities: context.capabilities,
832
+ error: { code: 'PR_NUMBER_INVALID', message: 'PR number must be positive', retryable: false }
704
833
  });
705
- const fetched = inspectPlatformChangeRequest(base.context.platform.type, {
706
- cwd: base.resolved.repoRoot,
707
- repository: base.context.platform.repository!,
708
- number: base.prNumber,
709
- client: options.client || base.client
834
+ const repository = context.platform.repository!;
835
+ const fetched = inspectGitHubPullRequest(client, repository, prNumber, cwd);
836
+ if (!fetched.ok) return result(fetched.error.retryable ? 'blocked' : 'failed', null, null, prNumber, {
837
+ platform: context.platform, capabilities: context.capabilities, resource: { kind: 'pull-request', number: prNumber }, error: fetched.error
710
838
  });
711
- if (!fetched.ok || !fetched.value) {
712
- const error = fetched.error || {
713
- code: 'PR_INSPECTION_INVALID',
714
- message: 'Platform adapter returned no change-request snapshot',
715
- retryable: false
716
- };
717
- return result(error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
718
- platform: base.context.platform, capabilities: base.context.capabilities,
719
- resource: { kind: 'pull-request', number: base.prNumber }, error
720
- });
721
- }
722
- return result('no-op', base.resolved.taskId, base.issueNumber, base.prNumber, {
723
- platform: base.context.platform, capabilities: base.context.capabilities,
724
- resource: { kind: 'pull-request', number: base.prNumber }, pullRequest: fetched.value, error: null
839
+ const files = inspectPullRequestFiles(client, repository, prNumber, cwd);
840
+ if (!files.ok) return result(files.error.retryable ? 'blocked' : 'failed', null, null, prNumber, {
841
+ platform: context.platform, capabilities: context.capabilities, pullRequest: fetched.value,
842
+ resource: { kind: 'pull-request', number: prNumber }, error: files.error
843
+ });
844
+ const mapping = readInLabelMapping(cwd);
845
+ if (!mapping.ok) return result('failed', null, null, prNumber, {
846
+ platform: context.platform, capabilities: context.capabilities, pullRequest: fetched.value,
847
+ resource: { kind: 'pull-request', number: prNumber }, error: mapping.error
848
+ });
849
+ const repositoryLabels = inspectRepositoryLabels(client, repository, cwd);
850
+ if (!repositoryLabels.ok) return result(repositoryLabels.error.retryable ? 'blocked' : 'failed', null, null, prNumber, {
851
+ platform: context.platform, capabilities: context.capabilities, pullRequest: fetched.value,
852
+ resource: { kind: 'pull-request', number: prNumber }, error: repositoryLabels.error
853
+ });
854
+ const plannedTarget = planInLabelUpdate({
855
+ changedFiles: files.value, currentLabels: fetched.value.labels,
856
+ mapping: mapping.value, repositoryLabels: repositoryLabels.value
857
+ });
858
+ if (plannedTarget.error) return result('failed', null, null, prNumber, {
859
+ platform: context.platform, capabilities: context.capabilities, pullRequest: fetched.value,
860
+ resource: { kind: 'pull-request', number: prNumber }, error: plannedTarget.error
861
+ });
862
+ const target = plannedTarget.target;
863
+ const association = inspectClosingIssueNumbers(client, repository, prNumber, cwd);
864
+ if (!association.ok) return result(association.error.retryable ? 'blocked' : 'failed', null, null, prNumber, {
865
+ platform: context.platform, capabilities: context.capabilities, pullRequest: fetched.value,
866
+ resource: { kind: 'pull-request', number: prNumber }, evidence: { kind: 'pull-request-files', pullRequestFiles: files.value }, error: association.error
725
867
  });
726
- }
727
868
 
728
- function inspectPlatformPullRequestByNumber(prNumber: number, options: InspectionOptions = {}): PullRequestResult {
729
- const client = (options.client as GitHubClient | undefined) || createGitHubClient();
730
- const context = resolvePlatformContext({ cwd: options.cwd || process.cwd(), client });
731
- const usable = (context.status === 'no-op' || context.status === 'degraded') && context.platform.repository;
732
- if (!usable) {
733
- return result(context.status, null, null, null, {
734
- platform: context.platform, capabilities: context.capabilities, operations: context.operations, error: context.error
869
+ const issueNumber = association.value.length === 1 ? association.value[0]! : null;
870
+ let issue: IssueSnapshot | null = null;
871
+ if (issueNumber) {
872
+ const fetchedIssue = inspectGitHubIssue(client, repository, issueNumber, cwd);
873
+ if (!fetchedIssue.ok) return result(fetchedIssue.error.retryable ? 'blocked' : 'failed', null, issueNumber, prNumber, {
874
+ platform: context.platform, capabilities: context.capabilities, pullRequest: fetched.value,
875
+ resource: { kind: 'issue', number: issueNumber }, evidence: { kind: 'pull-request-files', pullRequestFiles: files.value, closingIssues: association.value }, error: fetchedIssue.error
735
876
  });
877
+ issue = fetchedIssue.value;
736
878
  }
737
- if (!Number.isInteger(prNumber) || prNumber <= 0) {
738
- return result('failed', null, null, null, {
739
- platform: context.platform, capabilities: context.capabilities,
740
- error: { code: 'PR_NUMBER_INVALID', message: 'PR number must be positive', retryable: false }
879
+
880
+ const resources = issue
881
+ ? [
882
+ { kind: 'issue' as const, number: issue.number, snapshot: issue },
883
+ { kind: 'pull-request' as const, number: prNumber, snapshot: fetched.value }
884
+ ]
885
+ : [{ kind: 'pull-request' as const, number: prNumber, snapshot: fetched.value }];
886
+ const plans = resources.map((resource) => ({
887
+ ...resource,
888
+ plan: planInLabelUpdate({
889
+ changedFiles: files.value, currentLabels: resource.snapshot.labels,
890
+ mapping: mapping.value, repositoryLabels: repositoryLabels.value
891
+ })
892
+ }));
893
+ const operations: PlatformOperation[] = [
894
+ { name: 'closing-issues', status: 'no-op', reasonCode: association.value.length === 1 ? null : association.value.length === 0 ? 'ISSUE_ASSOCIATION_NONE' : 'ISSUE_ASSOCIATION_AMBIGUOUS' },
895
+ ...plans.map((item) => ({
896
+ name: `labels:in:${item.kind}`,
897
+ status: !context.capabilities.triage ? 'skipped' as const : item.plan.changed ? 'planned' as const : 'no-op' as const,
898
+ reasonCode: !context.capabilities.triage ? 'TRIAGE_REQUIRED' : null
899
+ }))
900
+ ];
901
+ const willWrite = context.capabilities.triage && !options.dryRun;
902
+ const resourcesState = plans.map((item) => inLabelResource(
903
+ item.kind,
904
+ item.number,
905
+ item.snapshot.labels,
906
+ target,
907
+ item.plan.changed && willWrite ? null : item.snapshot.labels,
908
+ item.plan.changed && willWrite ? 'unknown' : 'no-op'
909
+ ));
910
+ if (options.dryRun || !context.capabilities.triage) return result(
911
+ !context.capabilities.triage || association.value.length !== 1 ? 'degraded' : plans.some((item) => item.plan.changed) ? 'planned' : 'no-op',
912
+ null, issueNumber, prNumber, {
913
+ platform: context.platform, capabilities: context.capabilities, pullRequest: fetched.value,
914
+ resource: { kind: 'pull-request', number: prNumber }, operations, resources: resourcesState,
915
+ evidence: { kind: 'pull-request-files', pullRequestFiles: files.value, closingIssues: association.value }, error: null
916
+ }
917
+ );
918
+
919
+ let currentPullRequest = fetched.value;
920
+ let successfulWrites = 0;
921
+ for (const item of plans) {
922
+ if (!item.plan.changed) continue;
923
+ const synced = syncLabelDelta(client, repository, item.number, cwd, item.snapshot.labels, item.plan.target);
924
+ if (synced.status === 'failed' || synced.status === 'blocked') {
925
+ const state = resourcesState.find((resource) => resource.kind === item.kind && resource.number === item.number)!;
926
+ state.effect = synced.status === 'blocked' ? 'unknown' : 'no-op';
927
+ state.after = synced.status === 'blocked' ? null : item.snapshot.labels;
928
+ const partial = successfulWrites > 0 || synced.status === 'blocked';
929
+ const error = partial && synced.error
930
+ ? { ...synced.error, code: 'IN_LABEL_SYNC_PARTIAL', message: `In-label synchronization is partial or unknown: ${synced.error.message}` }
931
+ : synced.error;
932
+ return result(partial ? 'blocked' : synced.status, null, issueNumber, prNumber, {
933
+ changed: successfulWrites > 0 || synced.changed, platform: context.platform, capabilities: context.capabilities,
934
+ pullRequest: currentPullRequest, resource: { kind: 'pull-request', number: prNumber }, operations, resources: resourcesState,
935
+ evidence: { kind: 'pull-request-files', pullRequestFiles: files.value, closingIssues: association.value }, error
936
+ });
937
+ }
938
+ successfulWrites += synced.changed ? 1 : 0;
939
+ const reread = item.kind === 'issue'
940
+ ? inspectGitHubIssue(client, repository, item.number, cwd)
941
+ : inspectGitHubPullRequest(client, repository, item.number, cwd);
942
+ if (!reread.ok) return result('blocked', null, issueNumber, prNumber, {
943
+ changed: true, platform: context.platform, capabilities: context.capabilities,
944
+ pullRequest: currentPullRequest, resource: { kind: 'pull-request', number: prNumber }, operations, resources: resourcesState,
945
+ evidence: { kind: 'pull-request-files', pullRequestFiles: files.value, closingIssues: association.value },
946
+ error: { ...reread.error, code: 'IN_LABEL_SYNC_PARTIAL', message: `In-label synchronization is partial or unknown: ${reread.error.message}` }
741
947
  });
742
- }
743
- const fetched = inspectPlatformChangeRequest(context.platform.type, {
744
- cwd: options.cwd || process.cwd(),
745
- repository: context.platform.repository!,
746
- number: prNumber,
747
- client
748
- });
749
- if (!fetched.ok || !fetched.value) {
750
- const error = fetched.error || {
751
- code: 'PR_INSPECTION_INVALID',
752
- message: 'Platform adapter returned no change-request snapshot',
753
- retryable: false
754
- };
755
- return result(error.retryable ? 'blocked' : 'failed', null, null, prNumber, {
756
- platform: context.platform, capabilities: context.capabilities,
757
- resource: { kind: 'pull-request', number: prNumber }, error
948
+ const after = reread.value.labels;
949
+ const state = resourcesState.find((resource) => resource.kind === item.kind && resource.number === item.number)!;
950
+ state.after = after;
951
+ state.effect = after.filter((label: string) => label.startsWith('in:')).sort().join('\0') === target.join('\0') ? 'applied' : 'unknown';
952
+ if (state.effect === 'unknown') return result('blocked', null, issueNumber, prNumber, {
953
+ changed: true, platform: context.platform, capabilities: context.capabilities,
954
+ pullRequest: currentPullRequest, resource: { kind: 'pull-request', number: prNumber }, operations, resources: resourcesState,
955
+ evidence: { kind: 'pull-request-files', pullRequestFiles: files.value, closingIssues: association.value },
956
+ error: { code: 'IN_LABEL_SYNC_PARTIAL', message: `${item.kind} #${item.number} did not converge to the expected in: labels`, retryable: true }
758
957
  });
958
+ if (item.kind === 'pull-request') currentPullRequest = reread.value as PullRequestSnapshot;
759
959
  }
760
- return result('no-op', null, null, prNumber, {
761
- platform: context.platform, capabilities: context.capabilities,
762
- resource: { kind: 'pull-request', number: prNumber }, pullRequest: fetched.value, error: null
960
+ return result(association.value.length !== 1 ? 'degraded' : successfulWrites > 0 ? 'applied' : 'no-op', null, issueNumber, prNumber, {
961
+ changed: successfulWrites > 0, platform: context.platform, capabilities: context.capabilities,
962
+ pullRequest: currentPullRequest, resource: { kind: 'pull-request', number: prNumber },
963
+ operations: operations.map((operation) => operation.status === 'planned' ? { ...operation, status: 'applied' as const } : operation),
964
+ resources: resourcesState, evidence: { kind: 'pull-request-files', pullRequestFiles: files.value, closingIssues: association.value }, error: null
763
965
  });
764
966
  }
765
967
 
@@ -770,9 +972,8 @@ function appendActivity(content: string, line: string): string {
770
972
 
771
973
  function deliveryIdentity(pullRequest: PullRequestSnapshot) {
772
974
  return {
975
+ resource: pullRequestIdentity(pullRequest),
773
976
  repository: pullRequest.repository,
774
- number: pullRequest.number,
775
- nodeId: pullRequest.nodeId,
776
977
  url: pullRequest.url,
777
978
  head: { ...pullRequest.head },
778
979
  base: { ...pullRequest.base }
@@ -786,13 +987,13 @@ function factTimestamp(timestamp: string): string {
786
987
  function boundFactFor(
787
988
  pullRequest: PullRequestSnapshot,
788
989
  source: PrDeliveryBindingSource,
789
- issueNumber: number | null,
990
+ issue: number | ResourceIdentity | null,
790
991
  verifiedAt: string
791
992
  ): PrDeliveryFact {
792
993
  return buildBoundFact({
793
994
  identity: deliveryIdentity(pullRequest),
794
995
  source,
795
- issueNumber,
996
+ ...(typeof issue === 'object' ? { issueIdentity: issue } : { issueNumber: issue }),
796
997
  verifiedAt: factTimestamp(verifiedAt),
797
998
  remoteState: pullRequest.state,
798
999
  mergedAt: pullRequest.state === 'closed' ? pullRequest.mergedAt : null,
@@ -800,7 +1001,7 @@ function boundFactFor(
800
1001
  });
801
1002
  }
802
1003
 
803
- function writeCreateStarted(base: ReturnType<typeof resolvedContext> & { ok: true }, agent: string) {
1004
+ function writeCreateStarted(base: Awaited<ReturnType<typeof resolvedContext>> & { ok: true }, agent: string) {
804
1005
  const starts = (base.content.match(/\*\*Create PR \[started\]\*\*/g) || []).length;
805
1006
  const dones = (base.content.match(/\*\*Create PR\*\*/g) || []).length;
806
1007
  if (starts > dones) return { status: 'no-op' as const };
@@ -815,7 +1016,7 @@ function writeCreateStarted(base: ReturnType<typeof resolvedContext> & { ok: tru
815
1016
  }
816
1017
 
817
1018
  function bindIdentity(
818
- base: ReturnType<typeof resolvedContext> & { ok: true },
1019
+ base: Awaited<ReturnType<typeof resolvedContext>> & { ok: true },
819
1020
  pullRequest: PullRequestSnapshot,
820
1021
  agent: string,
821
1022
  source: PrDeliveryBindingSource,
@@ -829,10 +1030,10 @@ function bindIdentity(
829
1030
  errorCode: 'PR_BIND_IDENTITY_MISMATCH'
830
1031
  });
831
1032
  if (!identity.ok) return { status: 'failed' as const, error: identity.error };
832
- if (base.prNumber && base.prNumber !== pullRequest.number) return { status: 'failed' as const, error: {
833
- code: 'PR_BIND_CONFLICT', message: `Task is already bound to PR #${base.prNumber}`
1033
+ if (base.prIdentity && !resourceIdentityEquals(base.prIdentity, pullRequestIdentity(pullRequest))) return { status: 'failed' as const, error: {
1034
+ code: 'PR_BIND_CONFLICT', message: 'Task is already bound to a different pull request identity'
834
1035
  } };
835
- if (base.prNumber === pullRequest.number && base.fact?.state === 'bound') {
1036
+ if (base.prIdentity && resourceIdentityEquals(base.prIdentity, pullRequestIdentity(pullRequest)) && base.fact?.state === 'bound') {
836
1037
  if (JSON.stringify(base.fact.identity) !== JSON.stringify(deliveryIdentity(pullRequest))) return { status: 'failed' as const, error: {
837
1038
  code: 'PR_BIND_IDENTITY_MISMATCH', message: `Task binding for PR #${pullRequest.number} does not match the fetched pull request identity`
838
1039
  } };
@@ -840,7 +1041,7 @@ function bindIdentity(
840
1041
  }
841
1042
  const current = fs.readFileSync(base.resolved.taskMdPath, 'utf8');
842
1043
  const metadata = captureTaskWriteMetadata();
843
- const fact = boundFactFor(pullRequest, source, base.issueNumber, metadata.timestamp);
1044
+ const fact = boundFactFor(pullRequest, source, taskIssueIdentity(base.frontmatter), metadata.timestamp);
844
1045
  const factMutation = factFrontmatterMutation(fact);
845
1046
  return writeTask({
846
1047
  taskRef: base.resolved.taskId,
@@ -855,64 +1056,59 @@ function bindIdentity(
855
1056
  }, { repoRoot: base.resolved.repoRoot, metadataProvider: () => metadata });
856
1057
  }
857
1058
 
858
- function bindPlatformPullRequest(taskRef: string, options: BindOptions): PullRequestResult {
859
- const base = resolvedContext(taskRef, options);
1059
+ async function bindPlatformPullRequest(taskRef: string, options: BindOptions): Promise<PullRequestResult> {
1060
+ const base = await resolvedContext(taskRef, options);
860
1061
  if (!base.ok) return base.output;
861
1062
  if (!base.fact) return result('failed', base.resolved.taskId, base.issueNumber, null, {
862
1063
  error: { code: 'PR_DELIVERY_FACT_MISSING', message: 'Task has no pr_delivery_fact; repair the task metadata or create a new task', retryable: false }
863
1064
  });
864
- if (!Number.isInteger(options.pr) || options.pr <= 0) return result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
865
- error: { code: 'PR_NUMBER_INVALID', message: 'PR number must be positive', retryable: false }
866
- });
867
- if (base.prNumber && base.prNumber !== options.pr) return result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
868
- error: { code: 'PR_BIND_CONFLICT', message: `Task is already bound to PR #${base.prNumber}`, retryable: false }
1065
+ let prIdentity: ResourceIdentity;
1066
+ try { prIdentity = providerResourceToken(base.provider, 'pull-request', String(options.pr)); }
1067
+ catch (error) { return result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, { error: { code: 'PLATFORM_IDENTITY_TOKEN_INVALID', message: error instanceof Error ? error.message : String(error), retryable: false } }); }
1068
+ const requestedNumber = resourceIdentityNumber(prIdentity);
1069
+ if (base.prIdentity && !resourceIdentityEquals(base.prIdentity, prIdentity)) return result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
1070
+ error: { code: 'PR_BIND_CONFLICT', message: 'Task is already bound to a different pull request identity', retryable: false }
869
1071
  });
870
- const fetched = inspectGitHubPullRequest(base.client, base.context.platform.repository!, options.pr, base.resolved.repoRoot);
871
- if (!fetched.ok) return result(fetched.error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, base.prNumber, { error: fetched.error });
872
- const initialIdentity = validateWriterIdentity(base, fetched.value, {
1072
+ const inspected = await inspectExternalPullRequest(base, prIdentity);
1073
+ if (inspected.status === 'failed' || inspected.status === 'blocked' || !inspected.pullRequest) return inspected;
1074
+ const initialIdentity = validateWriterIdentity(base, inspected.pullRequest, {
873
1075
  expectedHead: String(base.frontmatter.branch || ''),
874
1076
  errorCode: 'PR_BIND_IDENTITY_MISMATCH'
875
1077
  });
876
- if (!initialIdentity.ok) return result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
877
- pullRequest: fetched.value, error: initialIdentity.error
878
- });
879
- const rechecked = inspectGitHubPullRequest(base.client, base.context.platform.repository!, options.pr, base.resolved.repoRoot);
880
- if (!rechecked.ok || !rechecked.value) return result(rechecked.ok ? 'failed' : rechecked.error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
881
- pullRequest: fetched.value,
882
- error: rechecked.ok ? { code: 'PR_BIND_RECHECK_FAILED', message: 'Pull request recheck returned no identity', retryable: false } : rechecked.error
1078
+ if (!initialIdentity.ok) return result('failed', base.resolved.taskId, base.issueNumber, requestedNumber, {
1079
+ pullRequest: inspected.pullRequest, error: initialIdentity.error
883
1080
  });
884
- if (!sameDeliveryIdentity(fetched.value, rechecked.value)) return result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
885
- pullRequest: rechecked.value,
886
- error: { code: 'PR_BIND_IDENTITY_MISMATCH', message: 'Pull request identity changed before task binding', retryable: false }
1081
+ const written = bindIdentity(base, inspected.pullRequest, options.agent, 'explicit-bind', options.dryRun);
1082
+ if (written.status === 'failed') return result('failed', base.resolved.taskId, base.issueNumber, requestedNumber, {
1083
+ pullRequest: inspected.pullRequest, error: { code: written.error.code, message: written.error.message, retryable: false }
887
1084
  });
888
- const written = bindIdentity(base, rechecked.value, options.agent, 'explicit-bind', options.dryRun);
889
- if (written.status === 'failed') return result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
890
- pullRequest: fetched.value, error: { code: written.error.code, message: written.error.message, retryable: false }
891
- });
892
- return result(options.dryRun ? 'planned' : written.status === 'no-op' ? 'no-op' : 'applied', base.resolved.taskId, base.issueNumber, options.dryRun ? base.prNumber : rechecked.value.number, {
1085
+ return result(options.dryRun ? 'planned' : written.status === 'no-op' ? 'no-op' : 'applied', base.resolved.taskId, base.issueNumber, options.dryRun ? base.prNumber : inspected.pullRequest.number, {
893
1086
  changed: !options.dryRun && written.status === 'applied', platform: base.context.platform, capabilities: base.context.capabilities,
894
- resource: { kind: 'pull-request', number: rechecked.value.number }, pullRequest: rechecked.value,
1087
+ resource: { kind: 'pull-request', number: inspected.pullRequest.number, identity: pullRequestIdentity(inspected.pullRequest) }, pullRequest: inspected.pullRequest,
895
1088
  operations: [{ name: 'task:bind-pr', status: options.dryRun ? 'planned' : written.status === 'no-op' ? 'no-op' : 'applied', reasonCode: null }], error: null
896
1089
  });
897
1090
  }
898
1091
 
899
1092
  function externalEvidenceNote(
900
- issueNumber: number,
1093
+ issueIdentity: ResourceIdentity | null,
901
1094
  source: 'unique' | 'explicit',
902
1095
  pullRequest: PullRequestSnapshot
903
1096
  ): string {
904
1097
  return [
905
1098
  `authorization=${source}`,
906
- externalIdentityEvidenceNote(issueNumber, pullRequest)
1099
+ externalIdentityEvidenceNote(issueIdentity, pullRequest)
907
1100
  ].join('; ');
908
1101
  }
909
1102
 
910
1103
  function externalIdentityEvidenceNote(
911
- issueNumber: number,
1104
+ issueIdentity: ResourceIdentity | null,
912
1105
  pullRequest: PullRequestSnapshot
913
1106
  ): string {
1107
+ const issueLabel = issueIdentity
1108
+ ? issueIdentity.kind === 'number' ? `#${issueIdentity.value}` : `${issueIdentity.kind}:${issueIdentity.value}`
1109
+ : 'unknown';
914
1110
  return [
915
- `issue=#${issueNumber}`,
1111
+ `issue=${issueLabel}`,
916
1112
  `pr=#${pullRequest.number}`,
917
1113
  `url=${pullRequest.url}`,
918
1114
  `base=${pullRequest.base.repository}:${pullRequest.base.ref}@${pullRequest.base.sha}`,
@@ -922,8 +1118,8 @@ function externalIdentityEvidenceNote(
922
1118
  ].join('; ');
923
1119
  }
924
1120
 
925
- function resolveExternalPullRequest(taskRef: string, options: ResolveExternalOptions): ExternalPullRequestResult {
926
- const base = resolvedContext(taskRef, options);
1121
+ async function resolveExternalPullRequest(taskRef: string, options: ResolveExternalOptions): Promise<ExternalPullRequestResult> {
1122
+ const base = await resolvedContext(taskRef, options);
927
1123
  if (!base.ok) return externalResult(base.output);
928
1124
  if (!base.fact) return externalResult(result('failed', base.resolved.taskId, base.issueNumber, null, {
929
1125
  error: { code: 'PR_DELIVERY_FACT_MISSING', message: 'Task has no pr_delivery_fact; repair the task metadata or create a new task', retryable: false }
@@ -947,28 +1143,41 @@ function resolveExternalPullRequest(taskRef: string, options: ResolveExternalOpt
947
1143
  error: null
948
1144
  }), { mode: 'normal' });
949
1145
  }
950
- if (!base.issueNumber) return externalResult(result('failed', base.resolved.taskId, null, base.prNumber, {
1146
+ if (!base.issueIdentity) return externalResult(result('failed', base.resolved.taskId, null, base.prNumber, {
951
1147
  platform: base.context.platform,
952
1148
  capabilities: base.context.capabilities,
953
1149
  error: { code: 'EXTERNAL_DELIVERY_ISSUE_REQUIRED', message: 'External delivery requires a valid issue_number', retryable: false }
954
1150
  }));
955
- const inspected = inspectPlatformIssueClosingChangeRequests(base.context.platform.type, {
956
- cwd: base.resolved.repoRoot,
957
- repository: base.context.platform.repository!,
958
- issueNumber: base.issueNumber,
959
- client: base.client
960
- });
1151
+ const inspected = base.provider.changeRequests?.listClosing
1152
+ ? await base.provider.changeRequests.listClosing({
1153
+ context: providerOperationContext(base.loadedContext),
1154
+ issue: base.issueIdentity
1155
+ })
1156
+ : unsupportedProviderOperation(base.provider, 'changeRequests.listClosing');
961
1157
  if (!inspected.ok || !inspected.value) {
962
- const error = inspected.error || { code: 'PR_IDENTITY_INVALID', message: 'Closing PR inspection returned no candidates', retryable: false };
1158
+ const error = 'error' in inspected && inspected.error
1159
+ ? inspected.error
1160
+ : { code: 'PR_IDENTITY_INVALID', message: 'Closing PR inspection returned no candidates', retryable: false };
963
1161
  return externalResult(result(error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
964
1162
  platform: base.context.platform, capabilities: base.context.capabilities, error
965
1163
  }));
966
1164
  }
1165
+ const candidates = (inspected.value as ProviderChangeRequestSnapshot[]).map((item) => normalizeProviderPullRequest(item, base.context.platform.repository!, item.number || 0));
1166
+ let explicitPrIdentity: ResourceIdentity | null = null;
1167
+ if (options.pr !== undefined) {
1168
+ try { explicitPrIdentity = providerResourceToken(base.provider, 'pull-request', String(options.pr)); }
1169
+ catch (error) {
1170
+ return externalResult(result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
1171
+ platform: base.context.platform, capabilities: base.context.capabilities,
1172
+ error: { code: 'PLATFORM_IDENTITY_TOKEN_INVALID', message: error instanceof Error ? error.message : String(error), retryable: false }
1173
+ }));
1174
+ }
1175
+ }
967
1176
  const selected = selectExternalPullRequest(
968
- inspected.value,
1177
+ candidates,
969
1178
  base.context.platform.repository!,
970
- base.prNumber,
971
- options.pr ?? null
1179
+ base.prIdentity,
1180
+ explicitPrIdentity
972
1181
  );
973
1182
  if (selected.status === 'normal') return externalResult(result('no-op', base.resolved.taskId, base.issueNumber, base.prNumber, {
974
1183
  platform: base.context.platform,
@@ -987,7 +1196,14 @@ function resolveExternalPullRequest(taskRef: string, options: ResolveExternalOpt
987
1196
  platform: base.context.platform, capabilities: base.context.capabilities,
988
1197
  pullRequest: selected.selected, error: initialIdentity.error
989
1198
  }), { candidates: selected.candidates, eligible: selected.eligible, selected: selected.selected });
990
- const rechecked = inspectGitHubPullRequest(base.client, base.context.platform.repository!, selected.selected.number, base.resolved.repoRoot);
1199
+ const rechecked = base.provider.changeRequests?.inspect
1200
+ ? await base.provider.changeRequests.inspect({
1201
+ context: providerOperationContext(base.loadedContext),
1202
+ target: pullRequestIdentity(selected.selected)
1203
+ }).then((response) => response.ok
1204
+ ? { ok: true as const, value: normalizeProviderPullRequest(response.value, base.context.platform.repository!, selected.selected.number, selected.selected) }
1205
+ : response)
1206
+ : unsupportedProviderOperation(base.provider, 'changeRequests.inspect');
991
1207
  if (!rechecked.ok || !rechecked.value) return externalResult(result(rechecked.ok ? 'failed' : rechecked.error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
992
1208
  platform: base.context.platform, capabilities: base.context.capabilities,
993
1209
  pullRequest: selected.selected,
@@ -1009,8 +1225,8 @@ function resolveExternalPullRequest(taskRef: string, options: ResolveExternalOpt
1009
1225
  pullRequest: rechecked.value, error: finalEvidence.error
1010
1226
  }), { candidates: selected.candidates, eligible: selected.eligible, selected: rechecked.value });
1011
1227
  const selectedPullRequest = rechecked.value;
1012
- const note = externalEvidenceNote(base.issueNumber, selected.source, selectedPullRequest);
1013
- const identityNote = externalIdentityEvidenceNote(base.issueNumber, selectedPullRequest);
1228
+ const note = externalEvidenceNote(base.issueIdentity, selected.source, selectedPullRequest);
1229
+ const identityNote = externalIdentityEvidenceNote(base.issueIdentity, selectedPullRequest);
1014
1230
  const activityEntry = `**Bind External PR** by ${options.agent} — ${note}`;
1015
1231
  const auditLines = base.content.split(/\r?\n/).filter((line) => line.includes('**Bind External PR**'));
1016
1232
  const alreadyAudited = auditLines.some((line) => line.endsWith(identityNote));
@@ -1020,13 +1236,13 @@ function resolveExternalPullRequest(taskRef: string, options: ResolveExternalOpt
1020
1236
  pullRequest: selectedPullRequest,
1021
1237
  error: { code: 'PR_BIND_CONFLICT', message: 'Existing external PR audit evidence conflicts with the selected identity', retryable: false }
1022
1238
  }), { candidates: selected.candidates, eligible: selected.eligible, selected: selectedPullRequest });
1023
- if (alreadyAudited && base.prNumber !== selectedPullRequest.number) return externalResult(result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
1239
+ if (alreadyAudited && base.prIdentity && !resourceIdentityEquals(base.prIdentity, pullRequestIdentity(selectedPullRequest))) return externalResult(result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
1024
1240
  platform: base.context.platform,
1025
1241
  capabilities: base.context.capabilities,
1026
1242
  pullRequest: selectedPullRequest,
1027
1243
  error: { code: 'PR_BIND_CONFLICT', message: 'External PR audit evidence does not match the task binding', retryable: false }
1028
1244
  }), { candidates: selected.candidates, eligible: selected.eligible, selected: selectedPullRequest });
1029
- if (base.prNumber === selectedPullRequest.number && alreadyAudited) {
1245
+ if (base.prIdentity && resourceIdentityEquals(base.prIdentity, pullRequestIdentity(selectedPullRequest)) && alreadyAudited) {
1030
1246
  return externalResult(result('no-op', base.resolved.taskId, base.issueNumber, base.prNumber, {
1031
1247
  platform: base.context.platform,
1032
1248
  capabilities: base.context.capabilities,
@@ -1066,167 +1282,20 @@ function resolveExternalPullRequest(taskRef: string, options: ResolveExternalOpt
1066
1282
  }), { mode: 'external', authorization: selected.source, candidates: selected.candidates, eligible: selected.eligible, selected: selectedPullRequest });
1067
1283
  }
1068
1284
 
1069
- function createPlatformPullRequestUnlocked(taskRef: string, options: CreateOptions): PullRequestResult {
1070
- const base = resolvedContext(taskRef, options);
1071
- if (!base.ok) return withCreation(base.output, PRECONDITION_NOT_CREATED);
1072
- if (!base.fact) return withCreation(result('failed', base.resolved.taskId, base.issueNumber, null, {
1073
- error: { code: 'PR_DELIVERY_FACT_MISSING', message: 'Task has no pr_delivery_fact; repair the task metadata or create a new task', retryable: false }
1074
- }), PRECONDITION_NOT_CREATED);
1075
- if (!options.base || !options.head || !options.title.trim() || !options.body.trim()) return withCreation(result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
1076
- error: { code: 'PR_PAYLOAD_INVALID', message: 'base, head, title and body are required', retryable: false }
1077
- }), PRECONDITION_NOT_CREATED);
1078
- const target = taskDeliveryTarget(base);
1079
- if (!target.ok) return withCreation(result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
1080
- error: target.error
1081
- }), PRECONDITION_NOT_CREATED);
1082
- if (options.base !== target.value.baseRef) return withCreation(result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
1083
- platform: base.context.platform, capabilities: base.context.capabilities,
1084
- error: { code: 'PR_DELIVERY_TARGET_MISMATCH', message: `Pull request base '${options.base}' does not match delivery target '${target.value.baseRef}'`, retryable: false }
1085
- }), PRECONDITION_NOT_CREATED);
1086
- if (base.prNumber) {
1087
- const headCheck = verifyCreateHead(base, options.head);
1088
- if (!headCheck.ok) return withCreation(result(headCheck.error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
1089
- platform: base.context.platform,
1090
- capabilities: base.context.capabilities,
1091
- error: headCheck.error
1092
- }), PRECONDITION_NOT_CREATED);
1093
- const inspected = inspectPlatformPullRequest(taskRef, options);
1094
- const identity = validateBoundPullRequest(base, inspected.pullRequest, options.head, options.base, headCheck.value);
1095
- if (!identity.ok) return withCreation(result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
1096
- platform: base.context.platform,
1097
- capabilities: base.context.capabilities,
1098
- pullRequest: inspected.pullRequest,
1099
- error: identity.error
1100
- }), PRECONDITION_NOT_CREATED);
1101
- return withCreation({ ...inspected, result: 'no_op' }, { kind: 'no-op', createdByCurrentOperation: false });
1102
- }
1103
- const headCheck = verifyCreateHead(base, options.head);
1104
- if (!headCheck.ok) return withCreation(result(headCheck.error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, null, {
1105
- platform: base.context.platform,
1106
- capabilities: base.context.capabilities,
1107
- error: headCheck.error
1108
- }), PRECONDITION_NOT_CREATED);
1109
- const expectedHeadSha = headCheck.value;
1110
- const located = locatePullRequest(base, options.head, options.base, expectedHeadSha);
1111
- if (!located.ok && located.error.code === 'PR_IDENTITY_AMBIGUOUS') return withCreation(result('failed', base.resolved.taskId, base.issueNumber, null, {
1112
- platform: base.context.platform, capabilities: base.context.capabilities, error: located.error
1113
- }), PRECONDITION_NOT_CREATED);
1114
- if (!located.ok && located.error.code === 'PR_HEAD_SHA_MISMATCH') return withCreation(result('failed', base.resolved.taskId, base.issueNumber, null, {
1115
- platform: base.context.platform, capabilities: base.context.capabilities, error: located.error
1116
- }), PRECONDITION_NOT_CREATED);
1117
- if (!located.ok && located.error.code !== 'PR_NOT_FOUND') return withCreation(result(located.error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, null, {
1118
- platform: base.context.platform, capabilities: base.context.capabilities, error: located.error
1119
- }), PRECONDITION_NOT_CREATED);
1120
- if (options.dryRun) return withCreation(result('planned', base.resolved.taskId, base.issueNumber, null, {
1121
- platform: base.context.platform, capabilities: base.context.capabilities,
1122
- operations: [{ name: located.ok ? 'pr:reuse' : 'pr:create', status: 'planned', reasonCode: null }],
1123
- result: located.ok ? 'pr_reused' : 'pr_created', error: null
1124
- }), { kind: 'planned', action: located.ok ? 'reuse' : 'create', createdByCurrentOperation: false });
1125
- const started = writeCreateStarted(base, options.agent);
1126
- if (started.status === 'failed') return withCreation(result('failed', base.resolved.taskId, base.issueNumber, null, {
1127
- error: { code: started.error.code, message: started.error.message, retryable: false }
1128
- }), PRECONDITION_NOT_CREATED);
1129
- let pullRequest = located.ok ? located.value : null;
1130
- let created = false;
1131
- if (!pullRequest) {
1132
- const repository = base.context.platform.repository!;
1133
- if (options.phase) options.phase.value = 'post-dispatched';
1134
- const response = base.client.json<RemotePullRequest>(['api', `repos/${repository}/pulls`, '-X', 'POST', '--input', '-'], {
1135
- cwd: base.resolved.repoRoot,
1136
- method: 'POST',
1137
- input: JSON.stringify({ title: options.title, body: options.body, head: options.head, base: options.base, draft: Boolean(options.draft) })
1138
- });
1139
- if (response.ok) {
1140
- // The remote POST is accepted before any normalization, recheck, or task write.
1141
- // From this point on the current call has created a remote resource even if
1142
- // local post-processing later fails.
1143
- created = true;
1144
- if (options.phase) options.phase.value = 'post-accepted';
1145
- pullRequest = normalizePullRequest(response.value, repository);
1146
- } else if (response.error.retryable) {
1147
- return withCreation(result('blocked', base.resolved.taskId, base.issueNumber, null, {
1148
- platform: base.context.platform, capabilities: base.context.capabilities,
1149
- operations: [{ name: 'pr:create', status: 'failed', reasonCode: 'PR_CREATE_OUTCOME_UNKNOWN' }],
1150
- error: { code: 'PR_CREATE_OUTCOME_UNKNOWN', message: response.error.message, retryable: true }
1151
- }), { kind: 'unknown', errorCode: 'PR_CREATE_OUTCOME_UNKNOWN' });
1152
- } else return withCreation(result('failed', base.resolved.taskId, base.issueNumber, null, {
1153
- platform: base.context.platform, capabilities: base.context.capabilities,
1154
- operations: [{ name: 'pr:create', status: 'failed', reasonCode: response.error.code }], error: response.error
1155
- }), { kind: 'not-created', reason: 'post-rejected', createdByCurrentOperation: false });
1156
- if (!pullRequest) return withCreation(result('failed', base.resolved.taskId, base.issueNumber, null, {
1157
- error: { code: 'PR_CREATE_RESPONSE_INVALID', message: 'PR create response lacks validated identity', retryable: false }
1158
- }), { kind: 'created', createdByCurrentOperation: true });
1159
- if (pullRequest.head.sha !== expectedHeadSha) return withCreation(result('failed', base.resolved.taskId, base.issueNumber, pullRequest.number, {
1160
- platform: base.context.platform, capabilities: base.context.capabilities,
1161
- resource: { kind: 'pull-request', number: pullRequest.number }, pullRequest,
1162
- error: { code: 'PR_HEAD_SHA_MISMATCH', message: 'Created pull request head does not match the expected local HEAD', retryable: false }
1163
- }), { kind: 'created', createdByCurrentOperation: true });
1164
- }
1165
- const bindHeadCheck = verifyCreateHead(base, options.head);
1166
- if (!bindHeadCheck.ok || bindHeadCheck.value !== expectedHeadSha) {
1167
- const error = bindHeadCheck.ok
1168
- ? { code: 'PR_REMOTE_HEAD_MISMATCH', message: 'Remote head changed before task binding', retryable: false }
1169
- : bindHeadCheck.error;
1170
- return withCreation(result(error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, pullRequest.number, {
1171
- platform: base.context.platform, capabilities: base.context.capabilities,
1172
- resource: { kind: 'pull-request', number: pullRequest.number }, pullRequest, error
1173
- }), created ? { kind: 'created', createdByCurrentOperation: true } : { kind: 'not-created', reason: 'precondition-failed', createdByCurrentOperation: false });
1174
- }
1175
- const beforeBind = inspectGitHubPullRequest(base.client, base.context.platform.repository!, pullRequest.number, base.resolved.repoRoot);
1176
- if (!beforeBind.ok || !beforeBind.value) {
1177
- const error = beforeBind.ok
1178
- ? { code: 'PR_BIND_RECHECK_FAILED', message: 'Pull request recheck returned no identity', retryable: false }
1179
- : beforeBind.error;
1180
- return withCreation(result(error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, pullRequest.number, {
1181
- platform: base.context.platform, capabilities: base.context.capabilities,
1182
- resource: { kind: 'pull-request', number: pullRequest.number }, pullRequest, error
1183
- }), created ? { kind: 'created', createdByCurrentOperation: true } : { kind: 'not-created', reason: 'precondition-failed', createdByCurrentOperation: false });
1184
- }
1185
- if (
1186
- beforeBind.value.head.sha !== expectedHeadSha
1187
- || beforeBind.value.head.ref !== expectedHead(base.context.platform.repository!, options.head).ref
1188
- || beforeBind.value.base.ref !== options.base
1189
- || beforeBind.value.base.repository !== base.context.platform.repository
1190
- ) return withCreation(result('failed', base.resolved.taskId, base.issueNumber, pullRequest.number, {
1191
- platform: base.context.platform, capabilities: base.context.capabilities,
1192
- resource: { kind: 'pull-request', number: pullRequest.number }, pullRequest: beforeBind.value,
1193
- error: { code: 'PR_BIND_IDENTITY_MISMATCH', message: 'Pull request identity changed before task binding', retryable: false }
1194
- }), created ? { kind: 'created', createdByCurrentOperation: true } : { kind: 'not-created', reason: 'precondition-failed', createdByCurrentOperation: false });
1195
- pullRequest = beforeBind.value;
1196
- const refreshed = resolvedContext(taskRef, options);
1197
- if (!refreshed.ok) return withCreation(result('failed', base.resolved.taskId, base.issueNumber, null, {
1198
- pullRequest, error: { code: 'PR_CREATED_BIND_FAILED', message: pullRequest.url, retryable: false }
1199
- }), created ? { kind: 'created', createdByCurrentOperation: true } : { kind: 'not-created', reason: 'precondition-failed', createdByCurrentOperation: false });
1200
- const bound = bindIdentity(refreshed, pullRequest, options.agent, created ? 'created' : 'reused', false, options.head, expectedHeadSha);
1201
- if (bound.status === 'failed') return withCreation(result('failed', base.resolved.taskId, base.issueNumber, null, {
1202
- platform: base.context.platform, capabilities: base.context.capabilities,
1203
- resource: { kind: 'pull-request', number: pullRequest.number }, pullRequest,
1204
- operations: [{ name: created ? 'pr:create' : 'pr:reuse', status: 'applied', reasonCode: null }, { name: 'task:bind-pr', status: 'failed', reasonCode: bound.error.code }],
1205
- error: { code: 'PR_CREATED_BIND_FAILED', message: `${pullRequest.url}: ${bound.error.message}`, retryable: false }
1206
- }), created ? { kind: 'created', createdByCurrentOperation: true } : { kind: 'reused', createdByCurrentOperation: false });
1207
- return withCreation(result('applied', base.resolved.taskId, base.issueNumber, pullRequest.number, {
1208
- changed: true, platform: base.context.platform, capabilities: base.context.capabilities,
1209
- resource: { kind: 'pull-request', number: pullRequest.number }, pullRequest,
1210
- operations: [{ name: created ? 'pr:create' : 'pr:reuse', status: created ? 'applied' : 'no-op', reasonCode: null }, { name: 'task:bind-pr', status: 'applied', reasonCode: null }],
1211
- result: created ? 'pr_created' : 'pr_reused', error: null
1212
- }), created ? { kind: 'created', createdByCurrentOperation: true } : { kind: 'reused', createdByCurrentOperation: false });
1213
- }
1214
-
1215
- function createPlatformPullRequest(taskRef: string, options: CreateOptions): PullRequestResult {
1216
- const base = resolvedContext(taskRef, options);
1285
+ async function createPlatformPullRequest(taskRef: string, options: CreateOptions): Promise<PullRequestResult> {
1286
+ const base = await resolvedContext(taskRef, options);
1217
1287
  if (!base.ok) return withCreation(base.output, PRECONDITION_NOT_CREATED);
1218
1288
  if (!options.base || !options.head || !options.title.trim() || !options.body.trim()) {
1219
1289
  return withCreation(result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
1220
1290
  error: { code: 'PR_PAYLOAD_INVALID', message: 'base, head, title and body are required', retryable: false }
1221
1291
  }), PRECONDITION_NOT_CREATED);
1222
1292
  }
1223
- const phase = { value: 'before-post' as CreatePhase };
1224
1293
  try {
1225
1294
  return withTaskExecutionLock(
1226
1295
  base.resolved.repoRoot,
1227
1296
  base.resolved.taskId,
1228
1297
  'platform-pr.create',
1229
- () => createPlatformPullRequestUnlocked(taskRef, { ...options, phase })
1298
+ () => createExternalPullRequest(taskRef, options, base)
1230
1299
  );
1231
1300
  } catch (error) {
1232
1301
  if (error instanceof TaskExecutionLockError) {
@@ -1236,27 +1305,154 @@ function createPlatformPullRequest(taskRef: string, options: CreateOptions): Pul
1236
1305
  error: { code: error.code, message: error.message, retryable: true }
1237
1306
  }), PRECONDITION_NOT_CREATED);
1238
1307
  }
1239
- const creation: CreationOutcome = phase.value === 'post-accepted'
1240
- ? { kind: 'created', createdByCurrentOperation: true }
1241
- : phase.value === 'post-dispatched'
1242
- ? { kind: 'unknown', errorCode: 'PR_CREATE_OUTCOME_UNKNOWN' }
1243
- : PRECONDITION_NOT_CREATED;
1244
- return withCreation(result(phase.value === 'post-dispatched' ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, null, {
1308
+ return withCreation(result('failed', base.resolved.taskId, base.issueNumber, null, {
1245
1309
  platform: base.context.platform,
1246
1310
  capabilities: base.context.capabilities,
1247
1311
  error: {
1248
- code: phase.value === 'post-dispatched' ? 'PR_CREATE_OUTCOME_UNKNOWN' : 'PR_CREATE_FAILED',
1312
+ code: 'PR_CREATE_FAILED',
1249
1313
  message: error instanceof Error ? error.message : String(error),
1250
- retryable: phase.value === 'post-dispatched'
1314
+ retryable: false
1251
1315
  }
1252
- }), creation);
1316
+ }), PRECONDITION_NOT_CREATED);
1253
1317
  }
1254
1318
  }
1255
1319
 
1256
- function syncPlatformPullRequest(taskRef: string, options: SyncOptions): PullRequestResult {
1320
+ async function createExternalPullRequest(
1321
+ taskRef: string,
1322
+ options: CreateOptions,
1323
+ base: Extract<Awaited<ReturnType<typeof resolvedContext>>, { ok: true }>
1324
+ ): Promise<PullRequestResult> {
1325
+ if (!base.fact) return withCreation(result('failed', base.resolved.taskId, base.issueNumber, null, {
1326
+ error: { code: 'PR_DELIVERY_FACT_MISSING', message: 'Task has no pr_delivery_fact; repair the task metadata or create a new task', retryable: false }
1327
+ }), PRECONDITION_NOT_CREATED);
1328
+ const target = taskDeliveryTarget(base);
1329
+ if (!target.ok) return withCreation(result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
1330
+ error: target.error
1331
+ }), PRECONDITION_NOT_CREATED);
1332
+ if (options.base !== target.value.baseRef) return withCreation(result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
1333
+ platform: base.context.platform,
1334
+ capabilities: base.context.capabilities,
1335
+ error: { code: 'PR_DELIVERY_TARGET_MISMATCH', message: `Pull request base '${options.base}' does not match delivery target '${target.value.baseRef}'`, retryable: false }
1336
+ }), PRECONDITION_NOT_CREATED);
1337
+ let verifiedHeadSha: string | undefined;
1338
+ if (base.provider.changeRequests?.verifyHead) {
1339
+ const verified = await base.provider.changeRequests.verifyHead({
1340
+ context: providerOperationContext(base.loadedContext),
1341
+ head: options.head
1342
+ });
1343
+ if (!verified.ok) return withCreation(providerPullRequestError(base, verified.error, base.prNumber), PRECONDITION_NOT_CREATED);
1344
+ verifiedHeadSha = verified.value.sha;
1345
+ }
1346
+ if (base.prIdentity) {
1347
+ const inspected = await inspectExternalPullRequest(base, base.prIdentity!);
1348
+ return inspected.status === 'failed' || inspected.status === 'blocked'
1349
+ ? withCreation(inspected, PRECONDITION_NOT_CREATED)
1350
+ : withCreation({ ...inspected, result: 'no_op' }, { kind: 'no-op', createdByCurrentOperation: false });
1351
+ }
1352
+ if (options.dryRun) return withCreation(result('planned', base.resolved.taskId, base.issueNumber, null, {
1353
+ platform: base.context.platform,
1354
+ capabilities: base.context.capabilities,
1355
+ operations: [{ name: 'pr:create', status: 'planned', reasonCode: null }],
1356
+ result: 'pr_created',
1357
+ error: null
1358
+ }), { kind: 'planned', action: 'create', createdByCurrentOperation: false });
1359
+
1360
+ const started = writeCreateStarted(base, options.agent);
1361
+ if (started.status === 'failed') return withCreation(result('failed', base.resolved.taskId, base.issueNumber, null, {
1362
+ error: { code: started.error.code, message: started.error.message, retryable: false }
1363
+ }), PRECONDITION_NOT_CREATED);
1364
+ const created = base.provider.changeRequests?.create
1365
+ ? await base.provider.changeRequests.create({
1366
+ context: providerOperationContext(base.loadedContext),
1367
+ base: options.base,
1368
+ head: options.head,
1369
+ title: options.title,
1370
+ body: options.body,
1371
+ draft: Boolean(options.draft),
1372
+ mutation: { idempotencyKey: `pull-request:create:${base.resolved.taskId}` }
1373
+ })
1374
+ : unsupportedProviderOperation(base.provider, 'changeRequests.create');
1375
+ if (!created.ok) return withCreation(providerPullRequestError(base, created.error, null), { kind: 'unknown', errorCode: 'PR_CREATE_OUTCOME_UNKNOWN' });
1376
+ const remoteId = String(created.value.remoteId || '');
1377
+ let createdIdentity: ResourceIdentity;
1378
+ try { createdIdentity = providerResourceIdentity(base.provider, 'pull-request', remoteId); }
1379
+ catch (error) {
1380
+ return withCreation(result('failed', base.resolved.taskId, base.issueNumber, null, {
1381
+ platform: base.context.platform, capabilities: base.context.capabilities,
1382
+ error: { code: 'PR_CREATE_RESPONSE_INVALID', message: error instanceof Error ? error.message : String(error), retryable: false }
1383
+ }), { kind: 'created', createdByCurrentOperation: true });
1384
+ }
1385
+ const number = resourceIdentityNumber(createdIdentity);
1386
+ const fallbackNumber = number ?? 0;
1387
+ const fallback = normalizeProviderPullRequest({
1388
+ id: remoteId,
1389
+ identity: createdIdentity,
1390
+ number: number || undefined,
1391
+ state: 'open',
1392
+ title: options.title,
1393
+ body: options.body,
1394
+ headSha: '',
1395
+ baseSha: '',
1396
+ head: { repository: base.context.platform.repository!, ref: options.head, sha: '' },
1397
+ base: { repository: base.context.platform.repository!, ref: options.base, sha: '' },
1398
+ displayUrl: `${base.provider.type}:${remoteId}`
1399
+ }, base.context.platform.repository!, fallbackNumber);
1400
+ const inspected = base.provider.changeRequests?.inspect
1401
+ ? await base.provider.changeRequests.inspect({
1402
+ context: providerOperationContext(base.loadedContext),
1403
+ target: createdIdentity
1404
+ })
1405
+ : unsupportedProviderOperation(base.provider, 'changeRequests.inspect');
1406
+ if (!inspected.ok) return withCreation(providerPullRequestError(base, inspected.error, number), { kind: 'created', createdByCurrentOperation: true });
1407
+ const pullRequest = normalizeProviderPullRequest(
1408
+ inspected.value,
1409
+ base.context.platform.repository!,
1410
+ inspected.value.number ?? fallback.number,
1411
+ fallback
1412
+ );
1413
+ if (!pullRequest.nodeId || !isUsableResourceIdentity(pullRequestIdentity(pullRequest))) return withCreation(result('failed', base.resolved.taskId, base.issueNumber, null, {
1414
+ platform: base.context.platform,
1415
+ capabilities: base.context.capabilities,
1416
+ error: { code: 'PR_CREATE_RESPONSE_INVALID', message: 'Provider create response lacks validated identity', retryable: false }
1417
+ }), { kind: 'created', createdByCurrentOperation: true });
1418
+ const identity = validateWriterIdentity(base, pullRequest, {
1419
+ expectedHead: options.head,
1420
+ expectedHeadSha: verifiedHeadSha,
1421
+ errorCode: 'PR_BIND_IDENTITY_MISMATCH'
1422
+ });
1423
+ if (!identity.ok) return withCreation(result('failed', base.resolved.taskId, base.issueNumber, pullRequest.number, {
1424
+ platform: base.context.platform,
1425
+ capabilities: base.context.capabilities,
1426
+ resource: { kind: 'pull-request', number: pullRequest.number },
1427
+ pullRequest,
1428
+ error: identity.error
1429
+ }), { kind: 'created', createdByCurrentOperation: true });
1430
+ const bound = bindIdentity(base, pullRequest, options.agent, 'created', false, options.head);
1431
+ if (bound.status === 'failed') return withCreation(result('failed', base.resolved.taskId, base.issueNumber, pullRequest.number, {
1432
+ platform: base.context.platform,
1433
+ capabilities: base.context.capabilities,
1434
+ resource: { kind: 'pull-request', number: pullRequest.number },
1435
+ pullRequest,
1436
+ error: { code: 'PR_CREATED_BIND_FAILED', message: `${pullRequest.url}: ${bound.error.message}`, retryable: false }
1437
+ }), { kind: 'created', createdByCurrentOperation: true });
1438
+ return withCreation(result('applied', base.resolved.taskId, base.issueNumber, pullRequest.number, {
1439
+ changed: true,
1440
+ platform: base.context.platform,
1441
+ capabilities: base.context.capabilities,
1442
+ resource: { kind: 'pull-request', number: pullRequest.number },
1443
+ pullRequest,
1444
+ operations: [{ name: 'pr:create', status: 'applied', reasonCode: null }, { name: 'task:bind-pr', status: 'applied', reasonCode: null }],
1445
+ result: 'pr_created',
1446
+ error: null
1447
+ }), { kind: 'created', createdByCurrentOperation: true });
1448
+ }
1449
+
1450
+ async function syncPlatformPullRequest(taskRef: string, options: SyncOptions): Promise<PullRequestResult> {
1257
1451
  const warningResult = warningResultForPrimary(options.primaryResult);
1258
1452
  const softenFailure = (output: PullRequestResult): PullRequestResult => {
1453
+ const authorityError = output.error?.code.startsWith('IN_LABEL_SYNC') || output.error?.code.startsWith('PR_IDENTITY');
1259
1454
  const warning = output.error && output.task.prNumber !== null
1455
+ && !authorityError
1260
1456
  && !['PR_NOT_LINKED', 'PR_BIND_CONFLICT'].includes(output.error.code)
1261
1457
  ? {
1262
1458
  code: output.error.code,
@@ -1271,66 +1467,148 @@ function syncPlatformPullRequest(taskRef: string, options: SyncOptions): PullReq
1271
1467
  ? { ...output, status: 'applied', changed: false, error: null, result: warningResult, warnings: mergeOperationWarnings([warning]) }
1272
1468
  : output;
1273
1469
  };
1274
- const base = resolvedContext(taskRef, options);
1470
+ const base = await resolvedContext(taskRef, options);
1275
1471
  if (!base.ok) return softenFailure(base.output);
1276
1472
  if (!base.fact) return softenFailure(result('failed', base.resolved.taskId, base.issueNumber, null, {
1277
1473
  error: { code: 'PR_DELIVERY_FACT_MISSING', message: 'Task has no pr_delivery_fact; repair the task metadata or create a new task', retryable: false }
1278
1474
  }));
1279
- if (!base.prNumber) return softenFailure(result('failed', base.resolved.taskId, base.issueNumber, null, {
1475
+ if (!base.prIdentity) return softenFailure(result('failed', base.resolved.taskId, base.issueNumber, null, {
1280
1476
  error: { code: 'PR_NOT_LINKED', message: 'Task has no verified bound pull request', retryable: false }
1281
1477
  }));
1282
- const fetched = inspectGitHubPullRequest(base.client, base.context.platform.repository!, base.prNumber, base.resolved.repoRoot);
1283
- if (!fetched.ok) return softenFailure(result(fetched.error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, base.prNumber, { error: fetched.error }));
1284
- if (!options.metadata && !options.closingIssue) return softenFailure(result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
1285
- error: { code: 'PR_PAYLOAD_INVALID', message: 'sync requires a desired-state option', retryable: false }
1286
- }));
1287
- if (!base.issueNumber) return softenFailure(result('degraded', base.resolved.taskId, null, base.prNumber, {
1288
- pullRequest: fetched.value, error: { code: 'ISSUE_NOT_LINKED', message: 'Task has no linked Issue to copy metadata from', retryable: false }
1289
- }));
1290
- const issue = inspectPlatformIssue(taskRef, { cwd: base.resolved.repoRoot, client: base.client });
1291
- if (!issue.issue) return softenFailure(result(issue.status, base.resolved.taskId, base.issueNumber, base.prNumber, {
1292
- platform: base.context.platform, capabilities: base.context.capabilities, pullRequest: fetched.value, error: issue.error
1293
- }));
1294
- const planned = planPullRequestMetadata({
1295
- pullRequest: fetched.value,
1296
- issue: issue.issue,
1297
- taskType: String(base.frontmatter.type || 'task'),
1298
- issueNumber: base.issueNumber,
1299
- capabilities: base.context.capabilities
1300
- }).operations.filter((operation) => options.metadata || operation.name === 'closing-issue')
1301
- .filter((operation) => options.closingIssue || operation.name !== 'closing-issue');
1302
- const operations: PlatformOperation[] = planned.map(({ name, status, reasonCode }) => ({ name, status, reasonCode }));
1303
- if (options.dryRun) return softenFailure(result(planned.some((operation) => operation.status === 'planned') ? 'planned' : 'no-op', base.resolved.taskId, base.issueNumber, base.prNumber, {
1304
- platform: base.context.platform, capabilities: base.context.capabilities, pullRequest: fetched.value, operations, error: null
1305
- }));
1306
- const payload: Record<string, unknown> = {};
1307
- for (const operation of planned) {
1308
- if (operation.status !== 'planned') continue;
1309
- if (operation.name === 'labels') payload.labels = operation.value;
1310
- if (operation.name === 'assignees') payload.assignees = operation.value;
1311
- if (operation.name === 'closing-issue') payload.body = operation.value;
1312
- if (operation.name === 'milestone') {
1313
- const milestones = base.client.json<unknown[]>(['api', '--paginate', '--slurp', `repos/${base.context.platform.repository}/milestones?state=open&per_page=100`], { cwd: base.resolved.repoRoot });
1314
- if (!milestones.ok) return softenFailure(result(milestones.error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, base.prNumber, { pullRequest: fetched.value, operations, error: milestones.error }));
1315
- const flat = milestones.value.flatMap((entry) => Array.isArray(entry) ? entry : [entry]) as Array<{ title?: string; number?: number }>;
1316
- payload.milestone = flat.find((entry) => entry.title === operation.value)?.number ?? null;
1478
+ {
1479
+ const inspected = await inspectExternalPullRequest(base, base.prIdentity!);
1480
+ if (inspected.status === 'failed' || inspected.status === 'blocked' || !inspected.pullRequest) return softenFailure(inspected);
1481
+ if (!options.metadata && !options.closingIssue) return softenFailure(result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
1482
+ error: { code: 'PR_PAYLOAD_INVALID', message: 'sync requires a desired-state option', retryable: false }
1483
+ }));
1484
+ const issue = await inspectPlatformIssue(taskRef, { cwd: base.resolved.repoRoot, client: base.client });
1485
+ if (!issue.issue) return softenFailure(result(issue.status, base.resolved.taskId, base.issueNumber, base.prNumber, {
1486
+ platform: base.context.platform, capabilities: base.context.capabilities, pullRequest: inspected.pullRequest, error: issue.error
1487
+ }));
1488
+ let inLabels = inspected.pullRequest.labels.filter((label) => label.startsWith('in:')).sort();
1489
+ let issueInLabelOperation: PlatformOperation | null = null;
1490
+ if (options.metadata && base.provider.issues?.listLabels) {
1491
+ const labels = await base.provider.issues.listLabels({ context: providerOperationContext(base.loadedContext) });
1492
+ if (!labels.ok) return softenFailure(providerPullRequestError(base, labels.error, base.prNumber));
1493
+ const target = taskInLabelTarget(base.resolved.repoRoot, base.frontmatter, inspected.pullRequest.labels, new Set(labels.value));
1494
+ if (!target.ok) return softenFailure(providerPullRequestError(base, target.error, base.prNumber));
1495
+ inLabels = target.value;
1496
+ const currentIssueLabels = issue.issue.labels.filter((label) => label.startsWith('in:')).sort();
1497
+ issueInLabelOperation = base.context.capabilities.triage
1498
+ ? currentIssueLabels.join('\0') === inLabels.join('\0')
1499
+ ? { name: 'labels:in:issue', status: 'no-op', reasonCode: null }
1500
+ : { name: 'labels:in:issue', status: 'planned', reasonCode: null }
1501
+ : { name: 'labels:in:issue', status: 'skipped', reasonCode: 'TRIAGE_REQUIRED' };
1317
1502
  }
1318
- }
1319
- if (Object.keys(payload).length === 0) {
1320
- const degraded = planned.some((operation) => operation.status === 'skipped');
1321
- return softenFailure(result(degraded ? 'degraded' : 'no-op', base.resolved.taskId, base.issueNumber, base.prNumber, {
1322
- platform: base.context.platform, capabilities: base.context.capabilities, resource: { kind: 'pull-request', number: base.prNumber }, pullRequest: fetched.value, operations, error: null
1503
+ const planned = planPullRequestMetadata({
1504
+ pullRequest: inspected.pullRequest,
1505
+ issue: issue.issue,
1506
+ taskType: String(base.frontmatter.type || 'task'),
1507
+ issueNumber: base.issueNumber,
1508
+ capabilities: base.context.capabilities,
1509
+ inLabels
1510
+ }).operations.filter((operation) => options.metadata || operation.name === 'closing-issue')
1511
+ .filter((operation) => options.closingIssue || operation.name !== 'closing-issue');
1512
+ const operations: PlatformOperation[] = [
1513
+ ...(issueInLabelOperation ? [issueInLabelOperation] : []),
1514
+ ...planned.map(({ name, status, reasonCode }) => ({ name, status, reasonCode }))
1515
+ ];
1516
+ const milestoneOperation = planned.find((operation) => operation.name === 'milestone' && operation.status === 'planned');
1517
+ if (milestoneOperation && base.provider.issues?.listMilestones) {
1518
+ const milestones = await base.provider.issues.listMilestones({ context: providerOperationContext(base.loadedContext) });
1519
+ if (!milestones.ok) return providerPullRequestError(base, milestones.error, base.prNumber);
1520
+ }
1521
+ if (options.dryRun) return softenFailure(result(planned.some((operation) => operation.status === 'planned') ? 'planned' : 'no-op', base.resolved.taskId, base.issueNumber, base.prNumber, {
1522
+ platform: base.context.platform, capabilities: base.context.capabilities, pullRequest: inspected.pullRequest, operations, error: null
1523
+ }));
1524
+ let issueChanged = false;
1525
+ if (issueInLabelOperation?.status === 'planned') {
1526
+ const desiredIssueLabels = [...new Set([
1527
+ ...issue.issue.labels.filter((label) => !label.startsWith('in:')),
1528
+ ...inLabels
1529
+ ])].sort();
1530
+ const updatedIssue = base.provider.issues?.update
1531
+ ? await base.provider.issues.update({
1532
+ context: providerOperationContext(base.loadedContext),
1533
+ target: base.issueIdentity!,
1534
+ currentLabels: issue.issue.labels,
1535
+ patch: { labels: desiredIssueLabels },
1536
+ mutation: { idempotencyKey: `issue:update:${base.resolved.taskId}:in-labels` }
1537
+ })
1538
+ : unsupportedProviderOperation(base.provider, 'issues.update');
1539
+ if (!updatedIssue.ok) return softenFailure(providerPullRequestError(base, updatedIssue.error, base.prNumber));
1540
+ issueChanged = updatedIssue.value.changed;
1541
+ const rereadIssue = base.provider.issues?.inspect
1542
+ ? await base.provider.issues.inspect({ context: providerOperationContext(base.loadedContext), target: base.issueIdentity! })
1543
+ : unsupportedProviderOperation(base.provider, 'issues.inspect');
1544
+ if (!rereadIssue.ok) return softenFailure(providerPullRequestError(base, {
1545
+ ...rereadIssue.error,
1546
+ code: 'IN_LABEL_SYNC_PARTIAL',
1547
+ message: `In-label synchronization is partial or unknown: ${rereadIssue.error.message}`
1548
+ }, base.prNumber));
1549
+ const actualIssueLabels = rereadIssue.value.labels.filter((label) => label.startsWith('in:')).sort();
1550
+ if (actualIssueLabels.join('\0') !== inLabels.join('\0')) return softenFailure(providerPullRequestError(base, {
1551
+ code: 'IN_LABEL_SYNC_PARTIAL',
1552
+ message: 'Issue in: labels did not converge after update',
1553
+ retryable: true
1554
+ }, base.prNumber));
1555
+ }
1556
+ const patch: Record<string, unknown> = {};
1557
+ for (const operation of planned) {
1558
+ if (operation.status !== 'planned') continue;
1559
+ if (operation.name === 'labels') patch.labels = operation.value;
1560
+ if (operation.name === 'assignees') patch.assignees = operation.value;
1561
+ if (operation.name === 'closing-issue') patch.body = operation.value;
1562
+ if (operation.name === 'milestone') patch.milestone = operation.value;
1563
+ }
1564
+ if (Object.keys(patch).length === 0) return softenFailure(result(planned.some((operation) => operation.status === 'skipped') ? 'degraded' : 'no-op', base.resolved.taskId, base.issueNumber, base.prNumber, {
1565
+ platform: base.context.platform, capabilities: base.context.capabilities, resource: { kind: 'pull-request', number: base.prNumber }, pullRequest: inspected.pullRequest, operations, error: null
1566
+ }));
1567
+ const updated = base.provider.changeRequests?.update
1568
+ ? await base.provider.changeRequests.update({
1569
+ context: providerOperationContext(base.loadedContext),
1570
+ target: base.prIdentity,
1571
+ currentLabels: inspected.pullRequest.labels,
1572
+ patch: patch as never,
1573
+ mutation: { idempotencyKey: `pull-request:update:${base.resolved.taskId}` }
1574
+ })
1575
+ : unsupportedProviderOperation(base.provider, 'changeRequests.update');
1576
+ if (!updated.ok) {
1577
+ const error = issueChanged && !updated.error.code.startsWith('IN_LABEL_SYNC')
1578
+ ? { ...updated.error, code: 'IN_LABEL_SYNC_PARTIAL', message: `In-label synchronization is partial or unknown: ${updated.error.message}` }
1579
+ : updated.error;
1580
+ return softenFailure(providerPullRequestError(base, error, base.prNumber));
1581
+ }
1582
+ let finalPullRequest = inspected.pullRequest;
1583
+ if (planned.some((operation) => operation.name === 'labels' && operation.status === 'planned')) {
1584
+ const reread = base.provider.changeRequests?.inspect
1585
+ ? await base.provider.changeRequests.inspect({ context: providerOperationContext(base.loadedContext), target: base.prIdentity })
1586
+ : unsupportedProviderOperation(base.provider, 'changeRequests.inspect');
1587
+ if (!reread.ok) return softenFailure(providerPullRequestError(base, {
1588
+ ...reread.error,
1589
+ code: 'IN_LABEL_SYNC_PARTIAL',
1590
+ message: `In-label synchronization is partial or unknown: ${reread.error.message}`
1591
+ }, base.prNumber));
1592
+ finalPullRequest = normalizeProviderPullRequest(reread.value, base.context.platform.repository!, base.prNumber || 0, inspected.pullRequest);
1593
+ const expected = ((planned.find((operation) => operation.name === 'labels')?.value as string[] || [])
1594
+ .filter((label) => label.startsWith('in:') || label.startsWith('type:'))).sort();
1595
+ const actual = finalPullRequest.labels.filter((label) => label.startsWith('in:') || label.startsWith('type:')).sort();
1596
+ if (actual.join('\0') !== expected.join('\0')) return softenFailure(providerPullRequestError(base, {
1597
+ code: 'IN_LABEL_SYNC_PARTIAL',
1598
+ message: 'Pull request labels did not converge after update',
1599
+ retryable: true
1600
+ }, base.prNumber));
1601
+ }
1602
+ return softenFailure(result(planned.some((operation) => operation.status === 'skipped') ? 'degraded' : 'applied', base.resolved.taskId, base.issueNumber, base.prNumber, {
1603
+ changed: issueChanged || updated.value.changed,
1604
+ platform: base.context.platform,
1605
+ capabilities: base.context.capabilities,
1606
+ resource: { kind: 'pull-request', number: base.prNumber },
1607
+ pullRequest: finalPullRequest,
1608
+ operations: operations.map((operation) => operation.status === 'planned' ? { ...operation, status: 'applied' } : operation),
1609
+ error: null
1323
1610
  }));
1324
1611
  }
1325
- const patched = base.client.json<RemotePullRequest>(['api', `repos/${base.context.platform.repository}/issues/${base.prNumber}`, '-X', 'PATCH', '--input', '-'], {
1326
- cwd: base.resolved.repoRoot, method: 'PATCH', input: JSON.stringify(payload)
1327
- });
1328
- if (!patched.ok) return softenFailure(result(patched.error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, base.prNumber, { pullRequest: fetched.value, operations, error: patched.error }));
1329
- return softenFailure(result(planned.some((operation) => operation.status === 'skipped') ? 'degraded' : 'applied', base.resolved.taskId, base.issueNumber, base.prNumber, {
1330
- changed: true, platform: base.context.platform, capabilities: base.context.capabilities,
1331
- resource: { kind: 'pull-request', number: base.prNumber }, pullRequest: fetched.value,
1332
- operations: operations.map((operation) => operation.status === 'planned' ? { ...operation, status: 'applied' } : operation), error: null
1333
- }));
1334
1612
  }
1335
1613
 
1336
1614
  function readProjectPrFlow(repoRoot: string): 'required' | 'disabled' | undefined {
@@ -1356,11 +1634,13 @@ function skipPlatformPullRequestFactUnlocked(
1356
1634
  error: { code: 'TASK_DOCUMENT_INVALID', message: error instanceof Error ? error.message : String(error), retryable: false }
1357
1635
  });
1358
1636
  }
1359
- const issue = Number(frontmatter.issue_number);
1360
- const issueNumber = Number.isSafeInteger(issue) && issue > 0 ? issue : null;
1361
- const existing = readPrDeliveryFact(frontmatter);
1637
+ let issueIdentity: ResourceIdentity | null;
1638
+ try { issueIdentity = taskIssueIdentity(frontmatter, undefined, options.runtimeVersion); }
1639
+ catch (error) { return result('failed', resolved.taskId, null, null, { error: { ...taskIssueIdentityError(error), retryable: false } }); }
1640
+ const issueNumber = resourceIdentityNumber(issueIdentity);
1641
+ const existing = readPrDeliveryFact(frontmatter, options.runtimeVersion);
1362
1642
  if (existing.status === 'invalid') return result('failed', resolved.taskId, issueNumber, null, {
1363
- error: { code: 'PR_DELIVERY_FACT_INVALID', message: existing.error.message, retryable: false }
1643
+ error: { code: existing.error.code, message: existing.error.message, retryable: false }
1364
1644
  });
1365
1645
  if (existing.status === 'missing') return result('failed', resolved.taskId, issueNumber, null, {
1366
1646
  error: { code: 'PR_DELIVERY_FACT_MISSING', message: 'Task has no pr_delivery_fact; repair the task metadata or create a new task', retryable: false }
@@ -1369,9 +1649,12 @@ function skipPlatformPullRequestFactUnlocked(
1369
1649
  operations: [{ name: 'task:skip-pr', status: 'no-op', reasonCode: null }],
1370
1650
  error: null
1371
1651
  });
1372
- if (existing.fact.state === 'bound') return result('failed', resolved.taskId, issueNumber, existing.fact.identity.number, {
1373
- error: { code: 'PR_DELIVERY_FACT_CONFLICT', message: `Task is already bound to PR #${existing.fact.identity.number}`, retryable: false }
1374
- });
1652
+ if (existing.fact.state === 'bound') {
1653
+ const boundNumber = resourceIdentityNumber(existing.fact.identity.resource);
1654
+ return result('failed', resolved.taskId, issueNumber, boundNumber, {
1655
+ error: { code: 'PR_DELIVERY_FACT_CONFLICT', message: `Task is already bound to PR ${boundNumber === null ? 'identity' : `#${boundNumber}`}`, retryable: false }
1656
+ });
1657
+ }
1375
1658
  if (readProjectPrFlow(resolved.repoRoot) === 'required') return result('failed', resolved.taskId, issueNumber, null, {
1376
1659
  error: { code: 'PR_SKIP_FORBIDDEN', message: 'Cannot skip PR delivery when prFlow is required', retryable: false }
1377
1660
  });
@@ -1400,7 +1683,7 @@ function skipPlatformPullRequestFactUnlocked(
1400
1683
  });
1401
1684
  }
1402
1685
 
1403
- function skipPlatformPullRequestFact(taskRef: string, options: SkipFactOptions): PullRequestResult {
1686
+ async function skipPlatformPullRequestFact(taskRef: string, options: SkipFactOptions): Promise<PullRequestResult> {
1404
1687
  const resolved = resolveTaskRef(taskRef, options.cwd ? { repoRoot: options.cwd } : {});
1405
1688
  if (!resolved.ok) return result('failed', resolved.taskId, null, null, {
1406
1689
  error: { code: resolved.code, message: resolved.message, retryable: false }
@@ -1433,12 +1716,12 @@ export {
1433
1716
  inspectPlatformPullRequest,
1434
1717
  inspectPlatformPullRequestByNumber,
1435
1718
  normalizePullRequest,
1436
- resolveGitHubChangeRequestGitEvidence,
1437
1719
  resolveExternalPullRequest,
1438
1720
  skipPlatformPullRequestFact,
1439
1721
  selectExternalPullRequest,
1440
1722
  selectPullRequest,
1441
- syncPlatformPullRequest
1723
+ syncPlatformPullRequest,
1724
+ syncPlatformPullRequestInLabels
1442
1725
  };
1443
1726
  export type { BindOptions, CreateOptions, ExternalPullRequestResult, ExternalPullRequestSelection, PullRequestPrimaryResult, PullRequestResult, PullRequestSnapshot, ResolveExternalOptions, SkipFactOptions, SyncOptions };
1444
1727
  export { warningResultForPrimary };