@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
@@ -0,0 +1,435 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { execFileSync } from 'node:child_process';
5
+
6
+ import { inspectPlatformCommentOperation, normalizeCommentContent, syncPlatformComment } from '../platform/issue-comments.ts';
7
+ import { inspectPlatformIssue, syncPlatformIssue } from '../platform/issues.ts';
8
+ import { resolvePlatformProviderContext } from '../platform/context.ts';
9
+ import { providerOperationContext, providerError, unsupportedProviderOperation } from '../platform/provider-bridge.ts';
10
+ import { inLabelMappingDigest } from '../platform/in-label-sync.ts';
11
+ import { bindPlatformPullRequest, inspectPlatformPullRequest, recoverCreatedPullRequest, syncPlatformPullRequest } from '../platform/pull-requests.ts';
12
+ import { syncPullRequestSummary } from '../platform/pr-summary.ts';
13
+ import { publishPrReview, readReviewBodyFile } from '../platform/pr-review.ts';
14
+ import type { PlatformClient } from '../platform/context.ts';
15
+ import { parseTaskFrontmatter } from './frontmatter.ts';
16
+ import { taskIssueIdentity } from '../platform/task-identities.ts';
17
+ import { parseResourceIdentity, resourceIdentityEquals } from '../platform/resource-identity.ts';
18
+ import { operationId } from './platform-operation-journal.ts';
19
+ import { canonicalizeSummaryBody } from '../platform/comment-safety.ts';
20
+ import { platformResult } from '../platform/types.ts';
21
+ import type { PlatformResult } from '../platform/types.ts';
22
+ import { resolveTaskRef } from './resolve-ref.ts';
23
+ import { readPlatformOperationJournal, recordPlatformOperation } from './platform-operation-journal.ts';
24
+
25
+ type RecoveryOptions = Readonly<{ agent: string; client?: PlatformClient; cwd?: string; limit?: number; excludeId?: string }>;
26
+ type RecoveryResult = Readonly<{
27
+ status: 'applied' | 'no-op' | 'blocked' | 'failed';
28
+ changed: boolean;
29
+ recovered: readonly string[];
30
+ pending: readonly string[];
31
+ error: { code: string; message: string; retryable: boolean } | null;
32
+ }>;
33
+
34
+ function labelsMatchOwnedPrefix(actual: readonly string[], expected: readonly string[], prefix: 'status:' | 'in:'): boolean {
35
+ return actual.filter((label) => label.startsWith(prefix)).sort().join('\0')
36
+ === expected.filter((label) => label.startsWith(prefix)).sort().join('\0');
37
+ }
38
+
39
+ function fieldsMatchExpected(
40
+ actual: Readonly<Record<string, string | number | null>>,
41
+ expected: Readonly<Record<string, string | number | null>>
42
+ ): boolean {
43
+ return Object.entries(expected).every(([name, value]) => actual[name] === value);
44
+ }
45
+
46
+ function result(status: RecoveryResult['status'], recovered: string[], pending: string[], error: RecoveryResult['error'] = null): RecoveryResult {
47
+ return { status, changed: recovered.length > 0, recovered, pending, error };
48
+ }
49
+
50
+ function summaryPayload(taskDir: string, taskId: string): { body: string; sha256: string } | null {
51
+ const file = path.join(taskDir, '.delivery-summary.json');
52
+ if (!fs.existsSync(file)) return null;
53
+ const data = JSON.parse(fs.readFileSync(file, 'utf8')) as { taskId?: unknown; body?: unknown; sha256?: unknown };
54
+ const canonical = typeof data.body === 'string' ? canonicalizeSummaryBody(data.body) : null;
55
+ if (data.taskId !== taskId || typeof data.body !== 'string' || typeof data.sha256 !== 'string'
56
+ || !canonical?.ok || canonical.value !== data.body
57
+ || createHash('sha256').update(data.body).digest('hex') !== data.sha256) {
58
+ throw Object.assign(new Error('delivery summary staging record is invalid'), { code: 'SUMMARY_STAGING_INVALID' });
59
+ }
60
+ return { body: data.body, sha256: data.sha256 };
61
+ }
62
+
63
+ async function replayComment(taskId: string, taskDir: string, operation: ReturnType<typeof readPlatformOperationJournal>['operations'][number], agent: string, cwd: string, client?: PlatformClient): Promise<PlatformResult> {
64
+ if (operation.kind === 'task-comment') {
65
+ return syncPlatformComment(taskId, { kind: 'task', agent, cwd, client, dependency: operation.dependency, skipQueue: true });
66
+ }
67
+ if (operation.kind === 'artifact-comment') {
68
+ return syncPlatformComment(taskId, { kind: 'artifact', artifact: operation.target, agent, cwd, client, dependency: operation.dependency, skipQueue: true });
69
+ }
70
+ if (operation.kind === 'summary-comment') {
71
+ const summary = summaryPayload(taskDir, taskId);
72
+ if (!summary) return platformResult('failed', { error: { code: 'SUMMARY_STAGING_MISSING', message: 'delivery summary staging record is unavailable', retryable: false } });
73
+ return syncPlatformComment(taskId, {
74
+ kind: 'summary', body: summary.body, agent, cwd,
75
+ summaryAuthorization: { sha256: summary.sha256 }, dependency: operation.dependency, client, skipQueue: true
76
+ });
77
+ }
78
+ if (operation.kind === 'cancel-comment') {
79
+ return syncPlatformComment(taskId, { kind: 'cancel', agent, cwd, client, dependency: operation.dependency, skipQueue: true });
80
+ }
81
+ return platformResult('failed', { error: { code: 'PLATFORM_OPERATION_UNSUPPORTED', message: `operation kind '${operation.kind}' has no registered recovery handler`, retryable: false } });
82
+ }
83
+
84
+ function currentIssueMetadataOperation(taskId: string, taskMdPath: string, repoRoot: string, operation: ReturnType<typeof readPlatformOperationJournal>['operations'][number]) {
85
+ if (operation.kind !== 'issue-metadata' || !operation.issueMetadata) return null;
86
+ const content = fs.readFileSync(taskMdPath, 'utf8');
87
+ const frontmatter = parseTaskFrontmatter(content);
88
+ const identity = taskIssueIdentity(frontmatter);
89
+ if (!identity) return null;
90
+ let fromDiffFiles: string[] | undefined;
91
+ let mappingDigest: string | undefined;
92
+ if (operation.issueMetadata.inLabels === 'from-diff') {
93
+ const taskBase = typeof frontmatter.delivery_base_ref === 'string' ? frontmatter.delivery_base_ref.trim() : '';
94
+ if (!taskBase || (operation.issueMetadata.base && operation.issueMetadata.base !== taskBase)) return null;
95
+ try {
96
+ fromDiffFiles = execFileSync('git', ['diff', `${taskBase}...HEAD`, '--name-only'], {
97
+ cwd: repoRoot, encoding: 'utf8'
98
+ }).trim().split(/\r?\n/).filter(Boolean).sort();
99
+ const config = JSON.parse(fs.readFileSync(path.join(repoRoot, '.agents', '.airc.json'), 'utf8')) as { labels?: { in?: unknown } };
100
+ const mapping = inLabelMappingDigest(config.labels?.in);
101
+ if (!mapping.ok) return null;
102
+ mappingDigest = mapping.digest;
103
+ } catch { return null; }
104
+ }
105
+ const currentMetadata = {
106
+ ...operation.issueMetadata,
107
+ ...(fromDiffFiles ? { fromDiffFiles } : {}),
108
+ ...(mappingDigest ? { inLabelMappingDigest: mappingDigest } : {})
109
+ };
110
+ const target = JSON.stringify(identity);
111
+ const expectedDigest = createHash('sha256').update(JSON.stringify({
112
+ ...currentMetadata,
113
+ task: taskId,
114
+ taskContent: createHash('sha256').update(content).digest('hex')
115
+ })).digest('hex');
116
+ return { kind: 'issue-metadata' as const, target, expectedDigest, issueMetadata: currentMetadata, id: operationId({ kind: 'issue-metadata', target, expectedDigest }) };
117
+ }
118
+
119
+ async function replayIssueMetadata(taskId: string, operation: ReturnType<typeof readPlatformOperationJournal>['operations'][number], agent: string, cwd: string, client?: PlatformClient): Promise<PlatformResult> {
120
+ if (!operation.issueMetadata) return platformResult('failed', { error: { code: 'PLATFORM_OPERATION_PAYLOAD_INVALID', message: 'Issue metadata recovery parameters are missing', retryable: false } });
121
+ let expectedIdentity;
122
+ try { expectedIdentity = parseResourceIdentity(JSON.parse(operation.target), 'journal Issue identity'); }
123
+ catch { return platformResult('failed', { error: { code: 'PLATFORM_OPERATION_PAYLOAD_INVALID', message: 'Journal Issue identity is invalid', retryable: false } }); }
124
+ const before = await inspectPlatformIssue(taskId, { cwd, client });
125
+ if (before.status !== 'no-op' || before.error || !before.issue
126
+ || !resourceIdentityEquals(before.issue.identity, expectedIdentity)) {
127
+ return platformResult('blocked', { error: before.error ?? { code: 'PLATFORM_OPERATION_IDENTITY_MISMATCH', message: 'Bound Issue identity differs from the journal target', retryable: false } });
128
+ }
129
+ const sync = await syncPlatformIssue(taskId, { ...operation.issueMetadata, agent, cwd, client, dependency: operation.dependency, skipQueue: true });
130
+ if ((sync.status !== 'applied' && sync.status !== 'no-op') || sync.error) return sync;
131
+ const after = await inspectPlatformIssue(taskId, { cwd, client });
132
+ if (after.status !== 'no-op' || after.error || !after.issue
133
+ || !resourceIdentityEquals(after.issue.identity, expectedIdentity)) {
134
+ return platformResult('blocked', { error: after.error ?? { code: 'PLATFORM_OPERATION_IDENTITY_MISMATCH', message: 'Issue identity changed during metadata recovery', retryable: false } });
135
+ }
136
+ const issue = after.issue;
137
+ const metadata = operation.issueMetadata;
138
+ const failedOperation = sync.operations.find((item) => item.status === 'failed' || item.status === 'skipped');
139
+ if (failedOperation) return platformResult('blocked', { error: {
140
+ code: failedOperation.reasonCode || 'PLATFORM_OPERATION_METADATA_UNCONFIRMED',
141
+ message: `Issue metadata recovery did not confirm operation '${failedOperation.name}'`, retryable: true
142
+ } });
143
+ const unconfirmed = sync.operations.some((item) => {
144
+ if (!('value' in item) || item.value === undefined) return false;
145
+ if (item.name === 'labels:status' || item.name === 'labels:in') {
146
+ const prefix = item.name === 'labels:status' ? 'status:' : 'in:';
147
+ return !labelsMatchOwnedPrefix(issue!.labels, item.value as string[], prefix);
148
+ }
149
+ if (item.name === 'assignees') return [...issue!.assignees].sort().join('\0') !== [...item.value as string[]].sort().join('\0');
150
+ if (item.name === 'milestone') return issue!.milestone !== item.value;
151
+ if (item.name === 'requirements') return issue!.body !== item.value;
152
+ if (item.name === 'state') return issue!.state !== item.value;
153
+ if (item.name === 'issue-type') return issue!.issueType !== item.value;
154
+ if (item.name === 'fields') return !fieldsMatchExpected(issue!.fields, item.value as Record<string, string | number | null>);
155
+ return false;
156
+ }) || (metadata.state !== undefined && issue!.state !== metadata.state)
157
+ || (metadata.assignees === 'none' && issue!.assignees.length !== 0)
158
+ || (metadata.milestone === 'none' && issue!.milestone !== null);
159
+ if (unconfirmed) return platformResult('blocked', { error: {
160
+ code: 'PLATFORM_OPERATION_METADATA_UNCONFIRMED',
161
+ message: 'Issue metadata differs from the requested recovery target after reread', retryable: true
162
+ } });
163
+ return platformResult('no-op', { changed: sync.changed || after.changed });
164
+ }
165
+
166
+ async function verifyIssueCreate(taskId: string, operation: ReturnType<typeof readPlatformOperationJournal>['operations'][number], cwd: string, client?: PlatformClient): Promise<PlatformResult> {
167
+ if (!operation.issueCreate) return platformResult('failed', { error: {
168
+ code: 'PLATFORM_OPERATION_PAYLOAD_INVALID', message: 'Issue creation recovery identity is missing', retryable: false
169
+ } });
170
+ const current = await inspectPlatformIssue(taskId, { cwd, client });
171
+ if (!current.issue) return platformResult('blocked', { error: current.error ?? {
172
+ code: 'PLATFORM_OPERATION_TARGET_UNAVAILABLE',
173
+ message: 'Issue creation outcome is unknown and the task has no bound Issue identity; bind the verified remote Issue before continuing',
174
+ retryable: true
175
+ } });
176
+ if (current.issue.title !== operation.issueCreate.title
177
+ || createHash('sha256').update(current.issue.body).digest('hex') !== operation.issueCreate.bodyDigest) return platformResult('blocked', { error: {
178
+ code: 'PLATFORM_OPERATION_IDENTITY_MISMATCH', message: 'Bound Issue does not match the queued creation intent', retryable: false
179
+ } });
180
+ return platformResult('no-op');
181
+ }
182
+
183
+ async function replayPullRequestSummary(taskId: string, operation: ReturnType<typeof readPlatformOperationJournal>['operations'][number], agent: string, cwd: string, client?: PlatformClient): Promise<PlatformResult> {
184
+ let identity;
185
+ try { identity = parseResourceIdentity(JSON.parse(operation.target), 'queued pull-request summary identity'); }
186
+ catch { return platformResult('failed', { error: { code: 'PLATFORM_OPERATION_PAYLOAD_INVALID', message: 'Queued pull-request summary identity is invalid', retryable: false } }); }
187
+ const resolved = await resolvePlatformProviderContext({ cwd, client });
188
+ if (!resolved.ok) return resolved.context;
189
+ const listed = resolved.value.provider.comments?.list
190
+ ? await resolved.value.provider.comments.list({ context: providerOperationContext(resolved.value), parent: identity })
191
+ : unsupportedProviderOperation(resolved.value.provider, 'comments.list');
192
+ if (!listed.ok) return platformResult(listed.error.retryable ? 'blocked' : 'failed', { error: providerError(listed.error, 'PLATFORM_PROVIDER_OPERATION_FAILED') });
193
+ const marker = `<!-- sync-pr:${taskId}:summary -->`;
194
+ const summaries = listed.value.filter((comment) => normalizeCommentContent(comment.body).split('\n', 1)[0] === marker);
195
+ if (summaries.length > 1) return platformResult('blocked', { error: {
196
+ code: 'PR_SUMMARY_MARKER_AMBIGUOUS', message: 'Multiple pull-request comments contain the queued summary marker', retryable: false
197
+ } });
198
+ if (summaries.length === 1 && createHash('sha256').update(normalizeCommentContent(summaries[0]!.body)).digest('hex') === operation.expectedDigest) {
199
+ return platformResult('no-op');
200
+ }
201
+ if (!operation.pullRequestSummary) return platformResult('blocked', { error: {
202
+ code: 'PLATFORM_OPERATION_PAYLOAD_INVALID', message: 'Pull-request summary recovery content is missing', retryable: false
203
+ } });
204
+ const bound = await inspectPlatformPullRequest(taskId, { cwd, client });
205
+ if (!bound.pullRequest || !resourceIdentityEquals(bound.pullRequest.identity, identity)) return platformResult('blocked', { error: bound.error ?? {
206
+ code: 'PLATFORM_OPERATION_IDENTITY_MISMATCH', message: 'Bound pull request differs from the queued summary target', retryable: false
207
+ } });
208
+ const replayed = await syncPullRequestSummary(taskId, {
209
+ agent,
210
+ cwd,
211
+ client,
212
+ body: operation.pullRequestSummary.body,
213
+ changeReportFile: operation.pullRequestSummary.changeReportFile,
214
+ primaryResult: 'no_op',
215
+ strict: true,
216
+ skipQueue: true,
217
+ lockAlreadyHeld: true
218
+ });
219
+ if ((replayed.status === 'applied' || replayed.status === 'no-op') && !replayed.error && replayed.warnings.length === 0) {
220
+ return platformResult(replayed.status, { changed: replayed.changed });
221
+ }
222
+ return platformResult('blocked', { error: replayed.error ?? {
223
+ code: 'PR_SUMMARY_RECOVERY_UNCONFIRMED', message: 'Pull-request summary replay did not confirm the requested remote state', retryable: true
224
+ } });
225
+ }
226
+
227
+ async function replayPullRequestReview(operation: ReturnType<typeof readPlatformOperationJournal>['operations'][number], agent: string, cwd: string, client?: PlatformClient): Promise<PlatformResult> {
228
+ const intent = operation.pullRequestReview;
229
+ if (!intent) return platformResult('failed', { error: { code: 'PLATFORM_OPERATION_PAYLOAD_INVALID', message: 'Pull-request review recovery parameters are missing', retryable: false } });
230
+ let target;
231
+ try { target = parseResourceIdentity(JSON.parse(operation.target), 'queued pull-request review identity'); }
232
+ catch { return platformResult('failed', { error: { code: 'PLATFORM_OPERATION_PAYLOAD_INVALID', message: 'Queued pull-request review identity is invalid', retryable: false } }); }
233
+ if (!intent.resource || !resourceIdentityEquals(target, intent.resource)) return platformResult('failed', { error: {
234
+ code: 'PLATFORM_OPERATION_IDENTITY_MISMATCH', message: 'Pull-request review identity differs from the journal target', retryable: false
235
+ } });
236
+ let body: string;
237
+ try { body = readReviewBodyFile(intent.scope, intent.round, intent.artifactFile, intent.bodyDigest, cwd); }
238
+ catch { return platformResult('failed', { error: {
239
+ code: 'PLATFORM_OPERATION_PAYLOAD_INVALID', message: 'Canonical pull-request review body is unavailable or does not match the queued digest', retryable: false
240
+ } }); }
241
+ return publishPrReview({
242
+ cwd,
243
+ client,
244
+ agent,
245
+ prNumber: intent.prNumber,
246
+ expectedResource: target,
247
+ expectedProviderScopeId: intent.providerScopeId,
248
+ identity: { scope: intent.scope, round: intent.round, commitSha: intent.commitSha, ...(intent.resource ? { resource: intent.resource } : {}) },
249
+ event: intent.event,
250
+ body,
251
+ recoveryArtifact: intent.artifactFile,
252
+ skipQueue: true
253
+ });
254
+ }
255
+
256
+ async function replayPullRequest(taskId: string, operation: ReturnType<typeof readPlatformOperationJournal>['operations'][number], agent: string, cwd: string): Promise<PlatformResult> {
257
+ const intent = operation.pullRequest;
258
+ if (!intent) return platformResult('failed', { error: { code: 'PLATFORM_OPERATION_PAYLOAD_INVALID', message: 'Pull-request recovery parameters are missing', retryable: false } });
259
+ if (intent.action === 'bind') {
260
+ if (!intent.prToken) return platformResult('failed', { error: { code: 'PLATFORM_OPERATION_PAYLOAD_INVALID', message: 'Pull-request bind identity is missing', retryable: false } });
261
+ return bindPlatformPullRequest(taskId, { agent, cwd, pr: intent.prToken, skipQueue: true });
262
+ }
263
+ if (intent.action === 'sync') {
264
+ const synced = await syncPlatformPullRequest(taskId, {
265
+ agent, cwd, metadata: intent.metadata === true, closingIssue: intent.closingIssue === true, primaryResult: 'no_op', skipQueue: true
266
+ });
267
+ if (synced.warnings.length > 0 || synced.result?.endsWith('_with_warnings')) {
268
+ return platformResult('blocked', { error: { code: 'PLATFORM_OPERATION_PR_SYNC_UNCONFIRMED', message: 'Pull-request metadata sync remains degraded', retryable: true } });
269
+ }
270
+ return synced;
271
+ }
272
+ return recoverCreatedPullRequest(taskId, { agent, cwd, base: intent.baseRef || '', head: intent.headRef || '', skipQueue: true });
273
+ }
274
+
275
+ async function recoverPlatformOperations(
276
+ taskRef: string,
277
+ selection: 'required' | 'deferred' | 'all',
278
+ options: RecoveryOptions
279
+ ): Promise<RecoveryResult> {
280
+ const resolved = resolveTaskRef(taskRef, options.cwd ? { repoRoot: options.cwd } : {});
281
+ if (!resolved.ok) return result('failed', [], [], { code: resolved.code, message: resolved.message, retryable: false });
282
+ let journal;
283
+ try { journal = readPlatformOperationJournal(resolved.taskId, resolved.repoRoot); }
284
+ catch (error) {
285
+ const value = error as { code?: string; message?: string };
286
+ return result('failed', [], [], { code: value.code || 'PLATFORM_OPERATION_JOURNAL_INVALID', message: value.message || String(error), retryable: false });
287
+ }
288
+ const candidates = journal.operations.filter((operation) => operation.id !== options.excludeId
289
+ && (operation.state === 'queued' || operation.state === 'pending' || operation.state === 'unknown'
290
+ || (operation.state === 'failed' && operation.lastCode !== 'PLATFORM_OPERATION_SUPERSEDED')))
291
+ .filter((operation) => selection === 'all' || operation.dependency === selection)
292
+ .sort((left, right) => left.updatedAt.localeCompare(right.updatedAt));
293
+ if (!candidates.length) return result('no-op', [], []);
294
+ const limit = options.limit === undefined ? candidates.length : Math.max(1, options.limit);
295
+ const recovered: string[] = [];
296
+ const pending: string[] = [];
297
+ const selected = candidates.slice(0, limit);
298
+ for (const [index, operation] of selected.entries()) {
299
+ if (operation.state === 'failed') {
300
+ pending.push(operation.id);
301
+ return result('blocked', recovered, pending, {
302
+ code: operation.lastCode || 'PLATFORM_OPERATION_FAILED',
303
+ message: 'A previous platform operation failed permanently; later writes remain queued',
304
+ retryable: false
305
+ });
306
+ }
307
+ if (operation.attempts >= operation.maxAttempts) {
308
+ pending.push(operation.id, ...selected.slice(index + 1).map((next) => next.id));
309
+ pending.push(...candidates.slice(limit).map((next) => next.id));
310
+ return result('blocked', recovered, pending, {
311
+ code: operation.lastCode || 'PLATFORM_OPERATION_RETRY_LIMIT_REACHED',
312
+ message: 'A previous platform operation exhausted its recovery attempts; later writes remain queued',
313
+ retryable: false
314
+ });
315
+ }
316
+ if (operation.kind !== 'issue-create') {
317
+ try {
318
+ recordPlatformOperation({
319
+ taskRef: resolved.taskId, cwd: resolved.repoRoot, kind: operation.kind,
320
+ target: operation.target, expectedDigest: operation.expectedDigest,
321
+ dependency: operation.dependency, state: 'pending',
322
+ ...(operation.issueMetadata ? { issueMetadata: operation.issueMetadata } : {}),
323
+ ...(operation.issueCreate ? { issueCreate: operation.issueCreate } : {}),
324
+ ...(operation.pullRequest ? { pullRequest: operation.pullRequest } : {}),
325
+ ...(operation.pullRequestSummary ? { pullRequestSummary: operation.pullRequestSummary } : {}),
326
+ ...(operation.pullRequestReview ? { pullRequestReview: operation.pullRequestReview } : {})
327
+ });
328
+ } catch (error) {
329
+ const value = error as { code?: string; message?: string };
330
+ return result('blocked', recovered, [...pending, operation.id], {
331
+ code: value.code || 'PLATFORM_OPERATION_JOURNAL_WRITE_FAILED',
332
+ message: value.message || String(error), retryable: true
333
+ });
334
+ }
335
+ }
336
+ let targetOperation = operation;
337
+ if (operation.kind === 'issue-metadata') {
338
+ const current = currentIssueMetadataOperation(resolved.taskId, resolved.taskMdPath, resolved.repoRoot, operation);
339
+ if (!current) {
340
+ pending.push(operation.id);
341
+ return result('blocked', recovered, pending, { code: 'PLATFORM_OPERATION_TARGET_UNAVAILABLE', message: 'Current Issue metadata target cannot be reconstructed safely', retryable: true });
342
+ }
343
+ if (current.target !== operation.target) {
344
+ return result('blocked', recovered, [...pending, operation.id], { code: 'PLATFORM_OPERATION_IDENTITY_MISMATCH', message: 'Bound Issue identity differs from the journal target', retryable: false });
345
+ }
346
+ if (current.id !== operation.id) {
347
+ try {
348
+ recordPlatformOperation({ taskRef: resolved.taskId, cwd: resolved.repoRoot, kind: operation.kind, target: operation.target,
349
+ expectedDigest: operation.expectedDigest, issueMetadata: operation.issueMetadata, dependency: operation.dependency,
350
+ state: 'failed', lastCode: 'PLATFORM_OPERATION_SUPERSEDED' });
351
+ targetOperation = { ...operation, expectedDigest: current.expectedDigest, issueMetadata: current.issueMetadata, id: current.id };
352
+ } catch (error) {
353
+ const value = error as { code?: string; message?: string };
354
+ return result('blocked', recovered, [...pending, operation.id], { code: value.code || 'PLATFORM_OPERATION_JOURNAL_WRITE_FAILED', message: value.message || String(error), retryable: true });
355
+ }
356
+ }
357
+ }
358
+ if (operation.kind === 'task-comment' || operation.kind === 'artifact-comment'
359
+ || operation.kind === 'summary-comment' || operation.kind === 'cancel-comment') {
360
+ const commentOptions = operation.kind === 'task-comment'
361
+ ? { kind: 'task' as const, agent: options.agent, cwd: resolved.repoRoot, dependency: operation.dependency }
362
+ : operation.kind === 'artifact-comment'
363
+ ? { kind: 'artifact' as const, artifact: operation.target, agent: options.agent, cwd: resolved.repoRoot, dependency: operation.dependency }
364
+ : operation.kind === 'summary-comment' ? (() => {
365
+ const summary = summaryPayload(resolved.taskDir, resolved.taskId);
366
+ return summary ? { kind: 'summary' as const, body: summary.body, agent: options.agent, cwd: resolved.repoRoot, summaryAuthorization: { sha256: summary.sha256 }, dependency: operation.dependency } : null;
367
+ })() : { kind: 'cancel' as const, agent: options.agent, cwd: resolved.repoRoot, dependency: operation.dependency };
368
+ const current = commentOptions ? inspectPlatformCommentOperation(resolved.taskId, commentOptions) : null;
369
+ if (!current) {
370
+ pending.push(operation.id);
371
+ return result('blocked', recovered, pending, { code: 'PLATFORM_OPERATION_TARGET_UNAVAILABLE', message: 'Current comment target cannot be reconstructed safely', retryable: true });
372
+ }
373
+ if (current.id !== operation.id) {
374
+ try {
375
+ recordPlatformOperation({ taskRef: resolved.taskId, cwd: resolved.repoRoot, kind: operation.kind, target: operation.target,
376
+ expectedDigest: operation.expectedDigest, dependency: operation.dependency, state: 'failed', lastCode: 'PLATFORM_OPERATION_SUPERSEDED' });
377
+ targetOperation = { ...operation, expectedDigest: current.expectedDigest, id: current.id };
378
+ } catch (error) {
379
+ const value = error as { code?: string; message?: string };
380
+ return result('blocked', recovered, [...pending, operation.id], { code: value.code || 'PLATFORM_OPERATION_JOURNAL_WRITE_FAILED', message: value.message || String(error), retryable: true });
381
+ }
382
+ }
383
+ }
384
+ let remote: PlatformResult;
385
+ try {
386
+ remote = operation.kind === 'issue-create'
387
+ ? await verifyIssueCreate(resolved.taskId, operation, resolved.repoRoot)
388
+ : operation.kind === 'pull-request-summary'
389
+ ? await replayPullRequestSummary(resolved.taskId, operation, options.agent, resolved.repoRoot, options.client)
390
+ : operation.kind === 'pull-request-review'
391
+ ? await replayPullRequestReview(operation, options.agent, resolved.repoRoot, options.client)
392
+ : operation.kind === 'issue-metadata'
393
+ ? await replayIssueMetadata(resolved.taskId, targetOperation, options.agent, resolved.repoRoot)
394
+ : operation.kind === 'pull-request'
395
+ ? await replayPullRequest(resolved.taskId, targetOperation, options.agent, resolved.repoRoot)
396
+ : await replayComment(resolved.taskId, resolved.taskDir, operation, options.agent, resolved.repoRoot);
397
+ }
398
+ catch (error) {
399
+ const value = error as { code?: string; message?: string };
400
+ remote = platformResult('blocked', { error: { code: value.code || 'PLATFORM_OPERATION_RECOVERY_FAILED', message: value.message || String(error), retryable: true } });
401
+ }
402
+ const succeeded = (remote.status === 'applied' || remote.status === 'no-op') && !remote.error;
403
+ try {
404
+ recordPlatformOperation({
405
+ taskRef: resolved.taskId,
406
+ cwd: resolved.repoRoot,
407
+ kind: operation.kind,
408
+ target: operation.target,
409
+ expectedDigest: targetOperation.expectedDigest,
410
+ ...(targetOperation.issueMetadata ? { issueMetadata: targetOperation.issueMetadata } : {}),
411
+ ...(operation.issueCreate ? { issueCreate: operation.issueCreate } : {}),
412
+ ...(operation.pullRequest ? { pullRequest: operation.pullRequest } : {}),
413
+ ...(operation.pullRequestSummary ? { pullRequestSummary: operation.pullRequestSummary } : {}),
414
+ ...(operation.pullRequestReview ? { pullRequestReview: operation.pullRequestReview } : {}),
415
+ dependency: operation.dependency,
416
+ state: succeeded ? 'succeeded' : remote.status === 'failed' && remote.error?.retryable === false ? 'failed' : 'unknown',
417
+ lastCode: remote.error?.code ?? null
418
+ });
419
+ } catch (error) {
420
+ const value = error as { code?: string; message?: string };
421
+ return result('blocked', recovered, [...pending, operation.id], { code: value.code || 'PLATFORM_OPERATION_JOURNAL_WRITE_FAILED', message: value.message || String(error), retryable: true });
422
+ }
423
+ if (!succeeded) {
424
+ pending.push(targetOperation.id);
425
+ return result(remote.status === 'failed' ? 'failed' : 'blocked', recovered, pending, remote.error ?? { code: 'PLATFORM_OPERATION_RECOVERY_UNKNOWN', message: 'Platform operation outcome remains unknown', retryable: true });
426
+ }
427
+ recovered.push(targetOperation.id);
428
+ }
429
+ const remaining = candidates.slice(limit).map((operation) => operation.id);
430
+ pending.push(...remaining);
431
+ return result(pending.length ? 'blocked' : recovered.length ? 'applied' : 'no-op', recovered, pending, pending.length ? { code: 'PLATFORM_OPERATION_RECOVERY_PENDING', message: 'Platform operations remain pending after the recovery budget', retryable: true } : null);
432
+ }
433
+
434
+ export { fieldsMatchExpected, labelsMatchOwnedPrefix, recoverPlatformOperations };
435
+ export type { RecoveryOptions, RecoveryResult };
@@ -13,7 +13,7 @@ type VerificationEvent =
13
13
  | 'review-analysis.completed' | 'plan.completed' | 'review-plan.completed'
