@fitlab-ai/agent-infra 0.10.0 → 0.11.1

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 (154) hide show
  1. package/README.md +2 -6
  2. package/README.zh-CN.md +2 -6
  3. package/bin/internal-cli.sh +0 -13
  4. package/dist/bin/internal-cli.js +11 -49
  5. package/dist/lib/internal/cli-route-inventory.js +3 -4
  6. package/dist/lib/internal/manual-validation.js +6 -2
  7. package/dist/lib/internal/platform-comment.js +60 -2
  8. package/dist/lib/internal/task-artifact.js +1 -1
  9. package/dist/lib/internal/task-finalization.js +2 -2
  10. package/dist/lib/internal/task-lifecycle.js +2 -2
  11. package/dist/lib/internal/task-operation-registry.js +22 -12
  12. package/dist/lib/internal/task-orchestration.js +2 -2
  13. package/dist/lib/internal/task-review.js +10 -5
  14. package/dist/lib/platform/activity-recovery.js +159 -0
  15. package/dist/lib/platform/issue-comments.js +84 -32
  16. package/dist/lib/platform/issues.js +3 -4
  17. package/dist/lib/platform/task-comment-projection.js +70 -0
  18. package/dist/lib/platform/task-recovery.js +55 -0
  19. package/dist/lib/platform/verification-sync.js +184 -162
  20. package/dist/lib/sandbox/control/executor.js +4 -2
  21. package/dist/lib/sandbox/control/task-workflow.js +1 -1
  22. package/dist/lib/sandbox/control/workflow-executor.js +90 -22
  23. package/dist/lib/server/process-state.js +17 -5
  24. package/dist/lib/task/artifact-command.js +6 -3
  25. package/dist/lib/task/artifact-lifecycle.js +15 -52
  26. package/dist/lib/task/artifact-operations.js +28 -2
  27. package/dist/lib/task/artifact-recovery.js +114 -68
  28. package/dist/lib/task/artifact-repair-intent.js +25 -8
  29. package/dist/lib/task/capabilities.js +0 -2
  30. package/dist/lib/task/control-authority.js +1 -1
  31. package/dist/lib/task/events.js +66 -49
  32. package/dist/lib/task/gate-policy.js +94 -0
  33. package/dist/lib/task/intermediate-cleanup.js +28 -3
  34. package/dist/lib/task/local-artifact-finalization.js +85 -18
  35. package/dist/lib/task/orchestration.js +8 -6
  36. package/dist/lib/task/review-artifacts.js +12 -6
  37. package/dist/lib/task/review-command.js +2 -2
  38. package/dist/lib/task/review-finalization.js +198 -11
  39. package/dist/lib/{host-control → task}/secure-fs.js +8 -19
  40. package/dist/lib/task/verification-engine.js +28 -17
  41. package/dist/lib/task/verification.js +25 -9
  42. package/dist/lib/task/workflow-command.js +2 -0
  43. package/dist/lib/task/workflow-dispatch.js +161 -0
  44. package/dist/lifecycle-build-manifest.json +3 -3
  45. package/lib/internal/cli-route-inventory.ts +3 -4
  46. package/lib/internal/manual-validation.ts +6 -2
  47. package/lib/internal/platform-comment.ts +53 -2
  48. package/lib/internal/task-artifact.ts +1 -1
  49. package/lib/internal/task-finalization.ts +1 -1
  50. package/lib/internal/task-lifecycle.ts +1 -1
  51. package/lib/internal/task-operation-registry.ts +22 -12
  52. package/lib/internal/task-orchestration.ts +1 -1
  53. package/lib/internal/task-review.ts +9 -5
  54. package/lib/platform/activity-recovery.ts +179 -0
  55. package/lib/platform/issue-comments.ts +98 -31
  56. package/lib/platform/issues.ts +3 -4
  57. package/lib/platform/task-comment-projection.ts +79 -0
  58. package/lib/platform/task-recovery.ts +54 -0
  59. package/lib/platform/verification-sync.ts +203 -188
  60. package/lib/sandbox/control/executor.ts +4 -2
  61. package/lib/sandbox/control/protocol.ts +1 -1
  62. package/lib/sandbox/control/task-workflow.ts +1 -1
  63. package/lib/sandbox/control/workflow-executor.ts +113 -22
  64. package/lib/server/process-state.ts +26 -5
  65. package/lib/task/artifact-command.ts +6 -4
  66. package/lib/task/artifact-lifecycle.ts +12 -49
  67. package/lib/task/artifact-operations.ts +28 -2
  68. package/lib/task/artifact-recovery.ts +120 -67
  69. package/lib/task/artifact-repair-intent.ts +38 -11
  70. package/lib/task/capabilities.ts +0 -1
  71. package/lib/task/control-authority.ts +1 -1
  72. package/lib/task/events.ts +55 -38
  73. package/lib/task/gate-policy.ts +133 -0
  74. package/lib/task/intermediate-cleanup.ts +18 -2
  75. package/lib/task/local-artifact-finalization.ts +91 -15
  76. package/lib/task/orchestration.ts +12 -7
  77. package/lib/task/review-artifacts.ts +8 -2
  78. package/lib/task/review-command.ts +1 -1
  79. package/lib/task/review-finalization.ts +198 -9
  80. package/lib/{host-control → task}/secure-fs.ts +10 -32
  81. package/lib/task/verification-engine.ts +24 -14
  82. package/lib/task/verification-types.ts +7 -0
  83. package/lib/task/verification.ts +25 -9
  84. package/lib/task/workflow-command.ts +2 -0
  85. package/lib/task/workflow-dispatch.ts +171 -0
  86. package/package.json +1 -1
  87. package/templates/.agents/rules/issue-sync.en.md +11 -1
  88. package/templates/.agents/rules/issue-sync.zh-CN.md +6 -2
  89. package/templates/.agents/rules/local-artifact-repair.en.md +18 -9
  90. package/templates/.agents/rules/local-artifact-repair.zh-CN.md +21 -11
  91. package/templates/.agents/skills/analyze-task/SKILL.en.md +2 -2
  92. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +2 -2
  93. package/templates/.agents/skills/analyze-task/config/verify.en.json +1 -10
  94. package/templates/.agents/skills/analyze-task/config/verify.zh-CN.json +1 -10
  95. package/templates/.agents/skills/block-task/config/verify.json +1 -3
  96. package/templates/.agents/skills/cancel-task/config/verify.json +1 -4
  97. package/templates/.agents/skills/code-task/SKILL.en.md +4 -4
  98. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +5 -5
  99. package/templates/.agents/skills/code-task/config/verify.en.json +1 -12
  100. package/templates/.agents/skills/code-task/config/verify.zh-CN.json +1 -12
  101. package/templates/.agents/skills/code-task/reference/dual-mode.en.md +1 -1
  102. package/templates/.agents/skills/code-task/reference/dual-mode.zh-CN.md +1 -1
  103. package/templates/.agents/skills/code-task/reference/report-template.en.md +11 -0
  104. package/templates/.agents/skills/code-task/reference/report-template.zh-CN.md +11 -0
  105. package/templates/.agents/skills/commit/config/verify.json +1 -5
  106. package/templates/.agents/skills/complete-manual-validation/config/verify.en.json +6 -6
  107. package/templates/.agents/skills/complete-manual-validation/config/verify.zh-CN.json +6 -6
  108. package/templates/.agents/skills/complete-task/config/verify.en.json +3 -14
  109. package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +3 -14
  110. package/templates/.agents/skills/create-pr/config/verify.json +1 -10
  111. package/templates/.agents/skills/create-task/config/verify.json +1 -4
  112. package/templates/.agents/skills/import-issue/config/verify.json +1 -2
  113. package/templates/.agents/skills/plan-task/SKILL.en.md +2 -2
  114. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +2 -2
  115. package/templates/.agents/skills/plan-task/config/verify.en.json +1 -10
  116. package/templates/.agents/skills/plan-task/config/verify.zh-CN.json +1 -10
  117. package/templates/.agents/skills/restore-task/SKILL.en.md +9 -4
  118. package/templates/.agents/skills/restore-task/SKILL.zh-CN.md +10 -5
  119. package/templates/.agents/skills/review-analysis/SKILL.en.md +1 -1
  120. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +1 -1
  121. package/templates/.agents/skills/review-analysis/config/verify.en.json +1 -10
  122. package/templates/.agents/skills/review-analysis/config/verify.zh-CN.json +1 -10
  123. package/templates/.agents/skills/review-analysis/reference/report-template.en.md +2 -0
  124. package/templates/.agents/skills/review-analysis/reference/report-template.zh-CN.md +2 -0
  125. package/templates/.agents/skills/review-code/SKILL.en.md +3 -2
  126. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +3 -2
  127. package/templates/.agents/skills/review-code/config/verify.en.json +1 -10
  128. package/templates/.agents/skills/review-code/config/verify.zh-CN.json +1 -10
  129. package/templates/.agents/skills/review-code/reference/report-template.en.md +2 -0
  130. package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +2 -0
  131. package/templates/.agents/skills/review-plan/SKILL.en.md +1 -1
  132. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +1 -1
  133. package/templates/.agents/skills/review-plan/config/verify.en.json +1 -10
  134. package/templates/.agents/skills/review-plan/config/verify.zh-CN.json +1 -10
  135. package/templates/.agents/skills/review-plan/reference/report-template.en.md +2 -0
  136. package/templates/.agents/skills/review-plan/reference/report-template.zh-CN.md +2 -0
  137. package/templates/.agents/skills/review-pr/config/verify.en.json +1 -8
  138. package/templates/.agents/skills/review-pr/config/verify.zh-CN.json +1 -8
  139. package/templates/.agents/skills/run-manual-validation/config/verify.en.json +38 -13
  140. package/templates/.agents/skills/run-manual-validation/config/verify.zh-CN.json +38 -13
  141. package/dist/lib/host-control/audit.js +0 -19
  142. package/dist/lib/host-control/client.js +0 -142
  143. package/dist/lib/host-control/command.js +0 -49
  144. package/dist/lib/host-control/path.js +0 -160
  145. package/dist/lib/host-control/server.js +0 -159
  146. package/dist/lib/host-control/service.js +0 -112
  147. package/dist/lib/internal/host-control.js +0 -28
  148. package/lib/host-control/audit.ts +0 -22
  149. package/lib/host-control/client.ts +0 -172
  150. package/lib/host-control/command.ts +0 -60
  151. package/lib/host-control/path.ts +0 -167
  152. package/lib/host-control/server.ts +0 -180
  153. package/lib/host-control/service.ts +0 -133
  154. package/lib/internal/host-control.ts +0 -28
