@fitlab-ai/agent-infra 0.9.11 → 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 (356) hide show
  1. package/README.md +13 -13
  2. package/README.zh-CN.md +13 -13
  3. package/dist/bin/internal-cli.js +29 -19
  4. package/dist/lib/agent-clients/adapters/codex-lifecycle/build-identity.js +71 -10
  5. package/dist/lib/agent-clients/next-steps.js +6 -1
  6. package/dist/lib/decide.js +3 -3
  7. package/dist/lib/defaults.json +4 -0
  8. package/dist/lib/git/workflow.js +43 -1
  9. package/dist/lib/init.js +1 -0
  10. package/dist/lib/internal/git-workflow.js +8 -1
  11. package/dist/lib/internal/platform-checks.js +3 -3
  12. package/dist/lib/internal/platform-comment.js +9 -9
  13. package/dist/lib/internal/platform-context.js +2 -2
  14. package/dist/lib/internal/platform-issue.js +8 -9
  15. package/dist/lib/internal/platform-pr-review.js +10 -10
  16. package/dist/lib/internal/platform-pr.js +68 -23
  17. package/dist/lib/internal/platform-release-notes.js +3 -3
  18. package/dist/lib/internal/pr-review-grade.js +11 -9
  19. package/dist/lib/internal/release-workflow.js +4 -4
  20. package/dist/lib/internal/sandbox-control.js +30 -4
  21. package/dist/lib/internal/task-activity.js +3 -3
  22. package/dist/lib/internal/task-artifact.js +76 -24
  23. package/dist/lib/internal/task-create.js +2 -2
  24. package/dist/lib/internal/task-delivery.js +59 -0
  25. package/dist/lib/internal/task-event.js +6 -3
  26. package/dist/lib/internal/task-finalization.js +2 -2
  27. package/dist/lib/internal/task-invalidation.js +60 -0
  28. package/dist/lib/internal/task-ledger.js +9 -6
  29. package/dist/lib/internal/task-lifecycle.js +2 -2
  30. package/dist/lib/internal/task-override.js +4 -4
  31. package/dist/lib/internal/task-review.js +3 -3
  32. package/dist/lib/internal/task-verify.js +6 -6
  33. package/dist/lib/internal/task-warning.js +3 -3
  34. package/dist/lib/platform/adapters.js +1 -3
  35. package/dist/lib/platform/change-request-git-evidence.js +27 -7
  36. package/dist/lib/platform/completion-backfill.js +6 -5
  37. package/dist/lib/platform/context.js +70 -130
  38. package/dist/lib/platform/github-client.js +8 -2
  39. package/dist/lib/platform/github-provider.js +903 -0
  40. package/dist/lib/platform/in-label-sync.js +143 -0
  41. package/dist/lib/platform/issue-comments.js +94 -41
  42. package/dist/lib/platform/issues.js +335 -189
  43. package/dist/lib/platform/metadata-labels.js +6 -2
  44. package/dist/lib/platform/none-provider.js +28 -0
  45. package/dist/lib/platform/pr-change-report.js +551 -0
  46. package/dist/lib/platform/pr-checks.js +124 -106
  47. package/dist/lib/platform/pr-review.js +88 -72
  48. package/dist/lib/platform/pr-summary.js +523 -49
  49. package/dist/lib/platform/provider-bridge.js +45 -0
  50. package/dist/lib/platform/provider-contract.d.ts +487 -0
  51. package/dist/lib/platform/provider-contract.js +95 -0
  52. package/dist/lib/platform/provider-loader.js +251 -0
  53. package/dist/lib/platform/provider-validation.js +504 -0
  54. package/dist/lib/platform/pull-request-metadata.js +8 -6
  55. package/dist/lib/platform/pull-requests.js +1050 -502
  56. package/dist/lib/platform/release-notes.js +78 -55
  57. package/dist/lib/platform/releases.js +79 -50
  58. package/dist/lib/platform/resource-identity.d.ts +26 -0
  59. package/dist/lib/platform/resource-identity.js +92 -0
  60. package/dist/lib/platform/task-identities.js +31 -0
  61. package/dist/lib/platform/verification-required.js +22 -12
  62. package/dist/lib/platform/verification-sync.js +118 -239
  63. package/dist/lib/pr-review/evidence-grading.js +30 -9
  64. package/dist/lib/run/index.js +63 -23
  65. package/dist/lib/sandbox/config.js +5 -0
  66. package/dist/lib/sandbox/control/client.js +96 -15
  67. package/dist/lib/sandbox/control/executor.js +3 -3
  68. package/dist/lib/sandbox/control/lifecycle.js +29 -7
  69. package/dist/lib/sandbox/control/protocol.js +77 -0
  70. package/dist/lib/sandbox/control/server.js +398 -25
  71. package/dist/lib/sandbox/control/state.js +204 -1
  72. package/dist/lib/server/process-state.js +2 -2
  73. package/dist/lib/server/protocol.js +38 -1
  74. package/dist/lib/task/activity-log.js +8 -1
  75. package/dist/lib/task/artifact-lifecycle.js +35 -7
  76. package/dist/lib/task/capabilities.js +239 -0
  77. package/dist/lib/task/commands/status.js +32 -0
  78. package/dist/lib/task/commit-intent.js +100 -0
  79. package/dist/lib/task/commit-operation.js +207 -102
  80. package/dist/lib/task/control-authority.js +8 -5
  81. package/dist/lib/task/create-service.js +8 -8
  82. package/dist/lib/task/create.js +33 -7
  83. package/dist/lib/task/current-contract.js +5 -1
  84. package/dist/lib/task/decision-details.js +3 -48
  85. package/dist/lib/task/delivery-target.js +97 -0
  86. package/dist/lib/task/delivery.js +153 -0
  87. package/dist/lib/task/events.js +297 -17
  88. package/dist/lib/task/finalization.js +37 -13
  89. package/dist/lib/task/human-override.js +61 -36
  90. package/dist/lib/task/invalidation-command.js +86 -0
  91. package/dist/lib/task/invalidation.js +260 -0
  92. package/dist/lib/task/ledger-intents.js +37 -0
  93. package/dist/lib/task/lifecycle.js +106 -9
  94. package/dist/lib/task/local-artifact-finalization.js +374 -0
  95. package/dist/lib/task/orchestration.js +100 -151
  96. package/dist/lib/task/pr-delivery-fact.js +183 -0
  97. package/dist/lib/task/review-finalization.js +4 -8
  98. package/dist/lib/task/review-fingerprint.js +32 -2
  99. package/dist/lib/task/rework-intent.js +138 -0
  100. package/dist/lib/task/skill-scope.js +23 -0
  101. package/dist/lib/task/task-execution-lock.js +27 -17
  102. package/dist/lib/task/verification-engine.js +89 -38
  103. package/dist/lib/task/verification-review.js +1 -1
  104. package/dist/lib/task/verification.js +3 -3
  105. package/dist/lib/task/workflow-warning-intents.js +1 -1
  106. package/dist/lib/task/write.js +11 -0
  107. package/dist/lib/update.js +9 -0
  108. package/dist/lib/version.js +11 -1
  109. package/dist/lifecycle-build-manifest.json +3 -3
  110. package/lib/agent-clients/adapters/codex-lifecycle/build-identity.ts +105 -10
  111. package/lib/agent-clients/adapters/codex-lifecycle/manifest-files.json +12 -1
  112. package/lib/agent-clients/next-steps.ts +6 -1
  113. package/lib/decide.ts +3 -3
  114. package/lib/defaults.json +4 -0
  115. package/lib/git/workflow.ts +38 -1
  116. package/lib/init.ts +4 -1
  117. package/lib/internal/git-workflow.ts +7 -1
  118. package/lib/internal/platform-checks.ts +3 -3
  119. package/lib/internal/platform-comment.ts +8 -8
  120. package/lib/internal/platform-context.ts +2 -2
  121. package/lib/internal/platform-issue.ts +7 -8
  122. package/lib/internal/platform-pr-review.ts +9 -9
  123. package/lib/internal/platform-pr.ts +53 -21
  124. package/lib/internal/platform-release-notes.ts +3 -3
  125. package/lib/internal/pr-review-grade.ts +10 -8
  126. package/lib/internal/release-workflow.ts +4 -4
  127. package/lib/internal/sandbox-control.ts +27 -3
  128. package/lib/internal/task-activity.ts +3 -3
  129. package/lib/internal/task-artifact.ts +65 -22
  130. package/lib/internal/task-create.ts +2 -2
  131. package/lib/internal/task-delivery.ts +53 -0
  132. package/lib/internal/task-event.ts +6 -3
  133. package/lib/internal/task-finalization.ts +3 -3
  134. package/lib/internal/task-invalidation.ts +39 -0
  135. package/lib/internal/task-ledger.ts +11 -8
  136. package/lib/internal/task-lifecycle.ts +2 -2
  137. package/lib/internal/task-override.ts +6 -6
  138. package/lib/internal/task-review.ts +3 -3
  139. package/lib/internal/task-verify.ts +6 -6
  140. package/lib/internal/task-warning.ts +3 -3
  141. package/lib/platform/adapters.ts +3 -2
  142. package/lib/platform/change-request-git-evidence.ts +36 -9
  143. package/lib/platform/completion-backfill.ts +9 -8
  144. package/lib/platform/context.ts +97 -134
  145. package/lib/platform/github-client.ts +10 -3
  146. package/lib/platform/github-provider.ts +910 -0
  147. package/lib/platform/in-label-sync.ts +208 -0
  148. package/lib/platform/issue-comments.ts +97 -47
  149. package/lib/platform/issues.ts +344 -174
  150. package/lib/platform/metadata-labels.ts +5 -2
  151. package/lib/platform/none-provider.ts +35 -0
  152. package/lib/platform/pr-change-report.ts +645 -0
  153. package/lib/platform/pr-checks.ts +129 -107
  154. package/lib/platform/pr-review.ts +88 -85
  155. package/lib/platform/pr-summary.ts +562 -52
  156. package/lib/platform/provider-bridge.ts +82 -0
  157. package/lib/platform/provider-contract.ts +536 -0
  158. package/lib/platform/provider-loader.ts +307 -0
  159. package/lib/platform/provider-validation.ts +529 -0
  160. package/lib/platform/pull-request-metadata.ts +10 -7
  161. package/lib/platform/pull-requests.ts +1126 -505
  162. package/lib/platform/release-notes.ts +76 -59
  163. package/lib/platform/releases.ts +77 -43
  164. package/lib/platform/resource-identity.ts +121 -0
  165. package/lib/platform/task-identities.ts +40 -0
  166. package/lib/platform/types.ts +4 -2
  167. package/lib/platform/verification-required.ts +21 -13
  168. package/lib/platform/verification-sync.ts +118 -293
  169. package/lib/pr-review/evidence-grading.ts +31 -11
  170. package/lib/run/index.ts +57 -22
  171. package/lib/sandbox/config.ts +7 -0
  172. package/lib/sandbox/control/client.ts +105 -15
  173. package/lib/sandbox/control/executor.ts +4 -4
  174. package/lib/sandbox/control/lifecycle.ts +30 -8
  175. package/lib/sandbox/control/protocol.ts +113 -0
  176. package/lib/sandbox/control/server.ts +431 -23
  177. package/lib/sandbox/control/state.ts +251 -1
  178. package/lib/server/process-state.ts +2 -2
  179. package/lib/server/protocol.ts +36 -1
  180. package/lib/task/activity-log.ts +8 -0
  181. package/lib/task/artifact-lifecycle.ts +34 -8
  182. package/lib/task/capabilities.ts +262 -0
  183. package/lib/task/commands/status.ts +38 -0
  184. package/lib/task/commit-intent.ts +137 -0
  185. package/lib/task/commit-operation.ts +186 -88
  186. package/lib/task/control-authority.ts +11 -8
  187. package/lib/task/create-service.ts +8 -8
  188. package/lib/task/create.ts +34 -9
  189. package/lib/task/current-contract.ts +6 -1
  190. package/lib/task/decision-details.ts +8 -64
  191. package/lib/task/delivery-target.ts +130 -0
  192. package/lib/task/delivery.ts +160 -0
  193. package/lib/task/events.ts +296 -16
  194. package/lib/task/finalization.ts +50 -14
  195. package/lib/task/human-override.ts +73 -46
  196. package/lib/task/invalidation-command.ts +86 -0
  197. package/lib/task/invalidation.ts +332 -0
  198. package/lib/task/ledger-intents.ts +35 -1
  199. package/lib/task/lifecycle.ts +93 -4
  200. package/lib/task/local-artifact-finalization.ts +563 -0
  201. package/lib/task/orchestration.ts +110 -146
  202. package/lib/task/pr-delivery-fact.ts +237 -0
  203. package/lib/task/review-finalization.ts +4 -14
  204. package/lib/task/review-fingerprint.ts +33 -2
  205. package/lib/task/rework-intent.ts +165 -0
  206. package/lib/task/skill-scope.ts +24 -0
  207. package/lib/task/task-execution-lock.ts +23 -14
  208. package/lib/task/verification-engine.ts +95 -43
  209. package/lib/task/verification-review.ts +2 -0
  210. package/lib/task/verification.ts +4 -4
  211. package/lib/task/workflow-warning-intents.ts +1 -1
  212. package/lib/task/write.ts +19 -0
  213. package/lib/update.ts +11 -1
  214. package/lib/version.ts +15 -1
  215. package/package.json +7 -1
  216. package/templates/.agents/rules/README.en.md +4 -2
  217. package/templates/.agents/rules/README.zh-CN.md +4 -2
  218. package/templates/.agents/rules/create-issue.en.md +11 -2
  219. package/templates/.agents/rules/create-issue.zh-CN.md +24 -2
  220. package/templates/.agents/rules/issue-pr-commands.en.md +4 -17
  221. package/templates/.agents/rules/issue-pr-commands.zh-CN.md +1 -1
  222. package/templates/.agents/rules/issue-sync.en.md +28 -5
  223. package/templates/.agents/rules/issue-sync.zh-CN.md +64 -5
  224. package/templates/.agents/rules/label-milestone-setup.en.md +15 -2
  225. package/templates/.agents/rules/label-milestone-setup.zh-CN.md +15 -2
  226. package/templates/.agents/rules/local-artifact-repair.en.md +53 -0
  227. package/templates/.agents/rules/local-artifact-repair.zh-CN.md +53 -0
  228. package/templates/.agents/rules/milestone-inference.en.md +12 -2
  229. package/templates/.agents/rules/milestone-inference.zh-CN.md +25 -3
  230. package/templates/.agents/rules/pr-sync.en.md +28 -13
  231. package/templates/.agents/rules/pr-sync.zh-CN.md +39 -5
  232. package/templates/.agents/rules/release-commands.en.md +42 -3
  233. package/templates/.agents/rules/release-commands.zh-CN.md +42 -3
  234. package/templates/.agents/rules/security-alerts.en.md +17 -2
  235. package/templates/.agents/rules/security-alerts.zh-CN.md +17 -2
  236. package/templates/.agents/rules/sync-content-generation.en.md +8 -0
  237. package/templates/.agents/rules/sync-content-generation.zh-CN.md +8 -0
  238. package/templates/.agents/rules/task-management.en.md +1 -1
  239. package/templates/.agents/rules/task-management.zh-CN.md +1 -1
  240. package/templates/.agents/rules/task-short-id.en.md +1 -4
  241. package/templates/.agents/rules/task-short-id.zh-CN.md +1 -3
  242. package/templates/.agents/scripts/security-alerts.github.sh +151 -0
  243. package/templates/.agents/scripts/security-alerts.sh +31 -0
  244. package/templates/.agents/skills/analyze-task/SKILL.en.md +14 -3
  245. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +11 -3
  246. package/templates/.agents/skills/check-task/SKILL.en.md +16 -37
  247. package/templates/.agents/skills/check-task/SKILL.zh-CN.md +16 -37
  248. package/templates/.agents/skills/close-codescan/SKILL.en.md +2 -2
  249. package/templates/.agents/skills/close-codescan/SKILL.zh-CN.md +2 -2
  250. package/templates/.agents/skills/close-dependabot/SKILL.en.md +2 -2
  251. package/templates/.agents/skills/close-dependabot/SKILL.zh-CN.md +2 -2
  252. package/templates/.agents/skills/code-task/SKILL.en.md +32 -10
  253. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +35 -13
  254. package/templates/.agents/skills/code-task/config/verify.en.json +1 -0
  255. package/templates/.agents/skills/code-task/config/verify.zh-CN.json +1 -0
  256. package/templates/.agents/skills/commit/SKILL.en.md +3 -3
  257. package/templates/.agents/skills/commit/SKILL.zh-CN.md +3 -3
  258. package/templates/.agents/skills/commit/config/verify.json +1 -1
  259. package/templates/.agents/skills/commit/reference/commit-orchestration.en.md +3 -3
  260. package/templates/.agents/skills/commit/reference/commit-orchestration.zh-CN.md +3 -3
  261. package/templates/.agents/skills/commit/reference/issue-metadata-sync.en.md +2 -0
  262. package/templates/.agents/skills/commit/reference/issue-metadata-sync.zh-CN.md +2 -0
  263. package/templates/.agents/skills/commit/reference/pr-summary-sync.en.md +4 -4
  264. package/templates/.agents/skills/commit/reference/pr-summary-sync.zh-CN.md +4 -4
  265. package/templates/.agents/skills/commit/reference/task-status-update.en.md +2 -2
  266. package/templates/.agents/skills/commit/reference/task-status-update.zh-CN.md +2 -2
  267. package/templates/.agents/skills/complete-manual-validation/SKILL.en.md +7 -4
  268. package/templates/.agents/skills/complete-manual-validation/SKILL.zh-CN.md +7 -4
  269. package/templates/.agents/skills/complete-manual-validation/config/verify.en.json +1 -1
  270. package/templates/.agents/skills/complete-manual-validation/config/verify.zh-CN.json +1 -1
  271. package/templates/.agents/skills/complete-manual-validation/reference/summary-update.en.md +5 -4
  272. package/templates/.agents/skills/complete-manual-validation/reference/summary-update.zh-CN.md +5 -4
  273. package/templates/.agents/skills/complete-task/SKILL.en.md +31 -13
  274. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +30 -13
  275. package/templates/.agents/skills/complete-task/reference/external-delivery.en.md +2 -2
  276. package/templates/.agents/skills/complete-task/reference/external-delivery.zh-CN.md +2 -2
  277. package/templates/.agents/skills/create-pr/SKILL.en.md +11 -10
  278. package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +11 -11
  279. package/templates/.agents/skills/create-pr/config/verify.json +1 -0
  280. package/templates/.agents/skills/create-pr/reference/change-report.en.md +16 -4
  281. package/templates/.agents/skills/create-pr/reference/change-report.zh-CN.md +16 -4
  282. package/templates/.agents/skills/create-pr/reference/comment-publish.en.md +5 -3
  283. package/templates/.agents/skills/create-pr/reference/comment-publish.zh-CN.md +5 -3
  284. package/templates/.agents/skills/create-pr/reference/pr-body-template.en.md +1 -1
  285. package/templates/.agents/skills/create-pr/reference/pr-body-template.zh-CN.md +1 -1
  286. package/templates/.agents/skills/create-pr/scripts/change-report.mjs +3 -0
  287. package/templates/.agents/skills/create-task/SKILL.en.md +1 -0
  288. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +1 -0
  289. package/templates/.agents/skills/import-codescan/SKILL.en.md +1 -1
  290. package/templates/.agents/skills/import-codescan/SKILL.zh-CN.md +1 -1
  291. package/templates/.agents/skills/import-dependabot/SKILL.en.md +1 -1
  292. package/templates/.agents/skills/import-dependabot/SKILL.zh-CN.md +1 -1
  293. package/templates/.agents/skills/init-labels/SKILL.en.md +9 -10
  294. package/templates/.agents/skills/init-labels/SKILL.zh-CN.md +9 -10
  295. package/templates/.agents/skills/init-labels/scripts/init-labels.github.sh +57 -1
  296. package/templates/.agents/skills/init-labels/scripts/init-labels.sh +27 -1
  297. package/templates/.agents/skills/init-milestones/SKILL.en.md +3 -5
  298. package/templates/.agents/skills/init-milestones/SKILL.zh-CN.md +3 -5
  299. package/templates/.agents/skills/plan-task/SKILL.en.md +14 -3
  300. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +11 -3
  301. package/templates/.agents/skills/review-analysis/SKILL.en.md +6 -5
  302. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +6 -5
  303. package/templates/.agents/skills/review-analysis/reference/output-templates.en.md +18 -0
  304. package/templates/.agents/skills/review-analysis/reference/output-templates.zh-CN.md +18 -0
  305. package/templates/.agents/skills/review-code/SKILL.en.md +10 -9
  306. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +10 -9
  307. package/templates/.agents/skills/review-code/reference/output-templates.en.md +19 -1
  308. package/templates/.agents/skills/review-code/reference/output-templates.zh-CN.md +19 -1
  309. package/templates/.agents/skills/review-code/reference/report-template.en.md +4 -2
  310. package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +4 -2
  311. package/templates/.agents/skills/review-plan/SKILL.en.md +6 -5
  312. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +6 -5
  313. package/templates/.agents/skills/review-plan/reference/output-templates.en.md +18 -0
  314. package/templates/.agents/skills/review-plan/reference/output-templates.zh-CN.md +18 -0
  315. package/templates/.agents/skills/review-pr/SKILL.en.md +1 -0
  316. package/templates/.agents/skills/review-pr/SKILL.zh-CN.md +1 -0
  317. package/templates/.agents/skills/review-pr/reference/evidence-grading.en.md +2 -2
  318. package/templates/.agents/skills/review-pr/reference/evidence-grading.zh-CN.md +2 -2
  319. package/templates/.agents/skills/run-manual-validation/SKILL.en.md +5 -2
  320. package/templates/.agents/skills/run-manual-validation/SKILL.zh-CN.md +5 -2
  321. package/templates/.agents/skills/run-task/SKILL.en.md +1 -1
  322. package/templates/.agents/skills/run-task/SKILL.zh-CN.md +1 -1
  323. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +4 -0
  324. package/templates/.agents/skills/watch-pr/SKILL.en.md +6 -6
  325. package/templates/.agents/skills/watch-pr/SKILL.zh-CN.md +6 -6
  326. package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.en.md +6 -0
  327. package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.zh-CN.md +6 -0
  328. package/templates/.agents/templates/task.en.md +23 -2
  329. package/templates/.agents/templates/task.zh-CN.md +23 -2
  330. package/templates/.agents/workflows/bug-fix.en.yaml +9 -8
  331. package/templates/.agents/workflows/bug-fix.zh-CN.yaml +9 -8
  332. package/templates/.agents/workflows/feature-development.en.yaml +9 -8
  333. package/templates/.agents/workflows/feature-development.zh-CN.yaml +9 -8
  334. package/templates/.agents/workflows/refactoring.en.yaml +9 -8
  335. package/templates/.agents/workflows/refactoring.zh-CN.yaml +9 -8
  336. package/templates/.github/workflows/pr-label.yml +7 -26
  337. package/templates/.agents/rules/create-issue.github.en.md +0 -14
  338. package/templates/.agents/rules/create-issue.github.zh-CN.md +0 -27
  339. package/templates/.agents/rules/issue-pr-commands.github.en.md +0 -34
  340. package/templates/.agents/rules/issue-pr-commands.github.zh-CN.md +0 -49
  341. package/templates/.agents/rules/issue-sync.github.en.md +0 -40
  342. package/templates/.agents/rules/issue-sync.github.zh-CN.md +0 -76
  343. package/templates/.agents/rules/label-milestone-setup.github.en.md +0 -60
  344. package/templates/.agents/rules/label-milestone-setup.github.zh-CN.md +0 -60
  345. package/templates/.agents/rules/milestone-inference.github.en.md +0 -15
  346. package/templates/.agents/rules/milestone-inference.github.zh-CN.md +0 -27
  347. package/templates/.agents/rules/pr-checks-commands.github.en.md +0 -18
  348. package/templates/.agents/rules/pr-checks-commands.github.zh-CN.md +0 -34
  349. package/templates/.agents/rules/pr-sync.github.en.md +0 -43
  350. package/templates/.agents/rules/pr-sync.github.zh-CN.md +0 -69
  351. package/templates/.agents/rules/release-commands.github.en.md +0 -44
  352. package/templates/.agents/rules/release-commands.github.zh-CN.md +0 -44
  353. package/templates/.agents/rules/security-alerts.github.en.md +0 -43
  354. package/templates/.agents/rules/security-alerts.github.zh-CN.md +0 -43
  355. /package/templates/.agents/rules/{issue-fields.github.en.md → issue-fields.en.md} +0 -0
  356. /package/templates/.agents/rules/{issue-fields.github.zh-CN.md → issue-fields.zh-CN.md} +0 -0
