@fitlab-ai/agent-infra 0.10.0 → 0.11.0

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/README.md +2 -6
  2. package/README.zh-CN.md +2 -6
  3. package/bin/internal-cli.sh +0 -13
  4. package/dist/bin/internal-cli.js +11 -49
  5. package/dist/lib/internal/cli-route-inventory.js +3 -4
  6. package/dist/lib/internal/platform-comment.js +60 -2
  7. package/dist/lib/internal/task-artifact.js +1 -1
  8. package/dist/lib/internal/task-finalization.js +2 -2
  9. package/dist/lib/internal/task-lifecycle.js +2 -2
  10. package/dist/lib/internal/task-operation-registry.js +22 -12
  11. package/dist/lib/internal/task-orchestration.js +2 -2
  12. package/dist/lib/internal/task-review.js +10 -5
  13. package/dist/lib/platform/activity-recovery.js +159 -0
  14. package/dist/lib/platform/issue-comments.js +84 -32
  15. package/dist/lib/platform/task-comment-projection.js +70 -0
  16. package/dist/lib/platform/task-recovery.js +55 -0
  17. package/dist/lib/platform/verification-sync.js +43 -9
  18. package/dist/lib/sandbox/control/executor.js +4 -2
  19. package/dist/lib/sandbox/control/task-workflow.js +1 -1
  20. package/dist/lib/sandbox/control/workflow-executor.js +83 -21
  21. package/dist/lib/server/process-state.js +17 -5
  22. package/dist/lib/task/artifact-command.js +6 -3
  23. package/dist/lib/task/artifact-operations.js +1 -1
  24. package/dist/lib/task/artifact-recovery.js +114 -68
  25. package/dist/lib/task/artifact-repair-intent.js +25 -8
  26. package/dist/lib/task/control-authority.js +1 -1
  27. package/dist/lib/task/events.js +6 -6
  28. package/dist/lib/task/intermediate-cleanup.js +28 -3
  29. package/dist/lib/task/local-artifact-finalization.js +78 -14
  30. package/dist/lib/task/review-command.js +2 -2
  31. package/dist/lib/task/review-finalization.js +203 -10
  32. package/dist/lib/{host-control → task}/secure-fs.js +8 -19
  33. package/dist/lib/task/workflow-command.js +2 -0
  34. package/dist/lib/task/workflow-dispatch.js +161 -0
  35. package/dist/lifecycle-build-manifest.json +3 -3
  36. package/lib/internal/cli-route-inventory.ts +3 -4
  37. package/lib/internal/platform-comment.ts +53 -2
  38. package/lib/internal/task-artifact.ts +1 -1
  39. package/lib/internal/task-finalization.ts +1 -1
  40. package/lib/internal/task-lifecycle.ts +1 -1
  41. package/lib/internal/task-operation-registry.ts +22 -12
  42. package/lib/internal/task-orchestration.ts +1 -1
  43. package/lib/internal/task-review.ts +9 -5
  44. package/lib/platform/activity-recovery.ts +179 -0
  45. package/lib/platform/issue-comments.ts +98 -31
  46. package/lib/platform/task-comment-projection.ts +79 -0
  47. package/lib/platform/task-recovery.ts +54 -0
  48. package/lib/platform/verification-sync.ts +46 -13
  49. package/lib/sandbox/control/executor.ts +4 -2
  50. package/lib/sandbox/control/protocol.ts +1 -1
  51. package/lib/sandbox/control/task-workflow.ts +1 -1
  52. package/lib/sandbox/control/workflow-executor.ts +102 -21
  53. package/lib/server/process-state.ts +26 -5
  54. package/lib/task/artifact-command.ts +6 -4
  55. package/lib/task/artifact-operations.ts +1 -1
  56. package/lib/task/artifact-recovery.ts +120 -67
  57. package/lib/task/artifact-repair-intent.ts +38 -11
  58. package/lib/task/control-authority.ts +1 -1
  59. package/lib/task/events.ts +6 -6
  60. package/lib/task/intermediate-cleanup.ts +18 -2
  61. package/lib/task/local-artifact-finalization.ts +85 -11
  62. package/lib/task/review-command.ts +1 -1
  63. package/lib/task/review-finalization.ts +203 -8
  64. package/lib/{host-control → task}/secure-fs.ts +10 -32
  65. package/lib/task/workflow-command.ts +2 -0
  66. package/lib/task/workflow-dispatch.ts +171 -0
  67. package/package.json +1 -1
  68. package/templates/.agents/rules/issue-sync.en.md +9 -1
  69. package/templates/.agents/rules/issue-sync.zh-CN.md +10 -3
  70. package/templates/.agents/rules/local-artifact-repair.en.md +7 -6
  71. package/templates/.agents/rules/local-artifact-repair.zh-CN.md +10 -8
  72. package/templates/.agents/skills/code-task/SKILL.en.md +1 -1
  73. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +2 -2
  74. package/templates/.agents/skills/restore-task/SKILL.en.md +9 -4
  75. package/templates/.agents/skills/restore-task/SKILL.zh-CN.md +9 -4
  76. package/templates/.agents/skills/review-analysis/SKILL.en.md +1 -1
  77. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +1 -1
  78. package/templates/.agents/skills/review-code/SKILL.en.md +3 -2
  79. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +3 -2
  80. package/templates/.agents/skills/review-plan/SKILL.en.md +1 -1
  81. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +1 -1
  82. package/dist/lib/host-control/audit.js +0 -19
  83. package/dist/lib/host-control/client.js +0 -142
  84. package/dist/lib/host-control/command.js +0 -49
  85. package/dist/lib/host-control/path.js +0 -160
  86. package/dist/lib/host-control/server.js +0 -159
  87. package/dist/lib/host-control/service.js +0 -112
  88. package/dist/lib/internal/host-control.js +0 -28
  89. package/lib/host-control/audit.ts +0 -22
  90. package/lib/host-control/client.ts +0 -172
  91. package/lib/host-control/command.ts +0 -60
  92. package/lib/host-control/path.ts +0 -167
  93. package/lib/host-control/server.ts +0 -180
  94. package/lib/host-control/service.ts +0 -133
  95. package/lib/internal/host-control.ts +0 -28
@@ -14,11 +14,14 @@ import { allowsManualOverride } from './guard-override.ts';
14
14
  import type { ManualOverrideCapability } from './guard-override.ts';
