@fitlab-ai/agent-infra 0.11.6 → 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 (115) hide show
  1. package/dist/lib/git/primary-worktree.js +58 -0
  2. package/dist/lib/platform/pull-requests.js +29 -3
  3. package/dist/lib/sandbox/control/server.js +6 -2
  4. package/dist/lib/server/config.js +7 -27
  5. package/dist/lib/server/daemon.js +4 -3
  6. package/dist/lib/server/process-control.js +4 -2
  7. package/dist/lib/server/runner.js +4 -3
  8. package/dist/lib/task/artifact-lifecycle.js +42 -10
  9. package/dist/lib/task/artifact-receipts.js +62 -22
  10. package/dist/lib/task/capabilities.js +22 -4
  11. package/dist/lib/task/completion-facts.js +56 -0
  12. package/dist/lib/task/create-service.js +6 -2
  13. package/dist/lib/task/create.js +46 -82
  14. package/dist/lib/task/events.js +268 -195
  15. package/dist/lib/task/finalization-handoff.js +2 -1
  16. package/dist/lib/task/invalidation-command.js +4 -2
  17. package/dist/lib/task/invalidation.js +38 -9
  18. package/dist/lib/task/local-artifact-finalization.js +17 -3
  19. package/dist/lib/task/qualification-audit.js +23 -105
  20. package/dist/lib/task/review-finalization.js +7 -0
  21. package/dist/lib/task/review-identity.js +69 -0
  22. package/dist/lib/task/sections.js +6 -5
  23. package/dist/lib/task/task-execution-lock.js +6 -3
  24. package/dist/lib/task/verification-engine.js +14 -63
  25. package/dist/lib/task/verification.js +35 -38
  26. package/dist/lifecycle-build-manifest.json +3 -3
  27. package/lib/git/primary-worktree.ts +62 -0
  28. package/lib/platform/pull-requests.ts +27 -3
  29. package/lib/sandbox/control/server.ts +5 -2
  30. package/lib/server/config.ts +7 -29
  31. package/lib/server/daemon.ts +5 -3
  32. package/lib/server/process-control.ts +4 -2
  33. package/lib/server/runner.ts +7 -4
  34. package/lib/server/server.schema.json +0 -4
  35. package/lib/task/artifact-lifecycle.ts +35 -11
  36. package/lib/task/artifact-receipts.ts +55 -15
  37. package/lib/task/capabilities.ts +17 -3
  38. package/lib/task/completion-facts.ts +66 -0
  39. package/lib/task/create-service.ts +7 -3
  40. package/lib/task/create.ts +44 -71
  41. package/lib/task/events.ts +248 -182
  42. package/lib/task/finalization-handoff.ts +2 -1
  43. package/lib/task/invalidation-command.ts +4 -2
  44. package/lib/task/invalidation.ts +35 -10
  45. package/lib/task/local-artifact-finalization.ts +13 -3
  46. package/lib/task/qualification-audit.ts +21 -114
  47. package/lib/task/review-finalization.ts +7 -0
  48. package/lib/task/review-identity.ts +85 -0
  49. package/lib/task/sections.ts +6 -6
  50. package/lib/task/task-execution-lock.ts +7 -3
  51. package/lib/task/verification-engine.ts +13 -86
  52. package/lib/task/verification.ts +34 -38
  53. package/package.json +1 -1
  54. package/templates/.agents/rules/README.en.md +1 -0
  55. package/templates/.agents/rules/README.zh-CN.md +1 -0
  56. package/templates/.agents/rules/decision-qualification.en.md +4 -3
  57. package/templates/.agents/rules/decision-qualification.zh-CN.md +4 -3
  58. package/templates/.agents/rules/validation-output.en.md +19 -0
  59. package/templates/.agents/rules/validation-output.zh-CN.md +19 -0
  60. package/templates/.agents/skills/analyze-task/SKILL.en.md +3 -1
  61. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +3 -3
  62. package/templates/.agents/skills/block-task/SKILL.en.md +2 -0
  63. package/templates/.agents/skills/block-task/SKILL.zh-CN.md +1 -1
  64. package/templates/.agents/skills/cancel-task/SKILL.en.md +2 -0
  65. package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +1 -1
  66. package/templates/.agents/skills/code-task/SKILL.en.md +3 -1
  67. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +2 -2
  68. package/templates/.agents/skills/code-task/reference/report-template.en.md +4 -8
  69. package/templates/.agents/skills/code-task/reference/report-template.zh-CN.md +4 -8
  70. package/templates/.agents/skills/commit/SKILL.en.md +1 -1
  71. package/templates/.agents/skills/commit/SKILL.zh-CN.md +1 -1
  72. package/templates/.agents/skills/complete-manual-validation/SKILL.en.md +1 -1
  73. package/templates/.agents/skills/complete-manual-validation/SKILL.zh-CN.md +1 -1
  74. package/templates/.agents/skills/complete-task/SKILL.en.md +2 -0
  75. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +2 -0
  76. package/templates/.agents/skills/create-pr/SKILL.en.md +2 -0
  77. package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +1 -1
  78. package/templates/.agents/skills/create-task/SKILL.en.md +2 -0
  79. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +1 -1
  80. package/templates/.agents/skills/import-codescan/SKILL.en.md +2 -0
  81. package/templates/.agents/skills/import-codescan/SKILL.zh-CN.md +1 -1
  82. package/templates/.agents/skills/import-dependabot/SKILL.en.md +2 -0
  83. package/templates/.agents/skills/import-dependabot/SKILL.zh-CN.md +1 -1
  84. package/templates/.agents/skills/import-issue/SKILL.en.md +2 -0
  85. package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +1 -1
  86. package/templates/.agents/skills/plan-task/SKILL.en.md +3 -1
  87. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +3 -3
  88. package/templates/.agents/skills/review-analysis/SKILL.en.md +3 -1
  89. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +3 -1
  90. package/templates/.agents/skills/review-analysis/reference/report-template.en.md +4 -8
  91. package/templates/.agents/skills/review-analysis/reference/report-template.zh-CN.md +4 -8
  92. package/templates/.agents/skills/review-code/SKILL.en.md +13 -12
  93. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +11 -12
  94. package/templates/.agents/skills/review-code/config/verify.en.json +1 -3
  95. package/templates/.agents/skills/review-code/config/verify.zh-CN.json +1 -3
  96. package/templates/.agents/skills/review-code/reference/output-templates.en.md +23 -46
  97. package/templates/.agents/skills/review-code/reference/output-templates.zh-CN.md +27 -48
  98. package/templates/.agents/skills/review-code/reference/report-template.en.md +8 -11
  99. package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +8 -11
  100. package/templates/.agents/skills/review-code/reference/review-criteria.en.md +2 -2
  101. package/templates/.agents/skills/review-code/reference/review-criteria.zh-CN.md +2 -2
  102. package/templates/.agents/skills/review-plan/SKILL.en.md +3 -1
  103. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +3 -1
  104. package/templates/.agents/skills/review-plan/reference/report-template.en.md +5 -9
  105. package/templates/.agents/skills/review-plan/reference/report-template.zh-CN.md +5 -9
  106. package/templates/.agents/skills/review-pr/SKILL.en.md +2 -0
  107. package/templates/.agents/skills/review-pr/SKILL.zh-CN.md +2 -0
  108. package/templates/.agents/skills/run-manual-validation/SKILL.en.md +1 -1
  109. package/templates/.agents/skills/run-manual-validation/SKILL.zh-CN.md +1 -1
  110. package/templates/.agents/skills/run-task/SKILL.en.md +1 -1
  111. package/templates/.agents/skills/run-task/SKILL.zh-CN.md +1 -1
  112. package/templates/.agents/skills/watch-pr/SKILL.en.md +2 -0
  113. package/templates/.agents/skills/watch-pr/SKILL.zh-CN.md +1 -1
  114. package/templates/.agents/templates/task.en.md +2 -2
  115. package/templates/.agents/templates/task.zh-CN.md +2 -2