@@ -1,26 +1,52 @@
1
1
  import fs from 'node:fs';
2
+ import path from 'node:path';
2
3
  import { execFileSync } from 'node:child_process';
3
4
 
4
- import { parseTaskFrontmatter } from '../task/frontmatter.ts';
5
+ import { parseTypedTaskFrontmatter } from '../task/frontmatter.ts';
5
6
  import { resolveTaskRef } from '../task/resolve-ref.ts';
6
7
  import { extractSection } from '../task/sections.ts';
7
8
  import { captureTaskWriteMetadata, writeTask } from '../task/write.ts';
8
- import {
9
- inspectPlatformChangeRequest,
10
- inspectPlatformIssueClosingChangeRequests,
11
- registerPlatformCapabilities
12
- } from './adapters.ts';
13
9
  import type { PlatformChangeRequestSnapshot } from './adapters.ts';
14
- import { parseGitHubRemote, resolvePlatformContext } from './context.ts';
10
+ import { parseGitHubRemote, resolvePlatformContext, resolvePlatformProviderContext } from './context.ts';
11
+ import type { PlatformClient } from './context.ts';
15
12
  import { createGitHubClient } from './github-client.ts';
16
- import type { GitHubClient } from './github-client.ts';
17
13
  import { inspectPlatformIssue } from './issues.ts';
