@fitlab-ai/agent-infra 0.11.5 → 0.11.7

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 (155) hide show
  1. package/dist/lib/git/primary-worktree.js +58 -0
  2. package/dist/lib/merge.js +62 -10
  3. package/dist/lib/platform/github-provider.js +9 -14
  4. package/dist/lib/platform/github-release-notes.js +80 -11
  5. package/dist/lib/platform/none-provider.js +1 -1
  6. package/dist/lib/platform/provider-contract.d.ts +39 -16
  7. package/dist/lib/platform/provider-contract.js +1 -1
  8. package/dist/lib/platform/provider-validation.js +83 -13
  9. package/dist/lib/platform/pull-requests.js +29 -3
  10. package/dist/lib/platform/release-notes.js +7 -8
  11. package/dist/lib/sandbox/capture.js +1 -1
  12. package/dist/lib/sandbox/commands/create.js +46 -1
  13. package/dist/lib/sandbox/commands/enter.js +1 -1
  14. package/dist/lib/sandbox/commands/start.js +1 -1
  15. package/dist/lib/sandbox/config.js +10 -0
  16. package/dist/lib/sandbox/control/lifecycle.js +18 -13
  17. package/dist/lib/sandbox/control/server.js +6 -2
  18. package/dist/lib/sandbox/task-resolver.js +62 -2
  19. package/dist/lib/server/config.js +7 -27
  20. package/dist/lib/server/daemon.js +4 -3
  21. package/dist/lib/server/process-control.js +4 -2
  22. package/dist/lib/server/runner.js +4 -3
  23. package/dist/lib/task/artifact-lifecycle.js +42 -10
  24. package/dist/lib/task/artifact-receipts.js +62 -22
  25. package/dist/lib/task/capabilities.js +22 -4
  26. package/dist/lib/task/completion-facts.js +56 -0
  27. package/dist/lib/task/create-service.js +6 -2
  28. package/dist/lib/task/create.js +46 -82
  29. package/dist/lib/task/events.js +268 -195
  30. package/dist/lib/task/finalization-handoff.js +2 -1
  31. package/dist/lib/task/invalidation-command.js +4 -2
  32. package/dist/lib/task/invalidation.js +38 -9
  33. package/dist/lib/task/local-artifact-finalization.js +17 -3
  34. package/dist/lib/task/qualification-audit.js +23 -105
  35. package/dist/lib/task/resolve-ref.js +3 -3
  36. package/dist/lib/task/review-finalization.js +7 -0
  37. package/dist/lib/task/review-identity.js +69 -0
  38. package/dist/lib/task/sections.js +6 -5
  39. package/dist/lib/task/task-execution-lock.js +6 -3
  40. package/dist/lib/task/verification-engine.js +14 -63
  41. package/dist/lib/task/verification.js +35 -38
  42. package/dist/lib/task/write.js +121 -1
  43. package/dist/lifecycle-build-manifest.json +3 -3
  44. package/lib/git/primary-worktree.ts +62 -0
  45. package/lib/merge.ts +59 -10
  46. package/lib/platform/github-provider.ts +11 -18
  47. package/lib/platform/github-release-notes.ts +89 -14
  48. package/lib/platform/none-provider.ts +1 -1
  49. package/lib/platform/provider-contract.ts +38 -12
  50. package/lib/platform/provider-validation.ts +83 -12
  51. package/lib/platform/pull-requests.ts +27 -3
  52. package/lib/platform/release-notes.ts +11 -12
  53. package/lib/sandbox/capture.ts +1 -1
  54. package/lib/sandbox/commands/create.ts +52 -1
  55. package/lib/sandbox/commands/enter.ts +1 -1
  56. package/lib/sandbox/commands/start.ts +1 -1
  57. package/lib/sandbox/config.ts +12 -0
  58. package/lib/sandbox/control/lifecycle.ts +22 -13
  59. package/lib/sandbox/control/server.ts +5 -2
  60. package/lib/sandbox/task-resolver.ts +51 -2
  61. package/lib/server/config.ts +7 -29
  62. package/lib/server/daemon.ts +5 -3
  63. package/lib/server/process-control.ts +4 -2
  64. package/lib/server/runner.ts +7 -4
  65. package/lib/server/server.schema.json +0 -4
  66. package/lib/task/artifact-lifecycle.ts +35 -11
  67. package/lib/task/artifact-receipts.ts +55 -15
  68. package/lib/task/capabilities.ts +17 -3
  69. package/lib/task/completion-facts.ts +66 -0
  70. package/lib/task/create-service.ts +7 -3
  71. package/lib/task/create.ts +44 -71
  72. package/lib/task/events.ts +248 -182
  73. package/lib/task/finalization-handoff.ts +2 -1
  74. package/lib/task/invalidation-command.ts +4 -2
  75. package/lib/task/invalidation.ts +35 -10
  76. package/lib/task/local-artifact-finalization.ts +13 -3
  77. package/lib/task/qualification-audit.ts +21 -114
  78. package/lib/task/resolve-ref.ts +3 -3
  79. package/lib/task/review-finalization.ts +7 -0
  80. package/lib/task/review-identity.ts +85 -0
  81. package/lib/task/sections.ts +6 -6
  82. package/lib/task/task-execution-lock.ts +7 -3
  83. package/lib/task/verification-engine.ts +13 -86
  84. package/lib/task/verification.ts +34 -38
  85. package/lib/task/write.ts +115 -1
  86. package/package.json +1 -1
  87. package/templates/.agents/rules/README.en.md +1 -0
  88. package/templates/.agents/rules/README.zh-CN.md +1 -0
  89. package/templates/.agents/rules/decision-qualification.en.md +4 -3
  90. package/templates/.agents/rules/decision-qualification.zh-CN.md +4 -3
  91. package/templates/.agents/rules/evidence-reporting.en.md +1 -2
  92. package/templates/.agents/rules/evidence-reporting.zh-CN.md +1 -2
  93. package/templates/.agents/rules/validation-output.en.md +19 -0
  94. package/templates/.agents/rules/validation-output.zh-CN.md +19 -0
  95. package/templates/.agents/skills/analyze-task/SKILL.en.md +3 -1
  96. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +3 -3
  97. package/templates/.agents/skills/archive-tasks/SKILL.en.md +2 -2
  98. package/templates/.agents/skills/archive-tasks/SKILL.zh-CN.md +2 -2
  99. package/templates/.agents/skills/archive-tasks/scripts/archive-tasks.sh +109 -6
  100. package/templates/.agents/skills/block-task/SKILL.en.md +2 -0
  101. package/templates/.agents/skills/block-task/SKILL.zh-CN.md +1 -1
  102. package/templates/.agents/skills/cancel-task/SKILL.en.md +2 -0
  103. package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +1 -1
  104. package/templates/.agents/skills/code-task/SKILL.en.md +3 -1
  105. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +2 -2
  106. package/templates/.agents/skills/code-task/reference/report-template.en.md +4 -8
  107. package/templates/.agents/skills/code-task/reference/report-template.zh-CN.md +4 -8
  108. package/templates/.agents/skills/commit/SKILL.en.md +1 -1
  109. package/templates/.agents/skills/commit/SKILL.zh-CN.md +1 -1
  110. package/templates/.agents/skills/complete-manual-validation/SKILL.en.md +1 -1
  111. package/templates/.agents/skills/complete-manual-validation/SKILL.zh-CN.md +1 -1
  112. package/templates/.agents/skills/complete-task/SKILL.en.md +2 -0
  113. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +2 -0
  114. package/templates/.agents/skills/create-pr/SKILL.en.md +2 -0
  115. package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +1 -1
  116. package/templates/.agents/skills/create-release-note/SKILL.en.md +17 -11
  117. package/templates/.agents/skills/create-release-note/SKILL.zh-CN.md +17 -11
  118. package/templates/.agents/skills/create-task/SKILL.en.md +2 -0
  119. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +1 -1
  120. package/templates/.agents/skills/import-codescan/SKILL.en.md +2 -0
  121. package/templates/.agents/skills/import-codescan/SKILL.zh-CN.md +1 -1
  122. package/templates/.agents/skills/import-dependabot/SKILL.en.md +2 -0
  123. package/templates/.agents/skills/import-dependabot/SKILL.zh-CN.md +1 -1
  124. package/templates/.agents/skills/import-issue/SKILL.en.md +2 -0
  125. package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +1 -1
  126. package/templates/.agents/skills/plan-task/SKILL.en.md +3 -1
  127. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +3 -3
  128. package/templates/.agents/skills/review-analysis/SKILL.en.md +3 -1
  129. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +3 -1
  130. package/templates/.agents/skills/review-analysis/reference/report-template.en.md +4 -8
  131. package/templates/.agents/skills/review-analysis/reference/report-template.zh-CN.md +4 -8
  132. package/templates/.agents/skills/review-code/SKILL.en.md +13 -12
  133. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +11 -12
  134. package/templates/.agents/skills/review-code/config/verify.en.json +1 -3
  135. package/templates/.agents/skills/review-code/config/verify.zh-CN.json +1 -3
  136. package/templates/.agents/skills/review-code/reference/output-templates.en.md +23 -46
  137. package/templates/.agents/skills/review-code/reference/output-templates.zh-CN.md +27 -48
  138. package/templates/.agents/skills/review-code/reference/report-template.en.md +8 -11
  139. package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +8 -11
  140. package/templates/.agents/skills/review-code/reference/review-criteria.en.md +2 -2
  141. package/templates/.agents/skills/review-code/reference/review-criteria.zh-CN.md +2 -2
  142. package/templates/.agents/skills/review-plan/SKILL.en.md +3 -1
  143. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +3 -1
  144. package/templates/.agents/skills/review-plan/reference/report-template.en.md +5 -9
  145. package/templates/.agents/skills/review-plan/reference/report-template.zh-CN.md +5 -9
  146. package/templates/.agents/skills/review-pr/SKILL.en.md +2 -0
  147. package/templates/.agents/skills/review-pr/SKILL.zh-CN.md +2 -0
  148. package/templates/.agents/skills/run-manual-validation/SKILL.en.md +1 -1
  149. package/templates/.agents/skills/run-manual-validation/SKILL.zh-CN.md +1 -1
  150. package/templates/.agents/skills/run-task/SKILL.en.md +1 -1
  151. package/templates/.agents/skills/run-task/SKILL.zh-CN.md +1 -1
  152. package/templates/.agents/skills/watch-pr/SKILL.en.md +2 -0
  153. package/templates/.agents/skills/watch-pr/SKILL.zh-CN.md +1 -1
  154. package/templates/.agents/templates/task.en.md +2 -2
  155. package/templates/.agents/templates/task.zh-CN.md +2 -2