@@ -5,7 +5,7 @@ import { resolveBranchWorktree } from '../git/branch-worktree.js';
5
5
  import { appendActivityEntry, locateActivityLog, pairEntries, startedBackedRows } from './activity-log.js';
6
6
  import { buildArtifactLinkSection, inspectArtifactDirectory, resolveArtifactContext, validateCompletedArtifact } from './artifact-lifecycle.js';
7
7
  import { artifactName, parseArtifactName } from './artifact-name.js';
8
- import { ArtifactReceiptError, parseArtifactReceipts, sha256File, upsertArtifactReceipt } from './artifact-receipts.js';
8
+ import { ArtifactReceiptError, parseArtifactReceipts, sha256File, upsertArtifactReceipts } from './artifact-receipts.js';
9
9
  import { parseTypedTaskFrontmatter } from './frontmatter.js';
10
10
  import { consumeImplementationInput, IMPLEMENTATION_INPUT_ALIASES, parseImplementationInputs, renderImplementationInputs } from './implementation-inputs.js';
11
11
  import { LEDGER_SECTION_MISSING_CODE, LEDGER_SECTION_MISSING_MESSAGE, parseLedgerDocument, summarizeLedgerStage, validateLedgerRows } from './ledger.js';
@@ -22,9 +22,11 @@ import { buildLifecycleFacts, canStart, effectiveReworkTarget } from './capabili
22
22
  import { createInvalidationOperation, invalidationMutation, parseInvalidationDocument, targetIdFor, upsertInvalidation } from './invalidation.js';
23
23
  import { reconcileTaskInvalidation } from './invalidation-command.js';
24
24
  import { consumeReworkIntents, parseReworkIntentDocument, reworkIntentMutation, supersedeReworkIntents } from './rework-intent.js';
25
- import { ARTIFACT_FAMILIES, parseQualificationAudit, parseTaskQualification, upstreamArtifactDigest } from './qualification-audit.js';
25
+ import { ARTIFACT_FAMILIES, parseQualificationAudit, parseTaskQualification } from './qualification-audit.js';
26
26
  import { getArtifactSchema } from './artifact-schema.js';
27
27
  import { canonicalSemanticDigest, inspectArtifactContract } from './artifact-operations.js';
28
+ import { inspectReviewIdentity } from './review-identity.js';
29
+ import { parseCompletionFacts } from './completion-facts.js';
28
30
  import { consumeLifecycleRecoveryAttestation, lifecycleRecoveryAttestationDigest, validateLifecycleRecoveryAttestation } from './control-authority.js';
29
31
  import { readManualValidationCompletion } from './manual-validation-completion.js';
30
32
  import { parseLifecyclePathDecision } from './lifecycle-path.js';
@@ -368,7 +370,106 @@ function eventTrigger(request, family) {
368
370
  explicitRequest: request.requestId !== undefined || request.reasonCode !== undefined || request.initiator !== undefined || request.orchestrated === true
369
371
  };
370
372
  }
