@fitlab-ai/agent-infra 0.11.7 → 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 (95) 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/internal/cli-route-inventory.js +3 -0
  6. package/dist/lib/internal/platform-pr-review.js +20 -3
  7. package/dist/lib/internal/task-operation-registry.js +3 -1
  8. package/dist/lib/internal/task-platform-recovery.js +56 -0
  9. package/dist/lib/merge.js +25 -5
  10. package/dist/lib/platform/completion-backfill.js +2 -1
  11. package/dist/lib/platform/in-label-sync.js +12 -1
  12. package/dist/lib/platform/issue-comments.js +58 -4
  13. package/dist/lib/platform/issues.js +105 -5
  14. package/dist/lib/platform/operation-coordinator.js +74 -0
  15. package/dist/lib/platform/pr-review.js +107 -1
  16. package/dist/lib/platform/pr-summary.js +79 -57
  17. package/dist/lib/platform/pull-requests.js +96 -4
  18. package/dist/lib/sandbox/commands/create.js +6 -3
  19. package/dist/lib/sandbox/commands/ls.js +4 -3
  20. package/dist/lib/sandbox/commands/rm.js +1 -1
  21. package/dist/lib/sandbox/config.js +22 -4
  22. package/dist/lib/sandbox/git-metadata.js +50 -0
  23. package/dist/lib/sandbox/index.js +1 -1
  24. package/dist/lib/sandbox/mounts.js +12 -0
  25. package/dist/lib/sandbox/recovery.js +11 -0
  26. package/dist/lib/sandbox/removal.js +58 -20
  27. package/dist/lib/sandbox/tools.js +29 -7
  28. package/dist/lib/task/activity-intent.js +4 -3
  29. package/dist/lib/task/create-service.js +5 -3
  30. package/dist/lib/task/create.js +22 -5
  31. package/dist/lib/task/finalization-artifacts.js +4 -1
  32. package/dist/lib/task/finalization.js +157 -24
  33. package/dist/lib/task/lifecycle.js +38 -2
  34. package/dist/lib/task/platform-operation-journal.js +152 -0
  35. package/dist/lib/task/platform-operation-recovery.js +459 -0
  36. package/dist/lib/task/workflow-warning-intents.js +7 -1
  37. package/dist/lifecycle-build-manifest.json +3 -3
  38. package/lib/agent-clients/adapters/codex.ts +1 -1
  39. package/lib/agent-clients/config.ts +9 -3
  40. package/lib/defaults.json +6 -3
  41. package/lib/internal/cli-route-inventory.ts +2 -0
  42. package/lib/internal/platform-pr-review.ts +14 -3
  43. package/lib/internal/task-operation-registry.ts +3 -1
  44. package/lib/internal/task-platform-recovery.ts +53 -0
  45. package/lib/merge.ts +26 -6
  46. package/lib/platform/completion-backfill.ts +2 -1
  47. package/lib/platform/in-label-sync.ts +12 -0
  48. package/lib/platform/issue-comments.ts +59 -3
  49. package/lib/platform/issues.ts +103 -5
  50. package/lib/platform/operation-coordinator.ts +96 -0
  51. package/lib/platform/pr-review.ts +113 -1
  52. package/lib/platform/pr-summary.ts +22 -1
  53. package/lib/platform/pull-requests.ts +105 -5
  54. package/lib/sandbox/commands/create.ts +7 -8
  55. package/lib/sandbox/commands/ls.ts +4 -3
  56. package/lib/sandbox/commands/rm.ts +1 -1
  57. package/lib/sandbox/config.ts +23 -5
  58. package/lib/sandbox/git-metadata.ts +66 -0
  59. package/lib/sandbox/index.ts +1 -1
  60. package/lib/sandbox/mounts.ts +14 -0
  61. package/lib/sandbox/recovery.ts +13 -0
  62. package/lib/sandbox/removal.ts +57 -19
  63. package/lib/sandbox/tool-types.ts +1 -1
  64. package/lib/sandbox/tools.ts +36 -7
  65. package/lib/task/activity-intent.ts +4 -3
  66. package/lib/task/create-service.ts +5 -3
  67. package/lib/task/create.ts +17 -5
  68. package/lib/task/finalization-artifacts.ts +4 -1
  69. package/lib/task/finalization.ts +154 -21
  70. package/lib/task/lifecycle.ts +35 -3
  71. package/lib/task/platform-operation-journal.ts +230 -0
  72. package/lib/task/platform-operation-recovery.ts +435 -0
  73. package/lib/task/workflow-warning-intents.ts +9 -1
  74. package/package.json +1 -1
  75. package/templates/.agents/README.en.md +19 -17
  76. package/templates/.agents/README.zh-CN.md +18 -16
  77. package/templates/.agents/rules/README.en.md +1 -1
  78. package/templates/.agents/rules/README.zh-CN.md +1 -1
  79. package/templates/.agents/rules/task-management.en.md +12 -0
  80. package/templates/.agents/rules/task-management.zh-CN.md +12 -0
  81. package/templates/.agents/skills/cancel-task/SKILL.en.md +4 -2
  82. package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +4 -2
  83. package/templates/.agents/skills/code-task/SKILL.en.md +2 -2
  84. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +2 -2
  85. package/templates/.agents/skills/complete-task/SKILL.en.md +2 -0
  86. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +2 -0
  87. package/templates/.agents/skills/plan-task/config/verify.en.json +1 -1
  88. package/templates/.agents/skills/plan-task/config/verify.zh-CN.json +1 -1
  89. package/templates/.agents/skills/review-pr/SKILL.en.md +11 -11
  90. package/templates/.agents/skills/review-pr/SKILL.zh-CN.md +11 -11
  91. package/templates/.agents/skills/review-pr/reference/output-guidance.en.md +3 -3
  92. package/templates/.agents/skills/review-pr/reference/output-guidance.zh-CN.md +3 -3
  93. package/templates/.agents/skills/review-pr/reference/report-template.en.md +4 -0
  94. package/templates/.agents/skills/review-pr/reference/report-template.zh-CN.md +4 -0
  95. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +19 -6
@@ -7,13 +7,19 @@ import { canonicalizeSummaryBody } from '../platform/comment-safety.ts';
7
7
  import { backfillCompletionComments, inspectCompletionBackfillEligibility } from '../platform/completion-backfill.ts';
