@fitlab-ai/agent-infra 0.11.7 → 0.11.9

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 (128) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/dist/bin/internal-cli.js +8 -2
  4. package/dist/lib/agent-clients/adapters/codex.js +1 -1
  5. package/dist/lib/agent-clients/config.js +11 -3
  6. package/dist/lib/agent-clients/custom-tool-invocations.js +92 -0
  7. package/dist/lib/agent-clients/next-steps.js +1 -1
  8. package/dist/lib/agent-clients/reconcile.js +3 -3
  9. package/dist/lib/agent-clients/schema.js +18 -10
  10. package/dist/lib/defaults.json +6 -3
  11. package/dist/lib/init.js +2 -2
  12. package/dist/lib/internal/agent-client.js +3 -3
  13. package/dist/lib/internal/cli-route-inventory.js +3 -0
  14. package/dist/lib/internal/platform-pr-review.js +20 -3
  15. package/dist/lib/internal/task-operation-registry.js +3 -1
  16. package/dist/lib/internal/task-platform-recovery.js +56 -0
  17. package/dist/lib/merge.js +25 -5
  18. package/dist/lib/platform/completion-backfill.js +2 -1
  19. package/dist/lib/platform/in-label-sync.js +12 -1
  20. package/dist/lib/platform/issue-comments.js +58 -4
  21. package/dist/lib/platform/issues.js +105 -5
  22. package/dist/lib/platform/operation-coordinator.js +74 -0
  23. package/dist/lib/platform/pr-review.js +107 -1
  24. package/dist/lib/platform/pr-summary.js +79 -57
  25. package/dist/lib/platform/pull-requests.js +96 -4
  26. package/dist/lib/sandbox/commands/create.js +6 -3
  27. package/dist/lib/sandbox/commands/ls.js +4 -3
  28. package/dist/lib/sandbox/commands/rm.js +1 -1
  29. package/dist/lib/sandbox/config.js +22 -4
  30. package/dist/lib/sandbox/git-metadata.js +50 -0
  31. package/dist/lib/sandbox/index.js +1 -1
  32. package/dist/lib/sandbox/mounts.js +12 -0
  33. package/dist/lib/sandbox/recovery.js +12 -22
  34. package/dist/lib/sandbox/removal.js +58 -20
  35. package/dist/lib/sandbox/tools.js +31 -7
  36. package/dist/lib/task/activity-intent.js +4 -3
  37. package/dist/lib/task/create-service.js +5 -3
  38. package/dist/lib/task/create.js +22 -5
  39. package/dist/lib/task/finalization-artifacts.js +4 -1
  40. package/dist/lib/task/finalization.js +243 -113
  41. package/dist/lib/task/lifecycle.js +38 -2
  42. package/dist/lib/task/platform-operation-journal.js +152 -0
  43. package/dist/lib/task/platform-operation-recovery.js +459 -0
  44. package/dist/lib/task/verification.js +0 -1
  45. package/dist/lib/task/workflow-warning-intents.js +7 -1
  46. package/dist/lib/update.js +2 -2
  47. package/dist/lifecycle-build-manifest.json +3 -3
  48. package/lib/agent-clients/adapters/codex.ts +1 -1
  49. package/lib/agent-clients/config.ts +9 -3
  50. package/lib/agent-clients/custom-tool-invocations.ts +129 -0
  51. package/lib/agent-clients/next-steps.ts +3 -3
  52. package/lib/agent-clients/reconcile.ts +3 -3
  53. package/lib/agent-clients/schema.ts +18 -10
  54. package/lib/defaults.json +6 -3
  55. package/lib/init.ts +2 -2
  56. package/lib/internal/agent-client.ts +3 -3
  57. package/lib/internal/cli-route-inventory.ts +2 -0
  58. package/lib/internal/platform-pr-review.ts +14 -3
  59. package/lib/internal/task-operation-registry.ts +3 -1
  60. package/lib/internal/task-platform-recovery.ts +53 -0
  61. package/lib/merge.ts +26 -6
  62. package/lib/platform/completion-backfill.ts +2 -1
  63. package/lib/platform/in-label-sync.ts +12 -0
  64. package/lib/platform/issue-comments.ts +59 -3
  65. package/lib/platform/issues.ts +103 -5
  66. package/lib/platform/operation-coordinator.ts +96 -0
  67. package/lib/platform/pr-review.ts +113 -1
  68. package/lib/platform/pr-summary.ts +22 -1
  69. package/lib/platform/pull-requests.ts +105 -5
  70. package/lib/sandbox/commands/create.ts +7 -8
  71. package/lib/sandbox/commands/ls.ts +4 -3
  72. package/lib/sandbox/commands/rm.ts +1 -1
  73. package/lib/sandbox/config.ts +23 -5
  74. package/lib/sandbox/git-metadata.ts +66 -0
  75. package/lib/sandbox/index.ts +1 -1
  76. package/lib/sandbox/mounts.ts +14 -0
  77. package/lib/sandbox/recovery.ts +13 -25
  78. package/lib/sandbox/removal.ts +57 -19
  79. package/lib/sandbox/tool-types.ts +2 -1
  80. package/lib/sandbox/tools.ts +38 -7
  81. package/lib/task/activity-intent.ts +4 -3
  82. package/lib/task/create-service.ts +5 -3
  83. package/lib/task/create.ts +17 -5
  84. package/lib/task/finalization-artifacts.ts +4 -1
  85. package/lib/task/finalization.ts +242 -106
  86. package/lib/task/lifecycle.ts +35 -3
  87. package/lib/task/platform-operation-journal.ts +230 -0
  88. package/lib/task/platform-operation-recovery.ts +435 -0
  89. package/lib/task/verification.ts +1 -2
  90. package/lib/task/workflow-warning-intents.ts +9 -1
  91. package/lib/update.ts +2 -3
  92. package/package.json +1 -1
  93. package/templates/.agents/README.en.md +20 -66
  94. package/templates/.agents/README.zh-CN.md +19 -65
  95. package/templates/.agents/rules/README.en.md +1 -1
  96. package/templates/.agents/rules/README.zh-CN.md +1 -1
  97. package/templates/.agents/rules/next-step-output.en.md +1 -1
  98. package/templates/.agents/rules/next-step-output.zh-CN.md +1 -1
  99. package/templates/.agents/rules/task-management.en.md +12 -0
  100. package/templates/.agents/rules/task-management.zh-CN.md +12 -0
  101. package/templates/.agents/scripts/lib/agent-infra-package.js +0 -7
  102. package/templates/.agents/skills/cancel-task/SKILL.en.md +4 -2
  103. package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +4 -2
  104. package/templates/.agents/skills/code-task/SKILL.en.md +2 -2
  105. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +2 -2
  106. package/templates/.agents/skills/commit/SKILL.en.md +1 -1
  107. package/templates/.agents/skills/commit/SKILL.zh-CN.md +1 -1
  108. package/templates/.agents/skills/complete-task/SKILL.en.md +2 -0
  109. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +2 -0
  110. package/templates/.agents/skills/create-pr/reference/pr-body-template.en.md +1 -1
  111. package/templates/.agents/skills/create-pr/reference/pr-body-template.zh-CN.md +1 -1
  112. package/templates/.agents/skills/plan-task/config/verify.en.json +1 -1
  113. package/templates/.agents/skills/plan-task/config/verify.zh-CN.json +1 -1
  114. package/templates/.agents/skills/review-code/SKILL.en.md +1 -1
  115. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +1 -1
  116. package/templates/.agents/skills/review-code/reference/output-templates.en.md +15 -6
  117. package/templates/.agents/skills/review-code/reference/output-templates.zh-CN.md +15 -6
  118. package/templates/.agents/skills/review-pr/SKILL.en.md +11 -11
  119. package/templates/.agents/skills/review-pr/SKILL.zh-CN.md +11 -11
  120. package/templates/.agents/skills/review-pr/reference/output-guidance.en.md +3 -3
  121. package/templates/.agents/skills/review-pr/reference/output-guidance.zh-CN.md +3 -3
  122. package/templates/.agents/skills/review-pr/reference/report-template.en.md +4 -0
  123. package/templates/.agents/skills/review-pr/reference/report-template.zh-CN.md +4 -0
  124. package/templates/.agents/skills/update-agent-infra/SKILL.en.md +1 -1
  125. package/templates/.agents/skills/update-agent-infra/SKILL.zh-CN.md +1 -1
  126. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +21 -274
  127. package/dist/lib/agent-clients/custom-tuis.js +0 -99
  128. package/lib/agent-clients/custom-tuis.ts +0 -149
