@feng0531/dsh-code-review 0.33.7 → 0.34.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.
package/CHANGELOG.md CHANGED
@@ -1,9 +1,20 @@
1
1
  # 变更记录
2
2
 
3
+ ## 0.34.1
4
+
5
+ - preview工具显式reviewRounds整数1..3,默认1,预览minimumCalls显示工作量并绑定服务保存值;execute不接受覆盖,调用者改参数或返回preview不改变批准发送量。
6
+ - 非法/null轮次在capture及发送之前拒绝;沿用原owner/route/TTL/快照校验与共享预算,不增加审批。360离线通过,待固定标签及同工件门禁。
7
+
8
+ ## 0.34.0
9
+
10
+ - 服务显式reviewRounds=1..3(默认1避免静默增加发送)支持独立pass,后续轮不带首轮计划,各轮主审/候选反证与综合共享原调用/检索/超时预算,不继承模型对话或已读证据。
11
+ - 各阶段reviewPasses与候选reviewRound保留,跨文件completedRounds完整才标完成;调用耗尽后未发送pass保留pending,取消/超时不伪造完成。预览minimumCalls及Markdown披露工作量。
12
+ - 353冻结离线测试及真实宿主smoke、固定标签四矩阵通过;唯一发布37915572586原工件92276字节与npm/Release逐字节及双hash一致。
13
+
3
14
  ## 0.33.7
4
15
 
5
16
  - C++窗口导航重复逐项confidence/notice移为统一免责声明,保留全部已生成line/expression/side,不裁剪hunk或批准源码。复现12KiB阻塞场景现为7217字节ready,预览/执行一致;导航不证明绑定/可达性或全部调用覆盖。
6
- - 340离线测试通过,候选待固定标签四矩阵及同工件发布核验。
17
+ - 340冻结离线测试及固定标签四矩阵通过,唯一发布37914089607原工件91349字节与npm/Release逐字节及双hash一致。
7
18
 
8
19
  ## 0.33.6
9
20
 
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  ## 当前状态
6
6
 
7
- 当前正式版本为 0.33.6(固定标签四矩阵及npm/Release原工件字节双hash门禁通过)。JSON围栏兼容与有界窗口上下文已交付;实际项目审查效果仍需原报告和新预览执行验证。
7
+ 当前正式版本为 0.34.0(固定标签四矩阵及npm/Release原工件字节双hash门禁通过)。JSON围栏兼容与有界窗口上下文已交付;实际项目审查效果仍需原报告和新预览执行验证。
8
8
 
9
9
  Git历史预览新增oldContextPaths:最多20个显式仓库相对文件,与contextPaths/自动上下文共享20槽、256KiB每blob及4MiB快照。仅从baseline普通Git blob捕获,不checkout/fetch/读取工作树;根提交无baseline拒绝,working/SVN明确拒绝。旧侧条目携带oldRevision/oldBlobOid/oldHash,仅进入context-old;oldOnly表示仅批准旧侧,不证明target路径不存在。所选变更复用原changed-old,不重复上下文;excluded/blocked变更、链接/submodule、凭据路径不能重新进入。预览显示身份元数据、不显示正文,确认执行才按宿主正常权限发送。主审/综合/复核按侧提示,计入原输入预算,旧证据不能冒充新侧回归证明。
10
10
 
package/index.mjs CHANGED
@@ -12,7 +12,7 @@ export function apply(ctx, config = {}) {
12
12
  };