371
- function invalidationMutationForCompletion(content, taskDir, family, artifact, timestamp) {
373
+ function receiptGraphHasCycle(receipts) {
374
+ const edges = new Map();
375
+ for (const receipt of receipts) {
376
+ const outputs = edges.get(receipt.input) ?? [];
377
+ outputs.push(receipt.output);
378
+ edges.set(receipt.input, outputs);
379
+ }
380
+ const visiting = new Set();
381
+ const visited = new Set();
382
+ const visit = (node) => {
383
+ if (visiting.has(node))
384
+ return true;
385
+ if (visited.has(node))
386
+ return false;
387
+ visiting.add(node);
388
+ for (const output of edges.get(node) ?? [])
389
+ if (visit(output))
390
+ return true;
391
+ visiting.delete(node);
392
+ visited.add(node);
393
+ return false;
394
+ };
395
+ return [...edges.keys()].some(visit);
396
+ }
397
+ function qualificationInvalidationSeeds(content, taskDir, inventory, baselineDigest) {
398
+ const task = parseTaskQualification(content);
399
+ if (!task.ok)
400
+ return { changed: true, safe: false, seeds: [] };
401
+ if (baselineDigest !== undefined) {
402
+ if (!/^[a-f0-9]{64}$/u.test(baselineDigest))
403
+ return { changed: true, safe: false, seeds: [] };
404
+ if (baselineDigest === task.qualification.taskInputDigest)
405
+ return { changed: false, safe: true, seeds: [] };
406
+ }
407
+ const audits = [];
408
+ let observedQualification = false;
409
+ for (const artifact of inventory) {
410
+ let artifactContent;
411
+ try {
412
+ artifactContent = fs.readFileSync(path.join(taskDir, artifact.name), 'utf8');
413
+ }
414
+ catch {
415
+ return { changed: true, safe: false, seeds: [] };
416
+ }
417
+ const parsed = parseQualificationAudit(artifactContent);
418
+ if (!parsed.ok)
419
+ return { changed: true, safe: false, seeds: [] };
420
+ if (!parsed.audit.present)
421
+ continue;
422
+ observedQualification = true;
423
+ const snapshot = parsed.audit.snapshot;
424
+ if (!snapshot)
425
+ return { changed: true, safe: false, seeds: [] };
426
+ audits.push({
427
+ name: artifact.name,
428
+ snapshotTaskDigest: snapshot.taskInputDigest,
429
+ snapshotNonConstraintDigest: snapshot.nonConstraintInputDigest,
430
+ dependencies: parsed.audit.constraintDependencies,
431
+ candidates: parsed.audit.candidateQualifications
432
+ });
433
+ }
434
+ if (!task.qualification.present)
435
+ return { changed: false, safe: true, seeds: [] };
436
+ const allAuditsPresent = observedQualification && audits.length === inventory.length;
437
+ if (!allAuditsPresent)
438
+ return { changed: true, safe: false, seeds: [] };
439
+ if (baselineDigest === undefined && audits.every((audit) => audit.snapshotTaskDigest === task.qualification.taskInputDigest)) {
440
+ return { changed: false, safe: true, seeds: [] };
441
+ }
442
+ const candidateMap = new Map(task.qualification.candidates.map((candidate) => [candidate.candidateId, candidate]));
443
+ const currentConstraints = new Map(task.qualification.constraints.map((constraint) => [constraint.constraintId, constraint.digest]));
444
+ const snapshotsMatch = audits.every((audit) => audit.snapshotNonConstraintDigest === task.qualification.nonConstraintInputDigest);
445
+ const candidatesMatch = audits.every((audit) => audit.candidates.length === candidateMap.size
446
+ && audit.candidates.every((row) => {
447
+ const current = candidateMap.get(row.candidateId);
448
+ return Boolean(current && row.status === current.status && row.impact === current.impact
449
+ && row.evidence === current.evidence && row.constraintIds.join(',') === current.constraintIds.join(','));
450
+ }));
451
+ const changedConstraints = new Set();
452
+ let referencesValid = true;
453
+ for (const audit of audits) {
454
+ for (const dependency of audit.dependencies) {
455
+ const currentDigest = currentConstraints.get(dependency.constraintId);
456
+ if (!currentDigest)
457
+ referencesValid = false;
458
+ else if (currentDigest !== dependency.constraintDigest)
459
+ changedConstraints.add(dependency.constraintId);
460
+ }
461
+ }
462
+ const safe = allAuditsPresent && snapshotsMatch && candidatesMatch && referencesValid && changedConstraints.size > 0;
463
+ if (!safe)
464
+ return { changed: true, safe: false, seeds: [] };
465
+ const seeds = new Set();
466
+ for (const audit of audits) {
467
+ if (audit.dependencies.some((dependency) => changedConstraints.has(dependency.constraintId)))
468
+ seeds.add(audit.name);
469
+ }
470
+ return { changed: true, safe: true, seeds: [...seeds] };
471
+ }
472
+ function invalidationMutationForCompletion(content, taskDir, family, artifact, timestamp, frontmatter) {
372
473
  if (!['analyze', 'plan', 'code'].includes(family))
373
474
  return { mutation: null };
374
475
  const parsed = parseInvalidationDocument(content);
@@ -379,6 +480,7 @@ function invalidationMutationForCompletion(content, taskDir, family, artifact, t
379
480
  plan: ['review-plan', 'code', 'review-code'],
380
481
  code: ['review-code']
381
482
  };
483
+ const qualificationFallback = ['analysis', 'review-analysis', 'plan', 'review-plan', 'code', 'review-code'];
382
484
  const sourceFamily = family;
383
485
  const sourceArtifactFamily = sourceFamily === 'analyze' ? 'analysis' : sourceFamily;
384
486
  let receipts = [];
@@ -402,115 +504,77 @@ function invalidationMutationForCompletion(content, taskDir, family, artifact, t
402
504
  throw new Error(`cannot inspect invalidation artifact '${name}': ${error instanceof Error ? error.message : String(error)}`);
403
505
  }
404
506
  });
405
- const taskQualification = parseTaskQualification(content);
406
- if (!taskQualification.ok)
407
- return { error: taskQualification.message };
408
507
  const nodeMap = new Map(inventory.map((node) => [`${node.family}/${node.name}`, node]));