14
14
  | 'code.completed' | 'review-code.completed' | 'manual-validation.completed' | 'validation-run.completed'
15
15
  | 'block-task.completed' | 'cancel-task.completed' | 'commit.completed'
16
- | 'complete-task.preflight' | 'complete-task.hard-preflight' | 'complete-task.prepared' | 'complete-task.completed'
16
+ | 'complete-task.preflight' | 'complete-task.hard-preflight' | 'complete-task.completed'
17
17
  | 'create-pr.completed' | 'create-task.completed'
18
18
  | 'import-codescan.completed' | 'import-dependabot.completed'
19
19
  | 'import-issue.completed' | 'watch-pr.completed'
@@ -68,7 +68,6 @@ const VERIFICATION_CATALOG: Readonly<Record<VerificationEvent, VerificationSpec>
68
68
  'commit.completed': gate('commit', 'active'),
69
69
  'complete-task.preflight': { skill: 'complete-task', expectedState: 'active', mode: 'checks', checks: ['required-pr-delivery'] },
70
70
  'complete-task.hard-preflight': { skill: 'complete-task', expectedState: 'active', mode: 'checks', checks: ['required-pr-delivery'] },
71
- 'complete-task.prepared': gate('complete-task', 'active'),
72
71
  'complete-task.completed': gate('complete-task', 'completed'),
