@fitlab-ai/agent-infra 0.9.15 → 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 (224) 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/agent-clients/adapters/codex-lifecycle/sandbox-controller.js +6 -2
  6. package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +43 -3
  7. package/dist/lib/fs/durable-write.js +2 -0
  8. package/dist/lib/internal/cli-route-inventory.js +3 -4
  9. package/dist/lib/internal/manual-validation.js +17 -8
  10. package/dist/lib/internal/platform-comment.js +60 -2
  11. package/dist/lib/internal/task-artifact.js +1 -1
  12. package/dist/lib/internal/task-finalization.js +2 -2
  13. package/dist/lib/internal/task-lifecycle.js +4 -4
  14. package/dist/lib/internal/task-operation-registry.js +22 -13
  15. package/dist/lib/internal/task-orchestration.js +2 -2
  16. package/dist/lib/internal/task-review.js +12 -7
  17. package/dist/lib/platform/activity-recovery.js +159 -0
  18. package/dist/lib/platform/issue-comments.js +89 -31
  19. package/dist/lib/platform/issues.js +1 -1
  20. package/dist/lib/platform/pr-change-report.js +5 -4
  21. package/dist/lib/platform/pr-summary.js +64 -32
  22. package/dist/lib/platform/pull-requests.js +3 -3
  23. package/dist/lib/platform/task-comment-projection.js +70 -0
  24. package/dist/lib/platform/task-identities.js +2 -9
  25. package/dist/lib/platform/task-recovery.js +55 -0
  26. package/dist/lib/platform/verification-sync.js +45 -7
  27. package/dist/lib/pr-review/evidence-grading.js +4 -8
  28. package/dist/lib/sandbox/commands/rm.js +21 -2
  29. package/dist/lib/sandbox/control/executor.js +4 -2
  30. package/dist/lib/sandbox/control/server.js +29 -9
  31. package/dist/lib/sandbox/control/state.js +7 -1
  32. package/dist/lib/sandbox/control/task-workflow.js +1 -1
  33. package/dist/lib/sandbox/control/workflow-executor.js +87 -35
  34. package/dist/lib/sandbox/index.js +1 -2
  35. package/dist/lib/sandbox/removal.js +116 -1011
  36. package/dist/lib/sandbox/workspace-identity.js +17 -1
  37. package/dist/lib/server/process-state.js +17 -5
  38. package/dist/lib/task/artifact-command.js +11 -30
  39. package/dist/lib/task/artifact-operations.js +17 -231
  40. package/dist/lib/task/artifact-recovery.js +476 -0
  41. package/dist/lib/task/artifact-repair-intent.js +90 -39
  42. package/dist/lib/task/commit-operation.js +31 -4
  43. package/dist/lib/task/control-authority.js +39 -8
  44. package/dist/lib/task/control-recovery.js +14 -2
  45. package/dist/lib/task/create-service.js +1 -8
  46. package/dist/lib/task/delegation-receipts.js +64 -3
  47. package/dist/lib/task/events.js +165 -23
  48. package/dist/lib/task/human-override.js +5 -8
  49. package/dist/lib/task/intermediate-cleanup.js +91 -8
  50. package/dist/lib/task/lifecycle-recovery.js +549 -0
  51. package/dist/lib/task/lifecycle.js +12 -3
  52. package/dist/lib/task/local-artifact-finalization.js +212 -123
  53. package/dist/lib/task/manual-validation-receipt.js +5 -1
  54. package/dist/lib/task/orchestration.js +52 -2
  55. package/dist/lib/task/pr-delivery-fact.js +12 -56
  56. package/dist/lib/task/recovery-warning.js +19 -0
  57. package/dist/lib/task/review-command.js +3 -2
  58. package/dist/lib/task/review-finalization.js +294 -74
  59. package/dist/lib/{host-control → task}/secure-fs.js +8 -19
  60. package/dist/lib/task/short-id.js +15 -7
  61. package/dist/lib/task/task-execution-lock.js +1 -1
  62. package/dist/lib/task/verification-engine.js +3 -14
  63. package/dist/lib/task/workflow-command.js +2 -1
  64. package/dist/lib/task/workflow-dispatch.js +161 -0
  65. package/dist/lib/version.js +1 -11
  66. package/dist/lifecycle-build-manifest.json +3 -3
  67. package/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.ts +7 -2
  68. package/lib/agent-clients/adapters/codex-lifecycle/store.ts +55 -3
  69. package/lib/fs/durable-write.ts +1 -0
  70. package/lib/internal/cli-route-inventory.ts +3 -4
  71. package/lib/internal/manual-validation.ts +17 -8
  72. package/lib/internal/platform-comment.ts +53 -2
  73. package/lib/internal/task-artifact.ts +1 -1
  74. package/lib/internal/task-finalization.ts +1 -1
  75. package/lib/internal/task-lifecycle.ts +5 -4
  76. package/lib/internal/task-operation-registry.ts +22 -13
  77. package/lib/internal/task-orchestration.ts +1 -1
  78. package/lib/internal/task-review.ts +11 -7
  79. package/lib/platform/activity-recovery.ts +179 -0
  80. package/lib/platform/issue-comments.ts +105 -30
  81. package/lib/platform/issues.ts +1 -1
  82. package/lib/platform/pr-change-report.ts +7 -5
  83. package/lib/platform/pr-summary.ts +64 -32
  84. package/lib/platform/pull-requests.ts +3 -3
  85. package/lib/platform/task-comment-projection.ts +79 -0
  86. package/lib/platform/task-identities.ts +3 -14
  87. package/lib/platform/task-recovery.ts +54 -0
  88. package/lib/platform/verification-sync.ts +48 -11
  89. package/lib/pr-review/evidence-grading.ts +4 -9
  90. package/lib/sandbox/commands/rm.ts +34 -2
  91. package/lib/sandbox/control/executor.ts +4 -2
  92. package/lib/sandbox/control/protocol.ts +6 -1
  93. package/lib/sandbox/control/server.ts +30 -10
  94. package/lib/sandbox/control/state.ts +9 -1
  95. package/lib/sandbox/control/task-workflow.ts +1 -1
  96. package/lib/sandbox/control/workflow-executor.ts +106 -35
  97. package/lib/sandbox/index.ts +1 -2
  98. package/lib/sandbox/removal.ts +126 -1128
  99. package/lib/sandbox/workspace-identity.ts +19 -3
  100. package/lib/server/process-state.ts +26 -5
  101. package/lib/task/artifact-command.ts +12 -29
  102. package/lib/task/artifact-operations.ts +22 -252
  103. package/lib/task/artifact-recovery.ts +572 -0
  104. package/lib/task/artifact-repair-intent.ts +126 -51
  105. package/lib/task/commit-operation.ts +35 -4
  106. package/lib/task/control-authority.ts +48 -15
  107. package/lib/task/control-recovery.ts +13 -1
  108. package/lib/task/create-service.ts +1 -10
  109. package/lib/task/delegation-receipts.ts +73 -2
  110. package/lib/task/events.ts +188 -24
  111. package/lib/task/human-override.ts +5 -8
  112. package/lib/task/intermediate-cleanup.ts +66 -12
  113. package/lib/task/lifecycle-recovery.ts +691 -0
  114. package/lib/task/lifecycle.ts +13 -4
  115. package/lib/task/local-artifact-finalization.ts +249 -131
  116. package/lib/task/manual-validation-receipt.ts +5 -1
  117. package/lib/task/orchestration.ts +65 -0
  118. package/lib/task/pr-delivery-fact.ts +15 -62
  119. package/lib/task/recovery-warning.ts +21 -0
  120. package/lib/task/review-command.ts +2 -1
  121. package/lib/task/review-finalization.ts +298 -84
  122. package/lib/{host-control → task}/secure-fs.ts +10 -32
  123. package/lib/task/short-id.ts +27 -5
  124. package/lib/task/task-execution-lock.ts +1 -1
  125. package/lib/task/verification-engine.ts +3 -17
  126. package/lib/task/verification-types.ts +0 -2
  127. package/lib/task/workflow-command.ts +2 -1
  128. package/lib/task/workflow-dispatch.ts +171 -0
  129. package/lib/version.ts +1 -15
  130. package/package.json +1 -1
  131. package/templates/.agents/rules/create-issue.en.md +1 -1
  132. package/templates/.agents/rules/create-issue.zh-CN.md +1 -1
  133. package/templates/.agents/rules/issue-sync.en.md +12 -2
  134. package/templates/.agents/rules/issue-sync.zh-CN.md +13 -5
  135. package/templates/.agents/rules/local-artifact-repair.en.md +47 -39
  136. package/templates/.agents/rules/local-artifact-repair.zh-CN.md +45 -36
  137. package/templates/.agents/skills/analyze-task/SKILL.en.md +6 -6
  138. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +6 -6
  139. package/templates/.agents/skills/analyze-task/config/verify.en.json +1 -1
  140. package/templates/.agents/skills/analyze-task/config/verify.zh-CN.json +1 -1
  141. package/templates/.agents/skills/block-task/SKILL.en.md +2 -2
  142. package/templates/.agents/skills/block-task/SKILL.zh-CN.md +2 -2
  143. package/templates/.agents/skills/block-task/config/verify.json +1 -1
  144. package/templates/.agents/skills/cancel-task/SKILL.en.md +3 -3
  145. package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +3 -3
  146. package/templates/.agents/skills/cancel-task/config/verify.json +1 -1
  147. package/templates/.agents/skills/code-task/SKILL.en.md +7 -7
  148. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +8 -8
  149. package/templates/.agents/skills/code-task/config/verify.en.json +1 -1
  150. package/templates/.agents/skills/code-task/config/verify.zh-CN.json +1 -1
  151. package/templates/.agents/skills/commit/reference/issue-metadata-sync.en.md +1 -1
  152. package/templates/.agents/skills/commit/reference/issue-metadata-sync.zh-CN.md +1 -1
  153. package/templates/.agents/skills/complete-manual-validation/SKILL.en.md +1 -1
  154. package/templates/.agents/skills/complete-manual-validation/SKILL.zh-CN.md +1 -1
  155. package/templates/.agents/skills/complete-task/SKILL.en.md +2 -2
  156. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +2 -2
  157. package/templates/.agents/skills/complete-task/config/verify.en.json +2 -2
  158. package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +2 -2
  159. package/templates/.agents/skills/complete-task/reference/external-delivery.en.md +1 -1
  160. package/templates/.agents/skills/complete-task/reference/external-delivery.zh-CN.md +1 -1
  161. package/templates/.agents/skills/create-pr/SKILL.en.md +2 -2
  162. package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +2 -2
  163. package/templates/.agents/skills/create-pr/config/verify.json +1 -1
  164. package/templates/.agents/skills/create-pr/reference/pr-body-template.en.md +2 -2
  165. package/templates/.agents/skills/create-pr/reference/pr-body-template.zh-CN.md +2 -2
  166. package/templates/.agents/skills/create-task/SKILL.en.md +5 -5
  167. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +5 -5
  168. package/templates/.agents/skills/create-task/config/verify.json +1 -1
  169. package/templates/.agents/skills/import-issue/SKILL.en.md +8 -8
  170. package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +8 -8
  171. package/templates/.agents/skills/import-issue/config/verify.json +2 -2
  172. package/templates/.agents/skills/plan-task/SKILL.en.md +4 -4
  173. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +4 -4
  174. package/templates/.agents/skills/plan-task/config/verify.en.json +1 -1
  175. package/templates/.agents/skills/plan-task/config/verify.zh-CN.json +1 -1
  176. package/templates/.agents/skills/restore-task/SKILL.en.md +11 -6
  177. package/templates/.agents/skills/restore-task/SKILL.zh-CN.md +11 -6
  178. package/templates/.agents/skills/restore-task/config/verify.json +1 -1
  179. package/templates/.agents/skills/review-analysis/SKILL.en.md +3 -3
  180. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +3 -3
  181. package/templates/.agents/skills/review-analysis/config/verify.en.json +1 -1
  182. package/templates/.agents/skills/review-analysis/config/verify.zh-CN.json +1 -1
  183. package/templates/.agents/skills/review-analysis/reference/report-template.en.md +7 -7
  184. package/templates/.agents/skills/review-analysis/reference/report-template.zh-CN.md +6 -6
  185. package/templates/.agents/skills/review-code/SKILL.en.md +5 -4
  186. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +5 -4
  187. package/templates/.agents/skills/review-code/config/verify.en.json +1 -1
  188. package/templates/.agents/skills/review-code/config/verify.zh-CN.json +1 -1
  189. package/templates/.agents/skills/review-code/reference/report-template.en.md +12 -12
  190. package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +12 -12
  191. package/templates/.agents/skills/review-plan/SKILL.en.md +3 -3
  192. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +3 -3
  193. package/templates/.agents/skills/review-plan/config/verify.en.json +1 -1
  194. package/templates/.agents/skills/review-plan/config/verify.zh-CN.json +1 -1
  195. package/templates/.agents/skills/review-plan/reference/report-template.en.md +7 -7
  196. package/templates/.agents/skills/review-plan/reference/report-template.zh-CN.md +7 -7
  197. package/templates/.agents/skills/review-pr/config/verify.en.json +1 -1
  198. package/templates/.agents/skills/review-pr/config/verify.zh-CN.json +1 -1
  199. package/templates/.agents/skills/review-pr/reference/evidence-grading.en.md +3 -3
  200. package/templates/.agents/skills/review-pr/reference/evidence-grading.zh-CN.md +3 -3
  201. package/templates/.agents/skills/run-manual-validation/config/verify.en.json +1 -1
  202. package/templates/.agents/skills/run-manual-validation/config/verify.zh-CN.json +1 -1
  203. package/templates/.agents/skills/run-task/SKILL.en.md +1 -1
  204. package/templates/.agents/skills/run-task/SKILL.zh-CN.md +1 -1
  205. package/templates/.agents/templates/task.en.md +1 -1
  206. package/templates/.agents/templates/task.zh-CN.md +1 -1
  207. package/templates/.claude/commands/import-issue.en.md +1 -1
  208. package/templates/.claude/commands/import-issue.zh-CN.md +1 -1
  209. package/templates/.claude/commands/restore-task.en.md +1 -1
  210. package/templates/.claude/commands/restore-task.zh-CN.md +1 -1
  211. package/dist/lib/host-control/audit.js +0 -19
  212. package/dist/lib/host-control/client.js +0 -142
  213. package/dist/lib/host-control/command.js +0 -49
  214. package/dist/lib/host-control/path.js +0 -160
  215. package/dist/lib/host-control/server.js +0 -159
  216. package/dist/lib/host-control/service.js +0 -112
  217. package/dist/lib/internal/host-control.js +0 -28
  218. package/lib/host-control/audit.ts +0 -22
  219. package/lib/host-control/client.ts +0 -172
  220. package/lib/host-control/command.ts +0 -60
  221. package/lib/host-control/path.ts +0 -167
  222. package/lib/host-control/server.ts +0 -180
  223. package/lib/host-control/service.ts +0 -133
  224. package/lib/internal/host-control.ts +0 -28