409
- const audits = new Map();
410
- let taskInputChanged = false;
411
- let graphUsable = taskQualification.qualification.present;
412
- if (graphUsable) {
413
- for (const node of inventory) {
414
- const artifactContent = fs.readFileSync(path.join(taskDir, node.name), 'utf8');
415
- const parsedAudit = parseQualificationAudit(artifactContent);
416
- if (!parsedAudit.ok || !parsedAudit.audit.present || !parsedAudit.audit.snapshot) {
417
- graphUsable = false;
418
- break;
419
- }
420
- audits.set(`${node.family}/${node.name}`, parsedAudit.audit);
421
- if (parsedAudit.audit.snapshot.taskInputDigest !== taskQualification.qualification.taskInputDigest)
422
- taskInputChanged = true;
423
- if (parsedAudit.audit.snapshot.nonConstraintInputDigest !== taskQualification.qualification.nonConstraintInputDigest) {
424
- graphUsable = false;
425
- break;
426
- }
427
- if (parsedAudit.audit.snapshot.upstreamArtifactDigest !== upstreamArtifactDigest(parsedAudit.audit.upstreamRelations)) {
428
- graphUsable = false;
429
- break;
430
- }
431
- for (const relation of parsedAudit.audit.upstreamRelations) {
432
- const upstream = nodeMap.get(`${relation.upstreamFamily}/${relation.upstreamArtifact}`);
433
- if (!upstream || upstream.round !== relation.upstreamRound || upstream.sha256 !== relation.upstreamSha256) {
434
- graphUsable = false;
435
- break;
436
- }
437
- }
438
- if (!graphUsable)
439
- break;
508
+ let graphUsable = receipts.length > 0;
509
+ for (const receipt of receipts) {
510
+ const output = parseArtifactName(receipt.output);
511
+ const input = nodeMap.get(`${parseArtifactName(receipt.input)?.family}/${receipt.input}`);
512
+ if (!output || !nodeMap.has(`${output.family}/${receipt.output}`) || !input || input.sha256 !== receipt.inputSha256) {
513
+ graphUsable = false;
514
+ break;
440
515
  }
441
516
  }
517
+ if (graphUsable && receiptGraphHasCycle(receipts))
518
+ graphUsable = false;
519
+ if (inventory.some((node) => node.family !== 'analysis' && node.name !== artifact.name
520
+ && !receipts.some((receipt) => receipt.output === node.name)))
521
+ graphUsable = false;
442
522
  const selected = new Set();
443
523
  if (graphUsable) {
444
- const changedConstraints = new Set();
445
- for (const audit of audits.values()) {
446
- for (const dependency of audit.constraintDependencies) {
447
- const current = taskQualification.qualification.constraints.find((row) => row.constraintId === dependency.constraintId);
448
- if (!current || current.digest !== dependency.constraintDigest)
449
- changedConstraints.add(dependency.constraintId);
450
- }
451
- }
452
- const currentCandidates = new Map(taskQualification.qualification.candidates.map((candidate) => [candidate.candidateId, candidate]));
453
- const candidateSnapshotMatches = [...audits.values()].every((audit) => audit.candidateQualifications.length === currentCandidates.size && audit.candidateQualifications.every((row) => {
454
- const current = currentCandidates.get(row.candidateId);
455
- return Boolean(current && current.status === row.status && current.impact === row.impact
456
- && current.evidence === row.evidence && current.constraintIds.join(',') === row.constraintIds.join(','));
457
- }));
458
- // A task-input digest also covers candidates. A narrowed invalidation graph
459
- // is safe only when the old audit rows show that the candidate projection
460
- // stayed unchanged and the change is explained by at least one constraint.
461
- if (taskInputChanged && (changedConstraints.size === 0 || !candidateSnapshotMatches))
524
+ const currentIdentity = parseArtifactName(artifact.name);
525
+ const previousName = currentIdentity && currentIdentity.round > 1 ? artifactName(sourceArtifactFamily, currentIdentity.round - 1) : null;
526
+ const previous = previousName ? inventory.find((node) => node.family === sourceArtifactFamily && node.name === previousName) : null;
527
+ const previousFact = previousName ? parseCompletionFacts(frontmatter.completion_facts).find((fact) => fact.output === previousName && fact.event === `${sourceFamily === 'analyze' ? 'analysis' : sourceFamily}.completed`) : null;
528
+ if (!previous || !previousFact || previousFact.outputSha256 !== previous.sha256)
462
529
  graphUsable = false;
463
- for (const node of inventory) {
464
- const audit = audits.get(`${node.family}/${node.name}`);
465
- if (audit.constraintDependencies.some((dependency) => changedConstraints.has(dependency.constraintId)))
466
- selected.add(`${node.family}/${node.name}`);
467
- if (audit.upstreamRelations.some((relation) => relation.upstreamFamily === sourceArtifactFamily))
468
- selected.add(`${node.family}/${node.name}`);
530
+ if (graphUsable && previous) {
531
+ selected.add(`${previous.family}/${previous.name}`);
532
+ let changed = true;
533
+ while (changed) {
534
+ changed = false;
535
+ for (const receipt of receipts) {
536
+ const consumer = `${parseArtifactName(receipt.output).family}/${receipt.output}`;
537
+ if (selected.has(`${parseArtifactName(receipt.input).family}/${receipt.input}`) && !selected.has(consumer)) {
538
+ selected.add(consumer);
539
+ changed = true;
540
+ }
541
+ }
542
+ }
543
+ selected.delete(`${previous.family}/${previous.name}`);
469
544
  }
470
- // Relation rows point from a consumer to its actual upstream artifact. Walk
471
- // that reverse edge so review-only seeds and multi-hop consumers receive the
472
- // same derived targets as ordinary executor seeds.
545
+ }
546
+ const qualification = qualificationInvalidationSeeds(content, taskDir, inventory, typeof frontmatter.qualification_input_digest === 'string' ? frontmatter.qualification_input_digest : undefined);
547
+ let qualificationChange = qualification.changed;
548
+ if (qualificationChange && (!graphUsable || !qualification.safe))
549
+ graphUsable = false;
550
+ if (qualificationChange && graphUsable) {
551
+ for (const seed of qualification.seeds)
552
+ selected.add(`${parseArtifactName(seed)?.family}/${seed}`);
473
553
  let changed = true;
474
554
  while (changed) {
475
555
  changed = false;
476
- for (const node of inventory) {
477
- const key = `${node.family}/${node.name}`;
478
- const audit = audits.get(key);
479
- if (audit.upstreamRelations.some((relation) => selected.has(`${relation.upstreamFamily}/${relation.upstreamArtifact}`)) && !selected.has(key)) {
480
- selected.add(key);
556
+ for (const receipt of receipts) {
557
+ const consumer = `${parseArtifactName(receipt.output).family}/${receipt.output}`;
558
+ if (selected.has(`${parseArtifactName(receipt.input).family}/${receipt.input}`) && !selected.has(consumer)) {
559
+ selected.add(consumer);
481
560
  changed = true;
482
561
  }
483
562
  }
484
563
  }
485
- // A relation cycle cannot establish a safe downstream closure.
486
- const visiting = new Set();
487
- const visited = new Set();
488
- const hasCycle = (key) => {
489
- if (visiting.has(key))
490
- return true;
491
- if (visited.has(key))
492
- return false;
493
- visiting.add(key);
494
- const cycle = audits.get(key)?.upstreamRelations.some((relation) => hasCycle(`${relation.upstreamFamily}/${relation.upstreamArtifact}`)) ?? false;
495
- visiting.delete(key);
496
- visited.add(key);
497
- return cycle;
498
- };
499
- if ([...nodeMap.keys()].some(hasCycle))
500
- graphUsable = false;
501
564
  }
502
- const reasonCode = graphUsable ? 'qualification-changed' : 'upstream-replaced';
565
+ const reasonCode = qualificationChange ? 'qualification-changed' : 'upstream-replaced';
566
+ const fallbackFamilies = qualificationChange ? qualificationFallback : downstream[sourceFamily];
503
567
  const targetNodes = (graphUsable
504
568
  ? inventory.filter((node) => selected.has(`${node.family}/${node.name}`))
505
- : inventory.filter((node) => downstream[sourceFamily].includes(node.family)))
569
+ : inventory.filter((node) => fallbackFamilies.includes(node.family)))
506
570
  .filter((node) => !(node.family === FAMILY[family].artifact && node.name === artifact.name));
507
571
  const targets = targetNodes.flatMap((node) => {
508
572
  const shapes = [
509
573
  { targetKind: 'artifact', targetFamily: node.family, targetArtifact: node.name, targetRound: node.round, targetSha256: node.sha256 }
510
574
  ];
511
- const receipt = receipts.find((candidate) => candidate.output === node.name);
512
- if (receipt)
513
- shapes.push({ targetKind: 'receipt', targetFamily: node.family, targetArtifact: node.name, targetRound: node.round, targetSha256: receipt.inputSha256 });
575
+ for (const receipt of receipts.filter((candidate) => candidate.output === node.name)) {
576
+ shapes.push({ targetKind: 'receipt', targetFamily: node.family, targetArtifact: node.name, targetInput: receipt.input, targetRound: node.round, targetSha256: receipt.inputSha256 });
577
+ }
514
578
  if (node.family.startsWith('review-'))
515
579
  shapes.push({ targetKind: 'approval', targetFamily: node.family, targetArtifact: node.name, targetRound: node.round, targetSha256: node.sha256 });
516
580
  if (node.family === 'review-code')
@@ -573,100 +637,84 @@ function reworkIntentMutationForCompletion(content, taskDir, family, artifact, t
573
637
  }
574
638
  return { mutation: JSON.stringify(next) === JSON.stringify(parsed.intents) ? null : reworkIntentMutation(content, next) };
575
639
  }
576
- function buildCompletionReceipt(content, taskDir, family, artifact, completedAt, frontmatter) {
577
- const existing = (() => {
578
- try {
579
- return parseArtifactReceipts(content).rows.find((receipt) => receipt.output === artifact.name) ?? null;
580
- }
581
- catch {
582
- return null;
583
- }
584
- })();
585
- if (family === 'code') {
586
- const startedInput = typeof frontmatter.code_input_artifact === 'string'
587
- ? frontmatter.code_input_artifact : existing?.input ?? '';
588
- const startedSha256 = typeof frontmatter.code_input_sha256 === 'string'
589
- ? frontmatter.code_input_sha256 : existing?.inputSha256 ?? '';
590
- if (!startedInput || !startedSha256)
591
- return { ok: false, message: 'code.started lifecycle input context is missing' };
592
- const inputIdentity = parseArtifactName(startedInput);
593
- if (!inputIdentity || !['analysis', 'plan'].includes(inputIdentity.family))
594
- return { ok: false, message: `code input '${startedInput}' has an invalid family` };
595
- const lifecycleInput = inspectArtifactDirectory(taskDir, inputIdentity.family);
596
- if (lifecycleInput.status !== 'ready' || !lifecycleInput.latest || lifecycleInput.latest.name !== startedInput) {
597
- return { ok: false, message: `code input '${startedInput}' is not the latest ${inputIdentity.family} artifact` };
598
- }
599
- try {
600
- const inputSha256 = sha256File(lifecycleInput.latest.path);
601
- if (inputSha256 !== startedSha256)
602
- return { ok: false, message: `code input ${startedInput} changed after code.started` };
603
- return {
604
- ok: true,
605
- receipt: {
606
- event: 'code.completed', output: artifact.name, input: startedInput,
607
- inputSha256, completedAt: existing?.completedAt ?? completedAt
608
- }
609
- };
610
- }
611
- catch (error) {
612
- return { ok: false, message: `cannot hash code lifecycle input: ${error instanceof Error ? error.message : String(error)}` };
613
- }
614
- }
615
- if (!family.startsWith('review-'))
640
+ function buildCompletionReceipt(content, taskDir, family, artifact, completedAt, frontmatter, request) {
641
+ if (!['analyze', 'plan', 'code'].includes(family) && !family.startsWith('review-'))
616
642
  return null;
617
- const expectedFamily = reviewInputFamily(family);
618
- const startedInput = typeof frontmatter.review_input_artifact === 'string'
619
- ? frontmatter.review_input_artifact : existing?.input ?? '';
620
- const startedSha256 = typeof frontmatter.review_input_sha256 === 'string'
621
- ? frontmatter.review_input_sha256 : existing?.inputSha256 ?? '';
622
- if (!startedInput || !startedSha256)
623
- return { ok: false, message: 'review started input context is missing' };
624
- const current = inspectArtifactDirectory(taskDir, expectedFamily);
625
- if (current.status !== 'ready' || !current.latest || current.latest.name !== startedInput) {
626
- return { ok: false, message: `review input '${startedInput}' is not the latest ${expectedFamily} artifact` };
627
- }
628
- const event = family === 'review-analysis'
629
- ? 'review-analysis.completed'
630
- : family === 'review-plan' ? 'review-plan.completed' : 'review-code.completed';
643
+ if (family === 'review-code') {
644
+ const identity = inspectReviewIdentity(taskDir, fs.readFileSync(artifact.path, 'utf8'));
645
+ if (identity.status !== 'matched')
646
+ return { ok: false, message: `review-code identity is ${identity.status}: ${identity.message}` };
647
+ }
648
+ const event = family === 'analyze' ? 'analysis.completed'
649
+ : family === 'plan' ? 'plan.completed'
650
+ : family === 'code' ? 'code.completed'
651
+ : `${family}.completed`;
652
+ const existingReceipts = parseArtifactReceipts(content).rows.filter((row) => row.output === artifact.name);
653
+ let inputs;
631
654
  try {
632
- const stat = fs.lstatSync(current.latest.path);
633
- if (!stat.isFile() || stat.isSymbolicLink())
634
- throw new Error('review input is not a regular file');
635
- const inputSha256 = sha256File(current.latest.path);
636
- if (inputSha256 !== startedSha256)
637
- return { ok: false, message: `review input ${startedInput} changed after review.started` };
638
- return {
639
- ok: true,
640
- receipt: {
641
- event, output: artifact.name, input: startedInput,
642
- inputSha256, completedAt: existing?.completedAt ?? completedAt
655
+ const raw = frontmatter.lifecycle_input_relations;
656
+ if (typeof raw !== 'string') {
657
+ const replayFact = currentCompletionFact(request, artifact);
658
+ const recordedFact = parseCompletionFacts(frontmatter.completion_facts).find((fact) => sameCompletionFact(fact, replayFact));
659
+ if (!recordedFact) {
660
+ return { ok: false, message: 'started lifecycle input context is missing and no matching completion fact exists for replay' };
643
661
  }
644
- };
662
+ const expected = recordedFact.lifecycleInputs;
663
+ if (!Array.isArray(expected)
664
+ || expected.some((input) => !input || typeof input.name !== 'string' || !parseArtifactName(input.name)
665
+ || typeof input.sha256 !== 'string' || !/^[a-f0-9]{64}$/u.test(input.sha256))) {
666
+ return { ok: false, message: 'matching completion fact does not record a valid lifecycle input set for replay' };
667
+ }
668
+ const actual = existingReceipts.map((receipt) => ({ name: receipt.input, sha256: receipt.inputSha256 }));
669
+ if (expected.length !== actual.length || expected.some((input) => !actual.some((receipt) => receipt.name === input.name && receipt.sha256 === input.sha256))) {
670
+ return { ok: false, message: 'existing input receipts do not match the complete input receipt set recorded at completion' };
671
+ }
672
+ for (const receipt of existingReceipts) {
673
+ const identity = parseArtifactName(receipt.input);
674
+ if (!identity)
675
+ return { ok: false, message: `lifecycle input '${receipt.input}' has an invalid artifact identity` };
676
+ const inventory = inspectArtifactDirectory(taskDir, identity.family);
677
+ const current = inventory.status === 'ready' && inventory.latest?.name === receipt.input
678
+ ? inventory.artifacts.find((item) => item.name === receipt.input) : null;
679
+ if (!current || sha256File(current.path) !== receipt.inputSha256)
680
+ return { ok: false, message: `lifecycle input '${receipt.input}' changed after completion` };
681
+ }
682
+ return { ok: true, receipts: existingReceipts };
683
+ }
684
+ const parsed = JSON.parse(raw);
685
+ if (!Array.isArray(parsed))
686
+ throw new Error('started lifecycle input context is invalid');
687
+ inputs = parsed;
645
688
  }
646
689
  catch (error) {
647
- return { ok: false, message: `cannot hash review input ${startedInput}: ${error instanceof Error ? error.message : String(error)}` };
648
- }
649
- }
650
- function completionFacts(frontmatter) {
651
- if (typeof frontmatter.completion_facts !== 'string')
652
- return [];
653
- try {
654
- const facts = JSON.parse(frontmatter.completion_facts);
655
- if (!Array.isArray(facts))
656
- return [];
657
- return facts.filter((fact) => Boolean(fact && typeof fact === 'object' && !Array.isArray(fact)
658
- && typeof fact.event === 'string'
659
- && typeof fact.output === 'string'
660
- && /^[a-f0-9]{64}$/u.test(fact.outputSha256)
661
- && /^[a-f0-9]{64}$/u.test(fact.semanticDigest)
662
- && typeof fact.requestId === 'string'
663
- && typeof fact.result === 'string'));
690
+ return { ok: false, message: `invalid lifecycle input context: ${error instanceof Error ? error.message : String(error)}` };
664
691
  }
665
- catch {
666
- return [];
692
+ const receipts = [];
693
+ for (const input of inputs) {
694
+ if (typeof input?.name !== 'string' || typeof input?.sha256 !== 'string')
695
+ return { ok: false, message: 'lifecycle input context has an invalid row' };
696
+ const identity = parseArtifactName(input.name);
697
+ if (!identity)
698
+ return { ok: false, message: `lifecycle input '${input.name}' has an invalid artifact identity` };
699
+ const inventory = inspectArtifactDirectory(taskDir, identity.family);
700
+ const current = inventory.status === 'ready' && inventory.latest?.name === input.name
701
+ ? inventory.artifacts.find((item) => item.name === input.name) : null;
702
+ if (!current)
703
+ return { ok: false, message: `lifecycle input '${input.name}' is missing, invalidated, or no longer latest` };
704
+ try {
705
+ const actualSha256 = sha256File(current.path);
706
+ if (actualSha256 !== input.sha256)
707
+ return { ok: false, message: `lifecycle input '${input.name}' changed after stage start` };
708
+ const previous = parseArtifactReceipts(content).rows.find((row) => row.output === artifact.name && row.input === input.name);
709
+ receipts.push({ event: event, output: artifact.name, input: input.name, inputSha256: actualSha256, completedAt: previous?.completedAt ?? completedAt });
710
+ }
711
+ catch (error) {
712
+ return { ok: false, message: `cannot verify lifecycle input '${input.name}': ${error instanceof Error ? error.message : String(error)}` };
713
+ }
667
714
  }
715
+ return { ok: true, receipts };
668
716
  }
669
- function currentCompletionFact(request, artifact) {
717
+ function currentCompletionFact(request, artifact, lifecycleInputs) {
670
718
  const content = fs.readFileSync(artifact.path, 'utf8');
671
719
  return {
672
720
  event: request.event,
@@ -684,7 +732,8 @@ function currentCompletionFact(request, artifact) {
684
732
  verdict: request.verdict,
685
733
  fixFor: request.fixFor,
686
734
  implementationInput: request.implementationInput
687
- })
735
+ }),
736
+ ...(lifecycleInputs ? { lifecycleInputs } : {})
688
737
  };
689
738
  }
690
739
  function sameCompletionFact(left, right) {
@@ -934,27 +983,30 @@ function applyTaskEventUnlocked(request, options = {}) {
934
983
  catch (error) {
935
984
  return failed(normalized, { code: 'METADATA_CAPTURE_FAILED', message: String(error) }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath });
936
985
  }
937
- let completionReceipt = null;
986
+ let completionReceipts = [];
938
987
  let currentFact = null;
939
988
  let completionInvalidation = null;
940
989
  let completionRework = null;
941
990
  if (eventIdentity.phase === 'completed' && completedArtifact) {
942
- const receipt = buildCompletionReceipt(content, resolved.taskDir, eventIdentity.family, completedArtifact, metadata.timestamp, frontmatter);
991
+ const receipt = buildCompletionReceipt(content, resolved.taskDir, eventIdentity.family, completedArtifact, metadata.timestamp, frontmatter, normalized);
943
992
  if (receipt && !receipt.ok) {
944
993
  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 });
945
994
  }
946
- completionReceipt = receipt?.receipt ?? null;
995
+ completionReceipts = receipt?.receipts ?? [];
947
996
  try {
948
- currentFact = currentCompletionFact(normalized, completedArtifact);
997
+ currentFact = currentCompletionFact(normalized, completedArtifact, completionReceipts.map((receipt) => ({
998
+ name: receipt.input,
999
+ sha256: receipt.inputSha256
1000
+ })));
949
1001
  }
950
1002
  catch (error) {
951
1003
  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 });
952
1004
  }
953
- if (completionFacts(frontmatter).some((fact) => sameCompletionFact(fact, currentFact))) {
954
- return successNoOp(normalized, resolved.taskId, resolved.taskMdPath, currentStep, eventIdentity, completionReceipt?.completedAt ?? metadata.timestamp, frontmatter, artifactContext);
1005
+ if (parseCompletionFacts(frontmatter.completion_facts).some((fact) => sameCompletionFact(fact, currentFact))) {
1006
+ return successNoOp(normalized, resolved.taskId, resolved.taskMdPath, currentStep, eventIdentity, completionReceipts[0]?.completedAt ?? metadata.timestamp, frontmatter, artifactContext);
955
1007
  }
956
1008
  try {
957
- const invalidation = invalidationMutationForCompletion(content, resolved.taskDir, eventIdentity.family, completedArtifact, metadata.timestamp);
1009
+ const invalidation = invalidationMutationForCompletion(content, resolved.taskDir, eventIdentity.family, completedArtifact, metadata.timestamp, frontmatter);
958
1010
  if ('error' in invalidation)
959
1011
  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 });
960
1012
  completionInvalidation = invalidation.mutation;
@@ -972,8 +1024,26 @@ function applyTaskEventUnlocked(request, options = {}) {
972
1024
  const body = appendActivityEntry(section, { time: metadata.timestamp, step: logStep, agent: normalized.agent, note: eventIdentity.note });
973
1025
  const frontmatterSet = { current_step: step, assigned_to: normalized.agent };
974
1026
  if (currentFact)
975
- frontmatterSet.completion_facts = JSON.stringify(replaceCompletionFact(completionFacts(frontmatter), currentFact));
1027
+ frontmatterSet.completion_facts = JSON.stringify(replaceCompletionFact(parseCompletionFacts(frontmatter.completion_facts), currentFact));
1028
+ if (completedArtifact && ['analyze', 'plan', 'code'].includes(eventIdentity.family)) {
1029
+ const task = parseTaskQualification(content);
1030
+ if (!task.ok)
1031
+ return failed(normalized, { code: 'EVENT_ARTIFACT_CONFLICT', message: task.message }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath });
1032
+ frontmatterSet.qualification_input_digest = task.qualification.taskInputDigest;
1033
+ }
976
1034
  let frontmatterRemove;
1035
+ if (eventIdentity.phase === 'started' && artifactContext && ['analyze', 'plan', 'code', 'review-analysis', 'review-plan', 'review-code'].includes(eventIdentity.family)) {
1036
+ try {
1037
+ const inputs = artifactContext.inputs.map((input) => ({ name: input.name, sha256: sha256File(input.path) }));
1038
+ frontmatterSet.lifecycle_input_relations = JSON.stringify(inputs);
1039
+ }
1040
+ catch (error) {
1041
+ 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 });
1042
+ }
1043
+ }
1044
+ if (eventIdentity.phase === 'completed' && ['analyze', 'plan', 'code', 'review-analysis', 'review-plan', 'review-code'].includes(eventIdentity.family)) {
1045
+ frontmatterRemove = ['lifecycle_input_relations'];
1046
+ }
977
1047
  if (eventIdentity.phase === 'started' && eventIdentity.family === 'code') {
978
1048
  const planInput = artifactContext?.inputs.find((input) => input.family === 'plan' || input.family === 'analysis');
979
1049
  if (!planInput)
@@ -987,23 +1057,26 @@ function applyTaskEventUnlocked(request, options = {}) {
987
1057
  }
988
1058
  }
989
1059
  else if (eventIdentity.phase === 'completed' && eventIdentity.family === 'code') {
990
- frontmatterRemove = ['code_input_artifact', 'code_input_sha256'];
1060
+ frontmatterRemove = [...(frontmatterRemove ?? []), 'code_input_artifact', 'code_input_sha256'];
991
1061
  }
992
1062
  else if (eventIdentity.phase === 'started' && eventIdentity.family.startsWith('review-')) {
993
1063
  const expectedFamily = reviewInputFamily(eventIdentity.family);
994
1064
  const input = artifactContext?.inputs.find((candidate) => candidate.family === expectedFamily);
995
- if (!input)
1065
+ if (!input && eventIdentity.family !== 'review-code') {
996
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 });
997
- try {
998
- frontmatterSet.review_input_artifact = input.name;
999
- frontmatterSet.review_input_sha256 = sha256File(input.path);
1000
1067
  }
1001
- catch (error) {
1002
- 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 });
1068
+ if (input) {
1069
+ try {
1070
+ frontmatterSet.review_input_artifact = input.name;
1071
+ frontmatterSet.review_input_sha256 = sha256File(input.path);
1072
+ }
1073
+ catch (error) {
1074
+ 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 });
1075
+ }
1003
1076
  }
