@feng0531/dsh-code-review 0.85.6 → 0.85.8

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,21 @@
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.85.8 — 变更审查重复工作与调度修复
6
+
7
+ - 变更模式独立文件链默认两路受控并发(可配置 1~4),分组只初始化一次,交互综合等待相关前置主审;报告按原顺序归并,取消和致命检查点错误排空在途任务。
8
+ - 复核首轮可携带批准快照的新鲜同侧锚点原文与本次凭据,保留反证检索、共享预算和最终精确校验。完整变更计划可容纳时每文件只规划一次,不以首窗口冒充全文件;窗口覆盖仍按各次主审读取单独计算。
9
+ - 默认每阶段三轮检索真正传入执行链,可配置 0~50;到限只允许最终答复,明确未完成范围,不以缩短审查冒充提速。
10
+ - 提供方实际调用增加元数据计时,末尾源码复核有明确阶段;修复终态计时继续增长及 blocked 状态暂时变 pending 的显示问题。
11
+ - 离线速度脚本覆盖真实主审与候选反驳/凭据校验入口,不将受控延迟结果作为真实提供方速度或审查质量保证。
12
+
13
+ ## 0.85.7 — 大仓库项目选择预览修复
14
+
15
+ - Git 项目模式显式选择文件时只查询所选精确索引,不再因未选文件导致全仓索引输出或节点数超限;空选择不枚举索引,目录及通配符不展开。
16
+ - 初次捕获、执行前及末尾复核使用同一局部范围,保留所选索引/正文变动拒绝、冲突及链接限制;未选索引变化不再使局部快照失效。
17
+ - 局部预览与报告明确显示清单不完整、全仓总数未知及所选清单数,不冒充全项目覆盖。未指定选择和 SVN 清单上限保持不变。
18
+ - 这是当前源码项目预览修复,不新增历史重命名配对,也不将项目审核当作重构前后功能等价证明。
19
+
5
20
  ## 0.85.6 — 快照正确性、任务隔离与取消修复
6
21
 
7
22
  - Git 工作区撤销暂存重命名后按 HEAD 到当前原路径的净内容比较,不再误报不存在的删除;历史提交允许非 UTF-8 消息,父对象标识仍严格验证。
package/DEVELOPMENT.md CHANGED
@@ -16,7 +16,15 @@
16
16
 
17
17
  服务按DSH session对象隔离缓存和锁,同一会话changes/project两种模式共用互斥锁;不同会话可并行,模型调用与取消由各请求生命周期约束。Git工作区默认比较HEAD到工作区;changeScope=staged/unstaged分别只比较HEAD→index和index→工作区,执行前复核对应内容。宿主dispose终止全部会话服务。全项目是跟踪文件的当前源码分批,不是多仓库汇总或跨批次语义分析。
18
18
 
19
- 项目捕获最多10000清单节点、1000选择节点、16MiB,不设默认总模型调用次数或执行秒表,初次捕获/复核均遵循宿主与用户取消。SVN项目模式拒绝不支持节点属性,不隐式继承固定历史审查的网络权限。
19
+ 项目捕获完整清单最多 10000 节点、1000 选择节点、16 MiB。Git 显式选择以精确字面路径查询所选索引,单次查询最多 64 KiB、总计 2 MiB,不受未选索引数量影响;初次、执行前及末尾复核使用相同范围。局部清单不宣称全仓完整性或总数,只绑定所选索引和正文,未选索引变动不使其失效。公开工具的显式列表仍最多 200 项。不设默认总模型调用次数或执行秒表,初次捕获/复核均遵循宿主与用户取消。SVN 仍枚举本地完整已观察清单,项目模式拒绝不支持节点属性,不隐式继承固定历史审查的网络权限。
20
+
21
+ ## 变更审查调度与离线对照
22
+
23
+ 生产 changes 服务默认 `changesConcurrency=2`,配置范围 1~4;底层 `reviewSnapshot` 未传该选项时保持串行兼容。所有文件共用原批准快照 reader,不能按 worker 重置额度。业务分组先完成,交互 owner 等待此前成员链结束;窗口、轮次与复核仍串行依赖,固定索引归并结果。取消或 checkpoint 失败必须先排空所有在途链再释放锁。
24
+
25
+ `maxRetrievalRounds` 默认 3、范围 0~50;生成/规划/复核分别使用该轮数上限,保留最终答复回合与 partial/uncertain。复核预取仅来自本次 reader 的候选同侧锚点范围,受输入/输出和最终校验预留约束;缺少事务、超预算或非行锚点回退普通检索,不信任模型自报证据。全范围风险计划仅限本次不可变文件调用内复用,规划元数据超过输入预算回退原窗口;预览与执行估算使用相同规划输入。
26
+
27
+ 运行 `node scripts/changes-speed-benchmark.mjs [baseline-directory]` 比较真实 review 入口上的 8 文件 SVN 受控候选/反驳夹具,包含 receipt 复核与最终精确原文校验;每次模型响应模拟 20ms。可先将冻结版本的 src 解包到独立目录作为 baseline。比较调用数、峰值并发和原文证据,不把离线计时或 mock 结论一致当作真实质量、token 成本或 45→12 分钟保证。`model-start/model-end` 检查点只记录计时、字节、状态及阶段,不写入源码或推理;各文件 `modelMetrics` 是提供方实际调用(含纠错)累计时间,并行时不可相加当作墙钟耗时。
20
28
 
21
29
  ## 全项目并发与离线速度对照
22
30
 
@@ -47,7 +55,7 @@
47
55
  除 README 中的文件和输入上限外:
48
56
 
49
57
  - 采集、导航、命令及模型审查默认不设插件内部秒表;内部API调用者仍可显式指定timeoutMs(0表示无内部截止)。用户/宿主取消仍传播并等待清理。
50
- - 每阶段最多三轮检索;全局最多 50 次读取请求,返回内容累计 256 KiB,单次最多 64 KiB,读取最多 200 行。
58
+ - 每阶段检索默认最多三轮,`maxRetrievalRounds` 可显式设 0~50;每个 review 快照共享最多 50 次读取操作,返回内容累计 256 KiB,单次最多 64 KiB,读取最多 200 行,预取与最终证据回读也计入同一账本。
51
59
  - Git 工作区依赖链最多三层、512 个精确路径查询、20 个候选、手动入口与自动依赖共享 1 MiB 正文;文件读取与最终复核共享宿主/用户取消信号。