13
13
  ctx.tools.register({
14
14
  name: 'code_review_preview', description: 'Preview Git/SVN working-copy or Git commit/range review scope and model destination without sending source to a model. repositoryPath selects an explicit child repository relative to session workspace; file paths are repository-relative. Preview each repository separately. Show the preview before execution.',
15
- parameters: { type: 'object', additionalProperties: false, properties: { oldContextPaths: { type: 'array', maxItems: 20, items: { type: 'string' }, description: 'Explicit Git historical baseline-only blob context paths. Shares 20 context slots. No target text is inferred; selected old changes reuse their original identity, excluded changes cannot reenter. Unsupported on working reviews and SVN.' }, callerScopePaths: { type: 'array', minItems: 1, maxItems: 4, items: { type: 'string' }, description: 'Explicit non-root repository-relative directories for bounded tracked literal reverse import/include discovery. Git working review only. Preview reads candidate bodies, including nonmatches; only captured matching context is sent. Navigation only, not complete caller coverage.' }, businessRequirement: { type: 'string', maxLength: 16384, description: 'Optional business constraint text up to 16KiB UTF8; untrusted data, not evidence or permission. Bound to preview and sent in review phases.' }, commit: { type: 'string', description: 'Git commit versus sole parent; root empty, merge requires range.' }, baseRevision: { type: 'string', description: 'Git old endpoint; requires targetRevision, mutually exclusive with commit.' }, targetRevision: { type: 'string', description: 'Git new endpoint, exact tree endpoints not merge-base. Context is captured from target tree only; explicit rules are target-tree blobs too.' }, repositoryPath: { type: 'string', description: 'Workspace-relative explicit child Git/SVN repository root, forward slashes; omit for current repository.' }, selectedPaths: { type: 'array', items: { type: 'string' }, maxItems: 200 }, autoContext: { type: 'boolean', description: 'Opt in to bounded direct tracked Git/SVN import/include context captured and shown in preview.' }, contextPaths: { type: 'array', items: { type: 'string' }, maxItems: 20 }, rulePaths: { type: 'array', items: { type: 'string' }, maxItems: 4 } } },
15
+ parameters: { type: 'object', additionalProperties: false, properties: { reviewRounds: { type: 'integer', minimum: 1, maximum: 3, description: 'Explicit independent review passes, default 1. Preview discloses extra sends; later passes omit the first risk plan. Shares existing timeout, call and retrieval budgets, not provider reasoning effort. Bound to this preview; execute cannot override.' }, oldContextPaths: { type: 'array', maxItems: 20, items: { type: 'string' }, description: 'Explicit Git historical baseline-only blob context paths. Shares 20 context slots. No target text is inferred; selected old changes reuse their original identity, excluded changes cannot reenter. Unsupported on working reviews and SVN.' }, callerScopePaths: { type: 'array', minItems: 1, maxItems: 4, items: { type: 'string' }, description: 'Explicit non-root repository-relative directories for bounded tracked literal reverse import/include discovery. Git working review only. Preview reads candidate bodies, including nonmatches; only captured matching context is sent. Navigation only, not complete caller coverage.' }, businessRequirement: { type: 'string', maxLength: 16384, description: 'Optional business constraint text up to 16KiB UTF8; untrusted data, not evidence or permission. Bound to preview and sent in review phases.' }, commit: { type: 'string', description: 'Git commit versus sole parent; root empty, merge requires range.' }, baseRevision: { type: 'string', description: 'Git old endpoint; requires targetRevision, mutually exclusive with commit.' }, targetRevision: { type: 'string', description: 'Git new endpoint, exact tree endpoints not merge-base. Context is captured from target tree only; explicit rules are target-tree blobs too.' }, repositoryPath: { type: 'string', description: 'Workspace-relative explicit child Git/SVN repository root, forward slashes; omit for current repository.' }, selectedPaths: { type: 'array', items: { type: 'string' }, maxItems: 200 }, autoContext: { type: 'boolean', description: 'Opt in to bounded direct tracked Git/SVN import/include context captured and shown in preview.' }, contextPaths: { type: 'array', items: { type: 'string' }, maxItems: 20 }, rulePaths: { type: 'array', items: { type: 'string' }, maxItems: 4 } } },
16
16
  output,
17
17
  async execute(args, exec) { const preview = await service.preview(args, exec); return { json: JSON.stringify(preview), markdown: '' }; },
18
18
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feng0531/dsh-code-review",
3
- "version": "0.33.7",
3
+ "version": "0.34.1",
4
4
  "private": false,
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
@@ -1,3 +1,4 @@
1
+ import {reviewRounds} from './review-rounds.mjs';
1
2
  import {riskPlanningDecision} from './risk-plan.mjs';
2
3
  import {localBusinessGroups} from './business-groups.mjs';
3
4
  import { prepareInteractionInput } from './interaction-input.mjs';
@@ -6,6 +7,7 @@ import { preparePrimaryInput } from './primary-input.mjs';
6
7
 
7
8
  // Metadata only. No retained payload, file reads, model calls, or approval grants.
8
9
  export function buildCoveragePlan(input, options) {
10
+ const rounds=reviewRounds(options?.reviewRounds);
9
11
  if (!input || typeof input.id !== 'string' || !input.id || !Array.isArray(input.files) || input.files.length > 200) throw new Error('Invalid planning snapshot');
10
12
  if (!options || typeof options.instructions !== 'string' || !Number.isSafeInteger(options.maxInputBytes) || options.maxInputBytes < 1) throw new Error('Invalid planning budget');
11
13
  const ids = new Set(), paths = new Set();
@@ -19,12 +21,12 @@ export function buildCoveragePlan(input, options) {
19
21
  if (file.eligibility !== 'reviewable') return item;
20
22
  const prepared = preparePrimaryInput(input, file, options);
21
23
  const riskPlanning = options.enableRiskPlanning === true ? options.skipSmallRiskPlans === true ? riskPlanningDecision([file]) : {required:true,reason:'unconditional'} : {required:false,reason:'disabled'};
22
- return { ...item, ...prepared.metadata, riskPlanning, status: prepared.budget.fits ? 'ready' : 'input-blocked', minimumCalls: prepared.budget.fits ? ((prepared.batches?.length ?? 1) * (riskPlanning.required ? 2 : 1) + (prepared.synthesis?.budget.fits ? 1 : 0)) : 0 };
24
+ return { ...item, ...prepared.metadata, riskPlanning, status: prepared.budget.fits ? 'ready' : 'input-blocked', minimumCalls: prepared.budget.fits ? ((prepared.batches?.length ?? 1) * (rounds + (riskPlanning.required ? 1 : 0)) + (prepared.synthesis?.budget.fits ? rounds : 0)) : 0 };
23
25
  });
24
26
  const local = options.enableBusinessGrouping === true ? localBusinessGroups(input.files) : null;
25
27
  const groups = [...(options.grouping?.groups ?? [])];
26
28
  for(const group of local?.groups ?? [])if(!groups.some(g=>[...g.fileIds].sort().join(',')===[...group.fileIds].sort().join(',')))groups.push(group);
27
- const interactions = options.scope && groups.length ? groups.filter(g => g.fileIds.length > 1).map(group => { const prepared = prepareInteractionInput(input, group, payload => initialInputBudget({ instructions: options.instructions, input: payload }, options.scope, options.maxInputBytes)); const riskPlanning = options.enableRiskPlanning === true ? options.skipSmallRiskPlans === true ? riskPlanningDecision(input.files.filter(f=>group.fileIds.includes(f.id))) : {required:true,reason:'unconditional'} : {required:false,reason:'disabled'}; return { groupId: group.id, fileIds: prepared.fileIds, riskPlanning, status: prepared.status, reason: prepared.reason, minimumCalls: prepared.status === 'ready' ? (riskPlanning.required ? 2 : 1) : 0 }; }) : [];
28
- const businessGrouping = options.enableBusinessGrouping === true && input.files.filter(f => f.eligibility === 'reviewable').length > 1 ? local ? {minimumCalls:0,strategy:local.strategy,churn:local.churn,notice:'本地调度无语义依赖证明。'} : { minimumCalls: 1, dynamicInteractionCalls: 'unknown-until-execution', maximumAdditionalInitialCalls: Math.floor(input.files.filter(f => f.eligibility === 'reviewable').length / 2) * (options.enableRiskPlanning === true ? 2 : 1), notice: '模型分组假设仅调度,额外综合动态且不保证预算或覆盖。' } : null;
29
- return { ...(businessGrouping ? { businessGrouping } : {}), interactions, snapshotId: input.id, maxInputBytes: options.maxInputBytes, items, minimumCalls: (businessGrouping?.minimumCalls ?? 0) + items.reduce((sum,item) => sum + item.minimumCalls, 0) + interactions.reduce((sum,item) => sum + item.minimumCalls, 0), initialRequestsOnly: true };
29
+ const interactions = options.scope && groups.length ? groups.filter(g => g.fileIds.length > 1).map(group => { const prepared = prepareInteractionInput(input, group, payload => initialInputBudget({ instructions: options.instructions, input: payload }, options.scope, options.maxInputBytes)); const riskPlanning = options.enableRiskPlanning === true ? options.skipSmallRiskPlans === true ? riskPlanningDecision(input.files.filter(f=>group.fileIds.includes(f.id))) : {required:true,reason:'unconditional'} : {required:false,reason:'disabled'}; return { groupId: group.id, fileIds: prepared.fileIds, riskPlanning, status: prepared.status, reason: prepared.reason, minimumCalls: prepared.status === 'ready' ? (rounds + (riskPlanning.required ? 1 : 0)) : 0 }; }) : [];
30
+ const businessGrouping = options.enableBusinessGrouping === true && input.files.filter(f => f.eligibility === 'reviewable').length > 1 ? local ? {minimumCalls:0,strategy:local.strategy,churn:local.churn,notice:'本地调度无语义依赖证明。'} : { minimumCalls: 1, dynamicInteractionCalls: 'unknown-until-execution', maximumAdditionalInitialCalls: Math.floor(input.files.filter(f => f.eligibility === 'reviewable').length / 2) * (rounds + (options.enableRiskPlanning === true ? 1 : 0)), notice: '模型分组假设仅调度,额外综合动态且不保证预算或覆盖。' } : null;
31
+ return { ...(businessGrouping ? { businessGrouping } : {}), interactions, snapshotId: input.id, reviewRounds:rounds, maxInputBytes: options.maxInputBytes, items, minimumCalls: (businessGrouping?.minimumCalls ?? 0) + items.reduce((sum,item) => sum + item.minimumCalls, 0) + interactions.reduce((sum,item) => sum + item.minimumCalls, 0), initialRequestsOnly: true };
30
32
  }
@@ -0,0 +1,5 @@
1
+ // Work quantity, never a provider reasoning-effort or evidence guarantee.
2
+ export function reviewRounds(value=1){
3
+ if(!Number.isSafeInteger(value)||value<1||value>3)throw new Error('reviewRounds must be an integer from 1 to 3');
4
+ return value;
5
+ }
package/src/review.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { reviewRounds } from './review-rounds.mjs';
1
2
  import { correctAnchor } from './anchor-correction.mjs';
2
3
  import { validateBusinessRequirement } from './business-requirement.mjs';
3
4
  import { riskReadCoverage } from './risk-read-coverage.mjs';
@@ -75,6 +76,7 @@ async function invoke(model, request, timeoutMs, parentSignal) {
75
76
  }
76
77
 
77
78
  export async function reviewSnapshot(snapshot, model, options = {}) {
79
+ const rounds = reviewRounds(options.reviewRounds);
78
80
  const { signal, maxInputBytes = 96 * 1024, maxCalls = 100, timeoutMs = 120_000 } = options;
79
81
  if (typeof model !== 'function') throw new Error('A model executor is required');
80
82
  for (const n of [maxInputBytes, maxCalls, timeoutMs]) if (!Number.isSafeInteger(n) || n < 1) throw new Error('Invalid review limit');
@@ -168,10 +170,12 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
168
170
  if (prepared.synthesis?.budget.fits) batches.push({ payload: prepared.synthesis.payload, windowIds: [], synthesis: true });
169
171
  const matchingInteractionPlans = interactionPlans.filter(p => input.files.filter(f => p.group.fileIds.includes(f.id)).at(-1)?.id === file.id);
170
172
  for (const interactionPlan of matchingInteractionPlans.filter(p => p.prepared.status === 'ready')) batches.push({ payload: interactionPlan.prepared.payload, windowIds: [], interaction: interactionPlan.group });
171
- for (const batch of batches) {
173
+ const scheduled = Array.from({length:rounds},(_,i)=>batches.map(b=>({...b,round:i+1}))).flat();
174
+ 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'}));
175
+ for (const [batchIndex,batch] of scheduled.entries()) {
172
176
  enclosingRequest.signal.throwIfAborted();
173
177
  let batchPayload = batch.payload;
174
- if (options.enableRiskPlanning === true && !batch.synthesis) {
178
+ if (options.enableRiskPlanning === true && !batch.synthesis && batch.round === 1) {
175
179
  const decision = options.skipSmallRiskPlans === true ? riskPlanningDecision(batch.interaction ? input.files.filter(f=>batch.interaction.fileIds.includes(f.id)) : [file]) : {required:true,reason:'unconditional'};
176
180
  state.riskPlans ??= [];
177
181
  const entry = { status: 'pending', windowIds: batch.windowIds, ...(batch.interaction ? {groupId:batch.interaction.id,sourceMode:'file-interaction'} : {}), risks: [] };
@@ -194,6 +198,8 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
194
198
  }
195
199
  }
196
200
  }
201
+ if(report.modelCalls>=maxCalls)throw new Error('Model call budget exceeded before next review pass');
202
+ if(rounds>1) state.reviewPasses[batchIndex].status='running';
197
203
  const request = { ...enclosingRequest, input: batchPayload };
198
204
  const auditStart = state.retrievalAudit?.length ?? 0;
199
205
  let response = await executor(request);
@@ -211,7 +217,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
211
217
  response = decoded;
212
218
  }
213
219
  }
214
- if (options.enableRiskPlanning === true && !batch.synthesis) {
220
+ if (options.enableRiskPlanning === true && !batch.synthesis && batch.round === 1) {
215
221
  const entry=state.riskPlans.at(-1);
216
222
  entry.sourceReadCoverage=riskReadCoverage(entry.risks,(state.retrievalAudit??[]).slice(auditStart),scope?.catalog()??[],input.id);
217
223
  if(entry.sourceReadCoverage.some(c=>c.unreadSourceIds.length)) report.limitations.push({fileId:file.id,text:'风险计划请求的部分来源未在本次生成阶段读取;不能视为已核查风险条件或排除问题'});
@@ -220,6 +226,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
220
226
  if (batch.synthesis) validateSynthesisReads(generated.findings, changes, (state.retrievalAudit ?? []).slice(auditStart), scope.catalog(), file.id, input.id);
221
227
  if (!batch.interaction && state.sourceMode === 'change-windows') validateWindowFindings(generated.findings, batch.synthesis ? prepared.synthesis.windows : JSON.parse(batch.payload).windows, batch.synthesis === true);
222
228
  request.signal.throwIfAborted();
229
+ if(rounds>1)generated.findings.forEach(f=>{f.reviewRound=batch.round;});
223
230
  report.findings.push(...generated.findings);
224
231
  if (generated.findings.some(f => f.attribution.status === 'missing')) report.limitations.push({ fileId: file.id, text: '部分发现缺少变更归因,仅校验了定位,尚不能确认属于本次回归' });
225
232
  report.limitations.push(...generated.limitations.map(text => ({ fileId: file.id, text })));
@@ -242,11 +249,16 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
242
249
  }
243
250
  }
244
251
  if (batch.synthesis) state.synthesisStatus = 'completed';
245
- if (batch.interaction) report.interactions.find(p => p.groupId === batch.interaction.id).status = 'completed';
246
- if (state.windowCoverage) {
252
+ if (batch.interaction) {
253
+ const interaction=report.interactions.find(p => p.groupId === batch.interaction.id);
254
+ if(rounds>1){interaction.completedRounds??=[];interaction.completedRounds.push(batch.round);interaction.reviewRounds=rounds;}
255
+ if(batch.round===rounds)interaction.status='completed';
256
+ }
257
+ if (state.windowCoverage && batch.round === 1) {
247
258
  state.windowCoverage.completed.push(...batch.windowIds);
248
259
  state.windowCoverage.pending = state.windowCoverage.pending.filter(id => !batch.windowIds.includes(id));
249
260
  }
261
+ if(rounds>1)state.reviewPasses[batchIndex].status='completed';
250
262
  }
251
263
  }, { instructions: REVIEW_INSTRUCTIONS, input: payload }, timeoutMs, signal);
