@fitlab-ai/agent-infra 0.11.6 → 0.11.8

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 (192) hide show
  1. package/dist/bin/internal-cli.js +8 -2
  2. package/dist/lib/agent-clients/adapters/codex.js +1 -1
  3. package/dist/lib/agent-clients/config.js +11 -3
  4. package/dist/lib/defaults.json +6 -3
  5. package/dist/lib/git/primary-worktree.js +58 -0
  6. package/dist/lib/internal/cli-route-inventory.js +3 -0
  7. package/dist/lib/internal/platform-pr-review.js +20 -3
  8. package/dist/lib/internal/task-operation-registry.js +3 -1
  9. package/dist/lib/internal/task-platform-recovery.js +56 -0
  10. package/dist/lib/merge.js +25 -5
  11. package/dist/lib/platform/completion-backfill.js +2 -1
  12. package/dist/lib/platform/in-label-sync.js +12 -1
  13. package/dist/lib/platform/issue-comments.js +58 -4
  14. package/dist/lib/platform/issues.js +105 -5
  15. package/dist/lib/platform/operation-coordinator.js +74 -0
  16. package/dist/lib/platform/pr-review.js +107 -1
  17. package/dist/lib/platform/pr-summary.js +79 -57
  18. package/dist/lib/platform/pull-requests.js +125 -7
  19. package/dist/lib/sandbox/commands/create.js +6 -3
  20. package/dist/lib/sandbox/commands/ls.js +4 -3
  21. package/dist/lib/sandbox/commands/rm.js +1 -1
  22. package/dist/lib/sandbox/config.js +22 -4
  23. package/dist/lib/sandbox/control/server.js +6 -2
  24. package/dist/lib/sandbox/git-metadata.js +50 -0
  25. package/dist/lib/sandbox/index.js +1 -1
  26. package/dist/lib/sandbox/mounts.js +12 -0
  27. package/dist/lib/sandbox/recovery.js +11 -0
  28. package/dist/lib/sandbox/removal.js +58 -20
  29. package/dist/lib/sandbox/tools.js +29 -7
  30. package/dist/lib/server/config.js +7 -27
  31. package/dist/lib/server/daemon.js +4 -3
  32. package/dist/lib/server/process-control.js +4 -2
  33. package/dist/lib/server/runner.js +4 -3
  34. package/dist/lib/task/activity-intent.js +4 -3
  35. package/dist/lib/task/artifact-lifecycle.js +42 -10
  36. package/dist/lib/task/artifact-receipts.js +62 -22
  37. package/dist/lib/task/capabilities.js +22 -4
  38. package/dist/lib/task/completion-facts.js +56 -0
  39. package/dist/lib/task/create-service.js +11 -5
  40. package/dist/lib/task/create.js +60 -79
  41. package/dist/lib/task/events.js +268 -195
  42. package/dist/lib/task/finalization-artifacts.js +4 -1
  43. package/dist/lib/task/finalization-handoff.js +2 -1
  44. package/dist/lib/task/finalization.js +157 -24
  45. package/dist/lib/task/invalidation-command.js +4 -2
  46. package/dist/lib/task/invalidation.js +38 -9
  47. package/dist/lib/task/lifecycle.js +38 -2
  48. package/dist/lib/task/local-artifact-finalization.js +17 -3
  49. package/dist/lib/task/platform-operation-journal.js +152 -0
  50. package/dist/lib/task/platform-operation-recovery.js +459 -0
  51. package/dist/lib/task/qualification-audit.js +23 -105
  52. package/dist/lib/task/review-finalization.js +7 -0
  53. package/dist/lib/task/review-identity.js +69 -0
  54. package/dist/lib/task/sections.js +6 -5
  55. package/dist/lib/task/task-execution-lock.js +6 -3
  56. package/dist/lib/task/verification-engine.js +14 -63
  57. package/dist/lib/task/verification.js +35 -38
  58. package/dist/lib/task/workflow-warning-intents.js +7 -1
  59. package/dist/lifecycle-build-manifest.json +3 -3
  60. package/lib/agent-clients/adapters/codex.ts +1 -1
  61. package/lib/agent-clients/config.ts +9 -3
  62. package/lib/defaults.json +6 -3
  63. package/lib/git/primary-worktree.ts +62 -0
  64. package/lib/internal/cli-route-inventory.ts +2 -0
  65. package/lib/internal/platform-pr-review.ts +14 -3
  66. package/lib/internal/task-operation-registry.ts +3 -1
  67. package/lib/internal/task-platform-recovery.ts +53 -0
  68. package/lib/merge.ts +26 -6
  69. package/lib/platform/completion-backfill.ts +2 -1
  70. package/lib/platform/in-label-sync.ts +12 -0
  71. package/lib/platform/issue-comments.ts +59 -3
  72. package/lib/platform/issues.ts +103 -5
  73. package/lib/platform/operation-coordinator.ts +96 -0
  74. package/lib/platform/pr-review.ts +113 -1
  75. package/lib/platform/pr-summary.ts +22 -1
  76. package/lib/platform/pull-requests.ts +132 -8
  77. package/lib/sandbox/commands/create.ts +7 -8
  78. package/lib/sandbox/commands/ls.ts +4 -3
  79. package/lib/sandbox/commands/rm.ts +1 -1
  80. package/lib/sandbox/config.ts +23 -5
  81. package/lib/sandbox/control/server.ts +5 -2
  82. package/lib/sandbox/git-metadata.ts +66 -0
  83. package/lib/sandbox/index.ts +1 -1
  84. package/lib/sandbox/mounts.ts +14 -0
  85. package/lib/sandbox/recovery.ts +13 -0
  86. package/lib/sandbox/removal.ts +57 -19
  87. package/lib/sandbox/tool-types.ts +1 -1
  88. package/lib/sandbox/tools.ts +36 -7
  89. package/lib/server/config.ts +7 -29
  90. package/lib/server/daemon.ts +5 -3
  91. package/lib/server/process-control.ts +4 -2
  92. package/lib/server/runner.ts +7 -4
  93. package/lib/server/server.schema.json +0 -4
  94. package/lib/task/activity-intent.ts +4 -3
  95. package/lib/task/artifact-lifecycle.ts +35 -11
  96. package/lib/task/artifact-receipts.ts +55 -15
  97. package/lib/task/capabilities.ts +17 -3
  98. package/lib/task/completion-facts.ts +66 -0
  99. package/lib/task/create-service.ts +12 -6
  100. package/lib/task/create.ts +54 -69
  101. package/lib/task/events.ts +248 -182
  102. package/lib/task/finalization-artifacts.ts +4 -1
  103. package/lib/task/finalization-handoff.ts +2 -1
  104. package/lib/task/finalization.ts +154 -21
  105. package/lib/task/invalidation-command.ts +4 -2
  106. package/lib/task/invalidation.ts +35 -10
  107. package/lib/task/lifecycle.ts +35 -3
  108. package/lib/task/local-artifact-finalization.ts +13 -3
  109. package/lib/task/platform-operation-journal.ts +230 -0
  110. package/lib/task/platform-operation-recovery.ts +435 -0
  111. package/lib/task/qualification-audit.ts +21 -114
  112. package/lib/task/review-finalization.ts +7 -0
  113. package/lib/task/review-identity.ts +85 -0
  114. package/lib/task/sections.ts +6 -6
  115. package/lib/task/task-execution-lock.ts +7 -3
  116. package/lib/task/verification-engine.ts +13 -86
  117. package/lib/task/verification.ts +34 -38
  118. package/lib/task/workflow-warning-intents.ts +9 -1
  119. package/package.json +1 -1
  120. package/templates/.agents/README.en.md +19 -17
  121. package/templates/.agents/README.zh-CN.md +18 -16
  122. package/templates/.agents/rules/README.en.md +2 -1
  123. package/templates/.agents/rules/README.zh-CN.md +2 -1
  124. package/templates/.agents/rules/decision-qualification.en.md +4 -3
  125. package/templates/.agents/rules/decision-qualification.zh-CN.md +4 -3
  126. package/templates/.agents/rules/task-management.en.md +12 -0
  127. package/templates/.agents/rules/task-management.zh-CN.md +12 -0
  128. package/templates/.agents/rules/validation-output.en.md +19 -0
  129. package/templates/.agents/rules/validation-output.zh-CN.md +19 -0
  130. package/templates/.agents/skills/analyze-task/SKILL.en.md +3 -1
  131. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +3 -3
  132. package/templates/.agents/skills/block-task/SKILL.en.md +2 -0
  133. package/templates/.agents/skills/block-task/SKILL.zh-CN.md +1 -1
  134. package/templates/.agents/skills/cancel-task/SKILL.en.md +6 -2
  135. package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +5 -3
  136. package/templates/.agents/skills/code-task/SKILL.en.md +5 -3
  137. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +4 -4
  138. package/templates/.agents/skills/code-task/reference/report-template.en.md +4 -8
  139. package/templates/.agents/skills/code-task/reference/report-template.zh-CN.md +4 -8
  140. package/templates/.agents/skills/commit/SKILL.en.md +1 -1
  141. package/templates/.agents/skills/commit/SKILL.zh-CN.md +1 -1
  142. package/templates/.agents/skills/complete-manual-validation/SKILL.en.md +1 -1
  143. package/templates/.agents/skills/complete-manual-validation/SKILL.zh-CN.md +1 -1
  144. package/templates/.agents/skills/complete-task/SKILL.en.md +4 -0
  145. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +4 -0
  146. package/templates/.agents/skills/create-pr/SKILL.en.md +2 -0
  147. package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +1 -1
  148. package/templates/.agents/skills/create-task/SKILL.en.md +2 -0
  149. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +1 -1
  150. package/templates/.agents/skills/import-codescan/SKILL.en.md +2 -0
  151. package/templates/.agents/skills/import-codescan/SKILL.zh-CN.md +1 -1
  152. package/templates/.agents/skills/import-dependabot/SKILL.en.md +2 -0
  153. package/templates/.agents/skills/import-dependabot/SKILL.zh-CN.md +1 -1
  154. package/templates/.agents/skills/import-issue/SKILL.en.md +2 -0
  155. package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +1 -1
  156. package/templates/.agents/skills/plan-task/SKILL.en.md +3 -1
  157. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +3 -3
  158. package/templates/.agents/skills/plan-task/config/verify.en.json +1 -1
  159. package/templates/.agents/skills/plan-task/config/verify.zh-CN.json +1 -1
  160. package/templates/.agents/skills/review-analysis/SKILL.en.md +3 -1
  161. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +3 -1
  162. package/templates/.agents/skills/review-analysis/reference/report-template.en.md +4 -8
  163. package/templates/.agents/skills/review-analysis/reference/report-template.zh-CN.md +4 -8
  164. package/templates/.agents/skills/review-code/SKILL.en.md +13 -12
  165. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +11 -12
  166. package/templates/.agents/skills/review-code/config/verify.en.json +1 -3
  167. package/templates/.agents/skills/review-code/config/verify.zh-CN.json +1 -3
  168. package/templates/.agents/skills/review-code/reference/output-templates.en.md +23 -46
  169. package/templates/.agents/skills/review-code/reference/output-templates.zh-CN.md +27 -48
  170. package/templates/.agents/skills/review-code/reference/report-template.en.md +8 -11
  171. package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +8 -11
  172. package/templates/.agents/skills/review-code/reference/review-criteria.en.md +2 -2
  173. package/templates/.agents/skills/review-code/reference/review-criteria.zh-CN.md +2 -2
  174. package/templates/.agents/skills/review-plan/SKILL.en.md +3 -1
  175. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +3 -1
  176. package/templates/.agents/skills/review-plan/reference/report-template.en.md +5 -9
  177. package/templates/.agents/skills/review-plan/reference/report-template.zh-CN.md +5 -9
  178. package/templates/.agents/skills/review-pr/SKILL.en.md +13 -11
  179. package/templates/.agents/skills/review-pr/SKILL.zh-CN.md +13 -11
  180. package/templates/.agents/skills/review-pr/reference/output-guidance.en.md +3 -3
  181. package/templates/.agents/skills/review-pr/reference/output-guidance.zh-CN.md +3 -3
  182. package/templates/.agents/skills/review-pr/reference/report-template.en.md +4 -0
  183. package/templates/.agents/skills/review-pr/reference/report-template.zh-CN.md +4 -0
  184. package/templates/.agents/skills/run-manual-validation/SKILL.en.md +1 -1
  185. package/templates/.agents/skills/run-manual-validation/SKILL.zh-CN.md +1 -1
  186. package/templates/.agents/skills/run-task/SKILL.en.md +1 -1
  187. package/templates/.agents/skills/run-task/SKILL.zh-CN.md +1 -1
  188. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +19 -6
  189. package/templates/.agents/skills/watch-pr/SKILL.en.md +2 -0
  190. package/templates/.agents/skills/watch-pr/SKILL.zh-CN.md +1 -1
  191. package/templates/.agents/templates/task.en.md +2 -2
  192. package/templates/.agents/templates/task.zh-CN.md +2 -2