14
+ import { inspectGitHubIssue } from './issues.ts';
15
+ import type { IssueSnapshot } from './issues.ts';
18
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';
19
24
  import { platformResult } from './types.ts';
20
25
  import type { PlatformOperation, PlatformResult } from './types.ts';
21
26
  import { inspectCompletionArtifacts } from '../task/finalization-artifacts.ts';
22
27
  import { TaskExecutionLockError, withTaskExecutionLock } from '../task/task-execution-lock.ts';
28
+ import { resolveDeliveryTarget } from '../task/delivery-target.ts';
23
29
  import { mergeOperationWarnings, type OperationWarning } from '../task/operation-outcome.ts';
30
+ import {
31
+ buildBoundFact,
32
+ buildSkippedFact,
33
+ factFrontmatterMutation,
34
+ readPrDeliveryFact
35
+ } from '../task/pr-delivery-fact.ts';
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';
24
50
 
25
51
  type PullRequestSnapshot = PlatformChangeRequestSnapshot;
26
52
 
@@ -28,10 +54,21 @@ type PullRequestResult = PlatformResult & {
28
54
  task: { id: string | null; issueNumber: number | null; prNumber: number | null };
29
55
  pullRequest: PullRequestSnapshot | null;
30
56
  result: 'pr_created' | 'pr_reused' | 'no_op' | 'pr_created_with_warnings' | 'pr_reused_with_warnings' | 'no_op_with_warnings' | 'failed' | 'blocked' | null;
57
+ creation: CreationOutcome | null;
31
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
+ }>;
32
68
  };
33
- type InspectionOptions = { cwd?: string; client?: unknown };
34
- 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 };
35
72
  type CreateOptions = SharedOptions & {
36
73
  agent: string;
37
74
  base: string;
@@ -40,11 +77,13 @@ type CreateOptions = SharedOptions & {
40
77
  body: string;
41
78
  draft?: boolean;
42
79
  dryRun?: boolean;
80
+ phase?: { value: CreatePhase };
43
81
  };
44
- type BindOptions = SharedOptions & { agent: string; pr: number; dryRun?: boolean };
82
+ type BindOptions = SharedOptions & { agent: string; pr: string | number; dryRun?: boolean };
45
83
  type PullRequestPrimaryResult = 'pr_created' | 'pr_reused' | 'no_op';
46
84
  type SyncOptions = SharedOptions & { agent: string; metadata?: boolean; closingIssue?: boolean; dryRun?: boolean; primaryResult: PullRequestPrimaryResult };
47
- type ResolveExternalOptions = SharedOptions & { agent: string; pr?: number; dryRun?: boolean };
85
+ type ResolveExternalOptions = SharedOptions & { agent: string; pr?: string | number; dryRun?: boolean };
86
+ type SkipFactOptions = { cwd?: string; agent: string; dryRun?: boolean; runtimeVersion?: string };
48
87
  type ExternalPullRequestSelection =
49
88
  | { status: 'normal'; candidates: PullRequestSnapshot[]; eligible: [] }
50
89
  | { status: 'selected'; source: 'unique' | 'explicit'; selected: PullRequestSnapshot; candidates: PullRequestSnapshot[]; eligible: PullRequestSnapshot[] }
@@ -57,6 +96,8 @@ type ExternalPullRequestResult = PullRequestResult & {
57
96
  selected: PullRequestSnapshot | null;
58
97
  };
59
98
 
99
+ type CreatePhase = 'before-post' | 'post-dispatched' | 'post-accepted';
100
+
60
101
  type RemotePullRequest = {
61
102
  number?: number;
62
103
  node_id?: string;
@@ -123,6 +164,7 @@ function result(
123
164
  task: { id: taskId, issueNumber, prNumber },
124
165
  pullRequest: null,
125
166
  result: null,
167
+ creation: null,
126
168
  warnings: [],
127
169
  ...overrides
128
170
  };
@@ -144,6 +186,14 @@ function externalResult(
144
186
  };
145
187
  }
146
188
 
189
+ function withCreation(output: PullRequestResult, creation: CreationOutcome): PullRequestResult {
190
+ return { ...output, creation };
191
+ }
192
+
193
+ const PRECONDITION_NOT_CREATED: CreationOutcome = {
194
+ kind: 'not-created', reason: 'precondition-failed', createdByCurrentOperation: false
195
+ };
196
+
147
197
  function normalizePullRequest(remote: RemotePullRequest, repository: string): PullRequestSnapshot | null {
148
198
  const number = Number(remote.number);
149
199
  const headRepository = remote.head?.repo?.full_name;
@@ -199,9 +249,17 @@ function repositoryKey(repository: string): string {
199
249
  return repository.trim().toLowerCase();
200
250
  }
201
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
+
202
260
  function identitySignature(pullRequest: PullRequestSnapshot): string {
203
261
  return [
204
- pullRequest.repository, pullRequest.number, pullRequest.nodeId, pullRequest.url,
262
+ pullRequest.repository, JSON.stringify(pullRequestIdentity(pullRequest)), pullRequest.nodeId, pullRequest.url,
205
263
  pullRequest.head.repository, pullRequest.head.ref, pullRequest.head.sha,
206
264
  pullRequest.base.repository, pullRequest.base.ref, pullRequest.base.sha,
207
265
  pullRequest.mergedAt || '', pullRequest.mergeCommitSha || ''
@@ -214,54 +272,59 @@ function hasCompleteExternalIdentity(pullRequest: PullRequestSnapshot): boolean
214
272
  pullRequest.head.repository, pullRequest.head.ref, pullRequest.head.sha,
215
273
  pullRequest.base.repository, pullRequest.base.ref, pullRequest.base.sha
216
274
  ];
217
- return Number.isInteger(pullRequest.number) && pullRequest.number > 0 &&
218
- required.every((value) => Boolean(value?.trim())) &&
219
- Boolean(pullRequest.mergedAt) === Boolean(pullRequest.mergeCommitSha);
275
+ return isUsableResourceIdentity(pullRequestIdentity(pullRequest)) && required.every((value) => Boolean(value?.trim())) &&
276
+ (pullRequest.state === 'open' || Boolean(pullRequest.mergedAt) === Boolean(pullRequest.mergeCommitSha));
220
277
  }
221
278
 
222
279
  function selectExternalPullRequest(
223
280
  candidates: PullRequestSnapshot[],
224
281
  repository: string,
225
- existingPrNumber: number | null,
226
- explicitPrNumber: number | null
282
+ existingPrIdentity: ResourceIdentity | number | null,
283
+ explicitPrIdentity: ResourceIdentity | number | null
227
284
  ): ExternalPullRequestSelection {
285
+ const existingIdentity = typeof existingPrIdentity === 'number'
286
+ ? resourceIdentity(existingPrIdentity) : existingPrIdentity;
287
+ const explicitIdentity = typeof explicitPrIdentity === 'number'
288
+ ? resourceIdentity(explicitPrIdentity) : explicitPrIdentity;
228
289
  const invalid = candidates.find((candidate) => !hasCompleteExternalIdentity(candidate));
229
290
  if (invalid) return {
230
291
  status: 'failed', code: 'PR_IDENTITY_INVALID',
231
292
  message: `Closing PR #${invalid.number} lacks required identity`, candidates, eligible: []
232
293
  };
233
- const byNumber = new Map<number, string>();
294
+ const byIdentity = new Map<string, string>();
234
295
  for (const candidate of candidates) {
235
296
  const signature = identitySignature(candidate);
236
- const prior = byNumber.get(candidate.number);
297
+ const identityKey = JSON.stringify(pullRequestIdentity(candidate));
298
+ const prior = byIdentity.get(identityKey);
237
299
  if (prior && prior !== signature) return {
238
300
  status: 'failed', code: 'PR_IDENTITY_INVALID',
239
301
  message: `Closing PR #${candidate.number} has conflicting identities`, candidates, eligible: []
240
302
  };
241
- byNumber.set(candidate.number, signature);
303
+ byIdentity.set(identityKey, signature);
242
304
  }
243
305
  const unique = candidates.filter((candidate, index) =>
244
- candidates.findIndex((other) => other.number === candidate.number) === index
306
+ candidates.findIndex((other) => resourceIdentityEquals(pullRequestIdentity(other), pullRequestIdentity(candidate))) === index
245
307
  );
246
308
  const wantedRepository = repositoryKey(repository);
247
309
  const eligible = unique.filter((candidate) =>
248
310
  repositoryKey(candidate.repository) === wantedRepository &&
249
311
  repositoryKey(candidate.base.repository) === wantedRepository &&
312
+ candidate.state === 'closed' &&
250
313
  Boolean(candidate.mergedAt && candidate.mergeCommitSha)
251
314
  );
252
- if (explicitPrNumber !== null) {
253
- const selected = unique.find((candidate) => candidate.number === explicitPrNumber);
254
- if (!selected) return { status: 'failed', code: 'PR_NOT_FOUND', message: `PR #${explicitPrNumber} is not a closing PR`, candidates, eligible };
255
- if (!eligible.some((candidate) => candidate.number === explicitPrNumber)) {
256
- 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 };
257
320
  }
258
- if (existingPrNumber !== null && existingPrNumber !== explicitPrNumber) {
259
- 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 };
260
323
  }
261
324
  return { status: 'selected', source: 'explicit', selected, candidates, eligible };
262
325
  }
263
- if (existingPrNumber !== null && eligible.length === 1 && existingPrNumber !== eligible[0]!.number) {
264
- 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 };
265
328
  }
266
329
  if (eligible.length === 0) return { status: 'normal', candidates, eligible: [] };