52
60
  - SVN工作副本全采集记录命令次数、共享1MiB元数据内存保护,不设默认命令次数或时间截止,含初始/重复采集、导航、属性和末尾验证;cat输出另受16MiB总硬上限约束。历史采集也有独立的元数据与正文内存保护,不以命令次数或时间中断。
53
61
  - SVN 依赖链最多三层,以精确本地路径和安全正常节点捕获;512 路径、20 候选和上下文。工作副本显式上下文、自动依赖、调用方扫描(含非命中)及关键字BASE核验共享1 MiB来源内存账本与宿主/用户取消信号,不能切换阶段重新获得额度;切换、外部与不支持属性节点拒绝。
package/README.md CHANGED
@@ -231,7 +231,9 @@ Git / SVN 历史审查也能使用这些参数;依赖和调用方按对应版
231
231
 
232
232
  重启后可按原jobId读回保存记录,原运行标记interrupted,不自动继续外发。recovery给出已观察文件/窗口与未完成路径;它不是完整分母或新授权,补审必须重新预览确认。新预览的runtime包含实际加载版本和模块位置,排查不同工作区时分别核对,不根据另一台机器推断。
233
233
 
234
- 大文件超出单次输入时按原行号分段。精确差异算不出时使用明确标注的包络,可能包含未变行,不声称精确归因。模型JSON错误会按当前阶段schema请求一次明确纠正,记录额外调用;检索请求非法时整批不执行并明确纠正,回填采用可追溯的滚动工作集、必要时延后重复窗口正文,不虚构证据。默认不按检索轮数中断;不会给错误输出补括号、猜字段或当成无缺陷。
234
+ 变更审查的独立文件链默认最多 2 条并行;配置 `changesConcurrency` 为 1~4(1 回退串行),跨文件综合等待相关前置主审结束。配置 `maxRetrievalRounds` 为 0~50,默认为 3,控制每阶段成功检索轮数(0 只允许最终答复)。配置不是 preview 参数,不改变模型或来源授权;并发仍共享同一份读取/调用预算,额度不足时完成顺序可能影响哪些任务未完成,不能保证与串行逐项结果相同。提供方限流时应降低并发。复核可预取批准快照中候选锚点附近的同侧原文,仍允许追加反证读取并逐字校验;不复用主审结论或其他会话凭据。完整变更计划可容纳时仅生成一次再分发到窗口,否则保留原分窗口计划并在预览估算中体现。
235
+
236
+ 大文件超出单次输入时按原行号分段。精确差异算不出时使用明确标注的包络,可能包含未变行,不声称精确归因。模型JSON错误会按当前阶段schema请求一次明确纠正,记录额外调用;检索请求非法时整批不执行并明确纠正,回填采用可追溯的滚动工作集、必要时延后重复窗口正文,不虚构证据。主审与复核默认各最多 3 轮检索;到限后只允许最终答复,保留未完成范围,不把已用完预算当成审核通过。不会给错误输出补括号、猜字段或当成无缺陷。
235
237
 
236
238
  ## 多个会话同时审查
237
239
 
@@ -254,11 +256,11 @@ Git / SVN 历史审查也能使用这些参数;依赖和调用方按对应版
254
256
 
255
257
  项目模式只接受reviewMode、repositoryPath和可选selectedPaths,不与历史revision、autoContext、规则或调用方参数混用。默认全部跟踪节点;不读取未跟踪/忽略文件、子模块、外部仓库及链接目标。目录只作清单记录,源码按批次审查;跨批次不宣称完整调用图或全程序语义分析。
256
258
 
257
- 清单上限10000节点,单次选择最多1000节点/16MiB,12节点一批,单文件256KiB;超限明确拒绝,缩小选择后必须重新预览。单次采集共享8MiB元数据内存保护,不按命令条数、插件秒表或模型调用次数中断;耗时受宿主/模型提供方限制,用户可随时取消。SVN为本地已观察清单,稀疏/外部/链接及不支持属性显示限制;不证明仓库远端完整覆盖。
259
+ 未指定选择时,清单上限 10000 节点,Git 索引输出上限 2 MiB。Git 显式 `selectedPaths` 只查询所选精确文件,不先枚举全仓,因此大仓库可用小范围预览;空数组不枚举索引,目录与通配符不会展开。此时 `inventoryScope:"selected"`、`inventoryComplete:false`、`selectionComplete:false`,`totalTrackedNodes:null` 表示全仓总数未知,`inventoryNodes` 仅为所选清单数;只绑定所选索引及正文,未选文件的变化不使该局部快照失效。单次选择运行时最多 1000 节点(公开工具参数显式列表最多 200 项)/16 MiB,12 节点一批,单文件 256 KiB;超限必须重新预览。SVN 仍枚举本地已观察清单,显式选择不绕过清单上限,稀疏/外部/链接及不支持属性显示限制,不证明远端完整覆盖。SVN 单次采集共享 8 MiB 元数据保护;Git 所选索引单次查询最多 64 KiB、合计 2 MiB。不按命令条数、插件秒表或模型调用次数中断;耗时受宿主/模型提供方限制,用户可随时取消。
258
260
 
259
261
  全项目审查默认最多 **8 个独立批次并行**,每批内主审、检索、复核仍按依赖顺序执行;预览和报告的 `execution.batchConcurrency` 显示实际配置。插件配置 `projectConcurrency` 可设为 1~8,设 1 回退串行;遇到模型提供方并发限制或 429,应降低并发。该设置不是 preview 参数,不扩大来源或共享模型调用预算,也不减少模型调用量或承诺 token 成本下降。仅一个批次时不会获得批间加速;实际提速受提供方吞吐、批次均衡及检查点写入影响。
260
262
 
261
- 对外只交付一份统一最终报告,给出总数、completed、excluded、blocked、pending、去重后的发现及未完成综合阶段;内部批次快照仅供证据追溯,不需要用户拼接报告。completed只表示所选捕获文件已处理,不代表代码正确;排除与阻断从不算完成。代码或清单变化会拒绝旧预览,运行后无法复核则标记outdated。
263
+ 对外只交付一份统一最终报告,给出总数、completed、excluded、blocked、pending、去重后的发现及未完成综合阶段;内部批次快照仅供证据追溯,不需要用户拼接报告。completed只表示所选捕获文件已处理,不代表代码正确;排除与阻断从不算完成。所绑定的代码或清单变化会拒绝旧预览,运行后无法复核则标记outdated。
262
264
 
263
265
  ## 工具调用流程(给需要精确指定参数的人)
264
266
 