@@ -13,7 +13,7 @@ import {
13
13
  } from './artifact-lifecycle.ts';
14
14
  import { artifactName, parseArtifactName } from './artifact-name.ts';
15
15
  import type { ArtifactContextResult, ArtifactErrorCode, ArtifactFamily, ArtifactIdentity } from './artifact-lifecycle.ts';
16
- import { ArtifactReceiptError, parseArtifactReceipts, sha256File, upsertArtifactReceipt } from './artifact-receipts.ts';
16
+ import { ArtifactReceiptError, parseArtifactReceipts, sha256File, upsertArtifactReceipts } from './artifact-receipts.ts';
17
17
  import type { ArtifactReceipt } from './artifact-receipts.ts';
18
18
  import { parseTypedTaskFrontmatter } from './frontmatter.ts';
19
19
  import {
@@ -43,10 +43,11 @@ import type { InvalidationTargetKind } from './invalidation.ts';
43
43
  import { reconcileTaskInvalidation } from './invalidation-command.ts';
44
44
  import { consumeReworkIntents, parseReworkIntentDocument, reworkIntentMutation, supersedeReworkIntents } from './rework-intent.ts';
45
45
  import type { ReworkTarget } from './rework-intent.ts';
46
- import { ARTIFACT_FAMILIES, parseQualificationAudit, parseTaskQualification, upstreamArtifactDigest } from './qualification-audit.ts';
47
- import type { QualificationAudit } from './qualification-audit.ts';
46
+ import { ARTIFACT_FAMILIES, parseQualificationAudit, parseTaskQualification } from './qualification-audit.ts';
48
47
  import { getArtifactSchema } from './artifact-schema.ts';
49
48
  import { canonicalSemanticDigest, inspectArtifactContract } from './artifact-operations.ts';
49
+ import { inspectReviewIdentity } from './review-identity.ts';
50
+ import { parseCompletionFacts, type CompletionFact } from './completion-facts.ts';
50
51
  import {
51
52
  consumeLifecycleRecoveryAttestation,
52
53
  lifecycleRecoveryAttestationDigest,
@@ -419,12 +420,99 @@ function eventTrigger(request: TaskEventRequest, family: EventFamily): ExplicitT
419
420
  };
420
421
  }
421
422
 