@@ -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 };
@@ -0,0 +1,96 @@
1
+ import { recoverPlatformOperations } from '../task/platform-operation-recovery.ts';
2
+ import { recordPlatformOperation } from '../task/platform-operation-journal.ts';
3
+ import type { RecordOperationInput } from '../task/platform-operation-journal.ts';
4
+ import type { PlatformError, PlatformStatus } from './types.ts';
5
+ import type { PlatformClient } from './context.ts';
6
+
7
+ type OperationResult = Readonly<{
8
+ status: PlatformStatus;
9
+ error?: PlatformError | null;
10
+ warnings?: readonly { code: string }[];
11
+ }>;
12
+
13
+ type CoordinateInput<T extends OperationResult> = Readonly<{
14
+ operation: Omit<RecordOperationInput, 'state' | 'lastCode'>;
15
+ agent: string;
16
+ client?: PlatformClient;
17
+ drain?: (taskRef: string, excludeId: string, agent: string, cwd?: string) => Promise<{
18
+ status: 'applied' | 'no-op' | 'blocked' | 'failed';
19
+ error: PlatformError | null;
20
+ }>;
21
+ execute: () => Promise<T>;
22
+ block: (error: PlatformError) => T;
23
+ persistenceFailure: (error: PlatformError) => T;
24
+ }>;
25
+
26
+ async function coordinatePlatformWrite<T extends OperationResult>(input: CoordinateInput<T>): Promise<T> {
27
+ let operationId: string;
28
+ try {
29
+ const queued = recordPlatformOperation({ ...input.operation, state: 'queued' });
30
+ operationId = queued.id;
31
+ } catch (error) {
32
+ const value = error as { code?: string; message?: string };
33
+ return input.persistenceFailure({
34
+ code: value.code || 'PLATFORM_OPERATION_JOURNAL_WRITE_FAILED',
35
+ message: value.message || 'Unable to queue platform operation',
36
+ retryable: true
37
+ });
38
+ }
39
+
40
+ const selection = input.operation.kind === 'issue-metadata' && input.operation.issueMetadata?.state === 'closed'
41
+ ? 'all' : input.operation.dependency;
42
+ const drained = input.drain
43
+ ? await input.drain(input.operation.taskRef, operationId, input.agent, input.operation.cwd)
44
+ : await recoverPlatformOperations(input.operation.taskRef, selection, {
45
+ agent: input.agent,
46
+ client: input.client,
47
+ cwd: input.operation.cwd,
48
+ excludeId: operationId
49
+ });
50
+ if (drained.status !== 'applied' && drained.status !== 'no-op') {
51
+ return input.block(drained.error ?? {
52
+ code: 'PLATFORM_OPERATION_QUEUE_BLOCKED',
53
+ message: 'A previous platform operation is still unresolved; the current write remains queued',
54
+ retryable: true
55
+ });
56
+ }
57
+
58
+ try {
59
+ recordPlatformOperation({ ...input.operation, state: 'pending' });
60
+ } catch (error) {
61
+ const value = error as { code?: string; message?: string };
62
+ return input.persistenceFailure({
63
+ code: value.code || 'PLATFORM_OPERATION_JOURNAL_WRITE_FAILED',
64
+ message: value.message || 'Unable to mark platform operation as started',
65
+ retryable: true
66
+ });
67
+ }
68
+
69
+ let output: T;
70
+ try {
71
+ output = await input.execute();
72
+ } catch (error) {
73
+ const value = error as { code?: string; message?: string };
74
+ output = input.block({ code: value.code || 'PLATFORM_WRITE_FAILED', message: value.message || String(error), retryable: true });
75
+ }
76
+ const succeeded = (output.status === 'applied' || output.status === 'no-op')
77
+ && !output.error && !(output.warnings?.length);
78
+ const state = succeeded ? 'succeeded' : output.status === 'failed' && output.error?.retryable === false ? 'failed' : 'unknown';
79
+ try {
80
+ recordPlatformOperation({
81
+ ...input.operation,
82
+ state,
83
+ lastCode: output.error?.code ?? output.warnings?.[0]?.code ?? null
84
+ });
85
+ } catch (error) {
86
+ const value = error as { code?: string; message?: string };
87
+ return input.persistenceFailure({
88
+ code: value.code || 'PLATFORM_OPERATION_JOURNAL_WRITE_FAILED',
89
+ message: value.message || 'Unable to persist platform operation outcome',
90
+ retryable: true
91
+ });
92
+ }
93
+ return output;
94
+ }
95
+
96
+ export { coordinatePlatformWrite };
@@ -1,3 +1,8 @@
1
+ import { createHash } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ import { coordinatePlatformWrite } from './operation-coordinator.ts';
1
6
  import { resolvePlatformProviderContext } from './context.ts';