8
8
  import type { PlatformResult } from '../platform/types.ts';
9
9
  import { taskIssueIdentity } from '../platform/task-identities.ts';
10
+ import { inspectPlatformIssue, syncPlatformIssue } from '../platform/issues.ts';
11
+ import type { IssueResult, SyncOptions as IssueSyncOptions } from '../platform/issues.ts';
12
+ import { resourceIdentityEquals } from '../platform/resource-identity.ts';
10
13
  import { parseTaskFrontmatter } from './frontmatter.ts';
14
+ import { recoverPlatformOperations } from './platform-operation-recovery.ts';
11
15
  import {
12
16
  applyTaskLifecycle,
13
17
  inspectTaskLifecycleProgress,
14
18
  type TaskLifecycleOptions,
15
19
  type TaskLifecycleRequest,
16
- type TaskLifecycleResult
20
+ type TaskLifecycleResult,
21
+ type TaskCompletionProjection,
22
+ previewTaskCompletion
17
23
  } from './lifecycle.ts';
18
24
  import { resolveTaskRef } from './resolve-ref.ts';
19
25
  import { inspectShortIdRegistry } from './short-id.ts';
@@ -71,6 +77,8 @@ type TaskFinalizationOptions = Readonly<{
71
77
  lifecycle?: typeof applyTaskLifecycle;
72
78
  backfill?: typeof backfillCompletionComments;
73
79
  commentSync?: typeof syncPlatformComment;
80
+ issueSync?: typeof syncPlatformIssue;
81
+ issueInspect?: typeof inspectPlatformIssue;
74
82
  verify?: typeof verifyTaskEvent;
75
83
  preflight?: typeof verifyTaskEvent;
76
84
  }>;
@@ -471,13 +479,13 @@ function capabilityError(code: 'FINALIZATION_CAPABILITY_STALE' | 'FINALIZATION_S
471
479
  return error;
472
480
  }
473
481
 