423
+ function receiptGraphHasCycle(receipts: readonly ArtifactReceipt[]): boolean {
424
+ const edges = new Map<string, string[]>();
425
+ for (const receipt of receipts) {
426
+ const outputs = edges.get(receipt.input) ?? [];
427
+ outputs.push(receipt.output);
428
+ edges.set(receipt.input, outputs);
429
+ }
430
+ const visiting = new Set<string>();
431
+ const visited = new Set<string>();
432
+ const visit = (node: string): boolean => {
433
+ if (visiting.has(node)) return true;
434
+ if (visited.has(node)) return false;
435
+ visiting.add(node);
436
+ for (const output of edges.get(node) ?? []) if (visit(output)) return true;
437
+ visiting.delete(node);
438
+ visited.add(node);
439
+ return false;
440
+ };
441
+ return [...edges.keys()].some(visit);
442
+ }
443
+
444
+ function qualificationInvalidationSeeds(
445
+ content: string,
446
+ taskDir: string,
447
+ inventory: readonly { family: string; name: string }[],
448
+ baselineDigest?: string
449
+ ): { changed: boolean; safe: boolean; seeds: readonly string[] } {
450
+ const task = parseTaskQualification(content);
451
+ if (!task.ok) return { changed: true, safe: false, seeds: [] };
452
+ if (baselineDigest !== undefined) {
453
+ if (!/^[a-f0-9]{64}$/u.test(baselineDigest)) return { changed: true, safe: false, seeds: [] };
454
+ if (baselineDigest === task.qualification.taskInputDigest) return { changed: false, safe: true, seeds: [] };
455
+ }
456
+ const audits: Array<{ name: string; snapshotTaskDigest: string; snapshotNonConstraintDigest: string; dependencies: readonly { constraintId: string; constraintDigest: string }[]; candidates: readonly { candidateId: string; status: string; impact: string; constraintIds: readonly string[]; evidence: string }[] }> = [];
457
+ let observedQualification = false;
458
+ for (const artifact of inventory) {
459
+ let artifactContent: string;
460
+ try { artifactContent = fs.readFileSync(path.join(taskDir, artifact.name), 'utf8'); }
461
+ catch { return { changed: true, safe: false, seeds: [] }; }
462
+ const parsed = parseQualificationAudit(artifactContent);
463
+ if (!parsed.ok) return { changed: true, safe: false, seeds: [] };
464
+ if (!parsed.audit.present) continue;
465
+ observedQualification = true;
466
+ const snapshot = parsed.audit.snapshot;
467
+ if (!snapshot) return { changed: true, safe: false, seeds: [] };
468
+ audits.push({
469
+ name: artifact.name,
470
+ snapshotTaskDigest: snapshot.taskInputDigest,
471
+ snapshotNonConstraintDigest: snapshot.nonConstraintInputDigest,
472
+ dependencies: parsed.audit.constraintDependencies,
473
+ candidates: parsed.audit.candidateQualifications
474
+ });
475
+ }
476
+ if (!task.qualification.present) return { changed: false, safe: true, seeds: [] };
477
+ const allAuditsPresent = observedQualification && audits.length === inventory.length;
478
+ if (!allAuditsPresent) return { changed: true, safe: false, seeds: [] };
479
+ if (baselineDigest === undefined && audits.every((audit) => audit.snapshotTaskDigest === task.qualification.taskInputDigest)) {
480
+ return { changed: false, safe: true, seeds: [] };
481
+ }
482
+ const candidateMap = new Map(task.qualification.candidates.map((candidate) => [candidate.candidateId, candidate]));
483
+ const currentConstraints = new Map(task.qualification.constraints.map((constraint) => [constraint.constraintId, constraint.digest]));
484
+ const snapshotsMatch = audits.every((audit) => audit.snapshotNonConstraintDigest === task.qualification.nonConstraintInputDigest);
485
+ const candidatesMatch = audits.every((audit) => audit.candidates.length === candidateMap.size
486
+ && audit.candidates.every((row) => {
487
+ const current = candidateMap.get(row.candidateId);
488
+ return Boolean(current && row.status === current.status && row.impact === current.impact
489
+ && row.evidence === current.evidence && row.constraintIds.join(',') === current.constraintIds.join(','));
490
+ }));
491
+ const changedConstraints = new Set<string>();
492
+ let referencesValid = true;
493
+ for (const audit of audits) {
494
+ for (const dependency of audit.dependencies) {
495
+ const currentDigest = currentConstraints.get(dependency.constraintId);
496
+ if (!currentDigest) referencesValid = false;
497
+ else if (currentDigest !== dependency.constraintDigest) changedConstraints.add(dependency.constraintId);
498
+ }
499
+ }
500
+ const safe = allAuditsPresent && snapshotsMatch && candidatesMatch && referencesValid && changedConstraints.size > 0;
501
+ if (!safe) return { changed: true, safe: false, seeds: [] };
502
+ const seeds = new Set<string>();
503
+ for (const audit of audits) {
504
+ if (audit.dependencies.some((dependency) => changedConstraints.has(dependency.constraintId))) seeds.add(audit.name);
505
+ }
506
+ return { changed: true, safe: true, seeds: [...seeds] };
507
+ }
508
+
422
509
  function invalidationMutationForCompletion(
423
510
  content: string,
424
511
  taskDir: string,
425
512
  family: EventFamily,
426
513
  artifact: ArtifactIdentity,
427
- timestamp: string
514
+ timestamp: string,
515
+ frontmatter: Record<string, unknown>
428
516
  ): { mutation: ReturnType<typeof invalidationMutation> | null } | { error: string } {
429
517
  if (!['analyze', 'plan', 'code'].includes(family)) return { mutation: null };
430
518
  const parsed = parseInvalidationDocument(content);
@@ -434,6 +522,7 @@ function invalidationMutationForCompletion(
434
522
  plan: ['review-plan', 'code', 'review-code'],
435
523
  code: ['review-code']
436
524
  };
525
+ const qualificationFallback = ['analysis', 'review-analysis', 'plan', 'review-plan', 'code', 'review-code'];
437
526
  const sourceFamily = family as 'analyze' | 'plan' | 'code';
438
527
  const sourceArtifactFamily = sourceFamily === 'analyze' ? 'analysis' : sourceFamily;
439
528
  let receipts: readonly ArtifactReceipt[] = [];
@@ -453,97 +542,75 @@ function invalidationMutationForCompletion(
453
542
  throw new Error(`cannot inspect invalidation artifact '${name}': ${error instanceof Error ? error.message : String(error)}`);
454
543
  }
455
544
  });
456
- const taskQualification = parseTaskQualification(content);
457
- if (!taskQualification.ok) return { error: taskQualification.message };
458
545
  const nodeMap = new Map(inventory.map((node) => [`${node.family}/${node.name}`, node]));