267
330
  if (eligible.length > 1) return {
@@ -340,64 +403,8 @@ function selectPullRequest(remotes: RemotePullRequest[], repository: string, hea
340
403
  return { status: matches.length === 0 ? 'missing' : 'ambiguous', pullRequest: null };
341
404
  }
342
405
 
343
- function repositoryHead(cwd: string): string {
344
- return execFileSync('git', ['rev-parse', 'HEAD'], {
345
- cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe']
346
- }).trim();
347
- }
348
-
349
- function remoteBranchHead(cwd: string, repository: string, head: string):
350
- | { ok: true; value: string }
351
- | { ok: false; error: { code: string; message: string; retryable: boolean } } {
352
- const wanted = expectedHead(repository, head);
353
- if (!wanted.ref || !/^[A-Za-z0-9._/-]+$/.test(wanted.ref)) return {
354
- ok: false,
355
- error: { code: 'PR_HEAD_INVALID', message: 'Pull request head ref is invalid', retryable: false }
356
- };
357
- const remotes = configuredGitRemotes(cwd);
358
- const remote = remotes.find((item) => repositoryKey(item.repository) === repositoryKey(wanted.repository))
359
- ?? remotes.find((item) => item.name === 'origin');
360
- if (!remote) return {
361
- ok: false,
362
- error: { code: 'PR_REMOTE_BRANCH_MISSING', message: `No configured remote can verify ${wanted.repository}:${wanted.ref}`, retryable: false }
363
- };
364
- try {
365
- const output = execFileSync('git', ['ls-remote', '--refs', remote.name, `refs/heads/${wanted.ref}`], {
366
- cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe']
367
- }).trim();
368
- const match = output.split(/\r?\n/).map((line) => line.trim()).find((line) => line.endsWith(`refs/heads/${wanted.ref}`));
369
- const sha = match?.split(/\s+/)[0] || null;
370
- if (!sha || !/^[a-f0-9]{40}$/i.test(sha)) return {
371
- ok: false,
372
- error: { code: 'PR_REMOTE_BRANCH_MISSING', message: `Remote branch ${wanted.repository}:${wanted.ref} does not exist`, retryable: false }
373
- };
374
- return { ok: true, value: sha };
375
- } catch (error) {
376
- return {
377
- ok: false,
378
- error: { code: 'PR_REMOTE_BRANCH_UNAVAILABLE', message: error instanceof Error ? error.message : String(error), retryable: true }
379
- };
380
- }
381
- }
382
-
383
- function verifyCreateHead(base: ReturnType<typeof resolvedContext> & { ok: true }, head: string) {
384
- let localHead: string;
385
- try {
386
- localHead = repositoryHead(base.resolved.repoRoot);
387
- } catch (error) {
388
- return { ok: false as const, error: { code: 'PR_LOCAL_HEAD_UNAVAILABLE', message: error instanceof Error ? error.message : String(error), retryable: false } };
389
- }
390
- const remote = remoteBranchHead(base.resolved.repoRoot, base.context.platform.repository!, head);
391
- if (!remote.ok) return remote;
392
- if (remote.value !== localHead) return {
393
- ok: false as const,
394
- error: { code: 'PR_REMOTE_HEAD_MISMATCH', message: `Remote head ${remote.value} does not match local HEAD ${localHead}`, retryable: false }
395
- };
396
- return { ok: true as const, value: localHead };
397
- }
398
-
399
406
  function validateBoundPullRequest(
400
- base: ReturnType<typeof resolvedContext> & { ok: true },
407
+ base: Awaited<ReturnType<typeof resolvedContext>> & { ok: true },
401
408
  pullRequest: PullRequestSnapshot | null,
402
409
  head: string,
403
410
  baseRef: string,
@@ -421,24 +428,187 @@ function validateBoundPullRequest(
421
428
  return { ok: true };
422
429
  }
423
430
 
424
- function resolvedContext(taskRef: string, options: InspectionOptions) {
431
+ type BindingIdentityOptions = {
432
+ expectedHead?: string;
433
+ expectedHeadSha?: string;
434
+ errorCode?: string;
435
+ };
436
+
437
+ function resolveTaskDeliveryTarget(repoRoot: string, frontmatter: Record<string, unknown>) {
438
+ const existing = {
439
+ ...(typeof frontmatter.delivery_remote === 'string' && frontmatter.delivery_remote
440
+ ? { remote: frontmatter.delivery_remote } : {}),
441
+ ...(typeof frontmatter.delivery_base_ref === 'string' && frontmatter.delivery_base_ref
442
+ ? { baseRef: frontmatter.delivery_base_ref } : {})
443
+ };
444
+ const target = resolveDeliveryTarget(repoRoot, existing);
445
+ return target.ok
446
+ ? { ok: true as const, value: target.value }
447
+ : { ok: false as const, error: { code: target.code, message: target.message, retryable: false } };
448
+ }
449
+
450
+ function taskDeliveryTarget(base: Awaited<ReturnType<typeof resolvedContext>> & { ok: true }) {
451
+ return resolveTaskDeliveryTarget(base.resolved.repoRoot, base.frontmatter);
452
+ }
453
+
454
+ function validateIdentityAgainstTarget(
455
+ repository: string,
456
+ target: { remote: string; baseRef: string },
457
+ pullRequest: PullRequestSnapshot | null,
458
+ options: BindingIdentityOptions = {}
459
+ ): { ok: true } | { ok: false; error: { code: string; message: string; retryable: boolean } } {
460
+ if (!pullRequest || !hasCompleteExternalIdentity(pullRequest)) return {
461
+ ok: false,
462
+ error: { code: options.errorCode || 'PR_BIND_IDENTITY_MISMATCH', message: 'Pull request identity is incomplete', retryable: false }
463
+ };
464
+ const wantedHead = options.expectedHead ? expectedHead(repository, options.expectedHead) : null;
465
+ const matches = repositoryKey(pullRequest.repository) === repositoryKey(repository)
466
+ && repositoryKey(pullRequest.base.repository) === repositoryKey(repository)
467
+ && pullRequest.base.ref === target.baseRef
468
+ && (!wantedHead || (repositoryKey(pullRequest.head.repository) === repositoryKey(wantedHead.repository) && pullRequest.head.ref === wantedHead.ref))
469
+ && (!options.expectedHeadSha || pullRequest.head.sha === options.expectedHeadSha);
470
+ if (!matches) return {
471
+ ok: false,
472
+ error: {
473
+ code: options.errorCode || 'PR_BIND_IDENTITY_MISMATCH',
474
+ message: 'Pull request identity does not match the task branch and delivery target',
475
+ retryable: false
476
+ }
477
+ };
478
+ return { ok: true };
479
+ }
480
+
481
+ function validateWriterIdentity(
482
+ base: Awaited<ReturnType<typeof resolvedContext>> & { ok: true },
483
+ pullRequest: PullRequestSnapshot | null,
484
+ options: BindingIdentityOptions = {}
485
+ ): { ok: true } | { ok: false; error: { code: string; message: string; retryable: boolean } } {
486
+ const target = taskDeliveryTarget(base);
487
+ if (!target.ok) return target;
488
+ return validateIdentityAgainstTarget(base.context.platform.repository!, target.value, pullRequest, options);
489
+ }
490
+
491
+ function sameDeliveryIdentity(left: PullRequestSnapshot, right: PullRequestSnapshot): boolean {
492
+ return JSON.stringify(deliveryIdentity(left)) === JSON.stringify(deliveryIdentity(right));
493
+ }
494
+
495
+ function validateExternalMergedEvidence(
496
+ initial: PullRequestSnapshot,
497
+ rechecked: PullRequestSnapshot
498
+ ): { ok: true } | { ok: false; error: { code: string; message: string; retryable: boolean } } {
499
+ if (
500
+ initial.state !== 'closed'
501
+ || !initial.mergedAt
502
+ || !initial.mergeCommitSha
503
+ || rechecked.state !== 'closed'
504
+ || !rechecked.mergedAt
505
+ || !rechecked.mergeCommitSha
506
+ || initial.mergedAt !== rechecked.mergedAt
507
+ || initial.mergeCommitSha !== rechecked.mergeCommitSha
508
+ ) return {
509
+ ok: false,
510
+ error: { code: 'PR_EXTERNAL_IDENTITY_MISMATCH', message: 'Selected pull request merged evidence changed before task binding', retryable: false }
511
+ };
512
+ return { ok: true };
513
+ }
514
+
515
+ async function resolvedContext(taskRef: string, options: InspectionOptions) {
425
516
  const resolved = resolveTaskRef(taskRef, options.cwd ? { repoRoot: options.cwd } : {});
426
517
  if (!resolved.ok) return { ok: false as const, output: result('failed', resolved.taskId, null, null, {
427
518
  error: { code: resolved.code, message: resolved.message, retryable: false }
428
519
  }) };
429
520
  const content = fs.readFileSync(resolved.taskMdPath, 'utf8');
430
- const frontmatter = parseTaskFrontmatter(content);
431
- const issue = Number(frontmatter.issue_number);
432
- const pr = Number(frontmatter.pr_number);
433
- const issueNumber = Number.isInteger(issue) && issue > 0 ? issue : null;
434
- const prNumber = Number.isInteger(pr) && pr > 0 ? pr : null;
435
- const client = (options.client as GitHubClient | undefined) || createGitHubClient();
436
- const context = resolvePlatformContext({ cwd: resolved.repoRoot, client });
521
+ const frontmatter = parseTypedTaskFrontmatter(content);
522
+ const factRead = readPrDeliveryFact(frontmatter, options.runtimeVersion);
523
+ if (factRead.status === 'invalid') return { ok: false as const, output: result('failed', resolved.taskId, null, null, {
524
+ error: { code: factRead.error.code, message: factRead.error.message, retryable: false }
525
+ }) };
526
+ const fact = factRead.status === 'valid' ? factRead.fact : null;
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;
437
536
  const usable = (context.status === 'no-op' || context.status === 'degraded') && context.platform.repository;
438
- 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, {
439
538
  platform: context.platform, capabilities: context.capabilities, operations: context.operations, error: context.error
440
539
  }) };
441
- return { ok: true as const, resolved, content, frontmatter, 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
+ });
442
612
  }
443
613
 
444
614
  function inspectGitHubPullRequest(client: GitHubClient, repository: string, number: number, cwd: string) {
@@ -450,200 +620,348 @@ function inspectGitHubPullRequest(client: GitHubClient, repository: string, numb
450
620
  : { ok: false as const, error: { code: 'PR_IDENTITY_INVALID', message: 'Remote resource is not a valid pull request', retryable: false } };
451
621
  }
452
622
 
453
- function configuredGitRemotes(cwd: string): Array<{ name: string; url: string; repository: string }> {
454
- try {
455
- return execFileSync('git', ['remote'], {
456
- cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe']
457
- }).split(/\r?\n/).filter(Boolean).flatMap((name) => {
458
- try {
459
- const url = execFileSync('git', ['config', '--get', `remote.${name}.url`], {
460
- cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe']
461
- }).trim();
462
- const repository = parseGitHubRemote(url);
463
- return repository ? [{ name, url, repository }] : [];
464
- } catch {
465
- return [];
466
- }
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 }
651
+ });
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
467
666
  });
468
- } catch {
469
- return [];
470
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
+ });
471
673
  }
472
674
 
473
- function rewriteGitHubRemote(remote: string, repository: string): string | null {
474
- const match = remote.trim().match(
475
- /^(https?:\/\/github\.com\/|ssh:\/\/(?:git@)?github\.com\/|git@github\.com:)[^/\s]+\/[^/\s]+(?:\.git)?$/i
476
- );
477
- return match ? `${match[1]}${repository}.git` : null;
478
- }
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}}}}}';
479
676
 
480
- function resolveGitHubChangeRequestGitEvidence({
481
- cwd,
482
- repository,
483
- pullRequest
484
- }: {
485
- cwd: string;
486
- repository: string;
487
- pullRequest: PullRequestSnapshot;
488
- }) {
489
- if (
490
- pullRequest.repository !== repository ||
491
- pullRequest.base.repository !== repository ||
492
- pullRequest.number <= 0
493
- ) {
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) {
494
685
  return {
495
- ok: false as const,
496
- error: {
497
- code: 'PR_MERGE_EVIDENCE_SOURCE_UNAVAILABLE',
498
- message: 'Pull request repository identity is inconsistent',
499
- retryable: false
500
- }
686
+ ok: false,
687
+ error: { code: 'IN_LABEL_SYNC_CONFIG_INVALID', message: error instanceof Error ? error.message : String(error), retryable: false }
501
688
  };
502
689
  }
503
- const reviewedHeadRef = `refs/pull/${pullRequest.number}/head`;
504
- const targetHeadRef = `refs/heads/${pullRequest.base.ref}`;
505
- const refValid = (ref: string) => {
506
- try {
507
- execFileSync('git', ['check-ref-format', ref], {
508
- cwd, encoding: 'utf8', stdio: ['ignore', 'ignore', 'ignore']
509
- });
510
- return true;
511
- } catch {
512
- return false;
513
- }
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 }
514
702
  };
515
- 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) {
516
709
  return {
517
710
  ok: false as const,
518
- error: {
519
- code: 'PR_MERGE_EVIDENCE_SOURCE_UNAVAILABLE',
520
- message: 'Pull request Git refs are invalid',
521
- retryable: false
522
- }
711
+ error: { code: 'IN_LABEL_SYNC_EVIDENCE_UNAVAILABLE', message: error instanceof Error ? error.message : String(error), retryable: false }
523
712
  };
524
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
+ }
525
720
 
526
- const remotes = configuredGitRemotes(cwd);
527
- const exact = remotes
528
- .filter((remote) => remote.repository === repository)
529
- .sort((left, right) => Number(right.name === 'origin') - Number(left.name === 'origin') ||
530
- left.name.localeCompare(right.name))[0];
531
- const origin = remotes.find((remote) => remote.name === 'origin');
532
- const remoteUrl = exact?.url || (origin ? rewriteGitHubRemote(origin.url, repository) : null);
533
- if (!remoteUrl) {
534
- 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 {
535
740
  ok: false as const,
536
- error: {
537
- code: 'PR_MERGE_EVIDENCE_SOURCE_UNAVAILABLE',
538
- message: `No GitHub remote can provide evidence for ${repository}`,
539
- retryable: false
540
- }
741
+ error: { code: 'IN_LABEL_SYNC_ASSOCIATION_INVALID', message: 'Closing Issue response is incomplete', retryable: false }
541
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 }
755
+ };
756
+ cursor = connection.pageInfo.endCursor;
542
757
  }
