@fitlab-ai/agent-infra 0.9.12 → 0.9.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (239) hide show
  1. package/README.md +11 -11
  2. package/README.zh-CN.md +11 -11
  3. package/dist/bin/internal-cli.js +24 -19
  4. package/dist/lib/agent-clients/next-steps.js +6 -1
  5. package/dist/lib/decide.js +3 -3
  6. package/dist/lib/internal/platform-checks.js +3 -3
  7. package/dist/lib/internal/platform-comment.js +9 -9
  8. package/dist/lib/internal/platform-context.js +2 -2
  9. package/dist/lib/internal/platform-issue.js +8 -9
  10. package/dist/lib/internal/platform-pr-review.js +10 -10
  11. package/dist/lib/internal/platform-pr.js +63 -24
  12. package/dist/lib/internal/platform-release-notes.js +3 -3
  13. package/dist/lib/internal/pr-review-grade.js +11 -9
  14. package/dist/lib/internal/release-workflow.js +4 -4
  15. package/dist/lib/internal/sandbox-control.js +30 -4
  16. package/dist/lib/internal/task-activity.js +3 -3
  17. package/dist/lib/internal/task-artifact.js +76 -24
  18. package/dist/lib/internal/task-create.js +2 -2
  19. package/dist/lib/internal/task-event.js +6 -3
  20. package/dist/lib/internal/task-finalization.js +2 -2
  21. package/dist/lib/internal/task-invalidation.js +60 -0
  22. package/dist/lib/internal/task-ledger.js +9 -6
  23. package/dist/lib/internal/task-lifecycle.js +2 -2
  24. package/dist/lib/internal/task-override.js +4 -4
  25. package/dist/lib/internal/task-review.js +3 -3
  26. package/dist/lib/internal/task-verify.js +6 -6
  27. package/dist/lib/internal/task-warning.js +3 -3
  28. package/dist/lib/platform/adapters.js +1 -3
  29. package/dist/lib/platform/change-request-git-evidence.js +27 -7
  30. package/dist/lib/platform/completion-backfill.js +6 -5
  31. package/dist/lib/platform/context.js +70 -130
  32. package/dist/lib/platform/github-client.js +1 -1
  33. package/dist/lib/platform/github-provider.js +903 -0
  34. package/dist/lib/platform/in-label-sync.js +143 -0
  35. package/dist/lib/platform/issue-comments.js +94 -41
  36. package/dist/lib/platform/issues.js +335 -189
  37. package/dist/lib/platform/metadata-labels.js +6 -2
  38. package/dist/lib/platform/none-provider.js +28 -0
  39. package/dist/lib/platform/pr-change-report.js +551 -0
  40. package/dist/lib/platform/pr-checks.js +119 -104
  41. package/dist/lib/platform/pr-review.js +88 -72
  42. package/dist/lib/platform/pr-summary.js +520 -50
  43. package/dist/lib/platform/provider-bridge.js +45 -0
  44. package/dist/lib/platform/provider-contract.d.ts +487 -0
  45. package/dist/lib/platform/provider-contract.js +95 -0
  46. package/dist/lib/platform/provider-loader.js +251 -0
  47. package/dist/lib/platform/provider-validation.js +504 -0
  48. package/dist/lib/platform/pull-request-metadata.js +8 -6
  49. package/dist/lib/platform/pull-requests.js +783 -537
  50. package/dist/lib/platform/release-notes.js +78 -55
  51. package/dist/lib/platform/releases.js +79 -50
  52. package/dist/lib/platform/resource-identity.d.ts +26 -0
  53. package/dist/lib/platform/resource-identity.js +92 -0
  54. package/dist/lib/platform/task-identities.js +31 -0
  55. package/dist/lib/platform/verification-required.js +11 -12
  56. package/dist/lib/platform/verification-sync.js +112 -238
  57. package/dist/lib/pr-review/evidence-grading.js +19 -6
  58. package/dist/lib/run/index.js +63 -23
  59. package/dist/lib/sandbox/control/client.js +96 -15
  60. package/dist/lib/sandbox/control/executor.js +3 -3
  61. package/dist/lib/sandbox/control/protocol.js +77 -0
  62. package/dist/lib/sandbox/control/server.js +398 -25
  63. package/dist/lib/sandbox/control/state.js +204 -1
  64. package/dist/lib/server/protocol.js +38 -1
  65. package/dist/lib/task/activity-log.js +8 -1
  66. package/dist/lib/task/artifact-lifecycle.js +33 -5
  67. package/dist/lib/task/capabilities.js +239 -0
  68. package/dist/lib/task/commands/status.js +32 -0
  69. package/dist/lib/task/commit-operation.js +12 -1
  70. package/dist/lib/task/control-authority.js +8 -5
  71. package/dist/lib/task/create-service.js +8 -8
  72. package/dist/lib/task/current-contract.js +5 -1
  73. package/dist/lib/task/decision-details.js +1 -1
  74. package/dist/lib/task/events.js +297 -17
  75. package/dist/lib/task/finalization.js +37 -13
  76. package/dist/lib/task/human-override.js +58 -36
  77. package/dist/lib/task/invalidation-command.js +86 -0
  78. package/dist/lib/task/invalidation.js +260 -0
  79. package/dist/lib/task/ledger-intents.js +37 -0
  80. package/dist/lib/task/local-artifact-finalization.js +374 -0
  81. package/dist/lib/task/orchestration.js +49 -93
  82. package/dist/lib/task/pr-delivery-fact.js +102 -101
  83. package/dist/lib/task/review-fingerprint.js +24 -2
  84. package/dist/lib/task/rework-intent.js +138 -0
  85. package/dist/lib/task/skill-scope.js +23 -0
  86. package/dist/lib/task/task-execution-lock.js +27 -17
  87. package/dist/lib/task/verification-engine.js +43 -16
  88. package/dist/lib/task/verification.js +3 -3
  89. package/dist/lib/task/workflow-warning-intents.js +1 -1
  90. package/dist/lib/task/write.js +11 -0
  91. package/dist/lib/version.js +11 -1
  92. package/dist/lifecycle-build-manifest.json +3 -3
  93. package/lib/agent-clients/next-steps.ts +6 -1
  94. package/lib/decide.ts +3 -3
  95. package/lib/init.ts +1 -1
  96. package/lib/internal/platform-checks.ts +3 -3
  97. package/lib/internal/platform-comment.ts +8 -8
  98. package/lib/internal/platform-context.ts +2 -2
  99. package/lib/internal/platform-issue.ts +7 -8
  100. package/lib/internal/platform-pr-review.ts +9 -9
  101. package/lib/internal/platform-pr.ts +47 -22
  102. package/lib/internal/platform-release-notes.ts +3 -3
  103. package/lib/internal/pr-review-grade.ts +10 -8
  104. package/lib/internal/release-workflow.ts +4 -4
  105. package/lib/internal/sandbox-control.ts +27 -3
  106. package/lib/internal/task-activity.ts +3 -3
  107. package/lib/internal/task-artifact.ts +65 -22
  108. package/lib/internal/task-create.ts +2 -2
  109. package/lib/internal/task-event.ts +6 -3
  110. package/lib/internal/task-finalization.ts +3 -3
  111. package/lib/internal/task-invalidation.ts +39 -0
  112. package/lib/internal/task-ledger.ts +11 -8
  113. package/lib/internal/task-lifecycle.ts +2 -2
  114. package/lib/internal/task-override.ts +6 -6
  115. package/lib/internal/task-review.ts +3 -3
  116. package/lib/internal/task-verify.ts +6 -6
  117. package/lib/internal/task-warning.ts +3 -3
  118. package/lib/platform/adapters.ts +3 -2
  119. package/lib/platform/change-request-git-evidence.ts +36 -9
  120. package/lib/platform/completion-backfill.ts +9 -8
  121. package/lib/platform/context.ts +97 -134
  122. package/lib/platform/github-client.ts +2 -2
  123. package/lib/platform/github-provider.ts +910 -0
  124. package/lib/platform/in-label-sync.ts +208 -0
  125. package/lib/platform/issue-comments.ts +97 -47
  126. package/lib/platform/issues.ts +344 -174
  127. package/lib/platform/metadata-labels.ts +5 -2
  128. package/lib/platform/none-provider.ts +35 -0
  129. package/lib/platform/pr-change-report.ts +645 -0
  130. package/lib/platform/pr-checks.ts +126 -106
  131. package/lib/platform/pr-review.ts +88 -85
  132. package/lib/platform/pr-summary.ts +560 -53
  133. package/lib/platform/provider-bridge.ts +82 -0
  134. package/lib/platform/provider-contract.ts +536 -0
  135. package/lib/platform/provider-loader.ts +307 -0
  136. package/lib/platform/provider-validation.ts +529 -0
  137. package/lib/platform/pull-request-metadata.ts +10 -7
  138. package/lib/platform/pull-requests.ts +826 -543
  139. package/lib/platform/release-notes.ts +76 -59
  140. package/lib/platform/releases.ts +77 -43
  141. package/lib/platform/resource-identity.ts +121 -0
  142. package/lib/platform/task-identities.ts +40 -0
  143. package/lib/platform/types.ts +4 -2
  144. package/lib/platform/verification-required.ts +11 -13
  145. package/lib/platform/verification-sync.ts +112 -292
  146. package/lib/pr-review/evidence-grading.ts +25 -8
  147. package/lib/run/index.ts +57 -22
  148. package/lib/sandbox/control/client.ts +105 -15
  149. package/lib/sandbox/control/executor.ts +4 -4
  150. package/lib/sandbox/control/protocol.ts +113 -0
  151. package/lib/sandbox/control/server.ts +431 -23
  152. package/lib/sandbox/control/state.ts +251 -1
  153. package/lib/server/protocol.ts +36 -1
  154. package/lib/task/activity-log.ts +8 -0
  155. package/lib/task/artifact-lifecycle.ts +32 -6
  156. package/lib/task/capabilities.ts +262 -0
  157. package/lib/task/commands/status.ts +38 -0
  158. package/lib/task/commit-operation.ts +12 -1
  159. package/lib/task/control-authority.ts +11 -8
  160. package/lib/task/create-service.ts +8 -8
  161. package/lib/task/current-contract.ts +6 -1
  162. package/lib/task/decision-details.ts +6 -2
  163. package/lib/task/events.ts +296 -16
  164. package/lib/task/finalization.ts +50 -14
  165. package/lib/task/human-override.ts +70 -46
  166. package/lib/task/invalidation-command.ts +86 -0
  167. package/lib/task/invalidation.ts +332 -0
  168. package/lib/task/ledger-intents.ts +35 -1
  169. package/lib/task/local-artifact-finalization.ts +563 -0
  170. package/lib/task/orchestration.ts +52 -85
  171. package/lib/task/pr-delivery-fact.ts +131 -148
  172. package/lib/task/review-fingerprint.ts +23 -2
  173. package/lib/task/rework-intent.ts +165 -0
  174. package/lib/task/skill-scope.ts +24 -0
  175. package/lib/task/task-execution-lock.ts +23 -14
  176. package/lib/task/verification-engine.ts +52 -17
  177. package/lib/task/verification.ts +4 -4
  178. package/lib/task/workflow-warning-intents.ts +1 -1
  179. package/lib/task/write.ts +19 -0
  180. package/lib/update.ts +1 -1
  181. package/lib/version.ts +15 -1
  182. package/package.json +7 -1
  183. package/templates/.agents/rules/README.en.md +1 -1
  184. package/templates/.agents/rules/README.zh-CN.md +1 -1
  185. package/templates/.agents/rules/issue-pr-commands.en.md +1 -1
  186. package/templates/.agents/rules/issue-pr-commands.zh-CN.md +1 -1
  187. package/templates/.agents/rules/issue-sync.en.md +2 -0
  188. package/templates/.agents/rules/issue-sync.zh-CN.md +2 -0
  189. package/templates/.agents/rules/local-artifact-repair.en.md +9 -2
  190. package/templates/.agents/rules/local-artifact-repair.zh-CN.md +9 -2
  191. package/templates/.agents/rules/pr-sync.en.md +10 -4
  192. package/templates/.agents/rules/pr-sync.zh-CN.md +17 -4
  193. package/templates/.agents/rules/task-short-id.en.md +1 -4
  194. package/templates/.agents/rules/task-short-id.zh-CN.md +1 -3
  195. package/templates/.agents/skills/analyze-task/SKILL.en.md +13 -3
  196. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +10 -3
  197. package/templates/.agents/skills/check-task/SKILL.en.md +16 -37
  198. package/templates/.agents/skills/check-task/SKILL.zh-CN.md +16 -37
  199. package/templates/.agents/skills/code-task/SKILL.en.md +27 -8
  200. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +29 -10
  201. package/templates/.agents/skills/code-task/config/verify.en.json +1 -0
  202. package/templates/.agents/skills/code-task/config/verify.zh-CN.json +1 -0
  203. package/templates/.agents/skills/commit/reference/issue-metadata-sync.en.md +2 -0
  204. package/templates/.agents/skills/commit/reference/issue-metadata-sync.zh-CN.md +2 -0
  205. package/templates/.agents/skills/commit/reference/pr-summary-sync.en.md +3 -3
  206. package/templates/.agents/skills/commit/reference/pr-summary-sync.zh-CN.md +3 -3
  207. package/templates/.agents/skills/complete-manual-validation/SKILL.en.md +5 -3
  208. package/templates/.agents/skills/complete-manual-validation/SKILL.zh-CN.md +5 -3
  209. package/templates/.agents/skills/complete-manual-validation/reference/summary-update.en.md +4 -3
  210. package/templates/.agents/skills/complete-manual-validation/reference/summary-update.zh-CN.md +4 -3
  211. package/templates/.agents/skills/complete-task/SKILL.en.md +21 -3
  212. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +20 -3
  213. package/templates/.agents/skills/create-pr/SKILL.en.md +7 -6
  214. package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +7 -7
  215. package/templates/.agents/skills/create-pr/config/verify.json +1 -0
  216. package/templates/.agents/skills/create-pr/reference/change-report.en.md +16 -4
  217. package/templates/.agents/skills/create-pr/reference/change-report.zh-CN.md +16 -4
  218. package/templates/.agents/skills/create-pr/reference/comment-publish.en.md +5 -3
  219. package/templates/.agents/skills/create-pr/reference/comment-publish.zh-CN.md +5 -3
  220. package/templates/.agents/skills/create-pr/reference/pr-body-template.en.md +1 -1
  221. package/templates/.agents/skills/create-pr/reference/pr-body-template.zh-CN.md +1 -1
  222. package/templates/.agents/skills/create-pr/scripts/change-report.mjs +3 -0
  223. package/templates/.agents/skills/plan-task/SKILL.en.md +13 -3
  224. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +10 -3
  225. package/templates/.agents/skills/review-analysis/SKILL.en.md +3 -3
  226. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +3 -3
  227. package/templates/.agents/skills/review-code/SKILL.en.md +4 -4
  228. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +4 -4
  229. package/templates/.agents/skills/review-plan/SKILL.en.md +3 -3
  230. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +3 -3
  231. package/templates/.agents/skills/run-manual-validation/SKILL.en.md +4 -2
  232. package/templates/.agents/skills/run-manual-validation/SKILL.zh-CN.md +4 -2
  233. package/templates/.agents/skills/watch-pr/SKILL.en.md +2 -2
  234. package/templates/.agents/skills/watch-pr/SKILL.zh-CN.md +2 -2
  235. package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.en.md +6 -0
  236. package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.zh-CN.md +6 -0
  237. package/templates/.agents/templates/task.en.md +18 -1
  238. package/templates/.agents/templates/task.zh-CN.md +18 -1
  239. package/templates/.github/workflows/pr-label.yml +7 -26