73
72
  'create-pr.completed': gate('create-pr', 'active'),
74
73
  'create-task.completed': gate('create-task', 'active'),
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs';
2
+ import path from 'node:path';
2
3
 
3
4
  import { resolveTaskRef } from './resolve-ref.ts';
4
5
  import { parseTaskFrontmatter } from './frontmatter.ts';
@@ -142,7 +143,14 @@ function projectFinalizationWarning(
142
143
  if (!resolved.ok) return { status: 'failed', changed: false, error: { code: resolved.code, message: resolved.message } };
143
144
  let prepared = resolved.state === 'completed';
144
145
  if (!prepared && resolved.state === 'active') {
145
- try { prepared = parseTaskFrontmatter(fs.readFileSync(resolved.taskMdPath, 'utf8')).status === 'completed'; }
146
+ try {
147
+ const taskContent = fs.readFileSync(resolved.taskMdPath, 'utf8');
148
+ prepared = parseTaskFrontmatter(taskContent).status === 'completed';
149
+ if (!prepared) {
150
+ const lifecycle = JSON.parse(fs.readFileSync(path.join(resolved.taskDir, '.task-lifecycle.json'), 'utf8')) as Record<string, unknown>;
151
+ prepared = lifecycle.taskId === resolved.taskId && lifecycle.intent === 'complete';
152
+ }
153
+ }
146
154
  catch { prepared = false; }
147
155
  }
148
156
  if (!prepared) {
package/lib/update.ts CHANGED
@@ -27,7 +27,6 @@ type UpdateConfig = {
27
27
  labels?: Record<string, unknown>;
28
28
  files?: Partial<FileRegistry>;
29
29
  agentClients?: unknown;
30
- customTUIs?: unknown;
31
30
  };
32
31
 
33
32
  type Defaults = {
@@ -189,8 +188,8 @@ async function cmdSync(): Promise<void> {
189
188
  ok('Seed files synced successfully!');
190
189
  console.log('');
191
190
  if (workflowPlan.nextSteps.length === 0) {
192
- console.log(' No Agent Client project integration enabled.');
193
- console.log(` Configure "customTUIs" in ${CONFIG_PATH} if needed.`);
191
+ console.log(' No Agent Client or sandbox custom tool integration enabled.');
192
+ console.log(` Configure a custom tool in "sandbox.tools.definitions" and select it in "sandbox.tools.ids" in ${CONFIG_PATH}.`);
194
193
  console.log('');
195
194
  } else {
196
195
  console.log(' Next step: run the full update in your AI TUI:');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fitlab-ai/agent-infra",
3
- "version": "0.11.7",
3
+ "version": "0.11.9",
4
4
  "description": "Bootstrap tool for AI multi-tool collaboration infrastructure — works with Claude Code, Codex, Antigravity CLI, and OpenCode",
5
5
  "license": "MIT",
6
6
  "type": "module",