2
7
  import type { PlatformClient } from './context.ts';
3
8
  import { platformResult } from './types.ts';
@@ -9,8 +14,10 @@ import {
9
14
  providerResourceToken,
10
15
  unsupportedProviderOperation
11
16
  } from './provider-bridge.ts';
12
- import { isResourceIdentity, resourceIdentityNumber, reviewMarker as resourceReviewMarker } from './resource-identity.ts';
17
+ import { isResourceIdentity, resourceIdentityEquals, resourceIdentityNumber, reviewMarker as resourceReviewMarker } from './resource-identity.ts';
13
18
  import type { ResourceIdentity } from './resource-identity.ts';
19
+ import type { PlatformPullRequestReviewIntent } from '../task/platform-operation-journal.ts';
20
+ import { resolveTaskRef } from '../task/resolve-ref.ts';
14
21
 
15
22
  export type PrReviewEvent = 'COMMENT' | 'APPROVE' | 'REQUEST_CHANGES';
16
23
  export type PrReviewIdentity = { scope: string; round: number; commitSha: string; resource?: ResourceIdentity };
@@ -38,6 +45,36 @@ function normalizeBody(body: string): string {
38
45
  return String(body || '').replace(/\r\n/g, '\n').replace(/\n+$/, '\n');
39
46
  }