15
15
  import { getArtifactSchema } from './artifact-schema.ts';
16
16
  import { canonicalSemanticDigest, inspectArtifactContract, sha256Content } from './artifact-operations.ts';
17
+ import { expectedQualificationRelations, validateQualificationAudit } from './qualification-audit.ts';
17
18
  import { readArtifactRecoveryIntent } from './artifact-repair-intent.ts';
18
19
  import {
19
20
  beginArtifactRecovery,
20
21
  commitArtifactRecovery,
22
+ prepareArtifactRecoveryFinal,
21
23
  prepareArtifactRecoveryCommit,
24
+ reconcileArtifactRecovery,
22
25
  recoveryContextFromIntent,
23
26
  stageArtifactCandidate
24
27
  } from './artifact-recovery.ts';
@@ -36,6 +39,7 @@ type ReviewFinalizationErrorCode =
36
39
  | 'REVIEW_SUMMARY_PLACEHOLDER_INVALID'
37
40
  | 'REVIEW_SUMMARY_COUNT_MISMATCH'
38
41
  | 'REVIEW_ARTIFACT_STRUCTURE_INVALID'
42
+ | 'REVIEW_ARTIFACT_QUALIFICATION_INVALID'
39
43
  | 'REVIEW_DECISION_DETAIL_INVALID'
40
44
  | 'REVIEW_ARTIFACT_CONFLICT'
41
45
  | 'REVIEW_PROVENANCE_INVALID'
@@ -73,6 +77,11 @@ type ReviewFinalizationResult = {
73
77
  }[];
74
78
  error: ReviewFinalizationError | null;
75
79
  };
80
+ type ReviewPreflightResult = Omit<ReviewFinalizationResult, 'status' | 'intent' | 'stageStatus'> & {
81
+ status: 'passed' | 'failed';
82
+ intent: 'preflight';
83
+ stageStatus: null;
84
+ };
76
85
  type ReviewFileSystem = {
77
86
  readFileSync: (file: string) => string;
78
87
  };
@@ -118,6 +127,149 @@ function failed(
118
127
  };
119
128
  }
120
129
 