@@ -57,8 +57,7 @@ import { extractReviewBaseline, extractReviewDiffBase, extractReviewTargetHead,
57
57
  import { resolveDeliveryTarget, resolveDiffBase, resolveTargetHead } from './delivery-target.ts';
58
58
  import { buildLifecycleFacts, canStart, recommendNext } from './capabilities.ts';
59
59
  import { hasOpenLifecycleExecution } from './activity-log.ts';
60
- import { invalidationBlocks } from './invalidation.ts';
61
- import type { InvalidationDocument } from './invalidation.ts';
60
+ import { reconcileTaskInvalidation } from './invalidation-command.ts';
62
61
  import type { LifecycleAction, LifecycleFacts } from './capabilities.ts';
63
62
  import { normalizeAgentToken } from '../agent-clients/tokens.ts';
64
63
 
@@ -678,13 +677,11 @@ function routeOrchestration(taskRef: string, options: OrchestrationOptions = {})
678
677
  }
679
678
  let metadata: ReturnType<typeof parseTypedTaskFrontmatter>;
680
679
  let currentLedger: LedgerDocument | null = null;
681
- let currentInvalidation: InvalidationDocument = { operations: [], targets: [] };
682
680
  if (resolved.state === 'active') {
683
681
  const contract = validateCurrentTaskContract(content);
684
682
  if (!contract.ok) return failed('ORCHESTRATION_CURRENT_CONTRACT_INVALID', contract.message, resolved.taskId);
685
683
  metadata = contract.metadata;
686
684
  currentLedger = contract.ledger;
687
- currentInvalidation = contract.invalidation;
688
685
  } else {
689
686
  try {
690
687
  metadata = parseTypedTaskFrontmatter(content);
@@ -692,9 +689,6 @@ function routeOrchestration(taskRef: string, options: OrchestrationOptions = {})
692
689
  return failed('ORCHESTRATION_TASK_INVALID', error instanceof Error ? error.message : String(error), resolved.taskId);
693
690
  }
694
691
  }
695
- if (invalidationBlocks(currentInvalidation)) {
696
- return failed('ORCHESTRATION_INVALIDATION_INCOMPLETE', 'artifact invalidation is incomplete; reconcile task.md before routing downstream work', resolved.taskId);
697
- }
698
692
  let run: OrchestrationRun | null;