543
- return {
544
- ok: true as const,
545
- value: { remoteUrl, reviewedHeadRef, targetHeadRef }
546
- };
547
758
  }
548
759
 
549
- registerPlatformCapabilities('github', {
550
- inspectChangeRequest({ client, repository, number, cwd }) {
551
- return inspectGitHubPullRequest((client as GitHubClient | undefined) || createGitHubClient(), repository, number, cwd);
552
- },
553
- inspectIssueClosingChangeRequests({ client, repository, issueNumber, cwd }) {
554
- return inspectGitHubIssueClosingChangeRequests(
555
- (client as GitHubClient | undefined) || createGitHubClient(), repository, issueNumber, cwd
556
- );
557
- },
558
- resolveChangeRequestGitEvidence(context) {
559
- return resolveGitHubChangeRequestGitEvidence(context);
560
- }
561
- });
562
-
563
- function locatePullRequest(base: ReturnType<typeof resolvedContext> & { ok: true }, head: string, target: string, expectedSha?: string) {
564
- const repository = base.context.platform.repository!;
565
- const listed = base.client.json<RemotePullRequest[]>([
566
- 'api', `repos/${repository}/pulls?state=open&base=${encodeURIComponent(target)}&per_page=100`
567
- ], { cwd: base.resolved.repoRoot });
568
- if (!listed.ok) return listed;
569
- const selected = selectPullRequest(listed.value, repository, head, target, expectedSha);
570
- return selected.status === 'resolved'
571
- ? { ok: true as const, value: selected.pullRequest }
572
- : { ok: false as const, error: {
573
- code: selected.status === 'ambiguous' ? 'PR_IDENTITY_AMBIGUOUS'
574
- : selected.status === 'head-mismatch' ? 'PR_HEAD_SHA_MISMATCH' : 'PR_NOT_FOUND',
575
- message: selected.status === 'ambiguous' ? 'Multiple pull requests match the exact head/base identity'
576
- : selected.status === 'head-mismatch' ? 'A pull request matches head/base but not the expected head SHA'
577
- : 'No pull request matches the exact head/base identity',
578
- retryable: false
579
- } };
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;
580
765
  }
581
766
 
582
- function inspectPlatformPullRequest(taskRef: string, options: InspectionOptions = {}): PullRequestResult {
583
- const base = resolvedContext(taskRef, options);
584
- if (!base.ok) return base.output;
585
- if (!base.prNumber) return result('no-op', base.resolved.taskId, base.issueNumber, null, {
586
- platform: base.context.platform, capabilities: base.context.capabilities,
587
- error: { code: 'PR_NOT_LINKED', message: 'Task has no valid pr_number', retryable: false }
588
- });
589
- const fetched = inspectPlatformChangeRequest(base.context.platform.type, {
590
- cwd: base.resolved.repoRoot,
591
- repository: base.context.platform.repository!,
592
- number: base.prNumber,
593
- client: options.client || base.client
594
- });
595
- if (!fetched.ok || !fetched.value) {
596
- const error = fetched.error || {
597
- code: 'PR_INSPECTION_INVALID',
598
- message: 'Platform adapter returned no change-request snapshot',
599
- retryable: false
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 }
774
+ };
775
+ }
776
+
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 }
600
798
  };
601
- return result(error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
602
- platform: base.context.platform, capabilities: base.context.capabilities,
603
- resource: { kind: 'pull-request', number: base.prNumber }, error
604
- });
605
799
  }
606
- return result('no-op', base.resolved.taskId, base.issueNumber, base.prNumber, {
607
- platform: base.context.platform, capabilities: base.context.capabilities,
608
- resource: { kind: 'pull-request', number: base.prNumber }, pullRequest: fetched.value, error: null
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
609
806
  });
807
+ if (planned.error) return { ok: false as const, error: planned.error };
808
+ return { ok: true as const, value: { changedFiles, target: planned.target } };
809
+ }
810
+
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 };
610
820
  }
611
821
 
612
- function inspectPlatformPullRequestByNumber(prNumber: number, options: InspectionOptions = {}): PullRequestResult {
613
- const client = (options.client as GitHubClient | undefined) || createGitHubClient();
614
- const context = resolvePlatformContext({ cwd: options.cwd || process.cwd(), client });
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 });
615
826
  const usable = (context.status === 'no-op' || context.status === 'degraded') && context.platform.repository;
616
- if (!usable) {
617
- return result(context.status, null, null, null, {
618
- platform: context.platform, capabilities: context.capabilities, operations: context.operations, error: context.error
827
+ if (!usable) return result(context.status, null, null, prNumber, {
828
+ platform: context.platform, capabilities: context.capabilities, operations: context.operations, error: context.error
829
+ });
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 }
833
+ });
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
838
+ });
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
867
+ });
868
+
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
619
876
  });
877
+ issue = fetchedIssue.value;
620
878
  }
621
- if (!Number.isInteger(prNumber) || prNumber <= 0) {
622
- return result('failed', null, null, null, {
623
- platform: context.platform, capabilities: context.capabilities,
624
- 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}` }
625
947
  });
626
- }
627
- const fetched = inspectPlatformChangeRequest(context.platform.type, {
628
- cwd: options.cwd || process.cwd(),
629
- repository: context.platform.repository!,
630
- number: prNumber,
631
- client
632
- });
633
- if (!fetched.ok || !fetched.value) {
634
- const error = fetched.error || {
635
- code: 'PR_INSPECTION_INVALID',
636
- message: 'Platform adapter returned no change-request snapshot',
637
- retryable: false
638
- };
639
- return result(error.retryable ? 'blocked' : 'failed', null, null, prNumber, {
640
- platform: context.platform, capabilities: context.capabilities,
641
- 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 }
642
957
  });
958
+ if (item.kind === 'pull-request') currentPullRequest = reread.value as PullRequestSnapshot;
643
959
  }
644
- return result('no-op', null, null, prNumber, {
645
- platform: context.platform, capabilities: context.capabilities,
646
- 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
647
965
  });
648
966
  }
649
967
 
@@ -652,7 +970,38 @@ function appendActivity(content: string, line: string): string {
652
970
  return `${body.replace(/\s+$/, '')}${body.trim() ? '\n' : ''}${line}`;
653
971
  }
654
972
 
655
- function writeCreateStarted(base: ReturnType<typeof resolvedContext> & { ok: true }, agent: string) {
973
+ function deliveryIdentity(pullRequest: PullRequestSnapshot) {
974
+ return {
975
+ resource: pullRequestIdentity(pullRequest),
976
+ repository: pullRequest.repository,
977
+ url: pullRequest.url,
978
+ head: { ...pullRequest.head },
979
+ base: { ...pullRequest.base }
980
+ };
981
+ }
982
+
983
+ function factTimestamp(timestamp: string): string {
984
+ return new Date(timestamp.replace(' ', 'T')).toISOString();
985
+ }
986
+
987
+ function boundFactFor(
988
+ pullRequest: PullRequestSnapshot,
989
+ source: PrDeliveryBindingSource,
990
+ issue: number | ResourceIdentity | null,
991
+ verifiedAt: string
992
+ ): PrDeliveryFact {
993
+ return buildBoundFact({
994
+ identity: deliveryIdentity(pullRequest),
995
+ source,
996
+ ...(typeof issue === 'object' ? { issueIdentity: issue } : { issueNumber: issue }),
997
+ verifiedAt: factTimestamp(verifiedAt),
998
+ remoteState: pullRequest.state,
999
+ mergedAt: pullRequest.state === 'closed' ? pullRequest.mergedAt : null,
1000
+ mergeCommitSha: pullRequest.state === 'closed' ? pullRequest.mergeCommitSha : null
1001
+ });
1002
+ }
1003
+
1004
+ function writeCreateStarted(base: Awaited<ReturnType<typeof resolvedContext>> & { ok: true }, agent: string) {
656
1005
  const starts = (base.content.match(/\*\*Create PR \[started\]\*\*/g) || []).length;
657
1006
  const dones = (base.content.match(/\*\*Create PR\*\*/g) || []).length;
658
1007
  if (starts > dones) return { status: 'no-op' as const };
@@ -666,19 +1015,40 @@ function writeCreateStarted(base: ReturnType<typeof resolvedContext> & { ok: tru
666
1015
  }, { repoRoot: base.resolved.repoRoot, metadataProvider: () => metadata });
667
1016
  }
668
1017
 
669
- function bindIdentity(base: ReturnType<typeof resolvedContext> & { ok: true }, pullRequest: PullRequestSnapshot, agent: string, dryRun = false) {
670
- if (base.prNumber && base.prNumber !== pullRequest.number) return { status: 'failed' as const, error: {
671
- code: 'PR_BIND_CONFLICT', message: `Task is already bound to PR #${base.prNumber}`
1018
+ function bindIdentity(
1019
+ base: Awaited<ReturnType<typeof resolvedContext>> & { ok: true },
1020
+ pullRequest: PullRequestSnapshot,
1021
+ agent: string,
1022
+ source: PrDeliveryBindingSource,
1023
+ dryRun = false,
1024
+ expectedHead?: string,
1025
+ expectedHeadSha?: string
1026
+ ) {
1027
+ const identity = validateWriterIdentity(base, pullRequest, {
1028
+ expectedHead: source === 'explicit-bind' ? String(base.frontmatter.branch || '') : expectedHead,
1029
+ expectedHeadSha,
1030
+ errorCode: 'PR_BIND_IDENTITY_MISMATCH'
1031
+ });
1032
+ if (!identity.ok) return { status: 'failed' as const, error: identity.error };
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'
672
1035
  } };
673
- if (base.prNumber === pullRequest.number) return { status: 'no-op' as const, error: null };
1036
+ if (base.prIdentity && resourceIdentityEquals(base.prIdentity, pullRequestIdentity(pullRequest)) && base.fact?.state === 'bound') {
1037
+ if (JSON.stringify(base.fact.identity) !== JSON.stringify(deliveryIdentity(pullRequest))) return { status: 'failed' as const, error: {
1038
+ code: 'PR_BIND_IDENTITY_MISMATCH', message: `Task binding for PR #${pullRequest.number} does not match the fetched pull request identity`
1039
+ } };
1040
+ return { status: 'no-op' as const, error: null };
1041
+ }
674
1042
  const current = fs.readFileSync(base.resolved.taskMdPath, 'utf8');
675
1043
  const metadata = captureTaskWriteMetadata();
1044
+ const fact = boundFactFor(pullRequest, source, taskIssueIdentity(base.frontmatter), metadata.timestamp);
1045
+ const factMutation = factFrontmatterMutation(fact);
676
1046
  return writeTask({
677
1047
  taskRef: base.resolved.taskId,
678
1048
  expectedState: 'active',
679
1049
  dryRun,
680
1050
  mutations: [
681
- { kind: 'frontmatter', set: { pr_number: pullRequest.number, pr_status: 'created', assigned_to: agent } },
1051
+ { kind: 'frontmatter', set: { ...factMutation.set, assigned_to: agent } },
682
1052
  { kind: 'section', aliases: ['活动日志', 'Activity Log'], heading: '活动日志', body: appendActivity(
683
1053
  current, `- ${metadata.timestamp} — **Create PR** by ${agent} — PR #${pullRequest.number} created → ${pullRequest.url}`
684
1054
  ) }
@@ -686,45 +1056,59 @@ function bindIdentity(base: ReturnType<typeof resolvedContext> & { ok: true }, p
686
1056
  }, { repoRoot: base.resolved.repoRoot, metadataProvider: () => metadata });
687
1057
  }
688
1058
 
689
- function bindPlatformPullRequest(taskRef: string, options: BindOptions): PullRequestResult {
690
- const base = resolvedContext(taskRef, options);
1059
+ async function bindPlatformPullRequest(taskRef: string, options: BindOptions): Promise<PullRequestResult> {
1060
+ const base = await resolvedContext(taskRef, options);
691
1061
  if (!base.ok) return base.output;
692
- if (!Number.isInteger(options.pr) || options.pr <= 0) return result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
693
- error: { code: 'PR_NUMBER_INVALID', message: 'PR number must be positive', retryable: false }
1062
+ if (!base.fact) return result('failed', base.resolved.taskId, base.issueNumber, null, {
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 }
1064
+ });
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 }
694
1071
  });
695
- if (base.prNumber && base.prNumber !== options.pr) return result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
696
- error: { code: 'PR_BIND_CONFLICT', message: `Task is already bound to PR #${base.prNumber}`, retryable: false }
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, {
1075
+ expectedHead: String(base.frontmatter.branch || ''),
1076
+ errorCode: 'PR_BIND_IDENTITY_MISMATCH'
697
1077
  });