package/RELEASE-NOTES.md CHANGED
@@ -1,22 +1,19 @@
1
- # v0.85.6 发布说明
1
+ # v0.85.8 发布说明
2
2
 
3
3
  使用方式见 [README](README.md),详细变化见 [变更记录](CHANGELOG.md),发布门禁见 [开发说明](DEVELOPMENT.md)。
4
4
 
5
- ## 审核正确性
5
+ ## 减少重复往返,保留独立复核
6
6
 
7
- - Git 撤销暂存重命名后按实际工作区净内容比较;历史审核不再因合法非 UTF-8 提交消息失败。
8
- - 方法绑定不再遗漏 `toString`、`valueOf` 等合法名称;构造行附加语句、原型替换及可执行参数不再被错误认证。
9
- - SVN 标准关键字别名与固定字段 UTF-8 字节宽度保持原生语义;显式上下文增加原始正文哈希重验,验证读取继续累计预算。
10
-
11
- ## 隔离、取消与发布验证
12
-
13
- - 后台任务卸载覆盖首次持久化到终态,避免卸载完成后才启动审核;报告目录以真实路径验证工作区隔离,阻断路径别名绕过。
14
- - 本地文件以只读非阻塞方式打开并验证句柄,避免 Linux FIFO 替换导致等待;取消时关闭自身捕获管道,仍等待直接子进程退出并保留终止升级机制。正常成功路径继续完整收集输出。
15
- - npm 元数据及安装包正文网络中断按预算重试;无效 JSON、非法工件地址、输出超限及完整性不一致仍立即失败。
16
- - 补齐测试临时 SVN 仓库与工作副本清理。
7
+ - 变更模式的独立文件链默认两路并行;`changesConcurrency` 可设 1~4,1 回退串行。跨文件综合等待相关前置链结束,结果按原输入顺序归并;取消或致命检查点错误先等待在途清理,再释放会话锁。所有文件仍共享原批准快照的读取与模型调用预算。
8
+ - 复核首轮可预置本次批准快照中候选同侧锚点附近的原文和新鲜凭据,减少“先请求锚点、再返回结论”的往返。不复用主审判断或外部凭据;反证、旧侧及调用方仍可继续读取,最终证据仍精确回读校验并计入共享预算。无检索模式不预取,无法安全预取时回退普通检索。
9
+ - 完整变更元数据能容纳时,每文件只生成一次全范围风险计划,再分发到窗口;不能容纳时保留原分窗口路径,不以首窗口冒充全文件。每次主审的来源读取覆盖仍独立计算,不合并其他窗口、规划或复核的读取作为主审证明。
10
+ - `maxRetrievalRounds` 默认 3、范围 0~50,变更与项目模式的配置、预览和执行保持一致。到限只允许最终答复,仍明确 partial/uncertain 和未完成范围,不把限额耗尽视为审核通过。
11
+ - 增加提供方实际调用的阶段、时长和字节统计,不记录源码或推理正文;明确最终源码复核阶段,修复终态耗时继续增长及 blocked 状态被暂时显示为 pending 的问题。
17
12
 
18
13
  ## 验证与边界
19
14
 
20
- 新增 43 项回归。本地完整测试:Windows 750 项中 746 通过、4 项平台限定跳过;Linux 750 项全部通过。正式发布仍须通过冻结标签同一提交的 Ubuntu/Windows × Node.js 22/24 四组 CI、隔离安装以及原始包、npm 与 GitHub Release 工件一致性门禁。最终结果以对应 CI 和发布工作流为准。
15
+ 本地 Windows 完整测试 824 项:820 通过、4 项平台限定跳过、0 失败;隔离安装入口、本地化导出和新增执行模块包内容验证通过。另有独立差异复核及共享预算、取消清理、交互依赖、零轮输入边界和回调预算权限回归。正式发布须通过冻结标签同一提交的 Ubuntu/Windows × Node.js 22/24 四组 CI、隔离安装,以及原始 Actions 包、npm 与 GitHub Release 工件一致性门禁;远端结果以对应 CI 与发布工作流为准。
16
+
17
+ 受控对照采用生产审查开关、8 文件 SVN 候选/反驳夹具和每次模型响应 20ms 模拟延迟:旧版 25 次模型调用,修复版 17 次;峰值并发由 1 到 2,单次样例墙钟从 755ms 到 330ms。夹具中的结论和精确原文证据一致。这不是实际提供方速度、token 成本或审查质量保证,也不表示真实审核必然从 45 分钟降到某个时间。
21
18
 