40
47
 
48
+ function canonicalReviewBody(body: string): string {
49
+ return normalizeBody(body).replace(/\n$/u, '');
50
+ }
51
+
52
+ function reviewArtifactName(round: number, artifactFile: string): boolean {
53
+ return path.basename(artifactFile) === artifactFile
54
+ && (artifactFile === 'pr-review.md' && round === 1 || artifactFile === `pr-review-r${round}.md`);
55
+ }
56
+
57
+ export function readReviewBodyFile(taskRef: string, round: number, artifactFile: string, bodyDigest: string, cwd?: string): string {
58
+ if (!/^TASK-\d{8}-\d{6}$/u.test(taskRef) || path.basename(artifactFile) !== artifactFile
59
+ || !reviewArtifactName(round, artifactFile)) {
60
+ throw Object.assign(new Error('Pull-request review artifact identity is invalid'), { code: 'PLATFORM_OPERATION_PAYLOAD_INVALID' });
61
+ }
62
+ const task = resolveTaskRef(taskRef, cwd ? { repoRoot: cwd } : {});
63
+ if (!task.ok) throw Object.assign(new Error(task.message), { code: 'PLATFORM_OPERATION_PAYLOAD_INVALID' });
64
+ const bodyFile = artifactFile.replace(/^pr-review/u, 'pr-review-body');
65
+ const report = fs.readFileSync(path.join(task.taskDir, artifactFile), 'utf8');
66
+ const bodyReferences = [...report.matchAll(/^\*\*(?:正文文件|Body file)\*\*(?::|:)\s*`([^`]+)`[^\r\n]*$/gmu)]
67
+ .map((match) => match[1]);
68
+ if (bodyReferences.length !== 1 || bodyReferences[0] !== bodyFile) {
69
+ throw Object.assign(new Error('Pull-request review artifact does not reference its canonical body file'), { code: 'PLATFORM_OPERATION_PAYLOAD_INVALID' });
70
+ }
71
+ const body = canonicalReviewBody(fs.readFileSync(path.join(task.taskDir, bodyFile), 'utf8'));
72
+ if (!body || createHash('sha256').update(body).digest('hex') !== bodyDigest) {
73
+ throw Object.assign(new Error('Canonical pull-request review body does not match its persisted digest'), { code: 'PLATFORM_OPERATION_PAYLOAD_INVALID' });
74
+ }
75
+ return body;
76
+ }
77
+
41
78
  function firstLine(body: string): string {
42
79
  return normalizeBody(String(body || '')).split('\n', 1)[0] || '';
43
80
  }
@@ -90,6 +127,11 @@ export async function publishPrReview(options: {
90
127
  cwd?: string;
91
128
  client?: PlatformClient;
92
129
  dryRun?: boolean;
130
+ agent?: string;
131
+ skipQueue?: boolean;
132
+ expectedResource?: ResourceIdentity;
133
+ expectedProviderScopeId?: string;
134
+ recoveryArtifact?: string;
93
135
  prNumber: PrToken;
94
136
  identity: PrReviewIdentity;
95
137
  event: PrReviewEvent;
@@ -100,6 +142,13 @@ export async function publishPrReview(options: {
100
142
  if (!hasUsableContext(context)) {
101
143
  return platformResult(context.status, { platform: context.platform, capabilities: context.capabilities, error: context.error });
102
144
  }
145
+ const providerScopeId = loaded.ok ? loaded.value.snapshot.scope.id : null;
146
+ if (options.expectedProviderScopeId && providerScopeId !== options.expectedProviderScopeId) {
147
+ return platformResult('failed', {
148
+ platform: context.platform, capabilities: context.capabilities,
149
+ error: { code: 'PLATFORM_OPERATION_IDENTITY_MISMATCH', message: 'Current provider scope differs from the persisted operation target', retryable: false }
150
+ });
151
+ }
103
152
  const identity = loaded.ok ? (() => { try { return providerResourceToken(loaded.value.provider, 'pull-request', String(options.prNumber)); } catch { return null; } })() : null;
104
153
  if (!identity) {
105
154
  return platformResult('failed', {
@@ -108,6 +157,13 @@ export async function publishPrReview(options: {
108
157
  });
109
158
  }
110
159
  const identityNumber = resourceIdentityNumber(identity);
160
+ if (options.expectedResource && !resourceIdentityEquals(identity, options.expectedResource)) {
161
+ return platformResult('failed', {
162
+ platform: context.platform, capabilities: context.capabilities,
163
+ resource: { kind: 'pull-request', number: identityNumber, identity },
164
+ error: { code: 'PLATFORM_OPERATION_IDENTITY_MISMATCH', message: 'Current pull request identity differs from the persisted operation target', retryable: false }
165
+ });
166
+ }
111
167
  if (!REVIEW_EVENTS.includes(options.event)) {
112
168
  return platformResult('failed', {
113
169
  platform: context.platform, capabilities: context.capabilities,
@@ -123,6 +179,62 @@ export async function publishPrReview(options: {
123
179
  });
124
180
  }
125
181
 
182
+ if (/^TASK-\d{8}-\d{6}$/u.test(options.identity.scope) && !options.skipQueue && !options.dryRun) {
183
+ if (!options.agent) return platformResult('failed', {
184
+ platform: context.platform, capabilities: context.capabilities,
185
+ resource: { kind: 'pull-request', number: identityNumber, identity },
186
+ error: { code: 'PR_REVIEW_AGENT_REQUIRED', message: 'task-scoped review publication requires an agent token', retryable: false }
187
+ });
188
+ if (!options.recoveryArtifact || !reviewArtifactName(options.identity.round, options.recoveryArtifact)) return platformResult('failed', {
189
+ platform: context.platform, capabilities: context.capabilities,
190
+ resource: { kind: 'pull-request', number: identityNumber, identity },
191
+ error: { code: 'PR_REVIEW_ARTIFACT_REQUIRED', message: 'task-scoped review publication requires its canonical review artifact', retryable: false }
192
+ });
193
+ const body = canonicalReviewBody(options.body);
194
+ const bodyDigest = createHash('sha256').update(body).digest('hex');
195
+ try {
196
+ if (readReviewBodyFile(options.identity.scope, options.identity.round, options.recoveryArtifact, bodyDigest, options.cwd) !== body) throw new Error('body mismatch');
197
+ } catch {
198
+ return platformResult('failed', {
199
+ platform: context.platform, capabilities: context.capabilities,
200
+ resource: { kind: 'pull-request', number: identityNumber, identity },
201
+ error: { code: 'PR_REVIEW_ARTIFACT_MISMATCH', message: 'Review body must match the canonical review body file', retryable: false }
202
+ });
203
+ }
204
+ const pullRequestReview: PlatformPullRequestReviewIntent = {
205
+ prNumber: String(options.prNumber),
206
+ resource: identity,
207
+ providerScopeId: providerScopeId!,
208
+ scope: options.identity.scope,
209
+ round: options.identity.round,
210
+ commitSha: options.identity.commitSha,
211
+ event: options.event,
212
+ artifactFile: options.recoveryArtifact,
213
+ bodyDigest
214
+ };
215
+ return coordinatePlatformWrite({
216
+ operation: {
217
+ taskRef: options.identity.scope,
218
+ cwd: options.cwd || process.cwd(),
219
+ kind: 'pull-request-review',
220
+ target: JSON.stringify(identity),
221
+ expectedDigest: createHash('sha256').update(JSON.stringify(pullRequestReview)).digest('hex'),
222
+ dependency: 'deferred',
223
+ pullRequestReview
224
+ },
225
+ agent: options.agent,
226
+ execute: () => publishPrReview({ ...options, expectedProviderScopeId: providerScopeId!, skipQueue: true }),
227
+ block: (error) => platformResult('blocked', {
228
+ platform: context.platform, capabilities: context.capabilities,
229
+ resource: { kind: 'pull-request', number: identityNumber, identity }, error
230
+ }),
231
+ persistenceFailure: (error) => platformResult('failed', {
232
+ platform: context.platform, capabilities: context.capabilities,
233
+ resource: { kind: 'pull-request', number: identityNumber, identity }, error
234
+ })
235
+ });
236
+ }
237
+
126
238
  const marker = reviewMarker({ ...options.identity, resource: identity });
127
239
  const listed = await listPrReviews(options.prNumber, { cwd: options.cwd, client: options.client });
128
240
  if (listed.status === 'failed' || listed.status === 'blocked') return listed;
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { createHash } from 'node:crypto';
3
4
 
4
5
  import { enumerateArtifacts } from '../task/artifacts.ts';
5
6
  import { parseTypedTaskFrontmatter } from '../task/frontmatter.ts';
@@ -13,6 +14,7 @@ import { normalizeCommentContent } from './issue-comments.ts';
13
14
  import { CONTROL_MARKER_PATTERN, sanitizeMarkdownDocument, splitDocumentPlaceholder } from './comment-safety.ts';
14
15
  import { platformResult } from './types.ts';
15
16
  import type { PlatformResult } from './types.ts';
17
+ import { coordinatePlatformWrite } from './operation-coordinator.ts';
16
18
  import type { OperationWarning } from '../task/operation-outcome.ts';
17
19
  import { providerError, providerOperationContext, providerStatus, providerResourceToken, resourceIdentityNumber, unsupportedProviderOperation } from './provider-bridge.ts';
18
20
  import { resourceIdentityEquals } from './resource-identity.ts';
@@ -491,7 +493,7 @@ async function reportWrite(taskRef: string, options: ReportWriteOptions): Promis
491
493
 
492
494
  async function syncPullRequestSummary(
493
495
  taskRef: string,
494
- options: { agent: string; body: string; changeReportFile?: string; cwd?: string; client?: PlatformClient; dryRun?: boolean; strict?: boolean; primaryResult: PullRequestPrimaryResult; runtimeVersion?: string; manualValidation?: ManualValidationSummaryOptions; lockAlreadyHeld?: boolean }
496
+ options: { agent: string; body: string; changeReportFile?: string; cwd?: string; client?: PlatformClient; dryRun?: boolean; strict?: boolean; primaryResult: PullRequestPrimaryResult; runtimeVersion?: string; manualValidation?: ManualValidationSummaryOptions; lockAlreadyHeld?: boolean; skipQueue?: boolean }
495
497
  ): Promise<PullRequestSummaryResult> {
496
498
  const warningResult = warningResultForPrimary(options.primaryResult);
497
499
  let knownPrNumber: number | null = null;
@@ -657,6 +659,7 @@ async function syncPullRequestSummary(
657
659
  operations: [{ name: `summary:${reconciliation.action}`, status: 'planned', reasonCode: null }],
658
660
  result: null, warnings: [], ...info
659
661
  };
662
+ const publish = async (): Promise<PullRequestSummaryResult> => {
660
663
  const existingComment = reconciliation.commentId === null
661
664
  ? undefined
662
665
  : providerResourceToken(loaded.value.provider, 'comment', String(reconciliation.commentId));
@@ -722,6 +725,24 @@ async function syncPullRequestSummary(
722
725
  operations: [{ name: `summary:${reconciliation.action}`, status: 'applied', reasonCode: null }],
723
726
  result: null, warnings: [], ...info
724
727
  };
728
+ };
729
+ const operation = {
730
+ taskRef: resolved.taskId,
731
+ cwd: resolved.repoRoot,
732
+ kind: 'pull-request-summary' as const,
733
+ target: JSON.stringify(prIdentity),
734
+ expectedDigest: createHash('sha256').update(desired).digest('hex'),
735
+ dependency: 'deferred' as const,
736
+ pullRequestSummary: { body: options.body, changeReportFile: options.changeReportFile! }
737
+ };
738
+ if (options.skipQueue) return publish();
739
+ return coordinatePlatformWrite({
740
+ operation,
741
+ agent: options.agent,
742
+ execute: publish,
743
+ block: (error) => fail('blocked', context, error, prNumber),
744
+ persistenceFailure: (error) => fail('failed', context, error, prNumber)
745
+ });
725
746
  };
726
747
  return await (options.lockAlreadyHeld
727
748
  ? execute()