698
- const fetched = inspectGitHubPullRequest(base.client, base.context.platform.repository!, options.pr, base.resolved.repoRoot);
699
- if (!fetched.ok) return result(fetched.error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, base.prNumber, { error: fetched.error });
700
- const written = bindIdentity(base, fetched.value, options.agent, options.dryRun);
701
- if (written.status === 'failed') return result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
702
- pullRequest: fetched.value, error: { code: written.error.code, message: written.error.message, retryable: false }
1078
+ if (!initialIdentity.ok) return result('failed', base.resolved.taskId, base.issueNumber, requestedNumber, {
1079
+ pullRequest: inspected.pullRequest, error: initialIdentity.error
703
1080
  });
704
- return result(options.dryRun ? 'planned' : written.status === 'no-op' ? 'no-op' : 'applied', base.resolved.taskId, base.issueNumber, options.dryRun ? base.prNumber : fetched.value.number, {
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 }
1084
+ });
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, {
705
1086
  changed: !options.dryRun && written.status === 'applied', platform: base.context.platform, capabilities: base.context.capabilities,
706
- resource: { kind: 'pull-request', number: fetched.value.number }, pullRequest: fetched.value,
1087
+ resource: { kind: 'pull-request', number: inspected.pullRequest.number, identity: pullRequestIdentity(inspected.pullRequest) }, pullRequest: inspected.pullRequest,
707
1088
  operations: [{ name: 'task:bind-pr', status: options.dryRun ? 'planned' : written.status === 'no-op' ? 'no-op' : 'applied', reasonCode: null }], error: null
708
1089
  });
709
1090
  }
710
1091
 
711
1092
  function externalEvidenceNote(
712
- issueNumber: number,
1093
+ issueIdentity: ResourceIdentity | null,
713
1094
  source: 'unique' | 'explicit',
714
1095
  pullRequest: PullRequestSnapshot
715
1096
  ): string {
716
1097
  return [
717
1098
  `authorization=${source}`,
718
- externalIdentityEvidenceNote(issueNumber, pullRequest)
1099
+ externalIdentityEvidenceNote(issueIdentity, pullRequest)
719
1100
  ].join('; ');
720
1101
  }
721
1102
 
722
1103
  function externalIdentityEvidenceNote(
723
- issueNumber: number,
1104
+ issueIdentity: ResourceIdentity | null,
724
1105
  pullRequest: PullRequestSnapshot
725
1106
  ): string {
1107
+ const issueLabel = issueIdentity
1108
+ ? issueIdentity.kind === 'number' ? `#${issueIdentity.value}` : `${issueIdentity.kind}:${issueIdentity.value}`
1109
+ : 'unknown';
726
1110
  return [
727
- `issue=#${issueNumber}`,
1111
+ `issue=${issueLabel}`,
728
1112
  `pr=#${pullRequest.number}`,
729
1113
  `url=${pullRequest.url}`,
730
1114
  `base=${pullRequest.base.repository}:${pullRequest.base.ref}@${pullRequest.base.sha}`,
@@ -734,9 +1118,12 @@ function externalIdentityEvidenceNote(
734
1118
  ].join('; ');
735
1119
  }
736
1120
 
737
- function resolveExternalPullRequest(taskRef: string, options: ResolveExternalOptions): ExternalPullRequestResult {
738
- const base = resolvedContext(taskRef, options);
1121
+ async function resolveExternalPullRequest(taskRef: string, options: ResolveExternalOptions): Promise<ExternalPullRequestResult> {
1122
+ const base = await resolvedContext(taskRef, options);
739
1123
  if (!base.ok) return externalResult(base.output);
1124
+ if (!base.fact) return externalResult(result('failed', base.resolved.taskId, base.issueNumber, null, {
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 }
1126
+ }));
740
1127
  const inventory = inspectCompletionArtifacts(base.resolved.taskId, { repoRoot: base.resolved.repoRoot });
741
1128
  if (inventory.status === 'failed') return externalResult(result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
742
1129
  platform: base.context.platform,
@@ -756,28 +1143,41 @@ function resolveExternalPullRequest(taskRef: string, options: ResolveExternalOpt
756
1143
  error: null
757
1144
  }), { mode: 'normal' });
758
1145
  }
759
- 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, {
760
1147
  platform: base.context.platform,
761
1148
  capabilities: base.context.capabilities,
762
1149
  error: { code: 'EXTERNAL_DELIVERY_ISSUE_REQUIRED', message: 'External delivery requires a valid issue_number', retryable: false }
763
1150
  }));
764
- const inspected = inspectPlatformIssueClosingChangeRequests(base.context.platform.type, {
765
- cwd: base.resolved.repoRoot,
766
- repository: base.context.platform.repository!,
767
- issueNumber: base.issueNumber,
768
- client: base.client
769
- });
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');
770
1157
  if (!inspected.ok || !inspected.value) {
771
- 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 };
772
1161
  return externalResult(result(error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
773
1162
  platform: base.context.platform, capabilities: base.context.capabilities, error
774
1163
  }));
775
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
+ }
776
1176
  const selected = selectExternalPullRequest(
777
- inspected.value,
1177
+ candidates,
778
1178
  base.context.platform.repository!,
779
- base.prNumber,
780
- options.pr ?? null
1179
+ base.prIdentity,
1180
+ explicitPrIdentity
781
1181
  );
782
1182
  if (selected.status === 'normal') return externalResult(result('no-op', base.resolved.taskId, base.issueNumber, base.prNumber, {
783
1183
  platform: base.context.platform,
@@ -791,32 +1191,66 @@ function resolveExternalPullRequest(taskRef: string, options: ResolveExternalOpt
791
1191
  error: { code: selected.code, message: selected.message, retryable: false }
792
1192
  }), { candidates: selected.candidates, eligible: selected.eligible });
793
1193
 
794
- const note = externalEvidenceNote(base.issueNumber, selected.source, selected.selected);
795
- const identityNote = externalIdentityEvidenceNote(base.issueNumber, selected.selected);
1194
+ const initialIdentity = validateWriterIdentity(base, selected.selected, { errorCode: 'PR_EXTERNAL_IDENTITY_MISMATCH' });
1195
+ if (!initialIdentity.ok) return externalResult(result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
1196
+ platform: base.context.platform, capabilities: base.context.capabilities,
1197
+ pullRequest: selected.selected, error: initialIdentity.error
1198
+ }), { candidates: selected.candidates, eligible: selected.eligible, selected: selected.selected });
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');
1207
+ if (!rechecked.ok || !rechecked.value) return externalResult(result(rechecked.ok ? 'failed' : rechecked.error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
1208
+ platform: base.context.platform, capabilities: base.context.capabilities,
1209
+ pullRequest: selected.selected,
1210
+ error: rechecked.ok ? { code: 'PR_EXTERNAL_RECHECK_FAILED', message: 'Selected pull request recheck returned no identity', retryable: false } : rechecked.error
1211
+ }), { candidates: selected.candidates, eligible: selected.eligible, selected: selected.selected });
1212
+ if (!sameDeliveryIdentity(selected.selected, rechecked.value)) return externalResult(result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
1213
+ platform: base.context.platform, capabilities: base.context.capabilities,
1214
+ pullRequest: rechecked.value,
1215
+ error: { code: 'PR_EXTERNAL_IDENTITY_MISMATCH', message: 'Selected pull request identity changed before task binding', retryable: false }
1216
+ }), { candidates: selected.candidates, eligible: selected.eligible, selected: rechecked.value });
1217
+ const finalIdentity = validateWriterIdentity(base, rechecked.value, { errorCode: 'PR_EXTERNAL_IDENTITY_MISMATCH' });
1218
+ if (!finalIdentity.ok) return externalResult(result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
1219
+ platform: base.context.platform, capabilities: base.context.capabilities,
1220
+ pullRequest: rechecked.value, error: finalIdentity.error
1221
+ }), { candidates: selected.candidates, eligible: selected.eligible, selected: rechecked.value });
1222
+ const finalEvidence = validateExternalMergedEvidence(selected.selected, rechecked.value);
1223
+ if (!finalEvidence.ok) return externalResult(result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
1224
+ platform: base.context.platform, capabilities: base.context.capabilities,
1225
+ pullRequest: rechecked.value, error: finalEvidence.error
1226
+ }), { candidates: selected.candidates, eligible: selected.eligible, selected: rechecked.value });
1227
+ const selectedPullRequest = rechecked.value;
1228
+ const note = externalEvidenceNote(base.issueIdentity, selected.source, selectedPullRequest);
1229
+ const identityNote = externalIdentityEvidenceNote(base.issueIdentity, selectedPullRequest);
796
1230
  const activityEntry = `**Bind External PR** by ${options.agent} — ${note}`;
797
1231
  const auditLines = base.content.split(/\r?\n/).filter((line) => line.includes('**Bind External PR**'));
798
1232
  const alreadyAudited = auditLines.some((line) => line.endsWith(identityNote));
799
1233
  if (auditLines.length > 0 && !alreadyAudited) return externalResult(result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
800
1234
  platform: base.context.platform,
801
1235
  capabilities: base.context.capabilities,
802
- pullRequest: selected.selected,
1236
+ pullRequest: selectedPullRequest,
803
1237
  error: { code: 'PR_BIND_CONFLICT', message: 'Existing external PR audit evidence conflicts with the selected identity', retryable: false }
804
- }), { candidates: selected.candidates, eligible: selected.eligible, selected: selected.selected });
805
- if (alreadyAudited && base.prNumber !== selected.selected.number) return externalResult(result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
1238
+ }), { candidates: selected.candidates, eligible: selected.eligible, selected: selectedPullRequest });
1239
+ if (alreadyAudited && base.prIdentity && !resourceIdentityEquals(base.prIdentity, pullRequestIdentity(selectedPullRequest))) return externalResult(result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
806
1240
  platform: base.context.platform,
807
1241
  capabilities: base.context.capabilities,
808
- pullRequest: selected.selected,
1242
+ pullRequest: selectedPullRequest,
809
1243
  error: { code: 'PR_BIND_CONFLICT', message: 'External PR audit evidence does not match the task binding', retryable: false }
810
- }), { candidates: selected.candidates, eligible: selected.eligible, selected: selected.selected });
811
- if (base.prNumber === selected.selected.number && alreadyAudited) {
1244
+ }), { candidates: selected.candidates, eligible: selected.eligible, selected: selectedPullRequest });
1245
+ if (base.prIdentity && resourceIdentityEquals(base.prIdentity, pullRequestIdentity(selectedPullRequest)) && alreadyAudited) {
812
1246
  return externalResult(result('no-op', base.resolved.taskId, base.issueNumber, base.prNumber, {
813
1247
  platform: base.context.platform,
814
1248
  capabilities: base.context.capabilities,
815
- resource: { kind: 'pull-request', number: selected.selected.number },
816
- pullRequest: selected.selected,
1249
+ resource: { kind: 'pull-request', number: selectedPullRequest.number },
1250
+ pullRequest: selectedPullRequest,
817
1251
  operations: [{ name: 'task:bind-external-pr', status: 'no-op', reasonCode: 'EVIDENCE_ALREADY_RECORDED' }],
818
1252
  error: null
819
- }), { mode: 'external', authorization: selected.source, candidates: selected.candidates, eligible: selected.eligible, selected: selected.selected });
1253
+ }), { mode: 'external', authorization: selected.source, candidates: selected.candidates, eligible: selected.eligible, selected: selectedPullRequest });
820
1254
  }
821
1255
  const metadata = captureTaskWriteMetadata();
822
1256
  const write = writeTask({
@@ -824,7 +1258,7 @@ function resolveExternalPullRequest(taskRef: string, options: ResolveExternalOpt
824
1258
  expectedState: 'active',
825
1259
  dryRun: options.dryRun,
826
1260
  mutations: [
827
- { kind: 'frontmatter', set: { pr_number: selected.selected.number, pr_status: 'created', assigned_to: options.agent } },
1261
+ { kind: 'frontmatter', set: { ...factFrontmatterMutation(boundFactFor(selectedPullRequest, selected.source === 'unique' ? 'external-unique' : 'external-explicit', base.issueNumber, metadata.timestamp)).set, assigned_to: options.agent } },
828
1262
  { kind: 'section', aliases: ['活动日志', 'Activity Log'], heading: '活动日志', body: appendActivity(
829
1263
  base.content, `- ${metadata.timestamp} — ${activityEntry}`
830
1264
  ) }
@@ -833,173 +1267,45 @@ function resolveExternalPullRequest(taskRef: string, options: ResolveExternalOpt
833
1267
  if (write.status === 'failed') return externalResult(result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
834
1268
  platform: base.context.platform,
835
1269
  capabilities: base.context.capabilities,
836
- pullRequest: selected.selected,
1270
+ pullRequest: selectedPullRequest,
837
1271
  error: { code: write.error.code, message: write.error.message, retryable: false }
838
- }), { candidates: selected.candidates, eligible: selected.eligible, selected: selected.selected });
1272
+ }), { candidates: selected.candidates, eligible: selected.eligible, selected: selectedPullRequest });
839
1273
  const operationStatus = options.dryRun ? 'planned' : write.status === 'applied' ? 'applied' : 'no-op';