22
- 只读非阻塞打开不是原子防路径替换,不保证任意设备或网络文件系统无阻塞;进程取消不承诺杀死全部后代。本版不保证模型始终输出合法 JSON,也不代表补齐此前自动审核失败的文件。发布不自动安装、升级或重启 DSH;更新并重新加载插件后生效。
19
+ 并发上限不是全宿主 RPM/TPM 限流器;遇到提供方限流应降低并发。预算不足时任务完成顺序可能改变未完成范围,不保证与串行逐项结果相同。本版不重构快照重复采集或串行检查点持久化,不新增跨批次全程序语义覆盖。发布不自动安装、升级或重启 DSH;更新并重新加载插件后生效。
package/index.mjs CHANGED
@@ -14,7 +14,7 @@ const EXECUTE_PARAMETERS = { type: 'object', additionalProperties: false, proper
14
14
  export function apply(ctx, config = {}) {
15
15
  // This entry runs only when the host invokes the registered tool.
16
16
  // Host tool policy owns permission; do not request a second approval here.
17
- const service = createReviewService(ctx.llm, { allowModelSending: true, enableRiskPlanning: true, enableBusinessGrouping: true, enableAnchorCorrection: true, projectConcurrency: config.projectConcurrency ?? DEFAULT_PROJECT_CONCURRENCY, authorize: async () => true });
17
+ const service = createReviewService(ctx.llm, { allowModelSending: true, enableRiskPlanning: true, enableBusinessGrouping: true, enableAnchorCorrection: true, projectConcurrency: config.projectConcurrency ?? DEFAULT_PROJECT_CONCURRENCY, changesConcurrency: config.changesConcurrency ?? 2, maxRetrievalRounds: config.maxRetrievalRounds ?? 3, authorize: async () => true });
18
18
  ctx.tools.register({
19
19
  name: 'code_review_preview', description: PREVIEW_DESCRIPTION, parameters: PREVIEW_PARAMETERS, output: reviewToolOutput,
20
20
  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.85.6",
3
+ "version": "0.85.8",
4
4
  "private": false,
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
@@ -1,5 +1,6 @@
1
1
  import {reviewRounds} from './review-rounds.mjs';
2
- import {riskPlanningDecision} from './risk-plan.mjs';
2
+ import {riskPlanningDecision, RISK_PLAN_INSTRUCTIONS} from './risk-plan.mjs';
3
+ import { canonicalRiskInput } from './risk-scope.mjs';
3
4
  import {localBusinessGroups} from './business-groups.mjs';
4
5
  import { prepareInteractionInput } from './interaction-input.mjs';
5
6
  import { initialInputBudget } from './input-budget.mjs';
@@ -21,7 +22,11 @@ export function buildCoveragePlan(input, options) {
21
22
  if (file.eligibility !== 'reviewable') return item;
22
23
  const prepared = preparePrimaryInput(input, file, options);
23
24
  const riskPlanning = options.enableRiskPlanning === true ? options.skipSmallRiskPlans === true ? riskPlanningDecision([file]) : {required:true,reason:'unconditional'} : {required:false,reason:'disabled'};
24
- return { ...item, ...prepared.metadata, riskPlanning, status: prepared.budget.fits ? 'ready' : 'input-blocked', minimumCalls: prepared.budget.fits ? ((prepared.batches?.length ?? 1) * (rounds + (riskPlanning.required ? 1 : 0)) + (prepared.synthesis?.status === 'ready' ? rounds : 0)) : 0 };
25
+ const primaryCalls = prepared.batches?.length ?? 1;
26
+ const canonical = riskPlanning.required && prepared.budget.fits
27
+ ? canonicalRiskInput(input, file, prepared, { ...options, instructions: RISK_PLAN_INSTRUCTIONS }) : null;
28
+ const planningCalls = riskPlanning.required ? (canonical?.cacheable ? 1 : primaryCalls) : 0;
29
+ return { ...item, ...prepared.metadata, riskPlanning, status: prepared.budget.fits ? 'ready' : 'input-blocked', minimumCalls: prepared.budget.fits ? (primaryCalls * rounds + planningCalls + (prepared.synthesis?.status === 'ready' ? rounds : 0)) : 0 };
25
30
  });
26
31
  const local = options.enableBusinessGrouping === true ? localBusinessGroups(input.files) : null;
27
32
  const groups = [...(options.grouping?.groups ?? [])];
package/src/dsh-model.mjs CHANGED
@@ -33,7 +33,12 @@ export function createDshModel(llm, route, {
33
33
  const invalidJsonErrors = new WeakSet();
34
34
  const failureMetadata = new WeakMap();
35
35
 
36
- async function once({ instructions, input, signal }, contract) {
36
+ // Out-of-band observers cannot fail a call or trigger another provider send.
37
+ async function notify(callback, event) {
38
+ try { await callback?.(event); } catch { /* Metrics are not model/budget authority. */ }
39
+ }
40
+
41
+ async function once({ instructions, input, signal, onModelEvent, attempt }, contract) {
37
42
  signal?.throwIfAborted();
38
43
  let output = '';
39
44
  let size = 0;
@@ -42,6 +47,8 @@ export function createDshModel(llm, route, {
42
47
  const blocks = new Map();
43
48
  let finished = false;
44
49
  let finishKind = 'missing';
50
+ let startedAt, firstChunkMs = null, status = 'failed';
51
+ const event = { attempt, inputBytes: Buffer.byteLength(input) };
45
52
  const diagnostic = () => ({
46
53
  visibleBytes: Buffer.byteLength(output), totalOutputBytes: size, finishKind,
47
54
  inputBytes: Buffer.byteLength(input), requestedMaxTokens: maxTokens, maxOutputBytes,
@@ -52,84 +59,106 @@ export function createDshModel(llm, route, {
52
59
  return error;
53
60
  };
54
61
  try {
55
- const stream = llm.stream({
56
- ...identity,
57
- system: instructions + '\n' + contract,
58
- messages: [{ role: 'user', content: [{ type: 'text', text: input }] }],
59
- tools: [], maxTokens, signal,
60
- });
61
- for await (const chunk of stream) {
62
- signal?.throwIfAborted();
63
- if (finished) throw failure('Model emitted data after finish', 'MODEL_PROTOCOL_ERROR');
64
- if (isToolCall(chunk)) throw failure('Review model attempted a tool call', 'MODEL_PROTOCOL_ERROR');
65
- const delta = chunk.type === 'text-delta' || chunk.type === 'reasoning-delta';
66
- const completed = chunk.type === 'block-end' &&
67
- (chunk.block.type === 'text' || chunk.block.type === 'reasoning');
68
- if (delta || completed) {
69
- const type = completed ? chunk.block.type : chunk.type === 'text-delta' ? 'text' : 'reasoning';
70
- // Unindexed delta-only streams remain supported for older callers.
71
- const key = chunk.index ?? type;
72
- const previous = blocks.get(key);
73
- if (previous && previous.type !== type) throw failure('Model changed block type', 'MODEL_PROTOCOL_ERROR');
74
- // Like the host BlockAssembler, first close wins; never append stragglers.
75
- if (previous?.closed) continue;
76
- const text = completed ? chunk.block.text : (previous?.text ?? '') + chunk.text;
77
- const bytes = Buffer.byteLength(text);
78
- // A shorter final snapshot must not refund previously consumed output budget.
79
- const chargedBytes = Math.max(previous?.chargedBytes ?? 0, bytes);
80
- size += chargedBytes - (previous?.chargedBytes ?? 0);
81
- if (size > maxOutputBytes) throw failure('Model output limit exceeded', 'MODEL_OUTPUT_LIMIT');
82
- blocks.set(key, { type, text, chargedBytes, closed: completed });
83
- output = [...blocks.values()].filter(block => block.type === 'text').map(block => block.text).join('');
62
+ startedAt = performance.now();
63
+ try {
64
+ let stream;
65
+ try {
66
+ stream = llm.stream({
67
+ ...identity,
68
+ system: instructions + '\n' + contract,
69
+ messages: [{ role: 'user', content: [{ type: 'text', text: input }] }],
70
+ tools: [], maxTokens, signal,
71
+ });
72
+ } finally {
73
+ await notify(onModelEvent, { ...event, action: 'model-start' });
74
+ }
75
+ if (signal?.aborted) {
76
+ try { await stream.return?.(); } finally { signal.throwIfAborted(); }
84
77
  }
85
- if (chunk.type === 'finish') {
86
- finishKind = chunk.reason.kind;
87
- if (finishKind !== 'stop') {
88
- const error = failure('Model did not complete: ' + finishKind, 'MODEL_INCOMPLETE_OUTPUT');
89
- Object.assign(error.diagnostic, providerDiagnostic(chunk.reason));
90
- throw error;
78
+ for await (const chunk of stream) {
79
+ firstChunkMs ??= Math.max(0, performance.now() - startedAt);
80
+ signal?.throwIfAborted();
81
+ if (finished) throw failure('Model emitted data after finish', 'MODEL_PROTOCOL_ERROR');
82
+ if (isToolCall(chunk)) throw failure('Review model attempted a tool call', 'MODEL_PROTOCOL_ERROR');
83
+ const delta = chunk.type === 'text-delta' || chunk.type === 'reasoning-delta';
84
+ const completed = chunk.type === 'block-end' &&
85
+ (chunk.block.type === 'text' || chunk.block.type === 'reasoning');
86
+ if (delta || completed) {
87
+ const type = completed ? chunk.block.type : chunk.type === 'text-delta' ? 'text' : 'reasoning';
88
+ // Unindexed delta-only streams remain supported for older callers.
89
+ const key = chunk.index ?? type;
90
+ const previous = blocks.get(key);
91
+ if (previous && previous.type !== type) throw failure('Model changed block type', 'MODEL_PROTOCOL_ERROR');
92
+ // Like the host BlockAssembler, first close wins; never append stragglers.
93
+ if (previous?.closed) continue;
94
+ const text = completed ? chunk.block.text : (previous?.text ?? '') + chunk.text;
95
+ const bytes = Buffer.byteLength(text);
96
+ // A shorter final snapshot must not refund previously consumed output budget.
97
+ const chargedBytes = Math.max(previous?.chargedBytes ?? 0, bytes);
98
+ size += chargedBytes - (previous?.chargedBytes ?? 0);
99
+ if (size > maxOutputBytes) throw failure('Model output limit exceeded', 'MODEL_OUTPUT_LIMIT');
100
+ blocks.set(key, { type, text, chargedBytes, closed: completed });
101
+ output = [...blocks.values()].filter(block => block.type === 'text').map(block => block.text).join('');
102
+ }
103
+ if (chunk.type === 'finish') {
104
+ finishKind = chunk.reason.kind;
105
+ if (finishKind !== 'stop') {
106
+ const error = failure('Model did not complete: ' + finishKind, 'MODEL_INCOMPLETE_OUTPUT');
107
+ Object.assign(error.diagnostic, providerDiagnostic(chunk.reason));
108
+ throw error;
109
+ }
110
+ finished = true;
91
111
  }
92
- finished = true;
93
112
  }
113
+ } catch (error) {
114
+ // Keep thrown provider/cancellation identity, including frozen and primitive values.
115
+ // Only add safe structured metadata when the error permits it.
116
+ if (error && (typeof error === 'object' || typeof error === 'function') && !failureMetadata.has(error)) {
117
+ const safeDiagnostic = { ...diagnostic(), ...providerDiagnostic(error) };
118
+ failureMetadata.set(error, { code: safeDiagnostic.providerCode, diagnostic: safeDiagnostic });
119
+ try {
120
+ if (Object.isExtensible(error) && !Object.hasOwn(error, 'diagnostic')) {
121
+ Object.defineProperty(error, 'diagnostic', { value: structuredClone(safeDiagnostic), configurable: true, enumerable: true, writable: true });
122
+ }
123
+ } catch { /* Diagnostic accessors/proxies must not replace the primary failure. */ }
124
+ }
125
+ throw error;
94
126
  }
95
- } catch (error) {
96
- // Keep thrown provider/cancellation identity, including frozen and primitive values.
97
- // Only add safe structured metadata when the error permits it.
98
- if (error && (typeof error === 'object' || typeof error === 'function') && !failureMetadata.has(error)) {
99
- const safeDiagnostic = { ...diagnostic(), ...providerDiagnostic(error) };
100
- failureMetadata.set(error, { code: safeDiagnostic.providerCode, diagnostic: safeDiagnostic });
101
- try {
102
- if (Object.isExtensible(error) && !Object.hasOwn(error, 'diagnostic')) {
103
- Object.defineProperty(error, 'diagnostic', { value: structuredClone(safeDiagnostic), configurable: true, enumerable: true, writable: true });
104
- }
105
- } catch { /* Diagnostic accessors/proxies must not replace the primary failure. */ }
127
+ // Cancellation during iterator cleanup/end must not become an invalid-JSON correction.
128
+ signal?.throwIfAborted();
129
+ if (!finished) throw failure('Model stream ended without finish', 'MODEL_INCOMPLETE_OUTPUT');
130
+ try {
131
+ const text = modelJson(output);
132
+ status = 'completed';
133
+ return { text, diagnostic: diagnostic() };
134
+ } catch (error) {
135
+ error.diagnostic = { ...diagnostic(), ...error.diagnostic };
136
+ invalidJsonErrors.add(error);
137
+ failureMetadata.set(error, { code: error.code, diagnostic: error.diagnostic });
138
+ throw error;
106
139
  }
107
- throw error;
108
- }
109
- // Cancellation during iterator cleanup/end must not become an invalid-JSON correction.
110
- signal?.throwIfAborted();
111
- if (!finished) throw failure('Model stream ended without finish', 'MODEL_INCOMPLETE_OUTPUT');
112
- try {
113
- return { text: modelJson(output), diagnostic: diagnostic() };
114
- } catch (error) {
115
- error.diagnostic = { ...diagnostic(), ...error.diagnostic };
116
- invalidJsonErrors.add(error);
117
- failureMetadata.set(error, { code: error.code, diagnostic: error.diagnostic });
118
- throw error;
140
+ } finally {
141
+ await notify(onModelEvent, {
142
+ ...event, action: 'model-end', status: signal?.aborted ? 'cancelled' : status,
143
+ durationMs: Math.max(0, performance.now() - startedAt), firstChunkMs,
144
+ visibleBytes: Buffer.byteLength(output), totalOutputBytes: size,
145
+ });
119
146
  }
120
147
  }
121
148
 
122
149
  return async function invoke(request) {
123
150
  // Snapshot these before an async budget/progress hook can mutate the request.
124
- const original = { instructions: request.instructions, input: request.input, signal: request.signal };
151
+ const original = { instructions: request.instructions, input: request.input, signal: request.signal, onModelEvent: request.onModelEvent };
152
+ const onProtocolCorrection = request.onProtocolCorrection, onProtocolEvent = request.onProtocolEvent;
125
153
  const contract = modelContract(original.input, original.instructions);
126
154
  const correctionLimit = correctJson ? maxJsonCorrections : 0;
127
155
  const attempts = [];
128
156
  for (let attempt = 1; ; attempt++) {
129
157
  let result;
130
158
  try {
131
- result = await once({ ...original, instructions: original.instructions +
159
+ result = await once({ ...original, attempt, instructions: original.instructions +
132
160
  (attempt > 1 ? JSON_CORRECTION_SUFFIX + jsonCorrectionHint(attempts.at(-1)?.diagnostic) + ' 纠正次数:' + (attempt - 1) + '/' + correctionLimit + '。' : '') }, contract);
161
+ original.signal?.throwIfAborted();
133
162
  } catch (error) {
134
163
  const metadata = failureMetadata.get(error) ?? {};
135
164
  failureMetadata.delete(error);
@@ -149,7 +178,7 @@ export function createDshModel(llm, route, {
149
178
  }
150
179
  original.signal?.throwIfAborted();
151
180
  // Exactly one reservation per extra call; a thrown budget/cancellation veto is final.
152
- await request.onProtocolCorrection?.({
181
+ await onProtocolCorrection?.({
153
182
  ...structuredClone(metadata), attempts: structuredClone(attempts),
154
183
  action: 'explicit-json-correction', attempt: attempt + 1,
155
184
  correction: attempt, maxJsonCorrections: correctionLimit,
@@ -160,7 +189,7 @@ export function createDshModel(llm, route, {
160
189
  }
161
190
  if (attempt > 1) {
162
191
  // Out-of-band audit only: never inject metadata into stage-specific model schemas.
163
- await request.onProtocolEvent?.({
192
+ await onProtocolEvent?.({
164
193
  action: 'json-correction-complete', attempt, corrections: attempt - 1,
165
194
  attempts: structuredClone([...attempts, { attempt, diagnostic: result.diagnostic }]),
166
195
  notice: 'Fresh complete JSON accepted; malformed outputs discarded; extra model calls occurred.',
@@ -0,0 +1,23 @@
1
+ // Admit only ready chains. Dependencies point to earlier input positions, never workers.
2
+ export async function scheduleFileChains(indices, run, dependencies, concurrency) {
3
+ const pending = [...indices], settled = new Set(), active = new Map();
4
+ let failed = false, failure;
5
+ const start = index => {
6
+ const promise = Promise.resolve().then(() => run(index)).catch(error => { if (!failed) { failed = true; failure = error; } }).finally(() => { settled.add(index); active.delete(index); });
7
+ active.set(index, promise);
8
+ };
9
+ while (pending.length || active.size) {
10
+ while (!failed && active.size < concurrency) {
11
+ const position = pending.findIndex(index => (dependencies.get(index) ?? []).every(dep => !indices.includes(dep) || settled.has(dep)));
12
+ if (position < 0) break;
13
+ start(pending.splice(position, 1)[0]);
14
+ }
15
+ if (!active.size) {
16
+ if (failed) break;
17
+ if (pending.length) throw new Error('Unresolvable file review dependency');
18
+ } else await Promise.race(active.values());
19
+ }
20
+ // Rejections never abandon sibling cleanup or release the service lock early.
21
+ await Promise.all(active.values());
22
+ if (failed) throw failure;
23
+ }
@@ -11,7 +11,7 @@ export function jobProgress(record) {
11
11
  return {
12
12
  stage: record.stage,
13
13
  terminal,
14
- elapsedMs: Date.now() - Date.parse(record.createdAt),
14
+ elapsedMs: Math.max(0, (record.finishedAt ? Date.parse(record.finishedAt) : Date.now()) - Date.parse(record.createdAt)),
15
15
  total: r.manifestSaved ? r.files.length : null,
16
16
  counts,
17
17
  processed,
@@ -1,4 +1,5 @@
1
1
  import { reviewSnapshot } from './review.mjs';
2
+ import { retrievalRounds } from './review-execution-policy.mjs';
2
3
 
3
4
  export const DEFAULT_PROJECT_CONCURRENCY = 8;
4
5
  export function projectConcurrency(value = DEFAULT_PROJECT_CONCURRENCY) {
@@ -8,8 +9,9 @@ export function projectConcurrency(value = DEFAULT_PROJECT_CONCURRENCY) {
8
9
 
9
10
  // Parallelize independent approved snapshots, never dependent stages within a batch.
10
11
  // Admission and the shared call reservation are synchronous; completion order is not report order.
11
- export async function reviewProjectBatches(batches, model, { concurrency = DEFAULT_PROJECT_CONCURRENCY, remainingCalls, signal, onProgress } = {}) {
12
+ export async function reviewProjectBatches(batches, model, { concurrency = DEFAULT_PROJECT_CONCURRENCY, remainingCalls, signal, onProgress, maxRetrievalRounds = 3 } = {}) {
12
13
  projectConcurrency(concurrency);
14
+ retrievalRounds(maxRetrievalRounds);
13
15
  if (!Array.isArray(batches) || typeof remainingCalls !== 'function') throw new Error('Invalid project execution');
14
16
  const controller = new AbortController();
15
17
  const batchSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
@@ -25,7 +27,7 @@ export async function reviewProjectBatches(batches, model, { concurrency = DEFAU
25
27
  if (batchSignal.aborted || remainingCalls() <= 0) { results[index] = unstarted(batch); continue; }
26
28
  results[index] = await reviewSnapshot(batch, model, {
27
29
  signal: batchSignal, maxCalls: remainingCalls(), remainingCalls, enableRetrieval: true, enableVerification: true,
28
- enableAnchorCorrection: true, correctProtocol: true,
30
+ enableAnchorCorrection: true, correctProtocol: true, maxRetrievalRounds,
29
31
  onProgress: onProgress ? event => onProgress({ ...event, snapshotId: batch.id, batchIndex: index }) : undefined,
30
32
  });
31
33
  } catch (error) {
@@ -7,6 +7,7 @@ import { detectVcs } from './snapshot.mjs';
7
7
  import { projectInventory, captureProjectBatch } from './project-snapshot.mjs';
8
8
  import { reviewProjectBatches, projectConcurrency as validateProjectConcurrency, DEFAULT_PROJECT_CONCURRENCY } from './project-execution.mjs';
9
9
  import { createDshModel } from './dsh-model.mjs';
10
+ import { retrievalRounds } from './review-execution-policy.mjs';
10
11
 
11
12
  const PREVIEW_NOTICE_SUFFIX = ' Full current source audit, not regression-only diff. Shared session execution budget: no internal elapsed-time or model-call cutoff, 16MiB capture memory protection. Blocked/excluded/pending files are not reviewed successfully. No untracked/ignored/external bodies; no complete semantic call graph. Independent approved batches run with bounded concurrency; per-batch evidence and global model-call budgets remain enforced. Show scope and model before confirmed execute.';
12
13
  const REPORT_NOTICE_SUFFIX = ' Coverage counts processing of selected tracked files only, not proof of correctness. Excluded and blocked are never completed. No cross-batch whole-program semantic coverage is claimed.';
@@ -31,7 +32,7 @@ function makeCaptureBudget(signal) {
31
32
 
32
33
  async function captureProject(cwd, args, signal) {
33
34
  const captureBudget = makeCaptureBudget(signal);
34
- const captureOptions = { signal, svnCaptureBudget: captureBudget };
35
+ const captureOptions = { signal, svnCaptureBudget: captureBudget, selectedPaths: args.selectedPaths };
35
36
  const root = await realpath(await resolveWorkspaceRepository(cwd, args.repositoryPath));
36
37
  const vcs = await detectVcs(root);
37
38
  const inventory = await projectInventory(root, vcs, captureOptions);
@@ -59,18 +60,20 @@ async function captureProject(cwd, args, signal) {
59
60
  }
60
61
 
61
62
  const result = {
62
- root, vcs, inventoryId: inventory.id, inventoryComplete: inventory.vcs === 'git',
63
- totalTrackedNodes: inventory.records.length, selectedFiles: paths.length,
64
- selectionComplete: paths.length === inventory.records.length, notice: inventory.notice, batches,
63
+ root, vcs, inventoryId: inventory.id, inventoryComplete: inventory.inventoryComplete,
64
+ inventoryScope: inventory.inventoryScope, inventoryNodes: inventory.records.length,
65
+ totalTrackedNodes: inventory.totalTrackedNodes, selectedFiles: paths.length,
66
+ selectionComplete: inventory.inventoryScope !== 'selected' && paths.length === inventory.records.length, notice: inventory.notice, batches,
65
67
  };
66
68
  return { ...result, id: hash(JSON.stringify(result)) };
67
69
  }
68
70
 
69
- export function createProjectReviewService(llm, { allowModelSending = false, authorize = async () => false, now = Date.now, ttlMs = 300000, maxPreviews = 8, projectMaxCalls = Number.MAX_SAFE_INTEGER, projectConcurrency = DEFAULT_PROJECT_CONCURRENCY } = {}) {
71
+ export function createProjectReviewService(llm, { allowModelSending = false, authorize = async () => false, now = Date.now, ttlMs = 300000, maxPreviews = 8, projectMaxCalls = Number.MAX_SAFE_INTEGER, projectConcurrency = DEFAULT_PROJECT_CONCURRENCY, maxRetrievalRounds = 3 } = {}) {
70
72
  for (const n of [ttlMs, maxPreviews]) if (!Number.isSafeInteger(n) || n < 1) throw new Error('Invalid project preview limit');
71
73
  if (!Number.isSafeInteger(projectMaxCalls) || projectMaxCalls < 1) throw new Error('Invalid project model call limit');
72
74
 
73
75
  validateProjectConcurrency(projectConcurrency);
76
+ retrievalRounds(maxRetrievalRounds);
74
77
  const previews = new Map();
75
78
  const lifetime = new AbortController();
76
79
  let busy = false;
@@ -96,10 +99,10 @@ export function createProjectReviewService(llm, { allowModelSending = false, aut
96
99
  previewId, snapshotId: s.id, reviewMode: 'project', repositoryRoot: s.root, vcs: s.vcs,
97
100
  model: current.route, modelSendingEnabled: allowModelSending,
98
101
  totalTrackedNodes: s.totalTrackedNodes, selectedFiles: s.selectedFiles, selectionComplete: s.selectionComplete,
99
- inventoryComplete: s.inventoryComplete, batches: s.batches.length,
100
- execution: { batchConcurrency: projectConcurrency },
102
+ inventoryComplete: s.inventoryComplete, inventoryScope: s.inventoryScope, inventoryNodes: s.inventoryNodes, batches: s.batches.length,
103
+ execution: { batchConcurrency: projectConcurrency, maxRetrievalRounds },
101
104
  files: s.batches.flatMap(b => b.files.map(f => ({ path: f.path, eligibility: f.eligibility, reason: f.reason }))),
102
- notice: s.notice + PREVIEW_NOTICE_SUFFIX,
105
+ notice: s.notice + PREVIEW_NOTICE_SUFFIX + ' Each stage allows at most ' + maxRetrievalRounds + ' retrieval rounds; exhaustion requires a final-only response and explicit unfinished coverage.',
103
106
  };
104
107
  } finally { busy = false; }
105
108
  },
@@ -139,7 +142,7 @@ export function createProjectReviewService(llm, { allowModelSending = false, aut
139
142
  };
140
143
 
141
144
  const results = await reviewProjectBatches(s.batches, model, {
142
- concurrency: projectConcurrency, signal, remainingCalls: () => projectMaxCalls - calls,
145
+ concurrency: projectConcurrency, maxRetrievalRounds, signal, remainingCalls: () => projectMaxCalls - calls,
143
146
  onProgress: exec.reviewCheckpoint,
144
147
  });
145
148
 
@@ -160,14 +163,20 @@ export function createProjectReviewService(llm, { allowModelSending = false, aut
160
163
  : results.every(r => r.status === 'completed') && files.every(f => ['completed', 'excluded'].includes(f.status)) ? 'completed'
161
164
  : 'partial',
162
165
  repositoryRoot: s.root, vcs: s.vcs, model: p.route, modelCalls: calls, maxModelCalls: projectMaxCalls,
163
- totalTrackedNodes: s.totalTrackedNodes, selectionComplete: s.selectionComplete, inventoryComplete: s.inventoryComplete,
166
+ totalTrackedNodes: s.totalTrackedNodes, selectionComplete: s.selectionComplete, inventoryComplete: s.inventoryComplete, inventoryScope: s.inventoryScope, inventoryNodes: s.inventoryNodes,
164
167
  coverage, files, findings, batches: results,
165
- execution: { batchConcurrency: projectConcurrency },
168
+ execution: { batchConcurrency: projectConcurrency, maxRetrievalRounds },
166
169
  notice: s.notice + REPORT_NOTICE_SUFFIX,
167
170
  };
168
171
 
169
- try { report.outdated = (await captureProject(current.cwd, p.args, signal)).id !== s.id; }
170
- catch { report.outdated = true; }
172
+ if (report.status === 'cancelled') report.outdated = true;
173
+ else {
174
+ await exec.reviewCheckpoint?.({ stage: 'source-freshness', snapshotId: s.id });
175
+ signal.throwIfAborted();
176
+ try { report.outdated = (await captureProject(current.cwd, p.args, signal)).id !== s.id; }
177
+ catch { signal.throwIfAborted(); report.outdated = true; }
178
+ signal.throwIfAborted();
179
+ }
171
180
  return { report, markdown: renderProjectReport(report) };
172
181
  } finally { busy = false; }
173
182
  },
@@ -15,7 +15,29 @@ async function inventoryGit(root, options) {
15
15
  const args = ['--no-optional-locks', '-c', 'core.fsmonitor=false', '-c', 'core.untrackedCache=false'];
16
16
  const actual = text(await checked('git', [...args, 'rev-parse', '--show-toplevel'], { ...options, cwd: root })).replace(/\r?\n$/, '');
17
17
  if (await realpath(actual) !== root) throw new Error('Full project requires explicit repository root');
18
- const index = await checked('git', [...args, 'ls-files', '--stage', '-z'], { ...options, cwd: root, maxBytes: 2 * 1024 * 1024 });
18
+ const selected = options.selectedPaths;
19
+ let index;
20
+ if (selected === undefined) {
21
+ index = await checked('git', [...args, 'ls-files', '--stage', '-z'], { ...options, cwd: root, maxBytes: 2 * 1024 * 1024 });
22
+ } else {
23
+ const chunks = [];
24
+ let bytes = 0;
25
+ for (const name of [...selected].sort()) {
26
+ // Literal pathspecs still recurse. Exclude descendants, not the node:
27
+ // a trailing-slash literal exclusion would also hide an exact gitlink.
28
+ const escaped = Array.from(name, c => '*?[]'.includes(c) ? String.fromCharCode(92) + c : c).join('');
29
+ const chunk = await checked('git', [...args, 'ls-files', '--stage', '-z', '--',
30
+ ':(top,literal)' + name, ':(top,glob,exclude)' + escaped + '/**'],
31
+ { ...options, cwd: root, maxBytes: 64 * 1024 });
32
+ for (const row of text(chunk).split(String.fromCharCode(0)).filter(Boolean)) {
33
+ if (row.slice(row.indexOf(String.fromCharCode(9)) + 1) !== name) throw new Error('Git project selection expanded beyond literal path');
34
+ }
35
+ bytes += chunk.length;
36
+ if (bytes > 2 * 1024 * 1024) throw new Error('Selected project inventory exceeds shared 2MiB limit');
37
+ chunks.push(chunk);
38
+ }
39
+ index = Buffer.concat(chunks);
40
+ }
19
41
  const records = [];
20
42
  for (const row of text(index).split(String.fromCharCode(0)).filter(Boolean)) {
21
43
  const m = /^(\d{6}) ([a-f0-9]+) ([0-3])\t([\s\S]+)$/.exec(row);
@@ -47,6 +69,11 @@ async function inventorySvn(root, options) {
47
69
  }
48
70
 
49
71
  export async function projectInventory(cwd, vcs, options = {}) {
72
+ const selected = options.selectedPaths;
73
+ if (selected !== undefined) {
74
+ if (!Array.isArray(selected) || selected.length > 1000 || new Set(selected).size !== selected.length) throw new Error('Invalid tracked project selection');
75
+ selected.forEach(relativePath);
76
+ }
50
77
  const root = await realpath(cwd);
51
78
  const source = vcs === 'git' ? await inventoryGit(root, options)
52
79
  : vcs === 'svn' ? await inventorySvn(root, options)
@@ -58,7 +85,14 @@ export async function projectInventory(cwd, vcs, options = {}) {
58
85
  const grouped = new Map();
59
86
  for (const r of records) { if (grouped.has(r.path)) { grouped.get(r.path).duplicate = true; } else grouped.set(r.path, r); }
60
87
  records = [...grouped.values()];
61
- const inventory = { root, vcs, records, identity: source.identity, notice: INVENTORY_NOTICE };
88
+ const scoped = vcs === 'git' && selected !== undefined;
89
+ const inventory = {
90
+ root, vcs, records, identity: source.identity,
91
+ inventoryScope: scoped ? 'selected' : 'repository',
92
+ inventoryComplete: vcs === 'git' && !scoped,
93
+ totalTrackedNodes: scoped ? null : records.length,
94
+ notice: INVENTORY_NOTICE + (scoped ? ' Explicit Git selection only: repository total is unknown; unrelated index entries are not enumerated or bound to this snapshot.' : ''),
95
+ };
62
96
  return { ...inventory, id: hash(JSON.stringify(canonical(inventory))) };
63
97
  }
64
98
 
@@ -100,7 +134,7 @@ export async function captureProjectBatch(inventory, paths, options = {}) {
100
134
  delete file.left; delete file.right;
101
135
  }
102
136
  }
103
- const scope = { mode: 'project', inventoryId: inventory.id, totalTrackedNodes: inventory.records.length, batchPaths: paths, untrackedIncluded: false, inventoryComplete: inventory.vcs === 'git', scopeCompleteness: inventory.vcs === 'svn' ? 'observed-working-copy-only' : 'tracked-index-only', notice: inventory.notice };
137
+ const scope = { mode: 'project', inventoryId: inventory.id, inventoryScope: inventory.inventoryScope, inventoryNodes: inventory.records.length, totalTrackedNodes: inventory.totalTrackedNodes, batchPaths: paths, untrackedIncluded: false, inventoryComplete: inventory.inventoryComplete, scopeCompleteness: inventory.vcs === 'svn' ? 'observed-working-copy-only' : inventory.inventoryScope === 'selected' ? 'selected-index-only' : 'tracked-index-only', notice: inventory.notice };
104
138
  const snapshot = { schemaVersion: 1, vcs: inventory.vcs, root: inventory.root, files, context: [], projectScope: scope };
105
139
  return { ...snapshot, id: hash(JSON.stringify(snapshot)) };
106
140
  }