@fitlab-ai/agent-infra 0.11.2 → 0.11.4

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 (162) hide show
  1. package/README.md +17 -8
  2. package/README.zh-CN.md +17 -8
  3. package/dist/bin/internal-cli.js +7 -2
  4. package/dist/lib/agent-clients/adapter.js +3 -0
  5. package/dist/lib/agent-clients/adapters/codex-lifecycle/recovery.js +234 -0
  6. package/dist/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.js +8 -35
  7. package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +1 -34
  8. package/dist/lib/agent-clients/adapters/codex.js +10 -0
  9. package/dist/lib/internal/cli-route-inventory.js +1 -1
  10. package/dist/lib/internal/manual-validation.js +22 -27
  11. package/dist/lib/internal/sandbox-control.js +1 -43
  12. package/dist/lib/internal/task-artifact.js +1 -1
  13. package/dist/lib/internal/task-finalization.js +41 -1
  14. package/dist/lib/internal/task-ledger.js +13 -5
  15. package/dist/lib/internal/task-lifecycle.js +1 -1
  16. package/dist/lib/internal/task-operation-registry.js +7 -2
  17. package/dist/lib/platform/completion-backfill.js +63 -17
  18. package/dist/lib/platform/issue-comments.js +2 -31
  19. package/dist/lib/platform/pr-summary.js +7 -2
  20. package/dist/lib/sandbox/control/client.js +3 -44
  21. package/dist/lib/sandbox/control/executor.js +14 -79
  22. package/dist/lib/sandbox/control/protocol.js +5 -64
  23. package/dist/lib/sandbox/control/server.js +49 -343
  24. package/dist/lib/sandbox/control/state.js +1 -7
  25. package/dist/lib/sandbox/control/task-view.js +1 -2
  26. package/dist/lib/sandbox/task-cleanup.js +13 -20
  27. package/dist/lib/task/artifact-command.js +11 -4
  28. package/dist/lib/task/artifact-lifecycle.js +217 -85
  29. package/dist/lib/task/artifact-operations.js +1 -26
  30. package/dist/lib/task/artifact-receipts.js +4 -1
  31. package/dist/lib/task/artifact-schema.js +1 -0
  32. package/dist/lib/task/artifact-selection.js +25 -0
  33. package/dist/lib/task/capabilities.js +158 -39
  34. package/dist/lib/task/commands/status.js +9 -2
  35. package/dist/lib/task/control-authority.js +37 -38
  36. package/dist/lib/task/control-recovery.js +3 -42
  37. package/dist/lib/task/decision-intents.js +12 -0
  38. package/dist/lib/task/delegation-receipts.js +2 -2
  39. package/dist/lib/task/delivery.js +1 -43
  40. package/dist/lib/task/events.js +80 -64
  41. package/dist/lib/task/finalization-artifacts.js +1 -3
  42. package/dist/lib/task/finalization-state.js +1 -1
  43. package/dist/lib/task/finalization.js +319 -234
  44. package/dist/lib/task/ledger-intents.js +157 -15
  45. package/dist/lib/task/lifecycle-path.js +75 -0
  46. package/dist/lib/task/lifecycle.js +11 -1
  47. package/dist/lib/task/local-artifact-finalization.js +1 -20
  48. package/dist/lib/task/manual-validation-receipt.js +8 -3
  49. package/dist/lib/task/orchestration.js +29 -13
  50. package/dist/lib/task/review-finalization.js +3 -16
  51. package/dist/lib/task/rework-intent.js +70 -4
  52. package/dist/lib/task/verification-engine.js +20 -30
  53. package/dist/lib/task/verification.js +1 -0
  54. package/dist/lib/task/workflow-warning-intents.js +21 -6
  55. package/dist/lib/task/workspace-snapshot.js +7 -2
  56. package/dist/lifecycle-build-manifest.json +3 -3
  57. package/lib/agent-clients/adapter.ts +46 -0
  58. package/lib/agent-clients/adapters/codex-lifecycle/recovery.ts +297 -0
  59. package/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.ts +13 -33
  60. package/lib/agent-clients/adapters/codex-lifecycle/store.ts +1 -44
  61. package/lib/agent-clients/adapters/codex.ts +10 -0
  62. package/lib/internal/cli-route-inventory.ts +1 -1
  63. package/lib/internal/manual-validation.ts +22 -26
  64. package/lib/internal/sandbox-control.ts +0 -42
  65. package/lib/internal/task-artifact.ts +1 -1
  66. package/lib/internal/task-finalization.ts +40 -1
  67. package/lib/internal/task-ledger.ts +12 -5
  68. package/lib/internal/task-lifecycle.ts +2 -2
  69. package/lib/internal/task-operation-registry.ts +8 -4
  70. package/lib/platform/completion-backfill.ts +78 -16
  71. package/lib/platform/issue-comments.ts +1 -35
  72. package/lib/platform/pr-summary.ts +10 -2
  73. package/lib/sandbox/control/client.ts +5 -68
  74. package/lib/sandbox/control/executor.ts +14 -81
  75. package/lib/sandbox/control/protocol.ts +7 -83
  76. package/lib/sandbox/control/server.ts +47 -362
  77. package/lib/sandbox/control/state.ts +1 -9
  78. package/lib/sandbox/control/task-view.ts +1 -2
  79. package/lib/sandbox/task-cleanup.ts +12 -20
  80. package/lib/task/artifact-command.ts +14 -4
  81. package/lib/task/artifact-lifecycle.ts +236 -85
  82. package/lib/task/artifact-operations.ts +1 -26
  83. package/lib/task/artifact-receipts.ts +4 -1
  84. package/lib/task/artifact-schema.ts +1 -0
  85. package/lib/task/artifact-selection.ts +53 -0
  86. package/lib/task/capabilities.ts +154 -40
  87. package/lib/task/commands/status.ts +11 -2
  88. package/lib/task/control-authority.ts +41 -48
  89. package/lib/task/control-recovery.ts +3 -43
  90. package/lib/task/decision-intents.ts +9 -0
  91. package/lib/task/delegation-receipts.ts +2 -2
  92. package/lib/task/delivery.ts +1 -40
  93. package/lib/task/events.ts +82 -64
  94. package/lib/task/finalization-artifacts.ts +1 -3
  95. package/lib/task/finalization-state.ts +1 -1
  96. package/lib/task/finalization.ts +310 -216
  97. package/lib/task/ledger-intents.ts +155 -15
  98. package/lib/task/lifecycle-path.ts +98 -0
  99. package/lib/task/lifecycle.ts +15 -2
  100. package/lib/task/local-artifact-finalization.ts +2 -25
  101. package/lib/task/manual-validation-receipt.ts +8 -2
  102. package/lib/task/orchestration.ts +56 -27
  103. package/lib/task/review-finalization.ts +3 -16
  104. package/lib/task/rework-intent.ts +75 -5
  105. package/lib/task/verification-engine.ts +19 -30
  106. package/lib/task/verification.ts +2 -1
  107. package/lib/task/workflow-warning-intents.ts +18 -6
  108. package/lib/task/workspace-snapshot.ts +3 -2
  109. package/package.json +1 -1
  110. package/templates/.agents/rules/lifecycle-orchestration.en.md +19 -5
  111. package/templates/.agents/rules/lifecycle-orchestration.zh-CN.md +19 -5
  112. package/templates/.agents/rules/review-handshake.en.md +4 -4
  113. package/templates/.agents/rules/review-handshake.zh-CN.md +4 -4
  114. package/templates/.agents/skills/analyze-task/SKILL.en.md +11 -4
  115. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +11 -4
  116. package/templates/.agents/skills/code-task/SKILL.en.md +14 -8
  117. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +18 -13
  118. package/templates/.agents/skills/code-task/reference/code-rules.en.md +2 -2
  119. package/templates/.agents/skills/code-task/reference/code-rules.zh-CN.md +2 -2
  120. package/templates/.agents/skills/code-task/reference/dual-mode.en.md +4 -4
  121. package/templates/.agents/skills/code-task/reference/dual-mode.zh-CN.md +4 -4
  122. package/templates/.agents/skills/code-task/reference/report-template.en.md +1 -1
  123. package/templates/.agents/skills/code-task/reference/report-template.zh-CN.md +1 -1
  124. package/templates/.agents/skills/complete-manual-validation/SKILL.en.md +2 -2
  125. package/templates/.agents/skills/complete-manual-validation/SKILL.zh-CN.md +2 -2
  126. package/templates/.agents/skills/complete-task/SKILL.en.md +7 -8
  127. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +7 -8
  128. package/templates/.agents/skills/plan-task/SKILL.en.md +13 -10
  129. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +13 -10
  130. package/templates/.agents/skills/review-analysis/SKILL.en.md +5 -1
  131. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +5 -1
  132. package/templates/.agents/skills/review-code/SKILL.en.md +5 -1
  133. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +5 -1
  134. package/templates/.agents/skills/review-plan/SKILL.en.md +5 -1
  135. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +5 -1
  136. package/templates/.agents/skills/run-manual-validation/SKILL.en.md +2 -2
  137. package/templates/.agents/skills/run-manual-validation/SKILL.zh-CN.md +2 -2
  138. package/templates/.agents/skills/run-task/SKILL.en.md +9 -8
  139. package/templates/.agents/skills/run-task/SKILL.zh-CN.md +9 -8
  140. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +18 -2
  141. package/templates/.agents/templates/task.en.md +2 -2
  142. package/templates/.agents/templates/task.zh-CN.md +2 -2
  143. package/templates/.agents/workflows/bug-fix.en.yaml +11 -6
  144. package/templates/.agents/workflows/bug-fix.zh-CN.yaml +11 -6
  145. package/templates/.agents/workflows/feature-development.en.yaml +11 -6
  146. package/templates/.agents/workflows/feature-development.zh-CN.yaml +11 -6
  147. package/templates/.agents/workflows/refactoring.en.yaml +11 -6
  148. package/templates/.agents/workflows/refactoring.zh-CN.yaml +11 -6
  149. package/dist/lib/sandbox/control/finalization-response.js +0 -35
  150. package/dist/lib/sandbox/control/task-workflow.js +0 -74
  151. package/dist/lib/sandbox/control/workflow-executor.js +0 -142
  152. package/dist/lib/task/lifecycle-recovery.js +0 -549
  153. package/dist/lib/task/recovery-warning.js +0 -19
  154. package/dist/lib/task/workflow-command.js +0 -21
  155. package/dist/lib/task/workflow-dispatch.js +0 -213
  156. package/lib/sandbox/control/finalization-response.ts +0 -37
  157. package/lib/sandbox/control/task-workflow.ts +0 -95
  158. package/lib/sandbox/control/workflow-executor.ts +0 -156
  159. package/lib/task/lifecycle-recovery.ts +0 -691
  160. package/lib/task/recovery-warning.ts +0 -21
  161. package/lib/task/workflow-command.ts +0 -22
  162. package/lib/task/workflow-dispatch.ts +0 -214
