@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
@@ -1,12 +1,11 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { execFileSync } from 'node:child_process';
3
4
 
4
5
  import { appendActivityEntry, locateActivityLog, pairEntries, startedBackedRows } from './activity-log.ts';
5
6
  import {
6
7
  buildArtifactLinkSection,
7
8
  inspectArtifactDirectory,
8
- parseReviewedInputReference,
9
- parseCodePlanInputReference,
10
9
  resolveArtifactContext,
11
10
  validateCompletedArtifact
12
11
  } from './artifact-lifecycle.ts';
@@ -24,6 +23,7 @@ import {
24
23
  import { LEDGER_SECTION_MISSING_CODE, LEDGER_SECTION_MISSING_MESSAGE, parseLedgerDocument, summarizeLedgerStage, validateLedgerRows } from './ledger.ts';
25
24
  import type { ReviewStage } from './ledger.ts';
26
25
  import { parseReviewSummary, resolveCanonicalVerdict } from './review-artifacts.ts';
26
+ import { extractReviewBaseline, extractReviewedHead, extractReviewedSnapshotTree } from './review-fingerprint.ts';
27
27
  import { resolveTaskRef } from './resolve-ref.ts';
28
28
  import { findSectionHeading } from './sections.ts';
29
29
  import { validateLifecycleExecution } from './lifecycle-execution.ts';
@@ -46,6 +46,7 @@ import { buildLifecycleFacts, canStart } from './capabilities.ts';
46
46
  import type { ExplicitTrigger, LifecycleAction, TriggerInitiator, TriggerReason } from './capabilities.ts';
47
47
  import { createInvalidationOperation, invalidationMutation, parseInvalidationDocument, targetIdFor, upsertInvalidation } from './invalidation.ts';
48
48
  import type { InvalidationTargetKind } from './invalidation.ts';
49
+ import { reconcileTaskInvalidation } from './invalidation-command.ts';
49
50
  import { consumeReworkIntents, parseReworkIntentDocument, reworkIntentMutation, supersedeReworkIntents } from './rework-intent.ts';
50
51
  import { ARTIFACT_FAMILIES, expectedQualificationRelations, parseQualificationAudit, parseTaskQualification, upstreamArtifactDigest, validateQualificationAudit } from './qualification-audit.ts';
51
52
  import type { QualificationAudit, UpstreamRelation } from './qualification-audit.ts';
@@ -113,6 +114,23 @@ type TaskEventResult = {
113
114
  operations: readonly TaskOperationSummary[]; error: TaskEventError | null;
114
115
  };
115
116
 
117
+ function approvedCleanReviewedCommit(reviewContent: string, verdict: Verdict | undefined, repoRoot: string): string | null {
118
+ if (verdict !== 'approved') return null;
119
+ const reviewedHead = extractReviewedHead(reviewContent) || extractReviewBaseline(reviewContent);
120
+ const reviewedTree = extractReviewedSnapshotTree(reviewContent);
121
+ if (!/^[a-f0-9]{40}$/.test(reviewedHead) || !/^[a-f0-9]{40}$/.test(reviewedTree)) return null;
122
+ try {
123
+ const currentHead = execFileSync('git', ['-C', repoRoot, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
124
+ if (currentHead !== reviewedHead) return null;
125
+ const currentTree = execFileSync('git', ['-C', repoRoot, 'rev-parse', 'HEAD^{tree}'], { encoding: 'utf8' }).trim();
126
+ if (currentTree !== reviewedTree) return null;
127
+ execFileSync('git', ['-C', repoRoot, 'diff-index', '--quiet', 'HEAD', '--']);
128
+ return reviewedHead;
129
+ } catch {
130
+ return null;
131
+ }
132
+ }
133
+
116
134
  const BASE_FIELDS = new Set(['taskRef', 'event', 'agent', 'dryRun', 'overrideTicket', 'overrideTarget', 'overrideScope', 'initiator', 'requestId', 'reasonCode', 'sourceFinding', 'sourceArtifact', 'sourceSha256']);
117
135
  const SCHEMAS: Record<TaskEventName, { required?: string[]; optional?: string[] }> = {
118
136
  'analyze.started': { optional: ['round'] },
@@ -402,7 +420,7 @@ function reconcileReviewCompletionReplay(
402
420
  }
403
421
  intent = reconciled.intent;
404
422
  }
405
- if (!['passed', 'consumed'].includes(intent.state)) {
423
+ if (!['passed', 'consumption-started', 'consumed'].includes(intent.state)) {
406
424
  return {
407
425
  code: 'EVENT_ARTIFACT_CONFLICT',
408
426
  message: `review finalizer provenance is incomplete for ${artifact}`
@@ -413,9 +431,7 @@ function reconcileReviewCompletionReplay(
413
431
  return { code: 'EVENT_ARTIFACT_CONFLICT', message: validated.error.message };
414
432
  }
415
433
  const content = fs.readFileSync(validated.artifact.path, 'utf8');
416
- const expectedRequestId = `review-finalize:${taskId}:${stage}:${round}`;
417
434
  if (intent.round !== round
418
- || intent.requestId !== expectedRequestId
419
435
  || intent.finalArtifactSha256 !== sha256Content(content)
420
436
  || intent.finalSemanticDigest !== canonicalSemanticDigest(content)) {
421
437
  return {
@@ -423,7 +439,7 @@ function reconcileReviewCompletionReplay(
423
439
  message: `review finalizer provenance does not match ${artifact}`
424
440
  };
425
441
  }
426
- if (intent.state === 'passed') {
442
+ if (intent.state === 'passed' || intent.state === 'consumption-started') {
427
443
  const consumed = { ...intent, state: 'consumed' as const, updatedAt: Date.now() };
428
444
  writeArtifactRecoveryIntent(repoRoot, consumed, { expected: intent });
429
445
  }
@@ -652,26 +668,20 @@ function buildCompletionReceipt(
652
668
  frontmatter: Record<string, unknown>
653
669
  ): { ok: true; receipt: ArtifactReceipt } | { ok: false; message: string } | null {
654
670
  if (family === 'code') {
655
- let content: string;
656
- try { content = fs.readFileSync(artifact.path, 'utf8'); }
657
- catch (error) { return { ok: false, message: `cannot read ${artifact.name}: ${String(error)}` }; }
658
- const input = parseCodePlanInputReference(content);
659
- if (!input) return { ok: false, message: `${artifact.name} does not reference a canonical plan artifact` };
660
671
  const startedInput = typeof frontmatter.code_input_artifact === 'string' ? frontmatter.code_input_artifact : '';
661
672
  const startedSha256 = typeof frontmatter.code_input_sha256 === 'string' ? frontmatter.code_input_sha256 : '';
662
673
  if (!startedInput || !startedSha256) return { ok: false, message: 'code.started plan input context is missing' };
663
- if (input.name !== startedInput) return { ok: false, message: `${artifact.name} plan input '${input.name}' does not match code.started input '${startedInput}'` };
664
674
  const plan = inspectArtifactDirectory(taskDir, 'plan');
665
- if (plan.status !== 'ready' || !plan.latest || plan.latest.name !== input.name) {
666
- return { ok: false, message: `code input '${input.name}' is not the latest plan artifact` };
675
+ if (plan.status !== 'ready' || !plan.latest || plan.latest.name !== startedInput) {
676
+ return { ok: false, message: `code input '${startedInput}' is not the latest plan artifact` };
667
677
  }
668
678
  try {
669
679
  const inputSha256 = sha256File(plan.latest.path);
670
- if (inputSha256 !== startedSha256) return { ok: false, message: `code input ${input.name} changed after code.started` };
680
+ if (inputSha256 !== startedSha256) return { ok: false, message: `code input ${startedInput} changed after code.started` };
671
681
  return {
672
682
  ok: true,
673
683
  receipt: {
674
- event: 'code.completed', output: artifact.name, input: input.name,
684
+ event: 'code.completed', output: artifact.name, input: startedInput,
675
685
  inputSha256, completedAt
676
686
  }
677
687
  };
@@ -681,18 +691,12 @@ function buildCompletionReceipt(
681
691
  }
682
692
  if (!family.startsWith('review-')) return null;
683
693
  const expectedFamily = reviewInputFamily(family);
684
- let content: string;
685
- try { content = fs.readFileSync(artifact.path, 'utf8'); }
686
- catch (error) { return { ok: false, message: `cannot read ${artifact.name}: ${String(error)}` }; }
687
- const input = parseReviewedInputReference(content, expectedFamily);
688
- if (!input) return { ok: false, message: `${artifact.name} does not reference a canonical ${expectedFamily} artifact` };
689
694
  const startedInput = typeof frontmatter.review_input_artifact === 'string' ? frontmatter.review_input_artifact : '';
690
695
  const startedSha256 = typeof frontmatter.review_input_sha256 === 'string' ? frontmatter.review_input_sha256 : '';
691
696
  if (!startedInput || !startedSha256) return { ok: false, message: 'review started input context is missing' };
692
- if (input.name !== startedInput) return { ok: false, message: `${artifact.name} input '${input.name}' does not match review.started input '${startedInput}'` };
693
697
  const current = inspectArtifactDirectory(taskDir, expectedFamily);
694
- if (current.status !== 'ready' || !current.latest || current.latest.name !== input.name) {
695
- return { ok: false, message: `review input '${input.name}' is not the latest ${expectedFamily} artifact` };
698
+ if (current.status !== 'ready' || !current.latest || current.latest.name !== startedInput) {
699
+ return { ok: false, message: `review input '${startedInput}' is not the latest ${expectedFamily} artifact` };
696
700
  }
697
701
  const event = family === 'review-analysis'
698
702
  ? 'review-analysis.completed' as const
@@ -701,16 +705,16 @@ function buildCompletionReceipt(
701
705
  const stat = fs.lstatSync(current.latest.path);
702
706
  if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('review input is not a regular file');
703
707
  const inputSha256 = sha256File(current.latest.path);
704
- if (inputSha256 !== startedSha256) return { ok: false, message: `review input ${input.name} changed after review.started` };
708
+ if (inputSha256 !== startedSha256) return { ok: false, message: `review input ${startedInput} changed after review.started` };
705
709
  return {
706
710
  ok: true,
707
711
  receipt: {
708
- event, output: artifact.name, input: input.name,
712
+ event, output: artifact.name, input: startedInput,
709
713
  inputSha256, completedAt
710
714
  }
711
715
  };
712
716
  } catch (error) {
713
- return { ok: false, message: `cannot hash review input ${input.name}: ${error instanceof Error ? error.message : String(error)}` };
717
+ return { ok: false, message: `cannot hash review input ${startedInput}: ${error instanceof Error ? error.message : String(error)}` };
714
718
  }
715
719
  }
716
720
 
@@ -724,6 +728,20 @@ function applyTaskEventUnlocked(request: TaskEventRequest, options: TaskEventOpt
724
728
  let content: string;
725
729
  try { content = fs.readFileSync(resolved.taskMdPath, 'utf8'); }
726
730
  catch (error) { return failed(request, { code: 'TASK_READ_FAILED', message: String(error) }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath }); }
731
+ const initialParts = eventParts(request.event);
732
+ if (initialParts.phase === 'started' && !request.dryRun) {
733
+ const reconciled = reconcileTaskInvalidation(request.taskRef, { repoRoot: resolved.repoRoot });
734
+ if (reconciled.status === 'failed') {
735
+ return failed(request, {
736
+ code: 'EVENT_TRANSITION_INVALID',
737
+ message: `INVALIDATION_RECONCILE_FAILED: ${reconciled.error?.code ?? 'UNKNOWN'}: ${reconciled.error?.message ?? ''}`
738
+ }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath });
739
+ }
740
+ if (reconciled.changed) {
741
+ try { content = fs.readFileSync(resolved.taskMdPath, 'utf8'); }
742
+ catch (error) { return failed(request, { code: 'TASK_READ_FAILED', message: String(error) }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath }); }
743
+ }
744
+ }
727
745
  let frontmatter;
728
746
  try { frontmatter = parseTypedTaskFrontmatter(content); }
729
747
  catch (error) { return failed(request, { code: 'TASK_DOCUMENT_INVALID', message: error instanceof Error ? error.message : String(error) }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath }); }
@@ -732,7 +750,6 @@ function applyTaskEventUnlocked(request: TaskEventRequest, options: TaskEventOpt
732
750
  const rows = startedBackedRows(pairEntries(section.entries));
733
751
  let normalized = request;
734
752
  let artifactContext: ArtifactContextResult | null = null;
735
- const initialParts = eventParts(request.event);
736
753
  if (initialParts.phase === 'started') {
737
754
  const openIdentity = openStartedIdentity(rows, initialParts.family);
738
755
  if (openIdentity && 'conflict' in openIdentity) return failed(request, { code: 'EVENT_LOG_CONFLICT', message: 'artifact family has more than one open started event' }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath });
@@ -963,20 +980,15 @@ function applyTaskEventUnlocked(request: TaskEventRequest, options: TaskEventOpt
963
980
  );
964
981
  if (reconciled.status === 'passed' || reconciled.status === 'consumed') reviewFinalizationIntent = reconciled.intent;
965
982
  }
966
- if (!reviewFinalizationIntent || !['passed', 'consumed'].includes(reviewFinalizationIntent.state)) {
983
+ if (!reviewFinalizationIntent || !['passed', 'consumption-started', 'consumed'].includes(reviewFinalizationIntent.state)) {
967
984
  return failed(normalized, {
968
985
  code: 'EVENT_ARTIFACT_CONFLICT',
969
986
  message: `review finalizer provenance is missing or incomplete for ${completedArtifact.name}`
970
987
  }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, fromStep: currentStep, toStep: currentStep, action: eventIdentity.action, phase: eventIdentity.phase });
971
988
  }
972
- const reviewStage = eventIdentity.family === 'review-analysis'
973
- ? 'analysis'
974
- : eventIdentity.family === 'review-plan' ? 'plan' : 'code';
975
989
  const actualSha256 = sha256File(completedArtifact.path);
976
990
  const actualSemanticDigest = canonicalSemanticDigest(reviewContent);
977
- const expectedRequestId = `review-finalize:${resolved.taskId}:${reviewStage}:${normalized.round}`;
978
991
  if (reviewFinalizationIntent.round !== normalized.round
979
- || reviewFinalizationIntent.requestId !== expectedRequestId
980
992
  || reviewFinalizationIntent.finalArtifactSha256 !== actualSha256
981
993
  || reviewFinalizationIntent.finalSemanticDigest !== actualSemanticDigest) {
982
994
  return failed(normalized, {
@@ -1072,6 +1084,11 @@ function applyTaskEventUnlocked(request: TaskEventRequest, options: TaskEventOpt
1072
1084
  } else if (eventIdentity.phase === 'completed' && eventIdentity.family.startsWith('review-')) {
1073
1085
  frontmatterRemove = ['review_input_artifact', 'review_input_sha256'];
1074
1086
  }
1087
+ if (eventIdentity.phase === 'completed' && eventIdentity.family === 'review-code' && reviewContent !== null) {
1088
+ const reviewedCommit = approvedCleanReviewedCommit(reviewContent, normalized.verdict, resolved.repoRoot);
1089
+ if (reviewedCommit) frontmatterSet.last_reviewed_commit = reviewedCommit;
1090
+ else if (normalized.verdict === 'approved') frontmatterSet.last_reviewed_commit = '';
1091
+ }
1075
1092
  if (eventIdentity.phase === 'completed') frontmatterRemove = [...(frontmatterRemove ?? []), 'qualification_input_relations'];
1076
1093
  if (eventIdentity.phase === 'started' && normalized.implementationInput) frontmatterSet.last_reviewed_commit = '';
1077
1094
  const mutations: Parameters<typeof writeTask>[0]['mutations'][number][] = [
@@ -1116,7 +1133,7 @@ function applyTaskEventUnlocked(request: TaskEventRequest, options: TaskEventOpt
1116
1133
  const expectedIntent = localFinalizationIntent;
1117
1134
  localFinalizationIntent = {
1118
1135
  ...localFinalizationIntent,
1119
- state: 'commit-started',
1136
+ state: 'consumption-started',
1120
1137
  phase: 'task-event.completed',
1121
1138
  authorityDigest: lifecycleRecoveryAttestationDigest(lifecycleAuthority),
1122
1139
  requestId: lifecycleAuthority.lifecycleRequestId,
@@ -1146,7 +1163,7 @@ function applyTaskEventUnlocked(request: TaskEventRequest, options: TaskEventOpt
1146
1163
  const expectedIntent = reviewFinalizationIntent;
1147
1164
  reviewFinalizationIntent = {
1148
1165
  ...reviewFinalizationIntent,
1149
- state: 'commit-started',
1166
+ state: 'consumption-started',
1150
1167
  phase: 'task-event.completed',
1151
1168
  updatedAt: Date.now()
1152
1169
  };
@@ -1183,7 +1200,7 @@ function applyTaskEventUnlocked(request: TaskEventRequest, options: TaskEventOpt
1183
1200
  }
1184
1201
  if (!normalized.dryRun && reviewFinalizationIntent && completedArtifact) {
1185
1202
  try {
1186
- if (reviewFinalizationIntent.state === 'commit-started') {
1203
+ if (reviewFinalizationIntent.state === 'consumption-started') {
1187
1204
  const consumed = { ...reviewFinalizationIntent, state: 'consumed' as const, updatedAt: Date.now() };
1188
1205
  writeArtifactRecoveryIntent(resolved.repoRoot, consumed, { expected: reviewFinalizationIntent });
1189
1206
  }
@@ -1231,7 +1248,7 @@ function applyTaskEvent(request: TaskEventRequest, options: TaskEventOptions = {
1231
1248
  if (invalid) return failed(request, invalid);
1232
1249
  const parts = eventParts(request.event);
1233
1250
  if (options.lockAlreadyHeld) return applyTaskEventUnlocked(request, options);
1234
- if (request.dryRun || parts.phase !== 'completed' || parts.family === 'manual-validation') {
1251
+ if (request.dryRun || !['started', 'completed'].includes(parts.phase)) {
1235
1252
  return applyTaskEventUnlocked(request, options);
1236
1253
  }
1237
1254
  const resolved = resolveTaskRef(request.taskRef, { repoRoot: options.repoRoot });
@@ -0,0 +1,133 @@
1
+ import type { VerificationStatus } from './verification-types.ts';
2
+
3
+ export type GateClassification = 'hard' | 'soft' | 'info';
4
+ export type PlatformAuditId =
5
+ | 'closed-status-labels' | 'status-label' | 'comment-marker' | 'pr-comment-marker'
6
+ | 'pr-comment-last-commit' | 'pr-comment-content' | 'comment-content' | 'task-comment-content'
7
+ | 'in-labels-computed' | 'pr-type-label' | 'in-labels-match-pr' | 'pr-assignee'
8
+ | 'requirements' | 'issue-type' | 'issue-fields' | 'milestone';
9
+
10
+ export type PlatformAuditPolicy = {
11
+ classification: GateClassification;
12
+ enabled: boolean;
13
+ expectedStatusLabel?: string;
14
+ expectedCommentMarkerKey?: 'artifact' | 'summary';
15
+ expectedPrCommentMarkerKey?: 'prSummary';
16
+ requireSpecificMilestone?: boolean;
17
+ };
18
+
19
+ export type VerificationRecord = {
20
+ type: string;
21
+ status: VerificationStatus;
22
+ message: string;
23
+ fail_type?: string;
24
+ checkId?: string;
25
+ classification?: GateClassification;
26
+ effectiveStatus?: VerificationStatus;
27
+ reason?: string;
28
+ action?: string;
29
+ dependsOn?: string;
30
+ warnings?: string[];
31
+ subchecks?: VerificationRecord[];
32
+ };
33
+
34
+ const OPTIONAL_PLATFORM_AUDITS = new Set<PlatformAuditId>([
35
+ 'closed-status-labels', 'status-label', 'comment-marker', 'pr-comment-marker',
36
+ 'pr-comment-last-commit', 'pr-comment-content', 'comment-content', 'task-comment-content',
37
+ 'in-labels-computed', 'pr-type-label', 'in-labels-match-pr', 'pr-assignee',
38
+ 'requirements', 'issue-type', 'issue-fields', 'milestone'
39
+ ]);
40
+
41
+ const STATUS_LABELS: Record<string, string> = {
42
+ 'create-task': 'status: waiting-for-triage',
43
+ 'analyze-task': 'status: pending-design-work',
44
+ 'review-analysis': 'status: pending-design-work',
45
+ 'plan-task': 'status: pending-design-work',
46
+ 'review-plan': 'status: pending-design-work',
47
+ 'code-task': 'status: in-progress',
48
+ 'review-code': 'status: in-progress'
49
+ };
50
+
51
+ const ARTIFACT_COMMENT_SKILLS = new Set([
52
+ 'analyze-task', 'plan-task', 'review-analysis', 'review-plan', 'code-task',
53
+ 'review-code', 'review-pr', 'run-manual-validation'
54
+ ]);
55
+ const PR_AUDIT_SKILLS = new Set(['create-pr']);
56
+ const IN_LABEL_AUDIT_SKILLS = new Set(['code-task', 'commit', 'create-pr']);
57
+
58
+ export function platformAuditPolicy(checkId: PlatformAuditId, context: { skillName?: string; artifactFile?: string | null }): PlatformAuditPolicy {
59
+ const skillName = context.skillName || '';
60
+ const artifactComment = Boolean(context.artifactFile) && ARTIFACT_COMMENT_SKILLS.has(skillName);
61
+ const prAudit = PR_AUDIT_SKILLS.has(skillName);
62
+ const inLabelAudit = IN_LABEL_AUDIT_SKILLS.has(skillName);
63
+
64
+ switch (checkId) {
65
+ case 'status-label':
66
+ return { classification: 'soft', enabled: Boolean(STATUS_LABELS[skillName]), expectedStatusLabel: STATUS_LABELS[skillName] };
67
+ case 'comment-marker':
68
+ return {
69
+ classification: 'soft',
70
+ enabled: artifactComment || skillName === 'complete-task',
71
+ ...(artifactComment ? { expectedCommentMarkerKey: 'artifact' } : skillName === 'complete-task' ? { expectedCommentMarkerKey: 'summary' } : {})
72
+ };
73
+ case 'pr-comment-marker':
74
+ return {
75
+ classification: 'soft',
76
+ enabled: skillName === 'commit' || prAudit,
77
+ ...(skillName === 'commit' || prAudit ? { expectedPrCommentMarkerKey: 'prSummary' } : {})
78
+ };
79
+ case 'pr-comment-last-commit':
80
+ return {
81
+ classification: 'soft',
82
+ enabled: skillName === 'commit',
83
+ ...(skillName === 'commit' ? { expectedPrCommentMarkerKey: 'prSummary' } : {})
84
+ };
85
+ case 'pr-comment-content':
86
+ return {
87
+ classification: 'soft',
88
+ enabled: skillName === 'complete-manual-validation',
89
+ ...(skillName === 'complete-manual-validation' ? { expectedPrCommentMarkerKey: 'prSummary' } : {})
90
+ };
91
+ case 'comment-content':
92
+ return { classification: 'soft', enabled: artifactComment, expectedCommentMarkerKey: 'artifact' };
93
+ case 'task-comment-content':
94
+ return { classification: 'soft', enabled: true };
95
+ case 'in-labels-computed':
96
+ return { classification: 'soft', enabled: inLabelAudit };
97
+ case 'pr-type-label':
98
+ case 'in-labels-match-pr':
99
+ case 'pr-assignee':
100
+ return { classification: 'soft', enabled: prAudit };
101
+ case 'requirements':
102
+ return { classification: 'soft', enabled: skillName === 'complete-task' };
103
+ case 'milestone':
104
+ return { classification: 'soft', enabled: true, requireSpecificMilestone: skillName === 'code-task' };
105
+ case 'closed-status-labels':
106
+ case 'issue-type':
107
+ case 'issue-fields':
108
+ return { classification: 'soft', enabled: true };
109
+ }
110
+ }
111
+
112
+ export function classificationForCheck(checkId: string): GateClassification {
113
+ return checkId.startsWith('platform.') && OPTIONAL_PLATFORM_AUDITS.has(checkId.slice('platform.'.length) as PlatformAuditId)
114
+ ? 'soft'
115
+ : 'hard';
116
+ }
117
+
118
+ export function effectiveStatus(status: VerificationStatus, classification: GateClassification): VerificationStatus {
119
+ return classification === 'hard' ? status : 'pass';
120
+ }
121
+
122
+ export function normalizeVerificationRecord(record: VerificationRecord): VerificationRecord {
123
+ const checkId = record.checkId ?? record.type;
124
+ const classification = record.classification ?? classificationForCheck(checkId);
125
+ return {
126
+ ...record,
127
+ checkId,
128
+ classification,
129
+ effectiveStatus: record.effectiveStatus ?? effectiveStatus(record.status, classification),
130
+ reason: record.reason ?? record.fail_type ?? (classification === 'info' ? 'NOT_APPLICABLE' : record.status === 'pass' ? 'OK' : 'CHECK_FAILED'),
131
+ action: record.action ?? (classification === 'info' || record.status === 'pass' ? 'No action required' : 'Review the check evidence and retry after correction')
132
+ };
133
+ }
@@ -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);
@@ -5,7 +5,6 @@ import { scanVisibleMarkdown } from './markdown.ts';
5
5
  import { parseArtifactName } from './artifact-name.ts';
6
6
  import {
7
7
  hasOpenArtifactRound,
8
- parseCodePlanInputReference,
9
8
  validateArtifactPublication,
10
9
  validateCompletedArtifact
11
10
  } from './artifact-lifecycle.ts';
@@ -19,8 +18,10 @@ import {
19
18
  beginArtifactRecovery,
20
19
  commitArtifactRecovery,
21
20
  consumeArtifactRecovery,
21
+ prepareArtifactRecoveryFinal,
22
22
  prepareArtifactRecoveryCommit,
23
23
  recordArtifactRecoveryPassed,
24
+ reconcileArtifactRecovery,
24
25
  recoveryContextFromIntent,
25
26
  stageArtifactCandidate
26
27
  } from './artifact-recovery.ts';
@@ -204,12 +205,15 @@ function validateLocalArtifact(
204
205
  if (!decisionDetails.ok) diagnostics.push({ code: 'LOCAL_DECISION_DETAIL_DUPLICATE', message: decisionDetails.message, line: null });
205
206
 
206
207
  if (options.taskContent !== undefined) {
207
- const expected = expectedQualificationRelations(options.taskContent, options.family);
208
- if (!expected.ok) diagnostics.push({ code: 'LOCAL_QUALIFICATION_AUDIT_INVALID', message: `${expected.code}: ${expected.message}`, line: null });
208
+ const identity = options.artifact ? parseArtifactName(options.artifact) : null;
209
+ const expected = identity?.family === options.family && hasOpenArtifactRound(options.taskContent, options.family, identity.round)
210
+ ? expectedQualificationRelations(options.taskContent, options.family)
211
+ : undefined;
212
+ if (expected && !expected.ok) diagnostics.push({ code: 'LOCAL_QUALIFICATION_AUDIT_INVALID', message: `${expected.code}: ${expected.message}`, line: null });
209
213
  const qualification = validateQualificationAudit(options.taskContent, content, {
210
214
  family: options.family,
211
215
  artifact: options.artifact,
212
- expectedUpstreamRelations: expected.ok ? expected.relations : undefined
216
+ expectedUpstreamRelations: expected?.ok ? expected.relations : undefined
213
217
  });
214
218
  if (!qualification.ok) diagnostics.push({ code: 'LOCAL_QUALIFICATION_AUDIT_INVALID', message: `${qualification.code}: ${qualification.message}`, line: null });
215
219
  }
@@ -265,7 +269,8 @@ function tupleFor(
265
269
  function prepareLocalArtifact(
266
270
  request: LocalArtifactFinalizationRequest,
267
271
  candidate?: string,
268
- authority?: LifecycleRecoveryAttestationV1
272
+ authority?: LifecycleRecoveryAttestationV1,
273
+ preflightOnly = false
269
274
  ): LocalArtifactPreparation {
270
275
  const failed = (code: string, message: string, extra: Partial<LocalArtifactFinalizationResult> = {}): LocalArtifactPreparation => ({
271
276
  result: failedFinalization(request, { code, message }, extra), content: candidate ?? ''
@@ -281,12 +286,14 @@ function prepareLocalArtifact(
281
286
  let taskContent: string;
282
287
  let content: string;
283
288
  let recovery: ArtifactRecoveryContext | undefined;
289
+ let recoveryState: ArtifactRecoveryIntent['state'] | undefined;
284
290
  try {
285
291
  taskContent = fs.readFileSync(resolved.taskMdPath, 'utf8');
286
292
  if (request.recoveryId) {
287
293
  const intent = readArtifactRecoveryIntent(resolved.repoRoot, resolved.taskId, request.family, request.artifact);
288
294
  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`);
295
+ 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`);
296
+ recoveryState = intent.state;
290
297
  recovery = recoveryContextFromIntent(resolved.repoRoot, resolved.taskDir, intent);
291
298
  content = fs.existsSync(recovery.stagingPath)
292
299
  ? fs.readFileSync(recovery.stagingPath, 'utf8')
@@ -315,26 +322,66 @@ function prepareLocalArtifact(
315
322
 
316
323
  if (request.recoveryId) {
317
324
  if (!validation.ok) return { result, content, repoRoot: resolved.repoRoot, recovery, authority, lockAlreadyHeld: request.lockAlreadyHeld };
325
+ if (preflightOnly && recovery && recoveryState === 'awaiting-preflight-recovery') {
326
+ const staged = stageArtifactCandidate(recovery, Buffer.from(content, 'utf8'), { lockAlreadyHeld: request.lockAlreadyHeld });
327
+ prepareArtifactRecoveryCommit(recovery, staged.candidateSha256, staged.semanticDigest, { lockAlreadyHeld: request.lockAlreadyHeld });
328
+ }
318
329
  return {
319
- result: { ...result, status: 'passed', error: null, recovery: recoveryInfo(recovery!) },
330
+ result: { ...result, status: 'passed', changed: false, error: null, recovery: recoveryInfo(recovery!) },
320
331
  content, repoRoot: resolved.repoRoot, recovery, authority, lockAlreadyHeld: request.lockAlreadyHeld
321
332
  };
322
333
  }
323
334
 
324
335
  const existing = readArtifactRecoveryIntent(resolved.repoRoot, resolved.taskId, request.family, request.artifact);
325
336
  if (existing?.state === 'passed' || existing?.state === 'consumed') {
326
- if (existing.finalArtifactSha256 !== artifactSha256 || existing.finalSemanticDigest !== validation.semanticDigest) {
337
+ if (existing.state === 'consumed' && (existing.finalArtifactSha256 !== artifactSha256 || existing.finalSemanticDigest !== validation.semanticDigest)) {
327
338
  return { ...failed('LOCAL_RECOVERY_PROVENANCE_CONFLICT', 'formal artifact does not match its completed recovery journal'), content, repoRoot: resolved.repoRoot, authority, lockAlreadyHeld: request.lockAlreadyHeld };
328
339
  }
340
+ if (existing.finalArtifactSha256 === artifactSha256 && existing.finalSemanticDigest === validation.semanticDigest) {
341
+ const context = recoveryContextFromIntent(resolved.repoRoot, resolved.taskDir, existing);
342
+ return {
343
+ result: { ...result, status: 'passed', error: null, recovery: recoveryInfo(context) },
344
+ content, repoRoot: resolved.repoRoot, recovery: context, authority, lockAlreadyHeld: request.lockAlreadyHeld
345
+ };
346
+ }
347
+ }
348
+
349
+ if (existing && ['preflight-ready', 'preflight-commit-started', 'preflight-passed', 'full-finalizer-ready'].includes(existing.state)) {
329
350
  const context = recoveryContextFromIntent(resolved.repoRoot, resolved.taskDir, existing);
330
351
  return {
331
- result: { ...result, status: 'passed', error: null, recovery: recoveryInfo(context) },
352
+ result: { ...result, status: 'passed', changed: false, error: null, recovery: recoveryInfo(context) },
353
+ content, repoRoot: resolved.repoRoot, recovery: context, authority, lockAlreadyHeld: request.lockAlreadyHeld
354
+ };
355
+ }
356
+ if (existing?.state === 'commit-started') {
357
+ if (existing.finalArtifactSha256 !== artifactSha256 || existing.finalSemanticDigest !== validation.semanticDigest) {
358
+ return { ...failed('LOCAL_RECOVERY_PROVENANCE_CONFLICT', 'formal artifact does not match the interrupted recovery journal'), content, repoRoot: resolved.repoRoot, authority, lockAlreadyHeld: request.lockAlreadyHeld };
359
+ }
360
+ const context = recoveryContextFromIntent(resolved.repoRoot, resolved.taskDir, existing);
361
+ const reconciled = reconcileArtifactRecovery(context, {
362
+ lockAlreadyHeld: request.lockAlreadyHeld,
363
+ validateFinal: () => validation.semanticDigest === existing.finalSemanticDigest
364
+ });
365
+ if (reconciled.status !== 'passed') {
366
+ return { ...failed('LOCAL_RECOVERY_PROVENANCE_CONFLICT', `interrupted recovery could not be reconciled: ${reconciled.status}`), content, repoRoot: resolved.repoRoot, recovery: context, authority, lockAlreadyHeld: request.lockAlreadyHeld };
367
+ }
368
+ return {
369
+ result: { ...result, status: 'passed', changed: false, error: null, recovery: recoveryInfo(context) },
332
370
  content, repoRoot: resolved.repoRoot, recovery: context, authority, lockAlreadyHeld: request.lockAlreadyHeld
333
371
  };
334
372
  }
335
373
 
336
374
  try {
337
375
  if (validation.ok) {
376
+ if (preflightOnly) {
377
+ const context = beginArtifactRecovery(tupleFor(request, resolved.taskId, authority), Buffer.from(content, 'utf8'), {
378
+ repoRoot: resolved.repoRoot, taskDir: resolved.taskDir,
379
+ ...(authority ? { recoveryId: authority.operationId } : {}), lockAlreadyHeld: request.lockAlreadyHeld
380
+ });
381
+ const staged = stageArtifactCandidate(context, Buffer.from(content, 'utf8'), { lockAlreadyHeld: request.lockAlreadyHeld });
382
+ prepareArtifactRecoveryCommit(context, staged.candidateSha256, staged.semanticDigest, { lockAlreadyHeld: request.lockAlreadyHeld });
383
+ return { result: { ...result, status: 'passed', error: null, recovery: recoveryInfo(context) }, content, repoRoot: resolved.repoRoot, recovery: context, authority, lockAlreadyHeld: request.lockAlreadyHeld };
384
+ }
338
385
  const context = recordArtifactRecoveryPassed(tupleFor(request, resolved.taskId, authority), {
339
386
  repoRoot: resolved.repoRoot,
340
387
  taskDir: resolved.taskDir,
@@ -344,7 +391,7 @@ function prepareLocalArtifact(
344
391
  lockAlreadyHeld: request.lockAlreadyHeld
345
392
  });
346
393
  recovery = context;
347
- return { result: { ...result, status: 'passed', error: null, recovery: recoveryInfo(context) }, content, repoRoot: resolved.repoRoot, recovery, authority, lockAlreadyHeld: request.lockAlreadyHeld };
394
+ return { result: { ...result, status: 'passed', changed: false, error: null, recovery: recoveryInfo(context) }, content, repoRoot: resolved.repoRoot, recovery, authority, lockAlreadyHeld: request.lockAlreadyHeld };
348
395
  }
349
396
  const context = beginArtifactRecovery(tupleFor(request, resolved.taskId, authority), Buffer.from(content, 'utf8'), {
350
397
  repoRoot: resolved.repoRoot,
@@ -362,7 +409,10 @@ function prepareLocalArtifact(
362
409
  }
363
410
  }
364
411
 
365
- function commitLocalArtifactProvenance(prepared: LocalArtifactPreparation): LocalArtifactFinalizationResult {
412
+ function commitLocalArtifactProvenance(
413
+ prepared: LocalArtifactPreparation,
414
+ options: Readonly<{ afterPublish?: () => void }> = {}
415
+ ): LocalArtifactFinalizationResult {
366
416
  if (!prepared.recovery || !prepared.repoRoot || prepared.result.status === 'failed' && !prepared.result.recovery) return prepared.result;
367
417
  if (prepared.result.status === 'failed') return prepared.result;
368
418
  try {
@@ -372,26 +422,42 @@ function commitLocalArtifactProvenance(prepared: LocalArtifactPreparation): Loca
372
422
  if (intent.state === 'passed' || intent.state === 'consumed') {
373
423
  return {
374
424
  ...prepared.result,
425
+ changed: false,
375
426
  artifactSha256: intent.finalArtifactSha256,
376
427
  semanticDigest: intent.finalSemanticDigest,
377
428
  error: null
378
429
  };
379
- } else if (intent.state === 'finalize-ready' || intent.state === 'commit-started') {
380
- committed = commitArtifactRecovery(prepared.recovery, { lockAlreadyHeld: prepared.lockAlreadyHeld });
381
- } else {
430
+ } else if (intent.state === 'commit-started') {
431
+ const reconciled = reconcileArtifactRecovery(prepared.recovery, {
432
+ lockAlreadyHeld: prepared.lockAlreadyHeld,
433
+ afterPublish: options.afterPublish
434
+ });
435
+ committed = reconciled.intent;
436
+ } else if (intent.state === 'awaiting-preflight-recovery') {
382
437
  const staged = stageArtifactCandidate(prepared.recovery, Buffer.from(prepared.content, 'utf8'), { lockAlreadyHeld: prepared.lockAlreadyHeld });
383
438
  prepareArtifactRecoveryCommit(prepared.recovery, staged.candidateSha256, staged.semanticDigest, { lockAlreadyHeld: prepared.lockAlreadyHeld });
384
439
  committed = commitArtifactRecovery(prepared.recovery, { lockAlreadyHeld: prepared.lockAlreadyHeld });
385
440
  }
386
- if (committed.state !== 'passed') throw new Error(`ARTIFACT_RECOVERY_STATE_INVALID: commit ended in '${committed.state}'`);
441
+ if (intent.state === 'preflight-ready' || intent.state === 'preflight-commit-started') {
442
+ committed = commitArtifactRecovery(prepared.recovery, { lockAlreadyHeld: prepared.lockAlreadyHeld });
443
+ }
444
+ if (committed?.state === 'preflight-passed' || intent.state === 'preflight-passed') {
445
+ prepareArtifactRecoveryFinal(prepared.recovery, Buffer.from(prepared.content, 'utf8'), { lockAlreadyHeld: prepared.lockAlreadyHeld });
446
+ committed = commitArtifactRecovery(prepared.recovery, { lockAlreadyHeld: prepared.lockAlreadyHeld, afterPublish: options.afterPublish });
447
+ } else if (intent.state === 'full-finalizer-ready') {
448
+ committed = commitArtifactRecovery(prepared.recovery, { lockAlreadyHeld: prepared.lockAlreadyHeld, afterPublish: options.afterPublish });
449
+ }
450
+ if (!committed || committed.state !== 'passed') throw new Error(`ARTIFACT_RECOVERY_STATE_INVALID: commit ended in '${committed?.state ?? intent.state}'`);
387
451
  return {
388
452
  ...prepared.result,
389
453
  status: 'passed',
454
+ changed: false,
390
455
  artifactSha256: committed.finalArtifactSha256,
391
456
  semanticDigest: committed.finalSemanticDigest,
392
457
  error: null
393
458
  };
394
459
  } catch (error) {
460
+ if (error instanceof Error && error.message.startsWith('TASK_WORKFLOW_FAULT_INJECTED:')) throw error;
395
461
  return { ...prepared.result, status: 'failed', error: {
396
462
  code: 'LOCAL_RECOVERY_COMMIT_FAILED', message: String(error)
397
463
  } };
@@ -417,10 +483,20 @@ function finalizeLocalArtifact(
417
483
  return result;
418
484
  }
419
485
 
486
+ /** Validate and seal an immutable preflight generation without publishing the formal artifact. */
487
+ function preflightLocalArtifact(
488
+ request: LocalArtifactFinalizationRequest,
489
+ authority?: LifecycleRecoveryAttestationV1
490
+ ): LocalArtifactFinalizationResult {
491
+ const prepared = prepareLocalArtifact(request, undefined, authority, true);
492
+ return prepared.result;
493
+ }
494
+
420
495
  export {
421
496
  LOCAL_ARTIFACT_REQUIRED_SECTIONS,
422
497
  consumeLocalArtifactFinalizationIntent,
423
498
  finalizeLocalArtifact,
499
+ preflightLocalArtifact,
424
500
  prepareLocalArtifact,
425
501
  commitLocalArtifactProvenance,
426
502
  readArtifactRecoveryIntent as readLocalArtifactFinalizationIntent,