699
693
  try {
700
694
  run = readRun(resolved.taskDir, options);
@@ -711,6 +705,9 @@ function routeOrchestration(taskRef: string, options: OrchestrationOptions = {})
711
705
  const routed = routeFromFacts(facts.facts);
712
706
  if (!routed) return failed('ORCHESTRATION_ROUTE_UNKNOWN', 'cannot determine a unique lifecycle action', resolved.taskId);
713
707
  if ('completion' in routed) {
708
+ if (facts.facts.executionBusy && !run?.pendingDelegation) {
709
+ return failed('ORCHESTRATION_EXECUTION_BUSY', 'clean completion requires no open lifecycle execution', resolved.taskId);
710
+ }
714
711
  const reviewRound = maxArtifactRound(fs.readdirSync(resolved.taskDir), 'review-code');
715
712
  const review = parseReviewSummary(fs.readFileSync(
716
713
  path.join(resolved.taskDir, artifactName('review-code', reviewRound)),
@@ -847,6 +844,14 @@ function prepareOrchestrationDelegationUnlocked(
847
844
  return failed('ORCHESTRATION_DELEGATION_BUSY', 'the repository already has a pending lifecycle delegation', resolved.taskId);
848
845
  }
849
846
  if (run.stepCount >= run.maxSteps) return pauseOrchestration(taskRef, 'ORCHESTRATION_MAX_STEPS', 'maximum orchestration steps reached', true, options);
847
+ const invalidation = reconcileTaskInvalidation(taskRef, { repoRoot: resolved.repoRoot });
848
+ if (invalidation.status === 'failed') {
849
+ return failed(
850
+ 'ORCHESTRATION_INVALIDATION_RECONCILE_FAILED',
851
+ `${invalidation.error?.code ?? 'UNKNOWN'}: ${invalidation.error?.message ?? ''}`,
852
+ resolved.taskId
853
+ );
854
+ }
850
855
  const routed = routeOrchestration(taskRef, options);
851
856
  if (!routed.next) return routed;
852
857
  const next = routed.next;
@@ -176,19 +176,25 @@ function equalCounts(left: ReviewFindingCounts, right: ReviewFindingCounts): boo
176
176
 
177
177
  function finalizeReviewSummaryContent(
178
178
  content: string,
179
- counts: ReviewFindingCounts
179
+ counts: ReviewFindingCounts,
180
+ options: { refreshNumericCounts?: boolean } = {}
180
181
  ): ReviewSummaryFinalizeResult {
181
182
  const parsed = parseReviewSummary(content);
182
183
  if (!parsed.ok) return parsed;
183
184
  if (parsed.summary.countState === 'numeric') {
184
185
  if (equalCounts(parsed.summary.counts!, counts)) return { ok: true, changed: false, content };
185
- return {
186
+ if (!options.refreshNumericCounts) return {
186
187
  ok: false,
187
188
  code: 'REVIEW_SUMMARY_COUNT_MISMATCH',
188
189
  message: 'review summary finding counts do not match the ledger snapshot'
189
190
  };
190
191
  }
191
192
  let line = content.slice(parsed.findingsStart, parsed.findingsEnd);
193
+ if (parsed.summary.countState === 'numeric') {
194
+ const values = [counts.blocker, counts.major, counts.minor];
195
+ let ordinal = 0;
196
+ line = line.replace(/\d+(?=\s*(?:阻塞项|主要|次要|blockers?\b|majors?\b|minors?\b))/gi, () => String(values[ordinal++]));
197
+ }
192
198
  line = line
193
199
  .replace(PLACEHOLDERS[0], String(counts.blocker))
194
200
  .replace(PLACEHOLDERS[1], String(counts.major))
@@ -3,7 +3,7 @@ import type { ReviewFinalizationRequest } from './review-finalization.ts';
3
3
  export function parseReviewCommand(args: readonly string[]): ReviewFinalizationRequest & {
4
4
  overrideTicket?: string; overrideTarget?: string; overrideScope?: string;
5
5
  } {
6
- if (!args[0] || args[1] !== 'finalize-summary') throw new Error('task ref and finalize-summary are required');
6
+ if (!args[0] || !['preflight', 'finalize-summary'].includes(args[1] ?? '')) throw new Error('task ref and a supported review operation are required');
7
7
  const values: Record<string, string | boolean> = {};
8
8
  const flags = {
9
9
  '--stage': 'stage', '--artifact': 'artifact', '--orchestrated': 'orchestrated', '--dry-run': 'dryRun',
@@ -14,11 +14,14 @@ import { allowsManualOverride } from './guard-override.ts';
14
14
  import type { ManualOverrideCapability } from './guard-override.ts';
15
15
  import { getArtifactSchema } from './artifact-schema.ts';
16
16
  import { canonicalSemanticDigest, inspectArtifactContract, sha256Content } from './artifact-operations.ts';
17
+ import { expectedQualificationRelations, validateQualificationAudit } from './qualification-audit.ts';
17
18
  import { readArtifactRecoveryIntent } from './artifact-repair-intent.ts';
18
19
  import {
19
20
  beginArtifactRecovery,
20
21
  commitArtifactRecovery,
22
+ prepareArtifactRecoveryFinal,
21
23
  prepareArtifactRecoveryCommit,
24
+ reconcileArtifactRecovery,
22
25
  recoveryContextFromIntent,
23
26
  stageArtifactCandidate
24
27
  } from './artifact-recovery.ts';
@@ -36,6 +39,7 @@ type ReviewFinalizationErrorCode =
36
39
  | 'REVIEW_SUMMARY_PLACEHOLDER_INVALID'
37
40
  | 'REVIEW_SUMMARY_COUNT_MISMATCH'
38
41
  | 'REVIEW_ARTIFACT_STRUCTURE_INVALID'
42
+ | 'REVIEW_ARTIFACT_QUALIFICATION_INVALID'
39
43
  | 'REVIEW_DECISION_DETAIL_INVALID'
40
44
  | 'REVIEW_ARTIFACT_CONFLICT'
41
45
  | 'REVIEW_PROVENANCE_INVALID'
@@ -73,6 +77,11 @@ type ReviewFinalizationResult = {
73
77
  }[];
74
78
  error: ReviewFinalizationError | null;
75
79
  };
80
+ type ReviewPreflightResult = Omit<ReviewFinalizationResult, 'status' | 'intent' | 'stageStatus'> & {
81
+ status: 'passed' | 'failed';
82
+ intent: 'preflight';
83
+ stageStatus: null;
84
+ };
76
85
  type ReviewFileSystem = {
77
86
  readFileSync: (file: string) => string;
78
87
  };
@@ -118,6 +127,141 @@ function failed(
118
127
  };
119
128
  }
120
129
 
130
+ function preflightFailed(
131
+ request: ReviewFinalizationRequest,
132
+ code: ReviewFinalizationErrorCode,
133
+ message: string,
134
+ taskId: string | null = null,
135
+ extra: Partial<ReviewPreflightResult> = {}
136
+ ): ReviewPreflightResult {
137
+ const { status: _status, intent: _intent, stageStatus: _stageStatus, ...finalizationExtra } = extra;
138
+ return {
139
+ ...failed(request, code, message, taskId, null, finalizationExtra),
140
+ status: 'failed',
141
+ intent: 'preflight',
142
+ stageStatus: null
143
+ };
144
+ }
145
+
146
+ /** Validate and seal a review draft before any finding-ledger write. */
147
+ function preflightReviewSummaryUnlocked(
148
+ request: ReviewFinalizationRequest,
149
+ options: ReviewFinalizationOptions = {}
150
+ ): ReviewPreflightResult {
151
+ const stage = request.stage as ReviewStage;
152
+ const spec = STAGES[stage];
153
+ if (!spec) return preflightFailed(request, 'REVIEW_STAGE_INVALID', `unsupported review stage '${request.stage}'`);
154
+ if (!request.taskRef || !request.artifact) return preflightFailed(request, 'REVIEW_PAYLOAD_INVALID', 'taskRef, stage, and artifact are required');
155
+ const resolved = resolveTaskRef(request.taskRef, { repoRoot: options.repoRoot });
156
+ if (!resolved.ok) return preflightFailed(request, resolved.code, resolved.message, resolved.taskId ?? null);
157
+ if (resolved.state !== 'active') return preflightFailed(request, 'TASK_STATE_MISMATCH', `task ${resolved.taskId} is ${resolved.state}, expected active`, resolved.taskId);
158
+ const parsed = parseArtifactName(request.artifact);
159
+ if (!parsed || parsed.family !== spec.family) return preflightFailed(request, 'REVIEW_ARTIFACT_IDENTITY_INVALID', `artifact '${request.artifact}' does not match ${spec.family}`, resolved.taskId);
160
+ const publicationError = validateArtifactPublication(resolved.taskDir, spec.family, request.artifact);
161
+ if (publicationError) return preflightFailed(request, 'REVIEW_ARTIFACT_IDENTITY_INVALID', publicationError.message, resolved.taskId);
162
+
163
+ let taskContent: string;
164
+ let content: string;
165
+ let recovery: ArtifactRecoveryContext | undefined;
166
+ try {
167
+ taskContent = fs.readFileSync(resolved.taskMdPath, 'utf8');
168
+ if (request.recoveryId) {
169
+ const intent = readArtifactRecoveryIntent(resolved.repoRoot, resolved.taskId, spec.family, request.artifact);
170
+ if (!intent || intent.recoveryOperationId !== request.recoveryId) return preflightFailed(request, 'REVIEW_PROVENANCE_INVALID', 'recovery id does not match the artifact journal', resolved.taskId);
171
+ if (!['awaiting-preflight-recovery', 'preflight-ready'].includes(intent.state)) return preflightFailed(request, 'REVIEW_PROVENANCE_INVALID', `recovery journal is in '${intent.state}' state`, resolved.taskId);
172
+ recovery = recoveryContextFromIntent(resolved.repoRoot, resolved.taskDir, intent);
173
+ content = fs.existsSync(recovery.stagingPath)
174
+ ? fs.readFileSync(recovery.stagingPath, 'utf8')
175
+ : fs.readFileSync(recovery.formalPath, 'utf8');
176
+ } else {
177
+ const validated = validateCompletedArtifact(resolved.taskDir, spec.family, request.artifact);
178
+ 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);
179
+ content = fs.readFileSync(validated.artifact.path, 'utf8');
180
+ const intent = readArtifactRecoveryIntent(resolved.repoRoot, resolved.taskId, spec.family, request.artifact);
181
+ if (intent?.state === 'preflight-ready') recovery = recoveryContextFromIntent(resolved.repoRoot, resolved.taskDir, intent);
182
+ if (intent?.state === 'consumed') return preflightFailed(request, 'REVIEW_PROVENANCE_INVALID', 'review artifact was already finalized', resolved.taskId);
183
+ }
184
+ } catch (error) { return preflightFailed(request, 'REVIEW_ARTIFACT_NOT_REGULAR', String(error), resolved.taskId); }
185
+
186
+ if (!hasOpenArtifactRound(taskContent, spec.family, parsed.round)) return preflightFailed(request, 'REVIEW_ARTIFACT_IDENTITY_INVALID', `${request.artifact} does not have one matching open started review event`, resolved.taskId);
187
+ const execution = validateLifecycleExecution(request.taskRef, {
188
+ mode: request.orchestrated ? 'orchestrated' : 'standalone',
189
+ identity: { stage: spec.family, round: parsed.round, artifact: request.artifact, role: 'reviewer' },
190
+ dryRun: request.dryRun
191
+ }, { repoRoot: options.repoRoot });
192
+ if (!execution.ok) return preflightFailed(request, 'REVIEW_PROVENANCE_INVALID', `${execution.error?.code ?? 'ORCHESTRATION_PROVENANCE_MISMATCH'}: ${execution.error?.message ?? 'orchestration provenance validation failed'}`, resolved.taskId);
193
+
194
+ const artifactSha256 = sha256Content(content);
195
+ const semanticDigest = canonicalSemanticDigest(content);
196
+ const detail = inspectDecisionDetailDuplicates(content);
197
+ const structure = inspectArtifactContract(content, getArtifactSchema(spec.family)!);
198
+ const expected = expectedQualificationRelations(taskContent, spec.family);
199
+ if (!expected.ok) return preflightFailed(request, 'REVIEW_ARTIFACT_QUALIFICATION_INVALID', `${expected.code}: ${expected.message}`, resolved.taskId, { artifactSha256, semanticDigest });
200
+ const qualification = validateQualificationAudit(taskContent, content, {
201
+ family: spec.family,
202
+ artifact: request.artifact,
203
+ expectedUpstreamRelations: expected.relations
204
+ });
205
+ const qualificationError = qualification.ok ? null : qualification;
206
+ if (!detail.ok || !structure.ok || qualificationError) {
207
+ if (!recovery && !request.dryRun) {
208
+ try {
209
+ recovery = beginArtifactRecovery({
210
+ taskId: resolved.taskId, family: spec.family, artifact: request.artifact, round: parsed.round,
211
+ requestId: `review-preflight:${resolved.taskId}:${stage}:${parsed.round}`
212
+ }, Buffer.from(content, 'utf8'), { repoRoot: resolved.repoRoot, taskDir: resolved.taskDir, lockAlreadyHeld: options.lockAlreadyHeld });
213
+ } catch (error) { return preflightFailed(request, 'REVIEW_PROVENANCE_INVALID', String(error), resolved.taskId); }
214
+ }
215
+ const diagnostics = [
216
+ ...(!detail.ok ? [`${detail.code}: ${detail.message}`] : []),
217
+ ...structure.diagnostics.map((item) => `${item.code}: ${item.message}`),
218
+ ...(qualificationError ? [`${qualificationError.code}: ${qualificationError.message}`] : [])
219
+ ].join('; ');
220
+ return preflightFailed(request, !detail.ok ? 'REVIEW_DECISION_DETAIL_INVALID' : !structure.ok ? 'REVIEW_ARTIFACT_STRUCTURE_INVALID' : 'REVIEW_ARTIFACT_QUALIFICATION_INVALID', diagnostics, resolved.taskId, {
221
+ artifactSha256, semanticDigest,
222
+ ...(recovery ? { recovery: recoveryInfo(recovery) } : {})
223
+ });
224
+ }
225
+ if (request.dryRun || recovery && readArtifactRecoveryIntent(resolved.repoRoot, resolved.taskId, spec.family, request.artifact)?.state === 'preflight-ready') {
226
+ return {
227
+ ...preflightFailed(request, 'REVIEW_ARTIFACT_CONFLICT', '', resolved.taskId, { artifactSha256, semanticDigest, ...(recovery ? { recovery: recoveryInfo(recovery) } : {}) }),
228
+ status: 'passed', changed: false, error: null
229
+ };
230
+ }
231
+ try {
232
+ recovery ??= beginArtifactRecovery({
233
+ taskId: resolved.taskId, family: spec.family, artifact: request.artifact, round: parsed.round,
234
+ requestId: `review-preflight:${resolved.taskId}:${stage}:${parsed.round}`
235
+ }, Buffer.from(content, 'utf8'), { repoRoot: resolved.repoRoot, taskDir: resolved.taskDir, lockAlreadyHeld: options.lockAlreadyHeld });
236
+ const staged = stageArtifactCandidate(recovery, Buffer.from(content, 'utf8'), { lockAlreadyHeld: options.lockAlreadyHeld });
237
+ prepareArtifactRecoveryCommit(recovery, staged.candidateSha256, staged.semanticDigest, { lockAlreadyHeld: options.lockAlreadyHeld });
238
+ return {
239
+ ...preflightFailed(request, 'REVIEW_ARTIFACT_CONFLICT', '', resolved.taskId, { artifactSha256, semanticDigest, recovery: recoveryInfo(recovery) }),
240
+ status: 'passed', changed: false, error: null
241
+ };
242
+ } catch (error) { return preflightFailed(request, 'REVIEW_PROVENANCE_INVALID', String(error), resolved.taskId, { artifactSha256, semanticDigest }); }
243
+ }
244
+
245
+ function preflightReviewSummary(
246
+ request: ReviewFinalizationRequest,
247
+ options: ReviewFinalizationOptions = {}
248
+ ): ReviewPreflightResult {
249
+ if (request.dryRun || options.lockAlreadyHeld) return preflightReviewSummaryUnlocked(request, options);
250
+ const resolved = resolveTaskRef(request.taskRef, { repoRoot: options.repoRoot });
251
+ if (!resolved.ok) return preflightReviewSummaryUnlocked(request, options);
252
+ try {
253
+ return withTaskExecutionLock(
254
+ resolved.repoRoot,
255
+ resolved.taskId,
256
+ 'task-review.preflight',
257
+ () => preflightReviewSummaryUnlocked(request, { ...options, lockAlreadyHeld: true })
258
+ );
259
+ } catch (error) {
260
+ if (!(error instanceof TaskExecutionLockError)) throw error;
261
+ return preflightFailed(request, 'REVIEW_PROVENANCE_INVALID', `${error.code}: ${error.message}`, resolved.taskId);
262
+ }
263
+ }
264
+
121
265
  type ReviewSummaryCandidatePreparation = Readonly<{
122
266
  result: ReviewFinalizationResult;
123
267
  content: string;
@@ -125,6 +269,15 @@ type ReviewSummaryCandidatePreparation = Readonly<{
125
269
  lockAlreadyHeld?: boolean;
126
270
  }>;
127
271
 
272
+ function recoveryInfo(recovery: ArtifactRecoveryContext): NonNullable<ReviewFinalizationResult['recovery']> {
273
+ return {
274
+ recoveryId: recovery.recoveryId,
275
+ candidatePath: recovery.stagingPath,
276
+ baselineSha256: recovery.baselineSha256,
277
+ baselineSemanticDigest: recovery.baselineSemanticDigest
278
+ };
279
+ }
280
+
128
281
  /** Prepare domain validation, summary bytes and provenance before any publication. */
129
282
  function prepareReviewSummaryCandidate(
130
283
  request: ReviewFinalizationRequest,
@@ -158,7 +311,7 @@ function prepareReviewSummaryCandidate(
158
311
  try {
159
312
  const intent = readArtifactRecoveryIntent(resolved.repoRoot, taskId!, spec.family, request.artifact);
160
313
  if (!intent || intent.recoveryOperationId !== request.recoveryId) return reject('REVIEW_PROVENANCE_INVALID', 'recovery id does not match the artifact journal');
161
- if (!['awaiting-recovery', 'finalize-ready', 'commit-started'].includes(intent.state)) return reject('REVIEW_PROVENANCE_INVALID', `recovery journal is in '${intent.state}' state`);
314
+ if (!['awaiting-preflight-recovery', 'preflight-ready', 'preflight-commit-started', 'preflight-passed', 'full-finalizer-ready', 'commit-started'].includes(intent.state)) return reject('REVIEW_PROVENANCE_INVALID', `recovery journal is in '${intent.state}' state`);
162
315
  recovery = recoveryContextFromIntent(resolved.repoRoot, resolved.taskDir, intent);
163
316
  artifactContent = fs.existsSync(recovery.stagingPath)
164
317
  ? fs.readFileSync(recovery.stagingPath, 'utf8')
@@ -171,11 +324,15 @@ function prepareReviewSummaryCandidate(
171
324
  let repairIntent;
172
325
  try { repairIntent = readArtifactRecoveryIntent(resolved.repoRoot, taskId!, spec.family, request.artifact); }
173
326
  catch (error) { return reject('REVIEW_PROVENANCE_INVALID', String(error), digests); }
174
- if (repairIntent?.state === 'passed' || repairIntent?.state === 'consumed') {
327
+ if (repairIntent?.state === 'consumed') {
175
328
  if (repairIntent.finalArtifactSha256 !== artifactSha256 || repairIntent.finalSemanticDigest !== semanticDigest) {
176
329
  return reject('REVIEW_PROVENANCE_INVALID', 'the review artifact changed after its finalization provenance was recorded', digests);
177
330
  }
178
331
  }
332
+ if (repairIntent?.state === 'passed'
333
+ && (repairIntent.finalArtifactSha256 !== artifactSha256 || repairIntent.finalSemanticDigest !== semanticDigest)) {
334
+ repairIntent = undefined;
335
+ }
179
336
  if (!hasOpenArtifactRound(taskContent, spec.family, parsed.round)) {
180
337
  return reject('REVIEW_ARTIFACT_IDENTITY_INVALID', `${request.artifact} does not have one matching open started review event`);
181
338
  }
@@ -220,9 +377,17 @@ function prepareReviewSummaryCandidate(
220
377
  ...(recovery ? { recovery } : {})
221
378
  };
222
379
  }
223
- const transformed = finalizeReviewSummaryContent(artifactContent, stageStatus.unresolvedFindingCounts);
380
+ const transformed = finalizeReviewSummaryContent(artifactContent, stageStatus.unresolvedFindingCounts, {
381
+ refreshNumericCounts: Boolean(request.recoveryId && recovery)
382
+ });
224
383
  if (!transformed.ok) return reject(transformed.code, transformed.message);
225
384
  const finalDigests = { artifactSha256: sha256Content(transformed.content), semanticDigest: canonicalSemanticDigest(transformed.content) };
385
+ if (!recovery && repairIntent?.state === 'commit-started') {
386
+ if (repairIntent.finalArtifactSha256 !== finalDigests.artifactSha256 || repairIntent.finalSemanticDigest !== finalDigests.semanticDigest) {
387
+ return reject('REVIEW_PROVENANCE_INVALID', 'formal review artifact does not match the interrupted recovery journal', finalDigests);
388
+ }
389
+ recovery = recoveryContextFromIntent(resolved.repoRoot, resolved.taskDir, repairIntent);
390
+ }
226
391
  if (!recovery && (repairIntent?.state === 'passed' || repairIntent?.state === 'consumed')) {
227
392
  if (transformed.changed) return reject('REVIEW_PROVENANCE_INVALID', 'the review artifact requires changes after its completed recovery was consumed', finalDigests);
228
393
  return {
@@ -260,21 +425,43 @@ function prepareReviewSummaryCandidate(
260
425
 
261
426
  function commitReviewSummaryProvenance(
262
427
  prepared: ReviewSummaryCandidatePreparation,
263
- repoRoot: string
428
+ repoRoot: string,
429
+ options: Readonly<{ afterPublish?: () => void }> = {}
264
430
  ): ReviewFinalizationResult {
265
431
  if (prepared.result.status === 'failed' || !prepared.recovery) return prepared.result;
266
432
  try {
267
433
  const intent = readArtifactRecoveryIntent(prepared.recovery.repoRoot, prepared.recovery.taskId, prepared.recovery.family, prepared.recovery.artifact);
268
434
  if (!intent) throw new Error('ARTIFACT_RECOVERY_INTENT_MISSING');
269
435
  let committed;
270
- if (intent.state === 'finalize-ready' || intent.state === 'commit-started') {
271
- committed = commitArtifactRecovery(prepared.recovery, { lockAlreadyHeld: prepared.lockAlreadyHeld });
272
- } else {
436
+ if (intent.state === 'passed' || intent.state === 'consumed') {
437
+ return {
438
+ ...prepared.result,
439
+ status: 'no-op',
440
+ changed: false,
441
+ artifactSha256: intent.finalArtifactSha256,
442
+ semanticDigest: intent.finalSemanticDigest,
443
+ error: null
444
+ };
445
+ } else if (intent.state === 'commit-started') {
446
+ const reconciled = reconcileArtifactRecovery(prepared.recovery, {
447
+ lockAlreadyHeld: prepared.lockAlreadyHeld,
448
+ afterPublish: options.afterPublish
449
+ });
450
+ committed = reconciled.intent;
451
+ } else if (intent.state === 'awaiting-preflight-recovery') {
273
452
  const staged = stageArtifactCandidate(prepared.recovery, Buffer.from(prepared.content, 'utf8'), { lockAlreadyHeld: prepared.lockAlreadyHeld });
274
453
  prepareArtifactRecoveryCommit(prepared.recovery, staged.candidateSha256, staged.semanticDigest, { lockAlreadyHeld: prepared.lockAlreadyHeld });
275
454
  committed = commitArtifactRecovery(prepared.recovery, { lockAlreadyHeld: prepared.lockAlreadyHeld });
455
+ } else if (intent.state === 'preflight-ready' || intent.state === 'preflight-commit-started') {
456
+ committed = commitArtifactRecovery(prepared.recovery, { lockAlreadyHeld: prepared.lockAlreadyHeld });
457
+ }
458
+ if (committed?.state === 'preflight-passed' || intent.state === 'preflight-passed') {
459
+ prepareArtifactRecoveryFinal(prepared.recovery, Buffer.from(prepared.content, 'utf8'), { lockAlreadyHeld: prepared.lockAlreadyHeld });
460
+ committed = commitArtifactRecovery(prepared.recovery, { lockAlreadyHeld: prepared.lockAlreadyHeld, afterPublish: options.afterPublish });
461
+ } else if (intent.state === 'full-finalizer-ready') {
462
+ committed = commitArtifactRecovery(prepared.recovery, { lockAlreadyHeld: prepared.lockAlreadyHeld, afterPublish: options.afterPublish });
276
463
  }
277
- if (committed.state !== 'passed') throw new Error(`ARTIFACT_RECOVERY_STATE_INVALID: commit ended in '${committed.state}'`);
464
+ if (!committed || committed.state !== 'passed') throw new Error(`ARTIFACT_RECOVERY_STATE_INVALID: commit ended in '${committed?.state ?? intent.state}'`);
278
465
  return {
279
466
  ...prepared.result,
280
467
  artifactSha256: committed.finalArtifactSha256,
@@ -282,6 +469,7 @@ function commitReviewSummaryProvenance(
282
469
  error: null
283
470
  };
284
471
  } catch (error) {
472
+ if (error instanceof Error && error.message.startsWith('TASK_WORKFLOW_FAULT_INJECTED:')) throw error;
285
473
  return { ...prepared.result, status: 'failed', error: {
286
474
  code: 'REVIEW_RECOVERY_COMMIT_FAILED', message: `cannot publish review candidate: ${String(error)}`
287
475
  } };
@@ -335,12 +523,13 @@ function finalizeReviewSummary(
335
523
  }
336
524
  }
337
525
 
338
- export { finalizeReviewSummary, prepareReviewSummaryCandidate, commitReviewSummaryProvenance };
526
+ export { finalizeReviewSummary, preflightReviewSummary, prepareReviewSummaryCandidate, commitReviewSummaryProvenance };
339
527
  export type {
340
528
  ReviewFinalizationError,
341
529
  ReviewFinalizationErrorCode,
342
530
  ReviewFinalizationOptions,
343
531
  ReviewFinalizationRequest,
344
532
  ReviewFinalizationResult,
533
+ ReviewPreflightResult,
345
534
  ReviewSummaryCandidatePreparation
346
535
  };
@@ -1,6 +1,6 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import fs from 'node:fs';
2
3
  import path from 'node:path';
3
- import { createHash } from 'node:crypto';
4
4
 
5
5
  const NO_FOLLOW = fs.constants.O_NOFOLLOW ?? 0;
6
6
  const DIRECTORY = fs.constants.O_DIRECTORY ?? 0;
@@ -25,11 +25,8 @@ function conflict(message: string): never {
25
25
  }
26
26
 
27
27
  function sameStat(left: fs.BigIntStats, right: fs.BigIntStats): boolean {
28
- return left.dev === right.dev
29
- && left.ino === right.ino
30
- && left.size === right.size
31
- && left.mtimeNs === right.mtimeNs
32
- && left.ctimeNs === right.ctimeNs;
28
+ return left.dev === right.dev && left.ino === right.ino && left.size === right.size
29
+ && left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
33
30
  }
34
31
 
35
32
  function assertNoSymlinkAncestors(candidate: string): void {
@@ -43,25 +40,15 @@ function assertNoSymlinkAncestors(candidate: string): void {
43
40
  }
44
41
  }
45
42
 
46
- export async function readStableFile(
47
- candidate: string,
48
- options: Readonly<{ maxBytes: number; expectedSha256?: string }>
49
- ): Promise<StableFile> {
43
+ export async function readStableFile(candidate: string, options: Readonly<{ maxBytes: number; expectedSha256?: string }>): Promise<StableFile> {
50
44
  return readStableFileSync(candidate, options);
51
45
  }
52
46
 
53
- /** Shared bounded reader for synchronous domain commands and async workflow callers. */
54
- export function readStableFileSync(
55
- candidate: string,
56
- options: Readonly<{ maxBytes: number; expectedSha256?: string }>
57
- ): StableFile {
58
- if (!path.isAbsolute(candidate)) {
59
- conflict('candidate path must be absolute and terminal');
60
- }
47
+ export function readStableFileSync(candidate: string, options: Readonly<{ maxBytes: number; expectedSha256?: string }>): StableFile {
48
+ if (!path.isAbsolute(candidate)) conflict('candidate path must be absolute and terminal');
61
49
  assertNoSymlinkAncestors(candidate);
62
50
  let descriptor: number | undefined;
63
51
  try {
64
- // O_NONBLOCK lets fstat reject a FIFO even when it has no writer.
65
52
  descriptor = fs.openSync(candidate, fs.constants.O_RDONLY | NO_FOLLOW | (fs.constants.O_NONBLOCK ?? 0));
66
53
  const before = fs.fstatSync(descriptor, { bigint: true });
67
54
  if (!before.isFile()) conflict('candidate is not a regular file');
@@ -76,16 +63,12 @@ export function readStableFileSync(
76
63
  const after = fs.fstatSync(descriptor, { bigint: true });
77
64
  if (!sameStat(before, after)) conflict('candidate identity or metadata changed during read');
78
65
  const sha256 = createHash('sha256').update(bytes).digest('hex');
79
- if (options.expectedSha256 !== undefined && sha256 !== options.expectedSha256) {
80
- conflict('candidate digest does not match the expected digest');
81
- }
66
+ if (options.expectedSha256 !== undefined && sha256 !== options.expectedSha256) conflict('candidate digest does not match the expected digest');
82
67
  return { bytes, sha256, stat: after };
83
68
  } catch (error) {
84
69
  if (error instanceof SecureFileError) throw error;
85
70
  const code = (error as NodeJS.ErrnoException).code;
86
- if (code === 'ELOOP' || code === 'ENOTSUP' || code === 'EINVAL') {
87
- conflict('candidate terminal is not a stable non-symlink file');
88
- }
71
+ if (code === 'ELOOP' || code === 'ENOTSUP' || code === 'EINVAL') conflict('candidate terminal is not a stable non-symlink file');
89
72
  throw new SecureFileError('TASK_ARTIFACT_WRITE_CONFLICT', error instanceof Error ? error.message : String(error));
90
73
  } finally {
91
74
  if (descriptor !== undefined) fs.closeSync(descriptor);
@@ -99,18 +82,14 @@ export async function writeAtomicFile(target: string, bytes: Buffer, mode = 0o60
99
82
  let stat: fs.Stats;
100
83
  try { stat = await fs.promises.lstat(current); }
101
84
  catch { throw new SecureFileError('TASK_ARTIFACT_WRITE_CONFLICT', 'authoritative parent directory is missing'); }
102
- if (!stat.isDirectory() || stat.isSymbolicLink()) {
103
- throw new SecureFileError('TASK_ARTIFACT_WRITE_CONFLICT', 'authoritative parent directory is not a real directory');
104
- }
85
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new SecureFileError('TASK_ARTIFACT_WRITE_CONFLICT', 'authoritative parent directory is not a real directory');
105
86
  const next = path.dirname(current);
106
87
  if (next === current) break;
107
88
  current = next;
108
89
  }
109
90
  try {
110
91
  const existing = await fs.promises.lstat(target);
111
- if (existing.isSymbolicLink() || !existing.isFile()) {
112
- throw new SecureFileError('TASK_ARTIFACT_WRITE_CONFLICT', 'authoritative target is not a regular file');
113
- }
92
+ if (existing.isSymbolicLink() || !existing.isFile()) throw new SecureFileError('TASK_ARTIFACT_WRITE_CONFLICT', 'authoritative target is not a regular file');
114
93
  } catch (error) {
115
94
  if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
116
95
  }
@@ -123,7 +102,6 @@ export async function writeAtomicFile(target: string, bytes: Buffer, mode = 0o60
123
102
  await handle.chmod(mode);
124
103
  await handle.close();
125
104
  handle = undefined;
126
- // Check after all asynchronous preparation; keep the final check/rename adjacent.
127
105
  if (expectedSha256 !== undefined) readStableFileSync(target, { maxBytes: 1024 * 1024, expectedSha256 });
128
106
  fs.renameSync(temporary, target);
129
107
  const directory = await fs.promises.open(parent, fs.constants.O_RDONLY | DIRECTORY);
@@ -44,6 +44,7 @@ import { validateQualificationAudit } from "./qualification-audit.ts";
44
44
  import { getArtifactSchema } from "./artifact-schema.ts";
45
45
  import { inspectArtifactContract } from "./artifact-operations.ts";
46
46
  import type { VerificationShared } from "./verification-types.ts";
47
+ import { normalizeVerificationRecord } from './gate-policy.ts';
47
48
  import { manualValidationFinalSummaryProjectionMatches } from "./manual-validation-receipt.ts";
48
49
  import { readManualValidationCompletion } from "./manual-validation-completion.ts";
49
50
  import { sha256File } from "./artifact-receipts.ts";
@@ -1392,11 +1393,11 @@ function interpolate(template: any, taskDir: any, artifactFile: any): any {
1392
1393
  }
1393
1394
 
1394
1395
  function summarizeGate(checks: any): any {
1395
- if (checks.some((check: any) => check.status === "blocked")) {
1396
+ if (checks.some((check: any) => check.effectiveStatus === "blocked")) {
1396
1397
  return "blocked";
1397
1398
  }
1398
1399
 
1399
- if (checks.some((check: any) => check.status === "fail")) {
1400
+ if (checks.some((check: any) => check.effectiveStatus === "fail")) {
1400
1401
  return "fail";
1401
1402
  }
1402
1403
 
@@ -1405,9 +1406,9 @@ function summarizeGate(checks: any): any {
1405
1406
 
1406
1407
  function summarizeChecks(checks: any): any {
1407
1408
  const counts = {
1408
- pass: checks.filter((check: any) => check.status === "pass").length,
1409
- fail: checks.filter((check: any) => check.status === "fail").length,
1410
- blocked: checks.filter((check: any) => check.status === "blocked").length
1409
+ pass: checks.filter((check: any) => check.effectiveStatus === "pass").length,
1410
+ fail: checks.filter((check: any) => check.effectiveStatus === "fail").length,
1411
+ blocked: checks.filter((check: any) => check.effectiveStatus === "blocked").length
1411
1412
  };
1412
1413
 
1413
1414
  if (counts.blocked > 0) {
@@ -1422,7 +1423,7 @@ function buildAction(gate: any, checks: any): any {
1422
1423
  return "All declared checks passed";
1423
1424
  }
1424
1425
 
1425
- const firstFailure = checks.find((check: any) => check.status !== "pass");
1426
+ const firstFailure = checks.find((check: any) => check.effectiveStatus !== "pass");
1426
1427
  if (!firstFailure) {
1427
1428
  return "Review validation output";
1428
1429
  }
@@ -1484,16 +1485,16 @@ async function verifyInProcess({ mode, skillName, taskDir, artifactFile, checks:
1484
1485
  const checks = [];
1485
1486
  for (const [type, checkConfig] of Object.entries(verifyConfig.checks || {})) {
1486
1487
  if (checkConfig === null) continue;
1487
- const result = await runCheck(type, {
1488
+ const result = normalizeVerificationRecord(await runCheck(type, {
1488
1489
  mode,
1489
1490
  skillName,
1490
1491
  taskDir: path.resolve(taskDir),
1491
1492
  artifactFile,
1492
1493
  repositoryRoot: repoRoot,
1493
1494
  config: checkConfig
1494
- }, shared);
1495
- checks.push(result);
1496
- if (result.status === "blocked") break;
1495
+ }, shared));
1496
+ checks.push(...(result.subchecks?.map(normalizeVerificationRecord) ?? [result]));
1497
+ if (checks.at(-1)?.effectiveStatus === "blocked") break;
1497
1498
  }
1498
1499
  const gate = summarizeGate(checks);
1499
1500
  return { gate, skill: skillName, checks, summary: summarizeChecks(checks), action: buildAction(gate, checks) };
@@ -1502,15 +1503,24 @@ async function verifyInProcess({ mode, skillName, taskDir, artifactFile, checks:
1502
1503
  const config = (verifyConfig.checks || {})[type];
1503
1504
  if (config === undefined) return { skill: skillName, ...failResult(type, `Unknown check type '${type}' for skill '${skillName}'.`) };
1504
1505
  if (config === null) return { skill: skillName, ...passResult(type, `Check '${type}' is disabled for skill '${skillName}'.`) };
1505
- return {
1506
- skill: skillName,
1507
- ...await runCheck(type, {
1506
+ const result = normalizeVerificationRecord(await runCheck(type, {
1508
1507
  skillName,
1509
1508
  taskDir: path.resolve(taskDir),
1510
1509
  artifactFile,
1511
1510
  repositoryRoot: repoRoot,
1512
1511
  config
1513
- }, shared)
1512
+ }, shared));
1513
+ if (!result.subchecks?.length) return { skill: skillName, ...result };
1514
+ const checks = result.subchecks.map(normalizeVerificationRecord);
1515
+ const gate = summarizeGate(checks);
1516
+ return {
1517
+ skill: skillName,
1518
+ ...result,
1519
+ status: gate,
1520
+ effectiveStatus: gate,
1521
+ reason: gate === 'pass' ? 'OK' : checks.find((check: any) => check.effectiveStatus !== 'pass')?.reason ?? 'CHECK_FAILED',
1522
+ action: buildAction(gate, checks),
1523
+ checks
1514
1524
  };
1515
1525
  }
1516
1526
 
@@ -20,6 +20,13 @@ type VerificationCheckResult = {
20
20
  message: string;
21
21
  fail_type?: string;
22
22
  warnings?: string[];
23
+ checkId?: string;
24
+ classification?: 'hard' | 'soft' | 'info';
25
+ effectiveStatus?: VerificationStatus;
26
+ reason?: string;
27
+ action?: string;
28
+ dependsOn?: string;
29
+ subchecks?: VerificationCheckResult[];
23
30
  };
24
31
  type VerificationContext = {
25
32
  skillName: string;