@@ -15,8 +15,11 @@ import { resolveTaskRef } from './resolve-ref.ts';
15
15
  import { writeTask } from './write.ts';
16
16
  import type { TaskMutation, TaskOperationSummary, TaskWriteOptions } from './write.ts';
17
17
  import { allowsManualOverride } from './guard-override.ts';
18
- import { parseReworkIntentDocument, reworkIntentMutation, upsertReworkIntent } from './rework-intent.ts';
19
- import type { ReworkIntent, ReworkTarget } from './rework-intent.ts';
18
+ import { parseLegacyReworkIntentDocument, parseReworkIntentDocument, reworkIntentMutation, upsertReworkIntent } from './rework-intent.ts';
19
+ import type { ReworkClassification, ReworkIntent, ReworkTarget } from './rework-intent.ts';
20
+ import { extractSection, extractSubSection } from './sections.ts';
21
+ import { parseLifecyclePathDecision } from './lifecycle-path.ts';
22
+ import { scanVisibleMarkdown } from './markdown.ts';
20
23
 
21
24
  type ReviewSeverity = 'blocker' | 'major' | 'minor';
22
25
  type ExecutorResponse = 'accepted' | 'adjusted' | 'refuted' | 'cannot-judge';
@@ -27,7 +30,8 @@ type LedgerIntent =
27
30
  | { kind: 'finding-review'; taskRef: string; id: string; status: ReviewDisposition; evidence: string; needsImplementation?: boolean; dryRun?: boolean }
28
31
  | { kind: 'decision-next-id'; taskRef: string }
29
32
  | { kind: 'decision-upsert'; taskRef: string; id: string; stage: ReviewStage; artifact: string; needsImplementation?: boolean; dryRun?: boolean }
30
- | { kind: 'rework-intent-upsert'; taskRef: string; intentId: string; findingId: string; sourceArtifact: string; sourceSha256: string; target: ReworkTarget; dryRun?: boolean };
33
+ | { kind: 'rework-intent-upsert'; taskRef: string; intentId: string; findingId: string; sourceArtifact: string; sourceSha256: string; classification: ReworkClassification; dryRun?: boolean }
34
+ | { kind: 'rework-intent-rebuild'; taskRef: string; findingId?: string; sourceArtifact?: string; sourceSha256?: string; classification?: ReworkClassification; dryRun?: boolean };
31
35
 
32
36
  type LedgerIntentError = { code: string; message: string };
