@feng0531/dsh-code-review 0.83.1 → 0.85.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/src/git.mjs CHANGED
@@ -2,6 +2,7 @@ import { isSecretPath } from './secret-path.mjs';
2
2
  import { realpath } from 'node:fs/promises';
3
3
  import { run, checked } from './process.mjs';
4
4
  import { hash, decode, readLocal, relativePath } from './content.mjs';
5
+ import { changeScope } from './git-change-scope.mjs';
5
6
 
6
7
  const flags = ['--no-optional-locks', '-c', 'core.fsmonitor=false', '-c', 'core.untrackedCache=false'];
7
8
  const STATUS_ARGS = ['status', '--porcelain=v1', '-z', '--untracked-files=all', '--ignore-submodules=none'];
@@ -38,6 +39,29 @@ function parseChanges(bytes) {
38
39
  return changes;
39
40
  }
40
41
 
42
+ /** Keep only the entries the requested comparison actually observes. */
43
+ function changesInScope(changes, scope) {
44
+ if (scope === 'worktree') return changes;
45
+ if (scope === 'staged') return changes.filter(change => change.rawStatus[0] !== ' ' && change.rawStatus[0] !== '?');
46
+ return changes.filter(change => change.rawStatus[1] !== ' ' || change.rawStatus === '??');
47
+ }
48
+
49
+ /** Report only the status column this scope observes so identity follows the compared sides. */
50
+ function scopeStatus(rawStatus, scope) {
51
+ if (scope === 'worktree') return rawStatus;
52
+ if (scope === 'staged') return rawStatus[0] + ' ';
53
+ return rawStatus === '??' ? '??' : ' ' + rawStatus[1];
54
+ }
55
+
56
+ /**
57
+ * Serialize one change for the snapshot. An unstaged entry compares index to worktree, so a
58
+ * staged rename's old path is not a side of that comparison and must not reach the model.
59
+ */
60
+ function scopeEntry(change, scope) {
61
+ const oldPath = scope === 'unstaged' ? undefined : change.oldPath;
62
+ return { path: change.path, ...(oldPath ? { oldPath } : {}), rawStatus: scopeStatus(change.rawStatus, scope) };
63
+ }
64
+
41
65
  /** Resolve only reviewable changed baselines; never enumerate the whole HEAD tree. */
