@fitlab-ai/agent-infra 0.11.3 → 0.11.5

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 (172) hide show
  1. package/README.md +17 -8
  2. package/README.zh-CN.md +17 -8
  3. package/dist/bin/internal-cli.js +1 -6
  4. package/dist/lib/decide.js +4 -41
  5. package/dist/lib/git/branch-worktree.js +45 -0
  6. package/dist/lib/internal/cli-route-inventory.js +1 -4
  7. package/dist/lib/internal/task-activity.js +7 -40
  8. package/dist/lib/internal/task-artifact.js +1 -1
  9. package/dist/lib/internal/task-event.js +4 -37
  10. package/dist/lib/internal/task-finalization.js +48 -1
  11. package/dist/lib/internal/task-ledger.js +14 -44
  12. package/dist/lib/internal/task-lifecycle.js +1 -1
  13. package/dist/lib/internal/task-operation-registry.js +2 -4
  14. package/dist/lib/internal/task-review.js +5 -37
  15. package/dist/lib/internal/task-verify.js +3 -47
  16. package/dist/lib/internal/task-warning.js +4 -40
  17. package/dist/lib/platform/completion-backfill.js +63 -17
  18. package/dist/lib/platform/pr-summary.js +4 -23
  19. package/dist/lib/platform/verification-sync.js +12 -49
  20. package/dist/lib/sandbox/control/client.js +4 -1
  21. package/dist/lib/sandbox/control/executor.js +12 -4
  22. package/dist/lib/sandbox/control/protocol.js +2 -1
  23. package/dist/lib/sandbox/control/server.js +26 -143
  24. package/dist/lib/sandbox/removal.js +15 -8
  25. package/dist/lib/sandbox/task-cleanup.js +13 -20
  26. package/dist/lib/task/activity-intent.js +1 -2
  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 +37 -28
  30. package/dist/lib/task/artifact-receipts.js +4 -1
  31. package/dist/lib/task/artifact-schema.js +8 -7
  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 +12 -168
  36. package/dist/lib/task/decision-intents.js +14 -4
  37. package/dist/lib/task/delivery.js +1 -43
  38. package/dist/lib/task/events.js +68 -73
  39. package/dist/lib/task/finalization-handoff.js +92 -0
  40. package/dist/lib/task/finalization.js +423 -154
  41. package/dist/lib/task/intermediate-cleanup.js +9 -148
  42. package/dist/lib/task/ledger-intents.js +159 -20
  43. package/dist/lib/task/lifecycle-path.js +75 -0
  44. package/dist/lib/task/lifecycle.js +60 -11
  45. package/dist/lib/task/local-artifact-finalization.js +13 -47
  46. package/dist/lib/task/orchestration.js +17 -7
  47. package/dist/lib/task/review-command.js +1 -2
  48. package/dist/lib/task/review-finalization.js +4 -18
  49. package/dist/lib/task/rework-intent.js +70 -4
  50. package/dist/lib/task/snapshot.js +6 -6
  51. package/dist/lib/task/verification-engine.js +25 -30
  52. package/dist/lib/task/verification.js +2 -13
  53. package/dist/lib/task/workflow-warning-intents.js +23 -10
  54. package/dist/lib/task/workspace-snapshot.js +7 -2
  55. package/dist/lib/task/write.js +1 -2
  56. package/dist/lifecycle-build-manifest.json +3 -3
  57. package/lib/decide.ts +5 -35
  58. package/lib/git/branch-worktree.ts +41 -0
  59. package/lib/internal/cli-route-inventory.ts +1 -3
  60. package/lib/internal/task-activity.ts +6 -34
  61. package/lib/internal/task-artifact.ts +1 -1
  62. package/lib/internal/task-event.ts +4 -37
  63. package/lib/internal/task-finalization.ts +47 -1
  64. package/lib/internal/task-ledger.ts +15 -41
  65. package/lib/internal/task-lifecycle.ts +2 -2
  66. package/lib/internal/task-operation-registry.ts +2 -4
  67. package/lib/internal/task-review.ts +5 -31
  68. package/lib/internal/task-verify.ts +3 -39
  69. package/lib/internal/task-warning.ts +3 -32
  70. package/lib/platform/completion-backfill.ts +78 -16
  71. package/lib/platform/pr-summary.ts +4 -25
  72. package/lib/platform/verification-sync.ts +13 -53
  73. package/lib/sandbox/control/client.ts +5 -1
  74. package/lib/sandbox/control/executor.ts +13 -4
  75. package/lib/sandbox/control/protocol.ts +3 -2
  76. package/lib/sandbox/control/server.ts +21 -146
  77. package/lib/sandbox/removal.ts +23 -7
  78. package/lib/sandbox/task-cleanup.ts +12 -20
  79. package/lib/task/activity-intent.ts +1 -2
  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 +59 -28
  83. package/lib/task/artifact-receipts.ts +4 -1
  84. package/lib/task/artifact-schema.ts +10 -7
  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 +17 -189
  89. package/lib/task/decision-intents.ts +11 -4
  90. package/lib/task/delivery.ts +1 -40
  91. package/lib/task/events.ts +73 -74
  92. package/lib/task/finalization-handoff.ts +104 -0
  93. package/lib/task/finalization.ts +388 -122
  94. package/lib/task/intermediate-cleanup.ts +9 -125
  95. package/lib/task/ledger-intents.ts +157 -20
  96. package/lib/task/lifecycle-path.ts +98 -0
  97. package/lib/task/lifecycle.ts +58 -13
  98. package/lib/task/local-artifact-finalization.ts +14 -56
  99. package/lib/task/orchestration.ts +26 -8
  100. package/lib/task/review-command.ts +2 -5
  101. package/lib/task/review-finalization.ts +4 -20
  102. package/lib/task/rework-intent.ts +75 -5
  103. package/lib/task/snapshot.ts +6 -6
  104. package/lib/task/verification-engine.ts +29 -30
  105. package/lib/task/verification.ts +3 -16
  106. package/lib/task/workflow-warning-intents.ts +20 -10
  107. package/lib/task/workspace-snapshot.ts +3 -2
  108. package/lib/task/write.ts +1 -4
  109. package/package.json +1 -1
  110. package/templates/.agents/rules/review-handshake.en.md +4 -4
  111. package/templates/.agents/rules/review-handshake.zh-CN.md +4 -4
  112. package/templates/.agents/skills/analyze-task/SKILL.en.md +21 -5
  113. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +21 -5
  114. package/templates/.agents/skills/code-task/SKILL.en.md +14 -8
  115. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +18 -13
  116. package/templates/.agents/skills/code-task/reference/code-rules.en.md +2 -2
  117. package/templates/.agents/skills/code-task/reference/code-rules.zh-CN.md +2 -2
  118. package/templates/.agents/skills/code-task/reference/dual-mode.en.md +4 -4
  119. package/templates/.agents/skills/code-task/reference/dual-mode.zh-CN.md +4 -4
  120. package/templates/.agents/skills/code-task/reference/report-template.en.md +7 -7
  121. package/templates/.agents/skills/code-task/reference/report-template.zh-CN.md +7 -7
  122. package/templates/.agents/skills/complete-manual-validation/config/verify.en.json +2 -2
  123. package/templates/.agents/skills/complete-manual-validation/config/verify.zh-CN.json +2 -2
  124. package/templates/.agents/skills/complete-manual-validation/reference/report-template.en.md +1 -3
  125. package/templates/.agents/skills/complete-manual-validation/reference/report-template.zh-CN.md +1 -3
  126. package/templates/.agents/skills/complete-task/SKILL.en.md +2 -2
  127. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +2 -2
  128. package/templates/.agents/skills/complete-task/config/verify.en.json +2 -2
  129. package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +2 -2
  130. package/templates/.agents/skills/plan-task/SKILL.en.md +13 -10
  131. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +13 -10
  132. package/templates/.agents/skills/review-analysis/SKILL.en.md +5 -1
  133. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +5 -1
  134. package/templates/.agents/skills/review-analysis/reference/report-template.en.md +3 -3
  135. package/templates/.agents/skills/review-analysis/reference/report-template.zh-CN.md +3 -3
  136. package/templates/.agents/skills/review-code/SKILL.en.md +5 -1
  137. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +5 -1
  138. package/templates/.agents/skills/review-code/reference/report-template.en.md +3 -3
  139. package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +3 -3
  140. package/templates/.agents/skills/review-plan/SKILL.en.md +5 -1
  141. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +5 -1
  142. package/templates/.agents/skills/review-plan/reference/report-template.en.md +3 -3
  143. package/templates/.agents/skills/review-plan/reference/report-template.zh-CN.md +3 -3
  144. package/templates/.agents/skills/review-pr/config/verify.en.json +3 -1
  145. package/templates/.agents/skills/review-pr/config/verify.zh-CN.json +3 -1
  146. package/templates/.agents/skills/review-pr/reference/report-template.en.md +3 -3
  147. package/templates/.agents/skills/review-pr/reference/report-template.zh-CN.md +3 -3
  148. package/templates/.agents/skills/run-manual-validation/config/verify.en.json +2 -2
  149. package/templates/.agents/skills/run-manual-validation/config/verify.zh-CN.json +2 -2
  150. package/templates/.agents/skills/run-manual-validation/reference/report-template.en.md +2 -2
  151. package/templates/.agents/skills/run-manual-validation/reference/report-template.zh-CN.md +2 -2
  152. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +18 -2
  153. package/templates/.agents/templates/task.en.md +2 -2
  154. package/templates/.agents/templates/task.zh-CN.md +2 -2
  155. package/templates/.agents/workflows/bug-fix.en.yaml +11 -6
  156. package/templates/.agents/workflows/bug-fix.zh-CN.yaml +11 -6
  157. package/templates/.agents/workflows/feature-development.en.yaml +11 -6
  158. package/templates/.agents/workflows/feature-development.zh-CN.yaml +11 -6
  159. package/templates/.agents/workflows/refactoring.en.yaml +11 -6
  160. package/templates/.agents/workflows/refactoring.zh-CN.yaml +11 -6
  161. package/dist/lib/internal/task-override.js +0 -138
  162. package/dist/lib/sandbox/control/finalization-response.js +0 -33
  163. package/dist/lib/task/artifact-recovery.js +0 -476
  164. package/dist/lib/task/artifact-repair-intent.js +0 -181
  165. package/dist/lib/task/guard-override.js +0 -31
  166. package/dist/lib/task/human-override.js +0 -1259
  167. package/lib/internal/task-override.ts +0 -144
  168. package/lib/sandbox/control/finalization-response.ts +0 -35
  169. package/lib/task/artifact-recovery.ts +0 -572
  170. package/lib/task/artifact-repair-intent.ts +0 -227
  171. package/lib/task/guard-override.ts +0 -50
  172. package/lib/task/human-override.ts +0 -1522