33
37
  type LedgerIntentResult = {
@@ -39,6 +43,7 @@ type LedgerIntentResult = {
39
43
  before: LedgerRow | null;
40
44
  after: LedgerRow | null;
41
45
  operations: readonly TaskOperationSummary[];
46
+ rebuild?: Readonly<{ mode: 'format-only' | 'pending-reclassification'; beforeSha256: string; afterSha256: string; converted: number; newIntentId: string | null }>;
42
47
  error: LedgerIntentError | null;
43
48
  };
44
49
 
@@ -60,12 +65,53 @@ function ledgerReadErrorCode(error: unknown): string {
60
65
  return code === 'TABLE_DUPLICATE_KEY' ? 'LEDGER_DUPLICATE_ID' : 'LEDGER_DOCUMENT_INVALID';
61
66
  }
62
67
 
63
- function maxHandshakeRounds(repoRoot: string): number {
64
- try {
65
- const parsed = JSON.parse(fs.readFileSync(path.join(repoRoot, '.agents', '.airc.json'), 'utf8')) as { review?: { maxHandshakeRounds?: unknown } };
66
- const value = parsed.review?.maxHandshakeRounds;
67
- return Number.isInteger(value) && Number(value) > 0 ? Number(value) : 3;
68
- } catch { return 3; }
68
+ const CLASSIFICATION_TARGET: Record<ReworkClassification, ReworkTarget> = {
69
+ 'scope-or-requirement': 'analysis', design: 'plan', implementation: 'code',
70
+ 'human-decision': 'pause', 'insufficient-evidence': 'pause'
71
+ };
72
+
73
+ function semanticDigest(value: string): string {
74
+ return createHash('sha256').update(value.normalize('NFKC').replace(/\s+/g, ' ').trim()).digest('hex');
75
+ }
76
+
77
+ function normalizedTaskFact(value: string): string {
78
+ return value.normalize('NFKC').replace(/\s+/g, ' ').trim();
79
+ }
80
+
81
+ function semanticFindingEvidence(reviewContent: string, heading: ReturnType<typeof scanVisibleMarkdown>['headings'][number], end: number): string {
82
+ const title = heading.text.replace(/^\d+[.、::]\s*/, '').trim();
83
+ return `${title}\n${reviewContent.slice(heading.end, end)}`;
84
+ }
85
+
86
+ function findingEvidence(reviewContent: string, evidence: string): string | null {
87
+ const anchor = evidence.split('#')[1];
88
+ if (!anchor) return null;
89
+ const markdown = scanVisibleMarkdown(reviewContent);
90
+ const explicit = markdown.anchors.find((candidate) => candidate.id === anchor);
91
+ if (explicit) {
92
+ const heading = markdown.headings.find((candidate) => candidate.start > explicit.start);
93
+ const next = heading && markdown.headings.find((candidate) => candidate.start > heading.start && candidate.level <= heading.level);
94
+ const end = next?.start ?? reviewContent.length;
95
+ return heading ? semanticFindingEvidence(reviewContent, heading, end) : reviewContent.slice(explicit.end, end);
96
+ }
97
+ const heading = markdown.headings.find((candidate) => {
98
+ const escapedAnchor = anchor.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
99
+ return candidate.text === anchor || new RegExp(`^${escapedAnchor}(?:\\s|[.、::])`).test(candidate.text);
100
+ });
101
+ if (!heading) return null;
102
+ const next = markdown.headings.find((candidate) => candidate.start > heading.start && candidate.level <= heading.level);
103
+ return semanticFindingEvidence(reviewContent, heading, next?.start ?? reviewContent.length);
104
+ }
105
+
106
+ function taskFactDigest(taskDir: string, content: string): string {
107
+ const analysisNames = fs.readdirSync(taskDir).filter((name) => /^analysis(?:-r\d+)?\.md$/.test(name));
108
+ const latest = analysisNames.map((name) => ({ name, round: parseArtifactName(name)?.round ?? 0 })).sort((a, b) => b.round - a.round)[0];
109
+ const flow = latest ? parseLifecyclePathDecision(fs.readFileSync(path.join(taskDir, latest.name), 'utf8')) : null;
110
+ return semanticDigest(JSON.stringify({
111
+ taskInput: normalizedTaskFact(extractSection(content, ['任务输入', 'Task Input'])),
112
+ requirements: normalizedTaskFact(extractSection(content, ['需求', 'Requirements'])),
113
+ flow: flow?.status === 'valid' ? flow.decision.semanticDigest : flow?.status ?? 'missing'
114
+ }));
69
115
  }
70
116
 
71
117
  function nextFindingId(rows: readonly LedgerRow[], stage: ReviewStage): string {
@@ -123,11 +169,14 @@ function applyLedgerIntent(intent: LedgerIntent, options: TaskWriteOptions = {})
123
169
  }
124
170
 
125
171
  if (intent.kind === 'rework-intent-upsert') {
126
- if (!/^RI-[1-9]\d*$/.test(intent.intentId) || !/^(AN|PL|CD)-[1-9]\d*$/.test(intent.findingId) || !['analysis', 'plan', 'code'].includes(intent.target) || !/^[a-f0-9]{64}$/.test(intent.sourceSha256)) {
127
- return failed(intent, 'LEDGER_PAYLOAD_INVALID', 'rework intent identity, target, or source hash is invalid', resolved.taskId, intent.intentId);
172
+ if (!/^RI-[1-9]\d*$/.test(intent.intentId) || !/^(AN|PL|CD)-[1-9]\d*$/.test(intent.findingId) || !(intent.classification in CLASSIFICATION_TARGET) || !/^[a-f0-9]{64}$/.test(intent.sourceSha256)) {
173
+ return failed(intent, 'LEDGER_PAYLOAD_INVALID', 'rework intent identity, classification, or source hash is invalid', resolved.taskId, intent.intentId);
128
174
  }
129
175
  const finding = rows.find((row) => row.id === intent.findingId);
130
176
  if (!finding || finding.severity === 'decision') return failed(intent, 'LEDGER_NOT_FOUND', `finding '${intent.findingId}' was not found`, resolved.taskId, intent.findingId);
177
+ if (intent.classification === 'human-decision' && finding.status !== 'needs-human-decision') {
178
+ return failed(intent, 'LEDGER_TRANSITION_INVALID', 'human-decision classification requires a needs-human-decision finding', resolved.taskId, intent.findingId);
179
+ }
131
180
  const sourceFromEvidence = finding.evidence.split('#')[0];
132
181
  if (sourceFromEvidence !== intent.sourceArtifact || parseArtifactName(intent.sourceArtifact)?.family !== `review-${finding.stage}`) {
133
182
  return failed(intent, 'LEDGER_IDENTITY_CONFLICT', 'rework intent source artifact does not match finding evidence', resolved.taskId, intent.intentId);
@@ -137,12 +186,34 @@ function applyLedgerIntent(intent: LedgerIntent, options: TaskWriteOptions = {})
137
186
  if (actualHash !== intent.sourceSha256) return failed(intent, 'LEDGER_IDENTITY_CONFLICT', 'rework intent source artifact hash does not match', resolved.taskId, intent.intentId);
138
187
  const parsed = parseReworkIntentDocument(content);
139
188
  if (!parsed.ok) return failed(intent, parsed.code, parsed.message, resolved.taskId, intent.intentId);
140
- const nextIntent: ReworkIntent = {
189
+ const reviewContent = fs.readFileSync(path.join(resolved.taskDir, intent.sourceArtifact), 'utf8');
190
+ const evidenceBlock = findingEvidence(reviewContent, finding.evidence);
191
+ if (!evidenceBlock) return failed(intent, 'LEDGER_EVIDENCE_INVALID', `finding evidence anchor '${finding.evidence}' was not found`, resolved.taskId, intent.intentId);
192
+ const evidenceDigest = semanticDigest(evidenceBlock);
193
+ const factsDigest = taskFactDigest(resolved.taskDir, content);
194
+ const requestedIntent: ReworkIntent = {
141
195
  intentId: intent.intentId, findingId: intent.findingId, sourceArtifact: intent.sourceArtifact,
142
- sourceSha256: intent.sourceSha256, target: intent.target, status: 'pending',
196
+ sourceSha256: intent.sourceSha256, target: CLASSIFICATION_TARGET[intent.classification], classification: intent.classification,
197
+ evidenceDigest, taskFactDigest: factsDigest, status: 'pending',
143
198
  declaredAt: new Date().toISOString(), consumedAt: ''
144
199
  };
145
- const next = upsertReworkIntent(parsed.intents, nextIntent);
200
+ if (parsed.intents.some((candidate) => candidate.intentId === intent.intentId)) {
201
+ const replay = upsertReworkIntent(parsed.intents, requestedIntent);
202
+ if (!replay.changed) {
203
+ return { status: 'no-op', changed: false, intent: intent.kind, taskId: resolved.taskId, entityId: intent.intentId, before: null, after: null, operations: [], error: null };
204
+ }
205
+ }
206
+ const same = parsed.intents.find((candidate) => candidate.findingId === intent.findingId
207
+ && candidate.evidenceDigest === evidenceDigest && candidate.taskFactDigest === factsDigest
208
+ && candidate.classification === intent.classification && candidate.target === CLASSIFICATION_TARGET[intent.classification]);
209
+ const classification: ReworkClassification = same ? 'insufficient-evidence' : intent.classification;
210
+ const nextIntent: ReworkIntent = {
211
+ ...requestedIntent, target: CLASSIFICATION_TARGET[classification], classification
212
+ };
213
+ const now = nextIntent.declaredAt;
214
+ const superseded = parsed.intents.map((candidate) => candidate.status === 'pending'
215
+ ? { ...candidate, status: 'superseded' as const, consumedAt: now } : candidate);
216
+ const next = upsertReworkIntent(superseded, nextIntent);
146
217
  if (!next.changed) return { status: 'no-op', changed: false, intent: intent.kind, taskId: resolved.taskId, entityId: intent.intentId, before: null, after: null, operations: [], error: null };
147
218
  const result = writeTask({ taskRef: intent.taskRef, expectedState: 'active', dryRun: intent.dryRun, mutations: [reworkIntentMutation(content, next.intents)] }, { ...options, taskLocation: { repoRoot: resolved.repoRoot, taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, state: resolved.state } });
148
219
  if (result.status === 'failed') return failed(intent, result.error.code, result.error.message, result.taskId, intent.intentId);
@@ -152,6 +223,75 @@ function applyLedgerIntent(intent: LedgerIntent, options: TaskWriteOptions = {})
152
223
  }
153
224
  }
154
225
 
226
+ if (intent.kind === 'rework-intent-rebuild') {
227
+ const beforeSha256 = createHash('sha256').update(content).digest('hex');
228
+ const supplied = [intent.findingId, intent.sourceArtifact, intent.sourceSha256, intent.classification].filter((value) => value !== undefined).length;
229
+ if (supplied !== 0 && supplied !== 4) return failed(intent, 'LEDGER_PAYLOAD_INVALID', 'rebuild binding options must be provided together', resolved.taskId);
230
+ const current = parseReworkIntentDocument(content);
231
+ if (current.ok && current.present) {
232
+ if (supplied !== 0) return failed(intent, 'LEDGER_PAYLOAD_INVALID', 'current rework intent format does not accept rebuild binding options', resolved.taskId);
233
+ return {
234
+ status: 'no-op', changed: false, intent: intent.kind, taskId: resolved.taskId, entityId: null,
235
+ before: null, after: null, operations: [], error: null,
236
+ rebuild: { mode: 'format-only', beforeSha256, afterSha256: beforeSha256, converted: current.intents.length, newIntentId: null }
237
+ };
238
+ }
239
+ const legacy = parseLegacyReworkIntentDocument(content);
240
+ if (!legacy.ok) return failed(intent, legacy.code, legacy.message, resolved.taskId);
241
+ const pending = legacy.intents.filter((row) => row.status === 'pending');
242
+ if (pending.length === 0 && supplied !== 0) return failed(intent, 'LEDGER_PAYLOAD_INVALID', 'format-only rebuild does not accept finding or classification options', resolved.taskId);
243
+ if (pending.length > 0 && supplied !== 4) return failed(intent, 'LEDGER_PAYLOAD_INVALID', 'legacy pending intents require finding, review, hash, and classification', resolved.taskId);
244
+ const now = new Date().toISOString();
245
+ let next = legacy.intents.map((row) => row.status === 'pending' ? { ...row, status: 'superseded' as const, consumedAt: now } : row);
246
+ let entityId: string | null = null;
247
+ if (pending.length > 0) {
248
+ const finding = rows.find((row) => row.id === intent.findingId);
249
+ if (!finding || finding.severity === 'decision' || finding.evidence.split('#')[0] !== intent.sourceArtifact) return failed(intent, 'LEDGER_IDENTITY_CONFLICT', 'rebuild binding does not match a review finding', resolved.taskId);
250
+ if (intent.classification === 'human-decision' && finding.status !== 'needs-human-decision') {
251
+ return failed(intent, 'LEDGER_TRANSITION_INVALID', 'human-decision classification requires a needs-human-decision finding', resolved.taskId);
252
+ }
253
+ const sourceIdentity = parseArtifactName(intent.sourceArtifact!);
254
+ const expectedFamily = `review-${finding.stage}`;
255
+ const latestSource = fs.readdirSync(resolved.taskDir)
256
+ .map((name) => parseArtifactName(name))
257
+ .filter((identity) => identity?.family === expectedFamily)
258
+ .sort((left, right) => right!.round - left!.round || left!.name.localeCompare(right!.name))[0];
259
+ if (!sourceIdentity || sourceIdentity.family !== expectedFamily || latestSource?.name !== sourceIdentity.name) {
260
+ return failed(intent, 'LEDGER_IDENTITY_CONFLICT', 'rebuild source artifact is not the latest review for the finding stage', resolved.taskId);
261
+ }
262
+ const sourcePath = path.join(resolved.taskDir, intent.sourceArtifact!);
263
+ const actualHash = createHash('sha256').update(fs.readFileSync(sourcePath)).digest('hex');
264
+ if (actualHash !== intent.sourceSha256) return failed(intent, 'LEDGER_IDENTITY_CONFLICT', 'rebuild source artifact hash does not match', resolved.taskId);
265
+ const max = next.reduce((value, row) => Math.max(value, Number(row.intentId.slice(3))), 0);
266
+ entityId = `RI-${max + 1}`;
267
+ const reviewContent = fs.readFileSync(sourcePath, 'utf8');
268
+ const classification = intent.classification!;
269
+ if (!(classification in CLASSIFICATION_TARGET)) return failed(intent, 'LEDGER_PAYLOAD_INVALID', 'rebuild classification is invalid', resolved.taskId);
270
+ const evidenceBlock = findingEvidence(reviewContent, finding.evidence);
271
+ if (!evidenceBlock) return failed(intent, 'LEDGER_EVIDENCE_INVALID', `finding evidence anchor '${finding.evidence}' was not found`, resolved.taskId);
272
+ next = [...next, {
273
+ intentId: entityId, findingId: intent.findingId!, sourceArtifact: intent.sourceArtifact!, sourceSha256: intent.sourceSha256!,
274
+ target: CLASSIFICATION_TARGET[classification], classification,
275
+ evidenceDigest: semanticDigest(evidenceBlock),
276
+ taskFactDigest: taskFactDigest(resolved.taskDir, content), status: 'pending', declaredAt: now, consumedAt: ''
277
+ }];
278
+ }
279
+ const result = writeTask({ taskRef: intent.taskRef, expectedState: 'active', dryRun: intent.dryRun, mutations: [reworkIntentMutation(content, next)] }, { ...options, taskLocation: { repoRoot: resolved.repoRoot, taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, state: resolved.state } });
280
+ if (result.status === 'failed') return failed(intent, result.error.code, result.error.message, result.taskId, entityId);
281
+ const afterContent = result.status === 'applied' || result.status === 'no-op' ? fs.readFileSync(resolved.taskMdPath, 'utf8') : content;
282
+ return {
283
+ status: result.status, changed: result.changed, intent: intent.kind, taskId: result.taskId, entityId,
284
+ before: null, after: null, operations: result.operations, error: null,
285
+ rebuild: {
286
+ mode: pending.length > 0 ? 'pending-reclassification' : 'format-only',
287
+ beforeSha256,
288
+ afterSha256: createHash('sha256').update(afterContent).digest('hex'),
289
+ converted: legacy.intents.length,
290
+ newIntentId: entityId
291
+ }
292
+ };
293
+ }
294
+
155
295
  let before: LedgerRow | null = null;
156
296
  let after: LedgerRow;
157
297
  if (intent.kind === 'finding-upsert') {
@@ -213,7 +353,7 @@ function applyLedgerIntent(intent: LedgerIntent, options: TaskWriteOptions = {})
213
353
  ? new Set(['confirmed', 'open', 'needs-human-decision'])
214
354
  : before.status === 'cannot-judge' ? new Set(['open', 'needs-human-decision']) : new Set<string>();
215
355
  const sameRoundMinorClose = before.status === 'open' && before.severity === 'minor' && intent.status === 'closed';
216
- if (((!sameRoundMinorClose && !allowed.has(intent.status)) || (intent.status === 'open' && Number(before.round) >= maxHandshakeRounds(resolved.repoRoot)))
356
+ if ((!sameRoundMinorClose && !allowed.has(intent.status))
217
357
  && !allowsManualOverride(options.manualOverride, 'ledger-intent', 'LEDGER_TRANSITION_INVALID')) {
218
358
  return failed(intent, 'LEDGER_TRANSITION_INVALID', `finding '${intent.id}' cannot transition from ${before.status} to ${intent.status}`, resolved.taskId, intent.id);
219
359
  }
@@ -0,0 +1,98 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ import { extractSection } from './sections.ts';
4
+
5
+ const PATH_STAGES = {
6
+ streamlined: ['analysis', 'code', 'review-code'],
7
+ standard: ['analysis', 'plan', 'code', 'review-code'],
8
+ full: ['analysis', 'review-analysis', 'plan', 'review-plan', 'code', 'review-code']
9
+ } as const;
10
+
11
+ type LifecyclePath = keyof typeof PATH_STAGES;
12
+ type LifecycleStage = (typeof PATH_STAGES)[LifecyclePath][number];
13
+ type LifecyclePathDecision = Readonly<{
14
+ path: LifecyclePath;
15
+ stages: readonly LifecycleStage[];
16
+ basis: string;
17
+ unmetHigherConditions: string;
18
+ upgradeTriggers: string;
19
+ semanticDigest: string;
20
+ sourceArtifact: string;
21
+ sourceSha256: string;
22
+ }>;
23
+ type LifecyclePathState =
24
+ | Readonly<{ status: 'missing'; decision: null; message: string }>
25
+ | Readonly<{ status: 'invalid'; decision: null; message: string }>
26
+ | Readonly<{ status: 'valid'; decision: LifecyclePathDecision; message: null }>;
27
+
28
+ const FIELD_ALIASES = {
29
+ path: ['本任务路径', 'Path'],
30
+ basis: ['判定依据', 'Basis'],
31
+ unmetHigherConditions: ['未满足的更高路径条件', '未满足的较低路径条件', 'Unmet Higher-path Conditions'],
32
+ upgradeTriggers: ['升级触发条件', 'Upgrade Triggers']
33
+ } as const;
34
+
35
+ function normalizeText(value: string): string {
36
+ return value.normalize('NFKC').replace(/\s+/g, ' ').trim().replace(/[。.]$/, '');
37
+ }
38
+
39
+ function digest(value: unknown): string {
40
+ return createHash('sha256').update(JSON.stringify(value)).digest('hex');
41
+ }
42
+
43
+ function pathToken(value: string): LifecyclePath | null {
44
+ const normalized = normalizeText(value).toLowerCase().replace(/[`*_]/g, '');
45
+ if (/^(?:streamlined|精简路径|精简)$/.test(normalized)) return 'streamlined';
46
+ if (/^(?:standard|标准路径|标准)$/.test(normalized)) return 'standard';
47
+ if (/^(?:full|完整路径|完整)$/.test(normalized)) return 'full';
48
+ return null;
49
+ }
50
+
51
+ function fieldValues(body: string, aliases: readonly string[]): string[] {
52
+ const wanted = new Set(aliases);
53
+ const values: string[] = [];
54
+ for (const line of body.split(/\r?\n/)) {
55
+ const match = /^\s*[-*]\s+\*\*([^*]+)\*\*[::]\s*(.*?)\s*$/.exec(line);
56
+ if (match && wanted.has(match[1]!.trim())) values.push(match[2]!.trim());
57
+ }
58
+ return values;
59
+ }
60
+
61
+ function parseLifecyclePathDecision(
62
+ analysisContent: string,
63
+ sourceArtifact = '',
64
+ sourceSha256 = ''
65
+ ): LifecyclePathState {
66
+ const body = extractSection(analysisContent, ['流程裁定', 'Flow Decision']);
67
+ if (!body) return { status: 'missing', decision: null, message: 'analysis flow decision section is missing' };
68
+ const fields = Object.fromEntries(Object.entries(FIELD_ALIASES).map(([key, aliases]) => [key, fieldValues(body, aliases)])) as Record<keyof typeof FIELD_ALIASES, string[]>;
69
+ const invalid = Object.entries(fields).find(([, values]) => values.length !== 1);
70
+ if (invalid) return { status: 'invalid', decision: null, message: `flow decision field '${invalid[0]}' must appear exactly once` };
71
+ const path = pathToken(fields.path[0]!);
72
+ if (!path) return { status: 'invalid', decision: null, message: `unknown lifecycle path '${fields.path[0]}'` };
73
+ const basis = normalizeText(fields.basis[0]!);
74
+ const unmetHigherConditions = normalizeText(fields.unmetHigherConditions[0]!);
75
+ const upgradeTriggers = normalizeText(fields.upgradeTriggers[0]!);
76
+ if (!basis || !unmetHigherConditions || !upgradeTriggers) return { status: 'invalid', decision: null, message: 'flow decision fields must be non-empty' };
77
+ return {
78
+ status: 'valid',
79
+ message: null,
80
+ decision: {
81
+ path,
82
+ stages: PATH_STAGES[path],
83
+ basis,
84
+ unmetHigherConditions,
85
+ upgradeTriggers,
86
+ semanticDigest: digest({ path, basis, unmetHigherConditions, upgradeTriggers }),
87
+ sourceArtifact,
88
+ sourceSha256
89
+ }
90
+ };
91
+ }
92
+
93
+ function pathIncludes(state: LifecyclePathState | undefined, stage: LifecycleStage): boolean {
94
+ return state?.status === 'valid' && state.decision.stages.includes(stage);
95
+ }
96
+
97
+ export { PATH_STAGES, parseLifecyclePathDecision, pathIncludes };
98
+ export type { LifecyclePath, LifecyclePathDecision, LifecyclePathState, LifecycleStage };
@@ -58,7 +58,7 @@ type TaskLifecycleRequest =
58
58
  | { taskRef: string; intent: 'close-codescan'; agent: string; alertNumber: number; reason: string; dryRun?: boolean }
59
59
  | { taskRef: string; intent: 'close-dependabot'; agent: string; alertNumber: number; reason: string; dryRun?: boolean }
60
60
  | { taskRef: string; intent: 'restore'; agent: string; stagingDir: string; issueNumber: number; dryRun?: boolean }
61
- | { taskRef: string; intent: 'recover-started'; agent: string; stage: 'analysis' | 'review-analysis' | 'plan' | 'review-plan' | 'code' | 'review-code'; round: number; artifact: string; reason: string; dryRun?: boolean };
61
+ | { taskRef: string; intent: 'recover-started'; agent: string; auto: true; dryRun?: boolean };
62
62
 
63
63
  type HotState = 'active' | 'blocked' | 'completed';
64
64
  type SourceState = HotState | 'staging';
@@ -108,6 +108,9 @@ type TaskLifecycleOptions = {
108
108
  taskFileSystem?: Partial<TaskFileSystem>;
109
109
  directoryRenameSync?: (source: string, target: string) => void;
110
110
  manualOverride?: Readonly<{ failureId: string; operator: string; reason: string }>;
111
+ /** Write the terminal task document and durable journal, but leave the move
112
+ * and short-id mutation for a separately authorized commit. */
113
+ prepareOnly?: boolean;
111
114
  };
112
115
 
113
116
  function allowsManualOverride(options: TaskLifecycleOptions, code: string): boolean {
@@ -209,7 +212,7 @@ function actionAndNote(request: TaskLifecycleRequest, restoredFiles = 0): { acti
209
212
  if (request.intent === 'complete') return { action: 'Complete Task', note: 'Task moved to completed/' };
210
213
  if (request.intent === 'close-codescan') return { action: 'Close Codescan', note: `Code Scanning alert #${request.alertNumber} dismissed: ${request.reason}` };
211
214
  if (request.intent === 'close-dependabot') return { action: 'Close Dependabot', note: `Dependabot alert #${request.alertNumber} dismissed: ${request.reason}` };
212
- if (request.intent === 'recover-started') return { action: 'Recover Started', note: request.reason };
215
+ if (request.intent === 'recover-started') return { action: 'Recover Started', note: 'automatic recovery' };
213
216
  return { action: 'Restore Task', note: `Restored ${restoredFiles} files from Issue #${request.issueNumber}` };
214
217
  }
215
218
 
@@ -574,6 +577,16 @@ function applyTaskLifecycle(requestInput: TaskLifecycleRequest, options: TaskLif
574
577
  try { writeJournal(journalPath, journal, io); }
575
578
  catch (error) { return failed(request, { code: 'LIFECYCLE_JOURNAL_WRITE_FAILED', message: String(error) }, { taskId, sourceState, targetState: spec.target, sourcePath, targetPath, journalPath, task: { operations: taskOperations }, changed: true, completedSteps: [...completed], timestamp: journal.metadata.timestamp, agentInfraVersion: journal.metadata.agentInfraVersion }); }
576
579
  }
580
+ if (options.prepareOnly && !completed.has('directory-moved')) {
581
+ return {
582
+ ...failed(request, { code: '', message: '' }), status: 'applied', changed: true, error: null,
583
+ taskId, sourceState, targetState: spec.target, sourcePath, targetPath,
584
+ task: { operations: taskOperations }, directory: { effect: 'unchanged', changed: false },
585
+ shortId: { effect: 'unchanged', shortId: null, changed: false },
586
+ timestamp: journal.metadata.timestamp, agentInfraVersion: journal.metadata.agentInfraVersion,
587
+ journalPath, completedSteps: [...completed], pendingSteps: STEPS.filter((step) => !completed.has(step))
588
+ };
589
+ }
577
590
  if (fs.existsSync(targetPath) && !fs.existsSync(sourcePath)) completed.add('directory-moved');
578
591
  if (!completed.has('directory-moved')) {
579
592
  fs.mkdirSync(path.dirname(targetPath), { recursive: true });
@@ -4,11 +4,7 @@ import path from 'node:path';
4
4
  import { inspectDecisionDetailDuplicates } from './decision-details.ts';
5
5
  import { scanVisibleMarkdown } from './markdown.ts';
6
6
  import { parseArtifactName } from './artifact-name.ts';
7
- import {
8
- hasOpenArtifactRound,
9
- } from './artifact-lifecycle.ts';
10
7
  import { resolveTaskRef } from './resolve-ref.ts';
11
- import { expectedQualificationRelations, validateQualificationAudit } from './qualification-audit.ts';
12
8
  import { canonicalSemanticDigest, inspectArtifactPatterns, inspectArtifactStructure, sha256Content } from './artifact-operations.ts';
13
9
  import { getArtifactSchema } from './artifact-schema.ts';
14
10
 
@@ -28,8 +24,7 @@ type LocalArtifactDiagnosticCode =
28
24
  | 'LOCAL_DECISION_DETAIL_DUPLICATE'
29
25
  | 'LOCAL_REQUIRED_PATTERN_MISSING'
30
26
  | 'LOCAL_STRUCTURAL_INVALID'
31
- | 'LOCAL_SECTION_HEADING_TRAILING_PUNCTUATION'
32
- | 'LOCAL_QUALIFICATION_AUDIT_INVALID';
27
+ | 'LOCAL_SECTION_HEADING_TRAILING_PUNCTUATION';
33
28
 
34
29
  type LocalArtifactDiagnostic = {
35
30
  code: LocalArtifactDiagnosticCode;
@@ -40,8 +35,6 @@ type LocalArtifactDiagnostic = {
40
35
  type LocalArtifactValidationOptions = {
41
36
  family: LocalArtifactFamily;
42
37
  requiredSections?: readonly string[];
43
- taskContent?: string;
44
- artifact?: string;
45
38
  };
46
39
 
47
40
  type LocalArtifactValidationResult = {
@@ -128,20 +121,6 @@ function validateLocalArtifact(
128
121
  const decisionDetails = inspectDecisionDetailDuplicates(content);
129
122
  if (!decisionDetails.ok) diagnostics.push({ code: 'LOCAL_DECISION_DETAIL_DUPLICATE', message: decisionDetails.message, line: null });
130
123
 
131
- if (options.taskContent !== undefined) {
132
- const identity = options.artifact ? parseArtifactName(options.artifact) : null;
133
- const expected = identity?.family === options.family && hasOpenArtifactRound(options.taskContent, options.family, identity.round)
134
- ? expectedQualificationRelations(options.taskContent, options.family)
135
- : undefined;
136
- if (expected && !expected.ok) diagnostics.push({ code: 'LOCAL_QUALIFICATION_AUDIT_INVALID', message: `${expected.code}: ${expected.message}`, line: null });
137
- const qualification = validateQualificationAudit(options.taskContent, content, {
138
- family: options.family,
139
- artifact: options.artifact,
140
- expectedUpstreamRelations: expected?.ok ? expected.relations : undefined
141
- });
142
- if (!qualification.ok) diagnostics.push({ code: 'LOCAL_QUALIFICATION_AUDIT_INVALID', message: `${qualification.code}: ${qualification.message}`, line: null });
143
- }
144
-
145
124
  return {
146
125
  ok: diagnostics.length === 0,
147
126
  family: options.family,
@@ -182,10 +161,8 @@ function prepareLocalArtifact(
182
161
  if (!resolved.ok) return failed(resolved.code, resolved.message);
183
162
  const parsed = parseArtifactName(request.artifact);
184
163
  if (!parsed || parsed.family !== request.family) return failed('ARTIFACT_IDENTITY_INVALID', `artifact '${request.artifact}' does not match ${request.family}`);
185
- let taskContent: string;
186
164
  let content: string;
187
165
  try {
188
- taskContent = fs.readFileSync(resolved.taskMdPath, 'utf8');
189
166
  const artifactPath = path.join(resolved.taskDir, request.artifact);
190
167
  if (!fs.statSync(artifactPath).isFile()) return failed('ARTIFACT_NOT_REGULAR', `artifact '${request.artifact}' is not a regular file`);
191
168
  content = candidate ?? fs.readFileSync(artifactPath, 'utf8');
@@ -195,7 +172,7 @@ function prepareLocalArtifact(
195
172
  return failed('ARTIFACT_IDENTITY_INVALID', 'artifact finalization requires an active task');
196
173
  }
197
174
  const validation = validateLocalArtifact(content, {
198
- family: request.family, requiredSections: request.requiredSections, taskContent, artifact: request.artifact
175
+ family: request.family, requiredSections: request.requiredSections
199
176
  });
200
177
  const artifactSha256 = sha256Content(content);
201
178
  const result = failedFinalization(request, {
@@ -14,6 +14,7 @@ import {
14
14
 
15
15
  const MANUAL_VALIDATION_RECEIPT_SCHEMA = 'agent-infra/manual-validation-receipt';
16
16
  const MANUAL_VALIDATION_RECEIPT_VERSION = 1;
17
+ const MANUAL_VALIDATION_RECEIPT_PLACEHOLDER = '[[manual-validation-receipt]]';
17
18
 
18
19
  type ManualValidationReceipt = Readonly<{
19
20
  schema: typeof MANUAL_VALIDATION_RECEIPT_SCHEMA;
@@ -73,13 +74,17 @@ function manualValidationFinalSummaryDigest(body: string): string {
73
74
  const projection = heading
74
75
  ? body.slice(heading.index, heading.index + heading[0].length + (nextSection?.index ?? afterHeading.length)).replace(/\s+$/u, '')
75
76
  : body;
76
- const preimage = projection.replace(/receipt=(?:[a-f0-9]{64}|<receipt>)/gu, 'receipt=<receipt>');
77
+ const preimage = projection
78
+ .replace(/\n*<!--\s*canonical-pr-change-report\s*-->\s*/gu, '')
79
+ .replace(/<!--\s*manual-validation-receipt:\s*[\s\S]*?-->/gu, MANUAL_VALIDATION_RECEIPT_PLACEHOLDER)
80
+ .replace(/receipt=(?:[a-f0-9]{64}|<receipt>)/gu, 'receipt=<receipt>');
77
81
  return createHash('sha256').update(preimage, 'utf8').digest('hex');
78
82
  }
79
83
 
80
84
  function manualValidationFinalSummaryProjectionMatches(body: string, receipt: ManualValidationReceipt): boolean {
81
85
  const identity = `transaction=${receipt.transactionId}; receipt=${receipt.receiptDigest}; evidence=${receipt.evidenceDigest}; head=${receipt.prHeadSha}`;
82
- return body.includes(identity) && manualValidationFinalSummaryDigest(body) === receipt.finalSummaryDigest;
86
+ return (body.includes(identity) || body.includes(MANUAL_VALIDATION_RECEIPT_PLACEHOLDER))
87
+ && manualValidationFinalSummaryDigest(body) === receipt.finalSummaryDigest;
83
88
  }
84
89
 
85
90
  function validateManualValidationReceipt(value: unknown, expected?: ManualValidationReceiptIdentity): ManualValidationReceiptResult {
@@ -137,6 +142,7 @@ function readManualValidationReceipt(taskDir: string, expected?: ManualValidatio
137
142
  export {
138
143
  MANUAL_VALIDATION_RECEIPT_SCHEMA,
139
144
  MANUAL_VALIDATION_RECEIPT_VERSION,
145
+ MANUAL_VALIDATION_RECEIPT_PLACEHOLDER,
140
146
  createManualValidationReceipt,
141
147
  manualValidationFinalSummaryDigest,
142
148
  manualValidationFinalSummaryProjectionMatches,
@@ -59,6 +59,7 @@ import { hasOpenLifecycleExecution } from './activity-log.ts';
59
59
  import { reconcileTaskInvalidation } from './invalidation-command.ts';
60
60
  import type { LifecycleAction, LifecycleFacts } from './capabilities.ts';
61
61
  import { normalizeAgentToken } from '../agent-clients/tokens.ts';
62
+ import { resolveArtifactContext } from './artifact-lifecycle.ts';
62
63
 
63
64
  type OrchestrationStatus = 'running' | 'paused' | 'completed';
64
65
  type ModelPolicySource = Readonly<{
@@ -642,18 +643,14 @@ function validateSavedCurrentDiffBase(
642
643
  return null;
643
644
  }
644
645
 
645
- function routeFromFacts(facts: LifecycleFacts): Omit<OrchestrationNext, 'requestedModel' | 'requestedReasoningEffort'> | { completion: true } | null {
646
+ function routeFromFacts(facts: LifecycleFacts): Omit<OrchestrationNext, 'requestedModel' | 'requestedReasoningEffort' | 'round' | 'artifact'> | { completion: true } | null {
646
647
  const recommendation = recommendNext(facts);
647
- if (!recommendation.action) return { completion: true };
648
+ if (!recommendation.action) return recommendation.reasonCode === 'LIFECYCLE_REVIEWED' ? { completion: true } : null;
648
649
  const action = recommendation.action;
649
650
  if (action === 'manual-validation' || action === 'validation-run') return null;
650
- const family = action === 'analysis' ? 'analysis' : action;
651
- const round = maxArtifactRound([
652
- ...(facts.artifacts[family] ?? []), ...(facts.staleArtifacts?.[family] ?? [])
653
- ], family) + 1;
654
651
  const command = action === 'analysis' ? 'analyze-task' : action === 'plan' ? 'plan-task' : action === 'code' ? 'code-task' : action;
655
652
  const role = action.startsWith('review-') ? 'reviewer' : 'executor';
656
- return { action: command, role, stage: action, round, artifact: artifactName(family, round) };
653
+ return { action: command, role, stage: action };
657
654
  }
658
655
 
659
656
  function routeOrchestration(taskRef: string, options: OrchestrationOptions = {}): OrchestrationResult {
@@ -786,14 +783,35 @@ function routeOrchestration(taskRef: string, options: OrchestrationOptions = {})
786
783
  return { status: 'completed', changed: true, taskId: resolved.taskId, run: completed, next: null, error: null };
787
784
  }
788
785
  const action = routed.stage as LifecycleAction;
786
+ const family = action === 'analysis' ? 'analysis' : action;
787
+ const artifactContext = resolveArtifactContext(taskRef, family, {
788
+ repoRoot: resolved.repoRoot
789
+ });
790
+ if (artifactContext.status !== 'ready' || !artifactContext.selection) {
791
+ return failed(
792
+ artifactContext.error?.code ?? 'ORCHESTRATION_ARTIFACT_SELECTION_FAILED',
793
+ artifactContext.error?.message ?? 'cannot determine artifact selection',
794
+ resolved.taskId
795
+ );
796
+ }
797
+ if (artifactContext.selection.disposition === 'reuse') {
798
+ return failed(
799
+ 'ORCHESTRATION_REUSE_STALLED',
800
+ `lifecycle still routes to ${action} after reusing ${artifactContext.selection.artifact.name}`,
801
+ resolved.taskId
802
+ );
803
+ }
804
+ const selected = artifactContext.selection.artifact;
789
805
  const capability = canStart(action, facts.facts, {
790
- initiator: 'orchestrator', requestId: `orchestration:${resolved.taskId}:${routed.stage}:${routed.round}`,
806
+ initiator: 'orchestrator', requestId: `orchestration:${resolved.taskId}:${routed.stage}:${selected.round}`,
791
807
  requestedAction: action, reasonCode: 'user-request', explicitRequest: true
792
808
  });
793
809
  if (!capability.allowed) return failed('ORCHESTRATION_CAPABILITY_DENIED', `${capability.reasonCode}: ${capability.evidence.join(', ')}`, resolved.taskId);
794
810
  const policy = run ? rolePolicy(run, routed.role) : null;
795
811
  const next: OrchestrationNext = {
796
812
  ...routed,
813
+ round: selected.round,
814
+ artifact: selected.name,
797
815
  requestedModel: policy?.model ?? null,
798
816
  requestedReasoningEffort: policy?.reasoningEffort ?? null
799
817
  };
@@ -1417,19 +1435,34 @@ function recoverPreparedOrchestrationDelegation(
1417
1435
  }
1418
1436
  }
1419
1437
 
1438
+ type ActivatedRecoveryEvent = Readonly<{
1439
+ receiptId: string;
1440
+ stage: DelegationStage;
1441
+ round: number;
1442
+ artifact: string;
1443
+ startedAgent: string;
1444
+ childId: string;
1445
+ stopRevision: number;
1446
+ consumer: string;
1447
+ consumedAt: string;
1448
+ }>;
1449
+
1450
+ function recoveryReceiptMatches(
1451
+ receipt: DelegationReceipt,
1452
+ taskId: string,
1453
+ event: ActivatedRecoveryEvent
1454
+ ): boolean {
1455
+ return receipt.taskId === taskId
1456
+ && receipt.stage === event.stage
1457
+ && receipt.round === event.round
1458
+ && receipt.artifact === event.artifact
1459
+ && receipt.childId === event.childId
1460
+ && normalizeAgentToken(event.startedAgent) === normalizeAgentToken(receipt.client);
1461
+ }
1462
+
1420
1463
  function recoverActivatedOrchestrationDelegationUnderLock(
1421
1464
  taskRef: string,
1422
- event: Readonly<{
1423
- receiptId: string;
1424
- stage: DelegationStage;
1425
- round: number;
1426
- artifact: string;
1427
- startedAgent: string;
1428
- childId: string;
1429
- stopRevision: number;
1430
- consumer: string;
1431
- consumedAt: string;
1432
- }>,
1465
+ event: ActivatedRecoveryEvent,
1433
1466
  options: OrchestrationOptions = {}
1434
1467
  ): OrchestrationResult {
1435
1468
  const resolved = resolveTaskRef(taskRef, { repoRoot: options.repoRoot });
@@ -1442,14 +1475,7 @@ function recoverActivatedOrchestrationDelegationUnderLock(
1442
1475
  ];
1443
1476
  const receipt = matches.find((candidate) => candidate.id === event.receiptId);
1444
1477
  if (!receipt) return failed('ORCHESTRATION_DELEGATION_MISSING', 'recovery receipt does not exist', resolved.taskId);
1445
- if (
1446
- receipt.taskId !== resolved.taskId
1447
- || receipt.stage !== event.stage
1448
- || receipt.round !== event.round
1449
- || receipt.artifact !== event.artifact
1450
- || receipt.childId !== event.childId
1451
- || normalizeAgentToken(event.startedAgent) !== normalizeAgentToken(receipt.client)
1452
- ) {
1478
+ if (!recoveryReceiptMatches(receipt, resolved.taskId, event)) {
1453
1479
  return failed('ORCHESTRATION_PROVENANCE_MISMATCH', 'recovery selector does not match the delegation receipt', resolved.taskId);
1454
1480
  }
1455
1481
  if (receipt.status === 'aborted' && run.pendingDelegation === null) {
@@ -1461,6 +1487,9 @@ function recoverActivatedOrchestrationDelegationUnderLock(
1461
1487
  if (run.receipts.some((candidate) => candidate.id === receipt.id)) {
1462
1488
  return failed('ORCHESTRATION_RECEIPT_DUPLICATE', 'recovery receipt already exists in the completed receipt history', resolved.taskId);
1463
1489
  }
1490
+ if (run.status !== 'running' || run.pause !== null) {
1491
+ return failed('ORCHESTRATION_RECOVERY_UNSAFE', 'activated recovery cannot replace an unrelated orchestration pause', resolved.taskId);
1492
+ }
1464
1493
  const aborted = abortActivatedDelegation(receipt, {
1465
1494
  childId: event.childId,
1466
1495
  stopRevision: event.stopRevision,