42
66
  async function resolveBaselines(root, head, changes, selectedPaths, options, signal) {
43
67
  const base = new Map();
@@ -60,22 +84,104 @@ async function resolveBaselines(root, head, changes, selectedPaths, options, sig
60
84
  return base;
61
85
  }
62
86
 
87
+ /**
88
+ * Resolve index entries for the selected paths only. Stage entries above zero mean an
89
+ * unresolved conflict; callers block those files instead of reading a partial side.
90
+ */
91
+ async function resolveIndexEntries(root, names, options) {
92
+ const entries = new Map();
93
+ for (let i = 0; i < names.length; i += 64) {
94
+ const chunk = names.slice(i, i + 64);
95
+ const records = text(await git(root, ['--literal-pathspecs', 'ls-files', '--stage', '-z', '--', ...chunk], options)).split('\0');
96
+ for (const record of records) {
97
+ if (!record) continue;
98
+ const m = /^(\d{6}) ([a-f0-9]{40}|[a-f0-9]{64}) ([0-3])\t([\s\S]+)$/.exec(record);
99
+ if (!m || !chunk.includes(m[4])) throw new Error('Invalid index entry lookup');
100
+ if (entries.has(m[4])) entries.set(m[4], { conflict: true });
101
+ else entries.set(m[4], { mode: m[1], oid: m[2], stage: m[3] });
102
+ }
103
+ }
104
+ return entries;
105
+ }
106
+
107
+ /** Stable identity of the index entries a capture depends on; mode and content changes are caught even when status text is unchanged. */
108
+ function indexIdentity(entries, names) {
109
+ return names.map(name => {
110
+ const entry = entries.get(name);
111
+ return name + '\0' + (entry ? (entry.conflict ? 'conflict' : entry.mode + ':' + entry.oid) : '');
112
+ }).join('\n');
113
+ }
114
+
115
+ function assertReviewableIndexEntry(entry) {
116
+ if (!entry) throw new Error('Missing index baseline');
117
+ if (entry.conflict || entry.stage !== '0') throw new Error('Unresolved conflict');
118
+ if (entry.mode === '120000' || entry.mode === '160000') throw new Error('Submodule or symbolic link baseline');
119
+ }
120
+
121
+ async function readIndexBlob(root, entry, options, maxFileBytes) {
122
+ let bytes;
123
+ try {
124
+ bytes = await git(root, ['cat-file', 'blob', entry.oid], { ...options, maxBytes: maxFileBytes });
125
+ } catch (error) {
126
+ if (/output limit exceeded/i.test(error.message)) throw new Error('File size limit exceeded');
127
+ throw error;
128
+ }
129
+ return decode(bytes);
130
+ }
131
+
132
+ /** A staged deletion has no index entry; every other staged path reads its index blob. */
133
+ async function readStagedRight(root, entry, options, maxFileBytes) {
134
+ if (!entry) return { right: decode(Buffer.alloc(0)), rightExists: false };
135
+ assertReviewableIndexEntry(entry);
136
+ return { right: await readIndexBlob(root, entry, options, maxFileBytes), rightExists: true };
137
+ }
138
+
139
+ /** Unstaged left side is the index entry; untracked files have no index baseline. */
140
+ async function readUnstagedLeft(root, change, entries, options, maxFileBytes) {
141
+ if (change.rawStatus === '??') return decode(Buffer.alloc(0));
142
+ const entry = entries.get(change.path);
143
+ assertReviewableIndexEntry(entry);
144
+ return readIndexBlob(root, entry, options, maxFileBytes);
145
+ }
146
+
147
+ /** Worktree and staged captures read the same HEAD baseline; unstaged uses the index instead. */
148
+ async function readHeadLeft(root, change, baselines, options, maxFileBytes) {
149
+ const previous = baselines.get(change.oldPath ?? change.path);
150
+ if (previous && (previous.type !== 'blob' || previous.mode === '120000')) throw new Error('Submodule or symbolic link baseline');
151
+ return previous ? decode(await git(root, ['cat-file', 'blob', previous.oid], { ...options, maxBytes: maxFileBytes })) : decode(Buffer.alloc(0));
152
+ }
153
+
154
+ async function readWorktreeRight(root, change, options) {
155
+ try {
156
+ return { right: await readLocal(root, change.path, options), rightExists: true };
157
+ } catch (error) {
158
+ if (error.code === 'ENOENT' && change.rawStatus.includes('D')) return { right: decode(Buffer.alloc(0)), rightExists: false };
159
+ throw error;
160
+ }
161
+ }
162
+
63
163
  export async function captureGit(cwd, options = {}) {
64
164
  const { signal, selectedPaths, maxFiles = 200, maxFileBytes = 256 * 1024 } = options;
165
+ const scope = changeScope(options.changeScope);
65
166
  if (selectedPaths) for (const name of selectedPaths) relativePath(name);
66
167
  const root = await resolveRoot(cwd, options);
67
168
  const before = await git(root, STATUS_ARGS, options);
68
169
  const head = await resolveHead(root, options);
69
- const changes = parseChanges(before);
70
- if (changes.length > maxFiles) throw new Error('Change count limit exceeded; narrow repository scope');
170
+ const changes = changesInScope(parseChanges(before), scope);
171
+ const counted = changes.filter(change => change.rawStatus !== '??' || selectedPaths?.includes(change.path));
172
+ if (counted.length > maxFiles) throw new Error('Change count limit exceeded; narrow repository scope');
71
173
  const known = new Set(changes.map(c => c.path));
72
- if (selectedPaths?.some(p => !known.has(p))) throw new Error('Selected path is not a current change');
73
- const base = head ? await resolveBaselines(root, head, changes, selectedPaths, options, signal) : new Map();
174
+ if (selectedPaths?.some(p => !known.has(p))) throw new Error('Selected path is not a current change in the selected scope');
175
+ const projectedBefore = scope === 'worktree' ? null : JSON.stringify(changes.map(change => scopeEntry(change, scope)));
176
+ const baselines = scope !== 'unstaged' && head ? await resolveBaselines(root, head, changes, selectedPaths, options, signal) : new Map();
177
+ const indexNames = scope === 'worktree' ? [] : [...new Set(changes.filter(change => change.rawStatus !== '??').map(change => change.path))];
178
+ const indexEntries = scope === 'worktree' ? new Map() : await resolveIndexEntries(root, indexNames, options);
179
+ const indexBefore = indexIdentity(indexEntries, indexNames);
74
180
 
75
181
  const files = [];
76
182
  for (const change of changes) {
77
183
  signal?.throwIfAborted();
78
- const item = { ...change, id: hash(change.path), eligibility: 'reviewable', properties: [] };
184
+ const item = { ...scopeEntry(change, scope), id: hash(change.path), eligibility: 'reviewable', properties: [] };
79
185
  files.push(item);
80
186
  if (isSecretPath(change.path) || isSecretPath(change.oldPath)) { item.eligibility = 'excluded'; item.reason = 'Credential path excluded'; continue; }
81
187
  if ((selectedPaths && !selectedPaths.includes(change.path)) || (change.rawStatus === '??' && !selectedPaths?.includes(change.path))) {
@@ -85,18 +191,14 @@ export async function captureGit(cwd, options = {}) {
85
191
  relativePath(change.path);
86
192
  if (change.oldPath) relativePath(change.oldPath);
87
193
  if (/U/.test(change.rawStatus) || ['AA', 'DD'].includes(change.rawStatus)) throw new Error('Unresolved conflict');
88
- const previous = base.get(change.oldPath ?? change.path);
89
- if (previous && (previous.type !== 'blob' || previous.mode === '120000')) throw new Error('Submodule or symbolic link baseline');
90
- const left = previous ? await git(root, ['cat-file', 'blob', previous.oid], { ...options, maxBytes: maxFileBytes }) : Buffer.alloc(0);
91
- item.left = decode(left);
92
- try {
93
- item.right = await readLocal(root, change.path, options);
94
- item.rightExists = true;
95
- } catch (error) {
96
- if (error.code === 'ENOENT' && change.rawStatus.includes('D')) { item.right = decode(Buffer.alloc(0)); item.rightExists = false; }
97
- else throw error;
98
- }
99
- if (previous && item.rightExists && item.left.hash === item.right.hash && !change.oldPath) {
194
+ const baselineExists = scope === 'unstaged' ? change.rawStatus !== '??' : baselines.has(change.oldPath ?? change.path);
195
+ item.left = scope === 'unstaged' ? await readUnstagedLeft(root, change, indexEntries, options, maxFileBytes) : await readHeadLeft(root, change, baselines, options, maxFileBytes);
196
+ const right = scope === 'staged'
197
+ ? await readStagedRight(root, change.rawStatus[0] === 'D' ? undefined : indexEntries.get(change.path), options, maxFileBytes)
198
+ : await readWorktreeRight(root, change, options);
199
+ item.right = right.right;
200
+ item.rightExists = right.rightExists;
201
+ if (baselineExists && item.rightExists && item.left.hash === item.right.hash && !item.oldPath) {
100
202
  item.eligibility = 'excluded'; item.reason = 'No net content change (mode-only changes not analyzed)';
101
203
  }
102
204
  } catch (error) {
@@ -106,18 +208,28 @@ export async function captureGit(cwd, options = {}) {
106
208
  }
107
209
  }
108
210
 
109
- if (!(await git(root, STATUS_ARGS, options)).equals(before)) throw new Error('Repository changed during capture');
211
+ const after = await git(root, STATUS_ARGS, options);
212
+ const scopeUnchanged = scope === 'worktree'
213
+ ? after.equals(before)
214
+ : JSON.stringify(changesInScope(parseChanges(after), scope).map(change => scopeEntry(change, scope))) === projectedBefore;
215
+ if (!scopeUnchanged) throw new Error('Repository changed during capture');
110
216
  const finalHead = await run('git', [...flags, 'rev-parse', '--verify', 'HEAD'], { ...options, cwd: root });
111
217
  if ((finalHead.code === 0 ? text(finalHead.stdout).trim() : null) !== head) throw new Error('HEAD changed during capture');
112
- for (const file of files.filter(f => f.right)) {
113
- if (file.rightExists) {
114
- if ((await readLocal(root, file.path, options)).hash !== file.right.hash) throw new Error('Content changed during capture');
115
- } else {
116
- try { await readLocal(root, file.path, options); }
117
- catch (error) { if (error.code === 'ENOENT') continue; throw error; }
118
- throw new Error('Deleted path reappeared during capture');
218
+ if (scope !== 'worktree') {
219
+ const entriesAfter = await resolveIndexEntries(root, indexNames, options);
220
+ if (indexIdentity(entriesAfter, indexNames) !== indexBefore) throw new Error('Index changed during capture');
221
+ }
222
+ if (scope !== 'staged') {
223
+ for (const file of files.filter(f => f.right)) {
224
+ if (file.rightExists) {
225
+ if ((await readLocal(root, file.path, options)).hash !== file.right.hash) throw new Error('Content changed during capture');
226
+ } else {
227
+ try { await readLocal(root, file.path, options); }
228
+ catch (error) { if (error.code === 'ENOENT') continue; throw error; }
229
+ throw new Error('Deleted path reappeared during capture');
230
+ }
119
231
  }
120
232
  }
121
- const snapshot = { schemaVersion: 1, vcs: 'git', root, baseline: head, files };
233
+ const snapshot = { schemaVersion: 1, vcs: 'git', root, baseline: head, ...(scope === 'worktree' ? {} : { changeScope: scope }), files };
122
234
  return { ...snapshot, id: hash(JSON.stringify(snapshot)) };
123
235
  }
@@ -3,12 +3,9 @@ const VERDICT_CONTRACT = '本阶段必须返回 {"verdicts":[{"candidateId":"c1"
3
3
  const GROUPS_CONTRACT = '本阶段返回groups数组,每项含fileIds和reason,文件身份必须来自输入,不是findings响应。';
4
4
  const RISK_CONTRACT = '本阶段返回 {"risks":[]} 或原指令要求的风险条目,不是findings。';
5
5
  const INTERACTION_CONTRACT = '本阶段返回 {"findings":[],"limitations":[]};发现必须包含原指令全部字段和跨文件interaction.editRefs。无足够跨文件证据时说明limitations,不编造。';
6
- const FINDINGS_CONTRACT = '本阶段最终返回 {"findings":[],"limitations":[]}。每项finding必须含fileId、severity(critical/high/medium/low)、title、evidence、trigger、impact、suggestion和anchor;fileId原样复制file.id,不用文件名替代。anchor.line用side/start/end/snippet且精确匹配原行;需要更多证据可按检索协议先返回requests。';
6
+ const FINDINGS_CONTRACT = '本阶段最终返回 {"findings":[],"limitations":[]}。每项finding必须含fileId、severity(critical/high/medium/low)、title、evidence、trigger、impact、suggestion和anchor;fileId原样复制file.id,不用文件名替代。行定位写成 anchor:{kind:"line",side:"new",start:1,end:1,snippet:"逐行原文"}(仅为形状示例,不是证据);start/end用原来源一基行号而非窗口内序号,精确匹配原行;old/new按当前阶段允许侧选择,示例不改变授权;仅当没有retrievalFinalization且仍有检索余量时,需要更多证据可按检索协议先返回requests。';
7
7
 
8
- export function modelContract(input, instructions = '') {
9
- let p;
10
- try { p = JSON.parse(input); } catch { return ''; }
11
- if (!p || typeof p !== 'object' || Array.isArray(p)) return '';
8
+ function stageContract(p, instructions) {
12
9
  if (Array.isArray(p.candidates)) return VERDICT_CONTRACT;
13
10
  if (/返回\{groups:/.test(instructions)) return GROUPS_CONTRACT;
14
11
  if (/仅制定审查计划/.test(instructions)) return RISK_CONTRACT;
@@ -16,3 +13,11 @@ export function modelContract(input, instructions = '') {
16
13
  if (p.file) return FINDINGS_CONTRACT;
17
14
  return '';
18
15
  }
16
+
17
+ export function modelContract(input, instructions = '') {
18
+ let p;
19
+ try { p = JSON.parse(input); } catch { return ''; }
20
+ if (!p || typeof p !== 'object' || Array.isArray(p)) return '';
21
+ const contract = stageContract(p, instructions);
22
+ return contract + (p.retrievalFinalization ? ' 本次检索已受限:只返回当前阶段最终schema,禁止requests;不能将缺证据声称为无问题或完整覆盖。' : '');
23
+ }
@@ -1,13 +1,19 @@
1
1
  // Accept one complete JSON value, optionally inside one exact Markdown fence.
2
+ // This parser never repairs bytes or invokes a model; orchestration owns corrections.
2
3
  export function modelJson(text) {
3
- const bytes=Buffer.byteLength(text);
4
- const trimmed=text.trim();
5
- const fence=/^```(?:json)?\r?\n([\s\S]*)\r?\n```$/.exec(trimmed);
6
- const body=fence?fence[1]:trimmed;
7
- try { JSON.parse(body); } catch {
8
- const shape = !trimmed ? 'empty' : trimmed.startsWith('```') && !fence ? 'non-exact-fence' : /^[{\[]/.test(body) ? 'json-like-invalid' : 'non-json-leading';
9
- const error=new Error('Model response is not complete JSON (bytes='+bytes+', envelope='+(fence?'json-fence':'plain')+', shape='+shape+'); no repair or retry performed');
10
- error.code='MODEL_INVALID_JSON';throw error;
11
- }
12
- return body;
4
+ const bytes = Buffer.byteLength(text);
5
+ const trimmed = text.trim();
6
+ const fence = /^```(?:json)?\r?\n([\s\S]*)\r?\n```$/.exec(trimmed);
7
+ const body = fence ? fence[1] : trimmed;
8
+ try {
9
+ JSON.parse(body);
10
+ } catch {
11
+ const envelope = fence ? 'json-fence' : 'plain';
12
+ const shape = !trimmed ? 'empty' : trimmed.startsWith('```') && !fence ? 'non-exact-fence' : /^[{\[]/.test(body) ? 'json-like-invalid' : 'non-json-leading';
13
+ const error = new Error('Model response is not complete JSON (bytes=' + bytes + ', envelope=' + envelope + ', shape=' + shape + '); no repair or retry performed by JSON parser');
14
+ error.code = 'MODEL_INVALID_JSON';
15
+ error.diagnostic = { jsonBytes: bytes, envelope, shape };
16
+ throw error;
17
+ }
18
+ return body;
13
19
  }
@@ -13,6 +13,7 @@ import { planWindowBatches } from './window-batches.mjs';
13
13
  import { changeWindows } from './change-windows.mjs';
14
14
  import { changeMap } from './change-map.mjs';
15
15
  import { initialInputBudget } from './input-budget.mjs';
16
+ import { changeScopeNotice } from './git-change-scope.mjs';
16
17
 
17
18
  const CPP_FILE = /\.(?:c|cc|cpp|cxx|h|hh|hpp|hxx)$/;
18
19
  const RELATION_NOTICE = '命名和相对导入关系仅为源码线索,可能被同名局部变量遮蔽,不证明绑定、调用可达或因果;只报告主file的问题,不为relatedFiles重复生成发现。';
@@ -35,6 +36,7 @@ export function preparePrimaryInput(input, file, { instructions, scope, grouping
35
36
 
36
37
  const base = {
37
38
  ...(input.projectScope ? { projectScope: input.projectScope, reviewNotice: 'Full current-file review; empty left is a sentinel, not a historical baseline. changes are transport coordinates only, do not infer introduced regression.' } : {}),
39
+ ...(input.changeScope ? { changeScope: input.changeScope, changeScopeNotice: changeScopeNotice(input.changeScope) } : {}),
38
40
  ...(input.history ? { contextSideNotice: CONTEXT_SIDE_NOTICE } : {}),
39
41
  ...(input.businessRequirement !== undefined ? { businessRequirement: input.businessRequirement, requirementNotice: REQUIREMENT_NOTICE } : {}),
40
42
  defectGuidance: defectGuidance(file.path, file.rightExists === false ? file.left.text : file.right.text),
@@ -0,0 +1,40 @@
1
+ import { reviewSnapshot } from './review.mjs';
2
+
3
+ export const DEFAULT_PROJECT_CONCURRENCY = 3;
4
+ export function projectConcurrency(value = DEFAULT_PROJECT_CONCURRENCY) {
5
+ if (!Number.isSafeInteger(value) || value < 1 || value > 8) throw new Error('Project concurrency must be an integer from 1 to 8');
6
+ return value;
7
+ }
8
+
9
+ // Parallelize independent approved snapshots, never dependent stages within a batch.
10
+ // 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
+ projectConcurrency(concurrency);
13
+ if (!Array.isArray(batches) || typeof remainingCalls !== 'function') throw new Error('Invalid project execution');
14
+ const controller = new AbortController();
15
+ const batchSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
16
+ const results = new Array(batches.length);
17
+ let next = 0, failed = false, failure;
18
+ const unstarted = batch => ({ snapshotId: batch.id, status: 'partial', files: batch.files.map(f => ({ fileId: f.id, path: f.path, status: f.eligibility === 'reviewable' ? 'pending' : f.eligibility, reason: 'Session project budget exhausted or cancelled' })), findings: [], limitations: [] });
19
+ const worker = async () => {
20
+ while (!failed) {
21
+ const index = next++;
22
+ if (index >= batches.length) return;
23
+ const batch = batches[index];
24
+ try {
25
+ if (batchSignal.aborted || remainingCalls() <= 0) { results[index] = unstarted(batch); continue; }
26
+ results[index] = await reviewSnapshot(batch, model, {
27
+ signal: batchSignal, maxCalls: remainingCalls(), remainingCalls, enableRetrieval: true, enableVerification: true,
28
+ enableAnchorCorrection: true, correctProtocol: true,
29
+ onProgress: onProgress ? event => onProgress({ ...event, snapshotId: batch.id, batchIndex: index }) : undefined,
30
+ });
31
+ } catch (error) {
32
+ if (!failed) { failed = true; failure = error; controller.abort(error); }
33
+ }
34
+ }
35
+ };
36
+ // Never release service ownership while a sibling model/cleanup is still running.
37
+ await Promise.all(Array.from({ length: Math.min(concurrency, batches.length) }, worker));
38
+ if (failed) throw failure;
39
+ return results;
40
+ }
@@ -5,10 +5,10 @@ import { hash } from './content.mjs';
5
5
  import { resolveWorkspaceRepository } from './workspace-repository.mjs';
6
6
  import { detectVcs } from './snapshot.mjs';
7
7
  import { projectInventory, captureProjectBatch } from './project-snapshot.mjs';
8
- import { reviewSnapshot } from './review.mjs';
8
+ import { reviewProjectBatches, projectConcurrency as validateProjectConcurrency } from './project-execution.mjs';
9
9
  import { createDshModel } from './dsh-model.mjs';
10
10
 
11
- 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. Show scope and model before confirmed execute.';
11
+ 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
12
  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.';
13
13
 
14
14
  function owner(exec) {
@@ -66,10 +66,11 @@ async function captureProject(cwd, args, signal) {
66
66
  return { ...result, id: hash(JSON.stringify(result)) };
67
67
  }
68
68
 
69
- export function createProjectReviewService(llm, { allowModelSending = false, authorize = async () => false, now = Date.now, ttlMs = 300000, maxPreviews = 8, projectMaxCalls = Number.MAX_SAFE_INTEGER } = {}) {
69
+ export function createProjectReviewService(llm, { allowModelSending = false, authorize = async () => false, now = Date.now, ttlMs = 300000, maxPreviews = 8, projectMaxCalls = Number.MAX_SAFE_INTEGER, projectConcurrency = 3 } = {}) {
70
70
  for (const n of [ttlMs, maxPreviews]) if (!Number.isSafeInteger(n) || n < 1) throw new Error('Invalid project preview limit');
71
71
  if (!Number.isSafeInteger(projectMaxCalls) || projectMaxCalls < 1) throw new Error('Invalid project model call limit');
72
72
 
73
+ validateProjectConcurrency(projectConcurrency);
73
74
  const previews = new Map();
74
75
  const lifetime = new AbortController();
75
76
  let busy = false;
@@ -96,6 +97,7 @@ export function createProjectReviewService(llm, { allowModelSending = false, aut
96
97
  model: current.route, modelSendingEnabled: allowModelSending,
97
98
  totalTrackedNodes: s.totalTrackedNodes, selectedFiles: s.selectedFiles, selectionComplete: s.selectionComplete,
98
99
  inventoryComplete: s.inventoryComplete, batches: s.batches.length,
100
+ execution: { batchConcurrency: projectConcurrency },
99
101
  files: s.batches.flatMap(b => b.files.map(f => ({ path: f.path, eligibility: f.eligibility, reason: f.reason }))),
100
102
  notice: s.notice + PREVIEW_NOTICE_SUFFIX,
101
103
  };
@@ -127,23 +129,19 @@ export function createProjectReviewService(llm, { allowModelSending = false, aut
127
129
  let calls = 0;
128
130
  const adapter = createDshModel(llm, p.route, { correctJson: true });
129
131
  const model = request => {
130
- if (calls >= projectMaxCalls) throw new Error('Shared project model call limit exceeded');
132
+ if (calls >= projectMaxCalls) throw Object.assign(new Error('Shared project model call limit exceeded'), { code: 'PROJECT_MODEL_CALL_LIMIT' });
131
133
  calls++;
132
134
  return adapter({ ...request, onProtocolCorrection: async info => {
133
- if (calls >= projectMaxCalls) throw new Error('Shared project model call limit exceeded');
135
+ if (calls >= projectMaxCalls) throw Object.assign(new Error('Shared project model call limit exceeded'), { code: 'PROJECT_MODEL_CALL_LIMIT' });
134
136
  calls++;
135
137
  await request.onProtocolCorrection?.(info);
136
138
  }});
137
139
  };
138
140
 
139
- const results = [];
140
- for (const b of s.batches) {
141
- if (signal.aborted || calls >= projectMaxCalls) {
142
- results.push({ snapshotId: b.id, status: 'partial', files: b.files.map(f => ({ fileId: f.id, path: f.path, status: f.eligibility === 'reviewable' ? 'pending' : f.eligibility, reason: 'Session project budget exhausted or cancelled' })), findings: [], limitations: [] });
143
- continue;
144
- }
145
- results.push(await reviewSnapshot(b, model, { signal, maxCalls: projectMaxCalls - calls, enableRetrieval: true, enableVerification: true, correctProtocol: true, onProgress: exec.reviewCheckpoint }));
146
- }
141
+ const results = await reviewProjectBatches(s.batches, model, {
142
+ concurrency: projectConcurrency, signal, remainingCalls: () => projectMaxCalls - calls,
143
+ onProgress: exec.reviewCheckpoint,
144
+ });
147
145
 
148
146
  const files = results.flatMap(r => r.files.map(f => ({ ...f, batchSnapshotId: r.snapshotId })));
149
147
  const coverage = {
@@ -164,6 +162,7 @@ export function createProjectReviewService(llm, { allowModelSending = false, aut
164
162
  repositoryRoot: s.root, vcs: s.vcs, model: p.route, modelCalls: calls, maxModelCalls: projectMaxCalls,
165
163
  totalTrackedNodes: s.totalTrackedNodes, selectionComplete: s.selectionComplete, inventoryComplete: s.inventoryComplete,
166
164
  coverage, files, findings, batches: results,
165
+ execution: { batchConcurrency: projectConcurrency },
167
166
  notice: s.notice + REPORT_NOTICE_SUFFIX,
168
167
  };
169
168
 
@@ -0,0 +1,28 @@
1
+ // Ask for one new response; never normalize identities, severities or evidence locally.
2
+ const GUIDANCE = {
3
+ MODEL_INVALID_REVIEW_RESPONSE: '返回含 findings 和 limitations 数组的当前阶段完整对象。',
4
+ MODEL_INVALID_FINDING_IDENTITY: 'fileId 必须原样复制本次主 file.id,不使用路径、候选编号或其他文件身份。',
5
+ MODEL_INVALID_FINDING_SEVERITY: 'severity 仅允许 critical、high、medium、low,不使用 P1/P2、数字或翻译标签。',
6
+ MODEL_MISSING_FINDING_FIELD: '每个发现必须包含非空 title/evidence/trigger/impact/suggestion。',
7
+ MODEL_INVALID_ANCHOR: 'anchor 必须使用当前阶段允许的 kind、side、整数 start/end 及精确 snippet。',
8
+ MODEL_ANCHOR_MISMATCH: 'snippet 必须逐行精确复制批准来源,start/end 是该来源的原始一基行号,不能使用展示窗口的相对行号。不得模糊匹配、改写源码或用 file anchor 隐藏定位失败。',
9
+ };
10
+
11
+ export async function correctedResponse(generate, validate, request, { enabled = false, canCorrect = () => true, onCorrection = async () => {} } = {}) {
12
+ const approved = { ...request };
13
+ const original = await generate({ ...approved });
14
+ try { return validate(original); } catch (error) {
15
+ approved.signal?.throwIfAborted();
16
+ if (!enabled || !Object.hasOwn(GUIDANCE, error?.code) || !canCorrect()) throw error;
17
+ await onCorrection({ action: 'review-response-correction', code: error.code, notice: 'Original response rejected; requesting one new response under the same approved scope and shared budgets.' });
18
+ approved.signal?.throwIfAborted();
19
+ const instructions = approved.instructions + '\n本次是一次明确的主审响应协议纠正:' + GUIDANCE[error.code] + ' 重新核对原批准源码并返回完整当前阶段 schema;不得把格式错误当成无缺陷,不得为通过校验编造证据或静默删除有证据的候选。原响应未被接受,纠正后仍需全部严格校验。';
20
+ // generate enforces the same input/call/reader limits and enclosing signal.
21
+ const response = await generate({ ...approved, instructions });
22
+ approved.signal?.throwIfAborted();
23
+ try { return validate(response); } catch (second) {
24
+ second.responseAttempts = [{ code: error.code }, { code: second.code ?? 'REVIEW_EXECUTION_FAILED' }];
25
+ throw second;
26
+ }
27
+ }
28
+ }
@@ -1,5 +1,5 @@
1
1
  import { RETRIEVAL_INSTRUCTIONS } from './prompts.mjs';
2
- import { executeRetrievalBatch } from './retrieval-protocol.mjs';
2
+ import { executeRetrievalBatch, parseRetrievalResponse } from './retrieval-protocol.mjs';
3
3
 
4
4
  export { RETRIEVAL_INSTRUCTIONS };
5
5
 
@@ -18,7 +18,7 @@ function compactFollowup(input) {
18
18
  }
19
19
 
20
20
  export function retrievalRequest(request, scope, retrieved = []) {
21
- return { ...request, instructions: request.instructions + '\n' + RETRIEVAL_INSTRUCTIONS, input: JSON.stringify({ ...JSON.parse(request.input), catalog: scope.catalog(), retrieved }) };
21
+ return { ...request, instructions: request.instructions + '\n' + RETRIEVAL_INSTRUCTIONS + BUDGET_SUFFIX, input: JSON.stringify({ ...JSON.parse(request.input), catalog: scope.catalog(), retrieved, ...(scope.budget ? { retrievalBudget: scope.budget() } : {}) }) };
22
22
  }
23
23
 
24
24
  function recordRetrieval(operation, result, onTruncated, onRetrieved) {
@@ -34,50 +34,95 @@ function recordRetrieval(operation, result, onTruncated, onRetrieved) {
34
34
 
35
35
  const CORRECTION_SUFFIX = ' 本次是检索协议纠正:上次请求整批未执行。只返回requests数组,不带findings/limitations;read使用catalog的id和整数start/count,不用path/end。或直接返回当前阶段最终schema。不得扩大批准来源。';
36
36
 
37
- export async function retrievalLoop(model, request, scope, { correctProtocol = false, maxRounds = Number.MAX_SAFE_INTEGER, maxInputBytes = 96 * 1024, beforeCall = () => {}, onTruncated = () => {}, onRetrieved = () => {} } = {}) {
37
+ const BUDGET_SUFFIX = ' retrievalBudget为宿主共享检索预算的当前余量,不是新授权。每项操作消耗一次call及实际序列化输出字节;不要请求超过余量的整批操作。预算将尽请及时按本阶段schema最终答复,只引用已提供证据,证据不足须保留限制或uncertain。';
38
+ const FINAL_SUFFIX = ' 检索预算最终答复:本次仅有一次最终schema答复机会,不得再返回requests。不得把缺证据变成无问题/已核查;只保留已提供且可严格验证的证据,其余说明限制或uncertain。不增加reader、模型调用、输入或超时预算。';
39
+ const budgetFailure = error => ['READER_CALL_BUDGET_EXCEEDED', 'READER_OUTPUT_BUDGET_EXCEEDED', 'RETRIEVAL_ROUND_BUDGET_EXCEEDED'].includes(error?.code);
40
+
41
+ function roundBudgetError() {
42
+ const error = new Error('Retrieval round budget exceeded');
43
+ error.code = 'RETRIEVAL_ROUND_BUDGET_EXCEEDED';
44
+ return error;
45
+ }
46
+
47
+ export async function retrievalLoop(model, request, scope, { correctProtocol = false, maxRounds = Number.MAX_SAFE_INTEGER, maxInputBytes = 96 * 1024, beforeCall = () => {}, onTruncated = () => {}, onRetrieved = () => {}, onBudgetLimited = () => {} } = {}) {
38
48
  if (!Number.isSafeInteger(maxRounds) || maxRounds < 0 || !Number.isSafeInteger(maxInputBytes) || maxInputBytes < 1) throw new Error('Invalid retrieval loop budget');
39
49
  const retrieved = [];
40
- let protocolCorrection;
50
+ let protocolCorrection, finalization;
51
+ const finalize = async error => {
52
+ finalization = { code: error.code, reason: error.message, ...(scope.budget ? { budget: scope.budget() } : {}) };
53
+ await onBudgetLimited(finalization);
54
+ await request.onProtocolEvent?.({ action: 'retrieval-budget-finalization', ...finalization });
55
+ };
56
+ const checkExhausted = async round => {
57
+ const budget = scope.budget?.();
58
+ if (!finalization && budget && (budget.remainingCalls === 0 || budget.remainingOutputBytes === 0)) {
59
+ const error = new Error(budget.remainingCalls === 0 ? 'Reader call budget exceeded' : 'Reader output budget exceeded');
60
+ error.code = budget.remainingCalls === 0 ? 'READER_CALL_BUDGET_EXCEEDED' : 'READER_OUTPUT_BUDGET_EXCEEDED';
61
+ await finalize(error);
62
+ } else if (!finalization && round >= maxRounds) await finalize(roundBudgetError());
63
+ };
64
+ const nextRequest = records => {
65
+ const next = retrievalRequest(request, scope, records);
66
+ if (finalization) next.input = JSON.stringify({ ...JSON.parse(next.input), retrievalFinalization: finalization });
67
+ next.instructions += (protocolCorrection ? CORRECTION_SUFFIX : '') + (finalization ? FINAL_SUFFIX : '');
68
+ return next;
69
+ };
41
70
  for (let round = 0; ; round++) {
42
71
  request.signal?.throwIfAborted();
43
- let { input, instructions: baseInstructions } = retrievalRequest(request, scope, retrieved);
44
- const instructions = baseInstructions + (protocolCorrection ? CORRECTION_SUFFIX : '');
72
+ await checkExhausted(round);
73
+ let { input, instructions } = nextRequest(retrieved);
45
74
  if (correctProtocol && retrieved.length && Buffer.byteLength(input) + Buffer.byteLength(instructions) > maxInputBytes) input = compactFollowup(input);
46
75
  if (Buffer.byteLength(input) + Buffer.byteLength(instructions) > maxInputBytes) throw new Error('Retrieval input budget exceeded');
76
+ request.signal?.throwIfAborted();
47
77
  beforeCall();
78
+ request.signal?.throwIfAborted();
48
79
  const response = await model({ ...request, instructions, input });
49
80
  request.signal?.throwIfAborted();
50
81
  const serialized = typeof response === 'string' ? response : JSON.stringify(response);
51
82
  if (typeof serialized !== 'string' || Buffer.byteLength(serialized) > 128 * 1024) throw new Error('Retrieval response too large');
52
83
  const parsed = JSON.parse(serialized);
53
84
  if (!parsed || !Object.hasOwn(parsed, 'requests')) return parsed;
54
- if (round >= maxRounds) throw new Error('Retrieval round budget exceeded');
85
+ // Schema and authorization remain hard failures, including on the final turn.
86
+ // Preflight all dynamic ranges and aggregate budgets before any capability.
55
87
  let batch;
56
88
  try {
57
- batch = executeRetrievalBatch(parsed, scope, request.signal, ({ request: operation, result }) => recordRetrieval(operation, result, onTruncated, onRetrieved));
89
+ const operations = parseRetrievalResponse(parsed);
90
+ const allowed = new Set(scope.catalog().map(item => item.id));
91
+ if (operations.some(r => r.kind === 'read' ? !allowed.has(r.id) : r.sourceIds?.some(id => !allowed.has(id)))) throw new Error('Source not authorized');
92
+ if (finalization) {
93
+ const error = new Error(finalization.reason + '; final response required, no further retrieval permitted');
94
+ error.code = 'RETRIEVAL_FINAL_RESPONSE_REQUIRED';
95
+ throw error;
96
+ }
97
+ scope.preflight?.(operations);
98
+ const execute = () => executeRetrievalBatch(parsed, scope, request.signal);
99
+ batch = scope.transaction ? scope.transaction(execute) : execute();
58
100
  } catch (error) {
59
101
  request.signal?.throwIfAborted();
60
- if (!correctProtocol || protocolCorrection || !(/Invalid retrieval batch|Invalid read request|Invalid search request|Invalid find request/.test(error.message))) throw error;
102
+ if (!finalization && budgetFailure(error)) { await finalize(error); continue; }
103
+ if (finalization || !correctProtocol || protocolCorrection || !(/Invalid retrieval batch|Invalid read request|Invalid search request|Invalid find request|Invalid line range|Line range outside source/.test(error.message))) throw error;
61
104
  protocolCorrection = { code: error.code ?? 'MODEL_INVALID_RETRIEVAL' };
62
105
  await request.onProtocolEvent?.({ action: 'retrieval-protocol-correction', code: protocolCorrection.code });
63
106
  continue;
64
107
  }
108
+ for (const item of batch) recordRetrieval(item.request, item.result, onTruncated, onRetrieved);
109
+ await checkExhausted(round + 1);
65
110
  for (const item of batch) {
66
111
  let candidate = [...retrieved, item];
67
- let next = retrievalRequest(request, scope, candidate);
112
+ let next = nextRequest(candidate);
68
113
  if (correctProtocol) {
69
114
  while (retrieved.length && Buffer.byteLength(compactFollowup(next.input)) + Buffer.byteLength(next.instructions) > maxInputBytes) {
70
115
  retrieved.shift();
71
116
  await request.onProtocolEvent?.({ action: 'prior-retrieval-deferred', notice: 'Prior returned fragment no longer in current prompt; reread approved source for evidence.' });
72
117
  candidate = [...retrieved, item];
73
- next = retrievalRequest(request, scope, candidate);
118
+ next = nextRequest(candidate);
74
119
  }
75
120
  }
76
121
  let fits = Buffer.byteLength(next.input) + Buffer.byteLength(next.instructions) <= maxInputBytes;
77
122
  if (correctProtocol && !fits) fits = Buffer.byteLength(compactFollowup(next.input)) + Buffer.byteLength(next.instructions) <= maxInputBytes;
78
123
  if (correctProtocol && !fits) {
79
124
  const omitted = { request: item.request, result: { omitted: true, reason: 'Result omitted: request fewer lines/matches; no body or receipt provided.' } };
80
- const minimal = retrievalRequest(request, scope, [...retrieved, omitted]);
125
+ const minimal = nextRequest([...retrieved, omitted]);
81
126
  if (Buffer.byteLength(compactFollowup(minimal.input)) + Buffer.byteLength(minimal.instructions) > maxInputBytes) throw new Error('Retrieval input metadata exceeds model capacity');
82
127
  retrieved.push(omitted);
83
128
  await request.onProtocolEvent?.({ action: 'oversized-retrieval-omitted', kind: item.request.kind });