@fitlab-ai/agent-infra 0.10.0 → 0.11.1

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 (154) 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/manual-validation.js +6 -2
  7. package/dist/lib/internal/platform-comment.js +60 -2
  8. package/dist/lib/internal/task-artifact.js +1 -1
  9. package/dist/lib/internal/task-finalization.js +2 -2
  10. package/dist/lib/internal/task-lifecycle.js +2 -2
  11. package/dist/lib/internal/task-operation-registry.js +22 -12
  12. package/dist/lib/internal/task-orchestration.js +2 -2
  13. package/dist/lib/internal/task-review.js +10 -5
  14. package/dist/lib/platform/activity-recovery.js +159 -0
  15. package/dist/lib/platform/issue-comments.js +84 -32
  16. package/dist/lib/platform/issues.js +3 -4
  17. package/dist/lib/platform/task-comment-projection.js +70 -0
  18. package/dist/lib/platform/task-recovery.js +55 -0
  19. package/dist/lib/platform/verification-sync.js +184 -162
  20. package/dist/lib/sandbox/control/executor.js +4 -2
  21. package/dist/lib/sandbox/control/task-workflow.js +1 -1
  22. package/dist/lib/sandbox/control/workflow-executor.js +90 -22
  23. package/dist/lib/server/process-state.js +17 -5
  24. package/dist/lib/task/artifact-command.js +6 -3
  25. package/dist/lib/task/artifact-lifecycle.js +15 -52
  26. package/dist/lib/task/artifact-operations.js +28 -2
  27. package/dist/lib/task/artifact-recovery.js +114 -68
  28. package/dist/lib/task/artifact-repair-intent.js +25 -8
  29. package/dist/lib/task/capabilities.js +0 -2
  30. package/dist/lib/task/control-authority.js +1 -1
  31. package/dist/lib/task/events.js +66 -49
  32. package/dist/lib/task/gate-policy.js +94 -0
  33. package/dist/lib/task/intermediate-cleanup.js +28 -3
  34. package/dist/lib/task/local-artifact-finalization.js +85 -18
  35. package/dist/lib/task/orchestration.js +8 -6
  36. package/dist/lib/task/review-artifacts.js +12 -6
  37. package/dist/lib/task/review-command.js +2 -2
  38. package/dist/lib/task/review-finalization.js +198 -11
  39. package/dist/lib/{host-control → task}/secure-fs.js +8 -19
  40. package/dist/lib/task/verification-engine.js +28 -17
  41. package/dist/lib/task/verification.js +25 -9
  42. package/dist/lib/task/workflow-command.js +2 -0
  43. package/dist/lib/task/workflow-dispatch.js +161 -0
  44. package/dist/lifecycle-build-manifest.json +3 -3
  45. package/lib/internal/cli-route-inventory.ts +3 -4
  46. package/lib/internal/manual-validation.ts +6 -2
  47. package/lib/internal/platform-comment.ts +53 -2
  48. package/lib/internal/task-artifact.ts +1 -1
  49. package/lib/internal/task-finalization.ts +1 -1
  50. package/lib/internal/task-lifecycle.ts +1 -1
  51. package/lib/internal/task-operation-registry.ts +22 -12
  52. package/lib/internal/task-orchestration.ts +1 -1
  53. package/lib/internal/task-review.ts +9 -5
  54. package/lib/platform/activity-recovery.ts +179 -0
  55. package/lib/platform/issue-comments.ts +98 -31
  56. package/lib/platform/issues.ts +3 -4
  57. package/lib/platform/task-comment-projection.ts +79 -0
  58. package/lib/platform/task-recovery.ts +54 -0
  59. package/lib/platform/verification-sync.ts +203 -188
  60. package/lib/sandbox/control/executor.ts +4 -2
  61. package/lib/sandbox/control/protocol.ts +1 -1
  62. package/lib/sandbox/control/task-workflow.ts +1 -1
  63. package/lib/sandbox/control/workflow-executor.ts +113 -22
  64. package/lib/server/process-state.ts +26 -5
  65. package/lib/task/artifact-command.ts +6 -4
  66. package/lib/task/artifact-lifecycle.ts +12 -49
  67. package/lib/task/artifact-operations.ts +28 -2
  68. package/lib/task/artifact-recovery.ts +120 -67
  69. package/lib/task/artifact-repair-intent.ts +38 -11
  70. package/lib/task/capabilities.ts +0 -1
  71. package/lib/task/control-authority.ts +1 -1
  72. package/lib/task/events.ts +55 -38
  73. package/lib/task/gate-policy.ts +133 -0
  74. package/lib/task/intermediate-cleanup.ts +18 -2
  75. package/lib/task/local-artifact-finalization.ts +91 -15
  76. package/lib/task/orchestration.ts +12 -7
  77. package/lib/task/review-artifacts.ts +8 -2
  78. package/lib/task/review-command.ts +1 -1
  79. package/lib/task/review-finalization.ts +198 -9
  80. package/lib/{host-control → task}/secure-fs.ts +10 -32
  81. package/lib/task/verification-engine.ts +24 -14
  82. package/lib/task/verification-types.ts +7 -0
  83. package/lib/task/verification.ts +25 -9
  84. package/lib/task/workflow-command.ts +2 -0
  85. package/lib/task/workflow-dispatch.ts +171 -0
  86. package/package.json +1 -1
  87. package/templates/.agents/rules/issue-sync.en.md +11 -1
  88. package/templates/.agents/rules/issue-sync.zh-CN.md +6 -2
  89. package/templates/.agents/rules/local-artifact-repair.en.md +18 -9
  90. package/templates/.agents/rules/local-artifact-repair.zh-CN.md +21 -11
  91. package/templates/.agents/skills/analyze-task/SKILL.en.md +2 -2
  92. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +2 -2
  93. package/templates/.agents/skills/analyze-task/config/verify.en.json +1 -10
  94. package/templates/.agents/skills/analyze-task/config/verify.zh-CN.json +1 -10
  95. package/templates/.agents/skills/block-task/config/verify.json +1 -3
  96. package/templates/.agents/skills/cancel-task/config/verify.json +1 -4
  97. package/templates/.agents/skills/code-task/SKILL.en.md +4 -4
  98. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +5 -5
  99. package/templates/.agents/skills/code-task/config/verify.en.json +1 -12
  100. package/templates/.agents/skills/code-task/config/verify.zh-CN.json +1 -12
  101. package/templates/.agents/skills/code-task/reference/dual-mode.en.md +1 -1
  102. package/templates/.agents/skills/code-task/reference/dual-mode.zh-CN.md +1 -1
  103. package/templates/.agents/skills/code-task/reference/report-template.en.md +11 -0
  104. package/templates/.agents/skills/code-task/reference/report-template.zh-CN.md +11 -0
  105. package/templates/.agents/skills/commit/config/verify.json +1 -5
  106. package/templates/.agents/skills/complete-manual-validation/config/verify.en.json +6 -6
  107. package/templates/.agents/skills/complete-manual-validation/config/verify.zh-CN.json +6 -6
  108. package/templates/.agents/skills/complete-task/config/verify.en.json +3 -14
  109. package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +3 -14
  110. package/templates/.agents/skills/create-pr/config/verify.json +1 -10
  111. package/templates/.agents/skills/create-task/config/verify.json +1 -4
  112. package/templates/.agents/skills/import-issue/config/verify.json +1 -2
  113. package/templates/.agents/skills/plan-task/SKILL.en.md +2 -2
  114. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +2 -2
  115. package/templates/.agents/skills/plan-task/config/verify.en.json +1 -10
  116. package/templates/.agents/skills/plan-task/config/verify.zh-CN.json +1 -10
  117. package/templates/.agents/skills/restore-task/SKILL.en.md +9 -4
  118. package/templates/.agents/skills/restore-task/SKILL.zh-CN.md +10 -5
  119. package/templates/.agents/skills/review-analysis/SKILL.en.md +1 -1
  120. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +1 -1
  121. package/templates/.agents/skills/review-analysis/config/verify.en.json +1 -10
  122. package/templates/.agents/skills/review-analysis/config/verify.zh-CN.json +1 -10
  123. package/templates/.agents/skills/review-analysis/reference/report-template.en.md +2 -0
  124. package/templates/.agents/skills/review-analysis/reference/report-template.zh-CN.md +2 -0
  125. package/templates/.agents/skills/review-code/SKILL.en.md +3 -2
  126. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +3 -2
  127. package/templates/.agents/skills/review-code/config/verify.en.json +1 -10
  128. package/templates/.agents/skills/review-code/config/verify.zh-CN.json +1 -10
  129. package/templates/.agents/skills/review-code/reference/report-template.en.md +2 -0
  130. package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +2 -0
  131. package/templates/.agents/skills/review-plan/SKILL.en.md +1 -1
  132. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +1 -1
  133. package/templates/.agents/skills/review-plan/config/verify.en.json +1 -10
  134. package/templates/.agents/skills/review-plan/config/verify.zh-CN.json +1 -10
  135. package/templates/.agents/skills/review-plan/reference/report-template.en.md +2 -0
  136. package/templates/.agents/skills/review-plan/reference/report-template.zh-CN.md +2 -0
  137. package/templates/.agents/skills/review-pr/config/verify.en.json +1 -8
  138. package/templates/.agents/skills/review-pr/config/verify.zh-CN.json +1 -8
  139. package/templates/.agents/skills/run-manual-validation/config/verify.en.json +38 -13
  140. package/templates/.agents/skills/run-manual-validation/config/verify.zh-CN.json +38 -13
  141. package/dist/lib/host-control/audit.js +0 -19
  142. package/dist/lib/host-control/client.js +0 -142
  143. package/dist/lib/host-control/command.js +0 -49
  144. package/dist/lib/host-control/path.js +0 -160
  145. package/dist/lib/host-control/server.js +0 -159
  146. package/dist/lib/host-control/service.js +0 -112
  147. package/dist/lib/internal/host-control.js +0 -28
  148. package/lib/host-control/audit.ts +0 -22
  149. package/lib/host-control/client.ts +0 -172
  150. package/lib/host-control/command.ts +0 -60
  151. package/lib/host-control/path.ts +0 -167
  152. package/lib/host-control/server.ts +0 -180
  153. package/lib/host-control/service.ts +0 -133
  154. package/lib/internal/host-control.ts +0 -28
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
4
 