@@ -8,13 +8,11 @@ import { requirementFieldLabels, renderTemplateBody } from '../task/issue-form.t
8
8
  import { resolveTaskRef } from '../task/resolve-ref.ts';
9
9
  import { extractSection, findSectionHeading } from '../task/sections.ts';
10
10
  import { writeTask } from '../task/write.ts';
11
- import { resolvePlatformContext } from './context.ts';
12
- import { createGitHubClient } from './github-client.ts';
13
- import type { GitHubClient } from './github-client.ts';
11
+ import { resolvePlatformProviderContext } from './context.ts';
12
+ import type { PlatformClient } from './context.ts';
14
13
  import {
15
14
  DEFAULT_REQUIREMENT_SECTION_ANCHORS,
16
15
  chooseMilestone,
17
- computeInLabels,
18
16
  desiredIssueType,
19
17
  normalizeOption,
20
18
  planIssueMetadata
@@ -22,10 +20,32 @@ import {
22
20
  import type { IssueDesiredState, PlannedOperation, Requirement, RequirementSectionAnchor } from './issue-metadata.ts';
23
21
  import { platformResult } from './types.ts';
24
22
  import type { PlatformResult } from './types.ts';
23
+ import {
24
+ labelDelta,
25
+ planInLabelUpdate,
26
+ syncLabelDelta,
27
+ validateInLabelMapping,
28
+ validateRepositoryLabelPayload
29
+ } from './in-label-sync.ts';
30
+ import {
31
+ providerError,
32
+ providerOperationContext,
33
+ providerStatus,
34
+ providerResourceIdentity,
35
+ providerResourceToken,
36
+ unsupportedProviderOperation
37
+ } from './provider-bridge.ts';
38
+ import { resourceIdentityEquals, resourceIdentityNumber, resourceIdentityString, serializeResourceIdentity } from './resource-identity.ts';
39
+ import { taskIssueIdentity, taskIssueIdentityError } from './task-identities.ts';
40
+ import type { ResourceIdentity } from './resource-identity.ts';
41
+
42
+ type GitHubClient = PlatformClient;
43
+ import type { IssueSnapshot as ProviderIssueSnapshot, RepositoryMetadataSnapshot } from './provider-contract.ts';
25
44
 
26
45
  type IssueSnapshot = {
27
46
  repository: string;
28
47
  number: number;
48
+ identity?: ResourceIdentity;
29
49
  databaseId: number | null;
30
50
  nodeId: string;
31
51
  url: string;
@@ -42,9 +62,9 @@ type IssueResult = PlatformResult & {
42
62
  task: { id: string | null; issueNumber: number | null };
43
63
  issue: IssueSnapshot | null;
44
64
  };
45
- type SharedOptions = { cwd?: string; client?: GitHubClient };
65
+ type SharedOptions = { cwd?: string; client?: PlatformClient; runtimeVersion?: string };
46
66
  type CreateOptions = SharedOptions & { agent: string; dryRun?: boolean };
47
- type BindOptions = SharedOptions & { issue: number; agent: string; dryRun?: boolean };
67
+ type BindOptions = SharedOptions & { issue: string | number; agent: string; dryRun?: boolean };
48
68
  type SyncOptions = SharedOptions & {
49
69
  agent: string;
50
70
  status?: string | 'none';
@@ -71,6 +91,7 @@ type RemoteIssue = {
71
91
  labels?: Array<string | { name?: string }>;
72
92
  assignees?: Array<{ login?: string }>;
73
93
  milestone?: { title?: string } | null;
94
+ type?: { name?: string } | null;
74
95
  pull_request?: unknown;
75
96
  };
76
97
 
@@ -100,39 +121,138 @@ function result(
100
121
  };
101
122
  }
102
123
 
103
- function resolvedContext(taskRef: string, options: SharedOptions) {
124
+ async function resolvedContext(taskRef: string, options: SharedOptions) {
104
125
  const resolved = resolveTaskRef(taskRef, options.cwd ? { repoRoot: options.cwd } : {});
105
126
  if (!resolved.ok) return { ok: false as const, output: result('failed', resolved.taskId, null, {
106
127
  error: { code: resolved.code, message: resolved.message, retryable: false }
107
128
  }) };
108
129
  const content = fs.readFileSync(resolved.taskMdPath, 'utf8');
109
130
  const frontmatter = parseTaskFrontmatter(content);
110
- const rawIssue = Number(frontmatter.issue_number);
111
- const issueNumber = Number.isInteger(rawIssue) && rawIssue > 0 ? rawIssue : null;
112
- const client = options.client || createGitHubClient();
113
- const context = resolvePlatformContext({ cwd: resolved.repoRoot, client });
131
+ let issueIdentity: ResourceIdentity | null;
132
+ try { issueIdentity = taskIssueIdentity(frontmatter, undefined, options.runtimeVersion); }
133
+ catch (error) {
134
+ return { ok: false as const, output: result('failed', resolved.taskId, null, { error: { ...taskIssueIdentityError(error), retryable: false } }) };
135
+ }
136
+ const issueNumber = resourceIdentityNumber(issueIdentity);
137
+ const loaded = await resolvePlatformProviderContext({ cwd: resolved.repoRoot, client: options.client });
138
+ const context = loaded.ok ? loaded.value.context : loaded.context;
114
139
  const usable = (context.status === 'no-op' || context.status === 'degraded') && context.platform.repository;
115
- if (!usable) return { ok: false as const, output: result(context.status, resolved.taskId, issueNumber, {
140
+ if (!usable || !loaded.ok) return { ok: false as const, output: result(context.status, resolved.taskId, issueNumber, {
116
141
  platform: context.platform, capabilities: context.capabilities, operations: context.operations, error: context.error
117
142
  }) };
118
- return { ok: true as const, resolved, content, frontmatter, issueNumber, client, context };
143
+ return { ok: true as const, resolved, content, frontmatter, issueIdentity, issueNumber, client: options.client, context, provider: loaded.value.provider, providerType: loaded.value.providerType, loadedContext: loaded.value };
144
+ }
145
+
146
+ function normalizeProviderIssue(remote: ProviderIssueSnapshot, repository: string, fallbackNumber: number | null): IssueSnapshot {
147
+ const identity = remote.identity || (remote.number ? { kind: 'number' as const, value: remote.number } : { kind: 'id' as const, value: remote.id });
148
+ const snapshot: IssueSnapshot = {
149
+ repository,
150
+ number: remote.number ?? resourceIdentityNumber(identity) ?? fallbackNumber ?? 0,
151
+ identity,
152
+ databaseId: null,
153
+ nodeId: remote.id,
154
+ url: remote.displayUrl || '',
155
+ state: remote.state,
156
+ title: remote.title,
157
+ body: remote.body,
158
+ labels: [...remote.labels].sort(),
159
+ assignees: [...remote.assignees].sort(),
160
+ milestone: remote.milestone,
161
+ issueType: remote.issueType?.name || null,
162
+ fields: { ...remote.fields }
163
+ };
164
+ Object.defineProperty(snapshot, 'identity', { value: identity, enumerable: false, configurable: true });
165
+ if (remote.issueType) Object.defineProperty(snapshot, 'issueTypeSnapshot', { value: remote.issueType, enumerable: false, configurable: true });
166
+ return snapshot;
119
167
  }
120
168
 
121
- function normalizeIssue(remote: RemoteIssue, repository: string): IssueSnapshot | null {
122
- if (!Number.isInteger(remote.number) || Number(remote.number) <= 0 || !remote.node_id || remote.pull_request) return null;
169
+ function planProviderMetadata(
170
+ remote: ProviderIssueSnapshot,
171
+ metadata: RepositoryMetadataSnapshot,
172
+ frontmatter: Record<string, string>,
173
+ options: Pick<SyncOptions, 'issueType' | 'fields'>,
174
+ capabilities: PlatformResult['capabilities']
175
+ ): PlannedOperation[] {
176
+ if (!options.issueType && !options.fields) return [];
177
+ if (!capabilities.push) return [
178
+ ...(options.issueType ? [{ name: 'issue-type', status: 'skipped' as const, reasonCode: 'PUSH_REQUIRED' }] : []),
179
+ ...(options.fields ? [{ name: 'fields', status: 'skipped' as const, reasonCode: 'PUSH_REQUIRED' }] : [])
180
+ ];
181
+ const target = metadata.issueTypes.find((candidate) => candidate.name === desiredIssueType(frontmatter.type || ''));
182
+ if (!target) return [
183
+ ...(options.issueType ? [{ name: 'issue-type', status: 'skipped' as const, reasonCode: 'ISSUE_TYPES_UNSUPPORTED' }] : []),
184
+ ...(options.fields ? [{ name: 'fields', status: 'skipped' as const, reasonCode: 'ISSUE_TYPES_UNSUPPORTED' }] : [])
185
+ ];
186
+ const operations: PlannedOperation[] = [];
187
+ if (options.issueType) {
188
+ operations.push(remote.issueType && resourceIdentityEquals(remote.issueType.identity, target.identity)
189
+ ? { name: 'issue-type', status: 'no-op', reasonCode: null }
190
+ : { name: 'issue-type', status: 'planned', reasonCode: null, value: target.name });
191
+ }
192
+ if (options.fields) {
193
+ const fields = target.fields.map((field) => ({
194
+ id: resourceIdentityString(field.identity) || '',
195
+ name: field.name,
196
+ kind: field.kind,
197
+ options: field.options.map((option) => ({ id: resourceIdentityString(option.identity) || '', name: option.name }))
198
+ }));
199
+ const desired = desiredFieldValues(frontmatter, fields);
200
+ const values = Object.fromEntries(desired.map((field) => [field.name, field.value]));
201
+ const changed = Object.entries(values).some(([name, value]) => remote.fields[name] !== value);
202
+ operations.push(changed
203
+ ? { name: 'fields', status: 'planned', reasonCode: null, value: values }
204
+ : { name: 'fields', status: 'no-op', reasonCode: null });
205
+ }
206
+ return operations;
207
+ }
208
+
209
+ function providerIssueError(base: Awaited<ReturnType<typeof resolvedContext>> & { ok: true }, error: { code: string; message: string; retryable: boolean }, issueIdentity: ResourceIdentity | null): IssueResult {
210
+ const issueNumber = resourceIdentityNumber(issueIdentity);
211
+ return result(error.code.startsWith('IN_LABEL_SYNC') ? 'blocked' : providerStatus(error), base.resolved.taskId, issueNumber, {
212
+ changed: error.code === 'IN_LABEL_SYNC_PARTIAL',
213
+ platform: base.context.platform,
214
+ capabilities: base.context.capabilities,
215
+ resource: { kind: 'issue', number: issueNumber, ...(issueIdentity ? { identity: issueIdentity } : {}) },
216
+ error: providerError(error, 'PLATFORM_PROVIDER_OPERATION_FAILED')
217
+ });
218
+ }
219
+
220
+ async function inspectExternalIssue(
221
+ base: Awaited<ReturnType<typeof resolvedContext>> & { ok: true },
222
+ issueIdentity: ResourceIdentity
223
+ ): Promise<IssueResult> {
224
+ const operation = base.provider.issues?.inspect
225
+ ? await base.provider.issues.inspect({ context: providerOperationContext(base.loadedContext), target: issueIdentity })
226
+ : unsupportedProviderOperation(base.provider, 'issues.inspect');
227
+ if (!operation.ok) return providerIssueError(base, operation.error, issueIdentity);
228
+ const issue = normalizeProviderIssue(operation.value, base.context.platform.repository!, resourceIdentityNumber(issueIdentity));
229
+ return result('no-op', base.resolved.taskId, resourceIdentityNumber(issueIdentity), {
230
+ platform: base.context.platform,
231
+ capabilities: base.context.capabilities,
232
+ resource: { kind: 'issue', number: resourceIdentityNumber(issueIdentity), identity: issueIdentity },
233
+ issue,
234
+ error: null
235
+ });
236
+ }
237
+
238
+ function normalizeIssue(remote: RemoteIssue, repository: string, fallbackNumber?: number): IssueSnapshot | null {
239
+ const number = Number.isInteger(remote.number) && Number(remote.number) > 0
240
+ ? Number(remote.number)
241
+ : fallbackNumber;
242
+ if (!number || !Number.isSafeInteger(number) || remote.pull_request) return null;
123
243
  return {
124
244
  repository,
125
- number: Number(remote.number),
245
+ number,
126
246
  databaseId: Number.isInteger(remote.id) ? Number(remote.id) : null,
127
- nodeId: remote.node_id,
128
- url: remote.html_url || `https://github.com/${repository}/issues/${remote.number}`,
129
- state: remote.state === 'closed' ? 'closed' : 'open',
247
+ nodeId: remote.node_id || `issue-${number}`,
248
+ url: remote.html_url || `https://github.com/${repository}/issues/${number}`,
249
+ state: String(remote.state || '').toLowerCase() === 'closed' ? 'closed' : 'open',
130
250
  title: remote.title || '',
131
251
  body: remote.body || '',
132
252
  labels: (remote.labels || []).map((label) => typeof label === 'string' ? label : label.name || '').filter(Boolean).sort(),
133
253
  assignees: (remote.assignees || []).map((assignee) => assignee.login || '').filter(Boolean).sort(),
134
254
  milestone: remote.milestone?.title || null,
135
- issueType: null,
255
+ issueType: remote.type?.name || null,
136
256
  fields: {}
137
257
  };
138
258
  }
@@ -173,15 +293,17 @@ function normalizeCurrentFields(value: unknown): { issueId: string | null; type:
173
293
  issueType?: { id?: string; name?: string; pinnedFields?: unknown[] } | null;
174
294
  issueFieldValues?: { nodes?: unknown[] };
175
295
  } } } })?.data?.repository?.issue;
176
- const type = issue?.issueType?.id && issue.issueType.name ? {
177
- id: issue.issueType.id, name: issue.issueType.name, fields: normalizeFieldSchemas(issue.issueType.pinnedFields)
296
+ const type = issue?.issueType?.name ? {
297
+ id: issue.issueType.id || issue.issueType.name,
298
+ name: issue.issueType.name,
299
+ fields: normalizeFieldSchemas(issue.issueType.pinnedFields)
178
300
  } : null;
179
301
  const values = (issue?.issueFieldValues?.nodes || []).flatMap((raw) => {
180
302
  const item = raw as { __typename?: string; name?: string; value?: string | number; field?: { id?: string; name?: string } };
181
303
  const kind = fieldKind(item);
182
304
  const value = kind === 'single-select' ? item.name : item.value;
183
- return item.field?.id && item.field.name && kind && value !== undefined
184
- ? [{ id: item.field.id, name: item.field.name, kind, value: value ?? null }]
305
+ return item.field?.name && kind && value !== undefined
306
+ ? [{ id: item.field.id || item.field.name, name: item.field.name, kind, value: value ?? null }]
185
307
  : [];
186
308
  });
187
309
  return { issueId: issue?.id || null, type, values };
@@ -204,7 +326,7 @@ function fetchIssue(client: GitHubClient, repository: string, issue: number, cwd
204
326
  function inspectGitHubIssue(client: GitHubClient, repository: string, issue: number, cwd: string) {
205
327
  const fetched = fetchIssue(client, repository, issue, cwd);
206
328
  if (!fetched.ok) return fetched;
207
- const snapshot = normalizeIssue(fetched.value, repository);
329
+ const snapshot = normalizeIssue(fetched.value, repository, issue);
208
330
  return snapshot
209
331
  ? { ok: true as const, value: snapshot }
210
332
  : { ok: false as const, error: { code: 'ISSUE_IDENTITY_INVALID', message: 'Remote resource is not a valid Issue', retryable: false } };
@@ -218,37 +340,21 @@ function inspectGitHubIssueMetadata(client: GitHubClient, repository: string, is
218
340
  ok: true as const,
219
341
  value: {
220
342
  state: String(remote.state || '').toLowerCase() === 'closed' ? 'closed' as const : 'open' as const,
221
- labels: (remote.labels || []).map((label) => typeof label === 'string' ? label : label.name || '').filter(Boolean).sort(),
343
+ labels: (remote.labels || []).map((label: string | { name?: string }) => typeof label === 'string' ? label : label.name || '').filter(Boolean).sort(),
222
344
  body: remote.body || '',
223
345
  milestone: remote.milestone?.title || null
224
346
  }
225
347
  };
226
348
  }
227
349
 
228
- function inspectPlatformIssue(taskRef: string, options: SharedOptions = {}): IssueResult {
229
- const base = resolvedContext(taskRef, options);
350
+ async function inspectPlatformIssue(taskRef: string, options: SharedOptions = {}): Promise<IssueResult> {
351
+ const base = await resolvedContext(taskRef, options);
230
352
  if (!base.ok) return base.output;
231
- if (!base.issueNumber) return result('no-op', base.resolved.taskId, null, {
232
- platform: base.context.platform, capabilities: base.context.capabilities,
233
- error: { code: 'ISSUE_NOT_LINKED', message: 'Task has no valid issue_number', retryable: false }
234
- });
235
- const fetched = inspectGitHubIssue(base.client, base.context.platform.repository!, base.issueNumber, base.resolved.repoRoot);
236
- if (!fetched.ok) return result(fetched.error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, {
237
- platform: base.context.platform, capabilities: base.context.capabilities,
238
- resource: { kind: 'issue', number: base.issueNumber }, error: fetched.error
239
- });
240
- const issue = fetched.value;
241
- if (base.context.capabilities.push) {
242
- const graph = graphState(base.client, base.context.platform.repository!, base.issueNumber, base.resolved.repoRoot);
243
- if (graph) {
244
- issue.issueType = graph.type?.name || null;
245
- issue.fields = Object.fromEntries(graph.values.map((field) => [field.name, field.value]));
246
- }
247
- }
248
- return result('no-op', base.resolved.taskId, base.issueNumber, {
353
+ if (!base.issueIdentity) return result('no-op', base.resolved.taskId, null, {
249
354
  platform: base.context.platform, capabilities: base.context.capabilities,
250
- resource: { kind: 'issue', number: base.issueNumber }, issue, error: null
355
+ error: { code: 'ISSUE_NOT_LINKED', message: 'Task has no valid platform issue identity', retryable: false }
251
356
  });
357
+ return inspectExternalIssue(base, base.issueIdentity);
252
358
  }
253
359
 
254
360
  function taskTitle(content: string): string {
@@ -321,101 +427,112 @@ function deterministicIssueBody(repoRoot: string, content: string, taskType: str
321
427
  return buildDefaultBody(content);
322
428
  }
323
429
 
324
- function createPlatformIssue(taskRef: string, options: CreateOptions): IssueResult {
325
- const base = resolvedContext(taskRef, options);
430
+ async function createPlatformIssue(taskRef: string, options: CreateOptions): Promise<IssueResult> {
431
+ const base = await resolvedContext(taskRef, options);
326
432
  if (!base.ok) return base.output;
327
- if (base.issueNumber) return inspectPlatformIssue(taskRef, options);
433
+ if (base.issueIdentity) return inspectPlatformIssue(taskRef, options);
328
434
  const repository = base.context.platform.repository!;
329
435
  let repositoryLabels: string[] = [];
330
- let milestones: Array<{ title: string; number: number }> = [];
436
+ let milestones: string[] = [];
331
437
  if (base.context.capabilities.triage) {
332
- const labels = base.client.json<unknown>(['api', '--paginate', '--slurp', `repos/${repository}/labels?per_page=100`], { cwd: base.resolved.repoRoot });
333
- if (labels.ok) repositoryLabels = listNames(labels.value);
334
- const listed = base.client.json<unknown>(['api', '--paginate', '--slurp', `repos/${repository}/milestones?state=open&per_page=100`], { cwd: base.resolved.repoRoot });
335
- if (listed.ok) milestones = flattenPages(listed.value).map((item) => ({
336
- title: String((item as { title?: string }).title || ''), number: Number((item as { number?: number }).number)
337
- })).filter((item) => item.title && Number.isInteger(item.number));
438
+ const metadata = base.provider.issues?.describeRepository
439
+ ? await base.provider.issues.describeRepository({ context: providerOperationContext(base.loadedContext) })
440
+ : unsupportedProviderOperation(base.provider, 'issues.describeRepository');
441
+ if (!metadata.ok) return providerIssueError(base, metadata.error, null);
442
+ repositoryLabels = metadata.value.labels.map((label) => label.name);
443
+ milestones = metadata.value.milestones.map((milestone) => milestone.title);
338
444
  }
339
445
  const typeLabel = ({
340
446
  bug: 'type: bug', bugfix: 'type: bug', feature: 'type: feature', enhancement: 'type: enhancement',
341
447
  refactor: 'type: enhancement', refactoring: 'type: enhancement', documentation: 'type: documentation',
342
448
  docs: 'type: documentation', 'dependency-upgrade': 'type: dependency-upgrade', task: 'type: task', chore: 'type: task'
343
449
  } as Record<string, string>)[base.frontmatter.type || ''];
344
- const explicitMilestone = base.frontmatter.milestone && milestones.some((item) => item.title === base.frontmatter.milestone)
450
+ const explicitMilestone = base.frontmatter.milestone && milestones.some((item) => item === base.frontmatter.milestone)
345
451
  ? base.frontmatter.milestone
346
- : chooseMilestone('initial', milestones.map((item) => item.title));
452
+ : chooseMilestone('initial', milestones);
347
453
  const payload = {
348
454
  title: conventionalTitle(base.content, base.frontmatter.type || 'task', base.resolved.repoRoot),
349
455
  body: deterministicIssueBody(base.resolved.repoRoot, base.content, base.frontmatter.type || 'task'),
350
456
  assignees: base.context.platform.currentUser ? [base.context.platform.currentUser] : [],
351
457
  ...(typeLabel && repositoryLabels.includes(typeLabel) ? { labels: [typeLabel] } : {}),
352
- ...(explicitMilestone ? { milestone: milestones.find((item) => item.title === explicitMilestone)?.number } : {})
458
+ ...(explicitMilestone ? { milestone: explicitMilestone } : {})
353
459
  };
354
460
  if (options.dryRun) return result('planned', base.resolved.taskId, null, {
355
461
  platform: base.context.platform, capabilities: base.context.capabilities,
356
462
  operations: [{ name: 'issue:create', status: 'planned', reasonCode: null }], error: null
357
463
  });
358
- const created = base.client.json<RemoteIssue>(['api', `repos/${repository}/issues`, '-X', 'POST', '--input', '-'], {
359
- cwd: base.resolved.repoRoot, method: 'POST', input: JSON.stringify(payload)
360
- });
361
- if (!created.ok) return result(created.error.retryable ? 'blocked' : 'failed', base.resolved.taskId, null, {
362
- platform: base.context.platform, capabilities: base.context.capabilities,
363
- operations: [{ name: 'issue:create', status: 'failed', reasonCode: created.error.code }], error: {
364
- ...created.error,
365
- code: created.error.retryable ? 'ISSUE_CREATE_OUTCOME_UNKNOWN' : created.error.code
464
+ {
465
+ const created = base.provider.issues?.create
466
+ ? await base.provider.issues.create({
467
+ context: providerOperationContext(base.loadedContext),
468
+ desired: {
469
+ title: payload.title,
470
+ body: payload.body,
471
+ labels: payload.labels || [],
472
+ assignees: payload.assignees,
473
+ milestone: typeof payload.milestone === 'string' ? payload.milestone : null,
474
+ fields: {}
475
+ },
476
+ mutation: { idempotencyKey: `issue:create:${base.resolved.taskId}` }
477
+ })
478
+ : unsupportedProviderOperation(base.provider, 'issues.create');
479
+ if (!created.ok) return providerIssueError(base, created.error, null);
480
+ let createdIdentity: ResourceIdentity;
481
+ try {
482
+ createdIdentity = providerResourceIdentity(base.provider, 'issue', created.value.remoteId);
483
+ } catch (error) {
484
+ return result('failed', base.resolved.taskId, null, {
485
+ platform: base.context.platform, capabilities: base.context.capabilities,
486
+ error: { code: 'ISSUE_CREATE_RESPONSE_INVALID', message: error instanceof Error ? error.message : String(error), retryable: false }
487
+ });
366
488
  }
367
- });
368
- const issueNumber = Number(created.value.number);
369
- if (!Number.isInteger(issueNumber) || issueNumber <= 0 || !created.value.node_id || !created.value.html_url) {
370
- return result('failed', base.resolved.taskId, null, {
489
+ const inspected = base.provider.issues?.inspect
490
+ ? await base.provider.issues.inspect({
491
+ context: providerOperationContext(base.loadedContext),
492
+ target: createdIdentity
493
+ })
494
+ : unsupportedProviderOperation(base.provider, 'issues.inspect');
495
+ if (!inspected.ok) return providerIssueError(base, inspected.error, createdIdentity);
496
+ const issueNumber = resourceIdentityNumber(inspected.value.identity || createdIdentity);
497
+ const written = writeTask({
498
+ taskRef: base.resolved.taskId, expectedState: 'active', mutations: [{ kind: 'frontmatter', set: { platform_issue_identity: serializeResourceIdentity(inspected.value.identity || createdIdentity) } }]
499
+ }, { repoRoot: base.resolved.repoRoot });
500
+ if (written.status === 'failed') return result('failed', base.resolved.taskId, null, {
371
501
  platform: base.context.platform, capabilities: base.context.capabilities,
372
- error: { code: 'ISSUE_CREATE_RESPONSE_INVALID', message: 'Issue create response lacks validated identity', retryable: false }
502
+ resource: { kind: 'issue', number: issueNumber, identity: inspected.value.identity || createdIdentity },
503
+ error: { code: 'ISSUE_CREATED_BIND_FAILED', message: written.error.message, retryable: false }
504
+ });
505
+ return result('applied', base.resolved.taskId, issueNumber, {
506
+ changed: true,
507
+ platform: base.context.platform,
508
+ capabilities: base.context.capabilities,
509
+ resource: { kind: 'issue', number: issueNumber, identity: inspected.value.identity || createdIdentity },
510
+ operations: [{ name: 'issue:create', status: 'applied', reasonCode: null }, { name: 'task:bind', status: 'applied', reasonCode: null }],
511
+ issue: normalizeProviderIssue(inspected.value, repository, issueNumber),
512
+ error: null
373
513
  });
374
514
  }
375
- const written = writeTask({
376
- taskRef: base.resolved.taskId, expectedState: 'active', mutations: [{ kind: 'frontmatter', set: { issue_number: issueNumber } }]
377
- }, { repoRoot: base.resolved.repoRoot });
378
- if (written.status === 'failed') return result('failed', base.resolved.taskId, null, {
379
- platform: base.context.platform, capabilities: base.context.capabilities,
380
- resource: { kind: 'issue', number: issueNumber },
381
- operations: [{ name: 'issue:create', status: 'applied', reasonCode: null }, { name: 'task:bind', status: 'failed', reasonCode: written.error.code }],
382
- error: { code: 'ISSUE_CREATED_BIND_FAILED', message: `${created.value.html_url}: ${written.error.message}`, retryable: false }
383
- });
384
- return result('applied', base.resolved.taskId, issueNumber, {
385
- changed: true, platform: base.context.platform, capabilities: base.context.capabilities,
386
- resource: { kind: 'issue', number: issueNumber },
387
- operations: [{ name: 'issue:create', status: 'applied', reasonCode: null }, { name: 'task:bind', status: 'applied', reasonCode: null }],
388
- issue: normalizeIssue({
389
- ...created.value,
390
- state: 'open',
391
- title: payload.title,
392
- body: payload.body,
393
- labels: [],
394
- assignees: payload.assignees.map((login) => ({ login })),
395
- milestone: null
396
- }, repository),
397
- error: null
398
- });
399
515
  }
400
516
 
401
- function bindPlatformIssue(taskRef: string, options: BindOptions): IssueResult {
402
- const base = resolvedContext(taskRef, options);
517
+ async function bindPlatformIssue(taskRef: string, options: BindOptions): Promise<IssueResult> {
518
+ const base = await resolvedContext(taskRef, options);
403
519
  if (!base.ok) return base.output;
404
- if (!Number.isInteger(options.issue) || options.issue <= 0) return result('failed', base.resolved.taskId, base.issueNumber, {
405
- error: { code: 'ISSUE_NUMBER_INVALID', message: 'Issue number must be positive', retryable: false }
520
+ let identity: ResourceIdentity;
521
+ try { identity = providerResourceToken(base.provider, 'issue', String(options.issue)); }
522
+ catch (error) { return result('failed', base.resolved.taskId, base.issueNumber, { error: { code: 'PLATFORM_IDENTITY_TOKEN_INVALID', message: error instanceof Error ? error.message : String(error), retryable: false } }); }
523
+ if (base.issueIdentity && !resourceIdentityEquals(base.issueIdentity, identity)) return result('failed', base.resolved.taskId, base.issueNumber, {
524
+ error: { code: 'ISSUE_BIND_CONFLICT', message: 'Task is already bound to a different Issue identity', retryable: false }
406
525
  });
407
- if (base.issueNumber && base.issueNumber !== options.issue) return result('failed', base.resolved.taskId, base.issueNumber, {
408
- error: { code: 'ISSUE_BIND_CONFLICT', message: `Task is already bound to Issue #${base.issueNumber}`, retryable: false }
409
- });
410
- const fetched = fetchIssue(base.client, base.context.platform.repository!, options.issue, base.resolved.repoRoot);
411
- if (!fetched.ok) return result(fetched.error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, { error: fetched.error });
412
- const issue = normalizeIssue(fetched.value, base.context.platform.repository!);
413
- if (!issue) return result('failed', base.resolved.taskId, base.issueNumber, { error: { code: 'ISSUE_IDENTITY_INVALID', message: 'Remote resource is not a valid Issue', retryable: false } });
414
- if (base.issueNumber === options.issue) return result('no-op', base.resolved.taskId, options.issue, { issue, error: null });
415
- const written = writeTask({ taskRef: base.resolved.taskId, expectedState: 'active', dryRun: options.dryRun, mutations: [{ kind: 'frontmatter', set: { issue_number: options.issue } }] }, { repoRoot: base.resolved.repoRoot });
526
+ const fetched = base.provider.issues?.inspect
527
+ ? await base.provider.issues.inspect({ context: providerOperationContext(base.loadedContext), target: identity })
528
+ : unsupportedProviderOperation(base.provider, 'issues.inspect');
529
+ if (!fetched.ok) return providerIssueError(base, fetched.error, identity);
530
+ const issue = normalizeProviderIssue(fetched.value, base.context.platform.repository!, resourceIdentityNumber(identity));
531
+ if (base.issueIdentity && resourceIdentityEquals(base.issueIdentity, identity)) return result('no-op', base.resolved.taskId, resourceIdentityNumber(identity), { issue, error: null });
532
+ const written = writeTask({ taskRef: base.resolved.taskId, expectedState: 'active', dryRun: options.dryRun, mutations: [{ kind: 'frontmatter', set: { platform_issue_identity: serializeResourceIdentity(identity) } }] }, { repoRoot: base.resolved.repoRoot });
416
533
  if (written.status === 'failed') return result('failed', base.resolved.taskId, null, { issue, error: { code: written.error.code, message: written.error.message, retryable: false } });
417
- return result(options.dryRun ? 'planned' : 'applied', base.resolved.taskId, options.dryRun ? null : options.issue, {
418
- changed: !options.dryRun, issue, operations: [{ name: 'task:bind', status: options.dryRun ? 'planned' : 'applied', reasonCode: null }], error: null
534
+ return result(options.dryRun ? 'planned' : 'applied', base.resolved.taskId, options.dryRun ? null : resourceIdentityNumber(identity), {
535
+ changed: !options.dryRun, issue, resource: { kind: 'issue', number: resourceIdentityNumber(identity), identity }, operations: [{ name: 'task:bind', status: options.dryRun ? 'planned' : 'applied', reasonCode: null }], error: null
419
536
  });
420
537
  }
421
538
 
@@ -466,29 +583,34 @@ function applyRestOperations(snapshot: IssueSnapshot, operations: PlannedOperati
466
583
  let labelsChanged = false;
467
584
  for (const operation of operations) {
468
585
  if (operation.status !== 'planned') continue;
586
+ if (operation.name === 'assignees') payload.assignees = operation.value;
587
+ if (operation.name === 'requirements') payload.body = operation.value;
588
+ if (operation.name === 'state') payload.state = operation.value;
589
+ if (operation.name === 'milestone') payload.milestone = operation.value;
469
590
  if (operation.name === 'labels:status') {
470
- labels = [
471
- ...labels.filter((label) => !label.startsWith('status:')),
472
- ...(operation.value as string[]).filter((label) => label.startsWith('status:'))
473
- ];
591
+ labels = applyLabelDeltaToSnapshot(labels, operation.value as string[], 'status:');
474
592
  labelsChanged = true;
475
593
  }
476
594
  if (operation.name === 'labels:in') {
477
- labels = [
478
- ...labels.filter((label) => !label.startsWith('in:')),
479
- ...(operation.value as string[]).filter((label) => label.startsWith('in:'))
480
- ];
595
+ labels = applyLabelDeltaToSnapshot(labels, operation.value as string[], 'in:');
481
596
  labelsChanged = true;
482
597
  }
483
- if (operation.name === 'assignees') payload.assignees = operation.value;
484
- if (operation.name === 'requirements') payload.body = operation.value;
485
- if (operation.name === 'state') payload.state = operation.value;
486
- if (operation.name === 'milestone') payload.milestone = operation.value;
598
+ if (operation.name === 'issue-type') payload.issueType = operation.value;
599
+ if (operation.name === 'fields') payload.fields = operation.value;
487
600
  }
488
- if (labelsChanged) payload.labels = [...new Set(labels)].sort();
601
+ if (labelsChanged) payload.labels = labels;
489
602
  return payload;
490
603
  }
491
604
 
605
+ function applyLabelDeltaToSnapshot(current: string[], target: readonly string[], prefix: string): string[] {
606
+ const delta = labelDelta(current, target, prefix);
607
+ const targetLabels = [...new Set(target.filter((label) => label.startsWith(prefix)))];
608
+ return [...new Set([
609
+ ...current.filter((label) => !delta.remove.includes(label)),
610
+ ...targetLabels
611
+ ])].sort();
612
+ }
613
+
492
614
  type DesiredFieldValue = {
493
615
  fieldId: string;
494
616
  name: string;
@@ -587,26 +709,27 @@ function executeGraphOperation(
587
709
  });
588
710
  }
589
711
 
590
- function syncPlatformIssue(taskRef: string, options: SyncOptions): IssueResult {
591
- const base = resolvedContext(taskRef, options);
712
+ async function syncPlatformIssue(taskRef: string, options: SyncOptions): Promise<IssueResult> {
713
+ const base = await resolvedContext(taskRef, options);
592
714
  if (!base.ok) return base.output;
593
- if (!base.issueNumber) return result('failed', base.resolved.taskId, null, { error: { code: 'ISSUE_NOT_LINKED', message: 'Task has no valid issue_number', retryable: false } });
715
+ if (!base.issueIdentity) return result('failed', base.resolved.taskId, null, { error: { code: 'ISSUE_NOT_LINKED', message: 'Task has no valid platform issue identity', retryable: false } });
594
716
  const repository = base.context.platform.repository!;
595
- const fetched = fetchIssue(base.client, repository, base.issueNumber, base.resolved.repoRoot);
596
- if (!fetched.ok) return result(fetched.error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, { error: fetched.error });
597
- const snapshot = normalizeIssue(fetched.value, repository);
598
- if (!snapshot) return result('failed', base.resolved.taskId, base.issueNumber, { error: { code: 'ISSUE_IDENTITY_INVALID', message: 'Remote resource is not a valid Issue', retryable: false } });
717
+ const inspected = base.provider.issues?.inspect
718
+ ? await base.provider.issues.inspect({ context: providerOperationContext(base.loadedContext), target: base.issueIdentity })
719
+ : unsupportedProviderOperation(base.provider, 'issues.inspect');
720
+ if (!inspected.ok) return providerIssueError(base, inspected.error, base.issueIdentity);
721
+ const snapshot = normalizeProviderIssue(inspected.value, repository, resourceIdentityNumber(base.issueIdentity));
722
+ let providerMetadata: RepositoryMetadataSnapshot | null = null;
599
723
  let repositoryLabels: string[] = [];
600
- if (options.status !== undefined || options.inLabels !== undefined) {
601
- const labels = base.client.json<unknown>(['api', '--paginate', '--slurp', `repos/${repository}/labels?per_page=100`], { cwd: base.resolved.repoRoot });
602
- if (!labels.ok) return result(labels.error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, { issue: snapshot, error: labels.error });
603
- repositoryLabels = listNames(labels.value).flatMap((name) => name);
604
- }
605
- let milestones: Array<{ title: string; number: number }> = [];
606
- if (options.milestone !== undefined) {
607
- const listed = base.client.json<unknown>(['api', '--paginate', '--slurp', `repos/${repository}/milestones?state=open&per_page=100`], { cwd: base.resolved.repoRoot });
608
- if (!listed.ok) return result(listed.error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, { issue: snapshot, error: listed.error });
609
- milestones = flattenPages(listed.value).map((item) => ({ title: String((item as { title?: string }).title || ''), number: Number((item as { number?: number }).number) })).filter((item) => item.title && Number.isInteger(item.number));
724
+ let milestones: string[] = [];
725
+ if (base.context.capabilities.triage && (options.status !== undefined || options.inLabels !== undefined || options.milestone !== undefined || options.issueType || options.fields)) {
726
+ const metadata = base.provider.issues?.describeRepository
727
+ ? await base.provider.issues.describeRepository({ context: providerOperationContext(base.loadedContext) })
728
+ : unsupportedProviderOperation(base.provider, 'issues.describeRepository');
729
+ if (!metadata.ok) return providerIssueError(base, metadata.error, base.issueIdentity);
730
+ providerMetadata = metadata.value;
731
+ repositoryLabels = metadata.value.labels.map((label) => label.name);
732
+ milestones = metadata.value.milestones.map((milestone) => milestone.title);
610
733
  }
611
734
  const desired: IssueDesiredState = {
612
735
  status: options.status,
@@ -619,7 +742,7 @@ function syncPlatformIssue(taskRef: string, options: SyncOptions): IssueResult {
619
742
  snapshot: options.milestone === 'specific'
620
743
  ? { ...snapshot, milestone: milestoneBasis(base.resolved.repoRoot, snapshot.milestone) }
621
744
  : snapshot,
622
- desired, repositoryLabels: new Set(repositoryLabels), milestones: milestones.map((item) => item.title),
745
+ desired, repositoryLabels: new Set(repositoryLabels), milestones,
623
746
  currentUser: base.context.platform.currentUser,
624
747
  requirementAnchors: requirementSectionAnchors(base.resolved.repoRoot, base.frontmatter.type || 'task'),
625
748
  capabilities: base.context.capabilities
@@ -634,48 +757,94 @@ function syncPlatformIssue(taskRef: string, options: SyncOptions): IssueResult {
634
757
  : { name: 'labels:in', status: 'skipped', reasonCode: 'TRIAGE_REQUIRED' });
635
758
  }
636
759
  if (options.inLabels === 'from-diff') {
637
- try {
638
- const changed = execFileSync('git', ['diff', `${options.base}...HEAD`, '--name-only'], { cwd: base.resolved.repoRoot, encoding: 'utf8' }).trim().split('\n').filter(Boolean);
639
- const config = JSON.parse(fs.readFileSync(`${base.resolved.repoRoot}/.agents/.airc.json`, 'utf8'));
640
- const targets = computeInLabels(changed, config?.labels?.in || {}, new Set(repositoryLabels));
641
- const labels = [...snapshot.labels.filter((label) => !label.startsWith('in:')), ...targets].sort();
642
- plan.operations.push(labels.join('\0') === snapshot.labels.join('\0')
643
- ? { name: 'labels:in', status: 'no-op', reasonCode: null }
644
- : { name: 'labels:in', status: 'planned', reasonCode: null, value: labels });
645
- } catch {
646
- plan.operations.push({ name: 'labels:in', status: 'skipped', reasonCode: 'DIFF_UNAVAILABLE' });
760
+ const taskBase = typeof base.frontmatter.delivery_base_ref === 'string'
761
+ ? base.frontmatter.delivery_base_ref.trim()
762
+ : '';
763
+ if (!taskBase) {
764
+ plan.operations.push({ name: 'labels:in', status: 'failed', reasonCode: 'IN_LABEL_SYNC_BASE_MISSING' });
765
+ } else if (options.base && options.base !== taskBase) {
766
+ plan.operations.push({ name: 'labels:in', status: 'failed', reasonCode: 'IN_LABEL_SYNC_BASE_MISMATCH' });
767
+ } else {
768
+ try {
769
+ const changed = execFileSync('git', ['diff', `${taskBase}...HEAD`, '--name-only'], {
770
+ cwd: base.resolved.repoRoot, encoding: 'utf8'
771
+ }).trim().split(/\r?\n/).filter(Boolean);
772
+ const config = JSON.parse(fs.readFileSync(`${base.resolved.repoRoot}/.agents/.airc.json`, 'utf8')) as {
773
+ labels?: { in?: Record<string, unknown> };
774
+ };
775
+ const mapping = validateInLabelMapping(config.labels?.in);
776
+ if (!mapping.ok) {
777
+ plan.operations.push({ name: 'labels:in', status: 'failed', reasonCode: mapping.error.code });
778
+ } else {
779
+ const planned = planInLabelUpdate({
780
+ changedFiles: changed,
781
+ currentLabels: snapshot.labels,
782
+ mapping: mapping.value,
783
+ repositoryLabels: new Set(repositoryLabels)
784
+ });
785
+ if (planned.error) {
786
+ plan.operations.push({ name: 'labels:in', status: 'failed', reasonCode: planned.error.code });
787
+ } else {
788
+ plan.operations.push(planned.changed
789
+ ? { name: 'labels:in', status: 'planned', reasonCode: null, value: planned.labels }
790
+ : { name: 'labels:in', status: 'no-op', reasonCode: null });
791
+ }
792
+ }
793
+ } catch {
794
+ if (!plan.operations.some((operation) => operation.name === 'labels:in' && operation.status === 'failed')) {
795
+ plan.operations.push({ name: 'labels:in', status: 'failed', reasonCode: 'IN_LABEL_SYNC_EVIDENCE_UNAVAILABLE' });
796
+ }
797
+ }
647
798
  }
648
799
  }
649
- const graph = planGraphMetadata(
650
- base.client, repository, base.issueNumber, base.resolved.repoRoot, base.frontmatter,
651
- options, base.context.capabilities
652
- );
653
- plan.operations.push(...graph.operations);
800
+ if (options.issueType || options.fields) {
801
+ plan.operations.push(...(providerMetadata
802
+ ? planProviderMetadata(inspected.value, providerMetadata, base.frontmatter, options, base.context.capabilities)
803
+ : [{ name: 'issue-type', status: 'skipped' as const, reasonCode: 'ISSUE_TYPES_UNSUPPORTED' }]));
804
+ }
654
805
  const failure = plan.operations.find((operation) => operation.status === 'failed');
655
806
  if (failure) return result('failed', base.resolved.taskId, base.issueNumber, { issue: snapshot, operations: plan.operations, error: { code: failure.reasonCode || 'ISSUE_SYNC_FAILED', message: `Operation ${failure.name} failed`, retryable: false } });
656
807
  const planned = plan.operations.filter((operation) => operation.status === 'planned');
657
808
  const skipped = plan.operations.some((operation) => operation.status === 'skipped');
658
809
  if (options.dryRun) return result(planned.length ? 'planned' : skipped ? 'degraded' : 'no-op', base.resolved.taskId, base.issueNumber, { issue: snapshot, operations: plan.operations, error: null });
659
810
  if (planned.length === 0) return result(skipped ? 'degraded' : 'no-op', base.resolved.taskId, base.issueNumber, { issue: snapshot, operations: plan.operations, error: null });
660
- const restPlanned = planned.filter((operation) => !['issue-type', 'fields'].includes(operation.name));
661
- const graphPlanned = planned.filter((operation) => ['issue-type', 'fields'].includes(operation.name));
662
- const payload = applyRestOperations(snapshot, restPlanned);
663
- if (typeof payload.milestone === 'string') payload.milestone = milestones.find((item) => item.title === payload.milestone)?.number ?? payload.milestone;
664
- if (options.closeReason) payload.state_reason = options.closeReason;
665
- if (Object.keys(payload).length > 0) {
666
- const patched = base.client.json<unknown>(['api', `repos/${repository}/issues/${base.issueNumber}`, '-X', 'PATCH', '--input', '-'], {
667
- cwd: base.resolved.repoRoot, method: 'PATCH', input: JSON.stringify(payload)
811
+ const payload = applyRestOperations(snapshot, planned) as Partial<{
812
+ title: string; body: string; labels: string[]; assignees: string[];
813
+ milestone: string | null; state: 'open' | 'closed'; fields: Record<string, string | number | null>;
814
+ }>;
815
+ const updated = base.provider.issues?.update
816
+ ? await base.provider.issues.update({ context: providerOperationContext(base.loadedContext), target: base.issueIdentity, currentLabels: snapshot.labels, patch: payload, mutation: { idempotencyKey: `issue:update:${base.resolved.taskId}` } })
817
+ : unsupportedProviderOperation(base.provider, 'issues.update');
818
+ if (!updated.ok) return providerIssueError(base, updated.error, base.issueIdentity);
819
+ const labelOperations = planned.filter((operation) => operation.name === 'labels:status' || operation.name === 'labels:in');
820
+ let finalIssue = snapshot;
821
+ if (labelOperations.length > 0) {
822
+ const reread = base.provider.issues?.inspect
823
+ ? await base.provider.issues.inspect({ context: providerOperationContext(base.loadedContext), target: base.issueIdentity })
824
+ : unsupportedProviderOperation(base.provider, 'issues.inspect');
825
+ if (!reread.ok) return result('blocked', base.resolved.taskId, base.issueNumber, {
826
+ changed: updated.value.changed,
827
+ issue: snapshot,
828
+ operations: plan.operations,
829
+ error: { ...reread.error, code: 'IN_LABEL_SYNC_PARTIAL', message: `In-label synchronization is partial or unknown: ${reread.error.message}` }
830
+ });
831
+ finalIssue = normalizeProviderIssue(reread.value, repository, resourceIdentityNumber(base.issueIdentity));
832
+ let expectedLabels = [...snapshot.labels];
833
+ for (const operation of labelOperations) {
834
+ expectedLabels = applyLabelDeltaToSnapshot(expectedLabels, operation.value as string[], operation.name === 'labels:status' ? 'status:' : 'in:');
835
+ }
836
+ const expected = expectedLabels.filter((label) => label.startsWith('status:') || label.startsWith('in:')).sort();
837
+ const actual = finalIssue.labels.filter((label) => label.startsWith('status:') || label.startsWith('in:')).sort();
838
+ if (expected.join('\0') !== actual.join('\0')) return result('blocked', base.resolved.taskId, base.issueNumber, {
839
+ changed: updated.value.changed,
840
+ issue: finalIssue,
841
+ operations: plan.operations,
842
+ error: { code: 'IN_LABEL_SYNC_PARTIAL', message: 'Issue labels did not converge after update', retryable: true }
668
843
  });
669
- if (!patched.ok) return result(patched.error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, { issue: snapshot, operations: plan.operations, error: patched.error });
670
- }
671
- for (const operation of graphPlanned) {
672
- if (!graph.issueId) return result('failed', base.resolved.taskId, base.issueNumber, { issue: snapshot, operations: plan.operations, error: { code: 'ISSUE_GRAPH_IDENTITY_INVALID', message: 'Issue GraphQL identity is unavailable', retryable: false } });
673
- const written = executeGraphOperation(base.client, base.resolved.repoRoot, graph.issueId, operation);
674
- if (!written.ok) return result(written.error.retryable ? 'blocked' : 'failed', base.resolved.taskId, base.issueNumber, { issue: snapshot, operations: plan.operations, error: written.error });
675
844
  }
676
845
  return result(skipped ? 'degraded' : 'applied', base.resolved.taskId, base.issueNumber, {
677
- changed: true, platform: base.context.platform, capabilities: base.context.capabilities,
678
- resource: { kind: 'issue', number: base.issueNumber }, issue: snapshot,
846
+ changed: updated.value.changed, platform: base.context.platform, capabilities: base.context.capabilities,
847
+ resource: { kind: 'issue', number: base.issueNumber, identity: base.issueIdentity }, issue: finalIssue,
679
848
  operations: plan.operations.map((operation) => operation.status === 'planned' ? { ...operation, status: 'applied' } : operation), error: null
680
849
  });
681
850
  }
@@ -683,6 +852,7 @@ function syncPlatformIssue(taskRef: string, options: SyncOptions): IssueResult {
683
852
  export {
684
853
  bindPlatformIssue,
685
854
  createPlatformIssue,
855
+ graphState,
686
856
  inspectGitHubIssue,
687
857
  inspectGitHubIssueMetadata,
688
858
  inspectPlatformIssue,