@feng0531/dsh-code-review 0.86.0 → 0.87.0

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 (39) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/CORE-ALIGNMENT-TASKS.md +19 -4
  3. package/CORE-SOURCE-INVENTORY.md +6 -6
  4. package/DEVELOPMENT.md +15 -1
  5. package/README.md +22 -5
  6. package/RELEASE-NOTES.md +14 -14
  7. package/index.mjs +1 -1
  8. package/package.json +1 -1
  9. package/src/background-review.mjs +36 -0
  10. package/src/candidate-verification.mjs +1 -1
  11. package/src/coverage-plan.mjs +20 -6
  12. package/src/cpp-migration-bindings.mjs +149 -0
  13. package/src/evidence-context.mjs +96 -0
  14. package/src/group-interaction.mjs +48 -0
  15. package/src/group-primary.mjs +14 -3
  16. package/src/interaction-risk.mjs +71 -0
  17. package/src/migration-contracts.mjs +160 -0
  18. package/src/migration-regression.mjs +103 -0
  19. package/src/model-contract.mjs +3 -3
  20. package/src/model-tools.mjs +6 -4
  21. package/src/primary-seeds.mjs +6 -2
  22. package/src/project-review-service.mjs +2 -2
  23. package/src/prompts.mjs +1 -0
  24. package/src/regression-assessment.mjs +8 -2
  25. package/src/report-summary.mjs +59 -0
  26. package/src/retrieval-loop.mjs +17 -4
  27. package/src/review-artifacts.mjs +1 -1
  28. package/src/review-diagnostics.mjs +2 -1
  29. package/src/review-jobs.mjs +1 -1
  30. package/src/review-reader-allocation.mjs +117 -0
  31. package/src/review-reader-policy.mjs +1 -1
  32. package/src/review-resume.mjs +67 -2
  33. package/src/review-stage-state.mjs +143 -0
  34. package/src/review.mjs +221 -52
  35. package/src/service.mjs +8 -8
  36. package/src/snapshot-reader.mjs +62 -22
  37. package/src/stage-continuation.mjs +74 -0
  38. package/src/verification-loop.mjs +9 -4
  39. package/src/verification-seeds.mjs +18 -1
package/src/review.mjs CHANGED
@@ -19,7 +19,7 @@ import { inferFileRelations } from './file-relations.mjs';
19
19
  import { buildReviewGroups } from './review-groups.mjs';
20
20
  import { changeMap } from './change-map.mjs';
21
21
  import { createRetrievalScope } from './retrieval-scope.mjs';
22
- import { retrievalLoop } from './retrieval-loop.mjs';
22
+ import { retrievalLoop, retrievalRequest } from './retrieval-loop.mjs';
23
23
  import { reviewDiagnostics, diagnosticsMarkdown } from './review-diagnostics.mjs';
24
24
  import { preparePrimaryInput } from './primary-input.mjs';
25
25
  import { validateAttribution } from './attribution.mjs';
@@ -29,8 +29,16 @@ import { changesConcurrency, retrievalRounds } from './review-execution-policy.m
29
29
  import { scheduleFileChains } from './file-chain-scheduler.mjs';
30
30
  import { reviewReaderBudget, readerAdmission } from './review-reader-policy.mjs';
31
31
  import { primarySeeds } from './primary-seeds.mjs';
32
+ import { createReviewReaderAllocation } from './review-reader-allocation.mjs';
32
33
  import { prepareGroupPrimary, validateGroupPrimary } from './group-primary.mjs';
33
34
  import { configureWireBudget, modelWireBudget } from './model-wire-budget.mjs';
35
+ import { createEvidenceContext } from './evidence-context.mjs';
36
+ import { prepareInteractionRisk, consumeInteractionRisks } from './interaction-risk.mjs';
37
+ import { reportSummaryMarkdown } from './report-summary.mjs';
38
+ import { validateGroupInteraction } from './group-interaction.mjs';
39
+ import { stageKey, prepareStageGenerations } from './review-stage-state.mjs';
40
+ import { createStageContinuation } from './stage-continuation.mjs';
41
+ import { buildMigrationContracts } from './migration-contracts.mjs';
34
42
 
35
43
  export { REVIEW_INSTRUCTIONS };
36
44
  export { PROJECT_REVIEW_INSTRUCTIONS };
@@ -123,8 +131,10 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
123
131
  }
124
132
  if (options.enableBusinessGrouping === true && options.enableRetrieval !== true) throw new Error('Business grouping requires approved retrieval scope');
125
133
  if (options.enableVerification === true && options.enableRetrieval !== true) throw new Error('Verification requires approved retrieval scope');
134
+ for (const key of ['enableEvidenceContext', 'inlineInteractionRisks', 'enableMigrationContracts', 'enableMigrationRegression', 'enableGroupInteractions', 'enableStageContinuation']) if (options[key] !== undefined && typeof options[key] !== 'boolean') throw new Error('Invalid review optimization option');
135
+ const evidenceContext = options.enableEvidenceContext === true ? createEvidenceContext(input.id) : null;
126
136
  const readerLimits = reviewReaderBudget(input, options.readerBudgetPolicy ?? 'fixed');
127
- const scope = options.enableRetrieval === true ? createRetrievalScope({ ...input, context: input.context ?? [] }, { ...readerLimits, signal }) : null;
137
+ const scope = options.enableRetrieval === true ? createRetrievalScope({ ...input, context: input.context ?? [] }, { ...readerLimits, signal, ...(options.optimizeReviewCalls === true ? { reuseResults: true } : {}) }) : null;
128
138
  const report = { schemaVersion: 1, snapshotId: input.id, vcs: input.vcs, status: 'completed', files: [], findings: [], limitations: [], modelCalls: 0 };
129
139
  validateBusinessRequirement(input.businessRequirement);
130
140
  if(input.businessRequirement!==undefined)report.businessRequirement={hash:hash(input.businessRequirement),bytes:Buffer.byteLength(input.businessRequirement),status:'untrusted-constraint'};
@@ -145,9 +155,14 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
145
155
  if(input.effectiveMergeSources)report.effectiveMergeSources=structuredClone(input.effectiveMergeSources);
146
156
  report.rules = (input.rules ?? []).map(({ path, hash }) => ({ path, hash }));
147
157
  if (scope) configureWireBudget(scope, { nativeOutputTools: options.nativeOutputTools === true });
148
- if (options.enableGrouping === true) report.grouping = buildReviewGroups(input.files, inferFileRelations(input.files));
158
+ if (options.enableMigrationContracts === true && scope && !input.projectScope) {
159
+ report.migrationContracts = buildMigrationContracts(input);
160
+ }
161
+ if (options.enableGrouping === true || report.migrationContracts?.contracts.length) report.grouping = buildReviewGroups(input.files, [...inferFileRelations(input.files), ...(report.migrationContracts?.links ?? []).map(link => ({ from: link.from, to: link.to, reason: link.reasons?.join(',') ?? 'migration-contract' }))]);
149
162
  report.interactions = [];