459
- const audits = new Map<string, QualificationAudit>();
460
- let taskInputChanged = false;
461
- let graphUsable = taskQualification.qualification.present;
462
- if (graphUsable) {
463
- for (const node of inventory) {
464
- const artifactContent = fs.readFileSync(path.join(taskDir, node.name), 'utf8');
465
- const parsedAudit = parseQualificationAudit(artifactContent);
466
- if (!parsedAudit.ok || !parsedAudit.audit.present || !parsedAudit.audit.snapshot) { graphUsable = false; break; }
467
- audits.set(`${node.family}/${node.name}`, parsedAudit.audit);
468
- if (parsedAudit.audit.snapshot.taskInputDigest !== taskQualification.qualification.taskInputDigest) taskInputChanged = true;
469
- if (parsedAudit.audit.snapshot.nonConstraintInputDigest !== taskQualification.qualification.nonConstraintInputDigest) {
470
- graphUsable = false;
471
- break;
472
- }
473
- if (parsedAudit.audit.snapshot.upstreamArtifactDigest !== upstreamArtifactDigest(parsedAudit.audit.upstreamRelations)) {
474
- graphUsable = false;
475
- break;
476
- }
477
- for (const relation of parsedAudit.audit.upstreamRelations) {
478
- const upstream = nodeMap.get(`${relation.upstreamFamily}/${relation.upstreamArtifact}`);
479
- if (!upstream || upstream.round !== relation.upstreamRound || upstream.sha256 !== relation.upstreamSha256) { graphUsable = false; break; }
480
- }
481
- if (!graphUsable) break;
546
+ let graphUsable = receipts.length > 0;
547
+ for (const receipt of receipts) {
548
+ const output = parseArtifactName(receipt.output);
549
+ const input = nodeMap.get(`${parseArtifactName(receipt.input)?.family}/${receipt.input}`);
550
+ if (!output || !nodeMap.has(`${output.family}/${receipt.output}`) || !input || input.sha256 !== receipt.inputSha256) {
551
+ graphUsable = false;
552
+ break;
482
553
  }
483
554
  }
555
+ if (graphUsable && receiptGraphHasCycle(receipts)) graphUsable = false;
556
+ if (inventory.some((node) => node.family !== 'analysis' && node.name !== artifact.name
557
+ && !receipts.some((receipt) => receipt.output === node.name))) graphUsable = false;
484
558
  const selected = new Set<string>();
485
559
  if (graphUsable) {
486
- const changedConstraints = new Set<string>();
487
- for (const audit of audits.values()) {
488
- for (const dependency of audit.constraintDependencies) {
489
- const current = taskQualification.qualification.constraints.find((row) => row.constraintId === dependency.constraintId);
490
- if (!current || current.digest !== dependency.constraintDigest) changedConstraints.add(dependency.constraintId);
560
+ const currentIdentity = parseArtifactName(artifact.name);
561
+ const previousName = currentIdentity && currentIdentity.round > 1 ? artifactName(sourceArtifactFamily, currentIdentity.round - 1) : null;
562
+ const previous = previousName ? inventory.find((node) => node.family === sourceArtifactFamily && node.name === previousName) : null;
563
+ const previousFact = previousName ? parseCompletionFacts(frontmatter.completion_facts).find((fact) => fact.output === previousName && fact.event === `${sourceFamily === 'analyze' ? 'analysis' : sourceFamily}.completed`) : null;
564
+ if (!previous || !previousFact || previousFact.outputSha256 !== previous.sha256) graphUsable = false;
565
+ if (graphUsable && previous) {
566
+ selected.add(`${previous.family}/${previous.name}`);
567
+ let changed = true;
568
+ while (changed) {
569
+ changed = false;
570
+ for (const receipt of receipts) {
571
+ const consumer = `${parseArtifactName(receipt.output)!.family}/${receipt.output}`;
572
+ if (selected.has(`${parseArtifactName(receipt.input)!.family}/${receipt.input}`) && !selected.has(consumer)) {
573
+ selected.add(consumer);
574
+ changed = true;
575
+ }
576
+ }
491
577
  }
578
+ selected.delete(`${previous.family}/${previous.name}`);
492
579
  }
493
- const currentCandidates = new Map(taskQualification.qualification.candidates.map((candidate) => [candidate.candidateId, candidate]));
494
- const candidateSnapshotMatches = [...audits.values()].every((audit) => audit.candidateQualifications.length === currentCandidates.size && audit.candidateQualifications.every((row) => {
495
- const current = currentCandidates.get(row.candidateId);
496
- return Boolean(current && current.status === row.status && current.impact === row.impact
497
- && current.evidence === row.evidence && current.constraintIds.join(',') === row.constraintIds.join(','));
498
- }));
499
- // A task-input digest also covers candidates. A narrowed invalidation graph
500
- // is safe only when the old audit rows show that the candidate projection
501
- // stayed unchanged and the change is explained by at least one constraint.
502
- if (taskInputChanged && (changedConstraints.size === 0 || !candidateSnapshotMatches)) graphUsable = false;
503
- for (const node of inventory) {
504
- const audit = audits.get(`${node.family}/${node.name}`)!;
505
- if (audit.constraintDependencies.some((dependency) => changedConstraints.has(dependency.constraintId))) selected.add(`${node.family}/${node.name}`);
506
- if (audit.upstreamRelations.some((relation) => relation.upstreamFamily === sourceArtifactFamily)) selected.add(`${node.family}/${node.name}`);
507
- }
508
- // Relation rows point from a consumer to its actual upstream artifact. Walk
509
- // that reverse edge so review-only seeds and multi-hop consumers receive the
510
- // same derived targets as ordinary executor seeds.
580
+ }
581
+ const qualification = qualificationInvalidationSeeds(
582
+ content, taskDir, inventory,
583
+ typeof frontmatter.qualification_input_digest === 'string' ? frontmatter.qualification_input_digest : undefined
584
+ );
585
+ let qualificationChange = qualification.changed;
586
+ if (qualificationChange && (!graphUsable || !qualification.safe)) graphUsable = false;
587
+ if (qualificationChange && graphUsable) {
588
+ for (const seed of qualification.seeds) selected.add(`${parseArtifactName(seed)?.family}/${seed}`);
511
589
  let changed = true;
512
590
  while (changed) {
513
591
  changed = false;
514
- for (const node of inventory) {
515
- const key = `${node.family}/${node.name}`;
516
- const audit = audits.get(key)!;
517
- if (audit.upstreamRelations.some((relation) => selected.has(`${relation.upstreamFamily}/${relation.upstreamArtifact}`)) && !selected.has(key)) {
518
- selected.add(key);
592
+ for (const receipt of receipts) {
593
+ const consumer = `${parseArtifactName(receipt.output)!.family}/${receipt.output}`;
594
+ if (selected.has(`${parseArtifactName(receipt.input)!.family}/${receipt.input}`) && !selected.has(consumer)) {
595
+ selected.add(consumer);
519
596
  changed = true;
520
597
  }
521
598
  }
522
599
  }
523
- // A relation cycle cannot establish a safe downstream closure.
524
- const visiting = new Set<string>();
525
- const visited = new Set<string>();
526
- const hasCycle = (key: string): boolean => {
527
- if (visiting.has(key)) return true;
528
- if (visited.has(key)) return false;
529
- visiting.add(key);
530
- const cycle = audits.get(key)?.upstreamRelations.some((relation) => hasCycle(`${relation.upstreamFamily}/${relation.upstreamArtifact}`)) ?? false;
531
- visiting.delete(key); visited.add(key);
532
- return cycle;
533
- };
534
- if ([...nodeMap.keys()].some(hasCycle)) graphUsable = false;
535
600
  }
536
- const reasonCode = graphUsable ? 'qualification-changed' : 'upstream-replaced';
601
+ const reasonCode = qualificationChange ? 'qualification-changed' : 'upstream-replaced';
602
+ const fallbackFamilies = qualificationChange ? qualificationFallback : downstream[sourceFamily];
537
603
  const targetNodes = (graphUsable
538
604
  ? inventory.filter((node) => selected.has(`${node.family}/${node.name}`))
539
- : inventory.filter((node) => downstream[sourceFamily].includes(node.family)))
605
+ : inventory.filter((node) => fallbackFamilies.includes(node.family)))
540
606
  .filter((node) => !(node.family === FAMILY[family].artifact && node.name === artifact.name));
541
607
  const targets = targetNodes.flatMap((node) => {
542
- const shapes: Array<{ targetKind: InvalidationTargetKind; targetFamily: string; targetArtifact: string; targetRound: number; targetSha256: string }> = [
608
+ const shapes: Array<{ targetKind: InvalidationTargetKind; targetFamily: string; targetArtifact: string; targetInput?: string; targetRound: number; targetSha256: string }> = [
543
609
  { targetKind: 'artifact', targetFamily: node.family, targetArtifact: node.name, targetRound: node.round, targetSha256: node.sha256 }
544
610
  ];
545
- const receipt = receipts.find((candidate) => candidate.output === node.name);
546
- if (receipt) shapes.push({ targetKind: 'receipt', targetFamily: node.family, targetArtifact: node.name, targetRound: node.round, targetSha256: receipt.inputSha256 });
611
+ for (const receipt of receipts.filter((candidate) => candidate.output === node.name)) {
612
+ shapes.push({ targetKind: 'receipt', targetFamily: node.family, targetArtifact: node.name, targetInput: receipt.input, targetRound: node.round, targetSha256: receipt.inputSha256 });
613
+ }
547
614
  if (node.family.startsWith('review-')) shapes.push({ targetKind: 'approval', targetFamily: node.family, targetArtifact: node.name, targetRound: node.round, targetSha256: node.sha256 });
548
615
  if (node.family === 'review-code') shapes.push({ targetKind: 'reviewed-snapshot', targetFamily: node.family, targetArtifact: node.name, targetRound: node.round, targetSha256: node.sha256 });
549
616
  return shapes.map((targetShape) => ({ ...targetShape, targetId: targetIdFor('pending', targetShape), operationId: 'pending', status: 'pending' as const, reasonCode, updatedAt: timestamp }));
@@ -609,101 +676,76 @@ function buildCompletionReceipt(
609
676
  family: EventFamily,
610
677
  artifact: ArtifactIdentity,
611
678
  completedAt: string,
612
- frontmatter: Record<string, unknown>
613
- ): { ok: true; receipt: ArtifactReceipt } | { ok: false; message: string } | null {
614
- const existing = (() => {
615
- try {
616
- return parseArtifactReceipts(content).rows.find((receipt) => receipt.output === artifact.name) ?? null;
617
- } catch {
618
- return null;
619
- }
620
- })();
621
- if (family === 'code') {
622
- const startedInput = typeof frontmatter.code_input_artifact === 'string'
623
- ? frontmatter.code_input_artifact : existing?.input ?? '';
624
- const startedSha256 = typeof frontmatter.code_input_sha256 === 'string'
625
- ? frontmatter.code_input_sha256 : existing?.inputSha256 ?? '';
626
- if (!startedInput || !startedSha256) return { ok: false, message: 'code.started lifecycle input context is missing' };
627
- const inputIdentity = parseArtifactName(startedInput);
628
- if (!inputIdentity || !['analysis', 'plan'].includes(inputIdentity.family)) return { ok: false, message: `code input '${startedInput}' has an invalid family` };
629
- const lifecycleInput = inspectArtifactDirectory(taskDir, inputIdentity.family);
630
- if (lifecycleInput.status !== 'ready' || !lifecycleInput.latest || lifecycleInput.latest.name !== startedInput) {
631
- return { ok: false, message: `code input '${startedInput}' is not the latest ${inputIdentity.family} artifact` };
632
- }
633
- try {
634
- const inputSha256 = sha256File(lifecycleInput.latest.path);
635
- if (inputSha256 !== startedSha256) return { ok: false, message: `code input ${startedInput} changed after code.started` };
636
- return {
637
- ok: true,
638
- receipt: {
639
- event: 'code.completed', output: artifact.name, input: startedInput,
640
- inputSha256, completedAt: existing?.completedAt ?? completedAt
641
- }
642
- };
643
- } catch (error) {
644
- return { ok: false, message: `cannot hash code lifecycle input: ${error instanceof Error ? error.message : String(error)}` };
645
- }
646
- }
647
- if (!family.startsWith('review-')) return null;
648
- const expectedFamily = reviewInputFamily(family);
649
- const startedInput = typeof frontmatter.review_input_artifact === 'string'
650
- ? frontmatter.review_input_artifact : existing?.input ?? '';
651
- const startedSha256 = typeof frontmatter.review_input_sha256 === 'string'
652
- ? frontmatter.review_input_sha256 : existing?.inputSha256 ?? '';
653
- if (!startedInput || !startedSha256) return { ok: false, message: 'review started input context is missing' };
654
- const current = inspectArtifactDirectory(taskDir, expectedFamily);
655
- if (current.status !== 'ready' || !current.latest || current.latest.name !== startedInput) {
656
- return { ok: false, message: `review input '${startedInput}' is not the latest ${expectedFamily} artifact` };
679
+ frontmatter: Record<string, unknown>,
680
+ request: TaskEventRequest
681
+ ): { ok: true; receipts: readonly ArtifactReceipt[] } | { ok: false; message: string } | null {
682
+ if (!['analyze', 'plan', 'code'].includes(family) && !family.startsWith('review-')) return null;
683
+ if (family === 'review-code') {
684
+ const identity = inspectReviewIdentity(taskDir, fs.readFileSync(artifact.path, 'utf8'));
685
+ if (identity.status !== 'matched') return { ok: false, message: `review-code identity is ${identity.status}: ${identity.message}` };
657
686
  }
658
- const event = family === 'review-analysis'
659
- ? 'review-analysis.completed' as const
660
- : family === 'review-plan' ? 'review-plan.completed' as const : 'review-code.completed' as const;
687
+ const event = family === 'analyze' ? 'analysis.completed'
688
+ : family === 'plan' ? 'plan.completed'
689
+ : family === 'code' ? 'code.completed'
690
+ : `${family}.completed`;
691
+ const existingReceipts = parseArtifactReceipts(content).rows.filter((row) => row.output === artifact.name);
692
+ let inputs: Array<{ name: string; sha256: string }>;
661
693
  try {
662
- const stat = fs.lstatSync(current.latest.path);
663
- if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('review input is not a regular file');
664
- const inputSha256 = sha256File(current.latest.path);
665
- if (inputSha256 !== startedSha256) return { ok: false, message: `review input ${startedInput} changed after review.started` };
666
- return {
667
- ok: true,
668
- receipt: {
669
- event, output: artifact.name, input: startedInput,
670
- inputSha256, completedAt: existing?.completedAt ?? completedAt
694
+ const raw = frontmatter.lifecycle_input_relations;
695
+ if (typeof raw !== 'string') {
696
+ const replayFact = currentCompletionFact(request, artifact);
697
+ const recordedFact = parseCompletionFacts(frontmatter.completion_facts).find((fact) => sameCompletionFact(fact, replayFact));
698
+ if (!recordedFact) {
699
+ return { ok: false, message: 'started lifecycle input context is missing and no matching completion fact exists for replay' };
671
700
  }
672
- };
673
- } catch (error) {
674
- return { ok: false, message: `cannot hash review input ${startedInput}: ${error instanceof Error ? error.message : String(error)}` };
675
- }
676
- }
677
-
678
- type CompletionFact = Readonly<{
679
- event: string;
680
- output: string;
681
- outputSha256: string;
682
- semanticDigest: string;
683
- requestId: string;
684
- result: string;
685
- }>;
686
-
687
- function completionFacts(frontmatter: Record<string, unknown>): CompletionFact[] {
688
- if (typeof frontmatter.completion_facts !== 'string') return [];
689
- try {
690
- const facts: unknown = JSON.parse(frontmatter.completion_facts);
691
- if (!Array.isArray(facts)) return [];
692
- return facts.filter((fact): fact is CompletionFact => Boolean(
693
- fact && typeof fact === 'object' && !Array.isArray(fact)
694
- && typeof (fact as CompletionFact).event === 'string'
695
- && typeof (fact as CompletionFact).output === 'string'
696
- && /^[a-f0-9]{64}$/u.test((fact as CompletionFact).outputSha256)
697
- && /^[a-f0-9]{64}$/u.test((fact as CompletionFact).semanticDigest)
698
- && typeof (fact as CompletionFact).requestId === 'string'
699
- && typeof (fact as CompletionFact).result === 'string'
700
- ));
701
- } catch {
702
- return [];
701
+ const expected = recordedFact.lifecycleInputs;
702
+ if (!Array.isArray(expected)
703
+ || expected.some((input) => !input || typeof input.name !== 'string' || !parseArtifactName(input.name)
704
+ || typeof input.sha256 !== 'string' || !/^[a-f0-9]{64}$/u.test(input.sha256))) {
705
+ return { ok: false, message: 'matching completion fact does not record a valid lifecycle input set for replay' };
706
+ }
707
+ const actual = existingReceipts.map((receipt) => ({ name: receipt.input, sha256: receipt.inputSha256 }));
708
+ if (expected.length !== actual.length || expected.some((input) => !actual.some((receipt) => receipt.name === input.name && receipt.sha256 === input.sha256))) {
709
+ return { ok: false, message: 'existing input receipts do not match the complete input receipt set recorded at completion' };
710
+ }
711
+ for (const receipt of existingReceipts) {
712
+ const identity = parseArtifactName(receipt.input);
713
+ if (!identity) return { ok: false, message: `lifecycle input '${receipt.input}' has an invalid artifact identity` };
714
+ const inventory = inspectArtifactDirectory(taskDir, identity.family);
715
+ const current = inventory.status === 'ready' && inventory.latest?.name === receipt.input
716
+ ? inventory.artifacts.find((item) => item.name === receipt.input) : null;
717
+ if (!current || sha256File(current.path) !== receipt.inputSha256) return { ok: false, message: `lifecycle input '${receipt.input}' changed after completion` };
718
+ }
719
+ return { ok: true, receipts: existingReceipts };
720
+ }
721
+ const parsed = JSON.parse(raw);
722
+ if (!Array.isArray(parsed)) throw new Error('started lifecycle input context is invalid');
723
+ inputs = parsed;
724
+ } catch (error) { return { ok: false, message: `invalid lifecycle input context: ${error instanceof Error ? error.message : String(error)}` }; }
725
+ const receipts: ArtifactReceipt[] = [];
726
+ for (const input of inputs) {
727
+ if (typeof input?.name !== 'string' || typeof input?.sha256 !== 'string') return { ok: false, message: 'lifecycle input context has an invalid row' };
728
+ const identity = parseArtifactName(input.name);
729
+ if (!identity) return { ok: false, message: `lifecycle input '${input.name}' has an invalid artifact identity` };
730
+ const inventory = inspectArtifactDirectory(taskDir, identity.family);
731
+ const current = inventory.status === 'ready' && inventory.latest?.name === input.name
732
+ ? inventory.artifacts.find((item) => item.name === input.name) : null;
733
+ if (!current) return { ok: false, message: `lifecycle input '${input.name}' is missing, invalidated, or no longer latest` };
734
+ try {
735
+ const actualSha256 = sha256File(current.path);
736
+ if (actualSha256 !== input.sha256) return { ok: false, message: `lifecycle input '${input.name}' changed after stage start` };
737
+ const previous = parseArtifactReceipts(content).rows.find((row) => row.output === artifact.name && row.input === input.name);
738
+ receipts.push({ event: event as ArtifactReceipt['event'], output: artifact.name, input: input.name, inputSha256: actualSha256, completedAt: previous?.completedAt ?? completedAt });
739
+ } catch (error) { return { ok: false, message: `cannot verify lifecycle input '${input.name}': ${error instanceof Error ? error.message : String(error)}` }; }
703
740
  }
741
+ return { ok: true, receipts };
704
742
  }
705
743
 
706
- function currentCompletionFact(request: TaskEventRequest, artifact: ArtifactIdentity): CompletionFact {
744
+ function currentCompletionFact(
745
+ request: TaskEventRequest,
746
+ artifact: ArtifactIdentity,
747
+ lifecycleInputs?: readonly Readonly<{ name: string; sha256: string }>[]
748
+ ): CompletionFact {
707
749
  const content = fs.readFileSync(artifact.path, 'utf8');
708
750
  return {
709
751
  event: request.event,
@@ -721,7 +763,8 @@ function currentCompletionFact(request: TaskEventRequest, artifact: ArtifactIden
721
763
  verdict: request.verdict,
722
764
  fixFor: request.fixFor,
723
765
  implementationInput: request.implementationInput
724
- })
766
+ }),
767
+ ...(lifecycleInputs ? { lifecycleInputs } : {})
725
768
  };
726
769
  }
727
770
 
@@ -942,35 +985,38 @@ function applyTaskEventUnlocked(request: TaskEventRequest, options: TaskEventOpt
942
985
  let metadata;
943
986
  try { metadata = (options.metadataProvider ?? captureTaskWriteMetadata)(); }
944
987
  catch (error) { return failed(normalized, { code: 'METADATA_CAPTURE_FAILED', message: String(error) }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath }); }
945
- let completionReceipt: ArtifactReceipt | null = null;
988
+ let completionReceipts: readonly ArtifactReceipt[] = [];
946
989
  let currentFact: CompletionFact | null = null;
947
990
  let completionInvalidation: ReturnType<typeof invalidationMutation> | null = null;
948
991
  let completionRework: ReturnType<typeof reworkIntentMutation> | null = null;
949
992
  if (eventIdentity.phase === 'completed' && completedArtifact) {
950
- const receipt = buildCompletionReceipt(content, resolved.taskDir, eventIdentity.family, completedArtifact, metadata.timestamp, frontmatter);
993
+ const receipt = buildCompletionReceipt(content, resolved.taskDir, eventIdentity.family, completedArtifact, metadata.timestamp, frontmatter, normalized);
951
994
  if (receipt && !receipt.ok) {
952
995
  return failed(normalized, { code: 'EVENT_ARTIFACT_CONFLICT', message: receipt.message }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, fromStep: currentStep, toStep: currentStep, action: eventIdentity.action, phase: eventIdentity.phase });
953
996
  }
954
- completionReceipt = receipt?.receipt ?? null;
997
+ completionReceipts = receipt?.receipts ?? [];
955
998
  try {
956
- currentFact = currentCompletionFact(normalized, completedArtifact);
999
+ currentFact = currentCompletionFact(normalized, completedArtifact, completionReceipts.map((receipt) => ({
1000
+ name: receipt.input,
1001
+ sha256: receipt.inputSha256
1002
+ })));
957
1003
  } catch (error) {
958
1004
  return failed(normalized, { code: 'EVENT_ARTIFACT_CONFLICT', message: `cannot inspect current completion result: ${error instanceof Error ? error.message : String(error)}` }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath });
959
1005
  }
960
- if (completionFacts(frontmatter).some((fact) => sameCompletionFact(fact, currentFact!))) {
1006
+ if (parseCompletionFacts(frontmatter.completion_facts).some((fact) => sameCompletionFact(fact, currentFact!))) {
961
1007
  return successNoOp(
962
1008
  normalized,
963
1009
  resolved.taskId,
964
1010
  resolved.taskMdPath,
965
1011
  currentStep,
966
1012
  eventIdentity,
967
- completionReceipt?.completedAt ?? metadata.timestamp,
1013
+ completionReceipts[0]?.completedAt ?? metadata.timestamp,
968
1014
  frontmatter,
969
1015
  artifactContext
970
1016
  );
971
1017
  }
972
1018
  try {
973
- const invalidation = invalidationMutationForCompletion(content, resolved.taskDir, eventIdentity.family, completedArtifact, metadata.timestamp);
1019
+ const invalidation = invalidationMutationForCompletion(content, resolved.taskDir, eventIdentity.family, completedArtifact, metadata.timestamp, frontmatter);
974
1020
  if ('error' in invalidation) return failed(normalized, { code: 'EVENT_ARTIFACT_CONFLICT', message: invalidation.error }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, fromStep: currentStep, toStep: currentStep, action: eventIdentity.action, phase: eventIdentity.phase });
975
1021
  completionInvalidation = invalidation.mutation;
976
1022
  const rework = reworkIntentMutationForCompletion(content, resolved.taskDir, eventIdentity.family, completedArtifact, metadata.timestamp);
@@ -984,8 +1030,24 @@ function applyTaskEventUnlocked(request: TaskEventRequest, options: TaskEventOpt
984
1030
  const logStep = eventIdentity.phase === 'started' ? `${eventIdentity.action} [started]` : eventIdentity.action;
985
1031
  const body = appendActivityEntry(section, { time: metadata.timestamp, step: logStep, agent: normalized.agent, note: eventIdentity.note });
986
1032
  const frontmatterSet: Record<string, string> = { current_step: step, assigned_to: normalized.agent };
987
- if (currentFact) frontmatterSet.completion_facts = JSON.stringify(replaceCompletionFact(completionFacts(frontmatter), currentFact));
1033
+ if (currentFact) frontmatterSet.completion_facts = JSON.stringify(replaceCompletionFact(parseCompletionFacts(frontmatter.completion_facts), currentFact));
1034
+ if (completedArtifact && ['analyze', 'plan', 'code'].includes(eventIdentity.family)) {
1035
+ const task = parseTaskQualification(content);
1036
+ if (!task.ok) return failed(normalized, { code: 'EVENT_ARTIFACT_CONFLICT', message: task.message }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath });
1037
+ frontmatterSet.qualification_input_digest = task.qualification.taskInputDigest;
1038
+ }
988
1039
  let frontmatterRemove: string[] | undefined;
1040
+ if (eventIdentity.phase === 'started' && artifactContext && ['analyze', 'plan', 'code', 'review-analysis', 'review-plan', 'review-code'].includes(eventIdentity.family)) {
1041
+ try {
1042
+ const inputs = artifactContext.inputs.map((input) => ({ name: input.name, sha256: sha256File(input.path) }));
1043
+ frontmatterSet.lifecycle_input_relations = JSON.stringify(inputs);
1044
+ } catch (error) {
1045
+ return failed(normalized, { code: 'EVENT_ARTIFACT_CONFLICT', message: `cannot freeze lifecycle inputs: ${error instanceof Error ? error.message : String(error)}` }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, fromStep: currentStep, toStep: step, action: eventIdentity.action, phase: eventIdentity.phase, artifactContext });
1046
+ }
1047
+ }
1048
+ if (eventIdentity.phase === 'completed' && ['analyze', 'plan', 'code', 'review-analysis', 'review-plan', 'review-code'].includes(eventIdentity.family)) {
1049
+ frontmatterRemove = ['lifecycle_input_relations'];
1050
+ }
989
1051
  if (eventIdentity.phase === 'started' && eventIdentity.family === 'code') {
990
1052
  const planInput = artifactContext?.inputs.find((input) => input.family === 'plan' || input.family === 'analysis');
991
1053
  if (!planInput) return failed(normalized, { code: 'EVENT_ARTIFACT_CONFLICT', message: 'code.started lifecycle input context is unavailable' }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, fromStep: currentStep, toStep: step, action: eventIdentity.action, phase: eventIdentity.phase, artifactContext });
@@ -996,19 +1058,23 @@ function applyTaskEventUnlocked(request: TaskEventRequest, options: TaskEventOpt
996
1058
  return failed(normalized, { code: 'EVENT_ARTIFACT_CONFLICT', message: `cannot hash code.started plan input: ${error instanceof Error ? error.message : String(error)}` }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, fromStep: currentStep, toStep: step, action: eventIdentity.action, phase: eventIdentity.phase, artifactContext });
997
1059
  }
998
1060
  } else if (eventIdentity.phase === 'completed' && eventIdentity.family === 'code') {
999
- frontmatterRemove = ['code_input_artifact', 'code_input_sha256'];
1061
+ frontmatterRemove = [...(frontmatterRemove ?? []), 'code_input_artifact', 'code_input_sha256'];
1000
1062
  } else if (eventIdentity.phase === 'started' && eventIdentity.family.startsWith('review-')) {
1001
1063
  const expectedFamily = reviewInputFamily(eventIdentity.family);
1002
1064
  const input = artifactContext?.inputs.find((candidate) => candidate.family === expectedFamily);
1003
- if (!input) return failed(normalized, { code: 'EVENT_ARTIFACT_CONFLICT', message: `review.started ${expectedFamily} input context is unavailable` }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, fromStep: currentStep, toStep: step, action: eventIdentity.action, phase: eventIdentity.phase, artifactContext });
1004
- try {
1005
- frontmatterSet.review_input_artifact = input.name;
1006
- frontmatterSet.review_input_sha256 = sha256File(input.path);
1007
- } catch (error) {
1008
- return failed(normalized, { code: 'EVENT_ARTIFACT_CONFLICT', message: `cannot hash review.started input: ${error instanceof Error ? error.message : String(error)}` }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, fromStep: currentStep, toStep: step, action: eventIdentity.action, phase: eventIdentity.phase, artifactContext });
1065
+ if (!input && eventIdentity.family !== 'review-code') {
1066
+ return failed(normalized, { code: 'EVENT_ARTIFACT_CONFLICT', message: `review.started ${expectedFamily} input context is unavailable` }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, fromStep: currentStep, toStep: step, action: eventIdentity.action, phase: eventIdentity.phase, artifactContext });
1067
+ }
1068
+ if (input) {
1069
+ try {
1070
+ frontmatterSet.review_input_artifact = input.name;
1071
+ frontmatterSet.review_input_sha256 = sha256File(input.path);
1072
+ } catch (error) {
1073
+ return failed(normalized, { code: 'EVENT_ARTIFACT_CONFLICT', message: `cannot hash review.started input: ${error instanceof Error ? error.message : String(error)}` }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, fromStep: currentStep, toStep: step, action: eventIdentity.action, phase: eventIdentity.phase, artifactContext });
1074
+ }
1009
1075
  }
1010
1076
  } else if (eventIdentity.phase === 'completed' && eventIdentity.family.startsWith('review-')) {
1011
- frontmatterRemove = ['review_input_artifact', 'review_input_sha256'];
1077
+ frontmatterRemove = [...(frontmatterRemove ?? []), 'review_input_artifact', 'review_input_sha256'];
1012
1078
  }
1013
1079
  if (eventIdentity.phase === 'completed' && eventIdentity.family === 'review-code' && reviewContent !== null) {
1014
1080
  const reviewedCommit = approvedCleanReviewedCommit(
@@ -1028,9 +1094,9 @@ function applyTaskEventUnlocked(request: TaskEventRequest, options: TaskEventOpt
1028
1094
  const link = buildArtifactLinkSection(content, completedArtifact);
1029
1095
  mutations.push({ kind: 'section', aliases: link.aliases, heading: link.heading, body: link.body });
1030
1096
  }
1031
- if (completionReceipt) {
1097
+ if (completionReceipts.length > 0) {
1032
1098
  try {
1033
- const receiptSection = upsertArtifactReceipt(content, completionReceipt);
1099
+ const receiptSection = upsertArtifactReceipts(content, completionReceipts);
1034
1100
  mutations.push({ kind: 'section', aliases: receiptSection.aliases, heading: receiptSection.heading, body: receiptSection.body });
1035
1101
  } catch (error) {
1036
1102
  const message = error instanceof ArtifactReceiptError ? error.message : String(error);
@@ -95,7 +95,8 @@ export function readTaskFinalizationHandoff(
95
95
  try {
96
96
  const current = fs.lstatSync(file);
97
97
  if (current.isFile() && !current.isSymbolicLink()
98
- && current.dev === initial.dev && current.ino === initial.ino) fs.unlinkSync(file);
98
+ && current.dev === initial.dev && current.ino === initial.ino
99
+ && current.birthtimeMs === initial.birthtimeMs) fs.unlinkSync(file);
99
100
  } catch (error) {
100
101
  if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
101
102
  }
@@ -41,8 +41,10 @@ function reconcileTaskInvalidation(taskRef: string, options: InvalidationCommand
41
41
  for (const target of parsed.document.targets) {
42
42
  if (target.status === 'completed') continue;
43
43
  if (target.targetKind === 'receipt') {
44
- const receipt = receipts.find((candidate) => candidate.output === target.targetArtifact);
45
- if (!receipt || receipt.inputSha256 !== target.targetSha256) {
44
+ const receiptExists = Boolean(target.targetInput) && receipts.some((candidate) => candidate.output === target.targetArtifact
45
+ && candidate.input === target.targetInput
46
+ && candidate.inputSha256 === target.targetSha256);
47
+ if (!receiptExists) {
46
48
  return {
47
49
  status: 'failed', changed: false, taskId: resolved.taskId, processed: 0, remaining: beforePending,
48
50
  error: { code: 'INVALIDATION_TARGET_HASH_CONFLICT', message: `invalidation receipt target '${target.targetArtifact}' changed before reconcile` }