252
264
  if (signal?.aborted) throw signal.reason;
@@ -256,6 +268,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
256
268
  } catch (error) {
257
269
  state.status = signal?.aborted ? 'cancelled' : 'failed';
258
270
  state.reason = error?.message ?? String(error);
271
+ for(const pass of state.reviewPasses??[])if(pass.status==='running')pass.status=signal?.aborted?'cancelled':'failed';
259
272
  state.failureCode = signal?.aborted ? 'CANCELLED' : ['MODEL_INVALID_JSON','MODEL_TIMEOUT'].includes(error?.code) ? error.code : 'REVIEW_EXECUTION_FAILED';
260
273
  }
261
274
  }
@@ -293,6 +306,7 @@ export function markdownReport(report) {
293
306
  if (report.history) output.push('- Git历史审查:' + safe(report.history.mode) + ' · ' + safe(report.history.base ?? 'empty') + ' → ' + safe(report.history.target) + ';精确端点树差异,重命名按增删,不含工作树');
294
307
  if (report.businessGrouping) output.push('- 业务分组:' + safe(report.businessGrouping.status) + (report.businessGrouping.reason ? ' — ' + safe(report.businessGrouping.reason) : '') + ';仅调度假设,不证明依赖或覆盖');
295
308
  for (const file of report.files) for (const plan of file.riskPlans ?? []) output.push('- 风险计划:' + safe(file.path) + ' · ' + safe(plan.status) + ' · ' + plan.risks.length + '项待证假设;不表示缺陷成立或覆盖完成');
309
+ for (const file of report.files) if(file.reviewPasses)output.push('- 独立审查轮次 '+safe(file.path)+':'+file.reviewPasses.filter(p=>p.status==='completed').length+'/'+file.reviewPasses.length+'阶段完成;同一生命周期预算,后续轮无首轮计划;不证明代码正确。');
296
310
  for (const file of report.files) output.push('- ' + safe(file.path) + ':' + file.status + (file.reason ? ' — ' + safe(file.reason) : ''));
297
311
  if (report.grouping) {
298
312
  const names = new Map(report.files.map(f => [f.fileId, f.path]));
package/src/service.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { reviewRounds as validateReviewRounds } from './review-rounds.mjs';
1
2
  import { validateCallerScopes } from './caller-index.mjs';
2
3
  import { validateBusinessRequirement } from './business-requirement.mjs';
3
4
  import { hash } from './content.mjs';
@@ -17,7 +18,7 @@ function owner(exec) {
17
18
  if (!agent || typeof cwd !== 'string' || !cwd || !agent.options?.provider || !agent.options?.model) throw new Error('Active Agent with cwd and model is required');
18
19
  return { agent, cwd, route: { provider: agent.options.provider, model: agent.options.model, ...(agent.options.reasoningEffort ? { reasoningEffort: agent.options.reasoningEffort } : {}) } };
19
20
  }
20
- export function createReviewService(llm, { capture = captureSnapshot, now = Date.now, ttlMs = 300000, maxPreviews = 8, allowModelSending = false, enableRiskPlanning = false, skipSmallRiskPlans = true, enableBusinessGrouping = false, enableAnchorCorrection = false, authorize = async () => false } = {}) {
21
+ export function createReviewService(llm, { capture = captureSnapshot, now = Date.now, ttlMs = 300000, maxPreviews = 8, allowModelSending = false, enableRiskPlanning = false, skipSmallRiskPlans = true, reviewRounds = 1, enableBusinessGrouping = false, enableAnchorCorrection = false, authorize = async () => false } = {}) {
21
22
  for (const value of [ttlMs, maxPreviews]) if (!Number.isSafeInteger(value) || value < 1) throw new Error('Invalid preview limit');
22
23
  const lifetime = new AbortController();
23
24
  const bind = exec => ({ ...exec, signal: exec.signal ? AbortSignal.any([exec.signal, lifetime.signal]) : lifetime.signal });
@@ -29,9 +30,10 @@ export function createReviewService(llm, { capture = captureSnapshot, now = Date
29
30
  exec = bind(exec);
30
31
  exec.signal.throwIfAborted();
31
32
  if (busy) throw new Error('Review service busy');
32
- if (!args || Object.keys(args).some(k => !['repositoryPath', 'selectedPaths', 'contextPaths', 'rulePaths', 'autoContext', 'commit', 'baseRevision', 'targetRevision','businessRequirement','callerScopePaths','oldContextPaths'].includes(k))) throw new Error('Unknown preview argument');
33
+ if (!args || Object.keys(args).some(k => !['repositoryPath', 'selectedPaths', 'contextPaths', 'rulePaths', 'autoContext', 'commit', 'baseRevision', 'targetRevision','businessRequirement','callerScopePaths','oldContextPaths','reviewRounds'].includes(k))) throw new Error('Unknown preview argument');
33
34
  if (args.autoContext !== undefined && typeof args.autoContext !== 'boolean') throw new Error('Invalid autoContext option');
34
35
  for (const key of ['selectedPaths', 'contextPaths', 'rulePaths', 'oldContextPaths']) if (args[key] !== undefined && (!Array.isArray(args[key]) || args[key].length > (key === 'rulePaths' ? 4 : key === 'oldContextPaths' ? 20 : 200) || args[key].some(p => typeof p !== 'string'))) throw new Error('Invalid path list');
36
+ const rounds=validateReviewRounds(args.reviewRounds === undefined ? reviewRounds : args.reviewRounds);
35
37
  validateBusinessRequirement(args.businessRequirement);
36
38
  if (args.callerScopePaths !== undefined) validateCallerScopes(args.callerScopePaths);
37
39
  const current = owner(exec);
@@ -42,12 +44,12 @@ export function createReviewService(llm, { capture = captureSnapshot, now = Date
42
44
  const snapshot = await capture(target, { ...options, signal: exec.signal });
43
45
  exec.signal?.throwIfAborted();
44
46
  if (owner(exec).cwd !== current.cwd) throw new Error('Agent directory changed; preview again');
45
- const plan = buildCoveragePlan(snapshot, { enableRiskPlanning, skipSmallRiskPlans, enableBusinessGrouping, instructions: REVIEW_INSTRUCTIONS, scope: createRetrievalScope(snapshot, { signal: exec.signal }), grouping: buildReviewGroups(snapshot.files, inferFileRelations(snapshot.files)), maxInputBytes: 96 * 1024, signal: exec.signal });
47
+ const plan = buildCoveragePlan(snapshot, { enableRiskPlanning, skipSmallRiskPlans, reviewRounds:rounds, enableBusinessGrouping, instructions: REVIEW_INSTRUCTIONS, scope: createRetrievalScope(snapshot, { signal: exec.signal }), grouping: buildReviewGroups(snapshot.files, inferFileRelations(snapshot.files)), maxInputBytes: 96 * 1024, signal: exec.signal });
46
48
  prune();
47
49
  while (previews.size >= maxPreviews) previews.delete(previews.keys().next().value);
48
50
  const previewId = randomUUID();
49
- previews.set(previewId, { ...current, target, options, snapshotId: snapshot.id, expires: now() + ttlMs });
50
- return { ...(options.businessRequirement!==undefined?{businessRequirement:{text:options.businessRequirement,hash:hash(options.businessRequirement),bytes:Buffer.byteLength(options.businessRequirement)}}:{}), previewId, plan, snapshotId: snapshot.id, repositoryRoot: snapshot.root, vcs: snapshot.vcs, ...(snapshot.history ? { history: snapshot.history } : {}), model: current.route, modelSendingEnabled: allowModelSending, files: snapshot.files.map(f => ({ path: f.path, eligibility: f.eligibility, reason: f.reason })), contextPaths: snapshot.context.map(c => c.path), ...(options.oldContextPaths !== undefined ? {oldContextPaths:options.oldContextPaths,oldContextSources:snapshot.context.filter(c=>c.oldOnly).map(c=>({path:c.path,side:'context-old',revision:c.oldRevision,blobOid:c.oldBlobOid,hash:c.oldHash}))} : {}), ...(snapshot.callerDiscovery ? {callerDiscovery:snapshot.callerDiscovery} : {}), ...(snapshot.autoContext ? { autoContext: snapshot.autoContext } : {}), rules: (snapshot.rules ?? []).map(({ path, hash }) => ({ path, hash })), notice: (options.oldContextPaths !== undefined ? 'oldContextPaths仅为baseline旧侧正文,无目标侧替代;选中变更复用原old身份,旧侧不能证明新侧行为。' : '') + (snapshot.callerDiscovery ? 'callerScopePaths已授权预览读取限定目录内tracked候选正文,包括不匹配文件;非匹配正文不发送模型,匹配捕获上下文会发送。仅字面引用导航,不是函数调用或完整调用覆盖。' : '') + (enableRiskPlanning ? '执行按plan阈值决定风险假设规划,小变更可跳过独立规划但仍主审并复核;计划不是证据,所有阶段共享原预算。' : '') + '执行会向上述模型提供方发送可审查文件两侧内容、预览列出的显式/自动上下文、所选项目规则全文及预览业务需求文本。模型可在该快照范围内多轮只读检索,不读取范围外文件;候选生成后会进行证据与反证复核,两阶段各最多3轮检索,共享每文件120秒和总模型调用100次预算;复核不能证明缺陷成立。plan仅预检初始输入,ready不代表已审查;minimumCalls不含后续检索/复核/动态业务综合;多文件可先进行业务分组,分组失败保留原组并记录限制,动态综合数执行前未知,实际预算可能不足。请先向用户展示范围,获得确认后执行。' };
51
+ previews.set(previewId, { ...current, target, options, rounds, snapshotId: snapshot.id, expires: now() + ttlMs });
52
+ return { ...(options.businessRequirement!==undefined?{businessRequirement:{text:options.businessRequirement,hash:hash(options.businessRequirement),bytes:Buffer.byteLength(options.businessRequirement)}}:{}), previewId, plan, snapshotId: snapshot.id, repositoryRoot: snapshot.root, vcs: snapshot.vcs, ...(snapshot.history ? { history: snapshot.history } : {}), model: current.route, modelSendingEnabled: allowModelSending, files: snapshot.files.map(f => ({ path: f.path, eligibility: f.eligibility, reason: f.reason })), contextPaths: snapshot.context.map(c => c.path), ...(options.oldContextPaths !== undefined ? {oldContextPaths:options.oldContextPaths,oldContextSources:snapshot.context.filter(c=>c.oldOnly).map(c=>({path:c.path,side:'context-old',revision:c.oldRevision,blobOid:c.oldBlobOid,hash:c.oldHash}))} : {}), ...(snapshot.callerDiscovery ? {callerDiscovery:snapshot.callerDiscovery} : {}), ...(snapshot.autoContext ? { autoContext: snapshot.autoContext } : {}), rules: (snapshot.rules ?? []).map(({ path, hash }) => ({ path, hash })), notice: (options.oldContextPaths !== undefined ? 'oldContextPaths仅为baseline旧侧正文,无目标侧替代;选中变更复用原old身份,旧侧不能证明新侧行为。' : '') + (snapshot.callerDiscovery ? 'callerScopePaths已授权预览读取限定目录内tracked候选正文,包括不匹配文件;非匹配正文不发送模型,匹配捕获上下文会发送。仅字面引用导航,不是函数调用或完整调用覆盖。' : '') + (enableRiskPlanning ? '执行按plan阈值决定风险假设规划,小变更可跳过独立规划但仍主审并复核;计划不是证据,所有阶段共享原预算。' : '') + '执行会向上述模型提供方发送可审查文件两侧内容、预览列出的显式/自动上下文、所选项目规则全文及预览业务需求文本。模型可在该快照范围内多轮只读检索,不读取范围外文件;候选生成后会进行证据与反证复核,两阶段各最多3轮检索,共享每文件120秒和总模型调用100次预算;复核不能证明缺陷成立。plan.reviewRounds为独立审查工作量,非模型reasoning effort;后续轮不带首轮风险计划,所有轮共享原超时与预算。plan仅预检初始输入,ready不代表已审查;minimumCalls不含后续检索/复核/动态业务综合;多文件可先进行业务分组,分组失败保留原组并记录限制,动态综合数执行前未知,实际预算可能不足。请先向用户展示范围,获得确认后执行。' };
51
53
  } finally { busy = false; }
52
54
  },
53
55
  async execute(args, exec) {
@@ -70,7 +72,7 @@ export function createReviewService(llm, { capture = captureSnapshot, now = Date
70
72
  if (await authorize({ snapshot, route: preview.route, exec }) !== true) throw new Error('Model sending approval denied or unavailable');
71
73
  exec.signal?.throwIfAborted();
72
74
  if (owner(exec).cwd !== current.cwd || JSON.stringify(owner(exec).route) !== JSON.stringify(preview.route)) throw new Error('Agent changed during approval');
73
- const report = await reviewSnapshot(snapshot, createDshModel(llm, preview.route), { signal: exec.signal, enableRetrieval: true, enableGrouping: true, enableVerification: true, enableRiskPlanning, skipSmallRiskPlans, enableBusinessGrouping, enableAnchorCorrection });
75
+ const report = await reviewSnapshot(snapshot, createDshModel(llm, preview.route), { signal: exec.signal, enableRetrieval: true, enableGrouping: true, enableVerification: true, enableRiskPlanning, skipSmallRiskPlans, reviewRounds:preview.rounds, enableBusinessGrouping, enableAnchorCorrection });
74
76
  report.model = preview.route;
75
77
  const latest = await resolveWorkspaceRepository(current.cwd, preview.options.repositoryPath).then(target => target === preview.target ? capture(target, { ...preview.options, signal: exec.signal }) : null).catch(() => null);
76
78
  report.outdated = !latest || latest.id !== snapshot.id || owner(exec).cwd !== current.cwd;