@fitlab-ai/agent-infra 0.11.3 → 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 (102) hide show
  1. package/README.md +17 -8
  2. package/README.zh-CN.md +17 -8
  3. package/dist/bin/internal-cli.js +1 -1
  4. package/dist/lib/internal/cli-route-inventory.js +1 -1
  5. package/dist/lib/internal/task-artifact.js +1 -1
  6. package/dist/lib/internal/task-finalization.js +41 -1
  7. package/dist/lib/internal/task-ledger.js +13 -5
  8. package/dist/lib/internal/task-operation-registry.js +1 -0
  9. package/dist/lib/platform/completion-backfill.js +63 -17
  10. package/dist/lib/sandbox/control/server.js +26 -143
  11. package/dist/lib/sandbox/task-cleanup.js +13 -20
  12. package/dist/lib/task/artifact-command.js +11 -4
  13. package/dist/lib/task/artifact-lifecycle.js +217 -85
  14. package/dist/lib/task/artifact-operations.js +1 -26
  15. package/dist/lib/task/artifact-receipts.js +4 -1
  16. package/dist/lib/task/artifact-schema.js +1 -0
  17. package/dist/lib/task/artifact-selection.js +25 -0
  18. package/dist/lib/task/capabilities.js +158 -39
  19. package/dist/lib/task/commands/status.js +9 -2
  20. package/dist/lib/task/control-authority.js +3 -2
  21. package/dist/lib/task/decision-intents.js +12 -0
  22. package/dist/lib/task/delivery.js +1 -43
  23. package/dist/lib/task/events.js +80 -64
  24. package/dist/lib/task/finalization.js +301 -143
  25. package/dist/lib/task/ledger-intents.js +157 -15
  26. package/dist/lib/task/lifecycle-path.js +75 -0
  27. package/dist/lib/task/lifecycle.js +10 -0
  28. package/dist/lib/task/local-artifact-finalization.js +1 -20
  29. package/dist/lib/task/orchestration.js +17 -7
  30. package/dist/lib/task/review-finalization.js +3 -16
  31. package/dist/lib/task/rework-intent.js +70 -4
  32. package/dist/lib/task/verification-engine.js +20 -30
  33. package/dist/lib/task/verification.js +1 -0
  34. package/dist/lib/task/workflow-warning-intents.js +21 -6
  35. package/dist/lib/task/workspace-snapshot.js +7 -2
  36. package/dist/lifecycle-build-manifest.json +3 -3
  37. package/lib/internal/cli-route-inventory.ts +1 -1
  38. package/lib/internal/task-artifact.ts +1 -1
  39. package/lib/internal/task-finalization.ts +40 -1
  40. package/lib/internal/task-ledger.ts +12 -5
  41. package/lib/internal/task-operation-registry.ts +1 -0
  42. package/lib/platform/completion-backfill.ts +78 -16
  43. package/lib/sandbox/control/server.ts +21 -146
  44. package/lib/sandbox/task-cleanup.ts +12 -20
  45. package/lib/task/artifact-command.ts +14 -4
  46. package/lib/task/artifact-lifecycle.ts +236 -85
  47. package/lib/task/artifact-operations.ts +1 -26
  48. package/lib/task/artifact-receipts.ts +4 -1
  49. package/lib/task/artifact-schema.ts +1 -0
  50. package/lib/task/artifact-selection.ts +53 -0
  51. package/lib/task/capabilities.ts +154 -40
  52. package/lib/task/commands/status.ts +11 -2
  53. package/lib/task/control-authority.ts +3 -1
  54. package/lib/task/decision-intents.ts +9 -0
  55. package/lib/task/delivery.ts +1 -40
  56. package/lib/task/events.ts +82 -64
  57. package/lib/task/finalization.ts +269 -111
  58. package/lib/task/ledger-intents.ts +155 -15
  59. package/lib/task/lifecycle-path.ts +98 -0
  60. package/lib/task/lifecycle.ts +13 -0
  61. package/lib/task/local-artifact-finalization.ts +2 -25
  62. package/lib/task/orchestration.ts +26 -8
  63. package/lib/task/review-finalization.ts +3 -16
  64. package/lib/task/rework-intent.ts +75 -5
  65. package/lib/task/verification-engine.ts +19 -30
  66. package/lib/task/verification.ts +2 -1
  67. package/lib/task/workflow-warning-intents.ts +18 -6
  68. package/lib/task/workspace-snapshot.ts +3 -2
  69. package/package.json +1 -1
  70. package/templates/.agents/rules/review-handshake.en.md +4 -4
  71. package/templates/.agents/rules/review-handshake.zh-CN.md +4 -4
  72. package/templates/.agents/skills/analyze-task/SKILL.en.md +11 -4
  73. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +11 -4
  74. package/templates/.agents/skills/code-task/SKILL.en.md +14 -8
  75. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +18 -13
  76. package/templates/.agents/skills/code-task/reference/code-rules.en.md +2 -2
  77. package/templates/.agents/skills/code-task/reference/code-rules.zh-CN.md +2 -2
  78. package/templates/.agents/skills/code-task/reference/dual-mode.en.md +4 -4
  79. package/templates/.agents/skills/code-task/reference/dual-mode.zh-CN.md +4 -4
  80. package/templates/.agents/skills/code-task/reference/report-template.en.md +1 -1
  81. package/templates/.agents/skills/code-task/reference/report-template.zh-CN.md +1 -1
  82. package/templates/.agents/skills/complete-task/SKILL.en.md +2 -2
  83. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +2 -2
  84. package/templates/.agents/skills/plan-task/SKILL.en.md +13 -10
  85. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +13 -10
  86. package/templates/.agents/skills/review-analysis/SKILL.en.md +5 -1
  87. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +5 -1
  88. package/templates/.agents/skills/review-code/SKILL.en.md +5 -1
  89. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +5 -1
  90. package/templates/.agents/skills/review-plan/SKILL.en.md +5 -1
  91. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +5 -1
  92. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +18 -2
  93. package/templates/.agents/templates/task.en.md +2 -2
  94. package/templates/.agents/templates/task.zh-CN.md +2 -2
  95. package/templates/.agents/workflows/bug-fix.en.yaml +11 -6
  96. package/templates/.agents/workflows/bug-fix.zh-CN.yaml +11 -6
  97. package/templates/.agents/workflows/feature-development.en.yaml +11 -6
  98. package/templates/.agents/workflows/feature-development.zh-CN.yaml +11 -6
  99. package/templates/.agents/workflows/refactoring.en.yaml +11 -6
  100. package/templates/.agents/workflows/refactoring.zh-CN.yaml +11 -6
  101. package/dist/lib/sandbox/control/finalization-response.js +0 -33
  102. package/lib/sandbox/control/finalization-response.ts +0 -35