@@ -14,26 +14,14 @@ import {
14
14
  readTaskFinalizationReceipt,
15
15
  type TaskFinalizationReceipt
16
16
  } from './finalization.ts';
17
- import {
18
- semanticDigest,
19
- sha256Content
20
- } from './local-artifact-finalization.ts';
21
- import { artifactRecoveryRoot, readArtifactRecoveryIntent } from './artifact-repair-intent.ts';
22
- import type { ArtifactRecoveryIntent } from './artifact-repair-intent.ts';
23
- import { parseArtifactName } from './artifact-name.ts';
24
- import type { ArtifactSchemaFamily } from './artifact-schema.ts';
25
17
  import { enumerateAllTaskDirs, type TaskWorkspaceState } from './resolve-ref.ts';
26
18
  import type { TaskControlBindingEvidence } from './finalization-state.ts';
27
19
 
28
20
  const TASK_ID_RE = /^TASK-\d{8}-\d{6}$/;
29
- const LOCAL_INTENT_RE = /^(TASK-\d{8}-\d{6})-(analysis|review-analysis|plan|review-plan|code|review-code)-(.+\.md)\.json$/;
30
21
  const COMMIT_INTENT_RE = /^(TASK-\d{8}-\d{6})\.json$/;
31
- const AUXILIARY_ROOTS = [
32
- '.local-artifact-finalization-intents',
33
- '.task-commit-intents'
34
- ] as const;
22
+ const AUXILIARY_ROOTS = ['.task-commit-intents'] as const;
35
23
 