1004
1077
  }
1005
1078
  else if (eventIdentity.phase === 'completed' && eventIdentity.family.startsWith('review-')) {
1006
- frontmatterRemove = ['review_input_artifact', 'review_input_sha256'];
1079
+ frontmatterRemove = [...(frontmatterRemove ?? []), 'review_input_artifact', 'review_input_sha256'];
1007
1080
  }
1008
1081
  if (eventIdentity.phase === 'completed' && eventIdentity.family === 'review-code' && reviewContent !== null) {
1009
1082
  const reviewedCommit = approvedCleanReviewedCommit(reviewContent, normalized.verdict, resolved.repoRoot, typeof frontmatter.branch === 'string' ? frontmatter.branch : '');
@@ -1021,9 +1094,9 @@ function applyTaskEventUnlocked(request, options = {}) {
1021
1094
  const link = buildArtifactLinkSection(content, completedArtifact);
1022
1095
  mutations.push({ kind: 'section', aliases: link.aliases, heading: link.heading, body: link.body });
1023
1096
  }
1024
- if (completionReceipt) {
1097
+ if (completionReceipts.length > 0) {
1025
1098
  try {
1026
- const receiptSection = upsertArtifactReceipt(content, completionReceipt);
1099
+ const receiptSection = upsertArtifactReceipts(content, completionReceipts);
1027
1100
  mutations.push({ kind: 'section', aliases: receiptSection.aliases, heading: receiptSection.heading, body: receiptSection.body });
1028
1101
  }
1029
1102
  catch (error) {
@@ -79,7 +79,8 @@ export function readTaskFinalizationHandoff(directory, taskId, binding, handoffS
79
79
  try {
80
80
  const current = fs.lstatSync(file);
81
81
  if (current.isFile() && !current.isSymbolicLink()
82
- && current.dev === initial.dev && current.ino === initial.ino)
82
+ && current.dev === initial.dev && current.ino === initial.ino
83
+ && current.birthtimeMs === initial.birthtimeMs)
83
84
  fs.unlinkSync(file);
84
85
  }
85
86
  catch (error) {
@@ -38,8 +38,10 @@ function reconcileTaskInvalidation(taskRef, options = {}) {
38
38
  if (target.status === 'completed')
39
39
  continue;
40
40
  if (target.targetKind === 'receipt') {
41
- const receipt = receipts.find((candidate) => candidate.output === target.targetArtifact);
42
- if (!receipt || receipt.inputSha256 !== target.targetSha256) {
41
+ const receiptExists = Boolean(target.targetInput) && receipts.some((candidate) => candidate.output === target.targetArtifact
42
+ && candidate.input === target.targetInput
43
+ && candidate.inputSha256 === target.targetSha256);
44
+ if (!receiptExists) {
43
45
  return {
44
46
  status: 'failed', changed: false, taskId: resolved.taskId, processed: 0, remaining: beforePending,
45
47
  error: { code: 'INVALIDATION_TARGET_HASH_CONFLICT', message: `invalidation receipt target '${target.targetArtifact}' changed before reconcile` }