5
5
  import { writeDurableFile } from '../fs/durable-write.ts';
6
- import { readStableFileSync } from '../host-control/secure-fs.ts';
6
+ import { readStableFileSync } from './secure-fs.ts';
7
7
  import { parseArtifactName } from './artifact-name.ts';
8
8
  import { canonicalSemanticDigest, sha256Content } from './artifact-operations.ts';
9
9
  import {
@@ -39,7 +39,7 @@ export type ArtifactRecoveryContext = Readonly<ArtifactRecoveryTuple & {
39
39
  formalPath: string;
40
40
  stagingPath: string;
41
41
  baselinePath: string;
42
- finalPath: string;
42
+ generationsPath: string;
43
43
  baselineSha256: string;
44
44
  baselineSemanticDigest: string;
45
45
  }>;
@@ -191,7 +191,7 @@ function contextPaths(
191
191
  formalPath: path.join(taskDir, tuple.artifact),
192
192
  stagingPath: path.join(root, 'candidate.md'),
193
193
  baselinePath: path.join(root, 'baseline.md'),
194
- finalPath: path.join(root, 'final.md'),
194
+ generationsPath: path.join(root, 'generations'),
195
195
  baselineSha256: '',
196
196
  baselineSemanticDigest: ''
197
197
  };
@@ -221,16 +221,22 @@ function contextWithIntent(context: ArtifactRecoveryContext, intent: ArtifactRec
221
221
 
222
222
  function createIntent(context: ArtifactRecoveryContext, now: number): ArtifactRecoveryIntent {
223
223
  return {
224
- version: 3,
224
+ version: 4,
225
225
  taskId: context.taskId,
226
226
  family: context.family,
227
227
  artifact: context.artifact,
228
228
  round: context.round,
229
- state: 'awaiting-recovery',
229
+ state: 'awaiting-preflight-recovery',
230
230
  baselineSha256: context.baselineSha256,
231
231
  baselineSemanticDigest: context.baselineSemanticDigest,
232
232
  stagingId: context.stagingId,
233
233
  candidateSha256: context.baselineSha256,
234
+ preflightArtifactSha256: null,
235
+ preflightSemanticDigest: null,
236
+ activeGenerationSha256: null,
237
+ activeGenerationSemanticDigest: null,
238
+ pendingGenerationSha256: null,
239
+ pendingGenerationSemanticDigest: null,
234
240
  finalArtifactSha256: null,
235
241
  finalSemanticDigest: null,
236
242
  recoveryOperationId: context.recoveryId,
@@ -267,7 +273,7 @@ export function beginArtifactRecovery(
267
273
  fail('ARTIFACT_RECOVERY_BASELINE_MISMATCH', 'formal artifact does not match the recovery baseline');
268
274
  }
269
275
  const existing = readArtifactRecoveryIntent(prepared.repoRoot, prepared.taskId, prepared.family, prepared.artifact);
270
- if (existing && existing.state !== 'aborted' && existing.state !== 'consumed') {
276
+ if (existing && !['aborted', 'consumed', 'passed'].includes(existing.state)) {
271
277
  fail('ARTIFACT_RECOVERY_CONFLICT', 'an active recovery journal already exists for this artifact');
272
278
  }
273
279
  writeBytes(prepared.baselinePath, baselineBytes);
@@ -287,6 +293,7 @@ export function recordArtifactRecoveryPassed(
287
293
  assertRecoveryId(context.recoveryId);
288
294
  assertRegular(context.formalPath, 'formal artifact');
289
295
  return runLocked(context, 'task-artifact.recovery.record-passed', () => {
296
+ assertRecoveryRoot(context);
290
297
  const current = readStableFileSync(context.formalPath, { maxBytes: MAX_ARTIFACT_BYTES });
291
298
  const prepared = {
292
299
  ...context,
@@ -306,18 +313,18 @@ export function recordArtifactRecoveryPassed(
306
313
  && existing.finalSemanticDigest === prepared.baselineSemanticDigest) {
307
314
  return recoveryContextFromIntent(prepared.repoRoot, prepared.taskDir, existing);
308
315
  }
309
- if (existing && existing.state !== 'aborted') {
316
+ if (existing && existing.state !== 'aborted' && existing.state !== 'passed') {
310
317
  fail('ARTIFACT_RECOVERY_CONFLICT', 'an active recovery journal already exists for this artifact');
311
318
  }
312
- const now = Date.now();
313
- const next: ArtifactRecoveryIntent = {
314
- ...createIntent(prepared, now),
315
- state: 'passed',
316
- candidateSha256: prepared.baselineSha256,
317
- finalArtifactSha256: prepared.baselineSha256,
318
- finalSemanticDigest: prepared.baselineSemanticDigest
319
- };
320
- writeArtifactRecoveryIntent(prepared.repoRoot, next, { expected: existing });
319
+ const base = createIntent(prepared, Date.now());
320
+ writeBytes(prepared.baselinePath, current.bytes);
321
+ writeBytes(prepared.stagingPath, current.bytes);
322
+ writeArtifactRecoveryIntent(prepared.repoRoot, base, { expected: existing });
323
+ const staged = stageArtifactCandidate(prepared, current.bytes, { lockAlreadyHeld: true });
324
+ prepareArtifactRecoveryCommit(prepared, staged.candidateSha256, staged.semanticDigest, { lockAlreadyHeld: true });
325
+ commitArtifactRecovery(prepared, { lockAlreadyHeld: true });
326
+ prepareArtifactRecoveryFinal(prepared, current.bytes, { lockAlreadyHeld: true });
327
+ commitArtifactRecovery(prepared, { lockAlreadyHeld: true });
321
328
  return prepared;
322
329
  }, options.lockAlreadyHeld);
323
330
  }
@@ -330,7 +337,7 @@ export function stageArtifactCandidate(
330
337
  return runLocked(context, 'task-artifact.recovery.stage', () => {
331
338
  assertRecoveryRoot(context);
332
339
  const intent = readIntentForContext(context);
333
- if (intent.state !== 'awaiting-recovery') fail('ARTIFACT_RECOVERY_STATE_INVALID', `cannot stage a candidate from '${intent.state}'`);
340
+ if (intent.state !== 'awaiting-preflight-recovery') fail('ARTIFACT_RECOVERY_STATE_INVALID', `cannot stage a candidate from '${intent.state}'`);
334
341
  const candidateSha256 = sha256Content(candidateBytes.toString('utf8'));
335
342
  const semanticDigest = canonicalSemanticDigest(candidateBytes.toString('utf8'));
336
343
  writeBytes(context.stagingPath, candidateBytes);
@@ -340,6 +347,49 @@ export function stageArtifactCandidate(
340
347
  }, options.lockAlreadyHeld);
341
348
  }
342
349
 
350
+ function generationPath(context: ArtifactRecoveryContext, sha256: string): string {
351
+ assertDigest(sha256, 'generation digest');
352
+ return path.join(context.generationsPath, `${sha256}.md`);
353
+ }
354
+
355
+ /** Persist and verify an immutable digest-addressed snapshot before it is journaled. */
356
+ function writeGeneration(context: ArtifactRecoveryContext, bytes: Buffer, sha256: string, semanticDigest: string): void {
357
+ ensureOwnedDirectoryTree(context.repoRoot, context.generationsPath);
358
+ const target = generationPath(context, sha256);
359
+ if (fs.existsSync(target)) {
360
+ const existing = readStableFileSync(target, { maxBytes: MAX_ARTIFACT_BYTES, expectedSha256: sha256 });
361
+ if (canonicalSemanticDigest(existing.bytes.toString('utf8')) !== semanticDigest) {
362
+ fail('ARTIFACT_RECOVERY_CONFLICT', 'existing generation does not match its semantic digest');
363
+ }
364
+ return;
365
+ }
366
+ writeBytes(target, bytes);
367
+ fs.chmodSync(target, 0o400);
368
+ const sealed = readStableFileSync(target, { maxBytes: MAX_ARTIFACT_BYTES, expectedSha256: sha256 });
369
+ if (canonicalSemanticDigest(sealed.bytes.toString('utf8')) !== semanticDigest) {
370
+ fail('ARTIFACT_RECOVERY_CANDIDATE_MISMATCH', 'generation does not match its validated semantic digest');
371
+ }
372
+ }
373
+
374
+ function publishGeneration(context: ArtifactRecoveryContext, sha256: string, semanticDigest: string, baselineSha256: string): void {
375
+ const target = readStableFileSync(context.formalPath, { maxBytes: MAX_ARTIFACT_BYTES });
376
+ const generation = readStableFileSync(generationPath(context, sha256), { maxBytes: MAX_ARTIFACT_BYTES, expectedSha256: sha256 });
377
+ if (canonicalSemanticDigest(generation.bytes.toString('utf8')) !== semanticDigest) {
378
+ fail('ARTIFACT_RECOVERY_CONFLICT', 'generation semantic digest does not match the journal');
379
+ }
380
+ if (target.sha256 === sha256) return;
381
+ if (target.sha256 !== baselineSha256) fail('ARTIFACT_RECOVERY_CONFLICT', 'formal artifact changed outside the expected generation');
382
+ const publishPath = path.join(path.dirname(context.stagingPath), 'publish.md');
383
+ unlinkIfPresent(publishPath);
384
+ writeBytes(publishPath, generation.bytes);
385
+ fs.chmodSync(publishPath, 0o400);
386
+ fs.renameSync(publishPath, context.formalPath);
387
+ const published = readStableFileSync(context.formalPath, { maxBytes: MAX_ARTIFACT_BYTES, expectedSha256: sha256 });
388
+ if (canonicalSemanticDigest(published.bytes.toString('utf8')) !== semanticDigest) {
389
+ fail('ARTIFACT_RECOVERY_CONFLICT', 'published artifact does not match its generation');
390
+ }
391
+ }
392
+
343
393
  export function prepareArtifactRecoveryCommit(
344
394
  context: ArtifactRecoveryContext,
345
395
  finalSha256: string,
@@ -350,24 +400,21 @@ export function prepareArtifactRecoveryCommit(
350
400
  assertDigest(finalSemanticDigest, 'final semantic digest');
351
401
  return runLocked(context, 'task-artifact.recovery.prepare', () => {
352
402
  const intent = readIntentForContext(context);
353
- if (intent.state !== 'awaiting-recovery') fail('ARTIFACT_RECOVERY_STATE_INVALID', `cannot prepare a commit from '${intent.state}'`);
403
+ if (intent.state !== 'awaiting-preflight-recovery') fail('ARTIFACT_RECOVERY_STATE_INVALID', `cannot prepare a preflight from '${intent.state}'`);
354
404
  assertRecoveryRoot(context);
355
405
  const staged = readStableFileSync(context.stagingPath, { maxBytes: MAX_ARTIFACT_BYTES });
356
406
  if (staged.sha256 !== finalSha256 || canonicalSemanticDigest(staged.bytes.toString('utf8')) !== finalSemanticDigest) {
357
407
  fail('ARTIFACT_RECOVERY_CANDIDATE_MISMATCH', 'staged candidate does not match the finalizer digest');
358
408
  }
359
- writeBytes(context.finalPath, staged.bytes);
360
- fs.chmodSync(context.finalPath, 0o400);
361
- const sealed = readStableFileSync(context.finalPath, { maxBytes: MAX_ARTIFACT_BYTES });
362
- if (sealed.sha256 !== finalSha256 || canonicalSemanticDigest(sealed.bytes.toString('utf8')) !== finalSemanticDigest) {
363
- fail('ARTIFACT_RECOVERY_CANDIDATE_MISMATCH', 'sealed candidate does not match the finalizer digest');
364
- }
409
+ writeGeneration(context, staged.bytes, finalSha256, finalSemanticDigest);
365
410
  const next: ArtifactRecoveryIntent = {
366
411
  ...intent,
367
- state: 'finalize-ready',
412
+ state: 'preflight-ready',
368
413
  candidateSha256: staged.sha256,
369
- finalArtifactSha256: finalSha256,
370
- finalSemanticDigest,
414
+ preflightArtifactSha256: finalSha256,
415
+ preflightSemanticDigest: finalSemanticDigest,
416
+ activeGenerationSha256: finalSha256,
417
+ activeGenerationSemanticDigest: finalSemanticDigest,
371
418
  errorCode: null,
372
419
  errorMessage: null,
373
420
  updatedAt: Date.now()
@@ -378,59 +425,61 @@ export function prepareArtifactRecoveryCommit(
378
425
  }
379
426
 
380
427
  function markCommitStarted(context: ArtifactRecoveryContext, intent: ArtifactRecoveryIntent): ArtifactRecoveryIntent {
381
- if (intent.state === 'commit-started') return intent;
382
- if (intent.state !== 'finalize-ready') fail('ARTIFACT_RECOVERY_STATE_INVALID', `cannot commit from '${intent.state}'`);
383
- const next: ArtifactRecoveryIntent = { ...intent, state: 'commit-started', updatedAt: Date.now() };
428
+ if (intent.state === 'preflight-commit-started' || intent.state === 'commit-started') return intent;
429
+ if (intent.state !== 'preflight-ready' && intent.state !== 'full-finalizer-ready') fail('ARTIFACT_RECOVERY_STATE_INVALID', `cannot commit from '${intent.state}'`);
430
+ const next: ArtifactRecoveryIntent = { ...intent, state: intent.state === 'preflight-ready' ? 'preflight-commit-started' : 'commit-started', updatedAt: Date.now() };
384
431
  writeArtifactRecoveryIntent(context.repoRoot, next, { expected: intent });
385
432
  return next;
386
433
  }
387
434
 
388
435
  export function commitArtifactRecovery(
389
436
  context: ArtifactRecoveryContext,
390
- options: Readonly<{ lockAlreadyHeld?: boolean }> = {}
437
+ options: Readonly<{ lockAlreadyHeld?: boolean; afterPublish?: () => void }> = {}
391
438
  ): ArtifactRecoveryIntent {
392
439
  return runLocked(context, 'task-artifact.recovery.commit', () => {
393
440
  let intent = readIntentForContext(context);
394
441
  if (intent.state === 'passed' || intent.state === 'consumed') return intent;
395
442
  assertRecoveryRoot(context);
396
443
  intent = markCommitStarted(context, intent);
397
- const target = readStableFileSync(context.formalPath, { maxBytes: MAX_ARTIFACT_BYTES });
398
- if (target.sha256 === intent.finalArtifactSha256) {
399
- const passed: ArtifactRecoveryIntent = { ...intent, state: 'passed', updatedAt: Date.now() };
444
+ if (intent.state === 'preflight-commit-started') {
445
+ publishGeneration(context, intent.activeGenerationSha256!, intent.activeGenerationSemanticDigest!, intent.baselineSha256);
446
+ const passed = { ...intent, state: 'preflight-passed' as const, updatedAt: Date.now() };
400
447
  writeArtifactRecoveryIntent(context.repoRoot, passed, { expected: intent });
401
448
  return passed;
402
449
  }
403
- if (target.sha256 !== intent.baselineSha256) fail('ARTIFACT_RECOVERY_CONFLICT', 'formal artifact changed outside the recovery baseline');
404
- const sealed = readStableFileSync(context.finalPath, {
405
- maxBytes: MAX_ARTIFACT_BYTES,
406
- expectedSha256: intent.finalArtifactSha256!
407
- });
408
- if (canonicalSemanticDigest(sealed.bytes.toString('utf8')) !== intent.finalSemanticDigest) {
409
- fail('ARTIFACT_RECOVERY_CONFLICT', 'sealed artifact does not match the staged final semantic digest');
410
- }
411
- // Publish the bytes that were validated above. The sealed path is only a
412
- // recovery input; renaming it directly would reopen a same-uid path swap
413
- // between validation and publication.
414
- const publishPath = path.join(path.dirname(context.finalPath), 'publish.md');
415
- unlinkIfPresent(publishPath);
416
- writeBytes(publishPath, sealed.bytes);
417
- fs.chmodSync(publishPath, 0o400);
418
- const publish = readStableFileSync(publishPath, {
419
- maxBytes: MAX_ARTIFACT_BYTES,
420
- expectedSha256: intent.finalArtifactSha256!
421
- });
422
- if (canonicalSemanticDigest(publish.bytes.toString('utf8')) !== intent.finalSemanticDigest) {
423
- fail('ARTIFACT_RECOVERY_CONFLICT', 'publish artifact does not match the staged final semantic digest');
424
- }
425
- fs.renameSync(publishPath, context.formalPath);
426
- const published = readStableFileSync(context.formalPath, { maxBytes: MAX_ARTIFACT_BYTES });
427
- if (published.sha256 !== intent.finalArtifactSha256) fail('ARTIFACT_RECOVERY_CONFLICT', 'formal artifact does not match the staged final digest');
428
- const passed: ArtifactRecoveryIntent = { ...intent, state: 'passed', updatedAt: Date.now() };
450
+ publishGeneration(context, intent.pendingGenerationSha256!, intent.pendingGenerationSemanticDigest!, intent.activeGenerationSha256!);
451
+ options.afterPublish?.();
452
+ const passed: ArtifactRecoveryIntent = {
453
+ ...intent, state: 'passed', activeGenerationSha256: intent.pendingGenerationSha256,
454
+ activeGenerationSemanticDigest: intent.pendingGenerationSemanticDigest,
455
+ pendingGenerationSha256: null, pendingGenerationSemanticDigest: null, updatedAt: Date.now()
456
+ };
429
457
  writeArtifactRecoveryIntent(context.repoRoot, passed, { expected: intent });
430
458
  return passed;
431
459
  }, options.lockAlreadyHeld);
432
460
  }
433
461
 
462
+ export function prepareArtifactRecoveryFinal(
463
+ context: ArtifactRecoveryContext,
464
+ finalBytes: Buffer,
465
+ options: Readonly<{ lockAlreadyHeld?: boolean }> = {}
466
+ ): ArtifactRecoveryIntent {
467
+ return runLocked(context, 'task-artifact.recovery.finalize', () => {
468
+ const intent = readIntentForContext(context);
469
+ if (intent.state !== 'preflight-passed') fail('ARTIFACT_RECOVERY_STATE_INVALID', `cannot finalize from '${intent.state}'`);
470
+ const finalSha256 = sha256Content(finalBytes.toString('utf8'));
471
+ const finalSemanticDigest = canonicalSemanticDigest(finalBytes.toString('utf8'));
472
+ if (finalSha256 !== intent.activeGenerationSha256) writeGeneration(context, finalBytes, finalSha256, finalSemanticDigest);
473
+ const next: ArtifactRecoveryIntent = {
474
+ ...intent, state: 'full-finalizer-ready', pendingGenerationSha256: finalSha256,
475
+ pendingGenerationSemanticDigest: finalSemanticDigest, finalArtifactSha256: finalSha256,
476
+ finalSemanticDigest, updatedAt: Date.now()
477
+ };
478
+ writeArtifactRecoveryIntent(context.repoRoot, next, { expected: intent });
479
+ return next;
480
+ }, options.lockAlreadyHeld);
481
+ }
482
+
434
483
  export function abortArtifactRecovery(
435
484
  context: ArtifactRecoveryContext,
436
485
  code: string,
@@ -462,19 +511,23 @@ export function reconcileArtifactRecovery(
462
511
  lockAlreadyHeld?: boolean;
463
512
  validateFinal?: () => boolean;
464
513
  restoreBaseline?: boolean;
514
+ afterPublish?: () => void;
465
515
  }> = {}
466
516
  ): ArtifactRecoveryReconcileResult {
467
517
  return runLocked(context, 'task-artifact.recovery.reconcile', () => {
468
518
  const intent = readIntentForContext(context);
469
- if (intent.state !== 'commit-started') return { status: intent.state, intent };
519
+ if (intent.state !== 'preflight-commit-started' && intent.state !== 'commit-started') return { status: intent.state, intent };
470
520
  const target = readStableFileSync(context.formalPath, { maxBytes: MAX_ARTIFACT_BYTES });
471
- if (target.sha256 === intent.finalArtifactSha256 && (options.validateFinal?.() ?? true)) {
472
- const passed: ArtifactRecoveryIntent = { ...intent, state: 'passed', updatedAt: Date.now() };
473
- writeArtifactRecoveryIntent(context.repoRoot, passed, { expected: intent });
474
- return { status: 'passed', intent: passed };
521
+ const expected = intent.state === 'preflight-commit-started' ? intent.activeGenerationSha256! : intent.pendingGenerationSha256!;
522
+ if (target.sha256 === expected && (options.validateFinal?.() ?? true)) {
523
+ const next = intent.state === 'preflight-commit-started'
524
+ ? { ...intent, state: 'preflight-passed' as const, updatedAt: Date.now() }
525
+ : { ...intent, state: 'passed' as const, activeGenerationSha256: intent.pendingGenerationSha256, activeGenerationSemanticDigest: intent.pendingGenerationSemanticDigest, pendingGenerationSha256: null, pendingGenerationSemanticDigest: null, updatedAt: Date.now() };
526
+ writeArtifactRecoveryIntent(context.repoRoot, next, { expected: intent });
527
+ return { status: next.state, intent: next };
475
528
  }
476
- if (target.sha256 === intent.baselineSha256) {
477
- const retried = commitArtifactRecovery(context, { lockAlreadyHeld: true });
529
+ if (target.sha256 === (intent.state === 'preflight-commit-started' ? intent.baselineSha256 : intent.activeGenerationSha256)) {
530
+ const retried = commitArtifactRecovery(context, { lockAlreadyHeld: true, afterPublish: options.afterPublish });
478
531
  return { status: retried.state, intent: retried };
479
532
  }
480
533
  if (options.restoreBaseline) {
@@ -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
  }
@@ -187,7 +187,6 @@ function qualificationRecoveryAction(facts: LifecycleFacts): LifecycleAction | n
187
187
  }
188
188
 
189
189
  function recommendNext(facts: LifecycleFacts): LifecycleRecommendation {
190
- if (invalidationBlocks(facts.invalidation)) return { action: null, reasonCode: 'INVALIDATION_INCOMPLETE', evidence: ['reconcile task invalidation before routing'] };
191
190
  if (facts.qualificationStale) {
192
191
  const action = qualificationRecoveryAction(facts);
193
192
  return {
@@ -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
  /**