@feng0531/dsh-code-review 0.89.0 → 0.90.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  本文件介绍对使用者有意义的变化。安装与参数见 [README](README.md),本次发布正文见 [发布说明](RELEASE-NOTES.md)。历史提交和安装包保留在 [GitHub Releases](https://github.com/f-e-n-g-0531/dsh-code-review/releases);本文不把开发日志当作功能介绍。
4
4
 
5
+ ## 0.90.0 — 清晰结论与有界复审闭环
6
+
7
+ - 数量优先摘要、稳定候选编号与严重程度排序,分开不确定和未完成,保留全部原始证据。
8
+ - 确定性风险投入与高风险候选独立反证,共享额度和新回执,反证失败不再记为完成;不使用多数票替代证据。
9
+ - owned历史台账、人工处置与最多三轮关联修复链;显式差异复审可省未变主审但始终partial,默认未知依赖全新主审,取消成果不丢失。
10
+ - 真实事件评测合同保留全部运行分母,未裁决无金标;r7364归档质量指标未知,不宣称已解决或提速。
11
+
5
12
  ## 0.89.0 — 超限分组的真实子智能体并行
6
13
 
7
14
  - 整体路径只有完整输入超限分组时创建真实spawn子会话,默认2组、可配1~4;同组持续上下文补读,主审全部收尾后集中复核。默认策略仍staged。
package/README.md CHANGED
@@ -288,6 +288,16 @@ Git / SVN 历史审查也能使用这些参数;依赖和调用方按对应版
288
288
 
289
289
  当前仅支持 fresh 单轮 changes;多轮、旧任务恢复及 project 明确拒绝,预览显示 `resumeSupported:false`。超大完整文件显示 blocked,不截断或把差异窗口冒充全文;多批跨边界未核查保持 partial。身份、原文定位、归因、独立复核回执及来源新鲜度校验不变。默认仍为 `staged`;整体路径尚无真实业务同条件质量与耗时验收,不宜把单测调用减少当作业务问题已解决。配置由宿主插件管理方式加载,发布不会自动安装或重启宿主。
290
290
 
291
+ ## 风险投入、发现台账与复审
292
+
293
+ `reviewEffort` 可选 `auto`(默认)、`quick`、`standard`、`deep`,在preview中明确。它是对批准变更的有界确定性检查,不额外调用模型规划;quick也不跳过原文、归因和独立复核。高风险线索只改变检查方向,不证明缺陷。深度模式下仅已获首轮支持的high/critical候选增加一次独立反证复核,使用新的读取回执;不把首轮结论传给第二位验证者。两次结论分歧、证据不足或反证未完成均保守处理,不按多数票升级。插件配置 `enableCounterVerification:false` 可关闭额外反证;开启状态在preview绑定。它可能增加调用和耗时,不承诺普遍提速。
294
+
295
+ 变更审查可在新后台preview传 `rereviewFromJobId` 接续本会话、相同仓库/模型/规则/需求政策的终态且未过期报告。每轮仍重新捕获、预览和授权;旧回执及结论不进入本轮证据。关联修复链第三轮提示重新考虑方案,第四轮拒绝;不是跨任务全局次数锁。可用 `findingOutcomes` 记录已知发现的 `fixed/refuted/intentional/deferred` 与原因,人工标记是处置声明,不证明修复。相同精确定位与缺陷主张保持台账身份,不因行号移动重复建项;未再次观察不自动关闭。
296
+
297
+ 默认 `rereviewScope:"full"`,未知完整依赖图时保持全新主审。明确选择 `rereviewScope:"diff-only"` 才将新旧两侧、属性和来源指纹都未变的主文件保留为未重审;本轮完整批准来源仍可检索。预览列出primary与unreviewed路径,实际省略未变主文件时报告强制partial,不能当作跨文件影响已排除或全范围通过。取消/失败终态不被partial覆盖。上述修复链和差异复审仅changes支持;project支持风险投入但不接受历史修复链参数。
298
+
299
+ 报告首屏先给复核支持数量和优先候选编号,分开已复核不确定与复核未完成,显示未查范围。正文按类别与严重程度展示,附录保留原候选顺序与完整证据。真实事件评测见[仓库评测说明](https://github.com/f-e-n-g-0531/dsh-code-review/blob/main/evaluation/README.md);没有专家确认和完整实际结果时不输出命中率。
300
+
291
301
  ## 工具调用流程(给需要精确指定参数的人)
292
302
 
293
303
  普通使用者只需上面的对话请求,不必手工构造 ID。需要精确控制时,让 Agent 按下列顺序调用:
package/RELEASE-NOTES.md CHANGED
@@ -1,17 +1,15 @@
1
- # v0.89.0 发布说明
1
+ # v0.90.0 发布说明
2
2
 
3
- ## 真正超限后,分组由子智能体并行处理
3
+ ## 报告、收敛复审、风险投入与真实事件评测统一优化
4
4
 
5
- 整体路径保持能装下就整体主审;只有完整输入超限形成多个组时,使用宿主真实spawn子智能体,`wholeScopeConcurrency` 默认2、可设1~4。每组一个独立Session和持续上下文,补读使用原会话,不把普通ctx.llm并发包装称为子智能体。全部主审收尾后仍集中独立复核;默认策略仍staged,整体路径需显式开启。
5
+ - 结论首屏按数量说明复核支持的新增/既有/当前源码问题,分开已复核不确定和复核未完成,优先列出严重候选编号与未查范围;不把空发现等同通过。原始候选及证据保持不变。
6
+ - 新增确定性风险投入auto/quick/standard/deep,不增加独立模型规划;quick保留严格校验。深度模式仅对首轮已支持的high/critical候选增加新回执的独立反证,不给第二验证者首轮结论;分歧保守不确定,反证失败明确incomplete,不缓存成完成。反证共用原预算、取消和来源边界,可能增加时间,不承诺提速。
7
+ - 变更审查新增owned终态报告修复链,fresh preview与授权不可省。稳定指纹合并重复定位和缺陷主张,人工fixed/refuted/intentional/deferred仅记录处置,不证明当前状态;未再次观察不自动fixed。关联链第三轮要求step-back,第四轮拒绝,不是全局次数锁。
8
+ - 默认全新主审。显式diff-only复审才减少完全未变文件的主审目标,批准原源仍可检索,跳过范围明确未重审,实际省略主文件时报告保持partial;未知依赖不得自动推断完整覆盖。不构造虚假历史差异,不继承旧回执或授权。取消与失败终态保留。修复链仅changes支持,project支持风险投入。
9
+ - 新增离线真实事件评测合同:固定快照/版本/模型及预注册全部运行,专家裁决绑定实际报告摘要哈希和候选序号;未裁决不作金标,缺失/失败/partial不从分母消失。分别统计误报、有害建议、未完成和操作耗时,不以最佳一次或模型自评作为成绩。
6
10
 
7
- 预览绑定并发数及执行方式,执行前重核宿主能力;缺失受限预设或结构化子会话能力明确拒绝,不隐藏回退。子会话只获得批准快照broker与结构化提交工具,不继承任意文件、shell、网络或递归委托能力。内部snapshot工具虽可能在父目录可见,但父会话及未绑定child在读取前拒绝。
11
+ ## 验证与未完成边界
8
12
 
9
- 多个child共享唯一读取账本与同步模型生成尝试总账;每次实际逻辑LLM调用先校验身份、路由、工具集合与输入容量再扣账。这里统计模型生成尝试和逻辑LLM输入,不声称统计adapter内部HTTP重试或最终HTTP字节。宿主提示及历史开销可能使规划组在发送前受限,不截断完整文件掩盖。
13
+ 本轮有合同、生产服务夹具、取消/身份/预算/定位回归与实际宿主独立离线子会话兼容验收;离线子会话只验证transport,不代表新增风险或反证管线真实模型成绩。r7364公开归档确证旧0.87.9为156次调用、861.351秒及大量未完成,但缺少完整原report JSON与专家确认标签,评测保持unadjudicated、质量指标null,不重建假报告或混用新鲜快照。没有同条件真实模型质量/耗时改善结论。
10
14
 
11
- 取消主动停止并等待子会话清理;检查点FIFO入队时冻结,存储错误中断并等待其他工作收尾,保留首个致命原因。统一报告按批准顺序汇总。多组跨边界未核查仍partial,不降低质量门槛。子会话由宿主按独立Session记录,可能含批准源码,不等同插件私有工件存储;不自动读这些会话作为审查证据。
12
-
13
- ## 验证边界
14
-
15
- 使用实际安装宿主的Agent/Session/Inbox/Loop/LLM/原生执行器/预设/子智能体运行时,在独立离线Context中验证file与group结构化路径:两个真实child并行,每个同id两次模型生成,中间读取批准fixture后保留上下文;只暴露snapshot与structured_output,结构化终态完成后释放,未绑定父读取拒绝。工作目录get/set、snapshot和模型提供方使用离线fixture,没有真实提供方调用、没有修改运行宿主。此证据证明执行契约,不证明真实业务质量、r7364闭环或提速。
16
-
17
- 本版不自动安装、改配置或重启宿主。配置见[使用说明](README.md),发布门禁见[开发说明](DEVELOPMENT.md)。
15
+ 本版保持只读,不自动修改/合并业务代码,不替代所需人类审批,不自动安装、改宿主配置或重启。配置见[使用说明](README.md),正式门禁见[开发说明](DEVELOPMENT.md),设计借鉴[Anthropic文章](https://claude.dev/blog/solving-code-review-at-anthropic/)。
@@ -17,6 +17,12 @@
17
17
 
18
18
  第二条命令只读取固定合成输入,校验内容指纹并计算初始输入大小,不调用模型。输出不含源码或答案标签;它也不是宿主执行授权。
19
19
 
20
+ ## 真实事件评测合同
21
+
22
+ [incident-eval.mjs](incident-eval.mjs)校验并评估外部注入的完整实际report与专家裁决,[schema](incidents.schema.json)定义固定快照/版本/模型身份、预注册全部runIds和confirmed/unadjudicated。confirmed中的reviewer与judgementRef是外部裁决声明,不是加密签名;评测器不自动确认专家身份或从模型判定推导金标。候选裁决必须绑定report精确JSON SHA256与索引。缺失、失败和partial保留所有预注册分母;未裁决或无金标时precision/recall为null,不以0冒充无缺陷。误报、有害建议、已处置与未完成分别统计。
23
+
24
+ 运行`node --test test/incident-eval.test.mjs`仅验证记账合同,不是业务成绩。真实业务素材、答案和原始会话保持仓库外,不进入安装包;模型输入不包含金标。当前r7364归档只验证旧调用和状态观察,缺完整report JSON与专家裁决,因此不宣称实际召回率或新版改进。任何prompt/工具/模型变化应重新运行同一组全部案例,不选最佳一次。可由现有registered-tool live runner保存新鲜预览和终态回执,再人工独立裁决;不直连provider、不自动读取私有模型工件。
25
+
20
26
  ## 真实模型观察
21
27
 
22
28
  已有六次小规模观察见 [试跑结果](PILOT-RESULTS.md)。其中一次证据复核未完成,其他结果也不能外推成质量指标。新评测仍后置,开始条件见 [运行条件](PILOT-GATE.md)。
package/index.mjs CHANGED
@@ -10,7 +10,7 @@ export const name = 'dsh-code-review';
10
10
  export const inject = ['tools', 'llm'];
11
11
 
12
12
  const 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. Each session reviews one selected repository independently; reviewMode=project selects tracked current source instead of changes; changeScope=staged or unstaged narrows a Git working-copy review to the index or to unstaged edits. Always capture and inspect the preview internally before execution. An explicit user request to review the selected code already authorizes execution within that request: proceed without asking the user to confirm the same review again. Ask only if the preview exceeds the requested scope or changes the authorized model/destination, or authorization is unclear. Never execute if the user requested preview only or explicitly asked to wait for confirmation. Preserve host tool permissions and all snapshot/identity checks.';
13
- const PREVIEW_PARAMETERS = { type: 'object', additionalProperties: false, properties: { resumeFromJobId: { type: 'string', description: 'Explicit terminal job to reuse identity-matching completed chains. Requires a fresh captured preview, confirmation and authorization; does not restore old approval.' }, reviewMode: {type:'string',enum:['changes','project'],description:'project reviews all tracked current files, including unchanged files, in bounded batches with shared session budget. Does not include ignored/untracked/external contents. Each DSH session owns independent preview, lock and model budget.'}, changeScope: {type:'string',enum:['worktree','staged','unstaged'],description:'Git working-copy comparison scope. worktree (default) compares HEAD to the working tree (net change); staged compares HEAD to the index so only staged content is reviewed; unstaged compares the index to the working tree so only unstaged edits are reviewed and staged content is the old side. Requires a Git working copy; mutually exclusive with Git history, SVN parameters and project mode.'}, svnReplacementBaseRevision: {type:'string',description:'Working-copy file replacement only: explicitly compare to this fixed repository revision at the target URL. Not local BASE and not proof of the removed-node revision. Requires separate remote opt-in when applicable; identity and properties frozen in preview.'}, svnReplacementAllowNetwork: {type:'boolean',description:'Explicit permission to read the selected working-copy target URL at svnReplacementBaseRevision. Does not permit arbitrary URLs, updates, merges or writes.'}, svnBasePath: {type:'string',description:'Explicit old endpoint path relative to the current SVN repository root; requires svnTargetPath and fixed revision range. Not an arbitrary URL. Same repository UUID required.'}, svnTargetPath: {type:'string',description:'Explicit new endpoint repository-relative path paired with svnBasePath. Each side content/context stays at its URL/revision; shared capture budgets and remote opt-in apply.'}, svnEffectiveMergeinfo: { type: 'boolean', description: 'Explicit SVN opt-in to bounded BASE/working or fixed-history ancestor mergeinfo property reads on each selected side. Offline for working copies; history uses existing remote permission and budgets. Mixed BASE revisions, unsafe ancestors or incomplete URL boundaries remain unknown. No branch source reads or merge attribution.' }, svnRevision: { type: 'string', description: 'SVN fixed numeric revision reviewed against revision minus one. Requires SVN working-copy root; no HEAD/date aliases.' }, svnBaseRevision: { type: 'string', description: 'SVN fixed numeric old endpoint; requires svnTargetRevision, mutually exclusive with svnRevision and Git revision parameters.' }, svnTargetRevision: { type: 'string', description: 'SVN fixed numeric new endpoint, greater than old endpoint. Fixed repository objects, not working-copy text. Supports explicitly selected target context with verified baseline provenance and target rules; explicit old-only context is also supported with fixed SVN provenance; autoContext follows at most three independent fixed-side dependency layers with bounded parent listings and shared capture budgets; callerScopePaths supports bounded fixed-side SVN historical literal discovery; only matched sides captured, never complete caller coverage.' }, svnAllowNetwork: { type: 'boolean', description: 'Explicit opt-in to read the working-copy repository URL at fixed SVN revisions during preview/execute; preview sends no source to a model. Required for non-file repository URLs; default denies remote access. Does not allow checkout/update/merge or arbitrary URLs.' }, 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/SVN historical baseline-only context paths. Git uses fixed blobs; SVN uses UUID/URL/peg/operative revision/hash. Shares 20 context slots. No target text is inferred; selected old changes reuse original identity, excluded changes cannot reenter. Unsupported on working reviews.' }, 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/SVN working review or fixed Git/SVN historical review. SVN uses offline normal nodes, rejects unsafe ancestor states and special/keyword properties. Historical sides share scan budgets and use object blobs 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. Explicit context uses target tree with available baseline provenance; oldContextPaths is baseline-only. Historical caller discovery scans fixed sides; explicit rules use target-tree blobs.' }, 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 tracked literal import/include context shown in preview. Working Git follows changed and explicitly selected context sources at most 3 dependency layers under shared 512 exact probes, 20 candidates/context slots, 1MiB source and 30s verification budget. Git history follows at most 3 baseline/target dependency layers with shared limits, object reads only; SVN follows at most 3 safe offline current-side layers with exact path probes and node/ancestor identity verification, sharing limits; no SVN historical review. Not semantic binding or complete definitions.' }, contextPaths: { type: 'array', items: { type: 'string' }, maxItems: 20 }, rulePaths: { type: 'array', items: { type: 'string' }, maxItems: 4 } } };
13
+ const PREVIEW_PARAMETERS = { type: 'object', additionalProperties: false, properties: { rereviewScope:{type:'string',enum:['full','diff-only'],description:'Changes only: explicit diff-only re-review omits unchanged primary files but retains approved retrieval context; unknown cross-file impact remains partial, never reuse old evidence. Requires rereviewFromJobId and fresh preview approval.'}, reviewEffort: {type:'string',enum:['auto','quick','standard','deep'],description:'Deterministic review effort policy; every tier retains strict evidence checks. No automatic human approval.'}, rereviewFromJobId:{type:'string',description:'Owned terminal review history for a fresh review-fix cycle; requires fresh snapshot preview and approval, never inherits old evidence or approval. Maximum three linked cycles.'}, findingOutcomes:{type:'array',maxItems:200,items:{type:'object',additionalProperties:false,properties:{id:{type:'string'},decision:{type:'string',enum:['fixed','refuted','intentional','deferred']},reason:{type:'string'}},required:['id','decision','reason']},description:'Explicit human dispositions of known historical findings; claims only, not proof of a fix. Requires rereviewFromJobId.'}, resumeFromJobId: { type: 'string', description: 'Explicit terminal job to reuse identity-matching completed chains. Requires a fresh captured preview, confirmation and authorization; does not restore old approval.' }, reviewMode: {type:'string',enum:['changes','project'],description:'project reviews all tracked current files, including unchanged files, in bounded batches with shared session budget. Does not include ignored/untracked/external contents. Each DSH session owns independent preview, lock and model budget.'}, changeScope: {type:'string',enum:['worktree','staged','unstaged'],description:'Git working-copy comparison scope. worktree (default) compares HEAD to the working tree (net change); staged compares HEAD to the index so only staged content is reviewed; unstaged compares the index to the working tree so only unstaged edits are reviewed and staged content is the old side. Requires a Git working copy; mutually exclusive with Git history, SVN parameters and project mode.'}, svnReplacementBaseRevision: {type:'string',description:'Working-copy file replacement only: explicitly compare to this fixed repository revision at the target URL. Not local BASE and not proof of the removed-node revision. Requires separate remote opt-in when applicable; identity and properties frozen in preview.'}, svnReplacementAllowNetwork: {type:'boolean',description:'Explicit permission to read the selected working-copy target URL at svnReplacementBaseRevision. Does not permit arbitrary URLs, updates, merges or writes.'}, svnBasePath: {type:'string',description:'Explicit old endpoint path relative to the current SVN repository root; requires svnTargetPath and fixed revision range. Not an arbitrary URL. Same repository UUID required.'}, svnTargetPath: {type:'string',description:'Explicit new endpoint repository-relative path paired with svnBasePath. Each side content/context stays at its URL/revision; shared capture budgets and remote opt-in apply.'}, svnEffectiveMergeinfo: { type: 'boolean', description: 'Explicit SVN opt-in to bounded BASE/working or fixed-history ancestor mergeinfo property reads on each selected side. Offline for working copies; history uses existing remote permission and budgets. Mixed BASE revisions, unsafe ancestors or incomplete URL boundaries remain unknown. No branch source reads or merge attribution.' }, svnRevision: { type: 'string', description: 'SVN fixed numeric revision reviewed against revision minus one. Requires SVN working-copy root; no HEAD/date aliases.' }, svnBaseRevision: { type: 'string', description: 'SVN fixed numeric old endpoint; requires svnTargetRevision, mutually exclusive with svnRevision and Git revision parameters.' }, svnTargetRevision: { type: 'string', description: 'SVN fixed numeric new endpoint, greater than old endpoint. Fixed repository objects, not working-copy text. Supports explicitly selected target context with verified baseline provenance and target rules; explicit old-only context is also supported with fixed SVN provenance; autoContext follows at most three independent fixed-side dependency layers with bounded parent listings and shared capture budgets; callerScopePaths supports bounded fixed-side SVN historical literal discovery; only matched sides captured, never complete caller coverage.' }, svnAllowNetwork: { type: 'boolean', description: 'Explicit opt-in to read the working-copy repository URL at fixed SVN revisions during preview/execute; preview sends no source to a model. Required for non-file repository URLs; default denies remote access. Does not allow checkout/update/merge or arbitrary URLs.' }, 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/SVN historical baseline-only context paths. Git uses fixed blobs; SVN uses UUID/URL/peg/operative revision/hash. Shares 20 context slots. No target text is inferred; selected old changes reuse original identity, excluded changes cannot reenter. Unsupported on working reviews.' }, 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/SVN working review or fixed Git/SVN historical review. SVN uses offline normal nodes, rejects unsafe ancestor states and special/keyword properties. Historical sides share scan budgets and use object blobs 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. Explicit context uses target tree with available baseline provenance; oldContextPaths is baseline-only. Historical caller discovery scans fixed sides; explicit rules use target-tree blobs.' }, 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 tracked literal import/include context shown in preview. Working Git follows changed and explicitly selected context sources at most 3 dependency layers under shared 512 exact probes, 20 candidates/context slots, 1MiB source and 30s verification budget. Git history follows at most 3 baseline/target dependency layers with shared limits, object reads only; SVN follows at most 3 safe offline current-side layers with exact path probes and node/ancestor identity verification, sharing limits; no SVN historical review. Not semantic binding or complete definitions.' }, contextPaths: { type: 'array', items: { type: 'string' }, maxItems: 20 }, rulePaths: { type: 'array', items: { type: 'string' }, maxItems: 4 } } };
14
14
  const EXECUTE_DESCRIPTION = 'Execute a previewed read-only code review using the current DSH model. Sends the approved snapshot to the current Agent model under DSH tool execution policy; no separate plugin approval prompt. confirmed=true attests that the current user explicitly requested this review and the inspected preview matches that authorization; it does not require a second confirmation message. Never execute a preview-only or explicit wait-for-confirmation request, or exceed authorized scope/model/destination. Never modifies the reviewed repository.';
15
15
  const EXECUTE_PARAMETERS = { type: 'object', additionalProperties: false, properties: { previewId: { type: 'string' }, confirmed: { type: 'boolean', description: 'True only when the current user explicitly authorized this review and the inspected preview matches the requested scope/model/destination. The original explicit review request is sufficient; do not ask again for the same review. Preview-only or explicit wait-for-confirmation requests do not authorize execution.' } }, required: ['previewId', 'confirmed'] };
16
16
 
@@ -21,7 +21,7 @@ export function apply(ctx, config = {}) {
21
21
  registerSnapshotTool(ctx);
22
22
  attachReviewChildMiddleware(ctx);
23
23
  ctx.on('dispose', () => hostChildren.dispose());
24
- const service = createReviewService(ctx.llm, { hostChildren, wholeScopeConcurrency: config.wholeScopeConcurrency ?? 2, executionStrategy: config.executionStrategy ?? 'staged', maxInputBytes: config.maxInputBytes ?? 96 * 1024, allowModelSending: true, nativeOutputTools: true, enableGroupPrimary: true, enableEvidenceContext: config.enableEvidenceContext ?? true, inlineInteractionRisks: config.inlineInteractionRisks ?? true, enableMigrationContracts: config.enableMigrationContracts ?? true, enableMigrationRegression: config.enableMigrationRegression ?? true, enableGroupInteractions: config.enableGroupInteractions ?? true, enableStageContinuation: config.enableStageContinuation ?? true, enableRiskPlanning: true, enableBusinessGrouping: true, enableAnchorCorrection: true, projectConcurrency: config.projectConcurrency ?? DEFAULT_PROJECT_CONCURRENCY, riskPlanningMode: config.riskPlanningMode ?? 'auto', readerBudgetPolicy: config.readerBudgetPolicy ?? 'scaled', optimizeReviewCalls: true, enablePrimarySeeds: true, focusPrimaryContext: config.focusPrimaryContext ?? true, enableSearchSeeds: config.enableSearchSeeds ?? true, changesConcurrency: config.changesConcurrency ?? 2, maxRetrievalRounds: config.maxRetrievalRounds ?? 3, authorize: async () => true });
24
+ const service = createReviewService(ctx.llm, { reviewEffort: config.reviewEffort ?? 'auto', enableCounterVerification: config.enableCounterVerification ?? true, hostChildren, wholeScopeConcurrency: config.wholeScopeConcurrency ?? 2, executionStrategy: config.executionStrategy ?? 'staged', maxInputBytes: config.maxInputBytes ?? 96 * 1024, allowModelSending: true, nativeOutputTools: true, enableGroupPrimary: true, enableEvidenceContext: config.enableEvidenceContext ?? true, inlineInteractionRisks: config.inlineInteractionRisks ?? true, enableMigrationContracts: config.enableMigrationContracts ?? true, enableMigrationRegression: config.enableMigrationRegression ?? true, enableGroupInteractions: config.enableGroupInteractions ?? true, enableStageContinuation: config.enableStageContinuation ?? true, enableRiskPlanning: true, enableBusinessGrouping: true, enableAnchorCorrection: true, projectConcurrency: config.projectConcurrency ?? DEFAULT_PROJECT_CONCURRENCY, riskPlanningMode: config.riskPlanningMode ?? 'auto', readerBudgetPolicy: config.readerBudgetPolicy ?? 'scaled', optimizeReviewCalls: true, enablePrimarySeeds: true, focusPrimaryContext: config.focusPrimaryContext ?? true, enableSearchSeeds: config.enableSearchSeeds ?? true, changesConcurrency: config.changesConcurrency ?? 2, maxRetrievalRounds: config.maxRetrievalRounds ?? 3, authorize: async () => true });
25
25
  ctx.tools.register({
26
26
  name: 'code_review_preview', description: PREVIEW_DESCRIPTION, parameters: PREVIEW_PARAMETERS, output: reviewToolOutput,
27
27
  async execute(args, exec) { const preview = await service.preview(args, exec); return { json: JSON.stringify(preview), markdown: '' }; },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feng0531/dsh-code-review",
3
- "version": "0.89.0",
3
+ "version": "0.90.0",
4
4
  "private": false,
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
@@ -0,0 +1,13 @@
1
+ import { isDeepStrictEqual } from 'node:util';
2
+ // Internal scope projection only; never an extra public source authority.
3
+ export function approvedRetrievalSnapshot(review,approved=review) {
4
+ if(approved===review)return review;
5
+ if(!approved||review.id!==approved.id||!Array.isArray(review.files)||review.files.length!==approved.files?.length)throw new Error('Incremental source identity mismatch');
6
+ const expected=structuredClone(approved);
7
+ for(let i=0;i<review.files.length;i++){
8
+ const target=review.files[i],source=expected.files[i];
9
+ if(target.eligibility==='excluded'&&target.reason==='incremental-unchanged-not-reviewed'&&source.eligibility==='reviewable'){source.eligibility=target.eligibility;source.reason=target.reason;}
10
+ }
11
+ if(!isDeepStrictEqual(expected,review))throw new Error('Incremental projection changed approved source or metadata');
12
+ return structuredClone(approved);
13
+ }
@@ -83,6 +83,7 @@ async function startBackgroundJob(args, exec, jobs, service) {
83
83
  const detached = {
84
84
  ...exec, agent: original, signal,
85
85
  resolveReviewResume: id => jobs.resume(id, exec),
86
+ resolvePriorReview: id => jobs.priorReview(id, exec),
86
87
  reviewResumeCheckpoint: saveResumeState,
87
88
  onModelArtifact: artifact,
88
89
  reviewCheckpoint: async value => {
@@ -0,0 +1,48 @@
1
+ import { verificationLoop } from './verification-loop.mjs';
2
+
3
+ const confirmed = value => value?.verdict === 'supported' && Array.isArray(value.evidence) && value.evidence.length > 0 && value.evidence.every(ref => ref?.status === 'references-validated');
4
+ const classification = value => value?.regression?.classification;
5
+ const comparable = value => ['introduced', 'preexisting'].includes(classification(value)) && value.regression.status === 'comparison-references-validated';
6
+ const fatal = (error, options) => ['MODEL_ARTIFACT_FAILED', 'CHECKPOINT_FAILED', 'RESUME_CHECKPOINT_FAILED'].includes(error?.code) || options.isFatalError?.(error) === true;
7
+
8
+ // Input baseline must be the just-completed strict verificationLoop result, not
9
+ // historical findings or model-supplied verdicts. This pass grants no authority.
10
+ export async function runCounterVerification(model, request, candidates, baseline, scope, options = {}) {
11
+ request.signal?.throwIfAborted();
12
+ if (!Array.isArray(candidates) || candidates.length > 50 || !Array.isArray(baseline) || baseline.length !== candidates.length) throw new Error('Invalid counterverification candidates or baseline');
13
+ if (options.enabled !== undefined && typeof options.enabled !== 'boolean') throw new Error('Invalid counterverification policy');
14
+ if (options.isFatalError !== undefined && typeof options.isFatalError !== 'function') throw new Error('Invalid counterverification fatal predicate');
15
+ const results = structuredClone(baseline);
16
+ if (options.enabled !== true) return results;
17
+ const indices = candidates.flatMap((candidate, index) => ['critical', 'high'].includes(candidate?.severity) && confirmed(baseline[index]) ? [index] : []);
18
+ if (!indices.length) return results;
19
+ const independent = indices.map(index => {
20
+ const value = structuredClone(candidates[index]);
21
+ delete value.verification;
22
+ delete value.counterVerification;
23
+ return value;
24
+ });
25
+ let counter, failureCode;
26
+ try {
27
+ counter = await verificationLoop(model, request, independent, scope, { ...options, verificationFocus: 'counterevidence', correctProtocol: false });
28
+ request.signal?.throwIfAborted();
29
+ } catch (error) {
30
+ request.signal?.throwIfAborted();
31
+ if (error?.name === 'AbortError' || fatal(error, options)) throw error;
32
+ // Never echo an untrusted model exception (which may contain source text).
33
+ failureCode = 'COUNTER_VERIFICATION_INCOMPLETE';
34
+ }
35
+ indices.forEach((index, offset) => {
36
+ const original = baseline[index], extra = counter?.[offset];
37
+ const metadata = extra ? { status: 'completed', result: structuredClone(extra), baselineResult: structuredClone(original) } : { status: 'incomplete', failureCode, baselineResult: structuredClone(original) };
38
+ results[index].counterVerification = metadata;
39
+ if (extra && confirmed(extra) && comparable(original) && comparable(extra) && classification(original) === classification(extra)) return;
40
+ // Preserve both independent records. Uncertainty is a conservative aggregate,
41
+ // not a new model verdict or evidence borrowed from the second receipt table.
42
+ results[index].verdict = 'uncertain';
43
+ results[index].reason = extra ? 'Independent counterevidence verification did not confirm the same supported classification.' : 'Independent counterevidence verification is incomplete.';
44
+ results[index].causality = 'unverified';
45
+ results[index].regression = { classification: 'uncertain', reason: results[index].reason, oldEvidence: [], newEvidence: [], causality: 'unverified' };
46
+ });
47
+ return results;
48
+ }
@@ -0,0 +1,169 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ // Navigation and human disposition metadata only. Never a source/evidence receipt,
4
+ // verification result, coverage certificate, approval, or permission to run again.
5
+ const MAX_CYCLES = 3;
6
+ const LIMIT = 10000;
7
+ const TRUST = 'historical-metadata-not-evidence';
8
+ const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
9
+ const fail = message => { throw new Error('Finding ledger: ' + message); };
10
+ const text = value => typeof value === 'string' && value.trim().length > 0 && value.length <= 16000;
11
+ const hash = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
12
+ const record = value => value && typeof value === 'object' && !Array.isArray(value) && [Object.prototype, null].includes(Object.getPrototypeOf(value));
13
+ function exact(value, keys) {
14
+ if (!record(value) || Reflect.ownKeys(value).some(key => typeof key !== 'string' || !keys.includes(key) || !Object.hasOwn(Object.getOwnPropertyDescriptor(value, key), 'value'))) fail('unknown or invalid fields');
15
+ }
16
+ function list(value) { if (!Array.isArray(value) || value.length > LIMIT) fail('invalid or excessive list'); }
17
+ function path(value) {
18
+ // SVN uses the canonical repository root for directory property changes.
19
+ if (value === '.') return value;
20
+ if (!text(value) || value.includes('\\') || value.includes('\0') || value.startsWith('/') || /^[a-z]:/i.test(value) || value.split('/').some(part => !part || part === '.' || part === '..')) fail('invalid repository-relative path');
21
+ return value;
22
+ }
23
+ function identityValue(value) {
24
+ const keys = ['repository', 'route', 'rulesHash', 'requirementPolicyHash'];
25
+ exact(value, keys);
26
+ if (!text(value.repository) || !text(value.route) || !hash(value.rulesHash) || !hash(value.requirementPolicyHash)) fail('invalid identity');
27
+ return Object.fromEntries(keys.map(key => [key, value[key]]));
28
+ }
29
+ function sameIdentity(left, right) { if (JSON.stringify(left) !== JSON.stringify(right)) fail('identity mismatch'); }
30
+ function freeze(value) {
31
+ if (value && typeof value === 'object') { for (const item of Object.values(value)) freeze(item); Object.freeze(value); }
32
+ return value;
33
+ }
34
+ function decisionValue(value) {
35
+ exact(value, ['decision', 'reason', 'cycle']);
36
+ if (!['fixed', 'refuted', 'intentional', 'deferred'].includes(value.decision) || !text(value.reason) || !Number.isInteger(value.cycle) || value.cycle < 1 || value.cycle > MAX_CYCLES) fail('invalid human decision');
37
+ return { decision: value.decision, reason: value.reason, cycle: value.cycle };
38
+ }
39
+ function validatePrevious(value, identity) {
40
+ exact(value, ['schemaVersion', 'identity', 'originSnapshotId', 'chainOrigin', 'cycle', 'maxCycles', 'nextAction', 'trust', 'snapshotId', 'findings']);
41
+ sameIdentity(identityValue(value.identity), identity);
42
+ if (value.schemaVersion !== 1 || value.trust !== TRUST || value.maxCycles !== MAX_CYCLES || !Number.isInteger(value.cycle) || value.cycle < 1 || value.cycle > MAX_CYCLES || !text(value.snapshotId) || !text(value.originSnapshotId) || value.chainOrigin !== digest([identity, value.originSnapshotId]) || value.nextAction !== (value.cycle === MAX_CYCLES ? 'step-back' : 'human-review')) fail('invalid chain');
43
+ list(value.findings);
44
+ const ids = new Set();
45
+ const findings = value.findings.map(item => {
46
+ exact(item, ['id', 'path', 'side', 'anchorHash', 'claimHash', 'matchable', 'firstSeen', 'lastSeen', 'observation', 'findingIndices', 'humanDecisions']);
47
+ path(item.path);
48
+ if (item.path === '.' && !['property', 'file'].includes(item.side)) fail('root findings require property or file anchor');
49
+ if (!hash(item.id) || ids.has(item.id) || !hash(item.anchorHash) || !hash(item.claimHash) || !['old', 'new', 'property', 'file'].includes(item.side) || typeof item.matchable !== 'boolean' || item.matchable !== (item.side !== 'file') || !['new', 'repeated', 'not-observed'].includes(item.observation) || !Number.isInteger(item.firstSeen) || !Number.isInteger(item.lastSeen) || item.firstSeen < 1 || item.lastSeen < item.firstSeen || item.lastSeen > value.cycle) fail('invalid historical finding');
50
+ list(item.findingIndices); list(item.humanDecisions);
51
+ if (item.findingIndices.some(index => !Number.isSafeInteger(index) || index < 0) || new Set(item.findingIndices).size !== item.findingIndices.length) fail('invalid finding indices');
52
+ const humanDecisions = item.humanDecisions.map(decisionValue);
53
+ if (humanDecisions.length > MAX_CYCLES || humanDecisions.some(d => d.cycle > value.cycle) || new Set(humanDecisions.map(d => d.cycle)).size !== humanDecisions.length) fail('invalid decision history');
54
+ const expected = digest([identity.repository, item.path, item.side, item.anchorHash, item.claimHash, item.matchable ? null : value.chainOrigin, item.matchable ? null : item.firstSeen]);
55
+ if (item.id !== expected) fail('invalid finding fingerprint');
56
+ ids.add(item.id);
57
+ return { ...item, findingIndices: [], humanDecisions };
58
+ });
59
+ return { ...value, findings };
60
+ }
61
+
62
+ /** Accepts the real report.findings shape from review.mjs (including project reports).
63
+ * Does not mutate or copy report verification, evidence receipts, status or source.
64
+ * Hashes exact source anchor and defect claim; prose drift deliberately makes a new
65
+ * candidate rather than heuristically merging distinct causes by title/path.
66
+ */
67
+ export function buildFindingLedger(report, identity, options = {}) {
68
+ identity = identityValue(identity);
69
+ exact(options, ['previous', 'outcomes']);
70
+ if (!record(report) || !text(report.snapshotId)) fail('invalid current report');
71
+ list(report.findings);
72
+ const previous = options.previous === undefined ? undefined : validatePrevious(options.previous, identity);
73
+ if (previous?.cycle === MAX_CYCLES) fail('cycle cap reached; step back with human review, not automatic approval');
74
+ const cycle = (previous?.cycle ?? 0) + 1;
75
+ const originSnapshotId = previous?.originSnapshotId ?? report.snapshotId;
76
+ const chainOrigin = digest([identity, originSnapshotId]);
77
+ const rows = new Map((previous?.findings ?? []).map(item => [item.id, { ...item, observation: 'not-observed' }]));
78
+ report.findings.forEach((finding, index) => {
79
+ if (!record(finding)) fail('invalid current finding');
80
+ const mapped = finding.path === undefined && Array.isArray(report.files) ? report.files.filter(file => file.fileId === finding.fileId && (finding.batchSnapshotId === undefined || file.batchSnapshotId === finding.batchSnapshotId)) : [];
81
+ const findingPath = path(finding.path ?? (mapped.length === 1 ? mapped[0].path : undefined)), anchor = finding.anchor;
82
+ if (!record(anchor) || !['line', 'property', 'file'].includes(anchor.kind)) fail('invalid current anchor');
83
+ if (findingPath === '.' && !['property', 'file'].includes(anchor.kind)) fail('root findings require property or file anchor');
84
+ const side = anchor.kind === 'line' ? anchor.side : anchor.kind;
85
+ if (anchor.kind === 'line' && (!['old', 'new'].includes(side) || !text(anchor.snippet))) fail('missing exact snippet/side');
86
+ if (anchor.kind === 'property' && !text(anchor.name)) fail('missing exact property');
87
+ for (const key of ['evidence', 'trigger', 'impact']) if (!text(finding[key])) fail('missing defect claim');
88
+ const a = finding.attribution;
89
+ const causal = ['beforeBehavior', 'afterBehavior', 'reason'].map(key => {
90
+ if (a?.[key] !== undefined && !text(a[key])) fail('invalid causal claim');
91
+ return a?.[key] ?? null;
92
+ });
93
+ // Qualified interaction refs differentiate cross-file claims without transferring receipts.
94
+ const interaction = finding.interaction?.editRefs;
95
+ if (interaction !== undefined) {
96
+ list(interaction);
97
+ for (const ref of interaction) if (!record(ref) || !text(ref.fileId) || !text(ref.editId)) fail('invalid interaction claim');
98
+ }
99
+ const claimHash = digest([finding.evidence, finding.trigger, finding.impact, causal, interaction?.map(ref => [ref.fileId, ref.editId]).sort() ?? null]);
100
+ const anchorHash = digest(anchor.kind === 'line' ? anchor.snippet : anchor.kind === 'property' ? anchor.name : 'file');
101
+ const matchable = anchor.kind !== 'file';
102
+ const id = digest([identity.repository, findingPath, side, anchorHash, claimHash, matchable ? null : chainOrigin, matchable ? null : cycle]);
103
+ const existing = rows.get(id);
104
+ if (existing) { existing.observation = existing.firstSeen === cycle ? 'new' : 'repeated'; existing.lastSeen = cycle; existing.findingIndices.push(index); }
105
+ else rows.set(id, { id, path: findingPath, side, anchorHash, claimHash, matchable, firstSeen: cycle, lastSeen: cycle, observation: 'new', findingIndices: [index], humanDecisions: [] });
106
+ });
107
+ const outcomes = options.outcomes ?? [];
108
+ list(outcomes);
109
+ const decided = new Set();
110
+ for (const outcome of outcomes) {
111
+ exact(outcome, ['id', 'decision', 'reason']);
112
+ const item = rows.get(outcome.id);
113
+ if (!item || decided.has(outcome.id)) fail('unknown or duplicate outcome id');
114
+ decided.add(outcome.id);
115
+ item.humanDecisions.push(decisionValue({ decision: outcome.decision, reason: outcome.reason, cycle }));
116
+ }
117
+ if (rows.size > LIMIT) fail('excessive historical findings');
118
+ return freeze({ schemaVersion: 1, identity, originSnapshotId, chainOrigin, cycle, maxCycles: MAX_CYCLES, nextAction: cycle === MAX_CYCLES ? 'step-back' : 'human-review', trust: TRUST, snapshotId: report.snapshotId, findings: [...rows.values()] });
119
+ }
120
+
121
+ function snapshotMetadata(value) {
122
+ exact(value, ['identity', 'snapshotId', 'files']);
123
+ const identity = identityValue(value.identity);
124
+ if (!text(value.snapshotId)) fail('invalid snapshot metadata');
125
+ list(value.files);
126
+ const files = new Map();
127
+ for (const file of value.files) {
128
+ exact(file, ['path', 'hash', 'dependencies']);
129
+ const name = path(file.path);
130
+ if (files.has(name) || file.hash !== undefined && !hash(file.hash)) fail('invalid or duplicate source hash');
131
+ const dependencies = file.dependencies;
132
+ if (dependencies !== undefined) {
133
+ list(dependencies);
134
+ const seen = new Set();
135
+ for (const dep of dependencies) {
136
+ exact(dep, ['path', 'hash']); path(dep.path);
137
+ if (seen.has(dep.path) || dep.hash !== undefined && !hash(dep.hash)) fail('invalid dependency');
138
+ seen.add(dep.path);
139
+ }
140
+ }
141
+ files.set(name, file);
142
+ }
143
+ return { identity, files };
144
+ }
145
+
146
+ /** Explicit complete dependency lists are caller-supplied metadata, not discovery
147
+ * or proof. Unknown/changing deps conservatively invalidate their transitive users.
148
+ * Even unchanged paths are navigation only, never full coverage or reused evidence.
149
+ */
150
+ export function planIncrementalReview(previous, current) {
151
+ const old = snapshotMetadata(previous), next = snapshotMetadata(current);
152
+ sameIdentity(old.identity, next.identity);
153
+ const safe = new Set();
154
+ for (const [name, file] of next.files) {
155
+ const before = old.files.get(name);
156
+ if (!before || !hash(file.hash) || file.hash !== before.hash || !Array.isArray(file.dependencies) || !Array.isArray(before.dependencies)) continue;
157
+ const deps = list => list.map(dep => [dep.path, dep.hash]).sort((a, b) => a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0);
158
+ if (JSON.stringify(deps(file.dependencies)) !== JSON.stringify(deps(before.dependencies))) continue;
159
+ if (file.dependencies.every(dep => hash(dep.hash) && next.files.get(dep.path)?.hash === dep.hash && old.files.get(dep.path)?.hash === dep.hash)) safe.add(name);
160
+ }
161
+ // Fixed point is cycle-safe: an unchanged, fully-known dependency cycle is safe
162
+ // navigation; any unknown/changed member invalidates every reachable dependent.
163
+ let changed = true;
164
+ while (changed) {
165
+ changed = false;
166
+ for (const name of safe) if (next.files.get(name).dependencies.some(dep => !safe.has(dep.path))) { safe.delete(name); changed = true; }
167
+ }
168
+ return freeze({ freshReviewPaths: [...next.files.keys()].filter(name => !safe.has(name)), unchangedNavigationPaths: [...next.files.keys()].filter(name => safe.has(name)), removedPaths: [...old.files.keys()].filter(name => !next.files.has(name)), coverage: false, trust: TRUST, requiresFreshEvidence: true });
169
+ }
@@ -0,0 +1,95 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { planIncrementalReview } from './finding-ledger.mjs';
3
+
4
+ const fail = message => { throw new Error('Incremental review: ' + message); };
5
+ const object = value => value && typeof value === 'object' && !Array.isArray(value) && [Object.prototype, null].includes(Object.getPrototypeOf(value));
6
+ const hash = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
7
+ function exact(value, keys) {
8
+ if (!object(value) || Reflect.ownKeys(value).some(key => typeof key !== 'string' || !keys.includes(key) || !Object.hasOwn(Object.getOwnPropertyDescriptor(value, key), 'value'))) fail('unknown metadata field');
9
+ }
10
+ function canonical(value) {
11
+ if (value === undefined) return null;
12
+ if (value === null || typeof value === 'string' || typeof value === 'boolean' || typeof value === 'number' && Number.isFinite(value)) return value;
13
+ if (Array.isArray(value)) return value.map(canonical);
14
+ if (!object(value)) fail('non-JSON snapshot metadata');
15
+ return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])]));
16
+ }
17
+ const digest = value => createHash('sha256').update(JSON.stringify(canonical(value))).digest('hex');
18
+
19
+ // Includes BOTH approved source sides, existence, properties, mappings and source
20
+ // provenance. A right-text hash alone would silently miss changed old-side diffs.
21
+ export function incrementalFileHash(file) {
22
+ if (!object(file)) fail('invalid snapshot file');
23
+ return digest(Object.fromEntries(Object.entries(file).filter(([key]) => !['id', 'eligibility', 'reason'].includes(key))));
24
+ }
25
+
26
+ // A changed approved context/rule/requirement invalidates automatic safe narrowing.
27
+ // Explicit user-selected partial re-review is not a complete dependency check. This is a local policy binding, never defect evidence.
28
+ export function incrementalContextHash(snapshot) {
29
+ return digest([snapshot.context ?? [], snapshot.rules ?? [], snapshot.businessRequirement ?? null]);
30
+ }
31
+
32
+ /** Pure primary selection; never creates last-reviewed-right -> current-right diff.
33
+ * previousManifest must be owned host metadata, not model/public request data.
34
+ * Automatic narrowing requires repository-wide complete graph attestation, never inferred
35
+ * from empty dependency lists or partial import/caller navigation. No attestation
36
+ * is available in today's capture path, so automatic production use stays full.
37
+ * An explicit diff-only user scope may omit unchanged primaries, always partial.
38
+ * The caller MUST construct retrieval from retrievalSnapshot (original approved
39
+ * source catalog) and use reviewSnapshot for primary scheduling. Skipped files
40
+ * are unreviewed, not completed; caller MUST enforce plan.requiredReportStatus.
41
+ */
42
+ export function buildIncrementalSelection(approvedFreshSnapshot, options) {
43
+ exact(options, ['previousManifest', 'currentManifest', 'dependencyGraph', 'capturePolicyUnchanged', 'explicitDiffOnly']);
44
+ if (options.capturePolicyUnchanged !== undefined && typeof options.capturePolicyUnchanged !== 'boolean') fail('invalid policy binding');
45
+ const snapshot = approvedFreshSnapshot;
46
+ if (!object(snapshot) || typeof snapshot.id !== 'string' || !snapshot.id || !Array.isArray(snapshot.files) || snapshot.files.length > 200 || !Array.isArray(snapshot.context) || snapshot.context.length > 100) fail('invalid approved snapshot');
47
+ const { previousManifest, currentManifest, dependencyGraph } = options;
48
+ // Also validates exact manifest schemas/identities/paths/hash shapes. A graph
49
+ // assertion cannot bypass repository, route, rule or requirement-policy binding.
50
+ const incremental = planIncrementalReview(previousManifest, currentManifest);
51
+ if (currentManifest.snapshotId !== snapshot.id) fail('fresh snapshot identity mismatch');
52
+ const current = new Map(currentManifest.files.map(file => [file.path, file]));
53
+ if (current.size !== snapshot.files.length) fail('manifest inventory mismatch');
54
+ const ids = new Set(), paths = new Set();
55
+ for (const file of snapshot.files) {
56
+ if (!object(file) || typeof file.id !== 'string' || !file.id || ids.has(file.id) || paths.has(file.path) || !['reviewable', 'excluded', 'blocked'].includes(file.eligibility)) fail('invalid snapshot inventory');
57
+ ids.add(file.id); paths.add(file.path);
58
+ if (!current.has(file.path) || current.get(file.path).hash !== incrementalFileHash(file)) fail('manifest source mismatch');
59
+ }
60
+ let graphKnown = false;
61
+ if (dependencyGraph !== undefined) {
62
+ exact(dependencyGraph, ['status', 'repositoryWide', 'previousSnapshotId', 'currentSnapshotId', 'previousContextHash', 'currentContextHash']);
63
+ if (!['complete', 'unknown'].includes(dependencyGraph.status) || typeof dependencyGraph.repositoryWide !== 'boolean') fail('invalid dependency graph metadata');
64
+ if (dependencyGraph.status === 'complete') {
65
+ if (dependencyGraph.previousSnapshotId !== previousManifest.snapshotId || dependencyGraph.currentSnapshotId !== snapshot.id || !hash(dependencyGraph.previousContextHash) || dependencyGraph.currentContextHash !== incrementalContextHash(snapshot)) fail('dependency graph binding mismatch');
66
+ graphKnown = dependencyGraph.repositoryWide === true && dependencyGraph.previousContextHash === dependencyGraph.currentContextHash;
67
+ }
68
+ }
69
+ // Any unknown dependency inventory makes the WHOLE repository graph unknown.
70
+ // A seemingly isolated unchanged file cannot rule out uncaptured reverse users.
71
+ const inventoriesKnown = [previousManifest, currentManifest].every(manifest => manifest.files.every(file => hash(file.hash) && Array.isArray(file.dependencies) && file.dependencies.every(dep => hash(dep.hash) && manifest.files.some(target => target.path === dep.path && target.hash === dep.hash))));
72
+ const safe = options.capturePolicyUnchanged === true && graphKnown && inventoriesKnown ? new Set(incremental.unchangedNavigationPaths) : new Set();
73
+ if (options.explicitDiffOnly !== undefined && typeof options.explicitDiffOnly !== 'boolean') fail('invalid explicit diff-only selection');
74
+ if (options.explicitDiffOnly === true) {
75
+ const old=new Map(previousManifest.files.map(file=>[file.path,file.hash]));
76
+ for(const file of snapshot.files) if(old.get(file.path)===incrementalFileHash(file)) safe.add(file.path);
77
+ }
78
+ const unreviewed = snapshot.files.filter(file => file.eligibility === 'reviewable' && safe.has(file.path));
79
+ const unreviewedPaths = unreviewed.map(file => file.path);
80
+ const skipped = new Set(unreviewedPaths);
81
+ const excludedRecords = unreviewed.map(file => ({ fileId: file.id, path: file.path, status: 'excluded', reason: 'incremental-unchanged-not-reviewed' }));
82
+ const reviewSnapshot = { ...snapshot, files: snapshot.files.map(file => skipped.has(file.path) ? { ...file, eligibility: 'excluded', reason: 'incremental-unchanged-not-reviewed' } : file) };
83
+ const plan = {
84
+ mode: skipped.size ? (options.explicitDiffOnly ? 'explicit-diff-only-subset' : 'incremental-primary-subset') : 'full-fresh-primary',
85
+ primaryPaths: reviewSnapshot.files.filter(file => file.eligibility === 'reviewable').map(file => file.path),
86
+ unreviewedPaths, removedPaths: incremental.removedPaths, excludedRecords,
87
+ requiredReportStatus: skipped.size ? 'partial' : null,
88
+ coverage: false, requiresFreshEvidence: true,
89
+ notice: skipped.size ? 'Unchanged primaries were not reviewed; original approved sources remain retrieval context only. No full coverage, fix verification, new incremental baseline, or prior receipt reuse.' : 'No safe narrowing; fresh primary scope retained. Unknown repository dependencies require full review, not assumed isolation.',
90
+ };
91
+ // Freeze only newly authored metadata. Never freeze caller-owned snapshot/body.
92
+ for (const key of ['primaryPaths', 'unreviewedPaths', 'removedPaths', 'excludedRecords']) Object.freeze(plan[key]);
93
+ for (const row of excludedRecords) Object.freeze(row);
94
+ return Object.freeze({ reviewSnapshot, retrievalSnapshot: snapshot, plan: Object.freeze(plan) });
95
+ }
@@ -1,3 +1,5 @@
1
+ import { reviewEffortPolicy } from './review-effort.mjs';
2
+ import { PROJECT_REVIEW_INSTRUCTIONS } from './prompts.mjs';
1
3
  import { reviewSnapshot } from './review.mjs';
2
4
  import { retrievalRounds } from './review-execution-policy.mjs';
3
5
 
@@ -9,7 +11,7 @@ export function projectConcurrency(value = DEFAULT_PROJECT_CONCURRENCY) {
9
11
 
10
12
  // Parallelize independent approved snapshots, never dependent stages within a batch.
11
13
  // Admission and the shared call reservation are synchronous; completion order is not report order.
12
- export async function reviewProjectBatches(batches, model, { concurrency = DEFAULT_PROJECT_CONCURRENCY, remainingCalls, signal, onProgress, maxRetrievalRounds = 3, resumeStates, onResumeCheckpoint, onModelArtifact, enableGroupPrimary = false, nativeOutputTools = false } = {}) {
14
+ export async function reviewProjectBatches(batches, model, { concurrency = DEFAULT_PROJECT_CONCURRENCY, reviewEffortMode = 'auto', enableCounterVerification = false, remainingCalls, signal, onProgress, maxRetrievalRounds = 3, resumeStates, onResumeCheckpoint, onModelArtifact, enableGroupPrimary = false, nativeOutputTools = false } = {}) {
13
15
  projectConcurrency(concurrency);
14
16
  retrievalRounds(maxRetrievalRounds);
15
17
  if (!Array.isArray(batches) || typeof remainingCalls !== 'function') throw new Error('Invalid project execution');
@@ -25,7 +27,10 @@ export async function reviewProjectBatches(batches, model, { concurrency = DEFAU
25
27
  const batch = batches[index];
26
28
  try {
27
29
  if (batchSignal.aborted || remainingCalls() <= 0) { results[index] = unstarted(batch); continue; }
30
+ const effort=reviewEffortPolicy(batch,{mode:reviewEffortMode});
28
31
  results[index] = await reviewSnapshot(batch, model, {
32
+ instructions: PROJECT_REVIEW_INSTRUCTIONS + '\nReview focus (not proof): ' + JSON.stringify(effort.checks),
33
+ enableCounterVerification:enableCounterVerification && effort.counterVerification.eligible,
29
34
  signal: batchSignal, maxCalls: remainingCalls(), remainingCalls, enableRetrieval: true, enableVerification: true,
30
35
  enableGroupPrimary, nativeOutputTools, enableAnchorCorrection: true, correctProtocol: true, maxRetrievalRounds,
31
36
  resumeState: resumeStates?.get(batch.id), onResumeCheckpoint: onResumeCheckpoint ? report => onResumeCheckpoint(batch, report) : undefined, onModelArtifact,
@@ -1,3 +1,4 @@
1
+ import { reviewEffortPolicy, reviewEffortMode } from './review-effort.mjs';
1
2
  import { previewValidity } from './preview-validity.mjs';
2
3
  import { REVIEW_RUNTIME } from './runtime-identity.mjs';
3
4
  import { buildResumeState, validateResumeState } from './review-resume.mjs';
@@ -72,10 +73,12 @@ async function captureProject(cwd, args, signal) {
72
73
  return { ...result, id: hash(JSON.stringify(result)) };
73
74
  }
74
75
 
75
- export function createProjectReviewService(llm, { executionStrategy = 'staged', allowModelSending = false, authorize = async () => false, now = Date.now, ttlMs = 300000, maxPreviews = 8, projectMaxCalls = Number.MAX_SAFE_INTEGER, projectConcurrency = DEFAULT_PROJECT_CONCURRENCY, maxRetrievalRounds = 3, nativeOutputTools = false, enableGroupPrimary = false } = {}) {
76
+ export function createProjectReviewService(llm, { reviewEffort = 'auto', enableCounterVerification = false, executionStrategy = 'staged', allowModelSending = false, authorize = async () => false, now = Date.now, ttlMs = 300000, maxPreviews = 8, projectMaxCalls = Number.MAX_SAFE_INTEGER, projectConcurrency = DEFAULT_PROJECT_CONCURRENCY, maxRetrievalRounds = 3, nativeOutputTools = false, enableGroupPrimary = false } = {}) {
76
77
  for (const n of [ttlMs, maxPreviews]) if (!Number.isSafeInteger(n) || n < 1) throw new Error('Invalid project preview limit');
77
78
  if (!Number.isSafeInteger(projectMaxCalls) || projectMaxCalls < 1) throw new Error('Invalid project model call limit');
78
79
 
80
+ reviewEffortMode(reviewEffort);
81
+ if(typeof enableCounterVerification!=='boolean')throw new Error('Invalid counterverification policy');
79
82
  validateProjectConcurrency(projectConcurrency);
80
83
  retrievalRounds(maxRetrievalRounds);
81
84
  if (typeof nativeOutputTools !== 'boolean' || typeof enableGroupPrimary !== 'boolean') throw new Error('Invalid project execution options');
@@ -94,16 +97,17 @@ export function createProjectReviewService(llm, { executionStrategy = 'staged',
94
97
  if (executionStrategy === 'whole-scope') throw new Error('Whole-scope currently supports fresh single-round changes only; project requires explicit staged configuration');
95
98
  if (busy) throw new Error('Review service busy');
96
99
  if (!args || typeof args !== 'object') throw new Error('Project preview parameters required');
97
- if (Object.keys(args).some(k => !['reviewMode', 'repositoryPath', 'selectedPaths', 'resumeFromJobId'].includes(k)) || args.reviewMode !== 'project') throw new Error('Project mode supports only reviewMode/repositoryPath/selectedPaths; change/history context parameters are not project parameters');
100
+ if (Object.keys(args).some(k => !['reviewMode', 'repositoryPath', 'selectedPaths', 'resumeFromJobId', 'reviewEffort'].includes(k)) || args.reviewMode !== 'project') throw new Error('Project mode supports only reviewMode/repositoryPath/selectedPaths; change/history context parameters are not project parameters');
98
101
  const current = owner(exec);
99
102
  busy = true;
100
103
  try {
101
104
  const captureArgs = structuredClone(args);
105
+ const effortMode=reviewEffortMode(captureArgs.reviewEffort ?? reviewEffort); delete captureArgs.reviewEffort;
102
106
  const sourceJobId = captureArgs.resumeFromJobId; delete captureArgs.resumeFromJobId;
103
107
  if (sourceJobId !== undefined && (typeof sourceJobId !== 'string' || !/^[a-f0-9-]{36}$/.test(sourceJobId) || typeof exec.resolveReviewResume !== 'function')) throw new Error('Invalid or unavailable resume job');
104
108
  const saved = sourceJobId === undefined ? undefined : await exec.resolveReviewResume(sourceJobId);
105
109
  const s = await captureProject(current.cwd, captureArgs, signalFor(exec));
106
- const identity = resumeIdentity(captureArgs, current.route);
110
+ const identity = resumeIdentity({...captureArgs,reviewEffort:effortMode,enableCounterVerification}, current.route);
107
111
  let resumeStates;
108
112
  if (saved !== undefined) {
109
113
  if (saved?.schemaVersion !== 1 || saved.mode !== 'project' || !Array.isArray(saved.batches)) throw new Error('Invalid project resume checkpoint');
@@ -119,7 +123,7 @@ export function createProjectReviewService(llm, { executionStrategy = 'staged',
119
123
  while (previews.size >= maxPreviews) previews.delete(previews.keys().next().value);
120
124
  const previewId = randomUUID();
121
125
  const expires = now() + ttlMs;
122
- previews.set(previewId, { ...current, args: captureArgs, snapshotId: s.id, resumeStates, sourceJobId, expires });
126
+ previews.set(previewId, { ...current, args: captureArgs, snapshotId: s.id, effortMode, resumeStates, sourceJobId, expires });
123
127
  return {
124
128
  ...(resumeStates ? { resume: { sourceJobId, reusableFiles: [...resumeStates.values()].reduce((sum, state) => sum + state.completed.length, 0) } } : {}),
125
129
  previewId, expiresAt: new Date(expires).toISOString(), validForMs: ttlMs, snapshotId: s.id, reviewMode: 'project', repositoryRoot: s.root, vcs: s.vcs,
@@ -128,6 +132,7 @@ export function createProjectReviewService(llm, { executionStrategy = 'staged',
128
132
  inventoryComplete: s.inventoryComplete, inventoryScope: s.inventoryScope, inventoryNodes: s.inventoryNodes, batches: s.batches.length,
129
133
  privateArtifacts: { enabled: typeof exec.onModelArtifact === 'function', sourceBearing: true, maxArtifactBytes: 1048576, maxJobBytes: 8388608, maxArtifacts: 4096, notice: 'Bounded private untrusted model outputs; raw access requires explicit consent and is not review evidence.' },
130
134
  execution: { nativeOutputTools, enableGroupPrimary, batchConcurrency: projectConcurrency, maxRetrievalRounds },
135
+ reviewEffort: {mode:effortMode,status:"planned-per-batch",counterVerification:enableCounterVerification},
131
136
  files: s.batches.flatMap(b => b.files.map(f => ({ path: f.path, eligibility: f.eligibility, reason: f.reason }))),
132
137
  notice: s.notice + PREVIEW_NOTICE_SUFFIX + ' Each stage allows at most ' + maxRetrievalRounds + ' retrieval rounds; exhaustion requires a final-only response and explicit unfinished coverage.',
133
138
  };
@@ -157,7 +162,7 @@ export function createProjectReviewService(llm, { executionStrategy = 'staged',
157
162
  files: s.batches.flatMap(b => b.files.map(f => ({ fileId: f.id, path: f.path, status: f.eligibility === 'reviewable' ? 'pending' : f.eligibility }))),
158
163
  });
159
164
 
160
- const identity = resumeIdentity(p.args, p.route);
165
+ const identity = resumeIdentity({...p.args,reviewEffort:p.effortMode,enableCounterVerification}, p.route);
161
166
  const states = new Map();
162
167
  for (const [id, saved] of p.resumeStates ?? []) {
163
168
  const batch = s.batches.find(batch => batch.id === id);
@@ -182,7 +187,7 @@ export function createProjectReviewService(llm, { executionStrategy = 'staged',
182
187
  };
183
188
 
184
189
  const results = await reviewProjectBatches(s.batches, model, {
185
- concurrency: projectConcurrency, maxRetrievalRounds, enableGroupPrimary, nativeOutputTools, signal, remainingCalls: () => projectMaxCalls - calls,
190
+ concurrency: projectConcurrency, reviewEffortMode:p.effortMode, enableCounterVerification, maxRetrievalRounds, enableGroupPrimary, nativeOutputTools, signal, remainingCalls: () => projectMaxCalls - calls,
186
191
  onProgress: exec.reviewCheckpoint, resumeStates: states, onResumeCheckpoint: persist, onModelArtifact: exec.onModelArtifact,
187
192
  });
188
193
 
@@ -208,6 +213,7 @@ export function createProjectReviewService(llm, { executionStrategy = 'staged',
208
213
  coverage, files, findings, scopeNotices: results.flatMap(result => result.scopeNotices ?? []), artifacts: results.flatMap(result => result.artifacts ?? []), batches: results, resume: { reusedFiles: files.filter(file => file.reused).length },
209
214
  privateArtifacts: { enabled: typeof exec.onModelArtifact === 'function', sourceBearing: true, maxArtifactBytes: 1048576, maxJobBytes: 8388608, maxArtifacts: 4096, notice: 'Bounded private untrusted model outputs; raw access requires explicit consent and is not review evidence.' },
210
215
  execution: { nativeOutputTools, enableGroupPrimary, batchConcurrency: projectConcurrency, maxRetrievalRounds },
216
+ reviewEffort: {mode:p.effortMode,status:"planned-per-batch",counterVerification:enableCounterVerification},
211
217
  notice: s.notice + REPORT_NOTICE_SUFFIX,
212
218
  };
213
219
 
@@ -42,6 +42,8 @@ export function findingsMarkdown(report) {
42
42
  for (const [label, items] of sections) {
43
43
  if (!items.length) continue;
44
44
  output.push('', '### ' + label);
45
+ const levels = {critical:0,high:1,medium:2,low:3};
46
+ items.sort((a,b)=>(levels[a.f.severity]??4)-(levels[b.f.severity]??4)||a.i-b.i);
45
47
  for (const {f,i} of items) {
46
48
  const severity = ({critical:'严重',high:'高',medium:'中',low:'低'})[f.severity] ?? safe(f.severity ?? '未分级');
47
49
  const hypothesis = label === '复核后不确定' || label === '复核未完成' || label === '已反驳';
@@ -58,6 +60,9 @@ export function findingsMarkdown(report) {
58
60
  else if (f.suggestion) output.push('- 建议:' + safe(f.suggestion));
59
61
  const related = [...duplicate].filter(([,first])=>first===i).map(([index])=>location(findings[index]));
60
62
  if (related.length) output.push('- 相同证据与解释的其他位置:' + related.join(';') + '。正文合并展示,原始候选仍保留。');
63
+ const ledgerItem = report.reviewHistory?.ledger?.findings?.find(row => row.findingIndices?.includes(i));
64
+ if (ledgerItem?.observation === 'repeated') output.push('- 历史台账:本轮再次观察到相同定位与缺陷主张;不重复创建新问题,当前证据仍须独立核对。');
65
+ if (ledgerItem?.humanDecisions?.length) output.push('- 人工处置记录:' + ledgerItem.humanDecisions.map(row => safe(row.decision) + ':' + safe(row.reason)).join(';') + ';这是历史决定,不是修复或缺陷证明。');
61
66
  if (f.reused || f.priorRun) output.push('- 来源:包含既往审核结果,不表示本轮重新取证。');
62
67
  output.push('- 完整证据与判定过程:见技术附录中的候选 ' + (i+1) + '。');
63
68
  }
@@ -30,6 +30,20 @@ export function verificationPresentation(finding, report = {}) {
30
30
  return 'uncertain';
31
31
  }
32
32
 
33
+ // Candidate numbers refer to the unchanged original finding/appendix order.
34
+ // Only evidence-qualified support is prioritized; severity never promotes a hypothesis.
35
+ export function supportedFindingPriorities(report = {}) {
36
+ const severity = { critical: 0, high: 1, medium: 2, low: 3 };
37
+ const category = { introduced: 0, current: 1, preexisting: 2 };
38
+ return rows(report.findings).flatMap((finding, index) => {
39
+ const state = verificationPresentation(finding, report);
40
+ if (!Object.hasOwn(category, state)) return [];
41
+ const level = Object.hasOwn(severity, finding.severity) ? finding.severity : 'unknown';
42
+ return [{ candidate: index + 1, state, severity: level }];
43
+ }).sort((a, b) => category[a.state] - category[b.state] ||
44
+ (severity[a.severity] ?? 4) - (severity[b.severity] ?? 4) || a.candidate - b.candidate);
45
+ }
46
+
33
47
  /**
34
48
  * Pure presentation data for Markdown/Lead renderers. Counts are candidate records,
35
49
  * not proven defects. coverage counts main-review entries (directories included in
@@ -82,6 +96,12 @@ export function reportSummaryData(report = {}) {
82
96
  counts: { total: findings.length, ...counts },
83
97
  coverage: { total, loaded: files.length, detailsComplete, unit: project ? 'files' : 'change-entries', ...coverage },
84
98
  unfinished, crossFile, stages, unverifiedCandidates,
99
+ verification: {
100
+ supported: counts.introduced + counts.preexisting + counts.current,
101
+ uncertain: findings.filter(finding => verificationPresentation(finding, report) === 'uncertain').length,
102
+ incomplete: findings.filter(finding => verificationPresentation(finding, report) === 'incomplete').length,
103
+ },
104
+ priorities: supportedFindingPriorities(report),
85
105
  limitations: rows(report.limitations).length,
86
106
  scopeNotices: rows(report.scopeNotices).length,
87
107
  reused: findings.some(finding => finding?.reused || finding?.priorRun),
@@ -93,16 +113,35 @@ export function reportSummaryMarkdown(report = {}) {
93
113
  const data = reportSummaryData(report), { counts, coverage } = data;
94
114
  const state = data.emptySelection ? '未选择条目,没有执行代码审核(' + (STATUS_LABELS[data.status] ?? '状态未知') + ')' :
95
115
  data.complete ? '所选范围已查完' : '尚未查完(' + (STATUS_LABELS[data.status] ?? '状态未知') + ')';
96
- const headline = state + (data.outdated ? ';报告已过期' : '') + ';' +
116
+ const firstCounts = counts.total ? [
117
+ counts.introduced ? '复核支持本次新增 ' + counts.introduced : '',
118
+ counts.current ? '当前源码复核支持 ' + counts.current : '',
119
+ counts.preexisting ? '复核支持原有 ' + counts.preexisting : '',
120
+ !data.verification.supported ? '尚无复核支持的问题' : '',
121
+ ].filter(Boolean).join(';') + ';' : '';
122
+ const headline = firstCounts + state + (data.outdated ? ';报告已过期' : '') + ';' +
97
123
  (counts.total ? '记录 ' + counts.total + ' 个候选问题' : '未记录候选问题') + ';不得据此放行。';
98
124
  const output = ['', '## 结论摘要', '- ' + headline];
125
+ if (counts.total || !data.complete || data.outdated) {
126
+ output.push('- 下一步:' + (data.outdated ? '先基于当前批准快照重新审核;' : '') +
127
+ (data.verification.supported ? '按下列候选编号核对触发条件与修复必要性;' : '无复核支持的问题不等于审核通过;') +
128
+ '已复核不确定 ' + data.verification.uncertain + '、复核未完成 ' + data.verification.incomplete +
129
+ ',先核实而非直接修复;已加载未完成条目 ' + data.unfinished.length +
130
+ (coverage.detailsComplete ? '' : ',明细尚未加载齐') +
131
+ (data.complete ? ';处理已结束,不代表所有疑点已排除。' : ';须补齐未完成范围和阶段,不能视为无问题。'));
132
+ }
133
+ if (data.priorities.length) {
134
+ const labels = { introduced: '本次新增', current: '当前源码', preexisting: '原有' };
135
+ const levels = { critical: '严重', high: '高', medium: '中', low: '低', unknown: '未分级' };
136
+ output.push('- 优先核对(仅复核支持,类别内按严重程度):' + data.priorities.slice(0, 5)
137
+ .map(item => '候选 ' + item.candidate + '(' + labels[item.state] + '/' + levels[item.severity] + ')').join(';') +
138
+ (data.priorities.length > 5 ? ';其余 ' + (data.priorities.length - 5) + ' 项见审查发现与技术附录' : '') + '。');
139
+ }
99
140
  if (counts.total) {
100
141
  const labels = { introduced: '复核支持本次新增', unresolved: '待核实', preexisting: '复核支持原有', refuted: '复核反驳', current: '当前源码复核支持' };
101
142
  output.push('- 候选复核:' + Object.entries(labels).filter(([key]) => counts[key] > 0).map(([key, label]) => label + ' ' + counts[key]).join(';') + '。');
102
143
  }
103
- const states = rows(report.findings).map(finding => verificationPresentation(finding, report));
104
- const uncertain = states.filter(state => state === 'uncertain').length;
105
- const incomplete = states.filter(state => state === 'incomplete').length;
144
+ const { uncertain, incomplete } = data.verification;
106
145
  output.push('- 复核状态分列:复核支持 ' + (counts.introduced + counts.preexisting + counts.current) + ';已复核不确定 ' + uncertain + ';复核未完成(未复核或未完成有效复核) ' + incomplete + (counts.refuted ? ';已反驳 ' + counts.refuted : '') + '。');
107
146
  if (counts.unresolved) output.push('- “待核实”为合计:已复核不确定与复核未完成分开列示,前者不表示复核未执行;均不能直接视为缺陷或必改项。');
108
147
  const unit = coverage.unit === 'files' ? '个文件' : '个变更条目(含目录)';
@@ -0,0 +1,104 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { lineDiff } from './line-diff.mjs';
3
+
4
+ // Planning only: these lexical signals are neither findings nor approval evidence.
5
+ const LIMITS = Object.freeze({ files: 200, sourceChars: 262144, totalSourceChars: 2097152, metadataChars: 4096 });
6
+ const BASELINE = Object.freeze([
7
+ 'Require exact changed-code evidence, a concrete trigger and impact before reporting a defect.',
8
+ 'Compare before/after behavior and captured callers; missing context remains uncertainty.',
9
+ 'Retain strict evidence verification in every effort tier; no policy result proves completion or approval.',
10
+ ]);
11
+ const CATEGORIES = Object.freeze({
12
+ lifecycle: { pattern: /\b(?:dispose|destroy|shutdown|close|release|free|cancel|abort|unsubscribe|removeEventListener)\b/i,
13
+ guidance: 'Seek cleanup guards, ownership transfers and completion before teardown on success, failure and cancellation paths.' },
14
+ concurrency: { pattern: /\b(?:await|async|mutex|lock|atomic|thread|worker|synchronized|volatile|Promise|goroutine)\b/i,
15
+ guidance: 'Seek actual synchronization, queue/actor isolation and cancellation ordering; source order alone is not a happens-before proof.' },
16
+ persistence: { pattern: /\b(?:transaction|commit|rollback|writeFile|rename|fsync|INSERT|UPDATE|DELETE|migration|serialize|deserialize)\b/i,
17
+ guidance: 'Seek rollback, atomic publication, schema/version guards and recovery behavior against captured storage consumers.' },
18
+ compatibility: { pattern: /\b(?:export|public|interface|ABI|API|schema|protocol|deprecated|version|override)\b/i,
19
+ guidance: 'Seek caller contracts, compatibility adapters and supported runtime/version evidence; do not invent a public contract.' },
20
+ sensitive: { pattern: /\b(?:auth|authorize|authorization|authenticate|authentication|permissions?|privilege|chmod|sudo|install|update|release|CI|postinstall|preinstall|pull_request_target|workflow_dispatch|publish|deploy|installer|autoUpdate)\b/i,
21
+ guidance: 'Seek trust-boundary validation and explicit authorization in captured code; installation, update, CI and release actions require separate human handling, never automatic approval.' },
22
+ });
23
+ const PATH_HINT = /(?:^|[\\/_.-])(?:auth|permissions?|install|update|release|deploy)(?:[\\/_.-]|$)|(?:^|[\\/])\.github[\\/]workflows[\\/]|(?:^|[\\/])\.gitlab-ci\.yml$/i;
24
+ const digest = value => createHash('sha256').update(value).digest('hex');
25
+
26
+ export function reviewEffortMode(value = 'auto') {
27
+ if (!['auto', 'quick', 'standard', 'deep'].includes(value)) throw new TypeError('Invalid review effort mode');
28
+ return value;
29
+ }
30
+
31
+ // Unknown severities are never silently promoted or treated as verified severe findings.
32
+ export function counterVerificationEligible(policy, candidate) {
33
+ return policy?.version === 1 && policy.tier === 'deep' && policy.counterVerification?.eligible === true &&
34
+ ['high', 'critical'].includes(candidate?.severity);
35
+ }
36
+
37
+ /** Pure bounded planning over captured snapshot.files[].left/right.text.
38
+ * Identity is a source/selected-metadata binding, not a receipt or reusable coverage.
39
+ * A null sourceFingerprint explicitly means the complete binding is unavailable.
40
+ * Requirements and other free-text instructions are not interpreted or returned.
41
+ */
42
+ export function reviewEffortPolicy(snapshot, { mode = 'auto' } = {}) {
43
+ mode = reviewEffortMode(mode);
44
+ if (!snapshot || !Array.isArray(snapshot.files)) throw new TypeError('Invalid effort snapshot');
45
+ const reasons = new Set(), categories = new Set();
46
+ let complete = true, used = 0, filesScanned = 0, exactChanges = true;
47
+ const hash = createHash('sha256');
48
+ const field = value => {
49
+ if (value === undefined) return null;
50
+ if (typeof value === 'boolean' || (typeof value === 'number' && Number.isFinite(value))) return value;
51
+ if (typeof value === 'string' && value.length <= LIMITS.metadataChars) return value;
52
+ complete = false; reasons.add('metadata-unavailable'); return null;
53
+ };
54
+ const bind = values => hash.update(JSON.stringify(values) + '\n');
55
+ bind(['review-effort-v1', ...['id', 'root', 'vcs', 'reviewMode', 'baseRevision', 'targetRevision', 'changeScope'].map(k => field(snapshot[k])), snapshot.files.length]);
56
+ if (typeof snapshot.id !== 'string' || !snapshot.id || typeof snapshot.root !== 'string' || !snapshot.root) {
57
+ complete = false; reasons.add('identity-unavailable');
58
+ }
59
+ if (snapshot.files.length > LIMITS.files) { complete = false; reasons.add('file-limit'); }
60
+ for (let i = 0; i < Math.min(snapshot.files.length, LIMITS.files); i++) {
61
+ const file = snapshot.files[i]; filesScanned++;
62
+ if (!file || typeof file !== 'object') { complete = false; exactChanges = false; reasons.add('source-unavailable'); continue; }
63
+ const path = field(file.path);
64
+ bind([field(file.id), path, field(file.oldPath), field(file.status), field(file.eligibility), field(file.reviewMode)]);
65
+ if (typeof path !== 'string' || !path) { complete = false; reasons.add('identity-unavailable'); }
66
+ if (typeof path === 'string' && PATH_HINT.test(path)) reasons.add('sensitive-path-hint');
67
+ const texts = [];
68
+ for (const side of ['left', 'right']) {
69
+ const source = file[side];
70
+ bind([side, field(source?.exists), field(source?.hash), field(source?.encoding)]);
71
+ const text = source?.text;
72
+ if (typeof text !== 'string' || text.length > LIMITS.sourceChars || used + text.length > LIMITS.totalSourceChars) {
73
+ complete = false; exactChanges = false; reasons.add(typeof text === 'string' ? 'source-limit' : 'source-unavailable');
74
+ texts.push(null); continue;
75
+ }
76
+ used += text.length; bind([text]); texts.push(text);
77
+ }
78
+ if (texts.includes(null)) continue;
79
+ const diff = lineDiff(texts[0], texts[1], { maxCells: 10000 });
80
+ if (diff.status === 'unchanged') continue;
81
+ if (diff.status !== 'changed' || diff.precision !== 'exact') { exactChanges = false; reasons.add('change-scope-unavailable'); continue; }
82
+ // Only exact added/removed lines drive escalation, never an unchanged keyword elsewhere.
83
+ for (const edit of diff.edits) for (const text of [edit.removed, edit.added]) {
84
+ for (const [category, rule] of Object.entries(CATEGORIES)) if (rule.pattern.test(text)) categories.add(category);
85
+ }
86
+ }
87
+ for (const category of categories) reasons.add('changed-' + category);
88
+ if (!reasons.size) reasons.add('standard-baseline');
89
+ const tier = mode === 'auto' ? (categories.size || !complete || !exactChanges ? 'deep' : 'standard') : mode;
90
+ const sensitive = categories.has('sensitive') || reasons.has('sensitive-path-hint');
91
+ return {
92
+ version: 1, status: 'planned', mode, tier, reasons: [...reasons].sort(),
93
+ checks: [...BASELINE, ...[...categories].sort().map(category => CATEGORIES[category].guidance)],
94
+ counterVerification: {
95
+ eligible: tier === 'deep', severities: ['high', 'critical'], requiresFreshEvidence: true,
96
+ categories: [...categories].sort(), guidance: [...categories].sort().map(category => CATEGORIES[category].guidance),
97
+ instructions: 'Attach these counterevidence questions to the primary review without another planning call. For high/critical candidates only, use a separate fresh verifier against exact captured evidence; do not downgrade or clear evidence merely because a counterexample was suggested.',
98
+ },
99
+ humanReview: { required: sensitive, advisoryOnly: true, approvalGranted: false },
100
+ identity: { snapshotIdHash: typeof snapshot.id === 'string' && snapshot.id.length <= LIMITS.metadataChars ? digest(snapshot.id) : null,
101
+ sourceFingerprint: complete ? hash.digest('hex') : null, complete, scope: 'captured-file-sources-and-selected-metadata', reusableCoverage: false },
102
+ scan: { filesScanned, totalFiles: snapshot.files.length, complete: complete && exactChanges, limits: { ...LIMITS } },
103
+ };
104
+ }
@@ -0,0 +1,29 @@
1
+ import { hash } from './content.mjs';
2
+ import { buildFindingLedger } from './finding-ledger.mjs';
3
+ import { buildIncrementalSelection, incrementalFileHash } from './incremental-review.mjs';
4
+ const canonical = value => Array.isArray(value) ? value.map(canonical) : value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().filter(k=>value[k]!==undefined).map(k=>[k,canonical(value[k])])) : value;
5
+ const digest = value => hash(JSON.stringify(canonical(value)));
6
+ export function reviewHistoryIdentity(snapshot,route,capturePolicy={}) {
7
+ return {repository:digest({root:snapshot.root,vcs:snapshot.vcs,repositoryUuid:snapshot.repositoryUuid ?? snapshot.history?.repositoryUuid,url:snapshot.repositoryUrl ?? snapshot.history?.url}),route:digest(route),rulesHash:digest(snapshot.rules??[]),requirementPolicyHash:digest({requirement:snapshot.businessRequirement,capturePolicy})};
8
+ }
9
+ export function reviewSourceManifest(snapshot,identity) {
10
+ // No inferred complete dependency graph: conservatively bind every captured source.
11
+ const files=snapshot.files.map(file=>({path:file.path,hash:incrementalFileHash(file)}));
12
+ // Dependency completeness is unknown. Do not invent an all-files graph or skip
13
+ // unchanged files merely because this snapshot happened to capture no callers.
14
+ return {identity,snapshotId:snapshot.id,files};
15
+ }
16
+ export function prepareReviewHistory(snapshot,route,capturePolicy,{previous,outcomes,explicitDiffOnly=false}={}) {
17
+ const identity=reviewHistoryIdentity(snapshot,route,capturePolicy);
18
+ const manifest=reviewSourceManifest(snapshot,identity);
19
+ if (!previous) {if(outcomes?.length)throw new Error('Finding outcomes require an owned prior review');return {identity,manifest};}
20
+ if(!previous.ledger||!previous.manifest||previous.ledger.cycle>=3)throw new Error('Review-fix cycle cap reached or prior history unavailable; step back and reconsider');
21
+ // Validate exact ledger identity and decisions without borrowing any conclusions.
22
+ buildFindingLedger({snapshotId:snapshot.id,findings:[],files:[]},identity,{previous:previous.ledger,outcomes});
23
+ const selection=buildIncrementalSelection(snapshot,{previousManifest:previous.manifest,currentManifest:manifest,explicitDiffOnly});
24
+ return {identity,manifest,previous:previous.ledger,outcomes,incremental:selection.plan,selection};
25
+ }
26
+ export function finishReviewHistory(report,prepared) {
27
+ const ledger=buildFindingLedger(report,prepared.identity,{...(prepared.previous?{previous:prepared.previous}:{}),...(prepared.outcomes?{outcomes:prepared.outcomes}:{})});
28
+ return {ledger,manifest:prepared.manifest,...(prepared.incremental?{incremental:prepared.incremental}:{})};
29
+ }
@@ -77,6 +77,7 @@ export function createReviewJobs({directory}={}){
77
77
  return {jobId:id,status:job.record.status,waitRevision:job.savedRevision,progress:jobProgress(job.record),nextAction:'Call code_review_wait with waitFor=terminal until terminal status; reuse the returned waitRevision as after without guessing increments. No code_review_status call is needed after each event. Do not finish the user request at start.',notice:'Background job accepted; start is not a completed review. Explicit cancel or plugin disposal stops it.'};
78
78
  },
79
79
  async resume(id,exec){const r=await owned(id,exec);if(jobs.get(id)&&!jobs.get(id).settled||['queued','running'].includes(r.status)||!r.resumeState)throw new Error('No terminal completed-chain resume state available');return structuredClone(r.resumeState);},
80
+ async priorReview(id,exec){const r=await owned(id,exec);if(jobs.get(id)&&!jobs.get(id).settled||r.status!=='completed'||!r.result?.report?.reviewHistory||r.result.report.outdated)throw new Error('Owned terminal current review history unavailable');return structuredClone(r.result.report.reviewHistory);},
80
81
  async artifacts(id,exec,page){const r=await owned(id,exec);return artifacts.read(id,r.owner,page);},
81
82
  async status(id,exec,page){const r=structuredClone(await owned(id,exec));delete r.resumeState;const active=jobs.get(id);if(active&&!active.settled&&!['queued','running'].includes(r.status)){r.status='running';r.stage='finalizing';delete r.result;delete r.finishedAt;}r.recovery=recoveryCoverage(r);r.progress=jobProgress(r);r.waitRevision=jobs.get(id)?.savedRevision??r.checkpoints.length;r.nextAction=['queued','running'].includes(r.status)?'Call code_review_wait with waitFor=terminal; reuse the exact returned waitRevision as after, never guess increments. No code_review_status call is needed after each event. Do not end the user request while task runs.':r.result?.previewId?'Inspect preview against current explicit user authorization. For a matching explicit review request, execute with confirmed=true without asking again. Do not execute preview-only or explicit wait-for-confirmation requests; ask for authorization if scope/model/destination exceeds authorization or is unclear. Preserve host permissions and snapshot/identity checks.':'Collect final report and unfinished scope, then provide one final answer.';if(!page)return r;const {offset=0,limit=20,checkpointIndex}=page;if(!Number.isSafeInteger(offset)||offset<0||!Number.isSafeInteger(limit)||limit<1||limit>100)throw new Error('Invalid result page');if(checkpointIndex!==undefined){if(!Number.isSafeInteger(checkpointIndex)||checkpointIndex<0||checkpointIndex>=r.checkpoints.length)throw new Error('Invalid checkpoint index');return {jobId:r.jobId,status:r.status,waitRevision:r.waitRevision,progress:r.progress,nextAction:r.nextAction,checkpointIndex,checkpoint:pageCheckpoint(r.checkpoints[checkpointIndex],offset,limit),page:{offset,limit}};}const result=r.result;const report=result?.report;return {...r,result:undefined,recovery:{...r.recovery,unfinishedStages:r.recovery.unfinishedStages.slice(offset,offset+limit),files:r.recovery.files.slice(offset,offset+limit),windows:r.recovery.windows.slice(offset,offset+limit),suggestedPaths:r.recovery.suggestedPaths.slice(offset,offset+limit),totalObservedFiles:r.recovery.files.length},checkpoints:r.checkpoints.slice(offset,offset+limit).map(c=>pageCheckpoint(c,0,limit)),checkpointPage:{offset,limit,total:r.checkpoints.length},...(result?{resultSummary:report?{status:report.status,coverage:report.coverage,model:report.model,snapshotId:report.snapshotId,diagnostics:report.diagnostics,execution:report.execution,modelCalls:report.modelCalls,totalFiles:report.files?.length,totalFindings:report.findings?.length,totalLimitations:report.limitations?.length,totalScopeNotices:report.scopeNotices?.length??0}:({...result,files:result.files?.slice(offset,offset+limit),filePage:result.files?{offset,limit,total:result.files.length}:undefined}),resultPage:report?{files:report.files?.slice(offset,offset+limit),findings:report.findings?.slice(offset,offset+limit),totalFiles:report.files?.length,totalFindings:report.findings?.length}:undefined}: {})};},
82
83
  async wait(id,exec,{after,waitFor='progress',timeoutMs=60000,signal}={}){
package/src/review.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { approvedRetrievalSnapshot } from './approved-retrieval.mjs';
1
2
  import { REVIEW_INSTRUCTIONS, PROJECT_REVIEW_INSTRUCTIONS } from './prompts.mjs';
2
3
  import { reviewWholeScope } from './whole-scope-review.mjs';
3
4
  import { reviewRounds } from './review-rounds.mjs';
@@ -16,6 +17,7 @@ import { validateSynthesisReads } from './synthesis-evidence.mjs';
16
17
  import { hash } from './content.mjs';
17
18
  import { coverageFollowup } from './coverage-followup.mjs';
18
19
  import { verificationLoop } from './verification-loop.mjs';
20
+ import { runCounterVerification } from './counter-verification.mjs';
19
21
  import { inferFileRelations } from './file-relations.mjs';
20
22
  import { buildReviewGroups } from './review-groups.mjs';
21
23
  import { changeMap } from './change-map.mjs';
@@ -47,7 +49,7 @@ import { buildMigrationContracts } from './migration-contracts.mjs';
47
49
 
48
50
  export { REVIEW_INSTRUCTIONS };
49
51
  export function createReviewReader(snapshot, options = {}) {
50
- return configureWireBudget(createRetrievalScope(snapshot, { ...reviewReaderBudget(snapshot, options.readerBudgetPolicy ?? 'fixed'), signal: options.signal, ...(options.optimizeReviewCalls ? { reuseResults: true } : {}) }), { nativeOutputTools: options.nativeOutputTools === true });
52
+ return configureWireBudget(createRetrievalScope(approvedRetrievalSnapshot(snapshot, options.approvedRetrievalSnapshot), { ...reviewReaderBudget(snapshot, options.readerBudgetPolicy ?? 'fixed'), signal: options.signal, ...(options.optimizeReviewCalls ? { reuseResults: true } : {}) }), { nativeOutputTools: options.nativeOutputTools === true });
51
53
  }
52
54
  export { PROJECT_REVIEW_INSTRUCTIONS };
53
55
  const nonempty = value => typeof value === 'string' && value.trim().length > 0 && value.length <= 8000;
@@ -107,7 +109,7 @@ async function invoke(model, request, timeoutMs, parentSignal) {
107
109
  }
108
110
 
109
111
  export async function reviewSnapshot(snapshot, model, options = {}) {
110
- if (options.executionStrategy === 'whole-scope') return reviewWholeScope(snapshot, model, { ...options, instructions: REVIEW_INSTRUCTIONS, validateFindingResponse: validateResponse });
112
+ if (options.executionStrategy === 'whole-scope') return reviewWholeScope(snapshot, model, { ...options, instructions: options.instructions ?? REVIEW_INSTRUCTIONS, validateFindingResponse: validateResponse });
111
113
  if (options.executionStrategy !== undefined && options.executionStrategy !== 'staged') throw new Error('Invalid review execution strategy');
112
114
  const startedAt = performance.now();
113
115
  const rounds = reviewRounds(options.reviewRounds);
@@ -131,7 +133,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
131
133
  if(!Number.isSafeInteger(timeoutMs)||timeoutMs<0)throw new Error('Invalid review limit');
132
134
  // Clone once: callers cannot change the input while a model call is pending.
133
135
  const input = structuredClone(snapshot);
134
- const instructions=input.projectScope?PROJECT_REVIEW_INSTRUCTIONS:REVIEW_INSTRUCTIONS;
136
+ const instructions=options.instructions ?? (input.projectScope?PROJECT_REVIEW_INSTRUCTIONS:REVIEW_INSTRUCTIONS);
135
137
  if (!input || !nonempty(input.id) || !['git', 'svn'].includes(input.vcs) || !Array.isArray(input.files) || input.files.length > 200) throw new Error('Invalid review snapshot');
136
138
  const identities = new Set(), paths = new Set();
137
139
  for (const file of input.files) {
@@ -144,7 +146,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
144
146
  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');
145
147
  const evidenceContext = options.enableEvidenceContext === true ? createEvidenceContext(input.id) : null;
146
148
  const readerLimits = reviewReaderBudget(input, options.readerBudgetPolicy ?? 'fixed');
147
- const scope = options.enableRetrieval === true ? createRetrievalScope({ ...input, context: input.context ?? [] }, { ...readerLimits, signal, ...(options.optimizeReviewCalls === true ? { reuseResults: true } : {}) }) : null;
149
+ const scope = options.enableRetrieval === true ? createRetrievalScope({ ...approvedRetrievalSnapshot(input, options.approvedRetrievalSnapshot), context: input.context ?? [] }, { ...readerLimits, signal, ...(options.optimizeReviewCalls === true ? { reuseResults: true } : {}) }) : null;
148
150
  const report = { schemaVersion: 1, snapshotId: input.id, vcs: input.vcs, status: 'completed', files: [], findings: [], limitations: [], modelCalls: 0 };
149
151
  validateBusinessRequirement(input.businessRequirement);
150
152
  if(input.businessRequirement!==undefined)report.businessRequirement={hash:hash(input.businessRequirement),bytes:Buffer.byteLength(input.businessRequirement),status:'untrusted-constraint'};
@@ -612,7 +614,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
612
614
  for (const finding of generated.findings) finding.verification = { status: 'pending', causality: 'unverified' };
613
615
  try {
614
616
  activeStage = 'verification';
615
- const verdicts = await verificationLoop(trackedModel, { ...request, onProtocolEvent, reviewPhase: 'verification' }, generated.findings, stageScope('verification:' + file.id), {
617
+ let verdicts = await verificationLoop(trackedModel, { ...request, onProtocolEvent, reviewPhase: 'verification' }, generated.findings, stageScope('verification:' + file.id), {
616
618
  correctProtocol:options.correctProtocol===true,maxRounds:maxRetrievalRounds,maxInputBytes, beforeCall, onBudgetLimited, yieldOnBudget: options.enableStageContinuation === true,
617
619
  enableMigrationRegression: options.enableMigrationRegression === true, migrationContracts: report.migrationContracts?.contracts ?? [], migrationSnapshot: input,
618
620
  onMigrationSeeds: event => {
@@ -626,7 +628,9 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
626
628
  onTruncated: () => { result.limitations.push({ fileId: file.id, text: '复核检索结果达到数量上限' }); },
627
629
  });
628
630
  request.signal.throwIfAborted();
629
- generated.findings.forEach((finding, i) => { finding.verification = { status: 'completed', ...verdicts[i] }; });
631
+ verdicts = await runCounterVerification(trackedModel, { ...request, onProtocolEvent, reviewPhase:'verification' }, generated.findings, verdicts, stageScope('verification:' + file.id), { enabled:options.enableCounterVerification === true, maxRounds:maxRetrievalRounds,maxInputBytes,beforeCall,onBudgetLimited, isFatalError:error=>checkpointFailed||error?.code==='MODEL_ARTIFACT_FAILED' });
632
+ request.signal.throwIfAborted();
633
+ generated.findings.forEach((finding, i) => { finding.verification = { status: verdicts[i].counterVerification?.status === 'incomplete' ? 'incomplete' : 'completed', ...verdicts[i] }; });
630
634
  if (verdicts.some(v => v.verdict === 'supported' && v.regression?.classification === 'uncertain')) result.limitations.push({ fileId: file.id, text: '部分模型支持候选缺少充分前后对照,不能确认由本次变更引入' });
631
635
  if (verdicts.some(v => v.verdict === 'uncertain')) result.limitations.push({ fileId: file.id, text: '部分候选复核仍不确定,不能视为已确认或已排除' });
632
636
  } catch (error) {
package/src/service.mjs CHANGED
@@ -1,4 +1,6 @@
1
1
  import { previewValidity } from './preview-validity.mjs';
2
+ import { reviewEffortPolicy } from './review-effort.mjs';
3
+ import { prepareReviewHistory, finishReviewHistory } from './review-history.mjs';
2
4
  import { configureWireBudget } from './model-wire-budget.mjs';
3
5
  import {REVIEW_RUNTIME} from './runtime-identity.mjs';
4
6
  import { buildResumeState, validateResumeState, resumePreview } from './review-resume.mjs';
@@ -56,10 +58,11 @@ function buildPreviewNotice(snapshot, options, enableRiskPlanning, changesConcur
56
58
  return scopeNotice + (snapshot.history?.vcs==='svn'&&options.autoContext===true?'SVN历史autoContext为精确依赖定位读取固定版本父目录有界清单,包含同目录非匹配名字元数据;仅匹配捕获正文发送模型,不扫描全仓,不用工作树替代历史版本。':'') + (options.oldContextPaths !== undefined ? 'oldContextPaths仅为baseline旧侧正文,无目标侧替代;选中变更复用原old身份,旧侧不能证明新侧行为。' : '') + (snapshot.callerDiscovery ? 'callerScopePaths已授权预览读取限定目录内tracked候选正文,包括不匹配文件;非匹配正文不发送模型,匹配捕获上下文会发送。仅字面引用导航,不是函数调用或完整调用覆盖。' : '') + (enableRiskPlanning ? '执行按plan阈值决定风险假设规划,小变更可跳过独立规划但仍主审并复核;计划不是证据,所有阶段共享原预算。' : '') + `执行会向上述模型提供方发送可审查文件两侧内容、预览列出的显式/自动上下文、所选项目规则全文及预览业务需求文本。模型可在该快照范围内多轮只读检索,不读取范围外文件;候选生成后会进行证据与反证复核,两阶段各最多${maxRetrievalRounds}轮检索;独立文件审查链最多${changesConcurrency}条并发,不设插件内部默认时间或总模型调用次数截止,遵循宿主/用户取消;输入字节和检索轮次保护仍保留;复核不能证明缺陷成立。plan.reviewRounds为独立审查工作量,非模型reasoning effort;后续轮不带首轮风险计划,所有轮共享原超时与预算。plan仅预检初始输入,ready不代表已审查;minimumCalls不含后续检索/复核/动态业务综合;多文件可先进行业务分组,分组失败保留原组并记录限制,动态综合数执行前未知,实际预算可能不足。请先内部核对预览与当前人类授权。用户已明确要求审核且范围、模型和发送目的地一致时直接执行,不重复询问确认;只要求预览或明确要求等待确认时不得执行,超出授权或边界不明确时才询问。宿主权限和快照/身份校验仍须通过。`;
57
59
  }
58
60
 
59
- function createSessionReviewService(llm, { capture = captureSnapshot, hostChildren, wholeScopeConcurrency = 2, executionStrategy = 'staged', maxInputBytes = 96 * 1024, now = Date.now, ttlMs = 300000, maxPreviews = 8, allowModelSending = false, enableRiskPlanning = false, skipSmallRiskPlans = true, reviewRounds = 1, enableBusinessGrouping = false, enableAnchorCorrection = false, changesConcurrency = 2, maxRetrievalRounds = 3, riskPlanningMode = 'separate', readerBudgetPolicy = 'fixed', optimizeReviewCalls = false, enablePrimarySeeds = false, focusPrimaryContext = false, enableSearchSeeds = false, nativeOutputTools = false, enableGroupPrimary = false, enableEvidenceContext = false, inlineInteractionRisks = false, enableMigrationContracts = false, enableMigrationRegression = false, enableGroupInteractions = false, enableStageContinuation = false, authorize = async () => false } = {}) {
61
+ function createSessionReviewService(llm, { capture = captureSnapshot, reviewEffort = 'auto', enableCounterVerification = false, hostChildren, wholeScopeConcurrency = 2, executionStrategy = 'staged', maxInputBytes = 96 * 1024, now = Date.now, ttlMs = 300000, maxPreviews = 8, allowModelSending = false, enableRiskPlanning = false, skipSmallRiskPlans = true, reviewRounds = 1, enableBusinessGrouping = false, enableAnchorCorrection = false, changesConcurrency = 2, maxRetrievalRounds = 3, riskPlanningMode = 'separate', readerBudgetPolicy = 'fixed', optimizeReviewCalls = false, enablePrimarySeeds = false, focusPrimaryContext = false, enableSearchSeeds = false, nativeOutputTools = false, enableGroupPrimary = false, enableEvidenceContext = false, inlineInteractionRisks = false, enableMigrationContracts = false, enableMigrationRegression = false, enableGroupInteractions = false, enableStageContinuation = false, authorize = async () => false } = {}) {
60
62
  for (const value of [ttlMs, maxPreviews]) if (!Number.isSafeInteger(value) || value < 1) throw new Error('Invalid preview limit');
61
63
  if (!Number.isSafeInteger(maxInputBytes) || maxInputBytes < 1 || maxInputBytes > 4 * 1024 * 1024) throw new Error('Invalid configured input capacity');
62
64
  if (!['staged', 'whole-scope'].includes(executionStrategy)) throw new Error('Invalid review execution strategy');
65
+ if (!['auto','quick','standard','deep'].includes(reviewEffort) || typeof enableCounterVerification !== 'boolean') throw new Error('Invalid review effort policy');
63
66
  wholeScopeConcurrency = reviewConcurrency(wholeScopeConcurrency);
64
67
  changesConcurrency = reviewConcurrency(changesConcurrency);
65
68
  maxRetrievalRounds = reviewRetrievalRounds(maxRetrievalRounds);
@@ -68,7 +71,7 @@ function createSessionReviewService(llm, { capture = captureSnapshot, hostChildr
68
71
  for (const value of [optimizeReviewCalls, enablePrimarySeeds, focusPrimaryContext, enableSearchSeeds, nativeOutputTools, enableGroupPrimary, enableEvidenceContext, inlineInteractionRisks, enableMigrationContracts, enableMigrationRegression, enableGroupInteractions, enableStageContinuation]) if (typeof value !== 'boolean') throw new Error('Invalid review optimization option');
69
72
  const lifetime = new AbortController();
70
73
  const bind = exec => ({ ...exec, signal: exec.signal ? AbortSignal.any([exec.signal, lifetime.signal]) : lifetime.signal });
71
- const resumeOptions = (options, route, rounds) => ({ captureOptions: options, route, runtime: REVIEW_RUNTIME, instructionsHash: hash(REVIEW_INSTRUCTIONS), execution: { executionStrategy, maxInputBytes, wholeScopeConcurrency, rounds, enableRiskPlanning, skipSmallRiskPlans, enableBusinessGrouping, enableAnchorCorrection, changesConcurrency, maxRetrievalRounds, riskPlanningMode, readerBudgetPolicy, optimizeReviewCalls, enablePrimarySeeds, focusPrimaryContext, enableSearchSeeds, nativeOutputTools, enableGroupPrimary, enableEvidenceContext, inlineInteractionRisks, enableMigrationContracts, enableMigrationRegression, enableGroupInteractions, enableStageContinuation } });
74
+ const resumeOptions = (options, route, rounds, effortMode, instructions) => ({ captureOptions: options, route, runtime: REVIEW_RUNTIME, instructionsHash: hash(instructions), execution: { reviewEffort:effortMode, enableCounterVerification, executionStrategy, maxInputBytes, wholeScopeConcurrency, rounds, enableRiskPlanning, skipSmallRiskPlans, enableBusinessGrouping, enableAnchorCorrection, changesConcurrency, maxRetrievalRounds, riskPlanningMode, readerBudgetPolicy, optimizeReviewCalls, enablePrimarySeeds, focusPrimaryContext, enableSearchSeeds, nativeOutputTools, enableGroupPrimary, enableEvidenceContext, inlineInteractionRisks, enableMigrationContracts, enableMigrationRegression, enableGroupInteractions, enableStageContinuation } });
72
75
  const previews = new Map();
73
76
  let busy = false;
74
77
  function prune() { for (const [id, p] of previews) if (p.expires <= now()) previews.delete(id); }
@@ -77,7 +80,7 @@ function createSessionReviewService(llm, { capture = captureSnapshot, hostChildr
77
80
  exec = bind(exec);
78
81
  exec.signal.throwIfAborted();
79
82
  if (busy) throw new Error('Review service busy');
80
- if (!args || Object.keys(args).some(k => !['repositoryPath', 'selectedPaths', 'contextPaths', 'rulePaths', 'autoContext', 'commit', 'baseRevision', 'targetRevision','businessRequirement','callerScopePaths','oldContextPaths','reviewRounds','svnRevision','svnBaseRevision','svnTargetRevision','svnAllowNetwork','svnEffectiveMergeinfo','svnBasePath','svnTargetPath','svnReplacementBaseRevision','svnReplacementAllowNetwork','changeScope','resumeFromJobId'].includes(k))) throw new Error('Unknown preview argument');
83
+ if (!args || Object.keys(args).some(k => !['repositoryPath', 'selectedPaths', 'contextPaths', 'rulePaths', 'autoContext', 'commit', 'baseRevision', 'targetRevision','businessRequirement','callerScopePaths','oldContextPaths','reviewRounds','svnRevision','svnBaseRevision','svnTargetRevision','svnAllowNetwork','svnEffectiveMergeinfo','svnBasePath','svnTargetPath','svnReplacementBaseRevision','svnReplacementAllowNetwork','changeScope','resumeFromJobId','rereviewFromJobId','findingOutcomes','reviewEffort','rereviewScope'].includes(k))) throw new Error('Unknown preview argument');
81
84
  if (args.resumeFromJobId !== undefined && (typeof args.resumeFromJobId !== 'string' || !/^[a-f0-9-]{36}$/.test(args.resumeFromJobId))) throw new Error('Invalid resume job id');
82
85
  if (args.autoContext !== undefined && typeof args.autoContext !== 'boolean') throw new Error('Invalid autoContext option');
83
86
  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');
@@ -85,10 +88,22 @@ function createSessionReviewService(llm, { capture = captureSnapshot, hostChildr
85
88
  if (executionStrategy === 'whole-scope' && (rounds !== 1 || args.resumeFromJobId !== undefined)) throw new Error('Whole-scope requires fresh single-round changes review');
86
89
  validateBusinessRequirement(args.businessRequirement);
87
90
  if (args.callerScopePaths !== undefined) validateCallerScopes(args.callerScopePaths);
91
+ const effortMode = args.reviewEffort ?? reviewEffort;
92
+ if (!['auto','quick','standard','deep'].includes(effortMode)) throw new Error('Invalid review effort mode');
93
+ if (args.rereviewFromJobId !== undefined && (typeof args.rereviewFromJobId !== 'string' || !/^[a-f0-9-]{36}$/.test(args.rereviewFromJobId) || args.resumeFromJobId !== undefined || rounds !== 1)) throw new Error('Invalid or conflicting re-review source');
94
+ if (args.findingOutcomes !== undefined && (!Array.isArray(args.findingOutcomes) || args.findingOutcomes.length > 200 || args.rereviewFromJobId === undefined)) throw new Error('Finding outcomes require bounded explicit re-review');
95
+ if(args.rereviewScope!==undefined&&(!['full','diff-only'].includes(args.rereviewScope)||args.rereviewFromJobId===undefined))throw new Error('Explicit re-review scope requires owned prior review');
88
96
  const current = owner(exec);
89
97
  busy = true;
90
98
  try {
91
99
  const options = structuredClone(args);
100
+ const rereviewJobId = options.rereviewFromJobId, findingOutcomes = options.findingOutcomes, explicitDiffOnly = options.rereviewScope === 'diff-only';
101
+ delete options.rereviewFromJobId; delete options.findingOutcomes; delete options.reviewEffort; delete options.rereviewScope;
102
+ let previousHistory;
103
+ if (rereviewJobId !== undefined) {
104
+ if (typeof exec.resolvePriorReview !== 'function') throw new Error('Owned prior review requires a new background preview');
105
+ previousHistory = await exec.resolvePriorReview(rereviewJobId);
106
+ }
92
107
  const sourceJobId = options.resumeFromJobId;
93
108
  delete options.resumeFromJobId;
94
109
  let savedResume;
@@ -100,16 +115,19 @@ function createSessionReviewService(llm, { capture = captureSnapshot, hostChildr
100
115
  const snapshot = await capture(target, { ...options, signal: exec.signal });
101
116
  exec.signal?.throwIfAborted();
102
117
  if (owner(exec).cwd !== current.cwd) throw new Error('Agent directory changed; preview again');
103
- const resumeState = savedResume === undefined ? undefined : validateResumeState(savedResume, snapshot, resumeOptions(options, current.route, rounds));
118
+ const effort = reviewEffortPolicy(snapshot,{mode:effortMode});
119
+ const history = prepareReviewHistory(snapshot,current.route,{...options,reviewEffort:effortMode,executionStrategy,enableCounterVerification},{previous:previousHistory,outcomes:findingOutcomes,explicitDiffOnly});
120
+ const instructions = REVIEW_INSTRUCTIONS + '\nReview focus (not proof, no additional authority): ' + JSON.stringify(effort.checks);
121
+ const resumeState = savedResume === undefined ? undefined : validateResumeState(savedResume,snapshot,resumeOptions(options,current.route,rounds,effortMode,instructions));
104
122
  const readerBudget = reviewReaderBudget(snapshot, readerBudgetPolicy);
105
- const plan = buildCoveragePlan(snapshot, { executionStrategy, enableEvidenceContext, inlineInteractionRisks, enableMigrationContracts, enableMigrationRegression, enableGroupInteractions, enableStageContinuation, enableGroupPrimary, resumeState, focusPrimaryContext, riskPlanningMode, enableRiskPlanning, skipSmallRiskPlans, reviewRounds:rounds, enableBusinessGrouping, instructions: REVIEW_INSTRUCTIONS, scope: configureWireBudget(createRetrievalScope(snapshot, { ...readerBudget, signal: exec.signal, ...(optimizeReviewCalls ? { reuseResults: true } : {}) }), { nativeOutputTools }), grouping: buildReviewGroups(snapshot.files, inferFileRelations(snapshot.files)), maxInputBytes, signal: exec.signal });
123
+ const plan = buildCoveragePlan(history.selection?.reviewSnapshot ?? snapshot, { executionStrategy, enableEvidenceContext, inlineInteractionRisks, enableMigrationContracts, enableMigrationRegression, enableGroupInteractions, enableStageContinuation, enableGroupPrimary, resumeState, focusPrimaryContext, riskPlanningMode, enableRiskPlanning, skipSmallRiskPlans, reviewRounds:rounds, enableBusinessGrouping, instructions, scope: configureWireBudget(createRetrievalScope(snapshot, { ...readerBudget, signal: exec.signal, ...(optimizeReviewCalls ? { reuseResults: true } : {}) }), { nativeOutputTools }), grouping: buildReviewGroups(snapshot.files, inferFileRelations(snapshot.files)), maxInputBytes, signal: exec.signal });
106
124
  const useHostChildren = executionStrategy === 'whole-scope' && plan.primaryGroups.length > 1 && !!hostChildren;
107
125
  if (useHostChildren) await hostChildren.prepare();
108
126
  prune();
109
127
  while (previews.size >= maxPreviews) previews.delete(previews.keys().next().value);
110
128
  const previewId = randomUUID();
111
129
  const expires = now() + ttlMs;
112
- previews.set(previewId, { ...current, target, options, rounds, snapshotId: snapshot.id, resumeState, sourceJobId, useHostChildren, expires });
130
+ previews.set(previewId, { ...current, target, options, rounds, snapshotId: snapshot.id, resumeState, sourceJobId, useHostChildren, effort, instructions, history, rereviewJobId, expires });
113
131
  return {
114
132
  ...(options.businessRequirement !== undefined
115
133
  ? { businessRequirement: { text: options.businessRequirement, hash: hash(options.businessRequirement), bytes: Buffer.byteLength(options.businessRequirement) } }
@@ -119,9 +137,11 @@ function createSessionReviewService(llm, { capture = captureSnapshot, hostChildr
119
137
  validForMs: ttlMs,
120
138
  ...(resumeState ? { resume: resumePreview(resumeState, sourceJobId) } : {}),
121
139
  plan,
140
+ reviewEffort: effort,
141
+ ...(rereviewJobId ? { rereview: { sourceJobId:rereviewJobId,cycle:history.previous.cycle+1,maxCycles:3,incremental:history.incremental,notice:'Historical metadata only; no prior approval, conclusions or receipts are reused. Navigation paths are not coverage.' } } : {}),
122
142
  snapshotId: snapshot.id,
123
143
  privateArtifacts: { enabled: typeof exec.onModelArtifact === 'function', sourceBearing: true, maxArtifactBytes: 1048576, maxJobBytes: 8388608, maxArtifacts: 4096, notice: 'Background jobs retain bounded untrusted model output outside the reviewed workspace; omissions/truncation explicit. Never included in ordinary status or reused as evidence; explicit consent required to read.' },
124
- execution: { executionStrategy, ...(executionStrategy === 'whole-scope' ? { resumeSupported: false, maxInputBytes, batchExecution: useHostChildren ? 'host-subagents' : 'direct', batchConcurrency: useHostChildren ? Math.min(wholeScopeConcurrency, plan.primaryGroups.length) : 1 } : {}), nativeOutputTools, enableGroupPrimary, enableEvidenceContext, inlineInteractionRisks, enableMigrationContracts, enableMigrationRegression, enableGroupInteractions, enableStageContinuation, fileConcurrency: changesConcurrency, maxRetrievalRounds, ...(optimizeReviewCalls ? { riskPlanningMode, readerBudget: { policy: readerBudgetPolicy, ...readerBudget }, missingContextSeeds: enablePrimarySeeds, focusPrimaryContext, searchContextSeeds: enableSearchSeeds } : {}) },
144
+ execution: { executionStrategy, counterVerification: enableCounterVerification && effort.counterVerification.eligible, ...(executionStrategy === 'whole-scope' ? { resumeSupported: false, maxInputBytes, batchExecution: useHostChildren ? 'host-subagents' : 'direct', batchConcurrency: useHostChildren ? Math.min(wholeScopeConcurrency, plan.primaryGroups.length) : 1 } : {}), nativeOutputTools, enableGroupPrimary, enableEvidenceContext, inlineInteractionRisks, enableMigrationContracts, enableMigrationRegression, enableGroupInteractions, enableStageContinuation, fileConcurrency: changesConcurrency, maxRetrievalRounds, ...(optimizeReviewCalls ? { riskPlanningMode, readerBudget: { policy: readerBudgetPolicy, ...readerBudget }, missingContextSeeds: enablePrimarySeeds, focusPrimaryContext, searchContextSeeds: enableSearchSeeds } : {}) },
125
145
  repositoryRoot: snapshot.root,
126
146
  vcs: snapshot.vcs,
127
147
  ...(snapshot.changeScope ? { changeScope: snapshot.changeScope } : options.changeScope !== undefined ? { changeScope: options.changeScope } : {}),
@@ -174,14 +194,17 @@ function createSessionReviewService(llm, { capture = captureSnapshot, hostChildr
174
194
  exec.signal?.throwIfAborted();
175
195
  if (snapshot.id !== preview.snapshotId || owner(exec).cwd !== current.cwd) throw new Error('Files changed since preview; preview again');
176
196
  if (preview.useHostChildren && !hostChildren?.capability()) throw new Error('Approved child execution capability changed; preview again');
177
- const resumeIdentity = resumeOptions(preview.options, preview.route, preview.rounds);
197
+ const resumeIdentity = resumeOptions(preview.options, preview.route, preview.rounds, preview.effort.mode, preview.instructions);
178
198
  const resumeState = preview.resumeState ? validateResumeState(preview.resumeState, snapshot, resumeIdentity) : undefined;
179
199
  if (await authorize({ snapshot, route: preview.route, exec }) !== true) throw new Error('Model sending approval denied or unavailable');
180
200
  exec.signal?.throwIfAborted();
181
201
  if (owner(exec).cwd !== current.cwd || JSON.stringify(owner(exec).route) !== JSON.stringify(preview.route)) throw new Error('Agent changed during approval');
182
202
  await exec.reviewCheckpoint?.({stage:'approved-manifest',snapshotId:snapshot.id,files:snapshot.files.map(f=>({fileId:f.id,path:f.path,status:f.eligibility==='reviewable'?'pending':f.eligibility}))});
183
- const report = await reviewSnapshot(snapshot, createDshModel(llm, preview.route,{correctJson:true,useNativeTools:nativeOutputTools}), { executionStrategy, maxInputBytes, wholeScopeConcurrency, childExecution: preview.useHostChildren ? 'host-subagents' : undefined, createBatchModel: preview.useHostChildren ? args => hostChildren.create({ ...args, parent: exec.agent, route: preview.route, cwd: target }) : undefined, signal: exec.signal, enableRetrieval: true, enableGrouping: true, enableGroupPrimary, enableEvidenceContext, inlineInteractionRisks, enableMigrationContracts, enableMigrationRegression, enableGroupInteractions, enableStageContinuation, nativeOutputTools, enableVerification: true, riskPlanningMode, readerBudgetPolicy, optimizeReviewCalls, enablePrimarySeeds, focusPrimaryContext, enableSearchSeeds, enableRiskPlanning, skipSmallRiskPlans, reviewRounds:preview.rounds, enableBusinessGrouping, enableAnchorCorrection,changesConcurrency,maxRetrievalRounds,correctProtocol:true, onProgress:exec.reviewCheckpoint, resumeState, onModelArtifact: exec.onModelArtifact, onResumeCheckpoint: exec.reviewResumeCheckpoint ? async checkpoint => { await exec.reviewResumeCheckpoint(buildResumeState(snapshot, resumeIdentity, checkpoint)); } : undefined });
203
+ const report = await reviewSnapshot(preview.history.selection?.reviewSnapshot ?? snapshot, createDshModel(llm, preview.route,{correctJson:true,useNativeTools:nativeOutputTools}), { approvedRetrievalSnapshot: snapshot, instructions: preview.instructions, reviewEffort: preview.effort, enableCounterVerification: enableCounterVerification && preview.effort.counterVerification.eligible, executionStrategy, maxInputBytes, wholeScopeConcurrency, childExecution: preview.useHostChildren ? 'host-subagents' : undefined, createBatchModel: preview.useHostChildren ? args => hostChildren.create({ ...args, parent: exec.agent, route: preview.route, cwd: target }) : undefined, signal: exec.signal, enableRetrieval: true, enableGrouping: true, enableGroupPrimary, enableEvidenceContext, inlineInteractionRisks, enableMigrationContracts, enableMigrationRegression, enableGroupInteractions, enableStageContinuation, nativeOutputTools, enableVerification: true, riskPlanningMode, readerBudgetPolicy, optimizeReviewCalls, enablePrimarySeeds, focusPrimaryContext, enableSearchSeeds, enableRiskPlanning, skipSmallRiskPlans, reviewRounds:preview.rounds, enableBusinessGrouping, enableAnchorCorrection,changesConcurrency,maxRetrievalRounds,correctProtocol:true, onProgress:exec.reviewCheckpoint, resumeState, onModelArtifact: exec.onModelArtifact, onResumeCheckpoint: exec.reviewResumeCheckpoint ? async checkpoint => { await exec.reviewResumeCheckpoint(buildResumeState(snapshot, resumeIdentity, checkpoint)); } : undefined });
204
+ if (preview.history.selection?.plan.requiredReportStatus === 'partial') { if (report.status==='completed') report.status='partial'; report.limitations.push({text:'增量复审只检查本轮选定主文件;未重审文件仅作批准检索上下文,不代表完整范围已查完。'}); }
184
205
  report.model = preview.route;
206
+ report.reviewEffort = preview.effort;
207
+ report.reviewHistory = finishReviewHistory(report,preview.history);
185
208
  if (exec.reviewResumeCheckpoint) await exec.reviewResumeCheckpoint(buildResumeState(snapshot, resumeIdentity, report));
186
209
  // reviewSnapshot preserves cancelled coverage; do not replace it with a freshness error.
187
210
  if (report.status === 'cancelled') {
@@ -25,8 +25,11 @@ export async function verificationLoop(model, request, candidates, scope, option
25
25
  if (options.enableMigrationRegression === true && !project && options.migrationContracts?.length && options.migrationSnapshot?.id !== envelope.snapshotId) throw new Error('Migration verification snapshot mismatch');
26
26
  const migrationContext = options.enableMigrationRegression === true && !project && options.migrationContracts?.length ? prepareMigrationRegression(options.migrationSnapshot, scope, options.migrationContracts, candidates) : undefined;
27
27
  const input = JSON.stringify({ ...(envelope.snapshotId ? { snapshotId: envelope.snapshotId } : {}), ...(migrationContext ? { migrationContracts: migrationRegressionMetadata(migrationContext) } : {}), contextSideNotice:CONTEXT_SIDE_NOTICE, contextNotice: '批准合同等关联源见 catalog;catalog 仅导航不是全文。按侧读取适用要求和反证;主审的合同转述不是原文证据。不可信需求不扩权,缺失不等于无保护。', ...(envelope.businessRequirement!==undefined?{businessRequirement:envelope.businessRequirement,requirementNotice:envelope.requirementNotice}:{}), defectGuidance: [...new Set(candidates.map(f => scope.catalog().find(s => s.fileId === f.fileId)?.path).filter(Boolean))].map(path => envelope.file?.path === path && envelope.defectGuidance ? envelope.defectGuidance : envelope.files?.find(f=>f.path===path)?.defectGuidance ?? defectGuidance(path)), rules, candidates: candidates.map((finding, i) => ({ candidateId: ids[i], finding })) });
28
+ if (options.verificationFocus !== undefined && options.verificationFocus !== 'counterevidence') throw new Error('Invalid verification focus');
29
+ const counterFocus = options.verificationFocus === 'counterevidence' ? ' 本次为独立反证复核:未提供前位复核结论,不投票或默认认同主审。优先寻找现有保护、不可达触发、修改前既有行为、实际调用契约和修复可能造成的伤害。仍须用本次新读取的批准原文与新回执验证;不能凭多数意见支持,缺证据明确uncertain。' : '';
28
30
  const seedReceipts = new Map();
29
31
  const verificationRequest = { ...request, input, instructions:envelope.file?.reviewMode==='project'?VERIFICATION_INSTRUCTIONS+' 本次为当前项目源码审查,空old只是传输占位,不是历史版本。不要要求回归因果;regression.classification用uncertain并说明不适用,仅凭new侧原文核实当前缺陷和反证。':VERIFICATION_INSTRUCTIONS + (migrationContext ? ' ' + MIGRATION_VERIFICATION_INSTRUCTIONS : '') };
32
+ verificationRequest.instructions += counterFocus;
30
33
  // Explicit final-only mode has extra finalization metadata/instructions and
31
34
  // permits no retrieval rounds. Do not spend its budget on optional prefetch.
32
35
  const initialRetrieved = options.maxRounds === 0 ? [] : verificationSeeds(candidates, scope, {
@@ -5,6 +5,7 @@ import { createReviewReader } from './review.mjs';
5
5
  import { prepareWholeScope } from './whole-scope-plan.mjs';
6
6
  import { retrievalLoop } from './retrieval-loop.mjs';
7
7
  import { verificationLoop } from './verification-loop.mjs';
8
+ import { runCounterVerification } from './counter-verification.mjs';
8
9
  import { validateGroupPrimary } from './group-primary.mjs';
9
10
  import { modelWireBudget } from './model-wire-budget.mjs';
10
11
  import { retrievalRounds } from './review-execution-policy.mjs';
@@ -178,10 +179,12 @@ export async function reviewWholeScope(snapshot, model, options = {}) {
178
179
  (report.orchestration.verifications ??= []).push(stage);
179
180
  try {
180
181
  gate(); // Do not prefetch receipt evidence for a vetoed model call.
181
- const verdicts = await verificationLoop(trackedModel, { ...requestFor(generatedBatches[0]), reviewPhase: 'verification' }, candidates, reader, loopOptions(stage));
182
+ let verdicts = await verificationLoop(trackedModel, { ...requestFor(generatedBatches[0]), reviewPhase: 'verification' }, candidates, reader, loopOptions(stage));
182
183
  signal.throwIfAborted();
183
- candidates.forEach((finding, i) => { finding.verification = { status: 'completed', ...verdicts[i] }; });
184
- stage.status = stage.retrievalLimited ? 'pending' : 'completed';
184
+ verdicts = await runCounterVerification(trackedModel, { ...requestFor(generatedBatches[0]),reviewPhase:'verification' }, candidates, verdicts, reader, { ...loopOptions(stage),enabled:options.enableCounterVerification===true,isFatalError:error=>checkpointFailed||error?.code==='MODEL_ARTIFACT_FAILED' });
185
+ signal.throwIfAborted();
186
+ candidates.forEach((finding, i) => { finding.verification = { status: verdicts[i].counterVerification?.status === 'incomplete' ? 'incomplete' : 'completed', ...verdicts[i] }; });
187
+ stage.status = stage.retrievalLimited || verdicts.some(v => v.counterVerification?.status === 'incomplete') ? 'pending' : 'completed';
185
188
  if (verdicts.some(v => v.verdict === 'uncertain' || v.verdict === 'supported' && !['introduced', 'preexisting'].includes(v.regression?.classification))) report.limitations.push({ text: '部分候选缺少充分复核证据或前后对照,保留不确定,不强行确认支持或本次引入。' });
186
189
  } catch (error) {
187
190
  signal.throwIfAborted();