@@ -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 };
@@ -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 {
@@ -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, {
@@ -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
  };
@@ -16,7 +16,6 @@ import { allowsManualOverride } from './guard-override.ts';
16
16
  import type { ManualOverrideCapability } from './guard-override.ts';
17
17
  import { getArtifactSchema } from './artifact-schema.ts';
18
18
  import { canonicalSemanticDigest, inspectArtifactContract, sha256Content } from './artifact-operations.ts';
19
- import { expectedQualificationRelations, validateQualificationAudit } from './qualification-audit.ts';
20
19
 
21
20
  type ReviewFinalizationErrorCode =
22
21
  | ResolveTaskRefErrorCode
@@ -30,7 +29,6 @@ type ReviewFinalizationErrorCode =
30
29
  | 'REVIEW_SUMMARY_PLACEHOLDER_INVALID'
31
30
  | 'REVIEW_SUMMARY_COUNT_MISMATCH'
32
31
  | 'REVIEW_ARTIFACT_STRUCTURE_INVALID'
33
- | 'REVIEW_ARTIFACT_QUALIFICATION_INVALID'
34
32
  | 'REVIEW_DECISION_DETAIL_INVALID'
35
33
  | 'REVIEW_ARTIFACT_CONFLICT'
36
34
  | 'REVIEW_PROVENANCE_INVALID'
@@ -143,10 +141,8 @@ function preflightReviewSummaryUnlocked(
143
141
  const publicationError = validateArtifactPublication(resolved.taskDir, spec.family, request.artifact);
144
142
  if (publicationError) return preflightFailed(request, 'REVIEW_ARTIFACT_IDENTITY_INVALID', publicationError.message, resolved.taskId);
145
143
 
146
- let taskContent: string;
147
144
  let content: string;
148
145
  try {
149
- taskContent = fs.readFileSync(resolved.taskMdPath, 'utf8');
150
146
  const validated = validateCompletedArtifact(resolved.taskDir, spec.family, request.artifact);
151
147
  if (!validated.ok) return preflightFailed(request, validated.error.code === 'ARTIFACT_NOT_REGULAR' ? 'REVIEW_ARTIFACT_NOT_REGULAR' : 'REVIEW_ARTIFACT_IDENTITY_INVALID', validated.error.message, resolved.taskId);
152
148
  content = fs.readFileSync(validated.artifact.path, 'utf8');
@@ -163,21 +159,12 @@ function preflightReviewSummaryUnlocked(
163
159
  const semanticDigest = canonicalSemanticDigest(content);
164
160
  const detail = inspectDecisionDetailDuplicates(content);
165
161
  const structure = inspectArtifactContract(content, getArtifactSchema(spec.family)!);
166
- const expected = expectedQualificationRelations(taskContent, spec.family);
167
- if (!expected.ok) return preflightFailed(request, 'REVIEW_ARTIFACT_QUALIFICATION_INVALID', `${expected.code}: ${expected.message}`, resolved.taskId, { artifactSha256, semanticDigest });
168
- const qualification = validateQualificationAudit(taskContent, content, {
169
- family: spec.family,
170
- artifact: request.artifact,
171
- expectedUpstreamRelations: expected.relations
172
- });
173
- const qualificationError = qualification.ok ? null : qualification;
174
- if (!detail.ok || !structure.ok || qualificationError) {
162
+ if (!detail.ok || !structure.ok) {
175
163
  const diagnostics = [
176
164
  ...(!detail.ok ? [`${detail.code}: ${detail.message}`] : []),
177
- ...structure.diagnostics.map((item) => `${item.code}: ${item.message}`),
178
- ...(qualificationError ? [`${qualificationError.code}: ${qualificationError.message}`] : [])
165
+ ...structure.diagnostics.map((item) => `${item.code}: ${item.message}`)
179
166
  ].join('; ');
180
- return preflightFailed(request, !detail.ok ? 'REVIEW_DECISION_DETAIL_INVALID' : !structure.ok ? 'REVIEW_ARTIFACT_STRUCTURE_INVALID' : 'REVIEW_ARTIFACT_QUALIFICATION_INVALID', diagnostics, resolved.taskId, {
167
+ return preflightFailed(request, !detail.ok ? 'REVIEW_DECISION_DETAIL_INVALID' : 'REVIEW_ARTIFACT_STRUCTURE_INVALID', diagnostics, resolved.taskId, {
181
168
  artifactSha256, semanticDigest,
182
169
  });
183
170
  }