@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
@@ -186,7 +186,8 @@ function isDelegationHostEvidence(value: unknown): value is DelegationHostEviden
186
186
  function hasCurrentCodexEvidence(receipt: DelegationReceipt): boolean {
187
187
  const provenance = receipt.lifecycleProvenance;
188
188
  if (!provenance) return false;
189
- const activated = ['activated', 'stage-completed', 'sealed', 'consumed'].includes(receipt.status);
189
+ const activated = ['activated', 'stage-completed', 'sealed', 'consumed'].includes(receipt.status)
190
+ || (receipt.status === 'aborted' && receipt.activatedAt !== null);
190
191
  if (!activated) return receipt.hostEvidence === null;
191
192
  const host = receipt.hostEvidence;
192
193
  if (
@@ -219,6 +220,12 @@ function hasCurrentCodexEvidence(receipt: DelegationReceipt): boolean {
219
220
  && host.consumer === receipt.id
220
221
  && exactText(host.consumedAt);
221
222
  }
223
+ if (receipt.status === 'aborted') {
224
+ return Number.isSafeInteger(host.stopRevision)
225
+ && (host.stopRevision as number) > host.startRevision
226
+ && host.consumer === `lifecycle-recovery:${receipt.taskId}:${receipt.id}`
227
+ && exactText(host.consumedAt);
228
+ }
222
229
  return host.stopRevision === null && host.consumer === null && host.consumedAt === null;
223
230
  }
224
231
 
@@ -244,7 +251,8 @@ function hasStatusBoundEvidence(receipt: DelegationReceipt): boolean {
244
251
  const dispatchComplete = dispatchFields.every((field) => field !== null);
245
252
  if (!dispatchEmpty && !dispatchComplete) return false;
246
253
 
247
- const beforeActivation = ['prepared', 'aborted', 'expired'].includes(receipt.status);
254
+ const beforeActivation = ['prepared', 'expired'].includes(receipt.status)
255
+ || (receipt.status === 'aborted' && receipt.activatedAt === null);
248
256
  if (beforeActivation) {
249
257
  return receipt.parentId === null
250
258
  && receipt.childId === null
@@ -282,6 +290,21 @@ function hasStatusBoundEvidence(receipt: DelegationReceipt): boolean {
282
290
  && receipt.consumedAt === null;
283
291
  }
284
292
 
293
+ if (receipt.status === 'aborted') {
294
+ const host = receipt.hostEvidence;
295
+ return receipt.agent === null
296
+ && receipt.afterFingerprint === null
297
+ && receipt.changedPaths.length === 0
298
+ && receipt.sealedAt === null
299
+ && receipt.consumedAt === null
300
+ && host !== null
301
+ && host !== undefined
302
+ && Number.isSafeInteger(host.stopRevision)
303
+ && (host.stopRevision as number) > host.startRevision
304
+ && host.consumer === `lifecycle-recovery:${receipt.taskId}:${receipt.id}`
305
+ && exactText(host.consumedAt);
306
+ }
307
+
285
308
  if (
286
309
  !exactText(receipt.agent)
287
310
  || normalizeAgentToken(receipt.agent) !== normalizeAgentToken(receipt.client)
@@ -584,6 +607,53 @@ function abortPreparedDelegation(receipt: DelegationReceipt): ReceiptResult {
584
607
  };
585
608
  }
586
609
 
610
+ function abortActivatedDelegation(
611
+ receipt: DelegationReceipt,
612
+ event: Readonly<{ childId: string; stopRevision: number; consumer: string; consumedAt: string }>
613
+ ): ReceiptResult {
614
+ if (receipt.status !== 'activated') {
615
+ return fail('DELEGATION_STATE_INVALID', `delegation ${receipt.id} is ${receipt.status}, expected activated`);
616
+ }
617
+ if (
618
+ receipt.client !== 'codex'
619
+ || !receipt.lifecycleProvenance
620
+ || receipt.hostEvidence?.kind !== 'codex-lifecycle-v2'
621
+ || typeof receipt.lifecycleProvenance.controllerInstanceDigest !== 'string'
622
+ || typeof receipt.lifecycleProvenance.controlGeneration !== 'string'
623
+ || typeof receipt.hostEvidence.controllerInstanceDigest !== 'string'
624
+ || typeof receipt.hostEvidence.controlGeneration !== 'string'
625
+ ) {
626
+ return fail('DELEGATION_RECOVERY_EVIDENCE_INVALID', 'activated delegation lacks the current Codex controller binding');
627
+ }
628
+ if (
629
+ event.childId !== receipt.childId
630
+ || !Number.isSafeInteger(event.stopRevision)
631
+ || event.stopRevision <= receipt.hostEvidence.startRevision
632
+ || event.consumer !== `lifecycle-recovery:${receipt.taskId}:${receipt.id}`
633
+ || !exactText(event.consumedAt)
634
+ ) {
635
+ return fail('DELEGATION_RECOVERY_EVIDENCE_INVALID', 'recovery stop evidence does not match the activated delegation');
636
+ }
637
+ return {
638
+ ok: true,
639
+ receipt: Object.freeze({
640
+ ...receipt,
641
+ status: 'aborted' as const,
642
+ agent: null,
643
+ afterFingerprint: null,
644
+ changedPaths: Object.freeze([]),
645
+ sealedAt: null,
646
+ consumedAt: null,
647
+ hostEvidence: Object.freeze({
648
+ ...receipt.hostEvidence,
649
+ stopRevision: event.stopRevision,
650
+ consumer: event.consumer,
651
+ consumedAt: event.consumedAt
652
+ })
653
+ })
654
+ };
655
+ }
656
+
587
657
  function completeDelegationStage(
588
658
  receipt: DelegationReceipt,
589
659
  event: Readonly<{ stage: DelegationStage; round: number; artifact: string; agent: string }>
@@ -666,6 +736,7 @@ function consumeDelegation(receipt: DelegationReceipt, options: { now?: () => st
666
736
 
667
737
  export {
668
738
  activateDelegation,
739
+ abortActivatedDelegation,
669
740
  abortPreparedDelegation,
670
741
  completeDelegationStage,
671
742
  consumeDelegation,
@@ -38,7 +38,9 @@ import {
38
38
  readLocalArtifactFinalizationIntent,
39
39
  validateLocalArtifact
40
40
  } from './local-artifact-finalization.ts';
41
- import { writeArtifactRepairIntent } from './artifact-repair-intent.ts';
41
+ import { writeArtifactRecoveryIntent, readArtifactRecoveryIntent } from './artifact-repair-intent.ts';
42
+ import { reconcileArtifactRecovery, recoveryContextFromIntent, sha256Content } from './artifact-recovery.ts';
43
+ import type { ArtifactRecoveryIntent } from './artifact-repair-intent.ts';
42
44
  import type { LocalArtifactFamily, LocalArtifactFinalizationIntent } from './local-artifact-finalization.ts';
43
45
  import { buildLifecycleFacts, canStart } from './capabilities.ts';
44
46
  import type { ExplicitTrigger, LifecycleAction, TriggerInitiator, TriggerReason } from './capabilities.ts';
@@ -48,7 +50,7 @@ import { consumeReworkIntents, parseReworkIntentDocument, reworkIntentMutation,
48
50
  import { ARTIFACT_FAMILIES, expectedQualificationRelations, parseQualificationAudit, parseTaskQualification, upstreamArtifactDigest, validateQualificationAudit } from './qualification-audit.ts';
49
51
  import type { QualificationAudit, UpstreamRelation } from './qualification-audit.ts';
50
52
  import { getArtifactSchema } from './artifact-schema.ts';
51
- import { inspectArtifactContract } from './artifact-operations.ts';
53
+ import { canonicalSemanticDigest, inspectArtifactContract } from './artifact-operations.ts';
52
54
  import {
53
55
  consumeLifecycleRecoveryAttestation,
54
56
  lifecycleRecoveryAttestationDigest,
@@ -373,6 +375,67 @@ function reviewInputFamily(family: EventFamily): ArtifactFamily {
373
375
  return family === 'review-analysis' ? 'analysis' : family === 'review-plan' ? 'plan' : 'code';
374
376
  }
375
377
 
378
+ function reconcileReviewCompletionReplay(
379
+ repoRoot: string,
380
+ taskDir: string,
381
+ taskId: string,
382
+ family: EventFamily,
383
+ artifact: string,
384
+ round: number
385
+ ): TaskEventError | null {
386
+ const stage = REVIEW_LEDGER_STAGES[family];
387
+ if (!stage) return null;
388
+ const reviewFamily = family as 'review-analysis' | 'review-plan' | 'review-code';
389
+ try {
390
+ let intent = readArtifactRecoveryIntent(repoRoot, taskId, reviewFamily, artifact);
391
+ if (!intent) return null;
392
+ if (intent.state === 'commit-started') {
393
+ const reconciled = reconcileArtifactRecovery(
394
+ recoveryContextFromIntent(repoRoot, taskDir, intent),
395
+ { lockAlreadyHeld: true }
396
+ );
397
+ if (reconciled.status !== 'passed' && reconciled.status !== 'consumed') {
398
+ return {
399
+ code: 'EVENT_ARTIFACT_CONFLICT',
400
+ message: `review finalizer recovery remains ${reconciled.status} for ${artifact}`
401
+ };
402
+ }
403
+ intent = reconciled.intent;
404
+ }
405
+ if (!['passed', 'consumption-started', 'consumed'].includes(intent.state)) {
406
+ return {
407
+ code: 'EVENT_ARTIFACT_CONFLICT',
408
+ message: `review finalizer provenance is incomplete for ${artifact}`
409
+ };
410
+ }
411
+ const validated = validateCompletedArtifact(taskDir, FAMILY[family].artifact, artifact, round);
412
+ if (!validated.ok) {
413
+ return { code: 'EVENT_ARTIFACT_CONFLICT', message: validated.error.message };
414
+ }
415
+ const content = fs.readFileSync(validated.artifact.path, 'utf8');
416
+ const expectedRequestId = `review-finalize:${taskId}:${stage}:${round}`;
417
+ if (intent.round !== round
418
+ || intent.requestId !== expectedRequestId
419
+ || intent.finalArtifactSha256 !== sha256Content(content)
420
+ || intent.finalSemanticDigest !== canonicalSemanticDigest(content)) {
421
+ return {
422
+ code: 'EVENT_ARTIFACT_CONFLICT',
423
+ message: `review finalizer provenance does not match ${artifact}`
424
+ };
425
+ }
426
+ if (intent.state === 'passed' || intent.state === 'consumption-started') {
427
+ const consumed = { ...intent, state: 'consumed' as const, updatedAt: Date.now() };
428
+ writeArtifactRecoveryIntent(repoRoot, consumed, { expected: intent });
429
+ }
430
+ return null;
431
+ } catch (error) {
432
+ return {
433
+ code: 'EVENT_ARTIFACT_CONFLICT',
434
+ message: `review finalizer recovery could not be reconciled for ${artifact}: ${error instanceof Error ? error.message : String(error)}`
435
+ };
436
+ }
437
+ }
438
+
376
439
  function relationForStartedInput(family: EventFamily, input: ArtifactIdentity): UpstreamRelation['relation'] {
377
440
  if (family.startsWith('review-')) {
378
441
  return input.family === reviewInputFamily(family) ? 'reviewed-input' : 'approval-context';
@@ -720,6 +783,8 @@ function applyTaskEventUnlocked(request: TaskEventRequest, options: TaskEventOpt
720
783
  const row = manual ? openRows.at(-1) : matchingRows[0];
721
784
  let completedArtifact: ArtifactIdentity | null = null;
722
785
  let localFinalizationIntent: LocalArtifactFinalizationIntent | null = null;
786
+ let reviewFinalizationIntent: ArtifactRecoveryIntent | null = null;
787
+ let reviewContent: string | null = null;
723
788
  if (eventIdentity.phase === 'started' && row) {
724
789
  if (row.agent !== normalized.agent) return failed(normalized, { code: 'EVENT_LOG_CONFLICT', message: 'open started event has a different agent' }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath });
725
790
  if (manual && normalized.transactionId !== undefined) {
@@ -732,7 +797,20 @@ function applyTaskEventUnlocked(request: TaskEventRequest, options: TaskEventOpt
732
797
  }
733
798
  if (eventIdentity.phase === 'started' && !manual && completedRows.length > 0) return failed(normalized, { code: 'EVENT_ALREADY_COMPLETED', message: 'event identity is already completed' }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath });
734
799
  const done = completedRows.find((item) => item.note === eventIdentity.note);
735
- if (eventIdentity.phase === 'completed' && done) return successNoOp(normalized, resolved.taskId, resolved.taskMdPath, currentStep, eventIdentity, done.done, frontmatter, artifactContext);
800
+ if (eventIdentity.phase === 'completed' && done) {
801
+ if (!normalized.dryRun && eventIdentity.family.startsWith('review-') && normalized.artifact && normalized.round) {
802
+ const recoveryError = reconcileReviewCompletionReplay(
803
+ resolved.repoRoot,
804
+ resolved.taskDir,
805
+ resolved.taskId,
806
+ eventIdentity.family,
807
+ normalized.artifact,
808
+ normalized.round
809
+ );
810
+ if (recoveryError) return failed(normalized, recoveryError, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, fromStep: currentStep, toStep: currentStep, action: eventIdentity.action, phase: eventIdentity.phase });
811
+ }
812
+ return successNoOp(normalized, resolved.taskId, resolved.taskMdPath, currentStep, eventIdentity, done.done, frontmatter, artifactContext);
813
+ }
736
814
  if (eventIdentity.phase === 'completed' && completedRows.length > 0 && openRows.length === 0) return failed(normalized, { code: 'EVENT_LOG_CONFLICT', message: 'event identity is already completed with a different payload' }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, fromStep: currentStep, toStep: currentStep, action: eventIdentity.action, phase: eventIdentity.phase });
737
815
  if (eventIdentity.phase === 'completed' && !row?.started && !allowsManualOverride(options.manualOverride, 'task-event', 'EVENT_START_MISSING')) return failed(normalized, { code: 'EVENT_START_MISSING', message: 'completion requires one open matching started event' }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, fromStep: currentStep, toStep: currentStep, action: eventIdentity.action, phase: eventIdentity.phase });
738
816
  if (eventIdentity.phase === 'completed') {
@@ -749,7 +827,6 @@ function applyTaskEventUnlocked(request: TaskEventRequest, options: TaskEventOpt
749
827
  if (validationError) return failed(normalized, validationError, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, fromStep: currentStep, toStep: currentStep, action: eventIdentity.action, phase: eventIdentity.phase });
750
828
  }
751
829
  if (eventIdentity.family.startsWith('review-')) {
752
- let reviewContent: string;
753
830
  try { reviewContent = fs.readFileSync(completedArtifact.path, 'utf8'); }
754
831
  catch (error) {
755
832
  return failed(normalized, { code: 'EVENT_ARTIFACT_CONFLICT', message: `cannot read qualification audit from ${completedArtifact.name}: ${error instanceof Error ? error.message : String(error)}` }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, fromStep: currentStep, toStep: currentStep, action: eventIdentity.action, phase: eventIdentity.phase });
@@ -776,6 +853,23 @@ function applyTaskEventUnlocked(request: TaskEventRequest, options: TaskEventOpt
776
853
  const localFamily = eventIdentity.family === 'analyze'
777
854
  ? 'analysis'
778
855
  : eventIdentity.family === 'plan' ? 'plan' : 'code';
856
+ try {
857
+ localFinalizationIntent = readLocalArtifactFinalizationIntent(
858
+ resolved.repoRoot, resolved.taskId, localFamily, completedArtifact.name
859
+ );
860
+ if (localFinalizationIntent?.state === 'commit-started') {
861
+ const reconciled = reconcileArtifactRecovery(
862
+ recoveryContextFromIntent(resolved.repoRoot, resolved.taskDir, localFinalizationIntent),
863
+ { lockAlreadyHeld: true }
864
+ );
865
+ if (reconciled.status === 'passed' || reconciled.status === 'consumed') localFinalizationIntent = reconciled.intent;
866
+ }
867
+ } catch (error) {
868
+ return failed(normalized, {
869
+ code: 'EVENT_ARTIFACT_CONFLICT',
870
+ message: `local finalizer recovery could not be reconciled for ${completedArtifact.name}: ${error instanceof Error ? error.message : String(error)}`
871
+ }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, fromStep: currentStep, toStep: currentStep, action: eventIdentity.action, phase: eventIdentity.phase });
872
+ }
779
873
  let artifactContent: string;
780
874
  try { artifactContent = fs.readFileSync(completedArtifact.path, 'utf8'); }
781
875
  catch (error) {
@@ -805,34 +899,23 @@ function applyTaskEventUnlocked(request: TaskEventRequest, options: TaskEventOpt
805
899
  message: `semantic digest does not match finalizer result for ${completedArtifact.name}`
806
900
  }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, fromStep: currentStep, toStep: currentStep, action: eventIdentity.action, phase: eventIdentity.phase });
807
901
  }
808
- try {
809
- localFinalizationIntent = readLocalArtifactFinalizationIntent(
810
- resolved.repoRoot, resolved.taskId, localFamily, completedArtifact.name
811
- );
812
- } catch (error) {
813
- return failed(normalized, {
814
- code: 'EVENT_ARTIFACT_CONFLICT',
815
- message: `cannot read local finalizer provenance for ${completedArtifact.name}: ${error instanceof Error ? error.message : String(error)}`
816
- }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, fromStep: currentStep, toStep: currentStep, action: eventIdentity.action, phase: eventIdentity.phase });
817
- }
818
- if (!localFinalizationIntent || !['passed', 'consumed', 'finalize-ready', 'commit-started'].includes(localFinalizationIntent.state)) {
902
+ if (!localFinalizationIntent || !['passed', 'consumed'].includes(localFinalizationIntent.state)) {
819
903
  return failed(normalized, {
820
904
  code: 'EVENT_ARTIFACT_CONFLICT',
821
905
  message: `local finalizer provenance is missing or incomplete for ${completedArtifact.name}`
822
906
  }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, fromStep: currentStep, toStep: currentStep, action: eventIdentity.action, phase: eventIdentity.phase });
823
907
  }
824
- if (localFinalizationIntent.artifactSha256 !== actualSha256 || localFinalizationIntent.semanticDigest !== local.semanticDigest) {
908
+ if (localFinalizationIntent.finalArtifactSha256 !== actualSha256 || localFinalizationIntent.finalSemanticDigest !== local.semanticDigest) {
825
909
  return failed(normalized, {
826
910
  code: 'EVENT_ARTIFACT_CONFLICT',
827
911
  message: `local finalizer provenance does not match ${completedArtifact.name}`
828
912
  }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, fromStep: currentStep, toStep: currentStep, action: eventIdentity.action, phase: eventIdentity.phase });
829
913
  }
830
- if (localFinalizationIntent.recoveryOperationId !== null) {
914
+ if (localFinalizationIntent.authorityDigest !== null) {
831
915
  if (!lifecycleAuthority
832
916
  || localFinalizationIntent.recoveryOperationId !== lifecycleAuthority.operationId
833
917
  || localFinalizationIntent.requestId !== lifecycleAuthority.lifecycleRequestId
834
- || (localFinalizationIntent.phase !== 'artifact.finalize-local'
835
- && localFinalizationIntent.authorityDigest !== lifecycleRecoveryAttestationDigest(lifecycleAuthority))) {
918
+ || localFinalizationIntent.authorityDigest !== lifecycleRecoveryAttestationDigest(lifecycleAuthority)) {
836
919
  return failed(normalized, {
837
920
  code: 'EVENT_ARTIFACT_CONFLICT',
838
921
  message: `local finalizer provenance does not match lifecycle completion authority for ${completedArtifact.name}`
@@ -865,6 +948,49 @@ function applyTaskEventUnlocked(request: TaskEventRequest, options: TaskEventOpt
865
948
  completedArtifact?.path ?? null
866
949
  );
867
950
  if (findingCountError) return failed(normalized, findingCountError, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, fromStep: currentStep, toStep: currentStep, action: eventIdentity.action, phase: eventIdentity.phase });
951
+ if (eventIdentity.phase === 'completed' && eventIdentity.family.startsWith('review-') && completedArtifact && reviewContent !== null) {
952
+ try {
953
+ reviewFinalizationIntent = readArtifactRecoveryIntent(
954
+ resolved.repoRoot,
955
+ resolved.taskId,
956
+ eventIdentity.family as 'review-analysis' | 'review-plan' | 'review-code',
957
+ completedArtifact.name
958
+ );
959
+ if (!normalized.dryRun && reviewFinalizationIntent?.state === 'commit-started') {
960
+ const reconciled = reconcileArtifactRecovery(
961
+ recoveryContextFromIntent(resolved.repoRoot, resolved.taskDir, reviewFinalizationIntent),
962
+ { lockAlreadyHeld: true }
963
+ );
964
+ if (reconciled.status === 'passed' || reconciled.status === 'consumed') reviewFinalizationIntent = reconciled.intent;
965
+ }
966
+ if (!reviewFinalizationIntent || !['passed', 'consumption-started', 'consumed'].includes(reviewFinalizationIntent.state)) {
967
+ return failed(normalized, {
968
+ code: 'EVENT_ARTIFACT_CONFLICT',
969
+ message: `review finalizer provenance is missing or incomplete for ${completedArtifact.name}`
970
+ }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, fromStep: currentStep, toStep: currentStep, action: eventIdentity.action, phase: eventIdentity.phase });
971
+ }
972
+ const reviewStage = eventIdentity.family === 'review-analysis'
973
+ ? 'analysis'
974
+ : eventIdentity.family === 'review-plan' ? 'plan' : 'code';
975
+ const actualSha256 = sha256File(completedArtifact.path);
976
+ const actualSemanticDigest = canonicalSemanticDigest(reviewContent);
977
+ const expectedRequestId = `review-finalize:${resolved.taskId}:${reviewStage}:${normalized.round}`;
978
+ if (reviewFinalizationIntent.round !== normalized.round
979
+ || reviewFinalizationIntent.requestId !== expectedRequestId
980
+ || reviewFinalizationIntent.finalArtifactSha256 !== actualSha256
981
+ || reviewFinalizationIntent.finalSemanticDigest !== actualSemanticDigest) {
982
+ return failed(normalized, {
983
+ code: 'EVENT_ARTIFACT_CONFLICT',
984
+ message: `review finalizer provenance does not match ${completedArtifact.name}`
985
+ }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, fromStep: currentStep, toStep: currentStep, action: eventIdentity.action, phase: eventIdentity.phase });
986
+ }
987
+ } catch (error) {
988
+ return failed(normalized, {
989
+ code: 'EVENT_ARTIFACT_CONFLICT',
990
+ message: `review finalizer recovery could not be reconciled for ${completedArtifact.name}: ${error instanceof Error ? error.message : String(error)}`
991
+ }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, fromStep: currentStep, toStep: currentStep, action: eventIdentity.action, phase: eventIdentity.phase });
992
+ }
993
+ }
868
994
  let orchestrationCompletion: OrchestrationStageCompletion | null = null;
869
995
  if (eventIdentity.phase === 'completed' && eventIdentity.family !== 'manual-validation' && eventIdentity.family !== 'validation-run') {
870
996
  const orchestrationStage = eventIdentity.family === 'analyze' ? 'analysis' : eventIdentity.family;
@@ -990,15 +1116,15 @@ function applyTaskEventUnlocked(request: TaskEventRequest, options: TaskEventOpt
990
1116
  const expectedIntent = localFinalizationIntent;
991
1117
  localFinalizationIntent = {
992
1118
  ...localFinalizationIntent,
993
- state: 'commit-started',
1119
+ state: 'consumption-started',
994
1120
  phase: 'task-event.completed',
995
1121
  authorityDigest: lifecycleRecoveryAttestationDigest(lifecycleAuthority),
996
1122
  requestId: lifecycleAuthority.lifecycleRequestId,
997
1123
  updatedAt: Date.now()
998
1124
  };
999
- writeArtifactRepairIntent(resolved.repoRoot, localFinalizationIntent, { expected: expectedIntent });
1125
+ writeArtifactRecoveryIntent(resolved.repoRoot, localFinalizationIntent, { expected: expectedIntent });
1000
1126
  } else if (!lifecycleAuthority && localFinalizationIntent.state === 'passed') {
1001
- localFinalizationIntent = consumeLocalArtifactFinalizationIntent(resolved.repoRoot, localFinalizationIntent);
1127
+ localFinalizationIntent = consumeLocalArtifactFinalizationIntent(resolved.repoRoot, localFinalizationIntent, { lockAlreadyHeld: options.lockAlreadyHeld });
1002
1128
  }
1003
1129
  } catch (error) {
1004
1130
  return failed(normalized, {
@@ -1015,6 +1141,31 @@ function applyTaskEventUnlocked(request: TaskEventRequest, options: TaskEventOpt
1015
1141
  });
1016
1142
  }
1017
1143
  }
1144
+ if (!normalized.dryRun && reviewFinalizationIntent && completedArtifact && reviewFinalizationIntent.state === 'passed') {
1145
+ try {
1146
+ const expectedIntent = reviewFinalizationIntent;
1147
+ reviewFinalizationIntent = {
1148
+ ...reviewFinalizationIntent,
1149
+ state: 'consumption-started',
1150
+ phase: 'task-event.completed',
1151
+ updatedAt: Date.now()
1152
+ };
1153
+ writeArtifactRecoveryIntent(resolved.repoRoot, reviewFinalizationIntent, { expected: expectedIntent });
1154
+ } catch (error) {
1155
+ return failed(normalized, {
1156
+ code: 'EVENT_ARTIFACT_CONFLICT',
1157
+ message: `review finalizer provenance could not be advanced before task write for ${completedArtifact.name}: ${error instanceof Error ? error.message : String(error)}`
1158
+ }, {
1159
+ taskId: resolved.taskId,
1160
+ taskMdPath: resolved.taskMdPath,
1161
+ fromStep: currentStep,
1162
+ toStep: step,
1163
+ action: eventIdentity.action,
1164
+ phase: eventIdentity.phase,
1165
+ artifactContext
1166
+ });
1167
+ }
1168
+ }
1018
1169
  const sourceCompletion = eventIdentity.phase === 'completed'
1019
1170
  && ['analyze', 'plan', 'code'].includes(eventIdentity.family);
1020
1171
  const result = writeTask({ taskRef: normalized.taskRef, expectedState: stateOverride ? resolved.state : 'active', dryRun: normalized.dryRun, mutations }, { ...options, invalidationContext: sourceCompletion ? 'source-completion' : 'standard', metadataProvider: () => metadata });
@@ -1022,7 +1173,7 @@ function applyTaskEventUnlocked(request: TaskEventRequest, options: TaskEventOpt
1022
1173
  if (!normalized.dryRun && lifecycleAuthority && localFinalizationIntent && completedArtifact) {
1023
1174
  try {
1024
1175
  if (!options.deferLifecycleRecoveryConsumption) consumeLifecycleRecoveryAttestation(lifecycleAuthority);
1025
- writeArtifactRepairIntent(resolved.repoRoot, { ...localFinalizationIntent, state: 'consumed', updatedAt: Date.now() }, { expected: localFinalizationIntent });
1176
+ writeArtifactRecoveryIntent(resolved.repoRoot, { ...localFinalizationIntent, state: 'consumed', updatedAt: Date.now() }, { expected: localFinalizationIntent });
1026
1177
  } catch (error) {
1027
1178
  return failed(normalized, {
1028
1179
  code: 'EVENT_ARTIFACT_CONFLICT',
@@ -1030,6 +1181,19 @@ function applyTaskEventUnlocked(request: TaskEventRequest, options: TaskEventOpt
1030
1181
  }, { taskId: result.taskId, taskMdPath: result.taskMdPath, fromStep: currentStep, toStep: step, action: eventIdentity.action, phase: eventIdentity.phase, timestamp: result.timestamp, agentInfraVersion: result.agentInfraVersion, operations: result.operations, artifactContext });
1031
1182
  }
1032
1183
  }
1184
+ if (!normalized.dryRun && reviewFinalizationIntent && completedArtifact) {
1185
+ try {
1186
+ if (reviewFinalizationIntent.state === 'consumption-started') {
1187
+ const consumed = { ...reviewFinalizationIntent, state: 'consumed' as const, updatedAt: Date.now() };
1188
+ writeArtifactRecoveryIntent(resolved.repoRoot, consumed, { expected: reviewFinalizationIntent });
1189
+ }
1190
+ } catch (error) {
1191
+ return failed(normalized, {
1192
+ code: 'EVENT_ARTIFACT_CONFLICT',
1193
+ message: `review finalizer recovery consumption is incomplete: ${error instanceof Error ? error.message : String(error)}`
1194
+ }, { taskId: result.taskId, taskMdPath: result.taskMdPath, fromStep: currentStep, toStep: step, action: eventIdentity.action, phase: eventIdentity.phase, timestamp: result.timestamp, agentInfraVersion: result.agentInfraVersion, operations: result.operations, artifactContext });
1195
+ }
1196
+ }
1033
1197
  if (!normalized.dryRun && orchestrationCompletion) {
1034
1198
  try {
1035
1199
  (options.commitOrchestrationCompletion ?? commitOrchestrationStageCompletion)(orchestrationCompletion);
@@ -1077,7 +1241,7 @@ function applyTaskEvent(request: TaskEventRequest, options: TaskEventOptions = {
1077
1241
  resolved.repoRoot,
1078
1242
  resolved.taskId,
1079
1243
  `task-event.${request.event}`,
1080
- () => applyTaskEventUnlocked(request, options)
1244
+ () => applyTaskEventUnlocked(request, { ...options, lockAlreadyHeld: true })
1081
1245
  );
1082
1246
  } catch (error) {
1083
1247
  if (!(error instanceof TaskExecutionLockError)) throw error;
@@ -617,10 +617,7 @@ function resourceForSnapshot(
617
617
  if (policy.producerId === 'platform.issue') {
618
618
  const identity = options.probeIssueNumber !== undefined
619
619
  ? resourceIdentity(options.probeIssueNumber)
620
- : taskIssueIdentity((frontmatter || {}) as Record<string, string | number | boolean | null>) || (() => {
621
- const number = toPositiveInteger(frontmatter?.issue_number);
622
- return number === null ? null : resourceIdentity(number);
623
- })();
620
+ : taskIssueIdentity((frontmatter || {}) as Record<string, string | number | boolean | null>);
624
621
  return identity ? { kind: 'issue', identity, number: resourceIdentityNumber(identity) } : null;
625
622
  }
626
623
  if (policy.producerId === 'platform.pull-request') {
@@ -719,11 +716,11 @@ async function probePlatformFailure(
719
716
  }
720
717
  const issueIdentity = options.probeIssueNumber !== undefined
721
718
  ? resourceIdentity(options.probeIssueNumber)
722
- : taskIssueIdentity(frontmatter) || (() => {
723
- const number = toPositiveInteger(frontmatter.issue_number);
724
- return number === null ? null : resourceIdentity(number);
725
- })();
719
+ : taskIssueIdentity(frontmatter);
726
720
  const fact = readPrDeliveryFact(frontmatter);
721
+ if (fact.status === 'invalid') {
722
+ return error(fact.error.code, fact.error.message);
723
+ }
727
724
  const pullRequestIdentity = options.probePullRequestNumber !== undefined
728
725
  ? resourceIdentity(options.probePullRequestNumber)
729
726
  : fact.status === 'valid' && fact.fact.state === 'bound' ? fact.fact.identity.resource : null;
@@ -15,17 +15,18 @@ import {
15
15
  type TaskFinalizationReceipt
16
16
  } from './finalization.ts';
17
17
  import {
18
- readLocalArtifactFinalizationIntent,
19
18
  semanticDigest,
20
- sha256Content,
21
- type LocalArtifactFinalizationIntent
19
+ sha256Content
22
20
  } from './local-artifact-finalization.ts';
21
+ import { artifactRecoveryRoot, readArtifactRecoveryIntent } from './artifact-repair-intent.ts';
22
+ import type { ArtifactRecoveryIntent } from './artifact-repair-intent.ts';
23
23
  import { parseArtifactName } from './artifact-name.ts';
24
+ import type { ArtifactSchemaFamily } from './artifact-schema.ts';
24
25
  import { enumerateAllTaskDirs, type TaskWorkspaceState } from './resolve-ref.ts';
25
26
  import type { TaskControlBindingEvidence } from './finalization-state.ts';
26
27
 
27
28
  const TASK_ID_RE = /^TASK-\d{8}-\d{6}$/;
28
- const LOCAL_INTENT_RE = /^(TASK-\d{8}-\d{6})-(analysis|plan|code)-(.+\.md)\.json$/;
29
+ const LOCAL_INTENT_RE = /^(TASK-\d{8}-\d{6})-(analysis|review-analysis|plan|review-plan|code|review-code)-(.+\.md)\.json$/;
29
30
  const COMMIT_INTENT_RE = /^(TASK-\d{8}-\d{6})\.json$/;
30
31
  const AUXILIARY_ROOTS = [
31
32
  '.local-artifact-finalization-intents',
@@ -161,7 +162,7 @@ function localIntentCandidate(
161
162
  task: TaskRecord | undefined,
162
163
  filePath: string,
163
164
  taskId: string,
164
- family: 'analysis' | 'plan' | 'code',
165
+ family: ArtifactSchemaFamily,
165
166
  artifact: string,
166
167
  identity: FileIdentity,
167
168
  options: ScanOptions
@@ -172,8 +173,8 @@ function localIntentCandidate(
172
173
  || !parseArtifactName(artifact)?.family || parseArtifactName(artifact)!.family !== family) {
173
174
  return item('LFAI-CONSUMED', taskId, filePath, 'protected', 'PATH_IDENTITY_MISMATCH', identity);
174
175
  }
175
- let intent: LocalArtifactFinalizationIntent | null;
176
- try { intent = readLocalArtifactFinalizationIntent(repoRoot, taskId, family, artifact); }
176
+ let intent: ArtifactRecoveryIntent | null;
177
+ try { intent = readArtifactRecoveryIntent(repoRoot, taskId, family, artifact); }
177
178
  catch { return item('LFAI-CONSUMED', taskId, filePath, 'protected', 'LFAI_SCHEMA_INVALID', identity); }
178
179
  if (!intent || intent.state !== 'consumed') {
179
180
  return item('LFAI-CONSUMED', taskId, filePath, 'protected', 'LFAI_STATE_PROTECTED', identity);
@@ -185,7 +186,7 @@ function localIntentCandidate(
185
186
  let content: string;
186
187
  try { content = fs.readFileSync(artifactPath, 'utf8'); }
187
188
  catch { return item('LFAI-CONSUMED', taskId, filePath, 'protected', 'ARTIFACT_UNREADABLE', identity); }
188
- if (intent.artifactSha256 !== sha256Content(content) || intent.semanticDigest !== semanticDigest(content)) {
189
+ if (intent.finalArtifactSha256 !== sha256Content(content) || intent.finalSemanticDigest !== semanticDigest(content)) {
189
190
  return item('LFAI-CONSUMED', taskId, filePath, 'protected', 'ARTIFACT_DIGEST_MISMATCH', identity);
190
191
  }
191
192
  const receiptReason = receiptGate(repoRoot, task!, options);
@@ -306,7 +307,56 @@ function scanIntermediateCleanup(repoRoot: string, options: ScanOptions = {}): I
306
307
  return buildIntermediateCleanupReport(candidates, true);
307
308
  }
308
309
 
309
- function removeCandidate(candidate: IntermediateCleanupItem): IntermediateCleanupItem {
310
+ function cleanupConsumedRecoveryFiles(repoRoot: string, task: TaskRecord | undefined, candidate: IntermediateCleanupItem): boolean {
311
+ if (candidate.kind !== 'LFAI-CONSUMED' || !candidate.taskId) return true;
312
+ if (!task) return false;
313
+ const match = LOCAL_INTENT_RE.exec(path.basename(candidate.path));
314
+ if (!match) return false;
315
+ let intent: ReturnType<typeof readArtifactRecoveryIntent>;
316
+ try { intent = readArtifactRecoveryIntent(repoRoot, candidate.taskId, match[2] as ArtifactSchemaFamily, match[3]!); }
317
+ catch { return false; }
318
+ if (!intent || intent.state !== 'consumed') return false;
319
+
320
+ const recovery = artifactRecoveryRoot(task.taskDir, intent.recoveryOperationId);
321
+ let stat: fs.Stats | null = null;
322
+ try { stat = fs.lstatSync(recovery); } catch (error) {
323
+ if (errorCode(error) === 'ENOENT') return true;
324
+ return false;
325
+ }
326
+ if (!stat.isDirectory() || stat.isSymbolicLink()) return false;
327
+ let entries: fs.Dirent[];
328
+ try { entries = fs.readdirSync(recovery, { withFileTypes: true }); } catch { return false; }
329
+ for (const entry of entries) {
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;
340
+ }
341
+ try {
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
+ }
350
+ fs.rmdirSync(recovery);
351
+ const recoveryRoot = path.dirname(recovery);
352
+ if (fs.readdirSync(recoveryRoot).length === 0) fs.rmdirSync(recoveryRoot);
353
+ return true;
354
+ } catch (error) {
355
+ return errorCode(error) === 'ENOENT';
356
+ }
357
+ }
358
+
359
+ function removeCandidate(repoRoot: string, candidate: IntermediateCleanupItem, task?: TaskRecord): IntermediateCleanupItem {
310
360
  if (candidate.disposition !== 'planned') return candidate;
311
361
  const root = candidate.kind === 'EMPTY-AUX-PARENT'
312
362
  ? path.dirname(path.dirname(candidate.path))
@@ -323,6 +373,9 @@ function removeCandidate(candidate: IntermediateCleanupItem): IntermediateCleanu
323
373
  const actual = safeLstat(root, candidate.path, candidate.kind === 'EMPTY-AUX-PARENT' ? 'directory' : 'file');
324
374
  if (!actual) return { ...candidate, disposition: 'protected', reason: 'PATH_IDENTITY_CHANGED' };
325
375
  if (!sameFileIdentity(expected, actual)) return { ...candidate, disposition: 'protected', reason: 'PATH_IDENTITY_CHANGED' };
376
+ if (!cleanupConsumedRecoveryFiles(repoRoot, task, candidate)) {
377
+ return { ...candidate, disposition: 'protected', reason: 'RECOVERY_STAGING_NOT_SAFE_TO_REMOVE' };
378
+ }
326
379
  try {
327
380
  if (candidate.kind === 'EMPTY-AUX-PARENT') {
328
381
  if (fs.readdirSync(candidate.path).length !== 0) return { ...candidate, disposition: 'protected', reason: 'AUXILIARY_ROOT_NOT_EMPTY' };
@@ -367,6 +420,7 @@ function cleanupIntermediateUnderRemovalCoordinator(
367
420
  indexes.push(index);
368
421
  byTask.set(candidate.taskId, indexes);
369
422
  }
423
+ const tasks = inspectTaskRecords(repoRoot, new Set(byTask.keys()));
370
424
 
371
425
  for (const taskId of [...byTask.keys()].sort()) {
372
426
  const execute = (): void => {
@@ -380,7 +434,7 @@ function cleanupIntermediateUnderRemovalCoordinator(
380
434
  if (initialCandidate.disposition !== 'planned') continue;
381
435
  const current = refreshedByKey.get(intermediateCleanupKey(initialCandidate));
382
436
  items[index] = current
383
- ? removeCandidate(current)
437
+ ? removeCandidate(repoRoot, current, tasks.get(taskId))
384
438
  : { ...initialCandidate, disposition: 'protected', reason: 'CANDIDATE_NO_LONGER_ELIGIBLE' };
385
439
  }
386
440
  };
@@ -397,14 +451,14 @@ function cleanupIntermediateUnderRemovalCoordinator(
397
451
 
398
452
  for (const [index, candidate] of items.entries()) {
399
453
  if (candidate.taskId || candidate.disposition !== 'planned') continue;
400
- items[index] = removeCandidate(candidate);
454
+ items[index] = removeCandidate(repoRoot, candidate);
401
455
  }
402
456
 
403
457
  const knownPaths = new Set(items.map((candidate) => intermediateCleanupKey(candidate)));
404
458
  for (const candidate of scanIntermediateCleanup(repoRoot, scanOptions).items) {
405
459
  if (candidate.kind !== 'EMPTY-AUX-PARENT' || candidate.disposition !== 'planned'
406
460
  || knownPaths.has(intermediateCleanupKey(candidate))) continue;
407
- items.push(removeCandidate(candidate));
461
+ items.push(removeCandidate(repoRoot, candidate));
408
462
  }
409
463
 
410
464
  return buildIntermediateCleanupReport(items);