840
- return externalResult(result(operationStatus, base.resolved.taskId, base.issueNumber, options.dryRun ? base.prNumber : selected.selected.number, {
1274
+ return externalResult(result(operationStatus, base.resolved.taskId, base.issueNumber, options.dryRun ? base.prNumber : selectedPullRequest.number, {
841
1275
  changed: !options.dryRun && write.changed,
842
1276
  platform: base.context.platform,
843
1277
  capabilities: base.context.capabilities,
844
- resource: { kind: 'pull-request', number: selected.selected.number },
845
- pullRequest: selected.selected,
1278
+ resource: { kind: 'pull-request', number: selectedPullRequest.number },
1279
+ pullRequest: selectedPullRequest,
846
1280
  operations: [{ name: 'task:bind-external-pr', status: operationStatus, reasonCode: null }],
847
1281
  error: null
848
- }), { mode: 'external', authorization: selected.source, candidates: selected.candidates, eligible: selected.eligible, selected: selected.selected });
849
- }
850
-
851
- function createPlatformPullRequestUnlocked(taskRef: string, options: CreateOptions): PullRequestResult {
852
- const base = resolvedContext(taskRef, options);
853
- if (!base.ok) return base.output;
854
- if (!options.base || !options.head || !options.title.trim() || !options.body.trim()) return result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
855
- error: { code: 'PR_PAYLOAD_INVALID', message: 'base, head, title and body are required', retryable: false }
856
- });
857
- if (base.prNumber) {
858
- const headCheck = verifyCreateHead(base, options.head);
859
- if (!headCheck.ok) return result(headCheck.error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
860
- platform: base.context.platform,
861
- capabilities: base.context.capabilities,
862
- error: headCheck.error
863
- });
864
- const inspected = inspectPlatformPullRequest(taskRef, options);
865
- const identity = validateBoundPullRequest(base, inspected.pullRequest, options.head, options.base, headCheck.value);
866
- if (!identity.ok) return result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
867
- platform: base.context.platform,
868
- capabilities: base.context.capabilities,
869
- pullRequest: inspected.pullRequest,
870
- error: identity.error
871
- });
872
- return { ...inspected, result: 'no_op' };
873
- }
874
- const headCheck = verifyCreateHead(base, options.head);
875
- if (!headCheck.ok) return result(headCheck.error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, null, {
876
- platform: base.context.platform,
877
- capabilities: base.context.capabilities,
878
- error: headCheck.error
879
- });
880
- const expectedHeadSha = headCheck.value;
881
- const located = locatePullRequest(base, options.head, options.base, expectedHeadSha);
882
- if (!located.ok && located.error.code === 'PR_IDENTITY_AMBIGUOUS') return result('failed', base.resolved.taskId, base.issueNumber, null, {
883
- platform: base.context.platform, capabilities: base.context.capabilities, error: located.error
884
- });
885
- if (!located.ok && located.error.code === 'PR_HEAD_SHA_MISMATCH') return result('failed', base.resolved.taskId, base.issueNumber, null, {
886
- platform: base.context.platform, capabilities: base.context.capabilities, error: located.error
887
- });
888
- if (options.dryRun) return result('planned', base.resolved.taskId, base.issueNumber, null, {
889
- platform: base.context.platform, capabilities: base.context.capabilities,
890
- operations: [{ name: located.ok ? 'pr:reuse' : 'pr:create', status: 'planned', reasonCode: null }],
891
- result: located.ok ? 'pr_reused' : 'pr_created', error: null
892
- });
893
- const started = writeCreateStarted(base, options.agent);
894
- if (started.status === 'failed') return result('failed', base.resolved.taskId, base.issueNumber, null, {
895
- error: { code: started.error.code, message: started.error.message, retryable: false }
896
- });
897
- let pullRequest = located.ok ? located.value : null;
898
- let created = false;
899
- if (!pullRequest) {
900
- const repository = base.context.platform.repository!;
901
- const response = base.client.json<RemotePullRequest>(['api', `repos/${repository}/pulls`, '-X', 'POST', '--input', '-'], {
902
- cwd: base.resolved.repoRoot,
903
- method: 'POST',
904
- input: JSON.stringify({ title: options.title, body: options.body, head: options.head, base: options.base, draft: Boolean(options.draft) })
905
- });
906
- if (response.ok) pullRequest = normalizePullRequest(response.value, repository);
907
- else if (response.error.retryable) {
908
- const recovered = locatePullRequest(base, options.head, options.base, expectedHeadSha);
909
- if (recovered.ok) pullRequest = recovered.value;
910
- else return result('blocked', base.resolved.taskId, base.issueNumber, null, {
911
- platform: base.context.platform, capabilities: base.context.capabilities,
912
- operations: [{ name: 'pr:create', status: 'failed', reasonCode: 'PR_CREATE_OUTCOME_UNKNOWN' }],
913
- error: { code: 'PR_CREATE_OUTCOME_UNKNOWN', message: response.error.message, retryable: true }
914
- });
915
- } else return result('failed', base.resolved.taskId, base.issueNumber, null, {
916
- platform: base.context.platform, capabilities: base.context.capabilities,
917
- operations: [{ name: 'pr:create', status: 'failed', reasonCode: response.error.code }], error: response.error
918
- });
919
- if (!pullRequest) return result('failed', base.resolved.taskId, base.issueNumber, null, {
920
- error: { code: 'PR_CREATE_RESPONSE_INVALID', message: 'PR create response lacks validated identity', retryable: false }
921
- });
922
- if (pullRequest.head.sha !== expectedHeadSha) return result('failed', base.resolved.taskId, base.issueNumber, pullRequest.number, {
923
- platform: base.context.platform, capabilities: base.context.capabilities,
924
- resource: { kind: 'pull-request', number: pullRequest.number }, pullRequest,
925
- error: { code: 'PR_HEAD_SHA_MISMATCH', message: 'Created pull request head does not match the expected local HEAD', retryable: false }
926
- });
927
- created = true;
928
- }
929
- const bindHeadCheck = verifyCreateHead(base, options.head);
930
- if (!bindHeadCheck.ok || bindHeadCheck.value !== expectedHeadSha) {
931
- const error = bindHeadCheck.ok
932
- ? { code: 'PR_REMOTE_HEAD_MISMATCH', message: 'Remote head changed before task binding', retryable: false }
933
- : bindHeadCheck.error;
934
- return result(error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, pullRequest.number, {
935
- platform: base.context.platform, capabilities: base.context.capabilities,
936
- resource: { kind: 'pull-request', number: pullRequest.number }, pullRequest, error
937
- });
938
- }
939
- const beforeBind = inspectGitHubPullRequest(base.client, base.context.platform.repository!, pullRequest.number, base.resolved.repoRoot);
940
- if (!beforeBind.ok || !beforeBind.value) {
941
- const error = beforeBind.ok
942
- ? { code: 'PR_BIND_RECHECK_FAILED', message: 'Pull request recheck returned no identity', retryable: false }
943
- : beforeBind.error;
944
- return result(error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, pullRequest.number, {
945
- platform: base.context.platform, capabilities: base.context.capabilities,
946
- resource: { kind: 'pull-request', number: pullRequest.number }, pullRequest, error
947
- });
948
- }
949
- if (
950
- beforeBind.value.head.sha !== expectedHeadSha
951
- || beforeBind.value.head.ref !== expectedHead(base.context.platform.repository!, options.head).ref
952
- || beforeBind.value.base.ref !== options.base
953
- || beforeBind.value.base.repository !== base.context.platform.repository
954
- ) return result('failed', base.resolved.taskId, base.issueNumber, pullRequest.number, {
955
- platform: base.context.platform, capabilities: base.context.capabilities,
956
- resource: { kind: 'pull-request', number: pullRequest.number }, pullRequest: beforeBind.value,
957
- error: { code: 'PR_BIND_IDENTITY_MISMATCH', message: 'Pull request identity changed before task binding', retryable: false }
958
- });
959
- pullRequest = beforeBind.value;
960
- const refreshed = resolvedContext(taskRef, options);
961
- if (!refreshed.ok) return result('failed', base.resolved.taskId, base.issueNumber, null, {
962
- pullRequest, error: { code: 'PR_CREATED_BIND_FAILED', message: pullRequest.url, retryable: false }
963
- });
964
- const bound = bindIdentity(refreshed, pullRequest, options.agent);
965
- if (bound.status === 'failed') return result('failed', base.resolved.taskId, base.issueNumber, null, {
966
- platform: base.context.platform, capabilities: base.context.capabilities,
967
- resource: { kind: 'pull-request', number: pullRequest.number }, pullRequest,
968
- operations: [{ name: created ? 'pr:create' : 'pr:reuse', status: 'applied', reasonCode: null }, { name: 'task:bind-pr', status: 'failed', reasonCode: bound.error.code }],
969
- error: { code: 'PR_CREATED_BIND_FAILED', message: `${pullRequest.url}: ${bound.error.message}`, retryable: false }
970
- });
971
- return result('applied', base.resolved.taskId, base.issueNumber, pullRequest.number, {
972
- changed: true, platform: base.context.platform, capabilities: base.context.capabilities,
973
- resource: { kind: 'pull-request', number: pullRequest.number }, pullRequest,
974
- operations: [{ name: created ? 'pr:create' : 'pr:reuse', status: created ? 'applied' : 'no-op', reasonCode: null }, { name: 'task:bind-pr', status: 'applied', reasonCode: null }],
975
- result: created ? 'pr_created' : 'pr_reused', error: null
976
- });
1282
+ }), { mode: 'external', authorization: selected.source, candidates: selected.candidates, eligible: selected.eligible, selected: selectedPullRequest });
977
1283
  }
978
1284
 
979
- function createPlatformPullRequest(taskRef: string, options: CreateOptions): PullRequestResult {
980
- const base = resolvedContext(taskRef, options);
981
- if (!base.ok) return base.output;
1285
+ async function createPlatformPullRequest(taskRef: string, options: CreateOptions): Promise<PullRequestResult> {
1286
+ const base = await resolvedContext(taskRef, options);
1287
+ if (!base.ok) return withCreation(base.output, PRECONDITION_NOT_CREATED);
982
1288
  if (!options.base || !options.head || !options.title.trim() || !options.body.trim()) {
983
- return result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
1289
+ return withCreation(result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
984
1290
  error: { code: 'PR_PAYLOAD_INVALID', message: 'base, head, title and body are required', retryable: false }
985
- });
1291
+ }), PRECONDITION_NOT_CREATED);
986
1292
  }
987
1293
  try {
988
1294
  return withTaskExecutionLock(
989
1295
  base.resolved.repoRoot,
990
1296
  base.resolved.taskId,
991
1297
  'platform-pr.create',
992
- () => createPlatformPullRequestUnlocked(taskRef, options)
1298
+ () => createExternalPullRequest(taskRef, options, base)
993
1299
  );
994
1300
  } catch (error) {
995
1301
  if (error instanceof TaskExecutionLockError) {
996
- return result('blocked', base.resolved.taskId, base.issueNumber, null, {
1302
+ return withCreation(result('blocked', base.resolved.taskId, base.issueNumber, null, {
997
1303
  platform: base.context.platform,
998
1304
  capabilities: base.context.capabilities,
999
1305
  error: { code: error.code, message: error.message, retryable: true }
1000
- });
1306
+ }), PRECONDITION_NOT_CREATED);
1001
1307
  }
1002
- return result('failed', base.resolved.taskId, base.issueNumber, null, {
1308
+ return withCreation(result('failed', base.resolved.taskId, base.issueNumber, null, {
1003
1309
  platform: base.context.platform,
1004
1310
  capabilities: base.context.capabilities,
1005
1311
  error: {
@@ -1007,14 +1313,146 @@ function createPlatformPullRequest(taskRef: string, options: CreateOptions): Pul
1007
1313
  message: error instanceof Error ? error.message : String(error),
1008
1314
  retryable: false
1009
1315
  }
1316
+ }), PRECONDITION_NOT_CREATED);
1317
+ }
1318
+ }
1319
+
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
1010
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 });
1011
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 });
1012
1448
  }