150
- const interactionPlans = scope && report.grouping ? report.grouping.groups.filter(g => g.fileIds.length > 1).map(group => ({ group, prepared: prepareInteractionInput(input, group, payload => initialInputBudget({ instructions, input: payload }, scope, maxInputBytes)) })) : [];
163
+ const selectedGroups = (report.grouping?.groups ?? []).filter(group => group.fileIds.length > 1);
164
+ if (report.migrationContracts) report.migrationContracts.groups = selectedGroups.filter(group => report.migrationContracts.groups.some(other => [...other.fileIds].sort().join(',') === [...group.fileIds].sort().join(',')));
165
+ const interactionPlans = scope ? selectedGroups.map(group => ({ group, prepared: prepareInteractionInput(input, group, payload => initialInputBudget({ instructions, input: payload }, scope, maxInputBytes)) })) : [];
151
166
  for (const plan of interactionPlans.filter(p => p.prepared.status !== 'not-applicable')) report.interactions.push({ groupId: plan.group.id, fileIds: plan.prepared.fileIds, status: plan.prepared.status === 'ready' ? 'pending' : 'blocked', reason: plan.prepared.reason, lifecycle: 'shares-last-primary-timeout' });
152
167
  const fileResults = new Array(input.files.length);
153
168
  const sharedState = report.orchestration = { modelMetrics: {}, modelCallPurposes: {} };
@@ -173,21 +188,47 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
173
188
  await options.onProgress?.({ stage: info.action, snapshotId: input.id, phase: request.reviewPhase, ...info });
174
189
  } });
175
190
  };