@@ -167,10 +167,16 @@ function normalizeAgentClients(
167
167
  }
168
168
  if (hasOwn(input, 'tuis')) fail('INVALID_AGENT_CLIENTS', 'tuis');
169
169
  const sandbox = isRecord(input.sandbox) ? input.sandbox : undefined;
170
- if (Array.isArray(sandbox?.tools)) {
171
- for (const [index, tool] of sandbox.tools.entries()) {
170
+ if (sandbox && hasOwn(sandbox, 'customTools')) {
171
+ fail('INVALID_AGENT_CLIENTS', 'sandbox.customTools');
172
+ }
173
+ if (sandbox && hasOwn(sandbox, 'tools')) {
174
+ if (!isRecord(sandbox.tools)) fail('INVALID_AGENT_CLIENTS', 'sandbox.tools');
175
+ const tools = sandbox.tools;
176
+ if (!Array.isArray(tools.ids)) fail('INVALID_AGENT_CLIENTS', 'sandbox.tools.ids');
177
+ for (const [index, tool] of tools.ids.entries()) {
172
178
  if (isAgentClientId(tool)) {
173
- fail('INVALID_AGENT_CLIENTS', `sandbox.tools[${index}]`);
179
+ fail('INVALID_AGENT_CLIENTS', `sandbox.tools.ids[${index}]`);
174
180
  }
175
181
  }
176
182
  }
package/lib/defaults.json CHANGED
@@ -34,9 +34,12 @@
34
34
  "runtimes": [
35
35
  "node22"
36
36
  ],
37
- "tools": [
38
- "agent-infra"
39
- ],
37
+ "tools": {
38
+ "ids": [
39
+ "agent-infra"
40
+ ],
41
+ "definitions": {}
42
+ },
40
43
  "refreshIntervalDays": 7,
41
44
  "dockerfile": null,
42
45
  "vm": {
@@ -0,0 +1,62 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+
4
+ import { sameFilesystemEntry } from './worktree-identity.ts';
5
+
6
+ function gitText(repositoryRoot: string, args: string[]): string {
7
+ return execFileSync('git', ['-C', repositoryRoot, ...args], {
8
+ encoding: 'utf8',
9
+ stdio: ['ignore', 'pipe', 'pipe']
10
+ }).replace(/\r?\n$/, '');
11
+ }
12
+
13
+ function isPrimaryWorktree(candidate: string, expectedCommonDirectory: string): boolean {
14
+ try {
15
+ const root = fs.realpathSync.native(candidate);
16
+ if (!fs.statSync(root).isDirectory()) return false;
17
+ if (gitText(root, ['rev-parse', '--show-prefix']) !== '') return false;
18
+
19
+ const commonDirectory = gitText(root, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
20
+ const gitDirectory = gitText(root, ['rev-parse', '--absolute-git-dir']);
21
+ return sameFilesystemEntry(commonDirectory, expectedCommonDirectory) &&
22
+ sameFilesystemEntry(gitDirectory, expectedCommonDirectory);
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+
28
+ function worktreePaths(repositoryRoot: string): string[] {
29
+ const output = execFileSync('git', ['-C', repositoryRoot, 'worktree', 'list', '--porcelain', '-z'], {
30
+ encoding: 'utf8',
31
+ stdio: ['ignore', 'pipe', 'pipe']
32
+ });
33
+
34
+ return output
35
+ .split('\0\0')
36
+ .flatMap((record) => record.split('\0'))
37
+ .filter((field) => field.startsWith('worktree '))
38
+ .map((field) => field.slice('worktree '.length));
39
+ }
40
+
41
+ export function resolvePrimaryWorktreeRoot(repositoryRoot: string): string {
42
+ try {
43
+ const root = fs.realpathSync.native(repositoryRoot);
44
+ if (!fs.statSync(root).isDirectory() || gitText(root, ['rev-parse', '--show-prefix']) !== '') {
45
+ throw new Error('repository root is not a worktree root');
46
+ }
47
+
48
+ const commonDirectory = gitText(root, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
49
+ const gitDirectory = gitText(root, ['rev-parse', '--absolute-git-dir']);
50
+ if (sameFilesystemEntry(gitDirectory, commonDirectory)) return root;
51
+
52
+ const matches = worktreePaths(root).filter((candidate) => isPrimaryWorktree(candidate, commonDirectory));
53
+ if (matches.length !== 1) {
54
+ throw new Error(matches.length === 0 ? 'primary worktree metadata was not found' : 'primary worktree metadata is ambiguous');
55
+ }
56
+
57
+ return fs.realpathSync.native(matches[0]!);
58
+ } catch (error) {
59
+ const detail = error instanceof Error ? error.message : String(error);
60
+ throw new Error(`server: unable to locate primary worktree: ${detail}`);
61
+ }
62
+ }
@@ -63,6 +63,7 @@ export const INTERNAL_HANDLER_ROUTE_SELECTORS = Object.freeze({
63
63
  'task-invalidation': ['reconcile'],
64
64
  'task-lifecycle': ['intent'],
65
65
  'task-finalization': ['complete'],
66
+ 'task-platform-recovery': ['inspect', 'recover'],
66
67
  'task-short-id': ['list', 'list-verify', 'alloc', 'release', 'resolve'],
67
68
  'task-snapshot': ['snapshot'],
68
69
  'task-verify': ['event'],
@@ -89,6 +90,7 @@ export function internalRouteSelector(command: string, args: readonly string[]):
89
90
  if (command === 'task-qualification') return args[1] ?? '';
90
91
  if (command === 'task-lifecycle') return args[1] ? 'intent' : '';
91
92
  if (command === 'task-finalization') return args[1] === 'complete' ? 'complete' : '';
93
+ if (command === 'task-platform-recovery') return args[1] ?? '';
92
94
  if (command === 'task-event') return args[1] ? 'event' : '';
93
95
  if (command === 'task-verify') return args[0] && args[1] ? 'event' : '';
94
96
  if (command === 'task-snapshot') return 'snapshot';
@@ -6,14 +6,15 @@ import { listPrReviews, publishPrReview } from '../platform/pr-review.ts';
6
6
  import type { PrReviewEvent } from '../platform/pr-review.ts';
7
7
  import type { PlatformResult } from '../platform/types.ts';
8
8
  import { ensureInternalHandlerRoute, internalHandlerRoute } from './cli-route-inventory.ts';
9
+ import { normalizeAgentToken, AGENT_USAGE_HINT } from '../agent-clients/tokens.ts';
9
10
 
10
11
  const USAGE = `Usage: agent-infra-internal platform-pr-review inspect --pr <token> [--cwd <path>]
11
12
  agent-infra-internal platform-pr-review list --pr <token> [--cwd <path>]
12
- agent-infra-internal platform-pr-review publish --pr <token> --scope <taskId|pr{N}> --round <N> --commit <sha> --event <COMMENT|APPROVE|REQUEST_CHANGES> --body-file <path|-> [--dry-run] [--cwd <path>]
13
+ agent-infra-internal platform-pr-review publish --pr <token> --scope <taskId|pr{N}> --round <N> --commit <sha> --event <COMMENT|APPROVE|REQUEST_CHANGES> --body-file <path|-> [--artifact <pr-review-artifact>] [--agent <agent>] [--dry-run] [--cwd <path>]
13
14
  `;
14
15
 
15
16
  const BOOLEAN_FLAGS = new Set(['--dry-run']);
16
- const VALUE_FLAGS = new Set(['--cwd', '--pr', '--scope', '--round', '--commit', '--event', '--body-file']);
17
+ const VALUE_FLAGS = new Set(['--cwd', '--pr', '--scope', '--round', '--commit', '--event', '--body-file', '--artifact', '--agent']);
17
18
 
18
19
  function key(flag: string): string {
19
20
  return flag.slice(2).replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase());
@@ -67,7 +68,7 @@ async function platformPrReview(args: string[] = []): Promise<void> {
67
68
  const allowed: Record<string, string[]> = {
68
69
  inspect: ['cwd', 'pr'],
69
70
  list: ['cwd', 'pr'],
70
- publish: ['cwd', 'pr', 'scope', 'round', 'commit', 'event', 'bodyFile', 'dryRun']
71
+ publish: ['cwd', 'pr', 'scope', 'round', 'commit', 'event', 'bodyFile', 'artifact', 'agent', 'dryRun']
71
72
  };
72
73
  const unexpected = Object.keys(values).find((name) => !allowed[operation]!.includes(name));
73
74
  if (unexpected) { fail(`${operation} does not accept --${unexpected}`); return; }
@@ -86,6 +87,8 @@ async function platformPrReview(args: string[] = []): Promise<void> {
86
87
  const round = Number(values.round);
87
88
  const commit = typeof values.commit === 'string' ? values.commit : '';
88
89
  const event = values.event as string;
90
+ const rawAgent = typeof values.agent === 'string' ? values.agent : '';
91
+ const agent = rawAgent ? normalizeAgentToken(rawAgent) : null;
89
92
  if (!scope || !/^(?:pr\d+|TASK-\d{8}-\d{6})$/.test(scope)) { fail('publish requires --scope <taskId|pr{N}>'); return; }
90
93
  if (!internalHandlerRoute('platform-pr-review', 'publish-task', publishSelector)
91
94
  && !internalHandlerRoute('platform-pr-review', 'publish-pr', publishSelector)) {
@@ -95,6 +98,12 @@ async function platformPrReview(args: string[] = []): Promise<void> {
95
98
  if (!/^[0-9a-f]{7,40}$/i.test(commit)) { fail('publish requires --commit <sha>'); return; }
96
99
  if (!['COMMENT', 'APPROVE', 'REQUEST_CHANGES'].includes(event)) { fail('publish requires --event <COMMENT|APPROVE|REQUEST_CHANGES>'); return; }
97
100
  if (typeof values.bodyFile !== 'string') { fail('publish requires --body-file'); return; }
101
+ if (/^TASK-\d{8}-\d{6}$/u.test(scope) && typeof values.artifact !== 'string') { fail('task-scoped publish requires --artifact <pr-review-artifact>'); return; }
102
+ if (/^TASK-\d{8}-\d{6}$/u.test(scope) && !agent) {
103
+ fail(`task-scoped publish requires --agent <agent>${rawAgent ? `: invalid '${rawAgent}'` : ''}: ${AGENT_USAGE_HINT}`);
104
+ return;
105
+ }
106
+ if (rawAgent && !agent) { fail(`invalid --agent '${rawAgent}': ${AGENT_USAGE_HINT}`); return; }
98
107
  let body: string;
99
108
  try {
100
109
  body = readBodyFile(values.bodyFile, cwd);
@@ -108,6 +117,8 @@ async function platformPrReview(args: string[] = []): Promise<void> {
108
117
  identity: { scope, round, commitSha: commit },
109
118
  event: event as PrReviewEvent,
110
119
  body,
120
+ ...(typeof values.artifact === 'string' ? { recoveryArtifact: values.artifact } : {}),
121
+ ...(agent ? { agent } : {}),
111
122
  dryRun: values.dryRun === true
112
123
  }));
113
124
  }
@@ -145,6 +145,8 @@ function internalTaskRoutes(): TaskOperationDescriptor[] {
145
145
  descriptor('internal', 'task-invalidation', 'reconcile', 'task-bound', 'progress'),
146
146
  descriptor('internal', 'task-lifecycle', 'intent', 'task-bound', 'progress'),
147
147
  descriptor('internal', 'task-finalization', 'complete', 'task-bound', 'progress'),
148
+ descriptor('internal', 'task-platform-recovery', 'inspect', 'task-bound', 'diagnostic'),
149
+ descriptor('internal', 'task-platform-recovery', 'recover', 'task-bound', 'recovery'),
148
150
  descriptor('internal', 'task-short-id', 'list', 'conditional', 'diagnostic'),
149
151
  descriptor('internal', 'task-short-id', 'list-verify', 'conditional', 'diagnostic'),
150
152
  descriptor('internal', 'task-short-id', 'alloc', 'task-bound', 'progress'),
@@ -221,7 +223,7 @@ export const INTERNAL_DISPATCHER_ROUTES = Object.freeze([
221
223
  'platform-comment', 'platform-issue', 'platform-pr', 'platform-pr-review', 'pr-review-grade',
222
224
  'platform-checks', 'task-context', 'task-ledger', 'task-warning', 'task-activity', 'task-artifact',
223
225
  'task-orchestration', 'task-review', 'task-event', 'task-invalidation', 'task-lifecycle',
224
- 'task-finalization', 'task-short-id', 'task-snapshot', 'task-verify', 'task-validate', 'manual-validation'
226
+ 'task-finalization', 'task-platform-recovery', 'task-short-id', 'task-snapshot', 'task-verify', 'task-validate', 'manual-validation'
225
227
  ]);
226
228
  export const PUBLIC_DISPATCHER_ROUTES = Object.freeze([
227
229
  'agent-client', 'cp', 'data', 'decide', 'help', 'init', 'merge', 'run', 'sandbox', 'server', 'task', 'sync', 'update', 'version'
@@ -0,0 +1,53 @@
1
+ import { detectRepoRoot, resolveTaskRef } from '../task/resolve-ref.ts';
2
+ import { readPlatformOperationJournal } from '../task/platform-operation-journal.ts';
3
+ import { recoverPlatformOperations } from '../task/platform-operation-recovery.ts';
4
+ import { ensureInternalHandlerRoute, internalHandlerRoute } from './cli-route-inventory.ts';
5
+
6
+ const USAGE = 'Usage: agent-infra-internal task-platform-recovery <N | TASK-id> <inspect | recover> [--agent <agent>] [--selection required|deferred|all]\n';
7
+
8
+ function option(args: readonly string[], name: string): string | undefined {
9
+ const index = args.indexOf(name);
10
+ return index >= 0 ? args[index + 1] : undefined;
11
+ }
12
+
13
+ async function taskPlatformRecovery(args: string[] = []): Promise<void> {
14
+ if (args[0] === '--help' || args[0] === '-h') { process.stdout.write(USAGE); return; }
15
+ if (!ensureInternalHandlerRoute('task-platform-recovery', args)) return;
16
+ const action = args[1];
17
+ const registered = action === 'inspect'
18
+ ? internalHandlerRoute('task-platform-recovery', 'inspect', action)
19
+ : action === 'recover' && internalHandlerRoute('task-platform-recovery', 'recover', action);
20
+ if (!registered) return;
21
+ const repoRoot = detectRepoRoot();
22
+ const task = resolveTaskRef(args[0] ?? '', { repoRoot });
23
+ if (!task.ok) {
24
+ process.stdout.write(`${JSON.stringify({ status: 'failed', changed: false, error: { code: task.code, message: task.message } })}\n`);
25
+ process.exitCode = 1;
26
+ return;
27
+ }
28
+ if (action === 'inspect') {
29
+ try {
30
+ const journal = readPlatformOperationJournal(task.taskId, repoRoot);
31
+ process.stdout.write(`${JSON.stringify({ status: 'ready', changed: false, taskId: task.taskId, journal })}\n`);
32
+ } catch (error) {
33
+ const value = error as { code?: string; message?: string };
34
+ process.stdout.write(`${JSON.stringify({ status: 'failed', changed: false, error: { code: value.code || 'PLATFORM_OPERATION_JOURNAL_INVALID', message: value.message || String(error) } })}\n`);
35
+ process.exitCode = 1;
36
+ }
37
+ return;
38
+ }
39
+ const agent = option(args, '--agent');
40
+ const selection = option(args, '--selection') ?? 'all';
41
+ if (!agent || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u.test(agent)
42
+ || !['required', 'deferred', 'all'].includes(selection)) {
43
+ process.stdout.write(`${JSON.stringify({ status: 'failed', changed: false, error: { code: 'PLATFORM_OPERATION_RECOVERY_PAYLOAD_INVALID', message: 'recover requires a valid --agent and --selection' } })}\n`);
44
+ process.stderr.write(USAGE);
45
+ process.exitCode = 1;
46
+ return;
47
+ }
48
+ const result = await recoverPlatformOperations(task.taskId, selection as 'required' | 'deferred' | 'all', { agent, cwd: repoRoot });
49
+ process.stdout.write(`${JSON.stringify(result)}\n`);
50
+ process.exitCode = result.status === 'applied' || result.status === 'no-op' ? 0 : result.status === 'blocked' ? 2 : 1;
51
+ }
52
+
53
+ export { taskPlatformRecovery };
package/lib/merge.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs';
2
+ import os from 'node:os';
2
3
  import path from 'node:path';
3
4
  import crypto from 'node:crypto';
4
5
  import { removeDirRecursive } from './remove-dir.ts';
@@ -424,6 +425,24 @@ function formatBackupTimestamp(date: Date): string {
424
425
  ].join('') + `-${String(date.getHours()).padStart(2, '0')}${String(date.getMinutes()).padStart(2, '0')}${String(date.getSeconds()).padStart(2, '0')}`;
425
426
  }
426
427
 
428
+ function resolveRecoveryProjectKey(repoRoot: string): string {
429
+ let projectKey: string | undefined;
430
+ try {
431
+ const config = JSON.parse(fs.readFileSync(path.join(repoRoot, '.agents', '.airc.json'), 'utf8')) as { project?: unknown };
432
+ if (typeof config.project === 'string' && config.project.trim() !== '') {
433
+ projectKey = config.project.trim();
434
+ }
435
+ } catch {
436
+ // Fall back to the repository directory when project config is unavailable.
437
+ }
438
+
439
+ projectKey ??= path.basename(repoRoot);
440
+ if (!/^[A-Za-z0-9_.@-]+$/.test(projectKey) || projectKey === '.' || projectKey === '..') {
441
+ throw new Error(`Invalid project name for recovery backups: ${projectKey}`);
442
+ }
443
+ return projectKey;
444
+ }
445
+
427
446
  function toPosixPath(relativePath: string): string {
428
447
  return relativePath.split(path.sep).join('/');
429
448
  }
@@ -541,7 +560,7 @@ function buildWorkspaceIndex(workspaceDir: string): Map<string, WorkspaceRecord>
541
560
  function backupTaskDir(backupRoot: string, section: MutableSection, taskDir: string, taskId: string): string {
542
561
  const backupDir = path.join(backupRoot, section, taskId);
543
562
  fs.mkdirSync(path.dirname(backupDir), { recursive: true });
544
- fs.cpSync(taskDir, backupDir, { recursive: true });
563
+ fs.cpSync(taskDir, backupDir, { recursive: true, verbatimSymlinks: true });
545
564
  return backupDir;
546
565
  }
547
566
 
@@ -1085,16 +1104,17 @@ async function cmdMerge(args: string[]): Promise<void> {
1085
1104
  throw new Error(`Source path is not a directory: ${sourcePath}`);
1086
1105
  }
1087
1106
 
1088
- const workspaceDir = path.join(process.cwd(), '.agents', 'workspace');
1107
+ const repoRoot = process.cwd();
1108
+ const workspaceDir = path.join(repoRoot, '.agents', 'workspace');
1089
1109
  const archiveDir = path.join(workspaceDir, 'archive');
1090
1110
  const backupStamp = formatBackupTimestamp(new Date());
1091
- const backupRootRelative = `.agents/workspace/.merge-backup/${backupStamp}/`;
1092
- const backupRoot = path.join(workspaceDir, '.merge-backup', backupStamp);
1093
- const report = createReport(resolvedSource, backupRootRelative);
1111
+ const projectKey = resolveRecoveryProjectKey(repoRoot);
1112
+ const recoveryRoot = path.join(os.homedir(), '.agent-infra', 'recovery-backups', projectKey, 'workspace-merge');
1113
+ const backupRoot = path.join(recoveryRoot, backupStamp);
1114
+ const report = createReport(resolvedSource, backupRoot);
1094
1115
  detectSourceMode(resolvedSource);
1095
1116
  validateSourceWorkspace(resolvedSource);
1096
1117
  validateArchiveSection(workspaceDir, new Map());
1097
-
1098
1118
  for (const section of ALL_SECTIONS) {
1099
1119
  fs.mkdirSync(path.join(workspaceDir, section), { recursive: true });
1100
1120
  }
@@ -51,7 +51,7 @@ function referencedBackfillArtifact(taskDir: string, message: string): BackfillA
51
51
  .map((match) => match[1]!);
52
52
  if (names.length !== 1) return { status: 'ignored', artifact: null, error: null };
53
53
  const parsed = parseArtifactName(names[0]!);
54
- if (!parsed || (parsed.family !== 'pr-review' && !COMPLETION_BACKFILL_FAMILIES.includes(parsed.family as typeof COMPLETION_BACKFILL_FAMILIES[number]))) {
54
+ if (!parsed || !COMPLETION_BACKFILL_FAMILIES.includes(parsed.family as typeof COMPLETION_BACKFILL_FAMILIES[number])) {
55
55
  return { status: 'ignored', artifact: null, error: null };
56
56
  }
57
57
  const artifactPath = path.join(taskDir, parsed.name);
@@ -141,6 +141,7 @@ async function backfillCompletionComments(
141
141
  artifact: artifact.name,
142
142
  agent: options.agent,
143
143
  backfill: true,
144
+ dependency: 'required',
144
145
  cwd: resolved.repoRoot,
145
146
  client: options.client
146
147
  });
@@ -1,4 +1,5 @@
1
1
  import { computeInLabels } from './metadata-labels.ts';
2
+ import { createHash } from 'node:crypto';
2
3
  import type { PlatformError } from './types.ts';
3
4
 
4
5
  type InLabelPlan = {
@@ -68,6 +69,16 @@ function validateInLabelMapping(value: unknown): { ok: true; value: Record<strin
68
69
  return { ok: true, value: normalized };
69
70
  }
70
71
 
72
+ function inLabelMappingDigest(value: unknown): { ok: true; digest: string } | { ok: false; error: InLabelValidationError } {
73
+ const mapping = validateInLabelMapping(value);
74
+ if (!mapping.ok) return mapping;
75
+ const canonical = Object.fromEntries(Object.keys(mapping.value).sort().map((name) => [
76
+ name,
77
+ [...new Set(mapping.value[name]!)].sort()
78
+ ]));
79
+ return { ok: true, digest: createHash('sha256').update(JSON.stringify(canonical)).digest('hex') };
80
+ }
81
+
71
82
  function repositoryLabelPages(value: unknown): unknown[][] | null {
72
83
  if (!Array.isArray(value)) return null;
73
84
  if (value.length === 0) return [];
@@ -197,6 +208,7 @@ export {
197
208
  extractRepositoryLabelNames,
198
209
  flattenPages,
199
210
  inLabels,
211
+ inLabelMappingDigest,
200
212
  labelDelta,
201
213
  mergeInLabels,
202
214
  planInLabelUpdate,
@@ -4,6 +4,7 @@ import { createHash } from 'node:crypto';
4
4
 
5
5
  import { parseTaskFrontmatter } from '../task/frontmatter.ts';
6
6
  import { resolveTaskRef } from '../task/resolve-ref.ts';
7
+ import { coordinatePlatformWrite } from './operation-coordinator.ts';
7
8
  import { resolvePlatformProviderContext } from './context.ts';
8
9
  import type { PlatformClient } from './context.ts';
9
10
  import { platformResult } from './types.ts';
@@ -51,6 +52,9 @@ type SyncOptions = {
51
52
  client?: PlatformClient;
52
53
  runtimeVersion?: string;
53
54
  summaryAuthorization?: { sha256: string };
55
+ dependency?: 'deferred' | 'required';
56
+ skipQueue?: boolean;
57
+ taskProjection?: { content: string; sha256: string };
54
58
  };
55
59
 
56
60
  function providerCommentId(id: string, provider: { identity?: { comment?: string } }): number | string {
@@ -74,6 +78,8 @@ const ARTIFACT_TITLES: Record<string, string> = {
74
78
  'review-plan': '技术方案审查',
75
79
  code: '实现报告',
76
80
  'review-code': '代码审查',
81
+ 'manual-validation': '人工验证',
82
+ 'validation-run': '验证运行证据',
77
83
  'pr-review': 'PR 审查报告'
78
84
  };
79
85
 
@@ -150,7 +156,7 @@ function taskCommentLanguage(repoRoot: string): string {
150
156
 
151
157
  function artifactIdentity(artifact: string): { stem: string; title: string } {
152
158
  const stem = path.basename(artifact, '.md');
153
- const match = stem.match(/^(analysis|review-analysis|plan|review-plan|code|review-code|pr-review)(?:-r(\d+))?$/);
159
+ const match = stem.match(/^(analysis|review-analysis|plan|review-plan|code|review-code|manual-validation|validation-run|pr-review)(?:-r(\d+))?$/);
154
160
  if (!match) throw new Error(`unsupported artifact '${artifact}'`);
155
161
  const base = ARTIFACT_TITLES[match[1]!]!;
156
162
  const round = match[2] ? Number(match[2]) : 1;
@@ -529,7 +535,7 @@ function writeComment(
529
535
  );
530
536
  }
531
537
 
532
- async function syncPlatformComment(taskRef: string, options: SyncOptions): Promise<PlatformResult> {
538
+ async function syncPlatformCommentImpl(taskRef: string, options: SyncOptions): Promise<PlatformResult> {
533
539
  const resolved = resolveTaskRef(taskRef, options.cwd ? { repoRoot: options.cwd } : {});
534
540
  if (!resolved.ok) {
535
541
  return platformResult('failed', {
@@ -547,7 +553,12 @@ async function syncPlatformComment(taskRef: string, options: SyncOptions): Promi
547
553
  }
548
554
  let desired: RenderedChunk[];
549
555
  try {
550
- desired = expectedComments(resolved.taskId, taskContent, resolved.taskDir, resolved.repoRoot, options);
556
+ const projectedContent = options.kind === 'task' && options.taskProjection ? options.taskProjection.content : taskContent;
557
+ if (options.kind === 'task' && options.taskProjection
558
+ && createHash('sha256').update(projectedContent).digest('hex') !== options.taskProjection.sha256) {
559
+ throw Object.assign(new Error('finalization task projection digest does not match content'), { code: 'FINALIZATION_PROJECTION_INVALID' });
560
+ }
561
+ desired = expectedComments(resolved.taskId, projectedContent, resolved.taskDir, resolved.repoRoot, options);
551
562
  } catch (error) {
552
563
  return platformResult('failed', {
553
564
  resource: { kind: 'issue', number: resourceIdentityNumber(issueIdentityFromTask) },
@@ -743,6 +754,50 @@ async function syncPlatformComment(taskRef: string, options: SyncOptions): Promi
743
754
  return result;
744
755
  }
745
756
 
757
+ async function syncPlatformComment(taskRef: string, options: SyncOptions): Promise<PlatformResult> {
758
+ const resolved = resolveTaskRef(taskRef, options.cwd ? { repoRoot: options.cwd } : {});
759
+ if (!resolved.ok) return syncPlatformCommentImpl(taskRef, options);
760
+ const taskContent = fs.readFileSync(resolved.taskMdPath, 'utf8');
761
+ if (!taskIssueIdentity(parseTaskFrontmatter(taskContent))) return syncPlatformCommentImpl(taskRef, options);
762
+
763
+ let operation: { kind: 'task-comment' | 'artifact-comment' | 'summary-comment' | 'cancel-comment'; target: string; expectedDigest: string };
764
+ try {
765
+ const sourceContent = options.kind === 'task' && options.taskProjection ? options.taskProjection.content : taskContent;
766
+ const desired = expectedComments(resolved.taskId, sourceContent, resolved.taskDir, resolved.repoRoot, options);
767
+ const content = desired.map((chunk) => chunk.content).join('\0');
768
+ const kind = options.kind === 'task' ? 'task-comment' : options.kind === 'summary' ? 'summary-comment'
769
+ : options.kind === 'cancel' ? 'cancel-comment' : 'artifact-comment';
770
+ const target = options.kind === 'artifact' ? options.artifact! : options.kind;
771
+ operation = { kind, target, expectedDigest: createHash('sha256').update(content).digest('hex') };
772
+ } catch {
773
+ return syncPlatformCommentImpl(taskRef, options);
774
+ }
775
+
776
+ const execute = () => syncPlatformCommentImpl(taskRef, options);
777
+ if (options.skipQueue) return execute();
778
+ return coordinatePlatformWrite({
779
+ operation: { taskRef: resolved.taskId, cwd: resolved.repoRoot, ...operation, dependency: options.dependency ?? 'deferred' },
780
+ agent: options.agent,
781
+ execute,
782
+ block: (error) => platformResult('blocked', { error }),
783
+ persistenceFailure: (error) => platformResult('failed', { error })
784
+ });
785
+ }
786
+
787
+ function inspectPlatformCommentOperation(taskRef: string, options: SyncOptions): { kind: 'task-comment' | 'artifact-comment' | 'summary-comment' | 'cancel-comment'; target: string; expectedDigest: string; id: string } | null {
788
+ const resolved = resolveTaskRef(taskRef, options.cwd ? { repoRoot: options.cwd } : {});
789
+ if (!resolved.ok) return null;
790
+ const taskContent = fs.readFileSync(resolved.taskMdPath, 'utf8');
791
+ if (!taskIssueIdentity(parseTaskFrontmatter(taskContent))) return null;
792
+ const sourceContent = options.kind === 'task' && options.taskProjection ? options.taskProjection.content : taskContent;
793
+ const desired = expectedComments(resolved.taskId, sourceContent, resolved.taskDir, resolved.repoRoot, options);
794
+ const kind = options.kind === 'task' ? 'task-comment' : options.kind === 'summary' ? 'summary-comment'
795
+ : options.kind === 'cancel' ? 'cancel-comment' : 'artifact-comment';
796
+ const target = options.kind === 'artifact' ? options.artifact! : options.kind;
797
+ const expectedDigest = createHash('sha256').update(desired.map((chunk) => chunk.content).join('\0')).digest('hex');
798
+ return { kind, target, expectedDigest, id: createHash('sha256').update(`${kind}\0${target}\0${expectedDigest}`).digest('hex') };
799
+ }
800
+
746
801
  async function listPlatformComments(issue: string | number, cwd = process.cwd(), client?: PlatformClient): Promise<PlatformResult & { comments?: RemoteComment[] }> {
747
802
  const loaded = await resolvePlatformProviderContext({ cwd, client });
748
803
  const context = loaded.ok ? loaded.value.context : loaded.context;
@@ -800,6 +855,7 @@ export {
800
855
  COMMENT_BYTE_LIMIT,
801
856
  MARKERS,
802
857
  chunkArtifactComment,
858
+ inspectPlatformCommentOperation,
803
859
  findMarkerComments,
804
860
  listRemoteComments,
805
861
  checkPlatformCommentOwner,
@@ -2,6 +2,7 @@ import type { PlatformIssueSnapshot as IssueSnapshot } from './snapshots.ts';
2
2
  import type { IssueFieldSchema } from './github-data.ts';
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
+ import { createHash } from 'node:crypto';
5
6
  import { execFileSync } from 'node:child_process';
6
7
 
7
8
  import { buildDefaultBody } from '../task/commands/issue-body.ts';
@@ -24,6 +25,7 @@ import { platformResult } from './types.ts';
24
25
  import type { PlatformResult } from './types.ts';
25
26
  import {
26
27
  labelDelta,
28
+ inLabelMappingDigest,
27
29
  planInLabelUpdate,
28
30
  syncLabelDelta,
29
31
  validateInLabelMapping,
@@ -39,9 +41,11 @@ import {
39
41
  } from './provider-bridge.ts';
40
42
  import { resourceIdentityEquals, resourceIdentityNumber, resourceIdentityString, serializeResourceIdentity } from './resource-identity.ts';
41
43
  import { taskIssueIdentity, taskIssueIdentityError } from './task-identities.ts';
44
+ import { coordinatePlatformWrite } from './operation-coordinator.ts';
42
45
  import type { ResourceIdentity } from './resource-identity.ts';
43
46
  import type { IssueSnapshot as ProviderIssueSnapshot, RepositoryMetadataSnapshot } from './provider-contract.ts';
44
- type IssueResult = PlatformResult & {
47
+ type IssueResult = Omit<PlatformResult, 'operations'> & {
48
+ operations: PlannedOperation[];
45
49
  task: { id: string | null; issueNumber: number | null };
46
50
  issue: IssueSnapshot | null;
47
51
  };
@@ -61,6 +65,8 @@ type SyncOptions = SharedOptions & {
61
65
  state?: 'open' | 'closed';
62
66
  closeReason?: 'completed' | 'not_planned';
63
67
  dryRun?: boolean;
68
+ dependency?: 'deferred' | 'required';
69
+ skipQueue?: boolean;
64
70
  };
65
71
 
66
72
  function result(
@@ -305,7 +311,7 @@ async function createPlatformIssue(taskRef: string, options: CreateOptions): Pro
305
311
  platform: base.context.platform, capabilities: base.context.capabilities,
306
312
  operations: [{ name: 'issue:create', status: 'planned', reasonCode: null }], error: null
307
313
  });
308
- {
314
+ const execute = async (): Promise<IssueResult> => {
309
315
  const created = base.provider.issues?.create
310
316
  ? await base.provider.issues.create({
311
317
  context: providerOperationContext(base.loadedContext),
@@ -344,7 +350,7 @@ async function createPlatformIssue(taskRef: string, options: CreateOptions): Pro
344
350
  if (written.status === 'failed') return result('failed', base.resolved.taskId, null, {
345
351
  platform: base.context.platform, capabilities: base.context.capabilities,
346
352
  resource: { kind: 'issue', number: issueNumber, identity: inspected.value.identity || createdIdentity },
347
- error: { code: 'ISSUE_CREATED_BIND_FAILED', message: written.error.message, retryable: false }
353
+ error: { code: 'ISSUE_CREATED_BIND_FAILED', message: written.error.message, retryable: true }
348
354
  });
349
355
  return result('applied', base.resolved.taskId, issueNumber, {
350
356
  changed: true,
@@ -355,7 +361,26 @@ async function createPlatformIssue(taskRef: string, options: CreateOptions): Pro
355
361
  issue: normalizeProviderIssue(inspected.value, repository, issueNumber),
356
362
  error: null
357
363
  });
358
- }
364
+ };
365
+ const issueCreate = { title: payload.title, bodyDigest: createHash('sha256').update(payload.body).digest('hex') };
366
+ const operation = {
367
+ kind: 'issue-create' as const,
368
+ target: `task:${base.resolved.taskId}`,
369
+ expectedDigest: createHash('sha256').update(JSON.stringify({
370
+ taskId: base.resolved.taskId,
371
+ title: payload.title,
372
+ body: createHash('sha256').update(payload.body).digest('hex'),
373
+ labels: payload.labels || [], assignees: payload.assignees,
374
+ milestone: typeof payload.milestone === 'string' ? payload.milestone : null
375
+ })).digest('hex')
376
+ };
377
+ return coordinatePlatformWrite({
378
+ operation: { taskRef: base.resolved.taskId, cwd: base.resolved.repoRoot, ...operation, issueCreate, dependency: 'required' },
379
+ agent: options.agent,
380
+ execute,
381
+ block: (error) => result('blocked', base.resolved.taskId, null, { platform: base.context.platform, capabilities: base.context.capabilities, error }),
382
+ persistenceFailure: (error) => result('failed', base.resolved.taskId, null, { error })
383
+ });
359
384
  }
360
385
 
361
386
  async function bindPlatformIssue(taskRef: string, options: BindOptions): Promise<IssueResult> {
@@ -478,7 +503,7 @@ function desiredFieldValues(frontmatter: Record<string, string>, fields: IssueFi
478
503
  return desired;
479
504
  }
480
505
 
481
- async function syncPlatformIssue(taskRef: string, options: SyncOptions): Promise<IssueResult> {
506
+ async function syncPlatformIssueImpl(taskRef: string, options: SyncOptions): Promise<IssueResult> {
482
507
  const base = await resolvedContext(taskRef, options);
483
508
  if (!base.ok) return base.output;
484
509
  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 } });
@@ -617,5 +642,78 @@ async function syncPlatformIssue(taskRef: string, options: SyncOptions): Promise
617
642
  });
618
643
  }
619
644
 
645
+ async function syncPlatformIssue(taskRef: string, options: SyncOptions): Promise<IssueResult> {
646
+ const resolved = resolveTaskRef(taskRef, options.cwd ? { repoRoot: options.cwd } : {});
647
+ if (!resolved.ok || options.dryRun) return syncPlatformIssueImpl(taskRef, options);
648
+ let frontmatter: Record<string, string>;
649
+ let taskContent: string;
650
+ try {
651
+ taskContent = fs.readFileSync(resolved.taskMdPath, 'utf8');
652
+ frontmatter = parseTaskFrontmatter(taskContent);
653
+ }
654
+ catch { return syncPlatformIssueImpl(taskRef, options); }
655
+ const identity = taskIssueIdentity(frontmatter);
656
+ if (!identity || ![
657
+ options.requirements, options.issueType, options.fields, options.status !== undefined,
658
+ options.assignees !== undefined, options.milestone !== undefined,
659
+ options.inLabels !== undefined, options.state !== undefined
660
+ ].some(Boolean)) return syncPlatformIssueImpl(taskRef, options);
661
+ let fromDiffFiles: string[] | undefined;
662
+ let mappingDigest: string | undefined;
663
+ if (options.inLabels === 'from-diff') {
664
+ const taskBase = typeof frontmatter.delivery_base_ref === 'string' ? frontmatter.delivery_base_ref.trim() : '';
665
+ if (taskBase && (!options.base || options.base === taskBase)) {
666
+ try {
667
+ fromDiffFiles = execFileSync('git', ['diff', `${taskBase}...HEAD`, '--name-only'], {
668
+ cwd: resolved.repoRoot, encoding: 'utf8'
669
+ }).trim().split(/\r?\n/).filter(Boolean).sort();
670
+ const config = JSON.parse(fs.readFileSync(path.join(resolved.repoRoot, '.agents', '.airc.json'), 'utf8')) as { labels?: { in?: unknown } };
671
+ const mapping = inLabelMappingDigest(config.labels?.in);
672
+ if (!mapping.ok) return result('failed', resolved.taskId, resourceIdentityNumber(identity), {
673
+ error: { code: mapping.error.code, message: mapping.error.message, retryable: false }
674
+ });
675
+ mappingDigest = mapping.digest;
676
+ } catch {
677
+ return result('failed', resolved.taskId, resourceIdentityNumber(identity), {
678
+ error: { code: 'IN_LABEL_SYNC_EVIDENCE_UNAVAILABLE', message: 'Unable to derive the current changed-file set for Issue metadata recovery', retryable: false }
679
+ });
680
+ }
681
+ }
682
+ }
683
+ const issueMetadata = {
684
+ requirements: options.requirements === true,
685
+ issueType: options.issueType === true,
686
+ fields: options.fields === true,
687
+ ...(options.status !== undefined ? { status: options.status } : {}),
688
+ ...(options.assignees !== undefined ? { assignees: options.assignees } : {}),
689
+ ...(options.milestone !== undefined ? { milestone: options.milestone } : {}),
690
+ ...(options.inLabels ? { inLabels: options.inLabels } : {}),
691
+ ...(options.base ? { base: options.base } : {}),
692
+ ...(fromDiffFiles ? { fromDiffFiles } : {}),
693
+ ...(mappingDigest ? { inLabelMappingDigest: mappingDigest } : {}),
694
+ ...(options.state ? { state: options.state } : {}),
695
+ ...(options.closeReason ? { closeReason: options.closeReason } : {})
696
+ };
697
+ const operation = {
698
+ kind: 'issue-metadata' as const,
699
+ target: JSON.stringify(identity),
700
+ expectedDigest: createHash('sha256').update(JSON.stringify({
701
+ ...issueMetadata,
702
+ task: resolved.taskId,
703
+ taskContent: createHash('sha256').update(taskContent).digest('hex')
704
+ })).digest('hex')
705
+ };
706
+ const dependency = options.dependency ?? (options.state === 'closed' && options.closeReason === 'not_planned' ? 'required' : 'deferred');
707
+ const execute = () => syncPlatformIssueImpl(taskRef, options);
708
+ if (options.skipQueue) return execute();
709
+ return coordinatePlatformWrite({
710
+ operation: { taskRef: resolved.taskId, cwd: resolved.repoRoot, ...operation, issueMetadata, dependency },
711
+ agent: options.agent,
712
+ execute,
713
+ block: (error) => result('blocked', resolved.taskId, resourceIdentityNumber(identity), { error }),
714
+ persistenceFailure: (error) => result('failed', resolved.taskId, resourceIdentityNumber(identity), { error })
715
+ });
716
+ }
717
+
620
718
  export { bindPlatformIssue, createPlatformIssue, inspectPlatformIssue, requirementSectionAnchors, syncPlatformIssue };
621
719
  export type { BindOptions, CreateOptions, IssueResult, SyncOptions };