1013
1449
 
1014
- function syncPlatformPullRequest(taskRef: string, options: SyncOptions): PullRequestResult {
1450
+ async function syncPlatformPullRequest(taskRef: string, options: SyncOptions): Promise<PullRequestResult> {
1015
1451
  const warningResult = warningResultForPrimary(options.primaryResult);
1016
1452
  const softenFailure = (output: PullRequestResult): PullRequestResult => {
1453
+ const authorityError = output.error?.code.startsWith('IN_LABEL_SYNC') || output.error?.code.startsWith('PR_IDENTITY');
1017
1454
  const warning = output.error && output.task.prNumber !== null
1455
+ && !authorityError
1018
1456
  && !['PR_NOT_LINKED', 'PR_BIND_CONFLICT'].includes(output.error.code)
1019
1457
  ? {
1020
1458
  code: output.error.code,
@@ -1029,63 +1467,245 @@ function syncPlatformPullRequest(taskRef: string, options: SyncOptions): PullReq
1029
1467
  ? { ...output, status: 'applied', changed: false, error: null, result: warningResult, warnings: mergeOperationWarnings([warning]) }
1030
1468
  : output;
1031
1469
  };
1032
- const base = resolvedContext(taskRef, options);
1470
+ const base = await resolvedContext(taskRef, options);
1033
1471
  if (!base.ok) return softenFailure(base.output);
1034
- if (!base.prNumber) return softenFailure(result('failed', base.resolved.taskId, base.issueNumber, null, {
1035
- error: { code: 'PR_NOT_LINKED', message: 'Task has no valid pr_number', retryable: false }
1036
- }));
1037
- const fetched = inspectGitHubPullRequest(base.client, base.context.platform.repository!, base.prNumber, base.resolved.repoRoot);
1038
- if (!fetched.ok) return softenFailure(result(fetched.error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, base.prNumber, { error: fetched.error }));
1039
- if (!options.metadata && !options.closingIssue) return softenFailure(result('failed', base.resolved.taskId, base.issueNumber, base.prNumber, {
1040
- error: { code: 'PR_PAYLOAD_INVALID', message: 'sync requires a desired-state option', retryable: false }
1041
- }));
1042
- if (!base.issueNumber) return softenFailure(result('degraded', base.resolved.taskId, null, base.prNumber, {
1043
- pullRequest: fetched.value, error: { code: 'ISSUE_NOT_LINKED', message: 'Task has no linked Issue to copy metadata from', retryable: false }
1044
- }));
1045
- const issue = inspectPlatformIssue(taskRef, { cwd: base.resolved.repoRoot, client: base.client });
1046
- if (!issue.issue) return softenFailure(result(issue.status, base.resolved.taskId, base.issueNumber, base.prNumber, {
1047
- platform: base.context.platform, capabilities: base.context.capabilities, pullRequest: fetched.value, error: issue.error
1472
+ if (!base.fact) return softenFailure(result('failed', base.resolved.taskId, base.issueNumber, null, {
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 }
1048
1474
  }));
1049
- const planned = planPullRequestMetadata({
1050
- pullRequest: fetched.value,
1051
- issue: issue.issue,
1052
- taskType: base.frontmatter.type || 'task',
1053
- issueNumber: base.issueNumber,
1054
- capabilities: base.context.capabilities
1055
- }).operations.filter((operation) => options.metadata || operation.name === 'closing-issue')
1056
- .filter((operation) => options.closingIssue || operation.name !== 'closing-issue');
1057
- const operations: PlatformOperation[] = planned.map(({ name, status, reasonCode }) => ({ name, status, reasonCode }));
1058
- if (options.dryRun) return softenFailure(result(planned.some((operation) => operation.status === 'planned') ? 'planned' : 'no-op', base.resolved.taskId, base.issueNumber, base.prNumber, {
1059
- platform: base.context.platform, capabilities: base.context.capabilities, pullRequest: fetched.value, operations, error: null
1475
+ if (!base.prIdentity) return softenFailure(result('failed', base.resolved.taskId, base.issueNumber, null, {
1476
+ error: { code: 'PR_NOT_LINKED', message: 'Task has no verified bound pull request', retryable: false }
1060
1477
  }));
1061
- const payload: Record<string, unknown> = {};
1062
- for (const operation of planned) {
1063
- if (operation.status !== 'planned') continue;
1064
- if (operation.name === 'labels') payload.labels = operation.value;
1065
- if (operation.name === 'assignees') payload.assignees = operation.value;
1066
- if (operation.name === 'closing-issue') payload.body = operation.value;
1067
- if (operation.name === 'milestone') {
1068
- const milestones = base.client.json<unknown[]>(['api', '--paginate', '--slurp', `repos/${base.context.platform.repository}/milestones?state=open&per_page=100`], { cwd: base.resolved.repoRoot });
1069
- 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 }));
1070
- const flat = milestones.value.flatMap((entry) => Array.isArray(entry) ? entry : [entry]) as Array<{ title?: string; number?: number }>;
1071
- 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' };
1072
1502
  }
1073
- }
1074
- if (Object.keys(payload).length === 0) {
1075
- const degraded = planned.some((operation) => operation.status === 'skipped');
1076
- return softenFailure(result(degraded ? 'degraded' : 'no-op', base.resolved.taskId, base.issueNumber, base.prNumber, {
1077
- 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
1078
1610
  }));
1079
1611
  }
1080
- const patched = base.client.json<RemotePullRequest>(['api', `repos/${base.context.platform.repository}/issues/${base.prNumber}`, '-X', 'PATCH', '--input', '-'], {
1081
- cwd: base.resolved.repoRoot, method: 'PATCH', input: JSON.stringify(payload)
1612
+ }
1613
+
1614
+ function readProjectPrFlow(repoRoot: string): 'required' | 'disabled' | undefined {
1615
+ try {
1616
+ const config = JSON.parse(fs.readFileSync(path.join(repoRoot, '.agents', '.airc.json'), 'utf8')) as { prFlow?: unknown };
1617
+ return config.prFlow === 'required' || config.prFlow === 'disabled' ? config.prFlow : undefined;
1618
+ } catch {
1619
+ return undefined;
1620
+ }
1621
+ }
1622
+
1623
+ function skipPlatformPullRequestFactUnlocked(
1624
+ resolved: Extract<ReturnType<typeof resolveTaskRef>, { ok: true }>,
1625
+ options: SkipFactOptions
1626
+ ): PullRequestResult {
1627
+ let content: string;
1628
+ let frontmatter: ReturnType<typeof parseTypedTaskFrontmatter>;
1629
+ try {
1630
+ content = fs.readFileSync(resolved.taskMdPath, 'utf8');
1631
+ frontmatter = parseTypedTaskFrontmatter(content);
1632
+ } catch (error) {
1633
+ return result('failed', resolved.taskId, null, null, {
1634
+ error: { code: 'TASK_DOCUMENT_INVALID', message: error instanceof Error ? error.message : String(error), retryable: false }
1635
+ });
1636
+ }
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);
1642
+ if (existing.status === 'invalid') return result('failed', resolved.taskId, issueNumber, null, {
1643
+ error: { code: existing.error.code, message: existing.error.message, retryable: false }
1082
1644
  });
1083
- 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 }));
1084
- return softenFailure(result(planned.some((operation) => operation.status === 'skipped') ? 'degraded' : 'applied', base.resolved.taskId, base.issueNumber, base.prNumber, {
1085
- changed: true, platform: base.context.platform, capabilities: base.context.capabilities,
1086
- resource: { kind: 'pull-request', number: base.prNumber }, pullRequest: fetched.value,
1087
- operations: operations.map((operation) => operation.status === 'planned' ? { ...operation, status: 'applied' } : operation), error: null
1088
- }));
1645
+ if (existing.status === 'missing') return result('failed', resolved.taskId, issueNumber, null, {
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 }
1647
+ });
1648
+ if (existing.fact.state === 'skipped') return result('no-op', resolved.taskId, issueNumber, null, {
1649
+ operations: [{ name: 'task:skip-pr', status: 'no-op', reasonCode: null }],
1650
+ error: null
1651
+ });
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
+ }
1658
+ if (readProjectPrFlow(resolved.repoRoot) === 'required') return result('failed', resolved.taskId, issueNumber, null, {
1659
+ error: { code: 'PR_SKIP_FORBIDDEN', message: 'Cannot skip PR delivery when prFlow is required', retryable: false }
1660
+ });
1661
+
1662
+ const metadata = captureTaskWriteMetadata();
1663
+ const fact = buildSkippedFact(factTimestamp(metadata.timestamp));
1664
+ const write = writeTask({
1665
+ taskRef: resolved.taskId,
1666
+ expectedState: 'active',
1667
+ dryRun: options.dryRun,
1668
+ mutations: [
1669
+ { kind: 'frontmatter', set: { ...factFrontmatterMutation(fact).set, assigned_to: options.agent } },
1670
+ { kind: 'section', aliases: ['活动日志', 'Activity Log'], heading: '活动日志', body: appendActivity(
1671
+ content, `- ${metadata.timestamp} — **Skip PR** by ${options.agent} — PR delivery explicitly skipped`
1672
+ ) }
1673
+ ]
1674
+ }, { repoRoot: resolved.repoRoot, metadataProvider: () => metadata });
1675
+ if (write.status === 'failed') return result('failed', resolved.taskId, issueNumber, null, {
1676
+ error: { code: write.error.code, message: write.error.message, retryable: false }
1677
+ });
1678
+ const status = options.dryRun ? 'planned' : write.status === 'no-op' ? 'no-op' : 'applied';
1679
+ return result(status, resolved.taskId, issueNumber, null, {
1680
+ changed: !options.dryRun && write.changed,
1681
+ operations: [{ name: 'task:skip-pr', status, reasonCode: null }],
1682
+ error: null
1683
+ });
1684
+ }
1685
+
1686
+ async function skipPlatformPullRequestFact(taskRef: string, options: SkipFactOptions): Promise<PullRequestResult> {
1687
+ const resolved = resolveTaskRef(taskRef, options.cwd ? { repoRoot: options.cwd } : {});
1688
+ if (!resolved.ok) return result('failed', resolved.taskId, null, null, {
1689
+ error: { code: resolved.code, message: resolved.message, retryable: false }
1690
+ });
1691
+ if (resolved.state !== 'active') return result('failed', resolved.taskId, null, null, {
1692
+ error: { code: 'TASK_STATE_INVALID', message: 'platform-pr skip requires an active task', retryable: false }
1693
+ });
1694
+ try {
1695
+ return withTaskExecutionLock(
1696
+ resolved.repoRoot,
1697
+ resolved.taskId,
1698
+ 'platform-pr.skip',
1699
+ () => skipPlatformPullRequestFactUnlocked(resolved, options)
1700
+ );
1701
+ } catch (error) {
1702
+ if (error instanceof TaskExecutionLockError) return result('blocked', resolved.taskId, null, null, {
1703
+ error: { code: error.code, message: error.message, retryable: true }
1704
+ });
1705
+ return result('failed', resolved.taskId, null, null, {
1706
+ error: { code: 'PR_SKIP_FAILED', message: error instanceof Error ? error.message : String(error), retryable: false }
1707
+ });
1708
+ }
1089
1709
  }
1090
1710
 
1091
1711
  export {
@@ -1096,11 +1716,12 @@ export {
1096
1716
  inspectPlatformPullRequest,
1097
1717
  inspectPlatformPullRequestByNumber,
1098
1718
  normalizePullRequest,
1099
- resolveGitHubChangeRequestGitEvidence,
1100
1719
  resolveExternalPullRequest,
1720
+ skipPlatformPullRequestFact,
1101
1721
  selectExternalPullRequest,
1102
1722
  selectPullRequest,
1103
- syncPlatformPullRequest
1723
+ syncPlatformPullRequest,
1724
+ syncPlatformPullRequestInLabels
1104
1725
  };
1105
- export type { BindOptions, CreateOptions, ExternalPullRequestResult, ExternalPullRequestSelection, PullRequestPrimaryResult, PullRequestResult, PullRequestSnapshot, ResolveExternalOptions, SyncOptions };
1726
+ export type { BindOptions, CreateOptions, ExternalPullRequestResult, ExternalPullRequestSelection, PullRequestPrimaryResult, PullRequestResult, PullRequestSnapshot, ResolveExternalOptions, SkipFactOptions, SyncOptions };
1106
1727
  export { warningResultForPrimary };