130
+ function preflightFailed(
131
+ request: ReviewFinalizationRequest,
132
+ code: ReviewFinalizationErrorCode,
133
+ message: string,
134
+ taskId: string | null = null,
135
+ extra: Partial<ReviewPreflightResult> = {}
136
+ ): ReviewPreflightResult {
137
+ const { status: _status, intent: _intent, stageStatus: _stageStatus, ...finalizationExtra } = extra;
138
+ return {
139
+ ...failed(request, code, message, taskId, null, finalizationExtra),
140
+ status: 'failed',
141
+ intent: 'preflight',
142
+ stageStatus: null
143
+ };
144
+ }
145
+
146
+ /** Validate and seal a review draft before any finding-ledger write. */
147
+ function preflightReviewSummaryUnlocked(
148
+ request: ReviewFinalizationRequest,
149
+ options: ReviewFinalizationOptions = {}
150
+ ): ReviewPreflightResult {
151
+ const stage = request.stage as ReviewStage;
152
+ const spec = STAGES[stage];
153
+ if (!spec) return preflightFailed(request, 'REVIEW_STAGE_INVALID', `unsupported review stage '${request.stage}'`);
154
+ if (!request.taskRef || !request.artifact) return preflightFailed(request, 'REVIEW_PAYLOAD_INVALID', 'taskRef, stage, and artifact are required');
155
+ const resolved = resolveTaskRef(request.taskRef, { repoRoot: options.repoRoot });
156
+ if (!resolved.ok) return preflightFailed(request, resolved.code, resolved.message, resolved.taskId ?? null);
157
+ if (resolved.state !== 'active') return preflightFailed(request, 'TASK_STATE_MISMATCH', `task ${resolved.taskId} is ${resolved.state}, expected active`, resolved.taskId);
158
+ const parsed = parseArtifactName(request.artifact);
159
+ if (!parsed || parsed.family !== spec.family) return preflightFailed(request, 'REVIEW_ARTIFACT_IDENTITY_INVALID', `artifact '${request.artifact}' does not match ${spec.family}`, resolved.taskId);
160
+ const publicationError = validateArtifactPublication(resolved.taskDir, spec.family, request.artifact);
161
+ if (publicationError) return preflightFailed(request, 'REVIEW_ARTIFACT_IDENTITY_INVALID', publicationError.message, resolved.taskId);
162
+
163
+ let taskContent: string;
164
+ let content: string;
165
+ let recovery: ArtifactRecoveryContext | undefined;
166
+ let existingIntent;
167
+ try {
168
+ taskContent = fs.readFileSync(resolved.taskMdPath, 'utf8');
169
+ if (request.recoveryId) {
170
+ const intent = readArtifactRecoveryIntent(resolved.repoRoot, resolved.taskId, spec.family, request.artifact);
171
+ if (!intent || intent.recoveryOperationId !== request.recoveryId) return preflightFailed(request, 'REVIEW_PROVENANCE_INVALID', 'recovery id does not match the artifact journal', resolved.taskId);
172
+ if (!['awaiting-preflight-recovery', 'preflight-ready'].includes(intent.state)) return preflightFailed(request, 'REVIEW_PROVENANCE_INVALID', `recovery journal is in '${intent.state}' state`, resolved.taskId);
173
+ recovery = recoveryContextFromIntent(resolved.repoRoot, resolved.taskDir, intent);
174
+ content = fs.existsSync(recovery.stagingPath)
175
+ ? fs.readFileSync(recovery.stagingPath, 'utf8')
176
+ : fs.readFileSync(recovery.formalPath, 'utf8');
177
+ } else {
178
+ const validated = validateCompletedArtifact(resolved.taskDir, spec.family, request.artifact);
179
+ if (!validated.ok) return preflightFailed(request, validated.error.code === 'ARTIFACT_NOT_REGULAR' ? 'REVIEW_ARTIFACT_NOT_REGULAR' : 'REVIEW_ARTIFACT_IDENTITY_INVALID', validated.error.message, resolved.taskId);
180
+ content = fs.readFileSync(validated.artifact.path, 'utf8');
181
+ const intent = readArtifactRecoveryIntent(resolved.repoRoot, resolved.taskId, spec.family, request.artifact);
182
+ existingIntent = intent;
183
+ if (intent?.state === 'preflight-ready') recovery = recoveryContextFromIntent(resolved.repoRoot, resolved.taskDir, intent);
184
+ if (intent?.state === 'consumed') return preflightFailed(request, 'REVIEW_PROVENANCE_INVALID', 'review artifact was already finalized', resolved.taskId);
185
+ }
186
+ } catch (error) { return preflightFailed(request, 'REVIEW_ARTIFACT_NOT_REGULAR', String(error), resolved.taskId); }
187
+
188
+ if (!hasOpenArtifactRound(taskContent, spec.family, parsed.round)) return preflightFailed(request, 'REVIEW_ARTIFACT_IDENTITY_INVALID', `${request.artifact} does not have one matching open started review event`, resolved.taskId);
189
+ const execution = validateLifecycleExecution(request.taskRef, {
190
+ mode: request.orchestrated ? 'orchestrated' : 'standalone',
191
+ identity: { stage: spec.family, round: parsed.round, artifact: request.artifact, role: 'reviewer' },
192
+ dryRun: request.dryRun
193
+ }, { repoRoot: options.repoRoot });
194
+ if (!execution.ok) return preflightFailed(request, 'REVIEW_PROVENANCE_INVALID', `${execution.error?.code ?? 'ORCHESTRATION_PROVENANCE_MISMATCH'}: ${execution.error?.message ?? 'orchestration provenance validation failed'}`, resolved.taskId);
195
+
196
+ const artifactSha256 = sha256Content(content);
197
+ const semanticDigest = canonicalSemanticDigest(content);
198
+ const detail = inspectDecisionDetailDuplicates(content);
199
+ const structure = inspectArtifactContract(content, getArtifactSchema(spec.family)!);
200
+ const expected = expectedQualificationRelations(taskContent, spec.family);
201
+ if (!expected.ok) return preflightFailed(request, 'REVIEW_ARTIFACT_QUALIFICATION_INVALID', `${expected.code}: ${expected.message}`, resolved.taskId, { artifactSha256, semanticDigest });
202
+ const qualification = validateQualificationAudit(taskContent, content, {
203
+ family: spec.family,
204
+ artifact: request.artifact,
205
+ expectedUpstreamRelations: expected.relations
206
+ });
207
+ const qualificationError = qualification.ok ? null : qualification;
208
+ if (!detail.ok || !structure.ok || qualificationError) {
209
+ if (!recovery && !request.dryRun) {
210
+ try {
211
+ recovery = beginArtifactRecovery({
212
+ taskId: resolved.taskId, family: spec.family, artifact: request.artifact, round: parsed.round,
213
+ requestId: `review-preflight:${resolved.taskId}:${stage}:${parsed.round}`
214
+ }, Buffer.from(content, 'utf8'), { repoRoot: resolved.repoRoot, taskDir: resolved.taskDir, lockAlreadyHeld: options.lockAlreadyHeld });
215
+ } catch (error) { return preflightFailed(request, 'REVIEW_PROVENANCE_INVALID', String(error), resolved.taskId); }
216
+ }
217
+ const diagnostics = [
218
+ ...(!detail.ok ? [`${detail.code}: ${detail.message}`] : []),
219
+ ...structure.diagnostics.map((item) => `${item.code}: ${item.message}`),
220
+ ...(qualificationError ? [`${qualificationError.code}: ${qualificationError.message}`] : [])
221
+ ].join('; ');
222
+ return preflightFailed(request, !detail.ok ? 'REVIEW_DECISION_DETAIL_INVALID' : !structure.ok ? 'REVIEW_ARTIFACT_STRUCTURE_INVALID' : 'REVIEW_ARTIFACT_QUALIFICATION_INVALID', diagnostics, resolved.taskId, {
223
+ artifactSha256, semanticDigest,
224
+ ...(recovery ? { recovery: recoveryInfo(recovery) } : {})
225
+ });
226
+ }
227
+ if (!recovery && existingIntent?.state === 'passed') {
228
+ return {
229
+ ...preflightFailed(request, 'REVIEW_ARTIFACT_CONFLICT', '', resolved.taskId, { artifactSha256, semanticDigest }),
230
+ status: 'passed', changed: false, error: null
231
+ };
232
+ }
233
+ if (request.dryRun || recovery && readArtifactRecoveryIntent(resolved.repoRoot, resolved.taskId, spec.family, request.artifact)?.state === 'preflight-ready') {
234
+ return {
235
+ ...preflightFailed(request, 'REVIEW_ARTIFACT_CONFLICT', '', resolved.taskId, { artifactSha256, semanticDigest, ...(recovery ? { recovery: recoveryInfo(recovery) } : {}) }),
236
+ status: 'passed', changed: false, error: null
237
+ };
238
+ }
239
+ try {
240
+ recovery ??= beginArtifactRecovery({
241
+ taskId: resolved.taskId, family: spec.family, artifact: request.artifact, round: parsed.round,
242
+ requestId: `review-preflight:${resolved.taskId}:${stage}:${parsed.round}`
243
+ }, Buffer.from(content, 'utf8'), { repoRoot: resolved.repoRoot, taskDir: resolved.taskDir, lockAlreadyHeld: options.lockAlreadyHeld });
244
+ const staged = stageArtifactCandidate(recovery, Buffer.from(content, 'utf8'), { lockAlreadyHeld: options.lockAlreadyHeld });
245
+ prepareArtifactRecoveryCommit(recovery, staged.candidateSha256, staged.semanticDigest, { lockAlreadyHeld: options.lockAlreadyHeld });
246
+ return {
247
+ ...preflightFailed(request, 'REVIEW_ARTIFACT_CONFLICT', '', resolved.taskId, { artifactSha256, semanticDigest, recovery: recoveryInfo(recovery) }),
248
+ status: 'passed', changed: false, error: null
249
+ };
250
+ } catch (error) { return preflightFailed(request, 'REVIEW_PROVENANCE_INVALID', String(error), resolved.taskId, { artifactSha256, semanticDigest }); }
251
+ }
252
+
253
+ function preflightReviewSummary(
254
+ request: ReviewFinalizationRequest,
255
+ options: ReviewFinalizationOptions = {}
256
+ ): ReviewPreflightResult {
257
+ if (request.dryRun || options.lockAlreadyHeld) return preflightReviewSummaryUnlocked(request, options);
258
+ const resolved = resolveTaskRef(request.taskRef, { repoRoot: options.repoRoot });
259
+ if (!resolved.ok) return preflightReviewSummaryUnlocked(request, options);
260
+ try {
261
+ return withTaskExecutionLock(
262
+ resolved.repoRoot,
263
+ resolved.taskId,
264
+ 'task-review.preflight',
265
+ () => preflightReviewSummaryUnlocked(request, { ...options, lockAlreadyHeld: true })
266
+ );
267
+ } catch (error) {
268
+ if (!(error instanceof TaskExecutionLockError)) throw error;
269
+ return preflightFailed(request, 'REVIEW_PROVENANCE_INVALID', `${error.code}: ${error.message}`, resolved.taskId);
270
+ }
271
+ }
272
+
121
273
  type ReviewSummaryCandidatePreparation = Readonly<{
122
274
  result: ReviewFinalizationResult;
123
275
  content: string;
@@ -125,6 +277,15 @@ type ReviewSummaryCandidatePreparation = Readonly<{
125
277
  lockAlreadyHeld?: boolean;
126
278
  }>;
127
279
 
280
+ function recoveryInfo(recovery: ArtifactRecoveryContext): NonNullable<ReviewFinalizationResult['recovery']> {
281
+ return {
282
+ recoveryId: recovery.recoveryId,
283
+ candidatePath: recovery.stagingPath,
284
+ baselineSha256: recovery.baselineSha256,
285
+ baselineSemanticDigest: recovery.baselineSemanticDigest
286
+ };
287
+ }
288
+
128
289
  /** Prepare domain validation, summary bytes and provenance before any publication. */
129
290
  function prepareReviewSummaryCandidate(
130
291
  request: ReviewFinalizationRequest,
@@ -158,7 +319,7 @@ function prepareReviewSummaryCandidate(
158
319
  try {
159
320
  const intent = readArtifactRecoveryIntent(resolved.repoRoot, taskId!, spec.family, request.artifact);
160
321
  if (!intent || intent.recoveryOperationId !== request.recoveryId) return reject('REVIEW_PROVENANCE_INVALID', 'recovery id does not match the artifact journal');
161
- if (!['awaiting-recovery', 'finalize-ready', 'commit-started'].includes(intent.state)) return reject('REVIEW_PROVENANCE_INVALID', `recovery journal is in '${intent.state}' state`);
322
+ if (!['awaiting-preflight-recovery', 'preflight-ready', 'preflight-commit-started', 'preflight-passed', 'full-finalizer-ready', 'commit-started'].includes(intent.state)) return reject('REVIEW_PROVENANCE_INVALID', `recovery journal is in '${intent.state}' state`);
162
323
  recovery = recoveryContextFromIntent(resolved.repoRoot, resolved.taskDir, intent);
163
324
  artifactContent = fs.existsSync(recovery.stagingPath)
164
325
  ? fs.readFileSync(recovery.stagingPath, 'utf8')
@@ -171,11 +332,15 @@ function prepareReviewSummaryCandidate(
171
332
  let repairIntent;
172
333
  try { repairIntent = readArtifactRecoveryIntent(resolved.repoRoot, taskId!, spec.family, request.artifact); }
173
334
  catch (error) { return reject('REVIEW_PROVENANCE_INVALID', String(error), digests); }
174
- if (repairIntent?.state === 'passed' || repairIntent?.state === 'consumed') {
335
+ if (repairIntent?.state === 'consumed') {
175
336
  if (repairIntent.finalArtifactSha256 !== artifactSha256 || repairIntent.finalSemanticDigest !== semanticDigest) {
176
337
  return reject('REVIEW_PROVENANCE_INVALID', 'the review artifact changed after its finalization provenance was recorded', digests);
177
338
  }
178
339
  }
340
+ if (repairIntent?.state === 'passed'
341
+ && (repairIntent.finalArtifactSha256 !== artifactSha256 || repairIntent.finalSemanticDigest !== semanticDigest)) {
342
+ repairIntent = undefined;
343
+ }
179
344
  if (!hasOpenArtifactRound(taskContent, spec.family, parsed.round)) {
180
345
  return reject('REVIEW_ARTIFACT_IDENTITY_INVALID', `${request.artifact} does not have one matching open started review event`);
181
346
  }
@@ -223,6 +388,12 @@ function prepareReviewSummaryCandidate(
223
388
  const transformed = finalizeReviewSummaryContent(artifactContent, stageStatus.unresolvedFindingCounts);
224
389
  if (!transformed.ok) return reject(transformed.code, transformed.message);
225
390
  const finalDigests = { artifactSha256: sha256Content(transformed.content), semanticDigest: canonicalSemanticDigest(transformed.content) };
391
+ if (!recovery && repairIntent?.state === 'commit-started') {
392
+ if (repairIntent.finalArtifactSha256 !== finalDigests.artifactSha256 || repairIntent.finalSemanticDigest !== finalDigests.semanticDigest) {
393
+ return reject('REVIEW_PROVENANCE_INVALID', 'formal review artifact does not match the interrupted recovery journal', finalDigests);
394
+ }
395
+ recovery = recoveryContextFromIntent(resolved.repoRoot, resolved.taskDir, repairIntent);
396
+ }
226
397
  if (!recovery && (repairIntent?.state === 'passed' || repairIntent?.state === 'consumed')) {
227
398
  if (transformed.changed) return reject('REVIEW_PROVENANCE_INVALID', 'the review artifact requires changes after its completed recovery was consumed', finalDigests);
228
399
  return {
@@ -260,21 +431,43 @@ function prepareReviewSummaryCandidate(
260
431
 
261
432
  function commitReviewSummaryProvenance(
262
433
  prepared: ReviewSummaryCandidatePreparation,
263
- repoRoot: string
434
+ repoRoot: string,
435
+ options: Readonly<{ afterPublish?: () => void }> = {}
264
436
  ): ReviewFinalizationResult {
265
437
  if (prepared.result.status === 'failed' || !prepared.recovery) return prepared.result;
266
438
  try {
267
439
  const intent = readArtifactRecoveryIntent(prepared.recovery.repoRoot, prepared.recovery.taskId, prepared.recovery.family, prepared.recovery.artifact);
268
440
  if (!intent) throw new Error('ARTIFACT_RECOVERY_INTENT_MISSING');
269
441
  let committed;
270
- if (intent.state === 'finalize-ready' || intent.state === 'commit-started') {
271
- committed = commitArtifactRecovery(prepared.recovery, { lockAlreadyHeld: prepared.lockAlreadyHeld });
272
- } else {
442
+ if (intent.state === 'passed' || intent.state === 'consumed') {
443
+ return {
444
+ ...prepared.result,
445
+ status: 'no-op',
446
+ changed: false,
447
+ artifactSha256: intent.finalArtifactSha256,
448
+ semanticDigest: intent.finalSemanticDigest,
449
+ error: null
450
+ };
451
+ } else if (intent.state === 'commit-started') {
452
+ const reconciled = reconcileArtifactRecovery(prepared.recovery, {
453
+ lockAlreadyHeld: prepared.lockAlreadyHeld,
454
+ afterPublish: options.afterPublish
455
+ });
456
+ committed = reconciled.intent;
457
+ } else if (intent.state === 'awaiting-preflight-recovery') {
273
458
  const staged = stageArtifactCandidate(prepared.recovery, Buffer.from(prepared.content, 'utf8'), { lockAlreadyHeld: prepared.lockAlreadyHeld });
274
459
  prepareArtifactRecoveryCommit(prepared.recovery, staged.candidateSha256, staged.semanticDigest, { lockAlreadyHeld: prepared.lockAlreadyHeld });
275
460
  committed = commitArtifactRecovery(prepared.recovery, { lockAlreadyHeld: prepared.lockAlreadyHeld });
461
+ } else if (intent.state === 'preflight-ready' || intent.state === 'preflight-commit-started') {
462
+ committed = commitArtifactRecovery(prepared.recovery, { lockAlreadyHeld: prepared.lockAlreadyHeld });
463
+ }
464
+ if (committed?.state === 'preflight-passed' || intent.state === 'preflight-passed') {
465
+ prepareArtifactRecoveryFinal(prepared.recovery, Buffer.from(prepared.content, 'utf8'), { lockAlreadyHeld: prepared.lockAlreadyHeld });
466
+ committed = commitArtifactRecovery(prepared.recovery, { lockAlreadyHeld: prepared.lockAlreadyHeld, afterPublish: options.afterPublish });
467
+ } else if (intent.state === 'full-finalizer-ready') {
468
+ committed = commitArtifactRecovery(prepared.recovery, { lockAlreadyHeld: prepared.lockAlreadyHeld, afterPublish: options.afterPublish });
276
469
  }
277
- if (committed.state !== 'passed') throw new Error(`ARTIFACT_RECOVERY_STATE_INVALID: commit ended in '${committed.state}'`);
470
+ if (!committed || committed.state !== 'passed') throw new Error(`ARTIFACT_RECOVERY_STATE_INVALID: commit ended in '${committed?.state ?? intent.state}'`);
278
471
  return {
279
472
  ...prepared.result,
280
473
  artifactSha256: committed.finalArtifactSha256,
@@ -282,6 +475,7 @@ function commitReviewSummaryProvenance(
282
475
  error: null
283
476
  };
284
477
  } catch (error) {
478
+ if (error instanceof Error && error.message.startsWith('TASK_WORKFLOW_FAULT_INJECTED:')) throw error;
285
479
  return { ...prepared.result, status: 'failed', error: {
286
480
  code: 'REVIEW_RECOVERY_COMMIT_FAILED', message: `cannot publish review candidate: ${String(error)}`
287
481
  } };
@@ -335,12 +529,13 @@ function finalizeReviewSummary(
335
529
  }
336
530
  }
337
531
 
338
- export { finalizeReviewSummary, prepareReviewSummaryCandidate, commitReviewSummaryProvenance };
532
+ export { finalizeReviewSummary, preflightReviewSummary, prepareReviewSummaryCandidate, commitReviewSummaryProvenance };
339
533
  export type {
340
534
  ReviewFinalizationError,
341
535
  ReviewFinalizationErrorCode,
342
536
  ReviewFinalizationOptions,
343
537
  ReviewFinalizationRequest,
344
538
  ReviewFinalizationResult,
539
+ ReviewPreflightResult,
345
540
  ReviewSummaryCandidatePreparation
346
541
  };
@@ -1,6 +1,6 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import fs from 'node:fs';
2
3
  import path from 'node:path';
3
- import { createHash } from 'node:crypto';
4
4
 
5
5
  const NO_FOLLOW = fs.constants.O_NOFOLLOW ?? 0;
6
6
  const DIRECTORY = fs.constants.O_DIRECTORY ?? 0;
@@ -25,11 +25,8 @@ function conflict(message: string): never {
25
25
  }
26
26
 
27
27
  function sameStat(left: fs.BigIntStats, right: fs.BigIntStats): boolean {
28
- return left.dev === right.dev
29
- && left.ino === right.ino
30
- && left.size === right.size
31
- && left.mtimeNs === right.mtimeNs
32
- && left.ctimeNs === right.ctimeNs;
28
+ return left.dev === right.dev && left.ino === right.ino && left.size === right.size
29
+ && left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
33
30
  }
34
31
 
35
32
  function assertNoSymlinkAncestors(candidate: string): void {
@@ -43,25 +40,15 @@ function assertNoSymlinkAncestors(candidate: string): void {
43
40
  }
44
41
  }
45
42
 
46
- export async function readStableFile(
47
- candidate: string,
48
- options: Readonly<{ maxBytes: number; expectedSha256?: string }>
49
- ): Promise<StableFile> {
43
+ export async function readStableFile(candidate: string, options: Readonly<{ maxBytes: number; expectedSha256?: string }>): Promise<StableFile> {
50
44
  return readStableFileSync(candidate, options);
51
45
  }
52
46
 
53
- /** Shared bounded reader for synchronous domain commands and async workflow callers. */
54
- export function readStableFileSync(
55
- candidate: string,
56
- options: Readonly<{ maxBytes: number; expectedSha256?: string }>
57
- ): StableFile {
58
- if (!path.isAbsolute(candidate)) {
59
- conflict('candidate path must be absolute and terminal');
60
- }
47
+ export function readStableFileSync(candidate: string, options: Readonly<{ maxBytes: number; expectedSha256?: string }>): StableFile {
48
+ if (!path.isAbsolute(candidate)) conflict('candidate path must be absolute and terminal');
61
49
  assertNoSymlinkAncestors(candidate);
62
50
  let descriptor: number | undefined;
63
51
  try {
64
- // O_NONBLOCK lets fstat reject a FIFO even when it has no writer.
65
52
  descriptor = fs.openSync(candidate, fs.constants.O_RDONLY | NO_FOLLOW | (fs.constants.O_NONBLOCK ?? 0));
66
53
  const before = fs.fstatSync(descriptor, { bigint: true });
67
54
  if (!before.isFile()) conflict('candidate is not a regular file');
@@ -76,16 +63,12 @@ export function readStableFileSync(
76
63
  const after = fs.fstatSync(descriptor, { bigint: true });
77
64
  if (!sameStat(before, after)) conflict('candidate identity or metadata changed during read');
78
65
  const sha256 = createHash('sha256').update(bytes).digest('hex');
79
- if (options.expectedSha256 !== undefined && sha256 !== options.expectedSha256) {
80
- conflict('candidate digest does not match the expected digest');
81
- }
66
+ if (options.expectedSha256 !== undefined && sha256 !== options.expectedSha256) conflict('candidate digest does not match the expected digest');
82
67
  return { bytes, sha256, stat: after };
83
68
  } catch (error) {
84
69
  if (error instanceof SecureFileError) throw error;
85
70
  const code = (error as NodeJS.ErrnoException).code;
86
- if (code === 'ELOOP' || code === 'ENOTSUP' || code === 'EINVAL') {
87
- conflict('candidate terminal is not a stable non-symlink file');
88
- }
71
+ if (code === 'ELOOP' || code === 'ENOTSUP' || code === 'EINVAL') conflict('candidate terminal is not a stable non-symlink file');
89
72
  throw new SecureFileError('TASK_ARTIFACT_WRITE_CONFLICT', error instanceof Error ? error.message : String(error));
90
73
  } finally {
91
74
  if (descriptor !== undefined) fs.closeSync(descriptor);
@@ -99,18 +82,14 @@ export async function writeAtomicFile(target: string, bytes: Buffer, mode = 0o60
99
82
  let stat: fs.Stats;
100
83
  try { stat = await fs.promises.lstat(current); }
101
84
  catch { throw new SecureFileError('TASK_ARTIFACT_WRITE_CONFLICT', 'authoritative parent directory is missing'); }
102
- if (!stat.isDirectory() || stat.isSymbolicLink()) {
103
- throw new SecureFileError('TASK_ARTIFACT_WRITE_CONFLICT', 'authoritative parent directory is not a real directory');
104
- }
85
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new SecureFileError('TASK_ARTIFACT_WRITE_CONFLICT', 'authoritative parent directory is not a real directory');
105
86
  const next = path.dirname(current);
106
87
  if (next === current) break;
107
88
  current = next;
108
89
  }
109
90
  try {
110
91
  const existing = await fs.promises.lstat(target);
111
- if (existing.isSymbolicLink() || !existing.isFile()) {
112
- throw new SecureFileError('TASK_ARTIFACT_WRITE_CONFLICT', 'authoritative target is not a regular file');
113
- }
92
+ if (existing.isSymbolicLink() || !existing.isFile()) throw new SecureFileError('TASK_ARTIFACT_WRITE_CONFLICT', 'authoritative target is not a regular file');
114
93
  } catch (error) {
115
94
  if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
116
95
  }
@@ -123,7 +102,6 @@ export async function writeAtomicFile(target: string, bytes: Buffer, mode = 0o60
123
102
  await handle.chmod(mode);
124
103
  await handle.close();
125
104
  handle = undefined;
126
- // Check after all asynchronous preparation; keep the final check/rename adjacent.
127
105
  if (expectedSha256 !== undefined) readStableFileSync(target, { maxBytes: 1024 * 1024, expectedSha256 });
128
106
  fs.renameSync(temporary, target);
129
107
  const directory = await fs.promises.open(parent, fs.constants.O_RDONLY | DIRECTORY);
@@ -1,7 +1,9 @@
1
1
  export const TASK_WORKFLOW_COMMANDS = Object.freeze({
2
2
  'artifact-inspect': ['task-artifact', 'inspect'],
3
3
  'artifact-init': ['task-artifact', 'init'],
4
+ 'artifact-preflight': ['task-artifact', 'preflight'],
4
5
  'artifact-finalize-local': ['task-artifact', 'finalize-local'],
6
+ 'review-preflight': ['task-review', 'preflight'],
5
7
  'review-finalize-summary': ['task-review', 'finalize-summary'],
6
8
  event: ['task-event', null],
7
9
  'ledger-finding-response': ['task-ledger', 'finding-respond'],
@@ -0,0 +1,171 @@
1
+ import { reconcileTaskInvalidation } from './invalidation-command.ts';
2
+ import { applyLedgerIntent } from './ledger-intents.ts';
3
+ import type { LedgerIntent } from './ledger-intents.ts';
4
+ import { TaskExecutionLockError, withTaskExecutionLock } from './task-execution-lock.ts';
5
+ import type { TaskWorkflowOperation } from './workflow-command.ts';
6
+ import { applyWorkflowWarningIntent } from './workflow-warning-intents.ts';
7
+ import type { WorkflowWarningIntent } from './workflow-warning-intents.ts';
8
+
9
+ export type WorkflowDispatchResult = Readonly<Record<string, unknown>>;
10
+
11
+ type OptionValues = Readonly<Record<string, string | boolean>>;
12
+ type DispatchFailure = Readonly<{
13
+ status: 'failed';
14
+ changed: false;
15
+ error: Readonly<{ code: string; message: string }>;
16
+ }>;
17
+
18
+ function failed(code: string, message: string): DispatchFailure {
19
+ return { status: 'failed', changed: false, error: { code, message } };
20
+ }
21
+
22
+ function isFailure(value: unknown): value is DispatchFailure {
23
+ return Boolean(value) && typeof value === 'object' && (value as { status?: unknown }).status === 'failed';
24
+ }
25
+
26
+ function options(
27
+ args: readonly string[],
28
+ allowed: Readonly<Record<string, string>>
29
+ ): OptionValues | DispatchFailure {
30
+ const values: Record<string, string | boolean> = {};
31
+ for (let index = 2; index < args.length; index += 1) {
32
+ const flag = args[index]!;
33
+ if (flag === '--dry-run') {
34
+ if (!Object.hasOwn(allowed, flag) || values[flag] !== undefined) return failed('TASK_WORKFLOW_PAYLOAD_INVALID', `invalid option '${flag}'`);
35
+ values[flag] = true;
36
+ continue;
37
+ }
38
+ const key = allowed[flag];
39
+ if (!key || values[flag] !== undefined) return failed('TASK_WORKFLOW_PAYLOAD_INVALID', `invalid option '${flag}'`);
40
+ const value = args[++index];
41
+ if (value === undefined || value.startsWith('--')) return failed('TASK_WORKFLOW_PAYLOAD_INVALID', `option '${flag}' requires a value`);
42
+ if (flag === '--needs-implementation' && value !== 'true' && value !== 'false') {
43
+ return failed('TASK_WORKFLOW_PAYLOAD_INVALID', "option '--needs-implementation' must be true or false");
44
+ }
45
+ values[flag] = value;
46
+ }
47
+ return values;
48
+ }
49
+
50
+ function value(values: OptionValues, flag: string): string | undefined {
51
+ const candidate = values[flag];
52
+ return typeof candidate === 'string' ? candidate : undefined;
53
+ }
54
+
55
+ function required(values: OptionValues, flags: readonly string[]): DispatchFailure | null {
56
+ const missing = flags.find((flag) => value(values, flag) === undefined);
57
+ return missing ? failed('TASK_WORKFLOW_PAYLOAD_INVALID', `option '${missing}' is required`) : null;
58
+ }
59
+
60
+ function resultForLock(error: unknown): WorkflowDispatchResult {
61
+ if (error instanceof TaskExecutionLockError) return failed(error.code, error.message);
62
+ throw error;
63
+ }
64
+
65
+ function ledgerIntent(operation: TaskWorkflowOperation, args: readonly string[]): LedgerIntent | DispatchFailure {
66
+ const [taskRef, kind] = args;
67
+ const mappings: Partial<Record<TaskWorkflowOperation, {
68
+ kind: LedgerIntent['kind']; required: readonly string[]; flags: Readonly<Record<string, string>>;
69
+ }>> = {
70
+ 'ledger-finding-response': {
71
+ kind: 'finding-respond', required: ['--id', '--round', '--status', '--evidence'],
72
+ flags: { '--id': 'id', '--round': 'round', '--status': 'status', '--evidence': 'evidence', '--dry-run': 'dryRun' }
73
+ },
74
+ 'ledger-finding-review': {
75
+ kind: 'finding-review', required: ['--id', '--status', '--evidence'],
76
+ flags: { '--id': 'id', '--status': 'status', '--evidence': 'evidence', '--needs-implementation': 'needsImplementation', '--dry-run': 'dryRun' }
77
+ },
78
+ 'ledger-finding-upsert': {
79
+ kind: 'finding-upsert', required: ['--stage', '--review-artifact', '--ordinal', '--severity', '--evidence'],
80
+ flags: { '--stage': 'stage', '--review-artifact': 'reviewArtifact', '--ordinal': 'ordinal', '--severity': 'severity', '--evidence': 'evidence', '--dry-run': 'dryRun' }
81
+ },
82
+ 'decision-next-id': { kind: 'decision-next-id', required: [], flags: {} },
83
+ 'decision-upsert': {
84
+ kind: 'decision-upsert', required: ['--id', '--stage', '--artifact'],
85
+ flags: { '--id': 'id', '--stage': 'stage', '--artifact': 'artifact', '--needs-implementation': 'needsImplementation', '--dry-run': 'dryRun' }
86
+ }
87
+ };
88
+ const mapping = mappings[operation];
89
+ if (!mapping || !taskRef || kind === undefined) return failed('TASK_WORKFLOW_PAYLOAD_INVALID', 'task workflow ledger request is invalid');
90
+ const values = options(args, mapping.flags);
91
+ if (isFailure(values)) return values;
92
+ const missing = required(values, mapping.required);
93
+ if (missing) return missing;
94
+ const dryRun = values['--dry-run'] === true;
95
+ if (mapping.kind === 'decision-next-id') return { kind: mapping.kind, taskRef };
96
+ if (mapping.kind === 'finding-respond') return {
97
+ kind: mapping.kind, taskRef, id: value(values, '--id')!, round: Number(value(values, '--round')),
98
+ status: value(values, '--status') as never, evidence: value(values, '--evidence')!, ...(dryRun ? { dryRun } : {})
99
+ } as LedgerIntent;
100
+ if (mapping.kind === 'finding-review') return {
101
+ kind: mapping.kind, taskRef, id: value(values, '--id')!, status: value(values, '--status') as never,
102
+ evidence: value(values, '--evidence')!, ...(value(values, '--needs-implementation') === 'true' ? { needsImplementation: true } : {}),
103
+ ...(dryRun ? { dryRun } : {})
104
+ } as LedgerIntent;
105
+ if (mapping.kind === 'finding-upsert') return {
106
+ kind: mapping.kind, taskRef, stage: value(values, '--stage') as never, reviewArtifact: value(values, '--review-artifact')!,
107
+ ordinal: Number(value(values, '--ordinal')), severity: value(values, '--severity') as never, evidence: value(values, '--evidence')!,
108
+ ...(dryRun ? { dryRun } : {})
109
+ } as LedgerIntent;
110
+ return {
111
+ kind: mapping.kind, taskRef, id: value(values, '--id')!, stage: value(values, '--stage') as never,
112
+ artifact: value(values, '--artifact')!, ...(value(values, '--needs-implementation') === 'true' ? { needsImplementation: true } : {}),
113
+ ...(dryRun ? { dryRun } : {})
114
+ } as LedgerIntent;
115
+ }
116
+
117
+ function warningIntent(args: readonly string[]): WorkflowWarningIntent | DispatchFailure {
118
+ const [taskRef, kind] = args;
119
+ if (!taskRef || kind !== 'add') return failed('TASK_WORKFLOW_PAYLOAD_INVALID', 'task workflow warning request is invalid');
120
+ const values = options(args, {
121
+ '--step': 'step', '--severity': 'severity', '--code': 'code', '--target': 'target',
122
+ '--message': 'message', '--action': 'action', '--dry-run': 'dryRun'
123
+ });
124
+ if (isFailure(values)) return values;
125
+ const missing = required(values, ['--step', '--severity', '--code', '--target', '--message', '--action']);
126
+ if (missing) return missing;
127
+ return {
128
+ kind: 'add', taskRef, step: value(values, '--step')!, severity: value(values, '--severity') as never,
129
+ code: value(values, '--code')!, target: value(values, '--target')!, message: value(values, '--message')!,
130
+ action: value(values, '--action')!, ...(values['--dry-run'] === true ? { dryRun: true } : {})
131
+ } as WorkflowWarningIntent;
132
+ }
133
+
134
+ /**
135
+ * The caller has already selected and authorized the execution boundary.
136
+ * This function deliberately invokes task domains in that process: starting a
137
+ * second CLI process would discard the broker binding and could misclassify a
138
+ * sandbox process with cleared environment variables as direct-host.
139
+ */
140
+ export function dispatchWorkflowCommand(
141
+ repoRoot: string,
142
+ operation: TaskWorkflowOperation,
143
+ args: readonly string[]
144
+ ): WorkflowDispatchResult {
145
+ const taskRef = args[0];
146
+ if (!taskRef) return failed('TASK_WORKFLOW_PAYLOAD_INVALID', 'task ref is required');
147
+ try {
148
+ if (operation === 'invalidation-reconcile') {
149
+ const values = options(args, { '--max-targets': 'maxTargets', '--dry-run': 'dryRun' });
150
+ if (isFailure(values)) return values;
151
+ const maxTargets = value(values, '--max-targets');
152
+ if (maxTargets !== undefined && (!Number.isSafeInteger(Number(maxTargets)) || Number(maxTargets) < 1)) {
153
+ return failed('TASK_WORKFLOW_PAYLOAD_INVALID', '--max-targets must be a positive integer');
154
+ }
155
+ return withTaskExecutionLock(repoRoot, taskRef, 'task-invalidation.reconcile', () => reconcileTaskInvalidation(taskRef, {
156
+ repoRoot, ...(maxTargets === undefined ? {} : { maxTargets: Number(maxTargets) }),
157
+ ...(values['--dry-run'] === true ? { dryRun: true } : {})
158
+ }));
159
+ }
160
+ if (operation === 'warning-add') {
161
+ const intent = warningIntent(args);
162
+ if (isFailure(intent)) return intent;
163
+ return withTaskExecutionLock(repoRoot, taskRef, 'task-warning.add', () => applyWorkflowWarningIntent(intent, { repoRoot }));
164
+ }
165
+ const intent = ledgerIntent(operation, args);
166
+ if (isFailure(intent)) return intent;
167
+ return withTaskExecutionLock(repoRoot, taskRef, `task-ledger.${intent.kind}`, () => applyLedgerIntent(intent, { repoRoot }));
168
+ } catch (error) {
169
+ return resultForLock(error);
170
+ }
171
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fitlab-ai/agent-infra",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
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",
@@ -11,12 +11,18 @@
11
11
  | `artifactChunk` | `<!-- sync-issue:{task-id}:{artifact-stem}:{part}/{total} -->` |
12
12
  | `summary` | `<!-- sync-issue:{task-id}:summary -->` |
13
13
  | `cancel` | `<!-- sync-issue:{task-id}:cancel -->` |
14
+ | `recoveryAction` | `<!-- sync-issue:{task-id}:recovery-action:{action-id} -->` |
15
+ | `recoveryPrepare` | `<!-- sync-issue:{task-id}:recovery-prepare:{commit-id} -->` |
16
+ | `recoveryCommit` | `<!-- sync-issue:{task-id}:recovery-commit:{commit-id} -->` |
14
17
 
15
18
  `pr-review` content is synced only as an Issue artifact comment (via the `artifact` / `artifactChunk` markers) and is never a `restore-task` recovery source; restore still accepts only an Issue token and reads only registered Issue markers, with no PR source.
16
19
 
17
20
  Comments use `platform-comment`; Issue resources use `platform-issue`:
18
21
 
19
22
  ```bash
23
+ agent-infra-internal platform-comment list --issue <issue-token> [--cwd <path>]
24
+ agent-infra-internal platform-comment recover --issue <issue-token> --task-id <TASK-id> \
25
+ --output <.agents/workspace/.restore-staging-*/task.md> [--cwd <path>]
20
26
  agent-infra-internal platform-issue inspect {task-id}
21
27
  agent-infra-internal platform-issue create {task-id} --agent {standard-agent-token}
22
28
  agent-infra-internal platform-issue bind {task-id} --issue {issue-token} --agent {standard-agent-token}
@@ -25,7 +31,9 @@ agent-infra-internal platform-issue sync {task-id} --agent {standard-agent-token
25
31
 
26
32
  The core owns status/in labels, assignees, milestones, Issue Type, pinned fields, requirements, state, capabilities, dry-run, retries, errors, and idempotency. Omitted flags preserve values; `none` explicitly clears them. Status labels converge to at most one, and ambiguous requirement identity fails closed.
27
33
 
28
- Task content over the platform comment byte limit is not uploaded; the core returns a skipped operation with `COMMENT_PAYLOAD_TOO_LARGE`. The local task file is unchanged, and platform verification skips the task marker/content check for the same oversized input.
34
+ The task comment synchronizes only a deterministic current-task projection. Process sections such as the Activity Log, Artifact Lifecycle Receipts, and invalidation records are carried by the recovery transaction. Byte preflight and platform verification consume the same final rendered UTF-8 body.
35
+
36
+ Recovery comments use `recovery-action`, `recovery-prepare`, and `recovery-commit` markers with a stable recovery id and a body file. Task sync publishes prepare, action, a task snapshot that is written and re-read, and then commit. Replaying identical content is a no-op; conflicting immutable content fails with `RECOVERY_IMMUTABLE_CONFLICT`. Restore accepts only an author-consistent, complete, phase/digest/marker-valid transaction bound to the current task comment body SHA-256.
29
37
 
30
38
  PR event `in:` synchronization uses `agent-infra-internal platform-pr sync-in-labels --pr <N> [--cwd <path>]`. PR files are the event evidence; a unique closing Issue is written and re-read before the PR. Zero or multiple closing Issues update only the PR and return `degraded`; unknown side effects or failed convergence return `blocked` with `IN_LABEL_SYNC_PARTIAL`.
31
39