36
- type CleanupKind = 'LFAI-CONSUMED' | 'COMMIT-SYNCED' | 'EMPTY-AUX-PARENT';
24
+ type CleanupKind = 'COMMIT-SYNCED' | 'EMPTY-AUX-PARENT';
37
25
  type CleanupDisposition = 'planned' | 'protected' | 'skipped' | 'deleted' | 'failed';
38
26
  type FileIdentity = Readonly<{ dev: string; ino: string; size: number; mtimeMs: number }>;
39
27
 
@@ -153,47 +141,6 @@ function receiptGate(
153
141
  return null;
154
142
  }
155
143
 
156
- function canonicalLocalIntentPath(taskId: string, family: string, artifact: string): string {
157
- return `${taskId}-${family}-${artifact}.json`;
158
- }
159
-
160
- function localIntentCandidate(
161
- repoRoot: string,
162
- task: TaskRecord | undefined,
163
- filePath: string,
164
- taskId: string,
165
- family: ArtifactSchemaFamily,
166
- artifact: string,
167
- identity: FileIdentity,
168
- options: ScanOptions
169
- ): IntermediateCleanupItem {
170
- const protectedReason = taskGate(task);
171
- if (protectedReason) return item('LFAI-CONSUMED', taskId, filePath, 'protected', protectedReason, identity);
172
- if (path.basename(filePath) !== canonicalLocalIntentPath(taskId, family, artifact)
173
- || !parseArtifactName(artifact)?.family || parseArtifactName(artifact)!.family !== family) {
174
- return item('LFAI-CONSUMED', taskId, filePath, 'protected', 'PATH_IDENTITY_MISMATCH', identity);
175
- }
176
- let intent: ArtifactRecoveryIntent | null;
177
- try { intent = readArtifactRecoveryIntent(repoRoot, taskId, family, artifact); }
178
- catch { return item('LFAI-CONSUMED', taskId, filePath, 'protected', 'LFAI_SCHEMA_INVALID', identity); }
179
- if (!intent || intent.state !== 'consumed') {
180
- return item('LFAI-CONSUMED', taskId, filePath, 'protected', 'LFAI_STATE_PROTECTED', identity);
181
- }
182
- const artifactPath = path.join(task!.taskDir, artifact);
183
- if (safeLstat(task!.taskDir, artifactPath, 'file') === null) {
184
- return item('LFAI-CONSUMED', taskId, filePath, 'protected', 'ARTIFACT_IDENTITY_MISMATCH', identity);
185
- }
186
- let content: string;
187
- try { content = fs.readFileSync(artifactPath, 'utf8'); }
188
- catch { return item('LFAI-CONSUMED', taskId, filePath, 'protected', 'ARTIFACT_UNREADABLE', identity); }
189
- if (intent.finalArtifactSha256 !== sha256Content(content) || intent.finalSemanticDigest !== semanticDigest(content)) {
190
- return item('LFAI-CONSUMED', taskId, filePath, 'protected', 'ARTIFACT_DIGEST_MISMATCH', identity);
191
- }
192
- const receiptReason = receiptGate(repoRoot, task!, options);
193
- if (receiptReason) return item('LFAI-CONSUMED', taskId, filePath, 'protected', receiptReason, identity);
194
- return item('LFAI-CONSUMED', taskId, filePath, 'planned', 'LFAI_CONSUMED_AND_VERIFIED', identity);
195
- }
196
-
197
144
  function commitIntentCandidate(
198
145
  repoRoot: string,
199
146
  task: TaskRecord | undefined,
@@ -258,23 +205,12 @@ function readAuxiliaryCandidates(
258
205
  candidates.push(item('AUXILIARY-UNKNOWN', null, filePath, 'protected', 'PATH_IDENTITY_MISMATCH'));
259
206
  continue;
260
207
  }
261
- if (rootName === '.local-artifact-finalization-intents') {
262
- const match = LOCAL_INTENT_RE.exec(entry.name);
263
- if (!match || (selected && !selected.has(match[1]!))) {
264
- if (!selected || !match) candidates.push(item('AUXILIARY-UNKNOWN', null, filePath, 'protected', 'PATH_IDENTITY_MISMATCH', identity));
265
- continue;
266
- }
267
- candidates.push(localIntentCandidate(
268
- repoRoot, tasks.get(match[1]!), filePath, match[1]!, match[2] as 'analysis' | 'plan' | 'code', match[3]!, identity, options
269
- ));
270
- } else {
271
- const match = COMMIT_INTENT_RE.exec(entry.name);
272
- if (!match || (selected && !selected.has(match[1]!))) {
273
- if (!selected || !match) candidates.push(item('AUXILIARY-UNKNOWN', null, filePath, 'protected', 'PATH_IDENTITY_MISMATCH', identity));
274
- continue;
275
- }
276
- candidates.push(commitIntentCandidate(repoRoot, tasks.get(match[1]!), filePath, match[1]!, identity, options));
208
+ const match = COMMIT_INTENT_RE.exec(entry.name);
209
+ if (!match || (selected && !selected.has(match[1]!))) {
210
+ if (!selected || !match) candidates.push(item('AUXILIARY-UNKNOWN', null, filePath, 'protected', 'PATH_IDENTITY_MISMATCH', identity));
211
+ continue;
277
212
  }
213
+ candidates.push(commitIntentCandidate(repoRoot, tasks.get(match[1]!), filePath, match[1]!, identity, options));
278
214
  }
279
215
  if (entries.length === 0) {
280
216
  candidates.push(item('EMPTY-AUX-PARENT', null, root, 'planned', 'EMPTY_CANONICAL_AUXILIARY_ROOT', rootIdentity));
@@ -307,56 +243,7 @@ function scanIntermediateCleanup(repoRoot: string, options: ScanOptions = {}): I
307
243
  return buildIntermediateCleanupReport(candidates, true);
308
244
  }
309
245
 
310
- function cleanupConsumedRecoveryFiles(repoRoot: string, task: TaskRecord | undefined, candidate: IntermediateCleanupItem): boolean {
311
- if (candidate.kind !== 'LFAI-CONSUMED' || !candidate.taskId) return true;
312
- if (!task) return false;
313
- const match = LOCAL_INTENT_RE.exec(path.basename(candidate.path));
314
- if (!match) return false;
315
- let intent: ReturnType<typeof readArtifactRecoveryIntent>;
316
- try { intent = readArtifactRecoveryIntent(repoRoot, candidate.taskId, match[2] as ArtifactSchemaFamily, match[3]!); }
317
- catch { return false; }
318
- if (!intent || intent.state !== 'consumed') return false;
319
-
320
- const recovery = artifactRecoveryRoot(task.taskDir, intent.recoveryOperationId);
321
- let stat: fs.Stats | null = null;
322
- try { stat = fs.lstatSync(recovery); } catch (error) {
323
- if (errorCode(error) === 'ENOENT') return true;
324
- return false;
325
- }
326
- if (!stat.isDirectory() || stat.isSymbolicLink()) return false;
327
- let entries: fs.Dirent[];
328
- try { entries = fs.readdirSync(recovery, { withFileTypes: true }); } catch { return false; }
329
- for (const entry of entries) {
330
- if (entry.isSymbolicLink()) return false;
331
- if (entry.isFile()) {
332
- if (!['baseline.md', 'candidate.md', 'final.md', 'publish.md'].includes(entry.name)) return false;
333
- continue;
334
- }
335
- if (!entry.isDirectory() || entry.name !== 'generations') return false;
336
- let generations: fs.Dirent[];
337
- try { generations = fs.readdirSync(path.join(recovery, entry.name), { withFileTypes: true }); } catch { return false; }
338
- if (generations.some((generation) => generation.isSymbolicLink()
339
- || !generation.isFile() || !/^[a-f0-9]{64}\.md$/u.test(generation.name))) return false;
340
- }
341
- try {
342
- for (const entry of entries) {
343
- const target = path.join(recovery, entry.name);
344
- if (entry.isFile()) fs.unlinkSync(target);
345
- else {
346
- for (const generation of fs.readdirSync(target, { withFileTypes: true })) fs.unlinkSync(path.join(target, generation.name));
347
- fs.rmdirSync(target);
348
- }
349
- }
350
- fs.rmdirSync(recovery);
351
- const recoveryRoot = path.dirname(recovery);
352
- if (fs.readdirSync(recoveryRoot).length === 0) fs.rmdirSync(recoveryRoot);
353
- return true;
354
- } catch (error) {
355
- return errorCode(error) === 'ENOENT';
356
- }
357
- }
358
-
359
- function removeCandidate(repoRoot: string, candidate: IntermediateCleanupItem, task?: TaskRecord): IntermediateCleanupItem {
246
+ function removeCandidate(repoRoot: string, candidate: IntermediateCleanupItem): IntermediateCleanupItem {
360
247
  if (candidate.disposition !== 'planned') return candidate;
361
248
  const root = candidate.kind === 'EMPTY-AUX-PARENT'
362
249
  ? path.dirname(path.dirname(candidate.path))
@@ -373,9 +260,6 @@ function removeCandidate(repoRoot: string, candidate: IntermediateCleanupItem, t
373
260
  const actual = safeLstat(root, candidate.path, candidate.kind === 'EMPTY-AUX-PARENT' ? 'directory' : 'file');
374
261
  if (!actual) return { ...candidate, disposition: 'protected', reason: 'PATH_IDENTITY_CHANGED' };
375
262
  if (!sameFileIdentity(expected, actual)) return { ...candidate, disposition: 'protected', reason: 'PATH_IDENTITY_CHANGED' };
376
- if (!cleanupConsumedRecoveryFiles(repoRoot, task, candidate)) {
377
- return { ...candidate, disposition: 'protected', reason: 'RECOVERY_STAGING_NOT_SAFE_TO_REMOVE' };
378
- }
379
263
  try {
380
264
  if (candidate.kind === 'EMPTY-AUX-PARENT') {
381
265
  if (fs.readdirSync(candidate.path).length !== 0) return { ...candidate, disposition: 'protected', reason: 'AUXILIARY_ROOT_NOT_EMPTY' };
@@ -434,7 +318,7 @@ function cleanupIntermediateUnderRemovalCoordinator(
434
318
  if (initialCandidate.disposition !== 'planned') continue;
435
319
  const current = refreshedByKey.get(intermediateCleanupKey(initialCandidate));
436
320
  items[index] = current
437
- ? removeCandidate(repoRoot, current, tasks.get(taskId))
321
+ ? removeCandidate(repoRoot, current)
438
322
  : { ...initialCandidate, disposition: 'protected', reason: 'CANDIDATE_NO_LONGER_ELIGIBLE' };
439
323
  }
440
324
  };
@@ -14,9 +14,11 @@ import type { LedgerRow, ReviewStage } from './ledger.ts';
14
14
  import { resolveTaskRef } from './resolve-ref.ts';
15
15
  import { writeTask } from './write.ts';
16
16
  import type { TaskMutation, TaskOperationSummary, TaskWriteOptions } from './write.ts';
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';
17
+ import { parseLegacyReworkIntentDocument, parseReworkIntentDocument, reworkIntentMutation, upsertReworkIntent } from './rework-intent.ts';
18
+ import type { ReworkClassification, ReworkIntent, ReworkTarget } from './rework-intent.ts';
19
+ import { extractSection, extractSubSection } from './sections.ts';
20
+ import { parseLifecyclePathDecision } from './lifecycle-path.ts';
21
+ import { scanVisibleMarkdown } from './markdown.ts';
20
22
 
21
23
  type ReviewSeverity = 'blocker' | 'major' | 'minor';
22
24
  type ExecutorResponse = 'accepted' | 'adjusted' | 'refuted' | 'cannot-judge';
@@ -27,7 +29,8 @@ type LedgerIntent =
27
29
  | { kind: 'finding-review'; taskRef: string; id: string; status: ReviewDisposition; evidence: string; needsImplementation?: boolean; dryRun?: boolean }
28
30
  | { kind: 'decision-next-id'; taskRef: string }
29
31
  | { 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 };
32
+ | { kind: 'rework-intent-upsert'; taskRef: string; intentId: string; findingId: string; sourceArtifact: string; sourceSha256: string; classification: ReworkClassification; dryRun?: boolean }
33
+ | { kind: 'rework-intent-rebuild'; taskRef: string; findingId?: string; sourceArtifact?: string; sourceSha256?: string; classification?: ReworkClassification; dryRun?: boolean };
31
34
 
32
35
  type LedgerIntentError = { code: string; message: string };
33
36
  type LedgerIntentResult = {
@@ -39,6 +42,7 @@ type LedgerIntentResult = {
39
42
  before: LedgerRow | null;
40
43
  after: LedgerRow | null;
41
44
  operations: readonly TaskOperationSummary[];
45
+ rebuild?: Readonly<{ mode: 'format-only' | 'pending-reclassification'; beforeSha256: string; afterSha256: string; converted: number; newIntentId: string | null }>;
42
46
  error: LedgerIntentError | null;
43
47
  };
44
48
 
@@ -60,12 +64,53 @@ function ledgerReadErrorCode(error: unknown): string {
60
64
  return code === 'TABLE_DUPLICATE_KEY' ? 'LEDGER_DUPLICATE_ID' : 'LEDGER_DOCUMENT_INVALID';
61
65
  }
62
66
 
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; }
67
+ const CLASSIFICATION_TARGET: Record<ReworkClassification, ReworkTarget> = {
68
+ 'scope-or-requirement': 'analysis', design: 'plan', implementation: 'code',
69
+ 'human-decision': 'pause', 'insufficient-evidence': 'pause'
70
+ };
71
+
72
+ function semanticDigest(value: string): string {
73
+ return createHash('sha256').update(value.normalize('NFKC').replace(/\s+/g, ' ').trim()).digest('hex');
74
+ }
75
+
76
+ function normalizedTaskFact(value: string): string {
77
+ return value.normalize('NFKC').replace(/\s+/g, ' ').trim();
78
+ }
79
+
80
+ function semanticFindingEvidence(reviewContent: string, heading: ReturnType<typeof scanVisibleMarkdown>['headings'][number], end: number): string {
81
+ const title = heading.text.replace(/^\d+[.、::]\s*/, '').trim();
82
+ return `${title}\n${reviewContent.slice(heading.end, end)}`;
83
+ }
84
+
85
+ function findingEvidence(reviewContent: string, evidence: string): string | null {
86
+ const anchor = evidence.split('#')[1];
87
+ if (!anchor) return null;
88
+ const markdown = scanVisibleMarkdown(reviewContent);
89
+ const explicit = markdown.anchors.find((candidate) => candidate.id === anchor);
90
+ if (explicit) {
91
+ const heading = markdown.headings.find((candidate) => candidate.start > explicit.start);
92
+ const next = heading && markdown.headings.find((candidate) => candidate.start > heading.start && candidate.level <= heading.level);
93
+ const end = next?.start ?? reviewContent.length;
94
+ return heading ? semanticFindingEvidence(reviewContent, heading, end) : reviewContent.slice(explicit.end, end);
95
+ }
96
+ const heading = markdown.headings.find((candidate) => {
97
+ const escapedAnchor = anchor.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
98
+ return candidate.text === anchor || new RegExp(`^${escapedAnchor}(?:\\s|[.、::])`).test(candidate.text);
99
+ });
100
+ if (!heading) return null;
101
+ const next = markdown.headings.find((candidate) => candidate.start > heading.start && candidate.level <= heading.level);
102
+ return semanticFindingEvidence(reviewContent, heading, next?.start ?? reviewContent.length);
103
+ }
104
+
105
+ function taskFactDigest(taskDir: string, content: string): string {
106
+ const analysisNames = fs.readdirSync(taskDir).filter((name) => /^analysis(?:-r\d+)?\.md$/.test(name));
107
+ const latest = analysisNames.map((name) => ({ name, round: parseArtifactName(name)?.round ?? 0 })).sort((a, b) => b.round - a.round)[0];
108
+ const flow = latest ? parseLifecyclePathDecision(fs.readFileSync(path.join(taskDir, latest.name), 'utf8')) : null;
109
+ return semanticDigest(JSON.stringify({
110
+ taskInput: normalizedTaskFact(extractSection(content, ['任务输入', 'Task Input'])),
111
+ requirements: normalizedTaskFact(extractSection(content, ['需求', 'Requirements'])),
112
+ flow: flow?.status === 'valid' ? flow.decision.semanticDigest : flow?.status ?? 'missing'
113
+ }));
69
114
  }
70
115
 
71
116
  function nextFindingId(rows: readonly LedgerRow[], stage: ReviewStage): string {
@@ -103,8 +148,7 @@ function mapWrite(intent: LedgerIntent, entityId: string, before: LedgerRow | nu
103
148
  function applyLedgerIntent(intent: LedgerIntent, options: TaskWriteOptions = {}): LedgerIntentResult {
104
149
  const resolved = resolveTaskRef(intent.taskRef, { repoRoot: options.repoRoot });
105
150
  if (!resolved.ok) return failed(intent, resolved.code, resolved.message, resolved.taskId);
106
- const stateOverride = allowsManualOverride(options.manualOverride, 'ledger-intent', 'TASK_STATE_MISMATCH');
107
- if (resolved.state !== 'active' && !stateOverride) return failed(intent, 'TASK_STATE_MISMATCH', `task ${resolved.taskId} is ${resolved.state}, expected active`, resolved.taskId);
151
+ if (resolved.state !== 'active') return failed(intent, 'TASK_STATE_MISMATCH', `task ${resolved.taskId} is ${resolved.state}, expected active`, resolved.taskId);
108
152
  let content: string;
109
153
  let rows: LedgerRow[];
110
154
  try {
@@ -123,11 +167,14 @@ function applyLedgerIntent(intent: LedgerIntent, options: TaskWriteOptions = {})
123
167
  }
124
168
 
125
169
  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);
170
+ 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)) {
171
+ return failed(intent, 'LEDGER_PAYLOAD_INVALID', 'rework intent identity, classification, or source hash is invalid', resolved.taskId, intent.intentId);
128
172
  }
129
173
  const finding = rows.find((row) => row.id === intent.findingId);
130
174
  if (!finding || finding.severity === 'decision') return failed(intent, 'LEDGER_NOT_FOUND', `finding '${intent.findingId}' was not found`, resolved.taskId, intent.findingId);
175
+ if (intent.classification === 'human-decision' && finding.status !== 'needs-human-decision') {
176
+ return failed(intent, 'LEDGER_TRANSITION_INVALID', 'human-decision classification requires a needs-human-decision finding', resolved.taskId, intent.findingId);
177
+ }
131
178
  const sourceFromEvidence = finding.evidence.split('#')[0];
132
179
  if (sourceFromEvidence !== intent.sourceArtifact || parseArtifactName(intent.sourceArtifact)?.family !== `review-${finding.stage}`) {
133
180
  return failed(intent, 'LEDGER_IDENTITY_CONFLICT', 'rework intent source artifact does not match finding evidence', resolved.taskId, intent.intentId);
@@ -137,12 +184,34 @@ function applyLedgerIntent(intent: LedgerIntent, options: TaskWriteOptions = {})
137
184
  if (actualHash !== intent.sourceSha256) return failed(intent, 'LEDGER_IDENTITY_CONFLICT', 'rework intent source artifact hash does not match', resolved.taskId, intent.intentId);
138
185
  const parsed = parseReworkIntentDocument(content);
139
186
  if (!parsed.ok) return failed(intent, parsed.code, parsed.message, resolved.taskId, intent.intentId);
140
- const nextIntent: ReworkIntent = {
187
+ const reviewContent = fs.readFileSync(path.join(resolved.taskDir, intent.sourceArtifact), 'utf8');
188
+ const evidenceBlock = findingEvidence(reviewContent, finding.evidence);
189
+ if (!evidenceBlock) return failed(intent, 'LEDGER_EVIDENCE_INVALID', `finding evidence anchor '${finding.evidence}' was not found`, resolved.taskId, intent.intentId);
190
+ const evidenceDigest = semanticDigest(evidenceBlock);
191
+ const factsDigest = taskFactDigest(resolved.taskDir, content);
192
+ const requestedIntent: ReworkIntent = {
141
193
  intentId: intent.intentId, findingId: intent.findingId, sourceArtifact: intent.sourceArtifact,
142
- sourceSha256: intent.sourceSha256, target: intent.target, status: 'pending',
194
+ sourceSha256: intent.sourceSha256, target: CLASSIFICATION_TARGET[intent.classification], classification: intent.classification,
195
+ evidenceDigest, taskFactDigest: factsDigest, status: 'pending',
143
196
  declaredAt: new Date().toISOString(), consumedAt: ''
144
197
  };
145
- const next = upsertReworkIntent(parsed.intents, nextIntent);
198
+ if (parsed.intents.some((candidate) => candidate.intentId === intent.intentId)) {
199
+ const replay = upsertReworkIntent(parsed.intents, requestedIntent);
200
+ if (!replay.changed) {
201
+ return { status: 'no-op', changed: false, intent: intent.kind, taskId: resolved.taskId, entityId: intent.intentId, before: null, after: null, operations: [], error: null };
202
+ }
203
+ }
204
+ const same = parsed.intents.find((candidate) => candidate.findingId === intent.findingId
205
+ && candidate.evidenceDigest === evidenceDigest && candidate.taskFactDigest === factsDigest
206
+ && candidate.classification === intent.classification && candidate.target === CLASSIFICATION_TARGET[intent.classification]);
207
+ const classification: ReworkClassification = same ? 'insufficient-evidence' : intent.classification;
208
+ const nextIntent: ReworkIntent = {
209
+ ...requestedIntent, target: CLASSIFICATION_TARGET[classification], classification
210
+ };
211
+ const now = nextIntent.declaredAt;
212
+ const superseded = parsed.intents.map((candidate) => candidate.status === 'pending'
213
+ ? { ...candidate, status: 'superseded' as const, consumedAt: now } : candidate);
214
+ const next = upsertReworkIntent(superseded, nextIntent);
146
215
  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
216
  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
217
  if (result.status === 'failed') return failed(intent, result.error.code, result.error.message, result.taskId, intent.intentId);
@@ -152,6 +221,75 @@ function applyLedgerIntent(intent: LedgerIntent, options: TaskWriteOptions = {})
152
221
  }
153
222
  }
154
223
 
224
+ if (intent.kind === 'rework-intent-rebuild') {
225
+ const beforeSha256 = createHash('sha256').update(content).digest('hex');
226
+ const supplied = [intent.findingId, intent.sourceArtifact, intent.sourceSha256, intent.classification].filter((value) => value !== undefined).length;
227
+ if (supplied !== 0 && supplied !== 4) return failed(intent, 'LEDGER_PAYLOAD_INVALID', 'rebuild binding options must be provided together', resolved.taskId);
228
+ const current = parseReworkIntentDocument(content);
229
+ if (current.ok && current.present) {
230
+ if (supplied !== 0) return failed(intent, 'LEDGER_PAYLOAD_INVALID', 'current rework intent format does not accept rebuild binding options', resolved.taskId);
231
+ return {
232
+ status: 'no-op', changed: false, intent: intent.kind, taskId: resolved.taskId, entityId: null,
233
+ before: null, after: null, operations: [], error: null,
234
+ rebuild: { mode: 'format-only', beforeSha256, afterSha256: beforeSha256, converted: current.intents.length, newIntentId: null }
235
+ };
236
+ }
237
+ const legacy = parseLegacyReworkIntentDocument(content);
238
+ if (!legacy.ok) return failed(intent, legacy.code, legacy.message, resolved.taskId);
239
+ const pending = legacy.intents.filter((row) => row.status === 'pending');
240
+ if (pending.length === 0 && supplied !== 0) return failed(intent, 'LEDGER_PAYLOAD_INVALID', 'format-only rebuild does not accept finding or classification options', resolved.taskId);
241
+ if (pending.length > 0 && supplied !== 4) return failed(intent, 'LEDGER_PAYLOAD_INVALID', 'legacy pending intents require finding, review, hash, and classification', resolved.taskId);
242
+ const now = new Date().toISOString();
243
+ let next = legacy.intents.map((row) => row.status === 'pending' ? { ...row, status: 'superseded' as const, consumedAt: now } : row);
244
+ let entityId: string | null = null;
245
+ if (pending.length > 0) {
246
+ const finding = rows.find((row) => row.id === intent.findingId);
247
+ 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);
248
+ if (intent.classification === 'human-decision' && finding.status !== 'needs-human-decision') {
249
+ return failed(intent, 'LEDGER_TRANSITION_INVALID', 'human-decision classification requires a needs-human-decision finding', resolved.taskId);
250
+ }
251
+ const sourceIdentity = parseArtifactName(intent.sourceArtifact!);
252
+ const expectedFamily = `review-${finding.stage}`;
253
+ const latestSource = fs.readdirSync(resolved.taskDir)
254
+ .map((name) => parseArtifactName(name))
255
+ .filter((identity) => identity?.family === expectedFamily)
256
+ .sort((left, right) => right!.round - left!.round || left!.name.localeCompare(right!.name))[0];
257
+ if (!sourceIdentity || sourceIdentity.family !== expectedFamily || latestSource?.name !== sourceIdentity.name) {
258
+ return failed(intent, 'LEDGER_IDENTITY_CONFLICT', 'rebuild source artifact is not the latest review for the finding stage', resolved.taskId);
259
+ }
260
+ const sourcePath = path.join(resolved.taskDir, intent.sourceArtifact!);
261
+ const actualHash = createHash('sha256').update(fs.readFileSync(sourcePath)).digest('hex');
262
+ if (actualHash !== intent.sourceSha256) return failed(intent, 'LEDGER_IDENTITY_CONFLICT', 'rebuild source artifact hash does not match', resolved.taskId);
263
+ const max = next.reduce((value, row) => Math.max(value, Number(row.intentId.slice(3))), 0);
264
+ entityId = `RI-${max + 1}`;
265
+ const reviewContent = fs.readFileSync(sourcePath, 'utf8');
266
+ const classification = intent.classification!;
267
+ if (!(classification in CLASSIFICATION_TARGET)) return failed(intent, 'LEDGER_PAYLOAD_INVALID', 'rebuild classification is invalid', resolved.taskId);
268
+ const evidenceBlock = findingEvidence(reviewContent, finding.evidence);
269
+ if (!evidenceBlock) return failed(intent, 'LEDGER_EVIDENCE_INVALID', `finding evidence anchor '${finding.evidence}' was not found`, resolved.taskId);
270
+ next = [...next, {
271
+ intentId: entityId, findingId: intent.findingId!, sourceArtifact: intent.sourceArtifact!, sourceSha256: intent.sourceSha256!,
272
+ target: CLASSIFICATION_TARGET[classification], classification,
273
+ evidenceDigest: semanticDigest(evidenceBlock),
274
+ taskFactDigest: taskFactDigest(resolved.taskDir, content), status: 'pending', declaredAt: now, consumedAt: ''
275
+ }];
276
+ }
277
+ 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 } });
278
+ if (result.status === 'failed') return failed(intent, result.error.code, result.error.message, result.taskId, entityId);
279
+ const afterContent = result.status === 'applied' || result.status === 'no-op' ? fs.readFileSync(resolved.taskMdPath, 'utf8') : content;
280
+ return {
281
+ status: result.status, changed: result.changed, intent: intent.kind, taskId: result.taskId, entityId,
282
+ before: null, after: null, operations: result.operations, error: null,
283
+ rebuild: {
284
+ mode: pending.length > 0 ? 'pending-reclassification' : 'format-only',
285
+ beforeSha256,
286
+ afterSha256: createHash('sha256').update(afterContent).digest('hex'),
287
+ converted: legacy.intents.length,
288
+ newIntentId: entityId
289
+ }
290
+ };
291
+ }
292
+
155
293
  let before: LedgerRow | null = null;
156
294
  let after: LedgerRow;
157
295
  if (intent.kind === 'finding-upsert') {
@@ -213,8 +351,7 @@ function applyLedgerIntent(intent: LedgerIntent, options: TaskWriteOptions = {})
213
351
  ? new Set(['confirmed', 'open', 'needs-human-decision'])
214
352
  : before.status === 'cannot-judge' ? new Set(['open', 'needs-human-decision']) : new Set<string>();
215
353
  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)))
217
- && !allowsManualOverride(options.manualOverride, 'ledger-intent', 'LEDGER_TRANSITION_INVALID')) {
354
+ if (!sameRoundMinorClose && !allowed.has(intent.status)) {
218
355
  return failed(intent, 'LEDGER_TRANSITION_INVALID', `finding '${intent.id}' cannot transition from ${before.status} to ${intent.status}`, resolved.taskId, intent.id);
219
356
  }
220
357
  after = { ...before, status: intent.status, evidence };
@@ -248,7 +385,7 @@ function applyLedgerIntent(intent: LedgerIntent, options: TaskWriteOptions = {})
248
385
  }
249
386
 
250
387
  const result = writeTask({
251
- taskRef: intent.taskRef, expectedState: stateOverride ? resolved.state : 'active',
388
+ taskRef: intent.taskRef, expectedState: 'active',
252
389
  mutations: [rowMutation(after), ...(implementationMutation ? [implementationMutation] : [])],
253
390
  dryRun: 'dryRun' in intent ? intent.dryRun : false
254
391
  }, { ...options, taskLocation: { repoRoot: resolved.repoRoot, taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, state: resolved.state } });
@@ -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 };