176
- const groupPrepared = new Map(), groupResults = new Map(), groupStarted = new Map();
191
+ const groupPrepared = new Map(), groupResults = new Map(), groupStarted = new Map(), groupInteractions = new Map();
192
+ const acceptedStages = new Map(prepareStageGenerations(input, options.resumeState?.stageGenerations ?? []).filter(record => record.phase === 'primary').map(record => [record.key, { generation: record.generation, record, restored: true }]));
193
+ const deferredStages = [], yieldedUnits = new Set(), fileDeadlines = new Map(), primaryStageKeys = new Map();
194
+ const checkpointStages = () => {
195
+ if (!options.enableStageContinuation) return;
196
+ report.stageGenerations = prepareStageGenerations(input, [...acceptedStages.values()].map(entry => entry.record).filter(record => record?.phase === 'primary'));
197
+ };
198
+ let readerAllocation;
199
+ const stageScope = id => readerAllocation
200
+ ? configureWireBudget(readerAllocation.scope(id), { nativeOutputTools: options.nativeOutputTools === true }) : scope;
201
+ const releaseStage = id => { if (!yieldedUnits.has(id)) readerAllocation?.release(id); };
202
+ const contextSeeds = (reader, request, existing = [], buildRequest = records => retrievalRequest(request, reader, records)) => {
203
+ if (!evidenceContext || !reader || maxRetrievalRounds === 0) return [];
204
+ const payload = JSON.parse(request.input), catalog = reader.catalog();
205
+ const members = [payload, ...(payload.reviews ?? [])];
206
+ const fileIds = new Set(members.flatMap(member => [member.file?.id, ...(member.files ?? []).map(item => item.fileId), ...(member.relatedFiles ?? []).map(item => item.id)]).filter(Boolean));
207
+ const paths = new Set(members.flatMap(member => (member.contextRelations ?? []).flatMap(item => [item.fromPath, item.toPath])).filter(Boolean));
208
+ for (const caller of members.flatMap(member => member.callerBindings ?? [])) if (caller.path) paths.add(caller.path);
209
+ const riskIds = new Set(members.flatMap(member => (member.riskPlan ?? []).flatMap(item => item.sourceIds ?? [])));
210
+ const sourceIds = catalog.filter(source => fileIds.has(source.fileId) || paths.has(source.path) || riskIds.has(source.id)).map(source => source.id).slice(0, 200);
211
+ return evidenceContext.seed(reader, request, { sourceIds, existing, maxInputBytes, buildRequest });
212
+ };
177
213
  const groupResult = async group => {
178
214
  if (!groupResults.has(group.id)) {
179
215
  groupStarted.set(group.id, performance.now());
180
216
  groupResults.set(group.id, invoke(async request => {
181
- const prepared = groupPrepared.get(group.id); let limited = false; const reads = [];
182
- const response = await retrievalLoop(sharedModel, { ...request, nativeOutputTools: options.nativeOutputTools === true, input: prepared.payload, instructions: prepared.instructions, reviewPhase: 'group-primary' }, scope, {
217
+ const prepared = groupPrepared.get(group.id), groupScope = stageScope('group:' + group.id); const limits = [], reads = [];
218
+ const groupRequest = { ...request, nativeOutputTools: options.nativeOutputTools === true, input: prepared.payload, instructions: prepared.instructions, reviewPhase: 'group-primary' };
219
+ const response = await retrievalLoop(sharedModel, groupRequest, groupScope, {
220
+ initialContext: (existing, build) => contextSeeds(groupScope, groupRequest, existing, build),
183
221
  maxRounds: maxRetrievalRounds, maxInputBytes, beforeCall: reserveShared, correctProtocol: options.correctProtocol === true,
184
222
  enableSearchSeeds: options.enableSearchSeeds === true,
185
- onRetrieved: records => { reads.push(...records); (sharedState.retrievalAudit ??= []).push(...records); },
186
- onBudgetLimited: info => { limited = true; (sharedState.retrievalLimits ??= []).push(info); },
223
+ onRetrieved: records => { reads.push(...records); (sharedState.retrievalAudit ??= []).push(...records); evidenceContext?.remember(records); },
224
+ onBudgetLimited: info => { limits.push(info); (sharedState.retrievalLimits ??= []).push(info); },
187
225
  });
188
226
  const results = validateGroupPrimary(response, prepared, input, validateResponse, scope.catalog());
227
+ const interaction = validateGroupInteraction(response, prepared, input, reads, scope.catalog());
228
+ groupInteractions.set(group.id, interaction);
229
+ if (interaction.status !== 'disabled') (report.groupInteractionChannels ??= []).push({ groupId: group.id, status: interaction.status, reason: interaction.reason });
189
230
  for (const generated of results.values()) {
190
- generated.groupLimited = limited;
231
+ generated.groupLimits = structuredClone(limits);
191
232
  if (generated.groupRisk) {
192
233
  generated.groupRisk.sourceReadCoverage = riskReadCoverage(generated.groupRisk.risks, reads, scope.catalog(), input.id);
193
234
  if (generated.groupRisk.status !== 'completed') generated.limitations.push('组主审的合并风险计划缺失或无效,未补发规划调用;不表示已完成风险条件核查');
@@ -197,7 +238,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
197
238
  return results;
198
239
  }, { input: groupPrepared.get(group.id).payload, instructions: groupPrepared.get(group.id).instructions }, timeoutMs, signal));
199
240
  }
200
- return groupResults.get(group.id);
241
+ try { return await groupResults.get(group.id); } finally { releaseStage('group:' + group.id); }
201
242
  };
202
243
  const runFile = async index => {
203
244
  const file = input.files[index];
@@ -229,14 +270,16 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
229
270
  if(changes.precision==='envelope')result.limitations.push({fileId:file.id,text:'精确差异不可得,按保守包络分段检查;包络包含可能未变的行,不证明精确编辑或回归因果。'});
230
271
  if (changes.status === 'limited') result.limitations.push({ fileId: file.id, text: '精确变更分析受限:' + changes.reason + ';仍按完整两侧内容审查' });
231
272
  if (state.groupFallback) result.limitations.push({ fileId: file.id, text: '关联组上下文超过输入预算,回退单文件审查' });
232
- if (state.initialInputBytes > maxInputBytes) { state.status = 'blocked'; state.reason = '输入超过预算,未截断或提交模型'; return; }
273
+ if (state.initialInputBytes > maxInputBytes) { state.status = 'blocked'; state.reason = '输入超过预算,未截断或提交模型'; releaseStage('primary:' + file.id); releaseStage('synthesis:' + file.id); releaseStage('verification:' + file.id); return; }
233
274
  try {
234
275
  const beforeCall = ({ reserved = false } = {}) => {
235
276
  if (!reserved && options.remainingCalls?.() <= 0) throw Object.assign(new Error('Shared project model call limit exceeded'), { code: 'PROJECT_MODEL_CALL_LIMIT' });
236
277
  if (report.modelCalls >= maxCalls) throw new Error('Model call budget exceeded');
237
278
  report.modelCalls++;
238
279
  };
280
+ let activeReader = scope, activeStage = 'primary';
239
281
  const onBudgetLimited = info => {
282
+ if (activeStage !== 'primary') (state.secondaryRetrievalLimits ??= []).push({ phase: activeStage, ...info });
240
283
  state.retrievalLimited = true;
241
284
  (state.retrievalLimits ??= []).push(info);
242
285
  result.limitations.push({ fileId: file.id, text: '检索预算受限,已保留现有结果但不能视为完整审查:' + info.reason });
@@ -277,8 +320,9 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
277
320
  });
278
321
  };
279
322
  const executor = scope
280
- ? (request, onLimited = () => {}) => retrievalLoop(trackedModel, { ...request, nativeOutputTools: options.nativeOutputTools === true, onProtocolEvent }, scope, { correctProtocol:options.correctProtocol===true,maxRounds:maxRetrievalRounds,maxInputBytes, beforeCall, initialRetrieved: request.initialRetrieved ?? [], allowExhaustedInitial: request.allowExhaustedInitial === true, enableSearchSeeds: options.enableSearchSeeds === true && !request.reviewPhase?.includes('verification'), onBudgetLimited: info => { onBudgetLimited(info); onLimited(info); }, onRetrieved: records => {
323
+ ? (request, onLimited = () => {}) => retrievalLoop(trackedModel, { ...request, nativeOutputTools: options.nativeOutputTools === true, onProtocolEvent }, activeReader, { correctProtocol:options.correctProtocol===true,maxRounds:maxRetrievalRounds,maxInputBytes, beforeCall, initialRetrieved: request.initialRetrieved ?? [], yieldOnBudget: options.enableStageContinuation === true && activeStage !== 'primary', initialContext: (existing, build) => contextSeeds(activeReader, request, existing, build), allowExhaustedInitial: request.allowExhaustedInitial === true, enableSearchSeeds: options.enableSearchSeeds === true && !request.reviewPhase?.includes('verification'), onBudgetLimited: info => { onBudgetLimited(info); onLimited(info); }, onRetrieved: records => {
281
324
  (state.retrievalAudit ??= []).push(...records);
325
+ evidenceContext?.remember(records);
282
326
  }, onTruncated: () => {
283
327
  if (!state.retrievalTruncated) result.limitations.push({ fileId: file.id, text: '检索结果达到数量上限,部分匹配未提供给模型' });
284
328
  state.retrievalTruncated = true;
@@ -288,42 +332,83 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
288
332
  const groupedGeneration = grouped ? (await groupResult({ id: grouped[0] })).get(file.id) : null;
289
333
  if (grouped) {
290
334
  state.primaryGroupId = grouped[0]; state.primaryMethod = 'group-primary';
291
- if (groupedGeneration.groupLimited) state.retrievalLimited = true;
335
+ if (groupedGeneration.groupLimits?.length) {
336
+ state.retrievalLimited = true;
337
+ state.groupRetrievalLimits = structuredClone(groupedGeneration.groupLimits);
338
+ result.limitations.push(...state.groupRetrievalLimits.map(limit => ({ fileId: file.id, text: '组主审检索受限:' + limit.code + ';已接受的本成员成果保留,不表示完整审查' })));
339
+ }
292
340
  if (groupedGeneration.groupRisk) (state.riskPlans ??= []).push({ ...groupedGeneration.groupRisk, method: 'inline', scope: 'group-all-edits', groupId: grouped[0], decision: { required: true, reason: 'group-inline-primary' } });
293
341
  }
294
342
  const chainTimeout = grouped && timeoutMs > 0 ? Math.max(0, timeoutMs - (performance.now() - groupStarted.get(grouped[0]))) : timeoutMs;
295
343
  if (grouped && timeoutMs > 0 && chainTimeout <= 0) throw Object.assign(new Error('Model timeout'), { code: 'MODEL_TIMEOUT' });
344
+ if (!fileDeadlines.has(file.id)) fileDeadlines.set(file.id, timeoutMs > 0 ? performance.now() + chainTimeout : Infinity);
296
345
  await invoke(async enclosingRequest => {
297
346
  const batches = [...(prepared.batches ?? [{ payload, windowIds: state.windowIds ?? [] }])];
298
347
  if (prepared.synthesis?.status === 'ready') batches.push({ payload: prepared.synthesis.payload, windowIds: [], synthesis: true });
299
348
  const matchingInteractionPlans = interactionPlans.filter(p => input.files.filter(f => p.group.fileIds.includes(f.id)).at(-1)?.id === file.id);
300
349
  for (const interactionPlan of matchingInteractionPlans.filter(p => p.prepared.status === 'ready')) batches.push({ payload: interactionPlan.prepared.payload, windowIds: [], interaction: interactionPlan.group });
301
350
  const scheduled = Array.from({length:rounds},(_,i)=>batches.map(b=>({...b,round:i+1}))).flat();
351
+ primaryStageKeys.set(file.id, scheduled.filter(batch => !batch.synthesis && !batch.interaction).map(batch => stageKey(file.id, { phase: 'primary', round: batch.round, windowIds: batch.windowIds ?? [] })));
302
352
  if(rounds>1) state.reviewPasses=scheduled.map(b=>({round:b.round,windowIds:b.windowIds,...(b.interaction?{groupId:b.interaction.id}:{}),...(b.synthesis?{synthesis:true}:{}),status:'pending'}));
303
353
  const riskInput = canonicalRiskInput(input, file, prepared, { instructions: RISK_PLAN_INSTRUCTIONS, scope, maxInputBytes });
304
354
  let primaryRiskPlan;
305
- for (const [batchIndex,batch] of scheduled.entries()) {
355
+ const executeStage = async (batch, batchIndex, enclosingRequest, retry = false) => {
306
356
  enclosingRequest.signal.throwIfAborted();
357
+ const key = stageKey(file.id, { phase: batch.interaction ? 'file-interaction' : batch.synthesis ? 'window-synthesis' : 'primary', round: batch.round, windowIds: batch.windowIds ?? [], ...(batch.interaction ? { groupId: batch.interaction.id } : {}) });
358
+ const priorStage = acceptedStages.get(key);
359
+ if (retry && priorStage?.completed) return;
360
+ const allocationId = batch.interaction ? 'interaction:' + batch.interaction.id : batch.synthesis ? 'synthesis:' + file.id : 'primary:' + file.id;
361
+ const defer = (error, unitId = allocationId) => {
362
+ if (!options.enableStageContinuation || !readerAllocation || !['READER_CALL_BUDGET_EXCEEDED', 'READER_OUTPUT_BUDGET_EXCEEDED'].includes(error?.code)) return false;
363
+ if (!yieldedUnits.has(unitId)) { readerAllocation.yield(unitId, { reason: error.code }); yieldedUnits.add(unitId); }
364
+ let pending = deferredStages.find(item => item.key === key);
365
+ if (pending && pending.allocationId !== unitId) { const previousUnit = pending.allocationId; pending.allocationId = unitId; if (!deferredStages.some(other => other !== pending && !other.done && other.allocationId === previousUnit)) { yieldedUnits.delete(previousUnit); readerAllocation.release(previousUnit); } }
366
+ if (!pending) {
367
+ pending = { key, allocationId: unitId, fileId: file.id, phase: unitId.startsWith('verification:') ? 'verification' : phase === 'window-synthesis' ? 'synthesis' : 'interaction', fileIds: batch.interaction?.fileIds ?? [file.id], attempts: 0, run: () => invoke(next => executeStage(batch, batchIndex, next, true), { instructions, input: payload }, timeoutMs > 0 ? Math.max(1, fileDeadlines.get(file.id) - performance.now()) : 0, signal) };
368
+ deferredStages.push(pending);
369
+ }
370
+ pending.requests = error.pendingRequests ?? []; pending.reason = error.code; pending.done = false;
371
+ state.continuationPending = true;
372
+ if (batch.interaction) Object.assign(report.interactions.find(item => item.groupId === batch.interaction.id), { status: 'pending', reason: error.code });
373
+ if (batch.synthesis) state.synthesisStatus = 'pending';
374
+ return true;
375
+ };
307
376
  const phase=batch.interaction?'file-interaction':batch.synthesis?'window-synthesis':'primary';
308
377
  try{
309
- const admission = scope && options.optimizeReviewCalls === true ? readerAdmission({ synthesis: batch.synthesis === true, interaction: !!batch.interaction, metadata: prepared.metadata }, scope.budget()) : { permitted: true };
378
+ activeStage = phase;
379
+ if (!priorStage && yieldedUnits.has(allocationId)) { defer(Object.assign(new Error('Stage is waiting for released reader capacity'), { code: 'READER_CALL_BUDGET_EXCEEDED' })); return; }
380
+ activeReader = priorStage || groupedGeneration && !batch.synthesis && !batch.interaction ? scope : stageScope(allocationId);
381
+ // Consuming an already validated group member performs no new source/model work.
382
+ const combinedInteraction = batch.interaction && batch.round === 1 ? [...groupInteractions].find(([id, channel]) => channel.status === 'accepted' && JSON.stringify([...groupPrepared.get(id).fileIds].sort()) === JSON.stringify([...batch.interaction.fileIds].sort()))?.[1] : null;
383
+ const cachedPrimary = !!priorStage || groupedGeneration && !batch.synthesis && !batch.interaction;
384
+ const admission = !cachedPrimary && !combinedInteraction && activeReader && options.optimizeReviewCalls === true
385
+ ? readerAdmission({ synthesis: batch.synthesis === true, interaction: !!batch.interaction, metadata: prepared.metadata }, activeReader.budget()) : { permitted: true };
310
386
  if (!admission.permitted) {
311
- state.readerAdmission = { code: admission.code, remaining: scope.budget() };
387
+ if ((batch.synthesis || batch.interaction) && defer(Object.assign(new Error('Stage reader allocation unavailable'), { code: admission.code }))) return;
388
+ state.readerAdmission = { code: admission.code, remaining: activeReader.budget() };
312
389
  if (batch.synthesis || batch.interaction) throw Object.assign(new Error('Required evidence budget unavailable before stage admission'), { code: admission.code });
313
390
  state.reason = '检索预算不足以取得本阶段所需证据,未启动模型;需重新预览补审';
314
391
  state.failureCode = admission.code;
315
392
  state.retrievalLimited = true;
316
- continue;
393
+ return;
317
394
  }
318
395
  let batchPayload = batch.payload;
319
- const inlineRisk = !groupedGeneration && prepared.inlineRisk === true && batch.round === 1 && !batch.synthesis && !batch.interaction;
320
- const batchInstructions = inlineRisk ? prepared.instructions : instructions;
396
+ const relevantContracts = report.migrationContracts?.contracts.filter(contract => contract.candidateFileIds.includes(file.id) || batch.interaction?.fileIds.some(id => contract.candidateFileIds.includes(id))) ?? [];
397
+ if (relevantContracts.length) {
398
+ const enriched = JSON.stringify({ ...JSON.parse(batchPayload), migrationContracts: relevantContracts, migrationNotice: 'Host navigation hypotheses only; old/new entries and callers are side-qualified. Follow the complete old public gate → new entry → caller chain under the SAME trigger. Contracts do not prove semantic migration, grant sources, or replace current independent verification.' });
399
+ if (initialInputBudget({ input: enriched, instructions }, activeReader, maxInputBytes).fits) batchPayload = enriched;
400
+ else (state.migrationContextLimitations ??= []).push('Migration navigation did not fit this stage; no coverage claim.');
401
+ }
402
+ const interactionStrategy = batch.interaction && !combinedInteraction ? prepareInteractionRisk(input, batch.interaction, matchingInteractionPlans.find(plan => plan.group.id === batch.interaction.id).prepared, { ...options, instructions, scope: activeReader, maxInputBytes, round: batch.round }) : null;
403
+ const inlineInteraction = !priorStage && interactionStrategy?.mode === 'inline';
404
+ const inlineRisk = !priorStage && (inlineInteraction || (!groupedGeneration && prepared.inlineRisk === true && batch.round === 1 && !batch.synthesis && !batch.interaction));
405
+ const batchInstructions = inlineInteraction ? interactionStrategy.instructions : inlineRisk ? prepared.instructions : instructions;
321
406
  let riskEntry, activeRisks = [];
322
407
  if (inlineRisk) {
323
- riskEntry = { status: 'pending', method: 'inline', windowIds: batch.windowIds, scope: state.sourceMode === 'change-windows' ? 'all-edits' : 'full-file', risks: [], decision: { required: true, reason: 'inline-primary' } };
408
+ riskEntry = { status: 'pending', method: 'inline', windowIds: batch.windowIds, scope: inlineInteraction ? 'group-all-edits' : state.sourceMode === 'change-windows' ? 'all-edits' : 'full-file', ...(inlineInteraction ? { groupId: batch.interaction.id, sourceMode: 'file-interaction' } : {}), risks: [], decision: { required: true, reason: inlineInteraction ? 'inline-interaction' : 'inline-primary' } };
324
409
  (state.riskPlans ??= []).push(riskEntry);
325
410
  }
326
- if (options.enableRiskPlanning === true && (!groupedGeneration || batch.interaction) && !inlineRisk && !batch.synthesis && batch.round === 1) {
411
+ if (options.enableRiskPlanning === true && (!groupedGeneration || batch.interaction) && !inlineRisk && !combinedInteraction && !priorStage && !batch.synthesis && batch.round === 1) {
327
412
  const reusable = !batch.interaction && riskInput.cacheable;
328
413
  riskEntry = reusable ? primaryRiskPlan : undefined;
329
414
  if (!riskEntry) {
@@ -364,7 +449,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
364
449
  }
365
450
  }
366
451
  }
367
- if(report.modelCalls>=maxCalls && !(groupedGeneration && !batch.synthesis && !batch.interaction))throw new Error('Model call budget exceeded before next review pass');
452
+ if(report.modelCalls>=maxCalls && !priorStage && !combinedInteraction && !(groupedGeneration && !batch.synthesis && !batch.interaction))throw new Error('Model call budget exceeded before next review pass');
368
453
  if(rounds>1) state.reviewPasses[batchIndex].status='running';
369
454
  const request = { ...enclosingRequest, nativeOutputTools: options.nativeOutputTools === true, instructions: batchInstructions, input: batchPayload, reviewPhase: phase, allowExhaustedInitial: options.optimizeReviewCalls === true && !batch.synthesis && !batch.interaction && prepared.metadata.contextMode !== 'approved-retrieval' };
370
455
  let auditStart = state.retrievalAudit?.length ?? 0;
@@ -372,7 +457,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
372
457
  const generate = async attempt => {
373
458
  if (!initialInputBudget(attempt, scope, maxInputBytes).fits) throw new Error('Review correction input budget exceeded');
374
459
  const initialRetrieved = scope && options.enablePrimarySeeds === true && maxRetrievalRounds > 0 && !batch.synthesis && !batch.interaction
375
- ? primarySeeds(scope, attempt, { maxInputBytes, signal: attempt.signal }) : [];
460
+ ? primarySeeds(activeReader, attempt, { maxInputBytes, signal: attempt.signal }) : [];
376
461
  return executor({ ...attempt, initialRetrieved }, () => { finalResponseOnly = true; });
377
462
  };
378
463
  const validate = response => {
@@ -390,13 +475,15 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
390
475
  response = decoded;
391
476
  }
392
477
  }
393
- const generated = batch.interaction ? validateInteractionFindings(response, input, batch.interaction, (state.retrievalAudit ?? []).slice(auditStart), scope.catalog()) : validateResponse(response, file, changes);
394
- if (inlineRisk) { const value = typeof response === 'string' ? JSON.parse(response) : response; if (Object.hasOwn(value, 'risks')) generated.inlineRisks = value.risks; }
478
+ const interactionResult = inlineInteraction ? consumeInteractionRisks(response, input, batch.interaction, scope.catalog()) : null;
479
+ const generated = batch.interaction ? validateInteractionFindings(interactionResult?.response ?? response, input, batch.interaction, (state.retrievalAudit ?? []).slice(auditStart), scope.catalog()) : validateResponse(response, file, changes);
480
+ if (inlineInteraction) generated.interactionRisk = interactionResult.risk;
481
+ else if (inlineRisk) { const value = typeof response === 'string' ? JSON.parse(response) : response; if (Object.hasOwn(value, 'risks')) generated.inlineRisks = value.risks; }
395
482
  if (batch.synthesis) validateSynthesisReads(generated.findings, changes, (state.retrievalAudit ?? []).slice(auditStart), scope.catalog(), file.id, input.id);
396
483
  if (!batch.interaction && state.sourceMode === 'change-windows') validateWindowFindings(generated.findings, batch.synthesis ? prepared.synthesis.windows : JSON.parse(batch.payload).windows, batch.synthesis === true);
397
484
  return generated;
398
485
  };
399
- const generated = groupedGeneration && !batch.synthesis && !batch.interaction ? structuredClone(groupedGeneration) : await correctedResponse(generate, validate, request, {
486
+ const generated = priorStage ? priorStage.generation : combinedInteraction ? { findings: structuredClone(combinedInteraction.findings), limitations: structuredClone(combinedInteraction.limitations) } : groupedGeneration && !batch.synthesis && !batch.interaction ? structuredClone(groupedGeneration) : await correctedResponse(generate, validate, request, {
400
487
  enabled: options.correctProtocol === true,
401
488
  canCorrect: () => !finalResponseOnly,
402
489
  onCorrection: async info => {
@@ -408,7 +495,12 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
408
495
  await options.onProgress?.({ stage: 'protocol-correction', fileId: file.id, path: file.path, ...info });
409
496
  },
410
497
  });
411
- if (inlineRisk) {
498
+ if (inlineInteraction) {
499
+ Object.assign(riskEntry, generated.interactionRisk);
500
+ activeRisks = riskEntry.risks;
501
+ if (riskEntry.limitation) result.limitations.push({ fileId: file.id, text: riskEntry.limitation });
502
+ delete generated.interactionRisk;
503
+ } else if (inlineRisk) {
412
504
  const response = generated.inlineRisks;
413
505
  if (response === undefined) { riskEntry.status = 'absent'; result.limitations.push({ fileId: file.id, text: '合并主审未返回风险问题,保留有效发现但不声明独立风险规划完成' }); }
414
506
  else {
@@ -417,7 +509,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
417
509
  }
418
510
  delete generated.inlineRisks;
419
511
  }
420
- if (options.enableRiskPlanning === true && (!groupedGeneration || batch.interaction) && !batch.synthesis && batch.round === 1) {
512
+ if (options.enableRiskPlanning === true && !priorStage && !combinedInteraction && (!groupedGeneration || batch.interaction) && !batch.synthesis && batch.round === 1) {
421
513
  const coverage = riskReadCoverage(activeRisks,(state.retrievalAudit??[]).slice(auditStart),scope?.catalog()??[],input.id);
422
514
  (riskEntry.generations ??= []).push({ round: batch.round, windowIds: batch.windowIds, coverage });
423
515
  // Preserve the single-generation field without unioning evidence across windows.
@@ -427,14 +519,24 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
427
519
  }
428
520
  request.signal.throwIfAborted();
429
521
  if(rounds>1)generated.findings.forEach(f=>{f.reviewRound=batch.round;});
430
- result.findings.push(...generated.findings);
522
+ if (options.enableStageContinuation && !priorStage && !state.retrievalLimited) {
523
+ const record = { key, fileId: file.id, phase, round: batch.round, windowIds: batch.windowIds ?? [], ...(batch.interaction ? { groupId: batch.interaction.id } : {}), generation: { findings: structuredClone(generated.findings), limitations: structuredClone(generated.limitations) } };
524
+ acceptedStages.set(key, { generation: generated, record, completed: false });
525
+ checkpointStages();
526
+ }
527
+ for (const finding of generated.findings) { const index = priorStage ? result.findings.findIndex(existing => existing.id === finding.id) : -1; if (index < 0) result.findings.push(finding); else result.findings[index] = finding; }
431
528
  if (generated.findings.some(f => f.attribution.status === 'missing')) result.limitations.push({ fileId: file.id, text: '部分发现缺少变更归因,仅校验了定位,尚不能确认属于本次回归' });
432
529
  result.limitations.push(...generated.limitations.map(text => ({ fileId: file.id, text })));
433
- if (options.enableVerification === true && generated.findings.length) {
530
+ if (!groupedGeneration && !scheduled.slice(batchIndex + 1).some(next => !next.synthesis && !next.interaction)) releaseStage('primary:' + file.id);
531
+ if (options.enableStageContinuation && !batch.interaction && !batch.synthesis && state.windowCoverage && batch.round === 1) { state.windowCoverage.completed = [...new Set([...state.windowCoverage.completed, ...batch.windowIds])]; state.windowCoverage.pending = state.windowCoverage.pending.filter(id => !batch.windowIds.includes(id)); }
532
+ if (options.enableVerification === true && generated.findings.length && !priorStage?.completed) {
533
+ if (yieldedUnits.has('verification:' + file.id)) { defer(Object.assign(new Error('Verification allocation is waiting for released capacity'), { code: 'READER_CALL_BUDGET_EXCEEDED' }), 'verification:' + file.id); return; }
434
534
  for (const finding of generated.findings) finding.verification = { status: 'pending', causality: 'unverified' };
435
535
  try {
436
- const verdicts = await verificationLoop(trackedModel, { ...request, onProtocolEvent, reviewPhase: 'verification' }, generated.findings, scope, {
437
- correctProtocol:options.correctProtocol===true,maxRounds:maxRetrievalRounds,maxInputBytes, beforeCall, onBudgetLimited,
536
+ activeStage = 'verification';
537
+ const verdicts = await verificationLoop(trackedModel, { ...request, onProtocolEvent, reviewPhase: 'verification' }, generated.findings, stageScope('verification:' + file.id), {
538
+ correctProtocol:options.correctProtocol===true,maxRounds:maxRetrievalRounds,maxInputBytes, beforeCall, onBudgetLimited, yieldOnBudget: options.enableStageContinuation === true,
539
+ enableMigrationRegression: options.enableMigrationRegression === true, migrationContracts: report.migrationContracts?.contracts ?? [], migrationSnapshot: input,
438
540
  onRetrieved: records => { (state.retrievalAudit ??= []).push(...records); },
439
541
  onTruncated: () => { result.limitations.push({ fileId: file.id, text: '复核检索结果达到数量上限' }); },
440
542
  });
@@ -447,11 +549,14 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
447
549
  for (const finding of generated.findings) finding.verification = { status: 'incomplete', causality: 'unverified' };
448
550
  result.limitations.push({ fileId: file.id, text: '候选复核未完成:' + (error?.message ?? String(error)) });
449
551
  request.signal.throwIfAborted();
552
+ if (defer(error, 'verification:' + file.id)) return;
450
553
  }
451
554
  }
555
+ if (acceptedStages.has(key)) acceptedStages.get(key).completed = !options.enableVerification || generated.findings.every(finding => finding.verification?.status === 'completed');
452
556
  if (batch.synthesis) state.synthesisStatus = 'completed';
453
557
  if (batch.interaction) {
454
558
  const interaction=report.interactions.find(p => p.groupId === batch.interaction.id);
559
+ if (combinedInteraction) Object.assign(interaction, { method: 'group-primary-validated-channel', channelGroupId: combinedInteraction.groupId });
455
560
  if(rounds>1){interaction.completedRounds??=[];interaction.completedRounds.push(batch.round);interaction.reviewRounds=rounds;}
456
561
  if(batch.round===rounds)interaction.status='completed';
457
562
  }
@@ -460,10 +565,16 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
460
565
  state.windowCoverage.pending = state.windowCoverage.pending.filter(id => !batch.windowIds.includes(id));
461
566
  }
462
567
  if(rounds>1)state.reviewPasses[batchIndex].status='completed';
568
+ if (retry) { const pending = deferredStages.find(stage => stage.key === key); if (pending) pending.succeeded = !options.enableVerification || generated.findings.every(finding => finding.verification?.status === 'completed'); }
463
569
  await options.onProgress?.({stage:'window-complete',snapshotId:input.id,fileId:file.id,path:file.path,windowIds:batch.windowIds,round:batch.round,findings:generated.findings,windowCoverage:state.windowCoverage});
464
570
  }catch(error){
465
571
  enclosingRequest.signal.throwIfAborted();
466
- if(phase==='primary'||error.code==='MODEL_ARTIFACT_FAILED'||(/budget exceeded/i.test(error.message)&&!groupedGeneration))throw error;
572
+ if (defer(error)) return;
573
+ if(phase==='primary'||error.code==='MODEL_ARTIFACT_FAILED')throw error;
574
+ if (['Model call budget exceeded', 'Model call budget exceeded before next review pass'].includes(error.message) || error.code === 'PROJECT_MODEL_CALL_LIMIT') {
575
+ result.limitations.push({ fileId: file.id, text: phase + '模型调用额度不足,尚未启动的阶段保留 pending;已完成主审成果保留' });
576
+ return false;
577
+ }
467
578
  const failure={phase,status:'failed',code:error.code??'REVIEW_EXECUTION_FAILED',reason:error.message,round:batch.round,...(error.attempts?{attempts:error.attempts}:{}),...(error.diagnostic?{diagnostic:error.diagnostic}:{})};
468
579
  (state.stageFailures??=[]).push(failure);
469
580
  if(batch.synthesis)state.synthesisStatus='failed';
@@ -472,16 +583,17 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
472
583
  result.limitations.push({fileId:file.id,text:phase+'未完成:'+error.message+';已完成主文件/窗口成果保留,不表示综合已通过'});
473
584
  await options.onProgress?.({stage:'synthesis-failed',snapshotId:input.id,fileId:file.id,path:file.path,...failure,windowCoverage:state.windowCoverage});
474
585
  }
475
- }
586
+ };
587
+ for (const [batchIndex, batch] of scheduled.entries()) if (await executeStage(batch, batchIndex, enclosingRequest) === false) break;
476
588
  }, { instructions, input: payload }, chainTimeout, signal);
477
589
  if (signal?.aborted) throw signal.reason;
478
590
 
479
- state.status = state.retrievalLimited ? 'pending' : 'completed';
591
+ state.status = state.retrievalLimited || state.continuationPending ? 'pending' : 'completed';
480
592
  if (state.retrievalLimited) {
481
- const codes = new Set((state.retrievalLimits ?? []).map(limit => limit.code));
593
+ const codes = new Set([...(state.retrievalLimits ?? []), ...(state.groupRetrievalLimits ?? [])].map(limit => limit.code));
482
594
  state.reason = codes.has('RETRIEVAL_ROUND_BUDGET_EXCEEDED')
483
- ? '单阶段检索轮数上限达到,未完成完整审查;共享读取额度可能仍有余量,已有发现保留,不表示通过'
484
- : '共享读取调用或输出预算受限,未完成完整审查;已有发现保留,不表示通过';
595
+ ? '单阶段检索轮数上限达到,未完成完整审查;读取额度可能仍有余量,已有发现保留,不表示通过'
596
+ : (readerAllocation ? '阶段取证分配或共享读取预算受限,未完成完整审查;其他阶段保留额度不表示本阶段已完成,已有发现保留' : '共享读取调用或输出预算受限,未完成完整审查;已有发现保留,不表示通过');
485
597
  if (codes.size === 1) state.failureCode = [...codes][0];
486
598
  }
487
599
 
@@ -496,8 +608,15 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
496
608
  state.failureCode = signal?.aborted ? 'CANCELLED' : ['MODEL_INVALID_JSON','MODEL_INCOMPLETE_OUTPUT','MODEL_TIMEOUT','MODEL_INVALID_READ_REQUEST','MODEL_OUTPUT_LIMIT','MODEL_PROTOCOL_ERROR','MODEL_INVALID_REVIEW_RESPONSE','MODEL_INVALID_FINDING_IDENTITY','MODEL_INVALID_FINDING_SEVERITY','MODEL_MISSING_FINDING_FIELD','MODEL_INVALID_ANCHOR','MODEL_ANCHOR_MISMATCH','READER_CALL_BUDGET_EXCEEDED','READER_OUTPUT_BUDGET_EXCEEDED','RETRIEVAL_ROUND_BUDGET_EXCEEDED','RETRIEVAL_FINAL_RESPONSE_REQUIRED','PROJECT_MODEL_CALL_LIMIT'].includes(error?.code) ? error.code : 'REVIEW_EXECUTION_FAILED';
497
609
  }
498
610
  } finally {
611
+ if (readerAllocation && file.eligibility === 'reviewable' && !options.resumeState?.completed?.some(row => row.fileId === file.id)) {
612
+ if (![...groupPrepared.values()].some(group => group.fileIds.includes(file.id))) releaseStage('primary:' + file.id);
613
+ releaseStage('verification:' + file.id);
614
+ releaseStage('synthesis:' + file.id);
615
+ for (const plan of interactionPlans.filter(plan => input.files.filter(member => plan.group.fileIds.includes(member.id)).at(-1)?.id === file.id)) releaseStage('interaction:' + plan.group.id);
616
+ }
499
617
  state.resumeEligible = state.status === 'completed' && !state.stageFailures?.length && !state.reviewPasses?.some(pass => pass.status !== 'completed') && !state.windowCoverage?.pending?.length && !state.retrievalLimited && result.findings.every(f => f.verification?.status === 'completed') && report.interactions.filter(item => item.fileIds?.includes(file.id)).every(item => item.status === 'completed');
500
618
  await options.onProgress?.({stage:'file-complete',snapshotId:input.id,file:state,findings:result.findings});
619
+ checkpointStages();
501
620
  await options.onResumeCheckpoint?.({ ...report, files: fileResults.filter(Boolean).map(item => item.state), findings: fileResults.filter(Boolean).flatMap(item => item.findings), limitations: [...report.limitations, ...fileResults.filter(Boolean).flatMap(item => item.limitations)] });
502
621
  }
503
622
  };
@@ -530,16 +649,29 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
530
649
  }
531
650
  if (options.enableGroupPrimary === true && scope && rounds === 1) {
532
651
  const seen = new Set();
533
- const groups = report.businessGrouping?.groups ?? report.grouping?.groups ?? [];
652
+ const groups = [...(report.migrationContracts?.groups ?? []), ...(report.businessGrouping?.groups ?? report.grouping?.groups ?? [])];
534
653
  for (const group of groups) {
535
- if (group.fileIds.some(id => seen.has(id) || options.resumeState?.completed?.some(row => row.fileId === id))) continue;
536
- const prepared = prepareGroupPrimary(input, group, { instructions, scope, maxInputBytes, focusPrimaryContext: options.focusPrimaryContext === true, enableRiskPlanning: options.enableRiskPlanning === true });
654
+ if (group.fileIds.some(id => seen.has(id) || options.resumeState?.completed?.some(row => row.fileId === id) || options.resumeState?.stageGenerations?.some(row => row.fileId === id))) continue;
655
+ const prepared = prepareGroupPrimary(input, group, { instructions, scope, maxInputBytes, focusPrimaryContext: options.focusPrimaryContext === true, enableRiskPlanning: options.enableRiskPlanning === true, enableGroupInteractions: options.enableGroupInteractions === true, migrationContracts: report.migrationContracts?.contracts ?? [] });
537
656
  if (!prepared) continue;
538
657
  groupPrepared.set(group.id, prepared);
539
658
  for (const id of group.fileIds) seen.add(id);
540
659
  }
541
660
  report.primaryGroups = [...groupPrepared].map(([groupId, p]) => ({ groupId, fileIds: p.fileIds, mode: 'group-primary' }));
542
661
  }
662
+ if (scope && (options.optimizeReviewCalls === true || options.enableStageContinuation === true)) {
663
+ const units = [];
664
+ const reusable = new Set((options.resumeState?.completed ?? []).map(row => row.fileId));
665
+ for (const [id, prepared] of groupPrepared) units.push({ id: 'group:' + id, weight: prepared.fileIds.length * 4 });
666
+ for (const file of input.files.filter(file => file.eligibility === 'reviewable' && !reusable.has(file.id))) {
667
+ const prepared = preparePrimaryInput(input, file, { instructions, scope, grouping: report.grouping, maxInputBytes, riskPlanningMode: options.riskPlanningMode, focusPrimaryContext: options.focusPrimaryContext === true, enableRiskPlanning: options.enableRiskPlanning, skipSmallRiskPlans: options.skipSmallRiskPlans });
668
+ if (![...groupPrepared.values()].some(group => group.fileIds.includes(file.id))) units.push({ id: 'primary:' + file.id, weight: 4 * Math.max(1, prepared.batches?.length ?? 1) });
669
+ if (prepared.synthesis?.status === 'ready') units.push({ id: 'synthesis:' + file.id, weight: 2 });
670
+ if (options.enableVerification === true) units.push({ id: 'verification:' + file.id, weight: 2 });
671
+ }
672
+ for (const plan of interactionPlans.filter(plan => plan.prepared.status === 'ready' && !plan.group.fileIds.every(id => reusable.has(id)))) units.push({ id: 'interaction:' + plan.group.id, weight: 2 });
673
+ if (units.length) readerAllocation = createReviewReaderAllocation(scope, units);
674
+ }
543
675
  const dependencies = new Map();
544
676
  for (const plan of interactionPlans.filter(p => p.prepared.status === 'ready')) {
545
677
  const members = input.files.map((file, index) => plan.group.fileIds.includes(file.id) ? index : -1).filter(index => index >= 0);
@@ -550,7 +682,46 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
550
682
  try { await runFile(index); }
551
683
  catch (error) { controller.abort(error); throw error; }
552
684
  }, dependencies, fileConcurrency);
685
+ if (options.enableStageContinuation === true && deferredStages.length && !signal.aborted) {
686
+ const primaryDone = input.files.filter(file => file.eligibility === 'reviewable' && (options.resumeState?.completed?.some(row => row.fileId === file.id) || primaryStageKeys.get(file.id)?.length && primaryStageKeys.get(file.id).every(key => acceptedStages.has(key)))).map(file => file.id);
687
+ const units = deferredStages.map(stage => ({ id: hash(stage.key), kind: stage.phase, fileIds: stage.fileIds, dependencies: stage.fileIds.map(id => 'primary:' + id) }));
688
+ const continuationOptions = { rounds, maxInputBytes, enableMigrationContracts: options.enableMigrationContracts === true, enableMigrationRegression: options.enableMigrationRegression === true };
689
+ let continuation = createStageContinuation(input, continuationOptions, { completedPrimary: primaryDone, units, maxAttempts: 2 });
690
+ if (options.resumeState?.continuation?.state) {
691
+ const fresh = continuation.checkpoint(), prior = options.resumeState.continuation.state;
692
+ for (const unit of fresh.units) { const old = prior.units.find(item => item.id === unit.id && item.kind === unit.kind && JSON.stringify(item.fileIds) === JSON.stringify(unit.fileIds)); if (old && old.status !== 'completed') { unit.attempts = Math.min(fresh.maxAttempts, old.attempts); unit.stopped = unit.attempts >= fresh.maxAttempts; unit.pendingReason = unit.stopped ? 'STAGE_ATTEMPTS_EXHAUSTED' : 'STAGE_INTERRUPTED'; } }
693
+ continuation = createStageContinuation(input, continuationOptions, { completedPrimary: primaryDone, units, checkpoint: fresh, maxAttempts: 2 });
694
+ }
695
+ for (let pass = 0; pass < 2; pass++) {
696
+ let progressed = false;
697
+ for (const stage of deferredStages) {
698
+ if (stage.done || !continuation.ready().some(unit => unit.id === hash(stage.key))) continue;
699
+ if (performance.now() >= fileDeadlines.get(stage.fileId) || signal.aborted || report.modelCalls >= maxCalls) { continuation.stop(hash(stage.key), 'STAGE_NO_PROGRESS'); continue; }
700
+ const admitted = !yieldedUnits.has(stage.allocationId) ? { resumed: true } : stage.requests.length ? readerAllocation.resume(stage.allocationId, stage.requests) : readerAllocation.resumeBudget(stage.allocationId);
701
+ if (!admitted.resumed) continue;
702
+ yieldedUnits.delete(stage.allocationId);
703
+ const token = continuation.start(hash(stage.key), { signal });
704
+ stage.attempts++; await stage.run();
705
+ progressed = true;
706
+ if (stage.succeeded && !yieldedUnits.has(stage.allocationId)) { stage.done = true; continuation.complete(hash(stage.key), token.attempt, { signal }); if (!deferredStages.some(other => other !== stage && !other.done && other.allocationId === stage.allocationId)) readerAllocation.release(stage.allocationId); }
707
+ else { continuation.yield(hash(stage.key), stage.reason); if (!yieldedUnits.has(stage.allocationId)) continuation.stop(hash(stage.key), 'STAGE_FAILED'); }
708
+ }
709
+ if (!progressed) break;
710
+ }
711
+ report.stageContinuation = continuation.summary();
712
+ report.continuation = continuation.checkpoint();
713
+ report.continuationOptions = continuationOptions;
714
+ for (const file of input.files) {
715
+ const result = fileResults[input.files.indexOf(file)];
716
+ const pending = deferredStages.filter(stage => stage.fileId === file.id && !stage.done);
717
+ if (result?.state.continuationPending && !pending.length) { result.state.continuationPending = false; if (result.state.status === 'pending' && primaryDone.includes(file.id) && !result.state.stageFailures?.length && !result.state.reviewPasses?.some(pass => pass.status !== 'completed')) result.state.status = result.state.retrievalLimited ? 'pending' : 'completed'; }
718
+ }
719
+ checkpointStages();
720
+ await options.onResumeCheckpoint?.({ ...report, files: fileResults.map(result => result.state), findings: fileResults.flatMap(result => result.findings), limitations: [...report.limitations, ...fileResults.flatMap(result => result.limitations)] });
721
+ }
722
+ for (const id of yieldedUnits) readerAllocation?.release(id);
553
723
  if (checkpointFailed) throw checkpointFailure;
724
+ for (const result of fileResults) if (result?.state.continuationPending === false) { const state = result.state; state.resumeEligible = state.status === 'completed' && !state.stageFailures?.length && !state.reviewPasses?.some(pass => pass.status !== 'completed') && !state.windowCoverage?.pending?.length && !state.retrievalLimited && result.findings.every(finding => finding.verification?.status === 'completed') && report.interactions.filter(item => item.fileIds?.includes(state.fileId)).every(item => item.status === 'completed'); }
554
725
  report.files = fileResults.map(result => result.state);
555
726
  report.findings = fileResults.flatMap(result => result.findings);
556
727
  report.limitations.push(...fileResults.flatMap(result => result.limitations));
@@ -576,6 +747,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
576
747
  await options.onResumeCheckpoint?.(report);
577
748
  report.followup = coverageFollowup(report);
578
749
  if (scope) report.readerBudget = { ...scope.usage(), ...scope.budget(), ...readerLimits };
750
+ if (readerAllocation) report.readerAllocation = readerAllocation.summary();
579
751
  report.diagnostics = reviewDiagnostics(report, { reviewWallMs: performance.now() - startedAt });
580
752
  return report;
581
753
  }
@@ -621,7 +793,12 @@ function renderFinding(output, finding, index, duplicates) {
621
793
  }
622
794
 
623
795
  export function markdownReport(report) {
624
- const output = ['# Code Review 报告', '', '状态:' + report.status, '快照:' + report.snapshotId, '', '## 覆盖情况'];
796
+ const output = ['# Code Review 报告', '', '状态:' + report.status, '快照:' + report.snapshotId, ...reportSummaryMarkdown(report), '', '## 审查发现'];
797
+ if (!report.findings.length) output.push(report.status === 'completed' ? '在已审查范围内未发现具体问题;不代表代码已被证明正确。' : '当前没有有效问题记录,但审查存在未完成项或限制,不能视为通过。');
798
+ const duplicates = new Map();
799
+ for (const group of groupDuplicateFindings(report.findings)) for (const index of group.findingIndices.slice(1)) duplicates.set(index, group.findingIndices[0]);
800
+ for (const [index, finding] of report.findings.entries()) renderFinding(output, finding, index, duplicates);
801
+ output.push('', '## 覆盖情况');
625
802
  if (report.callerDiscovery) output.push('- 引用方发现:扫描 '+report.callerDiscovery.scanned.length+' 个文件,捕获 '+report.callerDiscovery.capturedPaths.length+' 个上下文;仅批准目录字面引用导航,不证明函数调用或完整覆盖。');
626
803
  if(report.businessRequirement)output.push('- 业务需求:SHA256 '+safe(report.businessRequirement.hash)+' · '+report.businessRequirement.bytes+' bytes;不可信约束,不是证据或权限,全文不在报告重复。');
627
804
  for (const file of report.files) for (const correction of file.anchorCorrections ?? []) output.push('- 定位纠正:' + safe(correction.originalCandidate.fileId) + ' · ' + safe(correction.status) + ' · ' + safe(correction.reason) + ';原完整候选保留JSON,仅定位不证明因果');
@@ -640,18 +817,10 @@ export function markdownReport(report) {
640
817
  }
641
818
  for (const file of report.files) if (file.windowCoverage) output.push('- ' + safe(file.path) + ':变更窗口已完成 ' + file.windowCoverage.completed.length + ',待审 ' + file.windowCoverage.pending.length + ';仅表示窗口分析完成,不证明全文正确');
642
819
  for (const file of report.files) if (file.synthesisStatus) output.push('- ' + safe(file.path) + ':跨窗口综合 ' + safe(file.synthesisStatus) + ';不等于事实正确性证明');
643
- for (const interaction of report.interactions ?? []) output.push('- 跨文件综合 ' + safe(interaction.groupId) + ':' + safe(interaction.status) + ';共享组末主文件超时,不证明完整交互覆盖');
820
+ for (const interaction of report.interactions ?? []) output.push('- 跨文件综合 ' + safe(interaction.groupId) + ':' + safe(interaction.status) + (interaction.failureCode ? ' · ' + safe(interaction.failureCode) : '') + (interaction.reason ? ' — ' + safe(interaction.reason) : '') + ';与组末主文件共享生命周期,不证明完整交互覆盖');
644
821
  if (report.rules?.length) output.push('', '## 项目规则', '规则仅为已批准约束数据,不扩大权限或证明缺陷。', ...report.rules.map(rule => '- ' + safe(rule.path) + ' · SHA256 ' + safe(rule.hash)));
645
822
  if (report.followup?.suggestedPaths.length) output.push('', '## 后续选择建议', '仅为原快照未完成项;不会自动续审。再次执行需新预览、新审批,并显式选择规则和上下文。阻断项需先处理原因;关联上下文不等于主文件已审查。', ...report.followup.items.filter(item => item.followup).map(item => '- ' + safe(item.path) + ':' + item.status + (item.incompleteVerification ? '(候选复核未完成)' : '')));
646
- output.push('', '## 审查发现');
647
- if (!report.findings.length) output.push(report.status === 'completed' ? '在已审查范围内未发现具体问题;不代表代码已被证明正确。' : '当前没有有效问题记录,但审查存在未完成项或限制,不能视为通过。');
648
823
  output.push(...diagnosticsMarkdown(report.diagnostics));
649
- // Recompute from exact data rather than trusting potentially stale group metadata.
650
- const duplicates = new Map();
651
- for (const group of groupDuplicateFindings(report.findings)) {
652
- for (const index of group.findingIndices.slice(1)) duplicates.set(index, group.findingIndices[0]);
653
- }
654
- for (const [index, finding] of report.findings.entries()) renderFinding(output, finding, index, duplicates);
655
824
  if (report.limitations.length) output.push('', '## 限制', ...report.limitations.map(l => '- ' + safe(l.text)));
656
825
  return output.join('\n') + '\n';
657
826
  }