@@ -15,9 +15,11 @@ import { captureTaskWriteMetadata, writeTask } from './write.ts';
15
15
  import type { TaskFileSystem, TaskMutation, TaskOperationSummary, TaskWriteMetadata } from './write.ts';
16
16
  import { validateCurrentTaskContract } from './current-contract.ts';
17
17
  import { isValidAgentInfraVersion } from '../version.ts';
18
+ import { taskIssueIdentity } from '../platform/task-identities.ts';
19
+ import { resourceIdentityEquals } from '../platform/resource-identity.ts';
18
20
 
19
21
  const lifecycleIntentCatalog = [
20
- 'block', 'activate', 'cancel', 'complete', 'close-codescan', 'close-dependabot', 'restore'
22
+ 'block', 'activate', 'cancel', 'complete', 'close-codescan', 'close-dependabot', 'restore', 'recover-started'
21
23
  ] as const;
22
24
  const lifecycleFailureCatalog = [
23
25
  'LIFECYCLE_DIRECTORY_RENAME_FAILED',
@@ -55,7 +57,8 @@ type TaskLifecycleRequest =
55
57
  | { taskRef: string; intent: 'complete'; agent: string; dryRun?: boolean }
56
58
  | { taskRef: string; intent: 'close-codescan'; agent: string; alertNumber: number; reason: string; dryRun?: boolean }
57
59
  | { taskRef: string; intent: 'close-dependabot'; agent: string; alertNumber: number; reason: string; dryRun?: boolean }
58
- | { taskRef: string; intent: 'restore'; agent: string; stagingDir: string; issueNumber: number; dryRun?: boolean };
60
+ | { taskRef: string; intent: 'restore'; agent: string; stagingDir: string; issueNumber: number; dryRun?: boolean }
61
+ | { taskRef: string; intent: 'recover-started'; agent: string; stage: 'analysis' | 'review-analysis' | 'plan' | 'review-plan' | 'code' | 'review-code'; round: number; artifact: string; reason: string; dryRun?: boolean };
59
62
 
60
63
  type HotState = 'active' | 'blocked' | 'completed';
61
64
  type SourceState = HotState | 'staging';
@@ -206,6 +209,7 @@ function actionAndNote(request: TaskLifecycleRequest, restoredFiles = 0): { acti
206
209
  if (request.intent === 'complete') return { action: 'Complete Task', note: 'Task moved to completed/' };
207
210
  if (request.intent === 'close-codescan') return { action: 'Close Codescan', note: `Code Scanning alert #${request.alertNumber} dismissed: ${request.reason}` };
208
211
  if (request.intent === 'close-dependabot') return { action: 'Close Dependabot', note: `Dependabot alert #${request.alertNumber} dismissed: ${request.reason}` };
212
+ if (request.intent === 'recover-started') return { action: 'Recover Started', note: request.reason };
209
213
  return { action: 'Restore Task', note: `Restored ${restoredFiles} files from Issue #${request.issueNumber}` };
210
214
  }
211
215
 
@@ -249,8 +253,10 @@ function validateRestoreStaging(request: Extract<TaskLifecycleRequest, { intent:
249
253
  if (!fs.existsSync(taskFile)) return { code: 'LIFECYCLE_STAGING_INVALID', message: 'restore staging has no task.md' };
250
254
  try {
251
255
  const frontmatter = parseTypedTaskFrontmatter(fs.readFileSync(taskFile, 'utf8'));
252
- if (frontmatter.id !== taskId || Number(frontmatter.issue_number) !== request.issueNumber || frontmatter.current_step === 'completed') {
253
- return { code: 'LIFECYCLE_STAGING_IDENTITY_INVALID', message: 'restore task identity, issue, or current_step is invalid' };
256
+ const issueIdentity = taskIssueIdentity(frontmatter);
257
+ const requestedIssueIdentity = { kind: 'number' as const, value: request.issueNumber };
258
+ if (frontmatter.id !== taskId || !issueIdentity || !resourceIdentityEquals(issueIdentity, requestedIssueIdentity) || frontmatter.current_step === 'completed') {
259
+ return { code: 'LIFECYCLE_STAGING_IDENTITY_INVALID', message: 'restore task identity, platform issue identity, or current_step is invalid' };
254
260
  }
255
261
  } catch (error) {
256
262
  return { code: 'LIFECYCLE_STAGING_INVALID', message: error instanceof Error ? error.message : String(error) };
@@ -410,6 +416,9 @@ function removeCopiedSource(source: string, target: string): void {
410
416
  }
411
417
 
412
418
  function applyTaskLifecycle(requestInput: TaskLifecycleRequest, options: TaskLifecycleOptions = {}): TaskLifecycleResult {
419
+ if (requestInput.intent === 'recover-started') {
420
+ return failed(requestInput, { code: 'LIFECYCLE_RECOVERY_REQUIRES_AUTHORITY', message: 'recover-started must be dispatched through task control authority' });
421
+ }
413
422
  const normalized = normalizedRequest(requestInput);
414
423
  if ('code' in normalized) return failed(requestInput, normalized);
415
424
  const request = normalized;
@@ -5,6 +5,7 @@ import { scanVisibleMarkdown } from './markdown.ts';
5
5
  import { parseArtifactName } from './artifact-name.ts';
6
6
  import {
7
7
  hasOpenArtifactRound,
8
+ parseCodePlanInputReference,
8
9
  validateArtifactPublication,
9
10
  validateCompletedArtifact
10
11
  } from './artifact-lifecycle.ts';
@@ -12,8 +13,20 @@ import { resolveTaskRef } from './resolve-ref.ts';
12
13
  import { expectedQualificationRelations, validateQualificationAudit } from './qualification-audit.ts';
13
14
  import { canonicalSemanticDigest, inspectArtifactPatterns, inspectArtifactStructure, sha256Content } from './artifact-operations.ts';
14
15
  import { getArtifactSchema } from './artifact-schema.ts';
15
- import { readArtifactRepairIntent, writeArtifactRepairIntent } from './artifact-repair-intent.ts';
16
- import type { ArtifactRepairIntent } from './artifact-repair-intent.ts';
16
+ import { readArtifactRecoveryIntent } from './artifact-repair-intent.ts';
17
+ import type { ArtifactRecoveryIntent } from './artifact-repair-intent.ts';
18
+ import {
19
+ beginArtifactRecovery,
20
+ commitArtifactRecovery,
21
+ consumeArtifactRecovery,
22
+ prepareArtifactRecoveryFinal,
23
+ prepareArtifactRecoveryCommit,
24
+ recordArtifactRecoveryPassed,
25
+ reconcileArtifactRecovery,
26
+ recoveryContextFromIntent,
27
+ stageArtifactCandidate
28
+ } from './artifact-recovery.ts';
29
+ import type { ArtifactRecoveryContext } from './artifact-recovery.ts';
17
30
  import {
18
31
  consumeLifecycleRecoveryAttestation,
19
32
  lifecycleRecoveryAttestationDigest,
@@ -38,18 +51,14 @@ type LocalArtifactDiagnosticCode =
38
51
  | 'LOCAL_REQUIRED_PATTERN_MISSING'
39
52
  | 'LOCAL_STRUCTURAL_INVALID'
40
53
  | 'LOCAL_SECTION_HEADING_TRAILING_PUNCTUATION'
41
- | 'LOCAL_REPAIR_PROVENANCE_CONFLICT'
42
- | 'LOCAL_REPAIR_BASELINE_MISMATCH'
54
+ | 'LOCAL_RECOVERY_PROVENANCE_CONFLICT'
55
+ | 'LOCAL_RECOVERY_BASELINE_MISMATCH'
43
56
  | 'LOCAL_QUALIFICATION_AUDIT_INVALID';
44
57
 
45
58
  type LocalArtifactDiagnostic = {
46
59
  code: LocalArtifactDiagnosticCode;
47
60
  message: string;
48
- repairable: boolean;
49
61
  line: number | null;
50
- from?: string;
51
- to?: string;
52
- operation?: 'replace-line';
53
62
  };
54
63
 
55
64
  type LocalArtifactValidationOptions = {
@@ -63,7 +72,6 @@ type LocalArtifactValidationResult = {
63
72
  ok: boolean;
64
73
  family: LocalArtifactFamily;
65
74
  semanticDigest: string;
66
- repairable: boolean;
67
75
  diagnostics: readonly LocalArtifactDiagnostic[];
68
76
  };
69
77
 
@@ -73,23 +81,32 @@ type LocalArtifactFinalizationRequest = {
73
81
  artifact: string;
74
82
  repoRoot?: string;
75
83
  requiredSections?: readonly string[];
84
+ recoveryId?: string;
85
+ lockAlreadyHeld?: boolean;
76
86
  };
77
87
 
88
+ type LocalArtifactRecoveryInfo = Readonly<{
89
+ recoveryId: string;
90
+ candidatePath: string;
91
+ baselineSha256: string;
92
+ baselineSemanticDigest: string;
93
+ }>;
94
+
78
95
  type LocalArtifactFinalizationResult = {
79
96
  status: 'passed' | 'failed';
80
- changed: false;
97
+ changed: boolean;
81
98
  taskId: string | null;
82
99
  taskDir: string | null;
83
100
  family: LocalArtifactFamily;
84
101
  artifact: string;
85
102
  artifactSha256: string | null;
86
103
  semanticDigest: string | null;
87
- repairable: boolean;
88
104
  diagnostics: readonly LocalArtifactDiagnostic[];
105
+ recovery?: LocalArtifactRecoveryInfo;
89
106
  error: { code: string; message: string } | null;
90
107
  };
91
108
 
92
- type LocalArtifactFinalizationIntent = ArtifactRepairIntent;
109
+ type LocalArtifactFinalizationIntent = ArtifactRecoveryIntent;
93
110
 
94
111
  function authorityDigest(authority: LifecycleRecoveryAttestationV1): string {
95
112
  return lifecycleRecoveryAttestationDigest(authority);
@@ -113,18 +130,27 @@ function validFinalizerAuthority(
113
130
  && authority.operationId.length > 0;
114
131
  }
115
132
 
133
+ function recoveryInfo(context: ArtifactRecoveryContext): LocalArtifactRecoveryInfo {
134
+ return {
135
+ recoveryId: context.recoveryId,
136
+ candidatePath: context.stagingPath,
137
+ baselineSha256: context.baselineSha256,
138
+ baselineSemanticDigest: context.baselineSemanticDigest
139
+ };
140
+ }
141
+
116
142
  function consumeLocalArtifactFinalizationIntent(
117
143
  repoRoot: string,
118
- intent: LocalArtifactFinalizationIntent
144
+ intent: LocalArtifactFinalizationIntent,
145
+ options: Readonly<{ lockAlreadyHeld?: boolean }> = {}
119
146
  ): LocalArtifactFinalizationIntent {
120
147
  if (intent.state === 'consumed') return intent;
121
- if (intent.state !== 'passed') throw new Error('LOCAL_FINALIZATION_INTENT_INVALID: only passed provenance can be consumed');
122
- const consumed = { ...intent, state: 'consumed' as const, updatedAt: Date.now() };
123
- writeArtifactRepairIntent(repoRoot, consumed, { expected: intent });
124
- return consumed;
148
+ const resolved = resolveTaskRef(intent.taskId, { repoRoot });
149
+ if (!resolved.ok) throw new Error(resolved.message);
150
+ return consumeArtifactRecovery(recoveryContextFromIntent(repoRoot, resolved.taskDir, intent), options);
125
151
  }
126
152
 
127
- function localDiagnosticCode(code: string): LocalArtifactDiagnosticCode | null {
153
+ function localDiagnosticCode(code: string): LocalArtifactDiagnosticCode {
128
154
  const map: Record<string, LocalArtifactDiagnosticCode> = {
129
155
  ARTIFACT_EMPTY: 'LOCAL_ARTIFACT_EMPTY',
130
156
  ARTIFACT_MISSING_SECTION: 'LOCAL_ARTIFACT_MISSING_SECTION',
@@ -134,20 +160,8 @@ function localDiagnosticCode(code: string): LocalArtifactDiagnosticCode | null {
134
160
  return map[code] ?? 'LOCAL_STRUCTURAL_INVALID';
135
161
  }
136
162
 
137
- function localDiagnostic(
138
- item: { code: string; message: string; line: number | null; repairable: boolean; operation?: { kind: string; from: string; to: string } }
139
- ): LocalArtifactDiagnostic | null {
140
- const code = localDiagnosticCode(item.code);
141
- if (!code) return null;
142
- return {
143
- code,
144
- message: item.message,
145
- repairable: item.repairable,
146
- line: item.line,
147
- ...(item.operation?.kind === 'replace-line'
148
- ? { from: item.operation.from, to: item.operation.to, operation: 'replace-line' as const }
149
- : {})
150
- };
163
+ function localDiagnostic(item: { code: string; message: string; line: number | null }): LocalArtifactDiagnostic {
164
+ return { code: localDiagnosticCode(item.code), message: item.message, line: item.line };
151
165
  }
152
166
 
153
167
  function isStatusPattern(pattern: string): boolean {
@@ -159,14 +173,10 @@ function validateLocalArtifact(
159
173
  options: LocalArtifactValidationOptions
160
174
  ): LocalArtifactValidationResult {
161
175
  const schema = getArtifactSchema(options.family)!;
162
- const patterns = schema.requiredPatterns;
163
176
  const diagnostics: LocalArtifactDiagnostic[] = [];
164
177
  const scanned = scanVisibleMarkdown(content);
165
178
  const structure = inspectArtifactStructure(content, schema);
166
- for (const item of structure.diagnostics) {
167
- const mapped = localDiagnostic(item);
168
- if (mapped) diagnostics.push(mapped);
169
- }
179
+ for (const item of structure.diagnostics) diagnostics.push(localDiagnostic(item));
170
180
 
171
181
  const statusSection = schema.sections.find((section) => section.id === 'state-check');
172
182
  if (statusSection) {
@@ -178,9 +188,9 @@ function validateLocalArtifact(
178
188
  if (statusHeading) {
179
189
  const next = scanned.headings.find((candidate) => candidate.start > statusHeading.start && candidate.level <= 2);
180
190
  const body = content.slice(statusHeading.end, next?.start ?? content.length);
181
- for (const pattern of patterns.filter(isStatusPattern)) {
191
+ for (const pattern of schema.requiredPatterns.filter(isStatusPattern)) {
182
192
  if (!new RegExp(pattern, 'm').test(body)) {
183
- diagnostics.push({ code: 'LOCAL_STATUS_COMMAND_MISSING', message: `status section '${statusHeading.text}' is missing required command output`, repairable: false, line: content.slice(0, statusHeading.start).split('\n').length });
193
+ diagnostics.push({ code: 'LOCAL_STATUS_COMMAND_MISSING', message: `status section '${statusHeading.text}' is missing required command output`, line: content.slice(0, statusHeading.start).split('\n').length });
184
194
  }
185
195
  }
186
196
  }
@@ -188,40 +198,28 @@ function validateLocalArtifact(
188
198
 
189
199
  const patternInspection = inspectArtifactPatterns(content, {
190
200
  ...schema,
191
- requiredPatterns: patterns.filter((item) => !isStatusPattern(item))
201
+ requiredPatterns: schema.requiredPatterns.filter((item) => !isStatusPattern(item))
192
202
  });
193
- for (const item of patternInspection.diagnostics) {
194
- diagnostics.push({ code: 'LOCAL_REQUIRED_PATTERN_MISSING', message: item.message, repairable: false, line: null });
195
- }
203
+ for (const item of patternInspection.diagnostics) diagnostics.push({ code: 'LOCAL_REQUIRED_PATTERN_MISSING', message: item.message, line: null });
196
204
 
197
205
  const decisionDetails = inspectDecisionDetailDuplicates(content);
198
- if (!decisionDetails.ok) {
199
- diagnostics.push({ code: 'LOCAL_DECISION_DETAIL_DUPLICATE', message: decisionDetails.message, repairable: false, line: null });
200
- }
206
+ if (!decisionDetails.ok) diagnostics.push({ code: 'LOCAL_DECISION_DETAIL_DUPLICATE', message: decisionDetails.message, line: null });
201
207
 
202
208
  if (options.taskContent !== undefined) {
203
209
  const expected = expectedQualificationRelations(options.taskContent, options.family);
204
- if (!expected.ok) {
205
- diagnostics.push({ code: 'LOCAL_QUALIFICATION_AUDIT_INVALID', message: `${expected.code}: ${expected.message}`, repairable: false, line: null });
206
- }
210
+ if (!expected.ok) diagnostics.push({ code: 'LOCAL_QUALIFICATION_AUDIT_INVALID', message: `${expected.code}: ${expected.message}`, line: null });
207
211
  const qualification = validateQualificationAudit(options.taskContent, content, {
208
- family: options.family === 'analysis' ? 'analysis' : options.family === 'plan' ? 'plan' : 'code',
212
+ family: options.family,
209
213
  artifact: options.artifact,
210
214
  expectedUpstreamRelations: expected.ok ? expected.relations : undefined
211
215
  });
212
- if (!qualification.ok) {
213
- diagnostics.push({ code: 'LOCAL_QUALIFICATION_AUDIT_INVALID', message: `${qualification.code}: ${qualification.message}`, repairable: false, line: null });
214
- }
216
+ if (!qualification.ok) diagnostics.push({ code: 'LOCAL_QUALIFICATION_AUDIT_INVALID', message: `${qualification.code}: ${qualification.message}`, line: null });
215
217
  }
216
218
 
217
- const repairable = structure.repair !== null && diagnostics.length === 1 && diagnostics[0]?.repairable === true;
218
- const semanticDigestValue = canonicalSemanticDigest(content, structure.repair);
219
-
220
219
  return {
221
220
  ok: diagnostics.length === 0,
222
221
  family: options.family,
223
- semanticDigest: semanticDigestValue,
224
- repairable,
222
+ semanticDigest: canonicalSemanticDigest(content),
225
223
  diagnostics
226
224
  };
227
225
  }
@@ -236,7 +234,7 @@ function failedFinalization(
236
234
  taskId: null, taskDir: null,
237
235
  family: request.family, artifact: request.artifact,
238
236
  artifactSha256: null, semanticDigest: null,
239
- repairable: false, diagnostics: [], error, ...extra
237
+ diagnostics: [], error, ...extra
240
238
  };
241
239
  }
242
240
 
@@ -244,114 +242,222 @@ type LocalArtifactPreparation = Readonly<{
244
242
  result: LocalArtifactFinalizationResult;
245
243
  content: string;
246
244
  repoRoot?: string;
247
- provenance?: ArtifactRepairIntent;
248
- expectedIntent?: ArtifactRepairIntent | null;
245
+ lockAlreadyHeld?: boolean;
246
+ recovery?: ArtifactRecoveryContext;
247
+ authority?: LifecycleRecoveryAttestationV1;
249
248
  }>;
250
249
 
251
- /** Read and validate once; callers publish this content before committing provenance. */
250
+ function tupleFor(
251
+ request: LocalArtifactFinalizationRequest,
252
+ taskId: string,
253
+ authority?: LifecycleRecoveryAttestationV1
254
+ ) {
255
+ const round = parseArtifactName(request.artifact)?.round ?? 0;
256
+ return {
257
+ taskId,
258
+ family: request.family,
259
+ artifact: request.artifact,
260
+ round,
261
+ requestId: authority?.lifecycleRequestId ?? `local-finalize:${taskId}:${request.family}:${round}`,
262
+ phase: authority?.phase ?? 'artifact.finalize-local' as const,
263
+ authorityDigest: authority ? authorityDigest(authority) : null
264
+ };
265
+ }
266
+
252
267
  function prepareLocalArtifact(
253
268
  request: LocalArtifactFinalizationRequest,
254
269
  candidate?: string,
255
- authority?: LifecycleRecoveryAttestationV1
270
+ authority?: LifecycleRecoveryAttestationV1,
271
+ preflightOnly = false
256
272
  ): LocalArtifactPreparation {
257
- const failed = (code: string, message: string): LocalArtifactPreparation => ({
258
- result: failedFinalization(request, { code, message }), content: candidate ?? ''
273
+ const failed = (code: string, message: string, extra: Partial<LocalArtifactFinalizationResult> = {}): LocalArtifactPreparation => ({
274
+ result: failedFinalization(request, { code, message }, extra), content: candidate ?? ''
259
275
  });
260
276
  const resolved = resolveTaskRef(request.taskRef, { repoRoot: request.repoRoot });
261
277
  if (!resolved.ok) return failed(resolved.code, resolved.message);
262
278
  const parsed = parseArtifactName(request.artifact);
263
279
  if (!parsed || parsed.family !== request.family) return failed('ARTIFACT_IDENTITY_INVALID', `artifact '${request.artifact}' does not match ${request.family}`);
264
- let content: string;
280
+ if (authority && !validFinalizerAuthority(authority, request, resolved.taskId)) {
281
+ return failed('LOCAL_EXECUTION_AUTHORITY_INVALID', 'finalizer authority does not match the artifact tuple');
282
+ }
283
+
265
284
  let taskContent: string;
285
+ let content: string;
286
+ let recovery: ArtifactRecoveryContext | undefined;
287
+ let recoveryState: ArtifactRecoveryIntent['state'] | undefined;
266
288
  try {
267
289
  taskContent = fs.readFileSync(resolved.taskMdPath, 'utf8');
268
- if (candidate !== undefined) {
269
- const error = validateArtifactPublication(resolved.taskDir, request.family, request.artifact);
270
- if (error) return failed(error.code, error.message);
271
- if (resolved.state !== 'active' || !hasOpenArtifactRound(taskContent, request.family, parsed.round)) {
272
- return failed('ARTIFACT_IDENTITY_INVALID', 'candidate must match one open started round in an active task');
273
- }
274
- content = candidate;
290
+ if (request.recoveryId) {
291
+ const intent = readArtifactRecoveryIntent(resolved.repoRoot, resolved.taskId, request.family, request.artifact);
292
+ if (!intent || intent.recoveryOperationId !== request.recoveryId) return failed('LOCAL_RECOVERY_PROVENANCE_CONFLICT', 'recovery id does not match the artifact journal');
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;
295
+ recovery = recoveryContextFromIntent(resolved.repoRoot, resolved.taskDir, intent);
296
+ content = fs.existsSync(recovery.stagingPath)
297
+ ? fs.readFileSync(recovery.stagingPath, 'utf8')
298
+ : fs.readFileSync(recovery.formalPath, 'utf8');
275
299
  } else {
276
300
  const validated = validateCompletedArtifact(resolved.taskDir, request.family, request.artifact, parsed.round);
277
301
  if (!validated.ok) return failed(validated.error.code, validated.error.message);
278
- content = fs.readFileSync(validated.artifact.path, 'utf8');
302
+ content = candidate ?? fs.readFileSync(validated.artifact.path, 'utf8');
279
303
  }
280
304
  } catch (error) { return failed('ARTIFACT_NOT_READABLE', String(error)); }
305
+
306
+ if (resolved.state !== 'active' || !hasOpenArtifactRound(taskContent, request.family, parsed.round)) {
307
+ return failed('ARTIFACT_IDENTITY_INVALID', 'candidate must match one open started round in an active task');
308
+ }
281
309
  const validation = validateLocalArtifact(content, {
282
310
  family: request.family, requiredSections: request.requiredSections, taskContent, artifact: request.artifact
283
311
  });
284
312
  const artifactSha256 = sha256Content(content);
285
- const { semanticDigest, repairable, diagnostics } = validation;
286
313
  const result = failedFinalization(request, {
287
- code: 'LOCAL_ARTIFACT_INVALID', message: diagnostics.map((item) => `${item.code}: ${item.message}`).join('; ')
288
- }, { taskId: resolved.taskId, taskDir: resolved.taskDir, artifactSha256, semanticDigest, repairable, diagnostics });
289
- const preparedBase = { result, content, repoRoot: resolved.repoRoot };
290
- if (authority !== undefined && !validFinalizerAuthority(authority, request, resolved.taskId)) {
314
+ code: 'LOCAL_ARTIFACT_INVALID',
315
+ message: validation.diagnostics.map((item) => `${item.code}: ${item.message}`).join('; ')
316
+ }, {
317
+ taskId: resolved.taskId, taskDir: resolved.taskDir, artifactSha256,
318
+ semanticDigest: validation.semanticDigest, diagnostics: validation.diagnostics
319
+ });
320
+
321
+ if (request.recoveryId) {
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
+ }
291
327
  return {
292
- ...preparedBase,
293
- result: { ...result, repairable: false, error: {
294
- code: 'LOCAL_EXECUTION_AUTHORITY_INVALID',
295
- message: 'finalizer authority does not match the artifact tuple'
296
- }, diagnostics: [] }
328
+ result: { ...result, status: 'passed', changed: false, error: null, recovery: recoveryInfo(recovery!) },
329
+ content, repoRoot: resolved.repoRoot, recovery, authority, lockAlreadyHeld: request.lockAlreadyHeld
297
330
  };
298
331
  }
299
- const reject = (code: LocalArtifactDiagnosticCode, message: string): LocalArtifactPreparation => ({
300
- ...prepared, result: { ...result, repairable: false, error: { code, message },
301
- diagnostics: [{ code, message, repairable: false, line: null }] }
302
- });
303
- let intent: ArtifactRepairIntent | null;
304
- try { intent = readArtifactRepairIntent(resolved.repoRoot, resolved.taskId, request.family, request.artifact); }
305
- catch (error) {
306
- return { ...preparedBase, result: { ...result, repairable: false, diagnostics: [],
307
- error: { code: 'LOCAL_FINALIZATION_INTENT_INVALID', message: String(error) } } };
332
+
333
+ const existing = readArtifactRecoveryIntent(resolved.repoRoot, resolved.taskId, request.family, request.artifact);
334
+ if (existing?.state === 'passed' || existing?.state === 'consumed') {
335
+ if (existing.state === 'consumed' && (existing.finalArtifactSha256 !== artifactSha256 || existing.finalSemanticDigest !== validation.semanticDigest)) {
336
+ return { ...failed('LOCAL_RECOVERY_PROVENANCE_CONFLICT', 'formal artifact does not match its completed recovery journal'), content, repoRoot: resolved.repoRoot, authority, lockAlreadyHeld: request.lockAlreadyHeld };
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
+ }
308
345
  }
309
- const prepared = { ...preparedBase, expectedIntent: intent };
310
- if (intent && intent.recoveryOperationId !== null
311
- && (!authority || intent.recoveryOperationId !== authority.operationId)) {
312
- return reject('LOCAL_REPAIR_PROVENANCE_CONFLICT', 'local repair provenance is owned by another recovery operation');
346
+
347
+ if (existing && ['preflight-ready', 'preflight-commit-started', 'preflight-passed', 'full-finalizer-ready'].includes(existing.state)) {
348
+ const context = recoveryContextFromIntent(resolved.repoRoot, resolved.taskDir, existing);
349
+ return {
350
+ result: { ...result, status: 'passed', changed: false, error: null, recovery: recoveryInfo(context) },
351
+ content, repoRoot: resolved.repoRoot, recovery: context, authority, lockAlreadyHeld: request.lockAlreadyHeld
352
+ };
313
353
  }
314
- if (repairable) {
315
- if (intent && (intent.state !== 'awaiting-repair' || intent.baselineSemanticDigest !== semanticDigest)) {
316
- return reject('LOCAL_REPAIR_PROVENANCE_CONFLICT', 'a different local repair baseline is already recorded for this artifact');
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 };
317
357
  }
318
- if (intent && !authority) return prepared;
319
- } else {
320
- if (!validation.ok) return prepared;
321
- if (intent?.state === 'awaiting-repair' && intent.baselineSemanticDigest !== semanticDigest) {
322
- return reject('LOCAL_REPAIR_BASELINE_MISMATCH', 'the repaired artifact semantic digest does not match the recorded repair baseline');
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 };
323
365
  }
324
- if ((intent?.state === 'passed' || intent?.state === 'consumed')
325
- && (intent.artifactSha256 !== artifactSha256 || intent.semanticDigest !== semanticDigest)) {
326
- return reject('LOCAL_REPAIR_PROVENANCE_CONFLICT', 'the artifact changed after its finalization provenance was recorded');
366
+ return {
367
+ result: { ...result, status: 'passed', changed: false, error: null, recovery: recoveryInfo(context) },
368
+ content, repoRoot: resolved.repoRoot, recovery: context, authority, lockAlreadyHeld: request.lockAlreadyHeld
369
+ };
370
+ }
371
+
372
+ try {
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
+ }
383
+ const context = recordArtifactRecoveryPassed(tupleFor(request, resolved.taskId, authority), {
384
+ repoRoot: resolved.repoRoot,
385
+ taskDir: resolved.taskDir,
386
+ expectedFinalSha256: artifactSha256,
387
+ expectedFinalSemanticDigest: validation.semanticDigest,
388
+ ...(authority ? { recoveryId: authority.operationId } : {}),
389
+ lockAlreadyHeld: request.lockAlreadyHeld
390
+ });
391
+ recovery = context;
392
+ return { result: { ...result, status: 'passed', changed: false, error: null, recovery: recoveryInfo(context) }, content, repoRoot: resolved.repoRoot, recovery, authority, lockAlreadyHeld: request.lockAlreadyHeld };
327
393
  }
328
- prepared.result = { ...result, status: 'passed', error: null };
329
- if (intent?.state === 'consumed') return prepared;
394
+ const context = beginArtifactRecovery(tupleFor(request, resolved.taskId, authority), Buffer.from(content, 'utf8'), {
395
+ repoRoot: resolved.repoRoot,
396
+ taskDir: resolved.taskDir,
397
+ ...(authority ? { recoveryId: authority.operationId } : {}),
398
+ lockAlreadyHeld: request.lockAlreadyHeld
399
+ });
400
+ recovery = context;
401
+ return {
402
+ result: { ...result, recovery: recoveryInfo(context) },
403
+ content, repoRoot: resolved.repoRoot, recovery, authority, lockAlreadyHeld: request.lockAlreadyHeld
404
+ };
405
+ } catch (error) {
406
+ return { ...failed('LOCAL_RECOVERY_PROVENANCE_CONFLICT', String(error)), content, repoRoot: resolved.repoRoot, authority, lockAlreadyHeld: request.lockAlreadyHeld };
330
407
  }
331
- const timestamp = Date.now();
332
- return { ...prepared, provenance: {
333
- version: 2, taskId: resolved.taskId, family: request.family, artifact: request.artifact,
334
- state: repairable ? 'awaiting-repair' : 'passed',
335
- baselineSemanticDigest: repairable ? semanticDigest : intent?.baselineSemanticDigest ?? null,
336
- artifactSha256, semanticDigest,
337
- recoveryOperationId: authority?.operationId ?? intent?.recoveryOperationId ?? null,
338
- phase: authority?.phase ?? intent?.phase ?? null,
339
- authorityDigest: authority ? authorityDigest(authority) : intent?.authorityDigest ?? null,
340
- requestId: authority?.lifecycleRequestId ?? intent?.requestId ?? `local-finalize:${resolved.taskId}`,
341
- createdAt: intent?.createdAt ?? timestamp,
342
- updatedAt: timestamp
343
- } };
344
408
  }
345
409
 
346
- function commitLocalArtifactProvenance(prepared: LocalArtifactPreparation): LocalArtifactFinalizationResult {
410
+ function commitLocalArtifactProvenance(
411
+ prepared: LocalArtifactPreparation,
412
+ options: Readonly<{ afterPublish?: () => void }> = {}
413
+ ): LocalArtifactFinalizationResult {
414
+ if (!prepared.recovery || !prepared.repoRoot || prepared.result.status === 'failed' && !prepared.result.recovery) return prepared.result;
415
+ if (prepared.result.status === 'failed') return prepared.result;
347
416
  try {
348
- if (prepared.provenance) {
349
- writeArtifactRepairIntent(prepared.repoRoot!, prepared.provenance, { expected: prepared.expectedIntent ?? null });
417
+ const intent = readArtifactRecoveryIntent(prepared.repoRoot, prepared.recovery.taskId, prepared.recovery.family, prepared.recovery.artifact);
418
+ if (!intent) throw new Error('ARTIFACT_RECOVERY_INTENT_MISSING');
419
+ let committed;
420
+ if (intent.state === 'passed' || intent.state === 'consumed') {
421
+ return {
422
+ ...prepared.result,
423
+ changed: false,
424
+ artifactSha256: intent.finalArtifactSha256,
425
+ semanticDigest: intent.finalSemanticDigest,
426
+ error: null
427
+ };
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') {
435
+ const staged = stageArtifactCandidate(prepared.recovery, Buffer.from(prepared.content, 'utf8'), { lockAlreadyHeld: prepared.lockAlreadyHeld });
436
+ prepareArtifactRecoveryCommit(prepared.recovery, staged.candidateSha256, staged.semanticDigest, { lockAlreadyHeld: prepared.lockAlreadyHeld });
437
+ committed = commitArtifactRecovery(prepared.recovery, { lockAlreadyHeld: prepared.lockAlreadyHeld });
438
+ }
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 });
350
447
  }
351
- return prepared.result;
448
+ if (!committed || committed.state !== 'passed') throw new Error(`ARTIFACT_RECOVERY_STATE_INVALID: commit ended in '${committed?.state ?? intent.state}'`);
449
+ return {
450
+ ...prepared.result,
451
+ status: 'passed',
452
+ changed: false,
453
+ artifactSha256: committed.finalArtifactSha256,
454
+ semanticDigest: committed.finalSemanticDigest,
455
+ error: null
456
+ };
352
457
  } catch (error) {
458
+ if (error instanceof Error && error.message.startsWith('TASK_WORKFLOW_FAULT_INJECTED:')) throw error;
353
459
  return { ...prepared.result, status: 'failed', error: {
354
- code: 'LOCAL_FINALIZATION_INTENT_WRITE_FAILED', message: String(error)
460
+ code: 'LOCAL_RECOVERY_COMMIT_FAILED', message: String(error)
355
461
  } };
356
462
  }
357
463
  }
@@ -361,7 +467,8 @@ function finalizeLocalArtifact(
361
467
  authority?: LifecycleRecoveryAttestationV1,
362
468
  options: Readonly<{ deferLifecycleRecoveryConsumption?: boolean }> = {}
363
469
  ): LocalArtifactFinalizationResult {
364
- const result = commitLocalArtifactProvenance(prepareLocalArtifact(request, undefined, authority));
470
+ const prepared = prepareLocalArtifact(request, undefined, authority);
471
+ const result = commitLocalArtifactProvenance(prepared);
365
472
  if (authority && !options.deferLifecycleRecoveryConsumption && result.status === 'passed') {
366
473
  try { consumeLifecycleRecoveryAttestation(authority); }
367
474
  catch (error) {
@@ -374,13 +481,23 @@ function finalizeLocalArtifact(
374
481
  return result;
375
482
  }
376
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
+
377
493
  export {
378
494
  LOCAL_ARTIFACT_REQUIRED_SECTIONS,
379
495
  consumeLocalArtifactFinalizationIntent,
380
496
  finalizeLocalArtifact,
497
+ preflightLocalArtifact,
381
498
  prepareLocalArtifact,
382
499
  commitLocalArtifactProvenance,
383
- readArtifactRepairIntent as readLocalArtifactFinalizationIntent,
500
+ readArtifactRecoveryIntent as readLocalArtifactFinalizationIntent,
384
501
  canonicalSemanticDigest as semanticDigest,
385
502
  sha256Content,
386
503
  validateLocalArtifact
@@ -394,5 +511,6 @@ export type {
394
511
  LocalArtifactFinalizationIntent,
395
512
  LocalArtifactPreparation,
396
513
  LocalArtifactValidationOptions,
397
- LocalArtifactValidationResult
514
+ LocalArtifactValidationResult,
515
+ LocalArtifactRecoveryInfo
398
516
  };
@@ -68,7 +68,11 @@ function manualValidationReceiptDigest(receipt: ManualValidationReceipt | Manual
68
68
 
69
69
  function manualValidationFinalSummaryDigest(body: string): string {
70
70
  const heading = /^###\s+✅\s+(?:Manual Validation Passed|人工验证已通过)\s*$/mu.exec(body);
71
- const projection = heading ? body.slice(heading.index).replace(/\s+$/u, '') : body;
71
+ const afterHeading = heading ? body.slice(heading.index + heading[0].length) : '';
72
+ const nextSection = heading ? /^#{1,3}\s/mu.exec(afterHeading) : null;
73
+ const projection = heading
74
+ ? body.slice(heading.index, heading.index + heading[0].length + (nextSection?.index ?? afterHeading.length)).replace(/\s+$/u, '')
75
+ : body;
72
76
  const preimage = projection.replace(/receipt=(?:[a-f0-9]{64}|<receipt>)/gu, 'receipt=<receipt>');
73
77
  return createHash('sha256').update(preimage, 'utf8').digest('hex');
74
78
  }