474
- function hasPreparedTaskDocument(repoRoot: string, taskId: string): boolean {
482
+ function hasFinalizationTaskDocument(repoRoot: string, taskId: string): boolean {
475
483
  const resolved = resolveTaskRef(taskId, { repoRoot });
476
- if (!resolved.ok) return false;
477
- if (resolved.state === 'completed') return true;
478
- if (resolved.state !== 'active') return false;
479
- try { return parseTaskFrontmatter(fs.readFileSync(resolved.taskMdPath, 'utf8')).status === 'completed'; }
480
- catch { return false; }
484
+ if (!resolved.ok || (resolved.state !== 'active' && resolved.state !== 'completed')) return false;
485
+ try {
486
+ const journal = JSON.parse(fs.readFileSync(path.join(resolved.taskDir, '.task-lifecycle.json'), 'utf8')) as Record<string, unknown>;
487
+ return journal.taskId === taskId && journal.intent === 'complete';
488
+ } catch { return resolved.state === 'completed'; }
481
489
  }
482
490
 
483
491
  function mutationKeys(value: FinalizationMutation): string[] {
@@ -525,7 +533,14 @@ function mutationPatch(current: TaskFinalizationReceipt, mutation: FinalizationM
525
533
  if (mutation.scope === 'task-comment') {
526
534
  if (mutation.operation === 'succeeded') {
527
535
  const warnings = resolveStepWarnings(current, 'task-comment');
528
- return { taskComment: mutation.state, warningProjection: warnings.length > 0 ? 'pending' : 'done', warnings, lastError: null };
536
+ const retryWithResolvedWarning = mutation.state === 'done'
537
+ && current.warnings.some((warning) => warning.step === 'task-comment' && warning.status === 'open');
538
+ return {
539
+ taskComment: retryWithResolvedWarning ? 'pending' : mutation.state,
540
+ warningProjection: warnings.length > 0 || retryWithResolvedWarning ? 'pending' : 'done',
541
+ warnings,
542
+ lastError: null
543
+ };
529
544
  }
530
545
  const warnings = replaceWarning(current, warningFromError('task-comment', mutation.error), 'open');
531
546
  return { taskComment: 'pending', warningProjection: 'pending', warnings, lastError: mutation.error };
@@ -557,8 +572,8 @@ function applyFinalizationReceiptMutationUnderLock(
557
572
  mutation: FinalizationMutation,
558
573
  consumed: Set<string>
559
574
  ): TaskFinalizationReceipt {
560
- if (!hasPreparedTaskDocument(repoRoot, receipt.taskId)) {
561
- throw capabilityError('FINALIZATION_SCOPE_INVALID', 'finalization capability requires an active prepared task or a completed task');
575
+ if (!hasFinalizationTaskDocument(repoRoot, receipt.taskId)) {
576
+ throw capabilityError('FINALIZATION_SCOPE_INVALID', 'finalization capability requires an active task or a completed task');
562
577
  }
563
578
  const current = readReceipt(repoRoot, receipt.taskId);
564
579
  if (!current || current.receiptId !== receipt.receiptId || current.revision !== receipt.revision) {
@@ -576,8 +591,8 @@ function applyFinalizationReceiptMutation(
576
591
  capability: FinalizationCapability,
577
592
  mutation: FinalizationMutation
578
593
  ): TaskFinalizationReceipt {
579
- if (!hasPreparedTaskDocument(repoRoot, receipt.taskId)) {
580
- throw capabilityError('FINALIZATION_SCOPE_INVALID', 'finalization capability requires an active prepared task or a completed task');
594
+ if (!hasFinalizationTaskDocument(repoRoot, receipt.taskId)) {
595
+ throw capabilityError('FINALIZATION_SCOPE_INVALID', 'finalization capability requires an active task or a completed task');
581
596
  }
582
597
  return withTaskExecutionLock(repoRoot, receipt.taskId, 'task-finalization.receipt-mutation', () =>
583
598
  applyFinalizationReceiptMutationUnderLock(repoRoot, receipt, capability, mutation, new Set<string>())
@@ -627,6 +642,8 @@ async function syncPendingTaskComment(input: {
627
642
  taskId: string;
628
643
  agent: string;
629
644
  receipt: TaskFinalizationReceipt;
645
+ projection?: TaskCompletionProjection;
646
+ refreshProjection?: () => TaskCompletionProjection | undefined;
630
647
  commentSync: typeof syncPlatformComment;
631
648
  consumedCapabilities: Set<string>;
632
649
  }): Promise<TaskCommentSyncOutcome> {
@@ -641,7 +658,15 @@ async function syncPendingTaskComment(input: {
641
658
  };
642
659
  }
643
660
  try {
644
- const result = await commentSync(taskId, { kind: 'task', agent, cwd: repoRoot });
661
+ const result = await commentSync(taskId, {
662
+ kind: 'task', agent, cwd: repoRoot, dependency: 'required',
663
+ ...(input.projection ? {
664
+ taskProjection: {
665
+ content: input.projection.content,
666
+ sha256: createHash('sha256').update(input.projection.content).digest('hex')
667
+ }
668
+ } : {})
669
+ });
645
670
  const step = commentStep(result);
646
671
  if (result.status === 'applied' || result.status === 'no-op') {
647
672
  const skipped = result.error?.code === 'ISSUE_NOT_LINKED';
@@ -650,6 +675,14 @@ async function syncPendingTaskComment(input: {
650
675
  scope: 'task-comment', operation: 'succeeded', state: skipped ? 'skipped' : 'done'
651
676
  }, consumedCapabilities);
652
677
  receipt = reconcileWarningProjection(repoRoot, taskId, receipt, consumedCapabilities);
678
+ if (!skipped && receipt.taskComment === 'pending') {
679
+ const retry = await syncPendingTaskComment({
680
+ ...input,
681
+ receipt,
682
+ projection: input.refreshProjection?.() ?? input.projection
683
+ });
684
+ return { ...retry, changed: result.changed || retry.changed };
685
+ }
653
686
  return { receipt, step: skipped ? { ...step, status: 'skipped' } : step, changed: result.changed, error: null };
654
687
  }
655
688
  const detail = step.error ?? { code: 'COMMENT_SYNC_FAILED', message: 'task comment synchronization failed', retryable: true };
@@ -702,7 +735,7 @@ async function syncPendingSummary(input: {
702
735
  let { receipt } = input;
703
736
  try {
704
737
  const resolved = resolveTaskRef(input.taskId, { repoRoot: input.repoRoot });
705
- if (!resolved.ok || !hasPreparedTaskDocument(input.repoRoot, input.taskId)) throw Object.assign(new Error('prepared task is unavailable for summary sync'), { code: 'SUMMARY_STAGING_INVALID' });
738
+ if (!resolved.ok || (resolved.state !== 'active' && resolved.state !== 'completed')) throw Object.assign(new Error('task is unavailable for summary sync'), { code: 'SUMMARY_STAGING_INVALID' });
706
739
  if (!fs.existsSync(path.join(resolved.taskDir, '.delivery-summary.json'))) {
707
740
  receipt = updateReceipt(input.repoRoot, receipt, { summary: 'skipped', lastError: null });
708
741
  return { receipt, step: { status: 'skipped', changed: false, error: null }, changed: false, error: null };
@@ -716,7 +749,8 @@ async function syncPendingSummary(input: {
716
749
  const summary = readDeliverySummary(resolved.taskDir, input.taskId);
717
750
  const result = await input.commentSync(input.taskId, {
718
751
  kind: 'summary', agent: input.agent, body: summary.body, cwd: input.repoRoot,
719
- summaryAuthorization: { sha256: summary.sha256 }
752
+ summaryAuthorization: { sha256: summary.sha256 },
753
+ dependency: 'required'
720
754
  });
721
755
  const step = commentStep(result);
722
756
  if (result.status === 'applied' || result.status === 'no-op') {
@@ -726,7 +760,7 @@ async function syncPendingSummary(input: {
726
760
  receipt = updateReceipt(input.repoRoot, receipt, {
727
761
  summary: skipped ? 'skipped' : 'done',
728
762
  taskComment: resolvedWarning ? 'pending' : receipt.taskComment,
729
- warningProjection: warnings.some((warning) => warning.status === 'open') ? 'pending' : 'done',
763
+ warningProjection: warnings.some((warning) => warning.status === 'open') || resolvedWarning ? 'pending' : 'done',
730
764
  warnings,
731
765
  lastError: null
732
766
  });
@@ -814,6 +848,8 @@ async function prepareUnderLock(
814
848
  const lifecycle = options.lifecycle ?? applyTaskLifecycle;
815
849
  const backfill = options.backfill ?? backfillCompletionComments;
816
850
  const commentSync = options.commentSync ?? syncPlatformComment;
851
+ const issueSync = options.issueSync ?? syncPlatformIssue;
852
+ const issueInspect = options.issueInspect ?? inspectPlatformIssue;
817
853
  const verify = options.verify ?? verifyTaskEvent;
818
854
  const consumedCapabilities = new Set<string>();
819
855
  let receipt: TaskFinalizationReceipt;
@@ -858,6 +894,90 @@ async function prepareUnderLock(
858
894
  }
859
895
  }
860
896
 
897
+ if (preflightState.ok && preflightState.state === 'active') {
898
+ const recovery = await recoverPlatformOperations(taskId, 'all', { agent: request.agent, cwd: repoRoot });
899
+ if (recovery.status !== 'applied' && recovery.status !== 'no-op') {
900
+ return failed(taskId, recovery.error ?? {
901
+ code: 'PLATFORM_OPERATION_RECOVERY_PENDING',
902
+ message: 'Pending platform operations remain unresolved',
903
+ retryable: true
904
+ });
905
+ }
906
+ const taskContent = fs.readFileSync(preflightState.taskMdPath, 'utf8');
907
+ const taskFrontmatter = parseTaskFrontmatter(taskContent);
908
+ const issueIdentity = taskIssueIdentity(taskFrontmatter);
909
+ if (issueIdentity) {
910
+ const base = typeof taskFrontmatter.delivery_base_ref === 'string' ? taskFrontmatter.delivery_base_ref.trim() : '';
911
+ let synced: IssueResult;
912
+ try {
913
+ synced = await issueSync(taskId, {
914
+ agent: request.agent, cwd: repoRoot, requirements: true, issueType: true, fields: true,
915
+ ...(base ? { inLabels: 'from-diff', base } : {}), dependency: 'required'
916
+ } satisfies IssueSyncOptions);
917
+ } catch (error) {
918
+ const detail = errorOf(error, 'FINALIZATION_ISSUE_METADATA_FAILED', true);
919
+ return failed(taskId, detail);
920
+ }
921
+ const issueOperations = synced.operations as Array<{ name: string; status: string; reasonCode: string | null; value?: unknown }>;
922
+ const badOperation = issueOperations?.find((operation) =>
923
+ ['requirements', 'issue-type', 'fields', ...(base ? ['labels:in'] : [])].includes(operation.name)
924
+ && operation.status !== 'applied' && operation.status !== 'no-op'
925
+ );
926
+ if ((synced.status !== 'applied' && synced.status !== 'no-op') || synced.error || badOperation) {
927
+ const error = synced.error ?? {
928
+ code: badOperation?.reasonCode || 'FINALIZATION_ISSUE_METADATA_UNCONFIRMED',
929
+ message: `Required Issue metadata operation '${badOperation?.name ?? 'sync'}' was not confirmed`,
930
+ retryable: true
931
+ };
932
+ return failed(taskId, errorOf(error, 'FINALIZATION_ISSUE_METADATA_FAILED', true));
933
+ }
934
+ let inspected: IssueResult;
935
+ try { inspected = await issueInspect(taskId, { cwd: repoRoot }); }
936
+ catch (error) { return failed(taskId, errorOf(error, 'FINALIZATION_ISSUE_INSPECT_FAILED', true)); }
937
+ if (inspected.status !== 'no-op' || inspected.error || !inspected.issue
938
+ || !resourceIdentityEquals(inspected.issue.identity, issueIdentity)) {
939
+ return failed(taskId, inspected.error ?? {
940
+ code: 'FINALIZATION_ISSUE_IDENTITY_UNCONFIRMED',
941
+ message: 'Required Issue metadata could not be reread under the bound Issue identity',
942
+ retryable: true
943
+ });
944
+ }
945
+ for (const name of ['requirements', 'issue-type', 'fields', ...(base ? ['labels:in'] : [])]) {
946
+ const operation = issueOperations?.find((candidate) => candidate.name === name);
947
+ if (!operation) return failed(taskId, {
948
+ code: 'FINALIZATION_ISSUE_METADATA_UNCONFIRMED',
949
+ message: `Required Issue metadata operation '${name}' was not reported`, retryable: true
950
+ });
951
+ const before = synced.issue;
952
+ const remote = inspected.issue;
953
+ if (name === 'requirements') {
954
+ const expected = typeof operation.value === 'string' ? operation.value : before?.body;
955
+ if (expected === undefined || remote.body !== expected) return failed(taskId, {
956
+ code: 'FINALIZATION_ISSUE_REQUIREMENTS_UNCONFIRMED', message: 'Issue requirements did not match the verified target', retryable: true
957
+ });
958
+ } else if (name === 'issue-type') {
959
+ const expected = typeof operation.value === 'string' ? operation.value : before?.issueType;
960
+ if (!expected || remote.issueType !== expected) return failed(taskId, {
961
+ code: 'FINALIZATION_ISSUE_TYPE_UNCONFIRMED', message: 'Issue Type did not match the verified target', retryable: true
962
+ });
963
+ } else if (name === 'labels:in') {
964
+ const expected = Array.isArray(operation.value) ? operation.value as string[] : before?.labels;
965
+ if (!expected || expected.filter((label) => label.startsWith('in:')).sort().join('\0')
966
+ !== remote.labels.filter((label) => label.startsWith('in:')).sort().join('\0')) return failed(taskId, {
967
+ code: 'FINALIZATION_IN_LABELS_UNCONFIRMED', message: 'Issue in-labels did not match the verified target', retryable: true
968
+ });
969
+ } else {
970
+ const expected = operation.value && typeof operation.value === 'object'
971
+ ? operation.value as Record<string, string | number>
972
+ : before?.fields;
973
+ if (!expected || Object.entries(expected).some(([field, value]) => remote.fields[field] !== value)) return failed(taskId, {
974
+ code: 'FINALIZATION_ISSUE_FIELDS_UNCONFIRMED', message: 'Pinned Issue fields did not match the verified target', retryable: true
975
+ });
976
+ }
977
+ }
978
+ }
979
+ }
980
+
861
981
  let changed = false;
862
982
  let backfillResult: TaskFinalizationStep | null = null;
863
983
  let runBackfill = shouldRunBackfill(receipt);
@@ -921,9 +1041,10 @@ async function prepareUnderLock(
921
1041
  } else backfillResult = { status: 'no-op', changed: false, error: null };
922
1042
 
923
1043
  let lifecycleResult: TaskFinalizationStep | null = null;
1044
+ let projection: TaskCompletionProjection | undefined;
924
1045
  try {
925
1046
  const resolved = resolveTaskRef(taskId, { repoRoot });
926
- if (receipt.lifecycle === 'done' && resolved.ok && resolved.state === 'completed') {
1047
+ if (resolved.ok && resolved.state === 'completed') {
927
1048
  lifecycleResult = { status: 'no-op', changed: false, error: null };
928
1049
  receipt = updateReceipt(repoRoot, receipt, { lifecycle: 'done', lastError: null });
929
1050
  } else {
@@ -937,6 +1058,12 @@ async function prepareUnderLock(
937
1058
  return terminalResult(taskId, receipt, { backfill: backfillResult, lifecycle: lifecycleResult, taskComment: null, verification: null }, result.changed, lifecycleResult.error);
938
1059
  }
939
1060
  changed = changed || result.changed;
1061
+ if (result.timestamp && result.agentInfraVersion) {
1062
+ projection = previewTaskCompletion(taskId, request.agent, {
1063
+ repoRoot,
1064
+ metadataProvider: () => ({ timestamp: result.timestamp!, agentInfraVersion: result.agentInfraVersion! })
1065
+ });
1066
+ }
940
1067
  }
941
1068
  } catch (error) {
942
1069
  const detail = errorOf(error, 'TASK_FINALIZATION_LIFECYCLE_FAILED');
@@ -946,6 +1073,12 @@ async function prepareUnderLock(
946
1073
 
947
1074
  let taskComment: TaskFinalizationStep | null = null;
948
1075
  receipt = reconcileWarningProjection(repoRoot, taskId, receipt, consumedCapabilities);
1076
+ const currentProjection = (): TaskCompletionProjection | undefined => projection
1077
+ ? previewTaskCompletion(taskId, request.agent, {
1078
+ repoRoot,
1079
+ metadataProvider: () => ({ timestamp: projection!.timestamp, agentInfraVersion: projection!.agentInfraVersion })
1080
+ })
1081
+ : undefined;
949
1082
 
950
1083
  let verification: TaskFinalizationStep | null = null;
951
1084
  if (receipt.verification !== 'pending') {
@@ -971,7 +1104,7 @@ async function prepareUnderLock(
971
1104
  });
972
1105
  receipt = reconcileWarningProjection(repoRoot, taskId, receipt, consumedCapabilities);
973
1106
  const finalComment = await syncPendingTaskComment({
974
- repoRoot, taskId, agent: request.agent, receipt, commentSync, consumedCapabilities
1107
+ repoRoot, taskId, agent: request.agent, receipt, projection: currentProjection(), refreshProjection: currentProjection, commentSync, consumedCapabilities
975
1108
  });
976
1109
  receipt = finalComment.receipt;
977
1110
  if (finalComment.step.status !== 'no-op') taskComment = finalComment.step;
@@ -987,7 +1120,7 @@ async function prepareUnderLock(
987
1120
  });
988
1121
  receipt = reconcileWarningProjection(repoRoot, taskId, receipt, consumedCapabilities);
989
1122
  const warningComment = await syncPendingTaskComment({
990
- repoRoot, taskId, agent: request.agent, receipt, commentSync, consumedCapabilities
1123
+ repoRoot, taskId, agent: request.agent, receipt, projection: currentProjection(), refreshProjection: currentProjection, commentSync, consumedCapabilities
991
1124
  });
992
1125
  receipt = warningComment.receipt;
993
1126
  taskComment = warningComment.step;
@@ -1017,7 +1150,7 @@ async function prepareUnderLock(
1017
1150
  }, changed, hardError);
1018
1151
  }
1019
1152
  try {
1020
- const warningComment = await syncPendingTaskComment({ repoRoot, taskId, agent: request.agent, receipt, commentSync, consumedCapabilities });
1153
+ const warningComment = await syncPendingTaskComment({ repoRoot, taskId, agent: request.agent, receipt, projection: currentProjection(), refreshProjection: currentProjection, commentSync, consumedCapabilities });
1021
1154
  receipt = warningComment.receipt;
1022
1155
  if (warningComment.step.status !== 'no-op') taskComment = warningComment.step;
1023
1156
  changed = changed || warningComment.changed;
@@ -1038,7 +1171,7 @@ async function prepareUnderLock(
1038
1171
  receipt = reconcileWarningProjection(repoRoot, taskId, receipt, consumedCapabilities);
1039
1172
  if (receipt.taskComment === 'pending') {
1040
1173
  const finalComment = await syncPendingTaskComment({
1041
- repoRoot, taskId, agent: request.agent, receipt, commentSync, consumedCapabilities
1174
+ repoRoot, taskId, agent: request.agent, receipt, projection: currentProjection(), refreshProjection: currentProjection, commentSync, consumedCapabilities
1042
1175
  });
1043
1176
  receipt = finalComment.receipt;
1044
1177
  taskComment = finalComment.step;
@@ -5,7 +5,8 @@ import path from 'node:path';
5
5
  import { appendActivityEntry, locateActivityLog } from './activity-log.ts';
6
6
  import { artifactFamilyCatalog, inspectArtifactDirectory } from './artifact-lifecycle.ts';
7
7
  import { parseArtifactName } from './artifact-name.ts';
8
- import { parseTypedTaskFrontmatter } from './frontmatter.ts';
8
+ import { parseTypedTaskFrontmatter, updateTaskFrontmatter } from './frontmatter.ts';
9
+ import { upsertSection } from './sections.ts';
9
10
  import { locateHotTaskDirs, resolveTaskRef, TASK_ID_RE } from './resolve-ref.ts';
10
11
  import {
11
12
  configuredShortIdLength, executeShortIdCommand, loadShortIdByTaskId,
@@ -112,6 +113,8 @@ type TaskLifecycleOptions = {
112
113
  prepareOnly?: boolean;
113
114
  };
114
115
 
116
+ type TaskCompletionProjection = Readonly<{ taskId: string; content: string; timestamp: string; agentInfraVersion: string }>;
117
+
115
118
  const STEPS: readonly LifecycleStep[] = ['task-written', 'directory-moved', 'registry-committed'];
116
119
  const DEFAULT_IO: LifecycleFileSystem = {
117
120
  renameSync: (source, target) => fs.renameSync(source, target),
@@ -304,6 +307,25 @@ function mutationsFor(request: TaskLifecycleRequest, content: string, metadata:
304
307
  return mutations;
305
308
  }
306
309
 
310
+ function previewTaskCompletion(taskRef: string, agent: string, options: Pick<TaskLifecycleOptions, 'repoRoot' | 'metadataProvider'> = {}): TaskCompletionProjection {
311
+ const resolved = resolveTaskRef(taskRef, { repoRoot: options.repoRoot });
312
+ if (!resolved.ok) throw Object.assign(new Error(resolved.message), { code: resolved.code });
313
+ if (resolved.state !== 'active') throw Object.assign(new Error(`task ${resolved.taskId} is ${resolved.state}, expected active`), { code: 'TASK_STATE_MISMATCH' });
314
+ const original = fs.readFileSync(resolved.taskMdPath, 'utf8');
315
+ const metadata = (options.metadataProvider ?? captureTaskWriteMetadata)();
316
+ if (!isValidAgentInfraVersion(metadata.agentInfraVersion)) throw Object.assign(new Error('metadata agentInfraVersion is invalid'), { code: 'LIFECYCLE_METADATA_FAILED' });
317
+ const mutations = mutationsFor({ taskRef: resolved.taskId, intent: 'complete', agent }, original, metadata, 0);
318
+ if (!Array.isArray(mutations)) throw Object.assign(new Error(mutations.message), { code: mutations.code });
319
+ let content = original;
320
+ for (const mutation of mutations) {
321
+ if (mutation.kind === 'frontmatter') content = updateTaskFrontmatter(content, mutation.set, mutation.remove);
322
+ else if (mutation.kind === 'section') content = upsertSection(content, mutation).content;
323
+ else throw Object.assign(new Error('completion projection contains an unsupported mutation'), { code: 'LIFECYCLE_PROJECTION_INVALID' });
324
+ }
325
+ content = updateTaskFrontmatter(content, { updated_at: metadata.timestamp, agent_infra_version: metadata.agentInfraVersion });
326
+ return { taskId: resolved.taskId, content, timestamp: metadata.timestamp, agentInfraVersion: metadata.agentInfraVersion };
327
+ }
328
+
307
329
  function writeJournal(file: string, journal: LifecycleJournal, io: LifecycleFileSystem, create = false): void {
308
330
  const temporary = `${file}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
309
331
  io.writeFileSync(temporary, `${JSON.stringify(journal, null, 2)}\n`, 'wx');
@@ -590,6 +612,16 @@ function applyTaskLifecycle(requestInput: TaskLifecycleRequest, options: TaskLif
590
612
  if (completed.has('directory-moved')) completed.add('registry-committed');
591
613
  }
592
614
  let taskOperations: readonly TaskOperationSummary[] = [];
615
+ if (options.prepareOnly && !matchingCompletion(io.readFileSync(path.join(sourcePath, 'task.md')), request, restoredFiles)) {
616
+ return {
617
+ ...failed(request, { code: '', message: '' }), status: 'applied', changed: true, error: null,
618
+ taskId, sourceState, targetState: spec.target, sourcePath, targetPath,
619
+ task: { operations: [] }, directory: { effect: 'unchanged', changed: false },
620
+ shortId: { effect: 'unchanged', shortId: null, changed: false },
621
+ timestamp: journal.metadata.timestamp, agentInfraVersion: journal.metadata.agentInfraVersion,
622
+ journalPath, completedSteps: [...completed], pendingSteps: STEPS.filter((step) => !completed.has(step))
623
+ };
624
+ }
593
625
  if (matchingCompletion(io.readFileSync(path.join(sourcePath, 'task.md')), request, restoredFiles)) completed.add('task-written');
594
626
  if (!completed.has('task-written')) {
595
627
  const current = io.readFileSync(path.join(sourcePath, 'task.md'));
@@ -687,8 +719,8 @@ function applyTaskLifecycle(requestInput: TaskLifecycleRequest, options: TaskLif
687
719
  };
688
720
  }
689
721
 
690
- export { lifecycleIntentCatalog, lifecycleFailureCatalog, lifecycleProducerCatalog, applyTaskLifecycle };
722
+ export { lifecycleIntentCatalog, lifecycleFailureCatalog, lifecycleProducerCatalog, applyTaskLifecycle, previewTaskCompletion };
691
723
  export type {
692
724
  TaskLifecycleIntent, TaskLifecycleRequest, TaskLifecycleResult, TaskLifecycleOptions,
693
- LifecycleError, LifecycleJournal, LifecycleStep
725
+ LifecycleError, LifecycleJournal, LifecycleStep, TaskCompletionProjection
694
726
  };
@@ -0,0 +1,230 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+
5
+ import { isResourceIdentity } from '../platform/resource-identity.ts';
6
+ import type { ResourceIdentity } from '../platform/resource-identity.ts';
7
+ import { resolveTaskRef } from './resolve-ref.ts';
8
+
9
+ const JOURNAL_FILE = '.platform-operations.json';
10
+ const MAX_ATTEMPTS = 3;
11
+
12
+ type PlatformOperationKind = 'task-comment' | 'artifact-comment' | 'summary-comment' | 'cancel-comment' | 'issue-create' | 'issue-metadata' | 'pull-request' | 'pull-request-summary' | 'pull-request-review';
13
+ type PlatformOperationState = 'queued' | 'pending' | 'unknown' | 'succeeded' | 'failed';
14
+ type PlatformIssueMetadataIntent = Readonly<{
15
+ requirements: boolean;
16
+ issueType: boolean;
17
+ fields: boolean;
18
+ status?: string;
19
+ assignees?: 'current' | 'none';
20
+ milestone?: 'initial' | 'specific' | 'none';
21
+ inLabels?: 'from-diff' | 'none';
22
+ base?: string;
23
+ fromDiffFiles?: readonly string[];
24
+ inLabelMappingDigest?: string;
25
+ state?: 'open' | 'closed';
26
+ closeReason?: 'completed' | 'not_planned';
27
+ }>;
28
+ type PlatformPullRequestIntent = Readonly<{
29
+ action: 'create' | 'bind' | 'sync';
30
+ baseRef?: string;
31
+ headRef?: string;
32
+ prToken?: string;
33
+ metadata?: boolean;
34
+ closingIssue?: boolean;
35
+ }>;
36
+ type PlatformIssueCreateIntent = Readonly<{ title: string; bodyDigest: string }>;
37
+ type PlatformPullRequestSummaryIntent = Readonly<{ body: string; changeReportFile: string }>;
38
+ type PlatformPullRequestReviewIntent = Readonly<{
39
+ prNumber: string;
40
+ resource: ResourceIdentity;
41
+ providerScopeId: string;
42
+ scope: string;
43
+ round: number;
44
+ commitSha: string;
45
+ event: 'COMMENT' | 'APPROVE' | 'REQUEST_CHANGES';
46
+ artifactFile: string;
47
+ bodyDigest: string;
48
+ }>;
49
+ type PlatformOperation = Readonly<{
50
+ id: string;
51
+ kind: PlatformOperationKind;
52
+ target: string;
53
+ expectedDigest: string;
54
+ dependency: 'deferred' | 'required';
55
+ state: PlatformOperationState;
56
+ attempts: number;
57
+ maxAttempts: typeof MAX_ATTEMPTS;
58
+ lastCode: string | null;
59
+ issueMetadata?: PlatformIssueMetadataIntent;
60
+ issueCreate?: PlatformIssueCreateIntent;
61
+ pullRequest?: PlatformPullRequestIntent;
62
+ pullRequestSummary?: PlatformPullRequestSummaryIntent;
63
+ pullRequestReview?: PlatformPullRequestReviewIntent;
64
+ updatedAt: string;
65
+ }>;
66
+ type PlatformOperationJournal = Readonly<{
67
+ version: 1;
68
+ taskId: string;
69
+ operations: readonly PlatformOperation[];
70
+ }>;
71
+
72
+ type RecordOperationInput = Readonly<{
73
+ taskRef: string;
74
+ kind: PlatformOperationKind;
75
+ target: string;
76
+ expectedDigest: string;
77
+ dependency: 'deferred' | 'required';
78
+ state: PlatformOperationState;
79
+ lastCode?: string | null;
80
+ issueMetadata?: PlatformIssueMetadataIntent;
81
+ issueCreate?: PlatformIssueCreateIntent;
82
+ pullRequest?: PlatformPullRequestIntent;
83
+ pullRequestSummary?: PlatformPullRequestSummaryIntent;
84
+ pullRequestReview?: PlatformPullRequestReviewIntent;
85
+ cwd?: string;
86
+ }>;
87
+
88
+ function operationId(input: Pick<RecordOperationInput, 'kind' | 'target' | 'expectedDigest'>): string {
89
+ return createHash('sha256')
90
+ .update(`${input.kind}\0${input.target}\0${input.expectedDigest}`)
91
+ .digest('hex');
92
+ }
93
+
94
+ function journalPath(taskDir: string): string {
95
+ return path.join(taskDir, JOURNAL_FILE);
96
+ }
97
+
98
+ function parseJournal(file: string, taskId: string): PlatformOperationJournal {
99
+ let value: unknown;
100
+ try { value = JSON.parse(fs.readFileSync(file, 'utf8')); }
101
+ catch (error) {
102
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { version: 1, taskId, operations: [] };
103
+ throw Object.assign(new Error(`Platform operation journal is unreadable: ${String(error)}`), { code: 'PLATFORM_OPERATION_JOURNAL_INVALID' });
104
+ }
105
+ if (!value || typeof value !== 'object') throw Object.assign(new Error('Platform operation journal is invalid'), { code: 'PLATFORM_OPERATION_JOURNAL_INVALID' });
106
+ const journal = value as Partial<PlatformOperationJournal>;
107
+ if (journal.version !== 1 || journal.taskId !== taskId || !Array.isArray(journal.operations)) {
108
+ throw Object.assign(new Error('Platform operation journal identity or version is invalid'), { code: 'PLATFORM_OPERATION_JOURNAL_INVALID' });
109
+ }
110
+ for (const item of journal.operations) {
111
+ if (!item || typeof item !== 'object' || !/^[a-f0-9]{64}$/u.test(item.id)
112
+ || !['task-comment', 'artifact-comment', 'summary-comment', 'cancel-comment', 'issue-create', 'issue-metadata', 'pull-request', 'pull-request-summary', 'pull-request-review'].includes(item.kind)
113
+ || typeof item.target !== 'string' || !item.target
114
+ || !/^[a-f0-9]{64}$/u.test(item.expectedDigest)
115
+ || !['deferred', 'required'].includes(item.dependency)
116
+ || !['queued', 'pending', 'unknown', 'succeeded', 'failed'].includes(item.state)
117
+ || !Number.isSafeInteger(item.attempts) || item.attempts < 0 || item.attempts > MAX_ATTEMPTS
118
+ || item.maxAttempts !== MAX_ATTEMPTS
119
+ || !(item.lastCode === null || typeof item.lastCode === 'string')
120
+ || (item.kind === 'issue-metadata'
121
+ ? !item.issueMetadata || typeof item.issueMetadata.requirements !== 'boolean'
122
+ || typeof item.issueMetadata.issueType !== 'boolean' || typeof item.issueMetadata.fields !== 'boolean'
123
+ || (item.issueMetadata.status !== undefined && typeof item.issueMetadata.status !== 'string')
124
+ || (item.issueMetadata.assignees !== undefined && !['current', 'none'].includes(item.issueMetadata.assignees))
125
+ || (item.issueMetadata.milestone !== undefined && !['initial', 'specific', 'none'].includes(item.issueMetadata.milestone))
126
+ || (item.issueMetadata.inLabels !== undefined && !['from-diff', 'none'].includes(item.issueMetadata.inLabels))
127
+ || (item.issueMetadata.base !== undefined && (typeof item.issueMetadata.base !== 'string' || !item.issueMetadata.base.trim()))
128
+ || (item.issueMetadata.fromDiffFiles !== undefined && (!Array.isArray(item.issueMetadata.fromDiffFiles)
129
+ || item.issueMetadata.fromDiffFiles.some((file: unknown) => typeof file !== 'string')))
130
+ || (item.issueMetadata.inLabelMappingDigest !== undefined && !/^[a-f0-9]{64}$/u.test(item.issueMetadata.inLabelMappingDigest))
131
+ || (item.issueMetadata.state !== undefined && !['open', 'closed'].includes(item.issueMetadata.state))
132
+ || (item.issueMetadata.closeReason !== undefined && !['completed', 'not_planned'].includes(item.issueMetadata.closeReason))
133
+ || (!item.issueMetadata.requirements && !item.issueMetadata.issueType && !item.issueMetadata.fields
134
+ && item.issueMetadata.status === undefined && item.issueMetadata.assignees === undefined
135
+ && item.issueMetadata.milestone === undefined && item.issueMetadata.inLabels === undefined && item.issueMetadata.state === undefined)
136
+ : item.issueMetadata !== undefined)
137
+ || (item.kind === 'pull-request'
138
+ ? !item.pullRequest || !['create', 'bind', 'sync'].includes(item.pullRequest.action)
139
+ || Object.entries(item.pullRequest).some(([key, field]) => key !== 'action'
140
+ && key !== 'metadata' && key !== 'closingIssue' && (typeof field !== 'string' || !field.trim()))
141
+ || (item.pullRequest.metadata !== undefined && typeof item.pullRequest.metadata !== 'boolean')
142
+ || (item.pullRequest.closingIssue !== undefined && typeof item.pullRequest.closingIssue !== 'boolean')
143
+ : item.pullRequest !== undefined)
144
+ || (item.kind === 'issue-create'
145
+ ? !item.issueCreate || typeof item.issueCreate.title !== 'string' || !item.issueCreate.title.trim()
146
+ || !/^[a-f0-9]{64}$/u.test(item.issueCreate.bodyDigest)
147
+ : item.issueCreate !== undefined)
148
+ || (item.kind === 'pull-request-summary'
149
+ ? !item.pullRequestSummary || typeof item.pullRequestSummary.body !== 'string'
150
+ || !item.pullRequestSummary.body.trim() || typeof item.pullRequestSummary.changeReportFile !== 'string'
151
+ || !item.pullRequestSummary.changeReportFile.trim()
152
+ : item.pullRequestSummary !== undefined)
153
+ || (item.kind === 'pull-request-review'
154
+ ? !item.pullRequestReview || typeof item.pullRequestReview.prNumber !== 'string'
155
+ || !isResourceIdentity(item.pullRequestReview.resource)
156
+ || typeof item.pullRequestReview.providerScopeId !== 'string' || !item.pullRequestReview.providerScopeId.trim()
157
+ || !item.pullRequestReview.prNumber.trim() || !/^TASK-\d{8}-\d{6}$|^pr\d+$/u.test(item.pullRequestReview.scope)
158
+ || !Number.isSafeInteger(item.pullRequestReview.round) || item.pullRequestReview.round <= 0
159
+ || !/^[0-9a-f]{7,40}$/iu.test(item.pullRequestReview.commitSha)
160
+ || !['COMMENT', 'APPROVE', 'REQUEST_CHANGES'].includes(item.pullRequestReview.event)
161
+ || typeof item.pullRequestReview.artifactFile !== 'string'
162
+ || !/^pr-review(?:-r\d+)?\.md$/u.test(item.pullRequestReview.artifactFile)
163
+ || (item.pullRequestReview.artifactFile === 'pr-review.md'
164
+ ? item.pullRequestReview.round !== 1
165
+ : item.pullRequestReview.artifactFile !== `pr-review-r${item.pullRequestReview.round}.md`)
166
+ || !/^[a-f0-9]{64}$/u.test(item.pullRequestReview.bodyDigest)
167
+ : item.pullRequestReview !== undefined)
168
+ || typeof item.updatedAt !== 'string') {
169
+ throw Object.assign(new Error('Platform operation journal contains an invalid operation'), { code: 'PLATFORM_OPERATION_JOURNAL_INVALID' });
170
+ }
171
+ }
172
+ return journal as PlatformOperationJournal;
173
+ }
174
+
175
+ function writeJournal(file: string, journal: PlatformOperationJournal): void {
176
+ fs.mkdirSync(path.dirname(file), { recursive: true });
177
+ const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
178
+ try {
179
+ fs.writeFileSync(temporary, `${JSON.stringify(journal, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
180
+ fs.renameSync(temporary, file);
181
+ } finally {
182
+ try { fs.unlinkSync(temporary); } catch { /* renamed or already removed */ }
183
+ }
184
+ }
185
+
186
+ function resolveJournal(taskRef: string, cwd?: string): { taskId: string; file: string } {
187
+ const resolved = resolveTaskRef(taskRef, cwd ? { repoRoot: cwd } : {});
188
+ if (!resolved.ok) throw Object.assign(new Error(resolved.message), { code: resolved.code });
189
+ return { taskId: resolved.taskId, file: journalPath(resolved.taskDir) };
190
+ }
191
+
192
+ function recordPlatformOperation(input: RecordOperationInput): PlatformOperation {
193
+ if (!/^[a-f0-9]{64}$/u.test(input.expectedDigest) || !input.target.trim()) {
194
+ throw Object.assign(new Error('Platform operation requires a stable target and SHA-256 digest'), { code: 'PLATFORM_OPERATION_PAYLOAD_INVALID' });
195
+ }
196
+ const { taskId, file } = resolveJournal(input.taskRef, input.cwd);
197
+ const journal = parseJournal(file, taskId);
198
+ const id = operationId(input);
199
+ const previous = journal.operations.find((item) => item.id === id);
200
+ const next: PlatformOperation = {
201
+ id,
202
+ kind: input.kind,
203
+ target: input.target,
204
+ expectedDigest: input.expectedDigest,
205
+ dependency: input.dependency,
206
+ state: input.state,
207
+ attempts: Math.min(MAX_ATTEMPTS, (previous?.attempts ?? 0) + (input.state === 'pending' ? 1 : 0)),
208
+ maxAttempts: MAX_ATTEMPTS,
209
+ lastCode: input.lastCode ?? null,
210
+ ...(input.issueMetadata ? { issueMetadata: input.issueMetadata } : {}),
211
+ ...(input.issueCreate ? { issueCreate: input.issueCreate } : {}),
212
+ ...(input.pullRequest ? { pullRequest: input.pullRequest } : {}),
213
+ ...(input.pullRequestSummary ? { pullRequestSummary: input.pullRequestSummary } : {}),
214
+ ...(input.pullRequestReview ? { pullRequestReview: input.pullRequestReview } : {}),
215
+ updatedAt: new Date().toISOString()
216
+ };
217
+ const operations = previous
218
+ ? journal.operations.map((item) => item.id === id ? next : item)
219
+ : [...journal.operations, next];
220
+ writeJournal(file, { version: 1, taskId, operations });
221
+ return next;
222
+ }
223
+
224
+ function readPlatformOperationJournal(taskRef: string, cwd?: string): PlatformOperationJournal {
225
+ const { taskId, file } = resolveJournal(taskRef, cwd);
226
+ return parseJournal(file, taskId);
227
+ }
228
+
229
+ export { JOURNAL_FILE as PLATFORM_OPERATION_JOURNAL_FILE, MAX_ATTEMPTS as PLATFORM_OPERATION_MAX_ATTEMPTS, operationId, recordPlatformOperation, readPlatformOperationJournal };
230
+ export type { PlatformIssueCreateIntent, PlatformIssueMetadataIntent, PlatformOperation, PlatformOperationJournal, PlatformOperationKind, PlatformOperationState, PlatformPullRequestIntent, PlatformPullRequestReviewIntent, PlatformPullRequestSummaryIntent, RecordOperationInput };