@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
@@ -10,15 +10,19 @@ export type ArtifactRecoveryPhase =
10
10
  | 'artifact.finalize-local'
11
11
  | 'task-event.completed';
12
12
  export type ArtifactRecoveryState =
13
- | 'awaiting-recovery'
14
- | 'finalize-ready'
13
+ | 'awaiting-preflight-recovery'
14
+ | 'preflight-ready'
15
+ | 'preflight-commit-started'
16
+ | 'preflight-passed'
17
+ | 'full-finalizer-ready'
15
18
  | 'commit-started'
16
19
  | 'passed'
20
+ | 'consumption-started'
17
21
  | 'consumed'
18
22
  | 'aborted';
19
23
 
20
24
  export type ArtifactRecoveryIntent = Readonly<{
21
- version: 3;
25
+ version: 4;
22
26
  taskId: string;
23
27
  family: ArtifactSchemaFamily;
24
28
  artifact: string;
@@ -28,6 +32,12 @@ export type ArtifactRecoveryIntent = Readonly<{
28
32
  baselineSemanticDigest: string;
29
33
  stagingId: string;
30
34
  candidateSha256: string | null;
35
+ preflightArtifactSha256: string | null;
36
+ preflightSemanticDigest: string | null;
37
+ activeGenerationSha256: string | null;
38
+ activeGenerationSemanticDigest: string | null;
39
+ pendingGenerationSha256: string | null;
40
+ pendingGenerationSemanticDigest: string | null;
31
41
  finalArtifactSha256: string | null;
32
42
  finalSemanticDigest: string | null;
33
43
  recoveryOperationId: string;
@@ -82,22 +92,28 @@ export function isArtifactRecoveryIntent(value: unknown): value is ArtifactRecov
82
92
  if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
83
93
  const intent = value as Record<string, unknown>;
84
94
  return exactKeys(intent, [
85
- 'artifact', 'authorityDigest', 'baselineSemanticDigest', 'baselineSha256', 'candidateSha256',
95
+ 'activeGenerationSemanticDigest', 'activeGenerationSha256', 'artifact', 'authorityDigest', 'baselineSemanticDigest', 'baselineSha256', 'candidateSha256',
86
96
  'createdAt', 'errorCode', 'errorMessage', 'family', 'finalArtifactSha256', 'finalSemanticDigest',
87
- 'phase', 'recoveryOperationId', 'requestId', 'round', 'stagingId', 'state', 'taskId', 'updatedAt',
97
+ 'pendingGenerationSemanticDigest', 'pendingGenerationSha256', 'phase', 'preflightArtifactSha256', 'preflightSemanticDigest', 'recoveryOperationId', 'requestId', 'round', 'stagingId', 'state', 'taskId', 'updatedAt',
88
98
  'version'
89
99
  ])
90
- && intent.version === 3
100
+ && intent.version === 4
91
101
  && validTaskId(intent.taskId)
92
102
  && typeof intent.family === 'string'
93
103
  && ['analysis', 'review-analysis', 'plan', 'review-plan', 'code', 'review-code'].includes(intent.family)
94
104
  && Number.isSafeInteger(intent.round) && (intent.round as number) > 0
95
105
  && validArtifact(intent.artifact, intent.family, intent.round)
96
- && ['awaiting-recovery', 'finalize-ready', 'commit-started', 'passed', 'consumed', 'aborted'].includes(String(intent.state))
106
+ && ['awaiting-preflight-recovery', 'preflight-ready', 'preflight-commit-started', 'preflight-passed', 'full-finalizer-ready', 'commit-started', 'passed', 'consumption-started', 'consumed', 'aborted'].includes(String(intent.state))
97
107
  && validDigest(intent.baselineSha256)
98
108
  && validDigest(intent.baselineSemanticDigest)
99
109
  && validRecoveryId(intent.stagingId)
100
110
  && (intent.candidateSha256 === null || validDigest(intent.candidateSha256))
111
+ && (intent.preflightArtifactSha256 === null || validDigest(intent.preflightArtifactSha256))
112
+ && (intent.preflightSemanticDigest === null || validDigest(intent.preflightSemanticDigest))
113
+ && (intent.activeGenerationSha256 === null || validDigest(intent.activeGenerationSha256))
114
+ && (intent.activeGenerationSemanticDigest === null || validDigest(intent.activeGenerationSemanticDigest))
115
+ && (intent.pendingGenerationSha256 === null || validDigest(intent.pendingGenerationSha256))
116
+ && (intent.pendingGenerationSemanticDigest === null || validDigest(intent.pendingGenerationSemanticDigest))
101
117
  && (intent.finalArtifactSha256 === null || validDigest(intent.finalArtifactSha256))
102
118
  && (intent.finalSemanticDigest === null || validDigest(intent.finalSemanticDigest))
103
119
  && validRecoveryId(intent.recoveryOperationId)
@@ -109,11 +125,22 @@ export function isArtifactRecoveryIntent(value: unknown): value is ArtifactRecov
109
125
  && (intent.errorMessage === null || validToken(intent.errorMessage))
110
126
  && Number.isSafeInteger(intent.createdAt)
111
127
  && Number.isSafeInteger(intent.updatedAt)
112
- && (intent.state === 'awaiting-recovery'
113
- ? intent.finalArtifactSha256 === null && intent.finalSemanticDigest === null
128
+ && (intent.state === 'awaiting-preflight-recovery'
129
+ ? intent.preflightArtifactSha256 === null && intent.preflightSemanticDigest === null
130
+ && intent.activeGenerationSha256 === null && intent.activeGenerationSemanticDigest === null
131
+ && intent.pendingGenerationSha256 === null && intent.pendingGenerationSemanticDigest === null
132
+ && intent.finalArtifactSha256 === null && intent.finalSemanticDigest === null
114
133
  : true)
115
- && (intent.state === 'finalize-ready' || intent.state === 'commit-started' || intent.state === 'passed' || intent.state === 'consumed'
116
- ? validDigest(intent.candidateSha256) && validDigest(intent.finalArtifactSha256) && validDigest(intent.finalSemanticDigest)
134
+ && (intent.state === 'preflight-ready' || intent.state === 'preflight-commit-started' || intent.state === 'preflight-passed' || intent.state === 'full-finalizer-ready' || intent.state === 'commit-started' || intent.state === 'passed' || intent.state === 'consumed'
135
+ ? validDigest(intent.candidateSha256) && validDigest(intent.preflightArtifactSha256) && validDigest(intent.preflightSemanticDigest)
136
+ && validDigest(intent.activeGenerationSha256) && validDigest(intent.activeGenerationSemanticDigest)
137
+ : true)
138
+ && (intent.state === 'full-finalizer-ready' || intent.state === 'commit-started'
139
+ ? validDigest(intent.pendingGenerationSha256) && validDigest(intent.pendingGenerationSemanticDigest)
140
+ && validDigest(intent.finalArtifactSha256) && validDigest(intent.finalSemanticDigest)
141
+ : true)
142
+ && (intent.state === 'passed' || intent.state === 'consumption-started' || intent.state === 'consumed'
143
+ ? validDigest(intent.finalArtifactSha256) && validDigest(intent.finalSemanticDigest)
117
144
  : true)
118
145
  && (intent.state === 'aborted' ? intent.errorCode !== null && intent.errorMessage !== null : true);
119
146
  }
@@ -428,7 +428,7 @@ function lifecycleRecoveryIntentMatches(
428
428
  return intent.taskId === selector.taskId
429
429
  && intent.family === selector.family
430
430
  && intent.artifact === selector.artifact
431
- && (intent.state === 'commit-started' || intent.state === 'consumed');
431
+ && (intent.state === 'commit-started' || intent.state === 'consumption-started' || intent.state === 'consumed');
432
432
  }
433
433
 
434
434
  /**
@@ -402,7 +402,7 @@ function reconcileReviewCompletionReplay(
402
402
  }
403
403
  intent = reconciled.intent;
404
404
  }
405
- if (!['passed', 'consumed'].includes(intent.state)) {
405
+ if (!['passed', 'consumption-started', 'consumed'].includes(intent.state)) {
406
406
  return {
407
407
  code: 'EVENT_ARTIFACT_CONFLICT',
408
408
  message: `review finalizer provenance is incomplete for ${artifact}`
@@ -423,7 +423,7 @@ function reconcileReviewCompletionReplay(
423
423
  message: `review finalizer provenance does not match ${artifact}`
424
424
  };
425
425
  }
426
- if (intent.state === 'passed') {
426
+ if (intent.state === 'passed' || intent.state === 'consumption-started') {
427
427
  const consumed = { ...intent, state: 'consumed' as const, updatedAt: Date.now() };
428
428
  writeArtifactRecoveryIntent(repoRoot, consumed, { expected: intent });
429
429
  }
@@ -963,7 +963,7 @@ function applyTaskEventUnlocked(request: TaskEventRequest, options: TaskEventOpt
963
963
  );
964
964
  if (reconciled.status === 'passed' || reconciled.status === 'consumed') reviewFinalizationIntent = reconciled.intent;
965
965
  }
966
- if (!reviewFinalizationIntent || !['passed', 'consumed'].includes(reviewFinalizationIntent.state)) {
966
+ if (!reviewFinalizationIntent || !['passed', 'consumption-started', 'consumed'].includes(reviewFinalizationIntent.state)) {
967
967
  return failed(normalized, {
968
968
  code: 'EVENT_ARTIFACT_CONFLICT',
969
969
  message: `review finalizer provenance is missing or incomplete for ${completedArtifact.name}`
@@ -1116,7 +1116,7 @@ function applyTaskEventUnlocked(request: TaskEventRequest, options: TaskEventOpt
1116
1116
  const expectedIntent = localFinalizationIntent;
1117
1117
  localFinalizationIntent = {
1118
1118
  ...localFinalizationIntent,
1119
- state: 'commit-started',
1119
+ state: 'consumption-started',
1120
1120
  phase: 'task-event.completed',
1121
1121
  authorityDigest: lifecycleRecoveryAttestationDigest(lifecycleAuthority),
1122
1122
  requestId: lifecycleAuthority.lifecycleRequestId,
@@ -1146,7 +1146,7 @@ function applyTaskEventUnlocked(request: TaskEventRequest, options: TaskEventOpt
1146
1146
  const expectedIntent = reviewFinalizationIntent;
1147
1147
  reviewFinalizationIntent = {
1148
1148
  ...reviewFinalizationIntent,
1149
- state: 'commit-started',
1149
+ state: 'consumption-started',
1150
1150
  phase: 'task-event.completed',
1151
1151
  updatedAt: Date.now()
1152
1152
  };
@@ -1183,7 +1183,7 @@ function applyTaskEventUnlocked(request: TaskEventRequest, options: TaskEventOpt
1183
1183
  }
1184
1184
  if (!normalized.dryRun && reviewFinalizationIntent && completedArtifact) {
1185
1185
  try {
1186
- if (reviewFinalizationIntent.state === 'commit-started') {
1186
+ if (reviewFinalizationIntent.state === 'consumption-started') {
1187
1187
  const consumed = { ...reviewFinalizationIntent, state: 'consumed' as const, updatedAt: Date.now() };
1188
1188
  writeArtifactRecoveryIntent(resolved.repoRoot, consumed, { expected: reviewFinalizationIntent });
1189
1189
  }
@@ -327,10 +327,26 @@ function cleanupConsumedRecoveryFiles(repoRoot: string, task: TaskRecord | undef
327
327
  let entries: fs.Dirent[];
328
328
  try { entries = fs.readdirSync(recovery, { withFileTypes: true }); } catch { return false; }
329
329
  for (const entry of entries) {
330
- if (entry.isSymbolicLink() || !entry.isFile() || !['baseline.md', 'candidate.md', 'final.md', 'publish.md'].includes(entry.name)) return false;
330
+ if (entry.isSymbolicLink()) return false;
331
+ if (entry.isFile()) {
332
+ if (!['baseline.md', 'candidate.md', 'final.md', 'publish.md'].includes(entry.name)) return false;
333
+ continue;
334
+ }
335
+ if (!entry.isDirectory() || entry.name !== 'generations') return false;
336
+ let generations: fs.Dirent[];
337
+ try { generations = fs.readdirSync(path.join(recovery, entry.name), { withFileTypes: true }); } catch { return false; }
338
+ if (generations.some((generation) => generation.isSymbolicLink()
339
+ || !generation.isFile() || !/^[a-f0-9]{64}\.md$/u.test(generation.name))) return false;
331
340
  }
332
341
  try {
333
- for (const entry of entries) fs.unlinkSync(path.join(recovery, entry.name));
342
+ for (const entry of entries) {
343
+ const target = path.join(recovery, entry.name);
344
+ if (entry.isFile()) fs.unlinkSync(target);
345
+ else {
346
+ for (const generation of fs.readdirSync(target, { withFileTypes: true })) fs.unlinkSync(path.join(target, generation.name));
347
+ fs.rmdirSync(target);
348
+ }
349
+ }
334
350
  fs.rmdirSync(recovery);
335
351
  const recoveryRoot = path.dirname(recovery);
336
352
  if (fs.readdirSync(recoveryRoot).length === 0) fs.rmdirSync(recoveryRoot);
@@ -19,8 +19,10 @@ import {
19
19
  beginArtifactRecovery,
20
20
  commitArtifactRecovery,
21
21
  consumeArtifactRecovery,
22
+ prepareArtifactRecoveryFinal,
22
23
  prepareArtifactRecoveryCommit,
23
24
  recordArtifactRecoveryPassed,
25
+ reconcileArtifactRecovery,
24
26
  recoveryContextFromIntent,
25
27
  stageArtifactCandidate
26
28
  } from './artifact-recovery.ts';
@@ -265,7 +267,8 @@ function tupleFor(
265
267
  function prepareLocalArtifact(
266
268
  request: LocalArtifactFinalizationRequest,
267
269
  candidate?: string,
268
- authority?: LifecycleRecoveryAttestationV1
270
+ authority?: LifecycleRecoveryAttestationV1,
271
+ preflightOnly = false
269
272
  ): LocalArtifactPreparation {
270
273
  const failed = (code: string, message: string, extra: Partial<LocalArtifactFinalizationResult> = {}): LocalArtifactPreparation => ({
271
274
  result: failedFinalization(request, { code, message }, extra), content: candidate ?? ''
@@ -281,12 +284,14 @@ function prepareLocalArtifact(
281
284
  let taskContent: string;
282
285
  let content: string;
283
286
  let recovery: ArtifactRecoveryContext | undefined;
287
+ let recoveryState: ArtifactRecoveryIntent['state'] | undefined;
284
288
  try {
285
289
  taskContent = fs.readFileSync(resolved.taskMdPath, 'utf8');
286
290
  if (request.recoveryId) {
287
291
  const intent = readArtifactRecoveryIntent(resolved.repoRoot, resolved.taskId, request.family, request.artifact);
288
292
  if (!intent || intent.recoveryOperationId !== request.recoveryId) return failed('LOCAL_RECOVERY_PROVENANCE_CONFLICT', 'recovery id does not match the artifact journal');
289
- if (!['awaiting-recovery', 'finalize-ready', 'commit-started'].includes(intent.state)) return failed('LOCAL_RECOVERY_PROVENANCE_CONFLICT', `recovery journal is in '${intent.state}' state`);
293
+ if (!['awaiting-preflight-recovery', 'preflight-ready', 'preflight-commit-started', 'preflight-passed', 'full-finalizer-ready', 'commit-started'].includes(intent.state)) return failed('LOCAL_RECOVERY_PROVENANCE_CONFLICT', `recovery journal is in '${intent.state}' state`);
294
+ recoveryState = intent.state;
290
295
  recovery = recoveryContextFromIntent(resolved.repoRoot, resolved.taskDir, intent);
291
296
  content = fs.existsSync(recovery.stagingPath)
292
297
  ? fs.readFileSync(recovery.stagingPath, 'utf8')
@@ -315,26 +320,66 @@ function prepareLocalArtifact(
315
320
 
316
321
  if (request.recoveryId) {
317
322
  if (!validation.ok) return { result, content, repoRoot: resolved.repoRoot, recovery, authority, lockAlreadyHeld: request.lockAlreadyHeld };
323
+ if (preflightOnly && recovery && recoveryState === 'awaiting-preflight-recovery') {
324
+ const staged = stageArtifactCandidate(recovery, Buffer.from(content, 'utf8'), { lockAlreadyHeld: request.lockAlreadyHeld });
325
+ prepareArtifactRecoveryCommit(recovery, staged.candidateSha256, staged.semanticDigest, { lockAlreadyHeld: request.lockAlreadyHeld });
326
+ }
318
327
  return {
319
- result: { ...result, status: 'passed', error: null, recovery: recoveryInfo(recovery!) },
328
+ result: { ...result, status: 'passed', changed: false, error: null, recovery: recoveryInfo(recovery!) },
320
329
  content, repoRoot: resolved.repoRoot, recovery, authority, lockAlreadyHeld: request.lockAlreadyHeld
321
330
  };
322
331
  }
323
332
 
324
333
  const existing = readArtifactRecoveryIntent(resolved.repoRoot, resolved.taskId, request.family, request.artifact);
325
334
  if (existing?.state === 'passed' || existing?.state === 'consumed') {
326
- if (existing.finalArtifactSha256 !== artifactSha256 || existing.finalSemanticDigest !== validation.semanticDigest) {
335
+ if (existing.state === 'consumed' && (existing.finalArtifactSha256 !== artifactSha256 || existing.finalSemanticDigest !== validation.semanticDigest)) {
327
336
  return { ...failed('LOCAL_RECOVERY_PROVENANCE_CONFLICT', 'formal artifact does not match its completed recovery journal'), content, repoRoot: resolved.repoRoot, authority, lockAlreadyHeld: request.lockAlreadyHeld };
328
337
  }
338
+ if (existing.finalArtifactSha256 === artifactSha256 && existing.finalSemanticDigest === validation.semanticDigest) {
339
+ const context = recoveryContextFromIntent(resolved.repoRoot, resolved.taskDir, existing);
340
+ return {
341
+ result: { ...result, status: 'passed', error: null, recovery: recoveryInfo(context) },
342
+ content, repoRoot: resolved.repoRoot, recovery: context, authority, lockAlreadyHeld: request.lockAlreadyHeld
343
+ };
344
+ }
345
+ }
346
+
347
+ if (existing && ['preflight-ready', 'preflight-commit-started', 'preflight-passed', 'full-finalizer-ready'].includes(existing.state)) {
329
348
  const context = recoveryContextFromIntent(resolved.repoRoot, resolved.taskDir, existing);
330
349
  return {
331
- result: { ...result, status: 'passed', error: null, recovery: recoveryInfo(context) },
350
+ result: { ...result, status: 'passed', changed: false, error: null, recovery: recoveryInfo(context) },
351
+ content, repoRoot: resolved.repoRoot, recovery: context, authority, lockAlreadyHeld: request.lockAlreadyHeld
352
+ };
353
+ }
354
+ if (existing?.state === 'commit-started') {
355
+ if (existing.finalArtifactSha256 !== artifactSha256 || existing.finalSemanticDigest !== validation.semanticDigest) {
356
+ return { ...failed('LOCAL_RECOVERY_PROVENANCE_CONFLICT', 'formal artifact does not match the interrupted recovery journal'), content, repoRoot: resolved.repoRoot, authority, lockAlreadyHeld: request.lockAlreadyHeld };
357
+ }
358
+ const context = recoveryContextFromIntent(resolved.repoRoot, resolved.taskDir, existing);
359
+ const reconciled = reconcileArtifactRecovery(context, {
360
+ lockAlreadyHeld: request.lockAlreadyHeld,
361
+ validateFinal: () => validation.semanticDigest === existing.finalSemanticDigest
362
+ });
363
+ if (reconciled.status !== 'passed') {
364
+ return { ...failed('LOCAL_RECOVERY_PROVENANCE_CONFLICT', `interrupted recovery could not be reconciled: ${reconciled.status}`), content, repoRoot: resolved.repoRoot, recovery: context, authority, lockAlreadyHeld: request.lockAlreadyHeld };
365
+ }
366
+ return {
367
+ result: { ...result, status: 'passed', changed: false, error: null, recovery: recoveryInfo(context) },
332
368
  content, repoRoot: resolved.repoRoot, recovery: context, authority, lockAlreadyHeld: request.lockAlreadyHeld
333
369
  };
334
370
  }
335
371
 
336
372
  try {
337
373
  if (validation.ok) {
374
+ if (preflightOnly) {
375
+ const context = beginArtifactRecovery(tupleFor(request, resolved.taskId, authority), Buffer.from(content, 'utf8'), {
376
+ repoRoot: resolved.repoRoot, taskDir: resolved.taskDir,
377
+ ...(authority ? { recoveryId: authority.operationId } : {}), lockAlreadyHeld: request.lockAlreadyHeld
378
+ });
379
+ const staged = stageArtifactCandidate(context, Buffer.from(content, 'utf8'), { lockAlreadyHeld: request.lockAlreadyHeld });
380
+ prepareArtifactRecoveryCommit(context, staged.candidateSha256, staged.semanticDigest, { lockAlreadyHeld: request.lockAlreadyHeld });
381
+ return { result: { ...result, status: 'passed', error: null, recovery: recoveryInfo(context) }, content, repoRoot: resolved.repoRoot, recovery: context, authority, lockAlreadyHeld: request.lockAlreadyHeld };
382
+ }
338
383
  const context = recordArtifactRecoveryPassed(tupleFor(request, resolved.taskId, authority), {
339
384
  repoRoot: resolved.repoRoot,
340
385
  taskDir: resolved.taskDir,
@@ -344,7 +389,7 @@ function prepareLocalArtifact(
344
389
  lockAlreadyHeld: request.lockAlreadyHeld
345
390
  });
346
391
  recovery = context;
347
- return { result: { ...result, status: 'passed', error: null, recovery: recoveryInfo(context) }, content, repoRoot: resolved.repoRoot, recovery, authority, lockAlreadyHeld: request.lockAlreadyHeld };
392
+ return { result: { ...result, status: 'passed', changed: false, error: null, recovery: recoveryInfo(context) }, content, repoRoot: resolved.repoRoot, recovery, authority, lockAlreadyHeld: request.lockAlreadyHeld };
348
393
  }
349
394
  const context = beginArtifactRecovery(tupleFor(request, resolved.taskId, authority), Buffer.from(content, 'utf8'), {
350
395
  repoRoot: resolved.repoRoot,
@@ -362,7 +407,10 @@ function prepareLocalArtifact(
362
407
  }
363
408
  }
364
409
 
365
- function commitLocalArtifactProvenance(prepared: LocalArtifactPreparation): LocalArtifactFinalizationResult {
410
+ function commitLocalArtifactProvenance(
411
+ prepared: LocalArtifactPreparation,
412
+ options: Readonly<{ afterPublish?: () => void }> = {}
413
+ ): LocalArtifactFinalizationResult {
366
414
  if (!prepared.recovery || !prepared.repoRoot || prepared.result.status === 'failed' && !prepared.result.recovery) return prepared.result;
367
415
  if (prepared.result.status === 'failed') return prepared.result;
368
416
  try {
@@ -372,26 +420,42 @@ function commitLocalArtifactProvenance(prepared: LocalArtifactPreparation): Loca
372
420
  if (intent.state === 'passed' || intent.state === 'consumed') {
373
421
  return {
374
422
  ...prepared.result,
423
+ changed: false,
375
424
  artifactSha256: intent.finalArtifactSha256,
376
425
  semanticDigest: intent.finalSemanticDigest,
377
426
  error: null
378
427
  };
379
- } else if (intent.state === 'finalize-ready' || intent.state === 'commit-started') {
380
- committed = commitArtifactRecovery(prepared.recovery, { lockAlreadyHeld: prepared.lockAlreadyHeld });
381
- } else {
428
+ } else if (intent.state === 'commit-started') {
429
+ const reconciled = reconcileArtifactRecovery(prepared.recovery, {
430
+ lockAlreadyHeld: prepared.lockAlreadyHeld,
431
+ afterPublish: options.afterPublish
432
+ });
433
+ committed = reconciled.intent;
434
+ } else if (intent.state === 'awaiting-preflight-recovery') {
382
435
  const staged = stageArtifactCandidate(prepared.recovery, Buffer.from(prepared.content, 'utf8'), { lockAlreadyHeld: prepared.lockAlreadyHeld });
383
436
  prepareArtifactRecoveryCommit(prepared.recovery, staged.candidateSha256, staged.semanticDigest, { lockAlreadyHeld: prepared.lockAlreadyHeld });
384
437
  committed = commitArtifactRecovery(prepared.recovery, { lockAlreadyHeld: prepared.lockAlreadyHeld });
385
438
  }
386
- if (committed.state !== 'passed') throw new Error(`ARTIFACT_RECOVERY_STATE_INVALID: commit ended in '${committed.state}'`);
439
+ if (intent.state === 'preflight-ready' || intent.state === 'preflight-commit-started') {
440
+ committed = commitArtifactRecovery(prepared.recovery, { lockAlreadyHeld: prepared.lockAlreadyHeld });
441
+ }
442
+ if (committed?.state === 'preflight-passed' || intent.state === 'preflight-passed') {
443
+ prepareArtifactRecoveryFinal(prepared.recovery, Buffer.from(prepared.content, 'utf8'), { lockAlreadyHeld: prepared.lockAlreadyHeld });
444
+ committed = commitArtifactRecovery(prepared.recovery, { lockAlreadyHeld: prepared.lockAlreadyHeld, afterPublish: options.afterPublish });
445
+ } else if (intent.state === 'full-finalizer-ready') {
446
+ committed = commitArtifactRecovery(prepared.recovery, { lockAlreadyHeld: prepared.lockAlreadyHeld, afterPublish: options.afterPublish });
447
+ }
448
+ if (!committed || committed.state !== 'passed') throw new Error(`ARTIFACT_RECOVERY_STATE_INVALID: commit ended in '${committed?.state ?? intent.state}'`);
387
449
  return {
388
450
  ...prepared.result,
389
451
  status: 'passed',
452
+ changed: false,
390
453
  artifactSha256: committed.finalArtifactSha256,
391
454
  semanticDigest: committed.finalSemanticDigest,
392
455
  error: null
393
456
  };
394
457
  } catch (error) {
458
+ if (error instanceof Error && error.message.startsWith('TASK_WORKFLOW_FAULT_INJECTED:')) throw error;
395
459
  return { ...prepared.result, status: 'failed', error: {
396
460
  code: 'LOCAL_RECOVERY_COMMIT_FAILED', message: String(error)
397
461
  } };
@@ -417,10 +481,20 @@ function finalizeLocalArtifact(
417
481
  return result;
418
482
  }
419
483
 
484
+ /** Validate and seal an immutable preflight generation without publishing the formal artifact. */
485
+ function preflightLocalArtifact(
486
+ request: LocalArtifactFinalizationRequest,
487
+ authority?: LifecycleRecoveryAttestationV1
488
+ ): LocalArtifactFinalizationResult {
489
+ const prepared = prepareLocalArtifact(request, undefined, authority, true);
490
+ return prepared.result;
491
+ }
492
+
420
493
  export {
421
494
  LOCAL_ARTIFACT_REQUIRED_SECTIONS,
422
495
  consumeLocalArtifactFinalizationIntent,
423
496
  finalizeLocalArtifact,
497
+ preflightLocalArtifact,
424
498
  prepareLocalArtifact,
425
499
  commitLocalArtifactProvenance,
426
500
  readArtifactRecoveryIntent as readLocalArtifactFinalizationIntent,
@@ -3,7 +3,7 @@ import type { ReviewFinalizationRequest } from './review-finalization.ts';
3
3
  export function parseReviewCommand(args: readonly string[]): ReviewFinalizationRequest & {
4
4
  overrideTicket?: string; overrideTarget?: string; overrideScope?: string;
5
5
  } {
6
- if (!args[0] || args[1] !== 'finalize-summary') throw new Error('task ref and finalize-summary are required');
6
+ if (!args[0] || !['preflight', 'finalize-summary'].includes(args[1] ?? '')) throw new Error('task ref and a supported review operation are required');
7
7
  const values: Record<string, string | boolean> = {};
8
8
  const flags = {
9
9
  '--stage': 'stage', '--artifact': 'artifact', '--orchestrated': 'orchestrated', '--dry-run': 'dryRun',