@feng0531/dsh-code-review 0.85.13 → 0.86.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 +8 -0
- package/CORE-ALIGNMENT-TASKS.md +6 -1
- package/CORE-SOURCE-INVENTORY.md +4 -2
- package/README.md +16 -6
- package/RELEASE-NOTES.md +15 -16
- package/index.mjs +2 -2
- package/package.json +1 -1
- package/src/background-review.mjs +15 -1
- package/src/coverage-plan.mjs +16 -2
- package/src/dsh-model.mjs +80 -15
- package/src/group-primary.mjs +40 -0
- package/src/input-budget.mjs +2 -0
- package/src/model-contract.mjs +1 -0
- package/src/model-json.mjs +23 -1
- package/src/model-tools.mjs +58 -0
- package/src/model-wire-budget.mjs +32 -0
- package/src/primary-seeds.mjs +2 -1
- package/src/project-execution.mjs +3 -2
- package/src/project-review-service.mjs +47 -10
- package/src/retrieval-loop.mjs +11 -9
- package/src/review-artifacts.mjs +117 -0
- package/src/review-diagnostics.mjs +19 -8
- package/src/review-jobs.mjs +6 -2
- package/src/review-resume.mjs +53 -0
- package/src/review.mjs +133 -44
- package/src/search-seeds.mjs +2 -1
- package/src/service.mjs +24 -7
- package/src/verification-loop.mjs +2 -1
package/src/model-json.mjs
CHANGED
|
@@ -2,7 +2,28 @@ import { jsonSyntaxDiagnostic } from './model-diagnostics.mjs';
|
|
|
2
2
|
|
|
3
3
|
// Accept one complete JSON value, optionally inside one exact Markdown fence.
|
|
4
4
|
// This parser never repairs bytes or invokes a model; orchestration owns corrections.
|
|
5
|
-
|
|
5
|
+
// Accept only an unchanged leading JSON container followed by unmistakable prose.
|
|
6
|
+
function balancedPrefix(text) {
|
|
7
|
+
if (!['{', '['].includes(text[0])) return undefined;
|
|
8
|
+
const stack = []; let quoted = false, escaped = false;
|
|
9
|
+
for (let i = 0; i < text.length; i++) {
|
|
10
|
+
const c = text[i];
|
|
11
|
+
if (quoted) { if (escaped) escaped = false; else if (c === String.fromCharCode(92)) escaped = true; else if (c === '"') quoted = false; continue; }
|
|
12
|
+
if (c === '"') { quoted = true; continue; }
|
|
13
|
+
if (c === '{' || c === '[') stack.push(c);
|
|
14
|
+
else if (c === '}' || c === ']') {
|
|
15
|
+
if (stack.pop() !== (c === '}' ? '{' : '[')) return undefined;
|
|
16
|
+
if (!stack.length) {
|
|
17
|
+
const body = text.slice(0, i + 1), tail = text.slice(i + 1).trim();
|
|
18
|
+
if (!tail || /[{}\[\]\x60]/u.test(tail) || /^(?:true|false|null|-?\d|[,:;"'])/u.test(tail)) return undefined;
|
|
19
|
+
if (!/^[\p{L}]/u.test(tail)) return undefined;
|
|
20
|
+
try { JSON.parse(body); return body; } catch { return undefined; }
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
export function modelJson(text, { allowTrailingProse = false } = {}) {
|
|
6
27
|
const bytes = Buffer.byteLength(text);
|
|
7
28
|
const trimmed = text.trim();
|
|
8
29
|
const fence = /^```(?:json)?\r?\n([\s\S]*)\r?\n```$/.exec(trimmed);
|
|
@@ -10,6 +31,7 @@ export function modelJson(text) {
|
|
|
10
31
|
try {
|
|
11
32
|
JSON.parse(body);
|
|
12
33
|
} catch (parseError) {
|
|
34
|
+
if (allowTrailingProse && !fence) { const prefix = balancedPrefix(trimmed); if (prefix !== undefined) return prefix; }
|
|
13
35
|
const envelope = fence ? 'json-fence' : 'plain';
|
|
14
36
|
const shape = !trimmed ? 'empty' : trimmed.startsWith('```') && !fence ? 'non-exact-fence' : /^[{\[]/.test(body) ? 'json-like-invalid' : 'non-json-leading';
|
|
15
37
|
const error = new Error('Model response is not complete JSON (bytes=' + bytes + ', envelope=' + envelope + ', shape=' + shape + '); no repair or retry performed by JSON parser');
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Installed dsh-llm ToolSchema: {name,description,parameters}; tool arguments are
|
|
2
|
+
// strings, not parsed objects. This declares a data result, never an executable tool.
|
|
3
|
+
export const RESULT_TOOL_NAME = 'submit_review_result';
|
|
4
|
+
export const NATIVE_RESULT_INSTRUCTIONS = ' Prefer submit_review_result to submit the current stage JSON as tool arguments (data only, no execution). If tools are unavailable, return the same JSON as text in this response. Never submit multiple results or mix a result with prose.';
|
|
5
|
+
const strings = { type: 'array', items: { type: 'string' } };
|
|
6
|
+
const object = (properties, required = Object.keys(properties)) => ({ type: 'object', properties, required, additionalProperties: false });
|
|
7
|
+
const array = items => ({ type: 'array', items });
|
|
8
|
+
const text = { type: 'string' };
|
|
9
|
+
const anchor = { oneOf: [object({ kind: { const: 'line' }, side: { enum: ['old', 'new'] }, start: { type: 'integer', minimum: 1 }, end: { type: 'integer', minimum: 1 }, snippet: text }), object({ kind: { const: 'property' }, name: text }), object({ kind: { const: 'file' } })] };
|
|
10
|
+
const finding = { type: 'object', properties: { fileId: text, severity: { enum: ['critical', 'high', 'medium', 'low'] }, title: text,
|
|
11
|
+
evidence: text, trigger: text, impact: text, suggestion: text, anchor,
|
|
12
|
+
attribution: { type: 'object' }, interaction: { type: 'object' } },
|
|
13
|
+
required: ['fileId', 'severity', 'title', 'evidence', 'trigger', 'impact', 'suggestion', 'anchor'] };
|
|
14
|
+
const findings = array(finding); // Existing validators still enforce exact evidence and identities.
|
|
15
|
+
const risk = object({ id: { type: 'string' }, fileIds: strings,
|
|
16
|
+
editRefs: array(object({ fileId: { type: 'string' }, editId: { type: 'string' } })),
|
|
17
|
+
question: { type: 'string' }, trigger: { type: 'string' }, impact: { type: 'string' }, checks: strings, sourceIds: strings });
|
|
18
|
+
const review = object({ findings, limitations: strings, risks: array(risk) }, ['findings', 'limitations']);
|
|
19
|
+
const requests = object({ requests: { type: 'array', minItems: 1, maxItems: 8, items: { oneOf: [
|
|
20
|
+
object({ kind: { const: 'read' }, id: { type: 'string' }, start: { type: 'integer', minimum: 1 }, count: { type: 'integer', minimum: 1, maximum: 200 } }),
|
|
21
|
+
object({ kind: { const: 'search' }, query: { type: 'string' }, limit: { type: 'integer', minimum: 1, maximum: 100 }, sourceIds: strings }, ['kind', 'query']),
|
|
22
|
+
object({ kind: { const: 'find' }, query: { type: 'string' }, limit: { type: 'integer', minimum: 1, maximum: 100 } }, ['kind', 'query']),
|
|
23
|
+
] } } });
|
|
24
|
+
export function modelResultStage(input, instructions = '') {
|
|
25
|
+
let p; try { p = JSON.parse(input); } catch { return undefined; }
|
|
26
|
+
if (!p || typeof p !== 'object' || Array.isArray(p)) return undefined;
|
|
27
|
+
if (Array.isArray(p.candidates)) return 'verdicts';
|
|
28
|
+
if (/返回\{groups:/.test(instructions)) return 'groups';
|
|
29
|
+
if (/仅制定审查计划/.test(instructions)) return 'risks';
|
|
30
|
+
if (p.sourceMode === 'group-primary') return 'reviews';
|
|
31
|
+
if (p.file || p.sourceMode === 'file-interaction') return 'findings';
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
export function modelResultTools(input, instructions = '') {
|
|
35
|
+
const stage = modelResultStage(input, instructions);
|
|
36
|
+
if (!stage) return [];
|
|
37
|
+
const final = stage === 'findings' ? review : stage === 'risks' ? object({ risks: array(risk) }) :
|
|
38
|
+
stage === 'groups' ? object({ groups: array(object({ fileIds: strings, reason: { type: 'string' } })) }) :
|
|
39
|
+
stage === 'reviews' ? object({ reviews: array(object({ fileId: { type: 'string' }, findings, limitations: strings, risks: array(risk) }, ['fileId', 'findings', 'limitations'])), limitations: strings }) :
|
|
40
|
+
object({ verdicts: array(object({ candidateId: text, verdict: { enum: ['supported', 'refuted', 'uncertain'] }, reason: text, evidence: array({ type: 'object' }), regression: { type: 'object' } }, ['candidateId', 'verdict', 'reason', 'evidence'])) });
|
|
41
|
+
let p; try { p = JSON.parse(input); } catch { return []; }
|
|
42
|
+
const parameters = p.retrievalFinalization || stage === 'risks' || stage === 'groups' ? final : { type: 'object', oneOf: [final, requests] };
|
|
43
|
+
return [{ name: RESULT_TOOL_NAME, description: 'Submit only the current approved review stage result or approved retrieval requests as JSON data. Does not execute actions.', parameters }];
|
|
44
|
+
}
|
|
45
|
+
// Additional wire bytes beyond the ordinary text contract/input. No token estimate.
|
|
46
|
+
export function nativeResultWireBytes(input, instructions = '') {
|
|
47
|
+
const tools = modelResultTools(input, instructions);
|
|
48
|
+
return tools.length ? Buffer.byteLength(JSON.stringify(tools)) + Buffer.byteLength(NATIVE_RESULT_INSTRUCTIONS) : 0;
|
|
49
|
+
}
|
|
50
|
+
export function validateNativeResult(text, input, instructions) {
|
|
51
|
+
const value = JSON.parse(text), stage = modelResultStage(input, instructions);
|
|
52
|
+
const p = JSON.parse(input);
|
|
53
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
54
|
+
if (Object.hasOwn(value, 'requests')) return !p.retrievalFinalization && !['risks', 'groups'].includes(stage) && Object.keys(value).length === 1 && Array.isArray(value.requests);
|
|
55
|
+
const allowed = stage === 'findings' ? ['findings', 'limitations', 'risks'] : stage === 'reviews' ? ['reviews', 'limitations'] : [stage];
|
|
56
|
+
const required = stage === 'findings' ? ['findings', 'limitations'] : stage === 'reviews' ? ['reviews', 'limitations'] : [stage];
|
|
57
|
+
return !!stage && Object.keys(value).every(k => allowed.includes(k)) && required.every(k => Array.isArray(value[k]));
|
|
58
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { modelContract } from './model-contract.mjs';
|
|
2
|
+
import { nativeResultWireBytes } from './model-tools.mjs';
|
|
3
|
+
|
|
4
|
+
// Same UTF-8 fields checked by the adapter immediately before transmission.
|
|
5
|
+
// Does not estimate provider tokens or invent hidden slack for later corrections.
|
|
6
|
+
export function modelWireBudget(request, maxBytes) {
|
|
7
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) throw new Error('Invalid model wire budget');
|
|
8
|
+
if (!request || typeof request.instructions !== 'string' || typeof request.input !== 'string') throw new Error('Invalid model wire request');
|
|
9
|
+
if (request.nativeOutputTools !== undefined && typeof request.nativeOutputTools !== 'boolean') throw new Error('nativeOutputTools must be boolean');
|
|
10
|
+
const bytes = Buffer.byteLength(request.input) + Buffer.byteLength(request.instructions + '\n' + modelContract(request.input, request.instructions)) +
|
|
11
|
+
(request.nativeOutputTools === true ? nativeResultWireBytes(request.input, request.instructions) : 0);
|
|
12
|
+
return { bytes, maxBytes, fits: bytes <= maxBytes };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Local to one already captured scope. This is measurement policy only, never a
|
|
16
|
+
// larger source authorization, reader budget, or cross-review/global default.
|
|
17
|
+
const scopePolicies = new WeakMap();
|
|
18
|
+
export function configureWireBudget(scope, { nativeOutputTools } = {}) {
|
|
19
|
+
if (!scope || (typeof scope !== 'object' && typeof scope !== 'function')) throw new Error('Invalid model wire scope');
|
|
20
|
+
if (typeof nativeOutputTools !== 'boolean') throw new Error('nativeOutputTools must be boolean');
|
|
21
|
+
const previous = scopePolicies.get(scope);
|
|
22
|
+
if (previous !== undefined && previous !== nativeOutputTools) throw new Error('Model wire policy already configured');
|
|
23
|
+
scopePolicies.set(scope, nativeOutputTools);
|
|
24
|
+
return scope;
|
|
25
|
+
}
|
|
26
|
+
export function nativeWireEnabled(request, scope) {
|
|
27
|
+
if (request?.nativeOutputTools !== undefined) {
|
|
28
|
+
if (typeof request.nativeOutputTools !== 'boolean') throw new Error('nativeOutputTools must be boolean');
|
|
29
|
+
return request.nativeOutputTools;
|
|
30
|
+
}
|
|
31
|
+
return scope ? scopePolicies.get(scope) === true : false;
|
|
32
|
+
}
|
package/src/primary-seeds.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { retrievalRequest } from './retrieval-loop.mjs';
|
|
2
|
+
import { modelWireBudget } from './model-wire-budget.mjs';
|
|
2
3
|
|
|
3
4
|
const MAX_READS = 4, MAX_LINES = 40;
|
|
4
5
|
// Optional navigation must leave room for normal retrieval and exact evidence
|
|
@@ -78,7 +79,7 @@ export function primarySeeds(scope, request, { maxInputBytes = 96 * 1024, onRetr
|
|
|
78
79
|
checkSignal();
|
|
79
80
|
const next = retrievalRequest(request, scope, [...seeds, { request: operation, result }]);
|
|
80
81
|
const remaining = scope.budget();
|
|
81
|
-
if (result.start + result.count <= coordinate || remaining.remainingCalls < RESERVE_CALLS || remaining.remainingOutputBytes < RESERVE_BYTES || Buffer.byteLength(next.input) + Buffer.byteLength(next.instructions) > maxInputBytes) throw optionalError();
|
|
82
|
+
if (result.start + result.count <= coordinate || remaining.remainingCalls < RESERVE_CALLS || remaining.remainingOutputBytes < RESERVE_BYTES || (request.nativeOutputTools === true ? !modelWireBudget(next, maxInputBytes).fits : Buffer.byteLength(next.input) + Buffer.byteLength(next.instructions) > maxInputBytes)) throw optionalError();
|
|
82
83
|
return { request: operation, result };
|
|
83
84
|
});
|
|
84
85
|
} catch (error) {
|
|
@@ -9,7 +9,7 @@ export function projectConcurrency(value = DEFAULT_PROJECT_CONCURRENCY) {
|
|
|
9
9
|
|
|
10
10
|
// Parallelize independent approved snapshots, never dependent stages within a batch.
|
|
11
11
|
// 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 } = {}) {
|
|
12
|
+
export async function reviewProjectBatches(batches, model, { concurrency = DEFAULT_PROJECT_CONCURRENCY, remainingCalls, signal, onProgress, maxRetrievalRounds = 3, resumeStates, onResumeCheckpoint, onModelArtifact, enableGroupPrimary = false, nativeOutputTools = false } = {}) {
|
|
13
13
|
projectConcurrency(concurrency);
|
|
14
14
|
retrievalRounds(maxRetrievalRounds);
|
|
15
15
|
if (!Array.isArray(batches) || typeof remainingCalls !== 'function') throw new Error('Invalid project execution');
|
|
@@ -27,7 +27,8 @@ export async function reviewProjectBatches(batches, model, { concurrency = DEFAU
|
|
|
27
27
|
if (batchSignal.aborted || remainingCalls() <= 0) { results[index] = unstarted(batch); continue; }
|
|
28
28
|
results[index] = await reviewSnapshot(batch, model, {
|
|
29
29
|
signal: batchSignal, maxCalls: remainingCalls(), remainingCalls, enableRetrieval: true, enableVerification: true,
|
|
30
|
-
enableAnchorCorrection: true, correctProtocol: true, maxRetrievalRounds,
|
|
30
|
+
enableGroupPrimary, nativeOutputTools, enableAnchorCorrection: true, correctProtocol: true, maxRetrievalRounds,
|
|
31
|
+
resumeState: resumeStates?.get(batch.id), onResumeCheckpoint: onResumeCheckpoint ? report => onResumeCheckpoint(batch, report) : undefined, onModelArtifact,
|
|
31
32
|
onProgress: onProgress ? event => onProgress({ ...event, snapshotId: batch.id, batchIndex: index }) : undefined,
|
|
32
33
|
});
|
|
33
34
|
} catch (error) {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { REVIEW_RUNTIME } from './runtime-identity.mjs';
|
|
2
|
+
import { buildResumeState, validateResumeState } from './review-resume.mjs';
|
|
1
3
|
import { renderProjectReport } from './project-report.mjs';
|
|
2
4
|
import { reviewDiagnostics } from './review-diagnostics.mjs';
|
|
3
5
|
import { realpath } from 'node:fs/promises';
|
|
@@ -69,12 +71,15 @@ async function captureProject(cwd, args, signal) {
|
|
|
69
71
|
return { ...result, id: hash(JSON.stringify(result)) };
|
|
70
72
|
}
|
|
71
73
|
|
|
72
|
-
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 } = {}) {
|
|
74
|
+
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, nativeOutputTools = false, enableGroupPrimary = false } = {}) {
|
|
73
75
|
for (const n of [ttlMs, maxPreviews]) if (!Number.isSafeInteger(n) || n < 1) throw new Error('Invalid project preview limit');
|
|
74
76
|
if (!Number.isSafeInteger(projectMaxCalls) || projectMaxCalls < 1) throw new Error('Invalid project model call limit');
|
|
75
77
|
|
|
76
78
|
validateProjectConcurrency(projectConcurrency);
|
|
77
79
|
retrievalRounds(maxRetrievalRounds);
|
|
80
|
+
if (typeof nativeOutputTools !== 'boolean' || typeof enableGroupPrimary !== 'boolean') throw new Error('Invalid project execution options');
|
|
81
|
+
const resumeIdentity = (args, route) => ({ captureOptions: args, route, runtime: REVIEW_RUNTIME, execution: { projectMaxCalls, projectConcurrency, maxRetrievalRounds, nativeOutputTools, enableGroupPrimary, enableVerification: true, enableAnchorCorrection: true, correctProtocol: true } });
|
|
82
|
+
const projectBinding = snapshot => ({ ...snapshot, files: snapshot.batches.flatMap(batch => batch.files) });
|
|
78
83
|
const previews = new Map();
|
|
79
84
|
const lifetime = new AbortController();
|
|
80
85
|
let busy = false;
|
|
@@ -87,21 +92,39 @@ export function createProjectReviewService(llm, { allowModelSending = false, aut
|
|
|
87
92
|
async preview(args, exec) {
|
|
88
93
|
if (busy) throw new Error('Review service busy');
|
|
89
94
|
if (!args || typeof args !== 'object') throw new Error('Project preview parameters required');
|
|
90
|
-
if (Object.keys(args).some(k => !['reviewMode', 'repositoryPath', 'selectedPaths'].includes(k)) || args.reviewMode !== 'project') throw new Error('Project mode supports only reviewMode/repositoryPath/selectedPaths; change/history context parameters are not project parameters');
|
|
95
|
+
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');
|
|
91
96
|
const current = owner(exec);
|
|
92
97
|
busy = true;
|
|
93
98
|
try {
|
|
94
|
-
const
|
|
99
|
+
const captureArgs = structuredClone(args);
|
|
100
|
+
const sourceJobId = captureArgs.resumeFromJobId; delete captureArgs.resumeFromJobId;
|
|
101
|
+
if (sourceJobId !== undefined && (typeof sourceJobId !== 'string' || !/^[a-f0-9-]{36}$/.test(sourceJobId) || typeof exec.resolveReviewResume !== 'function')) throw new Error('Invalid or unavailable resume job');
|
|
102
|
+
const saved = sourceJobId === undefined ? undefined : await exec.resolveReviewResume(sourceJobId);
|
|
103
|
+
const s = await captureProject(current.cwd, captureArgs, signalFor(exec));
|
|
104
|
+
const identity = resumeIdentity(captureArgs, current.route);
|
|
105
|
+
let resumeStates;
|
|
106
|
+
if (saved !== undefined) {
|
|
107
|
+
if (saved?.schemaVersion !== 1 || saved.mode !== 'project' || !Array.isArray(saved.batches)) throw new Error('Invalid project resume checkpoint');
|
|
108
|
+
validateResumeState(saved.binding, projectBinding(s), identity);
|
|
109
|
+
if (saved.batches.length > s.batches.length || new Set(saved.batches.map(item => item?.snapshotId)).size !== saved.batches.length) throw new Error('Duplicate or excessive resume batches');
|
|
110
|
+
resumeStates = new Map(saved.batches.map(item => {
|
|
111
|
+
const batch = s.batches.find(batch => batch.id === item.snapshotId);
|
|
112
|
+
if (!batch) throw new Error('Project resume batch identity mismatch');
|
|
113
|
+
return [batch.id, validateResumeState(item, batch, identity)];
|
|
114
|
+
}));
|
|
115
|
+
}
|
|
95
116
|
for (const [id, p] of previews) if (p.expires <= now()) previews.delete(id);
|
|
96
117
|
while (previews.size >= maxPreviews) previews.delete(previews.keys().next().value);
|
|
97
118
|
const previewId = randomUUID();
|
|
98
|
-
previews.set(previewId, { ...current, args:
|
|
119
|
+
previews.set(previewId, { ...current, args: captureArgs, snapshotId: s.id, resumeStates, sourceJobId, expires: now() + ttlMs });
|
|
99
120
|
return {
|
|
121
|
+
...(resumeStates ? { resume: { sourceJobId, reusableFiles: [...resumeStates.values()].reduce((sum, state) => sum + state.completed.length, 0) } } : {}),
|
|
100
122
|
previewId, snapshotId: s.id, reviewMode: 'project', repositoryRoot: s.root, vcs: s.vcs,
|
|
101
123
|
model: current.route, modelSendingEnabled: allowModelSending,
|
|
102
124
|
totalTrackedNodes: s.totalTrackedNodes, selectedFiles: s.selectedFiles, selectionComplete: s.selectionComplete,
|
|
103
125
|
inventoryComplete: s.inventoryComplete, inventoryScope: s.inventoryScope, inventoryNodes: s.inventoryNodes, batches: s.batches.length,
|
|
104
|
-
|
|
126
|
+
privateArtifacts: { enabled: typeof exec.onModelArtifact === 'function', sourceBearing: true, maxArtifactBytes: 1048576, maxJobBytes: 8388608, maxArtifacts: 64, notice: 'Bounded private untrusted model outputs; raw access requires explicit consent and is not review evidence.' },
|
|
127
|
+
execution: { nativeOutputTools, enableGroupPrimary, batchConcurrency: projectConcurrency, maxRetrievalRounds },
|
|
105
128
|
files: s.batches.flatMap(b => b.files.map(f => ({ path: f.path, eligibility: f.eligibility, reason: f.reason }))),
|
|
106
129
|
notice: s.notice + PREVIEW_NOTICE_SUFFIX + ' Each stage allows at most ' + maxRetrievalRounds + ' retrieval rounds; exhaustion requires a final-only response and explicit unfinished coverage.',
|
|
107
130
|
};
|
|
@@ -130,8 +153,20 @@ export function createProjectReviewService(llm, { allowModelSending = false, aut
|
|
|
130
153
|
files: s.batches.flatMap(b => b.files.map(f => ({ fileId: f.id, path: f.path, status: f.eligibility === 'reviewable' ? 'pending' : f.eligibility }))),
|
|
131
154
|
});
|
|
132
155
|
|
|
156
|
+
const identity = resumeIdentity(p.args, p.route);
|
|
157
|
+
const states = new Map();
|
|
158
|
+
for (const [id, saved] of p.resumeStates ?? []) {
|
|
159
|
+
const batch = s.batches.find(batch => batch.id === id);
|
|
160
|
+
if (!batch) throw new Error('Project resume batch identity mismatch');
|
|
161
|
+
states.set(id, validateResumeState(saved, batch, identity));
|
|
162
|
+
}
|
|
163
|
+
const persist = async (batch, report) => {
|
|
164
|
+
if (!exec.reviewResumeCheckpoint) return;
|
|
165
|
+
states.set(batch.id, buildResumeState(batch, identity, report));
|
|
166
|
+
await exec.reviewResumeCheckpoint({ schemaVersion: 1, mode: 'project', binding: buildResumeState(projectBinding(s), identity, { files: [], findings: [] }), batches: [...states.values()] });
|
|
167
|
+
};
|
|
133
168
|
let calls = 0;
|
|
134
|
-
const adapter = createDshModel(llm, p.route, { correctJson: true });
|
|
169
|
+
const adapter = createDshModel(llm, p.route, { correctJson: true, useNativeTools: nativeOutputTools });
|
|
135
170
|
const model = request => {
|
|
136
171
|
if (calls >= projectMaxCalls) throw Object.assign(new Error('Shared project model call limit exceeded'), { code: 'PROJECT_MODEL_CALL_LIMIT' });
|
|
137
172
|
calls++;
|
|
@@ -143,10 +178,11 @@ export function createProjectReviewService(llm, { allowModelSending = false, aut
|
|
|
143
178
|
};
|
|
144
179
|
|
|
145
180
|
const results = await reviewProjectBatches(s.batches, model, {
|
|
146
|
-
concurrency: projectConcurrency, maxRetrievalRounds, signal, remainingCalls: () => projectMaxCalls - calls,
|
|
147
|
-
onProgress: exec.reviewCheckpoint,
|
|
181
|
+
concurrency: projectConcurrency, maxRetrievalRounds, enableGroupPrimary, nativeOutputTools, signal, remainingCalls: () => projectMaxCalls - calls,
|
|
182
|
+
onProgress: exec.reviewCheckpoint, resumeStates: states, onResumeCheckpoint: persist, onModelArtifact: exec.onModelArtifact,
|
|
148
183
|
});
|
|
149
184
|
|
|
185
|
+
for (let i = 0; i < results.length; i++) await persist(s.batches[i], results[i]);
|
|
150
186
|
const files = results.flatMap(r => r.files.map(f => ({ ...f, batchSnapshotId: r.snapshotId })));
|
|
151
187
|
const coverage = {
|
|
152
188
|
total: s.selectedFiles,
|
|
@@ -165,8 +201,9 @@ export function createProjectReviewService(llm, { allowModelSending = false, aut
|
|
|
165
201
|
: 'partial',
|
|
166
202
|
repositoryRoot: s.root, vcs: s.vcs, model: p.route, modelCalls: calls, maxModelCalls: projectMaxCalls,
|
|
167
203
|
totalTrackedNodes: s.totalTrackedNodes, selectionComplete: s.selectionComplete, inventoryComplete: s.inventoryComplete, inventoryScope: s.inventoryScope, inventoryNodes: s.inventoryNodes,
|
|
168
|
-
coverage, files, findings, batches: results,
|
|
169
|
-
|
|
204
|
+
coverage, files, findings, artifacts: results.flatMap(result => result.artifacts ?? []), batches: results, resume: { reusedFiles: files.filter(file => file.reused).length },
|
|
205
|
+
privateArtifacts: { enabled: typeof exec.onModelArtifact === 'function', sourceBearing: true, maxArtifactBytes: 1048576, maxJobBytes: 8388608, maxArtifacts: 64, notice: 'Bounded private untrusted model outputs; raw access requires explicit consent and is not review evidence.' },
|
|
206
|
+
execution: { nativeOutputTools, enableGroupPrimary, batchConcurrency: projectConcurrency, maxRetrievalRounds },
|
|
170
207
|
notice: s.notice + REPORT_NOTICE_SUFFIX,
|
|
171
208
|
};
|
|
172
209
|
|
package/src/retrieval-loop.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { RETRIEVAL_INSTRUCTIONS } from './prompts.mjs';
|
|
2
2
|
import { searchSeeds } from './search-seeds.mjs';
|
|
3
|
+
import { modelWireBudget } from './model-wire-budget.mjs';
|
|
3
4
|
import { executeRetrievalBatch, parseRetrievalResponse } from './retrieval-protocol.mjs';
|
|
4
5
|
|
|
5
6
|
export { RETRIEVAL_INSTRUCTIONS };
|
|
@@ -53,7 +54,7 @@ export async function retrievalLoop(model, request, scope, { correctProtocol = f
|
|
|
53
54
|
const retrieved = structuredClone(initialRetrieved);
|
|
54
55
|
if (retrieved.length) {
|
|
55
56
|
const seeded = retrievalRequest(request, scope, retrieved);
|
|
56
|
-
if (Buffer.byteLength(seeded.input) + Buffer.byteLength(seeded.instructions) > maxInputBytes) throw new Error('Initial retrieval input budget exceeded');
|
|
57
|
+
if (request.nativeOutputTools === true ? !modelWireBudget(seeded, maxInputBytes).fits : Buffer.byteLength(seeded.input) + Buffer.byteLength(seeded.instructions) > maxInputBytes) throw new Error('Initial retrieval input budget exceeded');
|
|
57
58
|
// Publish only fragments admitted to an actual outgoing prompt below.
|
|
58
59
|
}
|
|
59
60
|
const delivered = new Set();
|
|
@@ -71,13 +72,14 @@ export async function retrievalLoop(model, request, scope, { correctProtocol = f
|
|
|
71
72
|
await finalize(error);
|
|
72
73
|
} else if (!finalization && round >= maxRounds) await finalize(roundBudgetError());
|
|
73
74
|
};
|
|
75
|
+
const fits = value => request.nativeOutputTools === true ? modelWireBudget(value, maxInputBytes).fits : Buffer.byteLength(value.input) + Buffer.byteLength(value.instructions) <= maxInputBytes;
|
|
74
76
|
const nextRequest = records => {
|
|
75
77
|
const next = retrievalRequest(request, scope, records);
|
|
76
78
|
if (enableSearchSeeds) {
|
|
77
79
|
const annotated = { input: JSON.stringify({ ...JSON.parse(next.input), retrievalRounds: { max: maxRounds, remaining: finalization ? 0 : Math.max(0, maxRounds - currentRound) } }),
|
|
78
80
|
instructions: next.instructions + ' Batch independent exact reads/searches together (1..8). retrievalRounds is per-stage request turns, not reader calls or authority. Search-hit host reads may accompany results; supplied fragments are not complete coverage.' };
|
|
79
81
|
// Optional guidance must not turn an otherwise admitted envelope into a block.
|
|
80
|
-
if (
|
|
82
|
+
if (fits({ ...next, ...annotated })) Object.assign(next, annotated);
|
|
81
83
|
}
|
|
82
84
|
if (finalization) {
|
|
83
85
|
// The live ledger is already present in retrievalBudget. Do not duplicate it
|
|
@@ -93,8 +95,8 @@ export async function retrievalLoop(model, request, scope, { correctProtocol = f
|
|
|
93
95
|
request.signal?.throwIfAborted();
|
|
94
96
|
if (round > 0 || !allowExhaustedInitial) await checkExhausted(round);
|
|
95
97
|
let { input, instructions } = nextRequest(retrieved);
|
|
96
|
-
if (correctProtocol && retrieved.length &&
|
|
97
|
-
if (
|
|
98
|
+
if (correctProtocol && retrieved.length && !fits({ ...request, input, instructions })) input = compactFollowup(input);
|
|
99
|
+
if (!fits({ ...request, input, instructions })) throw new Error('Retrieval input budget exceeded');
|
|
98
100
|
request.signal?.throwIfAborted();
|
|
99
101
|
beforeCall();
|
|
100
102
|
request.signal?.throwIfAborted();
|
|
@@ -161,19 +163,19 @@ export async function retrievalLoop(model, request, scope, { correctProtocol = f
|
|
|
161
163
|
let candidate = [...retrieved, item];
|
|
162
164
|
let next = nextRequest(candidate);
|
|
163
165
|
if (correctProtocol) {
|
|
164
|
-
while (retrieved.length &&
|
|
166
|
+
while (retrieved.length && !fits({ ...next, input: compactFollowup(next.input) })) {
|
|
165
167
|
retrieved.shift();
|
|
166
168
|
await request.onProtocolEvent?.({ action: 'prior-retrieval-deferred', notice: 'Prior returned fragment no longer in current prompt; reread approved source for evidence.' });
|
|
167
169
|
candidate = [...retrieved, item];
|
|
168
170
|
next = nextRequest(candidate);
|
|
169
171
|
}
|
|
170
172
|
}
|
|
171
|
-
let
|
|
172
|
-
if (correctProtocol && !
|
|
173
|
-
if (correctProtocol && !
|
|
173
|
+
let admitted = fits(next);
|
|
174
|
+
if (correctProtocol && !admitted) admitted = fits({ ...next, input: compactFollowup(next.input) });
|
|
175
|
+
if (correctProtocol && !admitted) {
|
|
174
176
|
const omitted = { request: item.request, result: { omitted: true, reason: 'Result omitted: request fewer lines/matches; no body or receipt provided.' } };
|
|
175
177
|
const minimal = nextRequest([...retrieved, omitted]);
|
|
176
|
-
if (
|
|
178
|
+
if (!fits({ ...minimal, input: compactFollowup(minimal.input) })) throw new Error('Retrieval input metadata exceeds model capacity');
|
|
177
179
|
retrieved.push(omitted);
|
|
178
180
|
await request.onProtocolEvent?.({ action: 'oversized-retrieval-omitted', kind: item.request.kind });
|
|
179
181
|
} else {
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { mkdir, open, lstat, realpath, rename, unlink, chmod } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
const queues = new Map();
|
|
7
|
+
const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
|
|
8
|
+
const STATUSES = new Set(['completed', 'failed', 'cancelled', 'incomplete', 'validated-draft', 'rejected']);
|
|
9
|
+
const fail = code => Object.assign(new Error(code), { code });
|
|
10
|
+
const within = (base, name) => { const r = path.relative(base, name); return r === '' || (!path.isAbsolute(r) && r !== '..' && !r.startsWith('..' + path.sep)); };
|
|
11
|
+
function ownerIdentity(owner) {
|
|
12
|
+
if (!owner || !['sessionId', 'cwd', 'provider', 'model'].every(key => typeof owner[key] === 'string' && owner[key].length > 0 && owner[key].length <= 4096) || !path.isAbsolute(owner.cwd) || !(owner.reasoningEffort == null || typeof owner.reasoningEffort === 'string' && owner.reasoningEffort.length <= 256)) throw fail('INVALID_REVIEW_ARTIFACT_OWNER');
|
|
13
|
+
return { sessionId: owner.sessionId, cwd: owner.cwd, provider: owner.provider, model: owner.model, reasoningEffort: owner.reasoningEffort ?? null };
|
|
14
|
+
}
|
|
15
|
+
function snapshot(value, depth = 0) {
|
|
16
|
+
if (depth > 64) throw fail('INVALID_REVIEW_ARTIFACT');
|
|
17
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
|
|
18
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
19
|
+
if (typeof value !== 'object' || ![Object.prototype, Array.prototype, null].includes(Object.getPrototypeOf(value))) throw fail('INVALID_REVIEW_ARTIFACT');
|
|
20
|
+
const result = Array.isArray(value) ? [] : Object.create(null);
|
|
21
|
+
for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(value))) {
|
|
22
|
+
if (Array.isArray(value) && key === 'length') continue;
|
|
23
|
+
if (!descriptor.enumerable) continue;
|
|
24
|
+
if (!('value' in descriptor) || descriptor.value === undefined) throw fail('INVALID_REVIEW_ARTIFACT');
|
|
25
|
+
Object.defineProperty(result, key, { value: snapshot(descriptor.value, depth + 1), enumerable: true, configurable: true, writable: true });
|
|
26
|
+
}
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
29
|
+
async function projectedRealpath(name) {
|
|
30
|
+
let current = path.resolve(name); const suffix = [];
|
|
31
|
+
for (;;) {
|
|
32
|
+
try { return path.join(await realpath(current), ...suffix); }
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (error.code !== 'ENOENT') throw error;
|
|
35
|
+
try { if ((await lstat(current)).isSymbolicLink()) throw fail('UNSAFE_REVIEW_ARTIFACT'); } catch (e) { if (e.code !== 'ENOENT') throw e; }
|
|
36
|
+
const parent = path.dirname(current); if (parent === current) throw error;
|
|
37
|
+
suffix.unshift(path.basename(current)); current = parent;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Artifacts are untrusted private evidence, never approved source or confirmed findings.
|
|
43
|
+
export function createReviewArtifacts({ directory, maxArtifactBytes = 1048576, maxJobBytes = 8388608, maxArtifacts = 64 } = {}) {
|
|
44
|
+
if (typeof directory !== 'string' || !path.isAbsolute(directory)) throw fail('INVALID_REVIEW_ARTIFACT_DIRECTORY');
|
|
45
|
+
if (![maxArtifactBytes, maxJobBytes, maxArtifacts].every(n => Number.isSafeInteger(n) && n > 0) || maxArtifactBytes > 16777216 || maxJobBytes > 268435456 || maxArtifacts > 10000) throw fail('INVALID_REVIEW_ARTIFACT_LIMIT');
|
|
46
|
+
let canonical;
|
|
47
|
+
async function safeDirectory(owner) {
|
|
48
|
+
const [workspace, resolved] = await Promise.all([projectedRealpath(owner.cwd), projectedRealpath(directory)]);
|
|
49
|
+
if (within(workspace, resolved) || canonical !== undefined && canonical !== resolved) throw fail('UNSAFE_REVIEW_ARTIFACT');
|
|
50
|
+
await mkdir(resolved, { recursive: true, mode: 0o700 });
|
|
51
|
+
const stat = await lstat(directory);
|
|
52
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(directory) !== resolved) throw fail('UNSAFE_REVIEW_ARTIFACT');
|
|
53
|
+
await chmod(resolved, 0o700); canonical ??= resolved;
|
|
54
|
+
return resolved;
|
|
55
|
+
}
|
|
56
|
+
async function load(filename, jobId, owner) {
|
|
57
|
+
let handle;
|
|
58
|
+
try {
|
|
59
|
+
const stat = await lstat(filename);
|
|
60
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > maxJobBytes + maxArtifacts * 1024 + 65536) throw fail('UNSAFE_REVIEW_ARTIFACT');
|
|
61
|
+
handle = await open(filename, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
62
|
+
const actual = await handle.stat();
|
|
63
|
+
if (!actual.isFile() || stat.ino !== actual.ino || stat.dev !== actual.dev) throw fail('UNSAFE_REVIEW_ARTIFACT');
|
|
64
|
+
const record = JSON.parse(await handle.readFile('utf8'));
|
|
65
|
+
if (record.jobId !== jobId || JSON.stringify(record.owner) !== JSON.stringify(owner)) throw fail('FOREIGN_REVIEW_ARTIFACT');
|
|
66
|
+
if (!Array.isArray(record.artifacts) || record.artifacts.length > maxArtifacts || !Number.isSafeInteger(record.retainedBytes) || record.retainedBytes < 0 || record.retainedBytes > maxJobBytes) throw fail('UNSAFE_REVIEW_ARTIFACT');
|
|
67
|
+
return record;
|
|
68
|
+
} catch (error) { if (error.code === 'ENOENT') return { jobId, owner, retainedBytes: 0, artifacts: [] }; throw error; }
|
|
69
|
+
finally { await handle?.close(); }
|
|
70
|
+
}
|
|
71
|
+
async function transaction(jobId, owner, work) {
|
|
72
|
+
if (typeof jobId !== 'string' || !UUID.test(jobId)) throw fail('INVALID_REVIEW_ARTIFACT_JOB');
|
|
73
|
+
try {
|
|
74
|
+
const root = await safeDirectory(owner), filename = path.join(root, jobId + '.json');
|
|
75
|
+
const previous = queues.get(filename) ?? Promise.resolve();
|
|
76
|
+
const next = previous.catch(() => {}).then(async () => {
|
|
77
|
+
await safeDirectory(owner);
|
|
78
|
+
const record = await load(filename, jobId, owner);
|
|
79
|
+
return work(record, filename);
|
|
80
|
+
});
|
|
81
|
+
queues.set(filename, next);
|
|
82
|
+
try { return await next; } finally { if (queues.get(filename) === next) queues.delete(filename); }
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (['INVALID_REVIEW_ARTIFACT_JOB', 'UNSAFE_REVIEW_ARTIFACT', 'FOREIGN_REVIEW_ARTIFACT'].includes(error.code)) throw fail(error.code);
|
|
85
|
+
throw fail('ARTIFACT_STORE_FAILED');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
async record(jobId, owner, artifact) {
|
|
90
|
+
const who = ownerIdentity(owner), value = snapshot(artifact);
|
|
91
|
+
if (!value || Array.isArray(value) || typeof value !== 'object') throw fail('INVALID_REVIEW_ARTIFACT');
|
|
92
|
+
const serialized = JSON.stringify(value), bytes = Buffer.byteLength(serialized);
|
|
93
|
+
const summary = { artifactId: randomUUID(), hash: createHash('sha256').update(serialized).digest('hex'), bytes, status: STATUSES.has(value.status) ? value.status : 'unclassified', omitted: false, recorded: true };
|
|
94
|
+
return transaction(jobId, who, async (record, filename) => {
|
|
95
|
+
if (bytes > maxArtifactBytes || record.retainedBytes + bytes > maxJobBytes || record.artifacts.length >= maxArtifacts) {
|
|
96
|
+
summary.omitted = true; summary.diagnostic = 'REVIEW_ARTIFACT_LIMIT_EXCEEDED';
|
|
97
|
+
if (record.artifacts.length >= maxArtifacts) return { ...summary, recorded: false };
|
|
98
|
+
}
|
|
99
|
+
record.artifacts.push({ ...summary, ...(summary.omitted ? {} : { artifact: value }) });
|
|
100
|
+
if (!summary.omitted) record.retainedBytes += bytes;
|
|
101
|
+
const tmp = path.join(canonical, jobId + '.' + randomUUID() + '.tmp'); let handle;
|
|
102
|
+
try {
|
|
103
|
+
handle = await open(tmp, 'wx', 0o600); await handle.writeFile(JSON.stringify(record)); await handle.sync(); await handle.close(); handle = undefined;
|
|
104
|
+
await safeDirectory(who);
|
|
105
|
+
try { const stat = await lstat(filename); if (stat.isSymbolicLink() || !stat.isFile() || stat.nlink !== 1) throw fail('UNSAFE_REVIEW_ARTIFACT'); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
106
|
+
await rename(tmp, filename);
|
|
107
|
+
} finally { await handle?.close(); await unlink(tmp).catch(() => {}); }
|
|
108
|
+
return { ...summary };
|
|
109
|
+
});
|
|
110
|
+
},
|
|
111
|
+
async read(jobId, owner, { offset = 0, limit = 20 } = {}) {
|
|
112
|
+
const who = ownerIdentity(owner);
|
|
113
|
+
if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw fail('INVALID_REVIEW_ARTIFACT_PAGE');
|
|
114
|
+
return transaction(jobId, who, async record => ({ jobId, totalArtifacts: record.artifacts.length, offset, limit, artifacts: record.artifacts.slice(offset, offset + limit) }));
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
import { REVIEW_RUNTIME } from './runtime-identity.mjs';
|
|
2
|
-
const PHASES = ['business-grouping', 'risk-planning', 'primary', 'file-interaction', 'window-synthesis', 'verification'];
|
|
3
|
-
const PURPOSES = ['primary-initial', 'retrieval-followup', 'budget-finalization', 'json-correction', 'business-grouping', 'risk-planning', 'window-synthesis', 'file-interaction', 'verification'];
|
|
2
|
+
const PHASES = ['business-grouping', 'risk-planning', 'primary', 'group-primary', 'file-interaction', 'window-synthesis', 'verification'];
|
|
3
|
+
const PURPOSES = ['primary-initial', 'retrieval-followup', 'budget-finalization', 'json-correction', 'business-grouping', 'risk-planning', 'group-primary', 'window-synthesis', 'file-interaction', 'verification'];
|
|
4
4
|
const READER_FIELDS = ['calls', 'outputBytes', 'maxCalls', 'maxOutputBytes', 'remainingCalls', 'remainingOutputBytes', 'maxResultBytes'];
|
|
5
5
|
const JSON_ISSUES = ['property-name', 'colon', 'separator', 'invalid-escape', 'control-character', 'unterminated-string', 'unexpected-end', 'trailing-content', 'syntax'];
|
|
6
6
|
const CORRECTION_CODES = ['MODEL_INVALID_JSON', 'MODEL_INVALID_REVIEW_RESPONSE', 'MODEL_INVALID_FINDING_IDENTITY', 'MODEL_INVALID_FINDING_SEVERITY', 'MODEL_MISSING_FINDING_FIELD', 'MODEL_INVALID_ANCHOR', 'MODEL_ANCHOR_MISMATCH', 'RETRIEVAL_PROTOCOL_ERROR'];
|
|
7
7
|
const LIMIT_CODES = ['READER_CALL_BUDGET_EXCEEDED', 'READER_OUTPUT_BUDGET_EXCEEDED', 'RETRIEVAL_ROUND_BUDGET_EXCEEDED'];
|
|
8
|
+
const RECOVERY_KINDS = ['native-tool', 'text-compatible', 'trailing-prose'];
|
|
8
9
|
const FALLBACK_REASONS = ['mode-notauto', 'smallchange', 'disabled', 'project', 'laterround', 'multi-batch', 'incomplete-edit-scope', 'input-budget'];
|
|
9
10
|
const number = n => Number.isFinite(n) && n >= 0 ? Math.round(n) : null;
|
|
10
11
|
// Source-free counters only; call walls overlap and include lifecycle observation.
|
|
11
12
|
export function reviewDiagnostics(report, { reviewWallMs = null, executionWallMs = null } = {}) {
|
|
13
|
+
// Shared orchestration is one aggregator, not copied into each file's work.
|
|
14
|
+
const sources = [...(report.files ?? []), ...(report.orchestration ? [report.orchestration] : [])];
|
|
12
15
|
const phases = [];
|
|
13
16
|
for (const phase of PHASES) {
|
|
14
|
-
const rows =
|
|
17
|
+
const rows = sources.map(f => f.modelMetrics?.[phase]).filter(Boolean);
|
|
15
18
|
if (!rows.length) continue;
|
|
16
19
|
const sum = field => rows.every(r => number(r[field]) !== null) ? number(rows.reduce((n, r) => n + r[field], 0)) : null;
|
|
17
20
|
phases.push({ phase, calls: sum('calls'), observedCallWallSumMs: sum('durationMs'), inputBytes: sum('inputBytes'), totalOutputBytes: sum('totalOutputBytes') });
|
|
@@ -19,24 +22,29 @@ export function reviewDiagnostics(report, { reviewWallMs = null, executionWallMs
|
|
|
19
22
|
const plans = (report.files ?? []).flatMap(f => f.riskPlans ?? []);
|
|
20
23
|
const observedModelCalls = phases.length && phases.every(p => p.calls !== null) ? phases.reduce((n, p) => n + p.calls, 0) : null;
|
|
21
24
|
const modelCallPurposes = PURPOSES.flatMap(purpose => {
|
|
22
|
-
const values =
|
|
25
|
+
const values = sources.map(f => f.modelCallPurposes?.[purpose]).filter(value => value !== undefined);
|
|
23
26
|
if (!values.length) return [];
|
|
24
27
|
return [{ purpose, calls: values.every(value => number(value) !== null) ? number(values.reduce((sum, value) => sum + value, 0)) : null }];
|
|
25
28
|
});
|
|
26
29
|
const readerBudget = Object.fromEntries(READER_FIELDS.map(field => [field, number(report.readerBudget?.[field])]));
|
|
27
30
|
const correctionCounts = new Map();
|
|
28
|
-
for (const event of
|
|
31
|
+
for (const event of sources.flatMap(f => f.modelCorrections ?? [])) {
|
|
29
32
|
if (!['explicit-json-correction', 'retrieval-protocol-correction', 'review-format-correction'].includes(event?.action) || !CORRECTION_CODES.includes(event.code)) continue;
|
|
30
33
|
const jsonIssue = JSON_ISSUES.includes(event.jsonIssue) ? event.jsonIssue : null;
|
|
31
34
|
const key = JSON.stringify([event.code, jsonIssue]);
|
|
32
35
|
correctionCounts.set(key, (correctionCounts.get(key) ?? 0) + 1);
|
|
33
36
|
}
|
|
34
37
|
const correctionReasons = [...correctionCounts].map(([key, calls]) => { const [code, jsonIssue] = JSON.parse(key); return { code, jsonIssue, calls }; }).sort((a, b) => a.code.localeCompare(b.code) || String(a.jsonIssue).localeCompare(String(b.jsonIssue)));
|
|
35
|
-
const limits =
|
|
38
|
+
const limits = sources.flatMap(f => f.retrievalLimits ?? []);
|
|
36
39
|
const readerLimitReasons = LIMIT_CODES.flatMap(code => { const calls = limits.filter(row => row?.code === code).length; return calls ? [{ code, calls }] : []; });
|
|
37
40
|
const readerLimits = { maxRounds: number(report.readerLimits?.maxRounds ?? report.maxRetrievalRounds) };
|
|
38
41
|
const riskPlanningFallbackReasons = FALLBACK_REASONS.flatMap(reason => { const calls = (report.files ?? []).filter(f => f.riskPlanningFallbackReason === reason).length; return calls ? [{ reason, calls }] : []; });
|
|
39
|
-
|
|
42
|
+
const recoveryEvents = sources.flatMap(source => source.modelRecoveries ?? []);
|
|
43
|
+
const transportRecoveries = RECOVERY_KINDS.flatMap(kind => { const calls = recoveryEvents.filter(event => event?.action === 'json-transport-recovery' && event.kind === kind).length; return calls ? [{ kind, calls }] : []; });
|
|
44
|
+
const resume = { reusedFiles: number(report.resume?.reusedFiles) };
|
|
45
|
+
const artifactRecords = Array.isArray(report.artifacts) ? report.artifacts : null;
|
|
46
|
+
const artifacts = { retained: artifactRecords ? artifactRecords.filter(record => record?.omitted === false && record.recorded === true).length : null, omitted: artifactRecords ? artifactRecords.filter(record => record?.omitted === true).length : null };
|
|
47
|
+
return { schemaVersion: 2, resume, artifacts, transportRecoveries, pluginVersion: REVIEW_RUNTIME.version, reviewWallMs: number(reviewWallMs), executionWallMs: number(executionWallMs),
|
|
40
48
|
modelCalls: number(report.modelCalls), observedModelCalls, phases, modelCallPurposes, readerBudget, correctionReasons, readerLimitReasons, readerLimits, riskPlanningFallbackReasons,
|
|
41
49
|
planning: { completed: plans.filter(p => p.status === 'completed' && p.method !== 'inline').length, inline: plans.filter(p => p.method === 'inline' && p.status === 'completed').length, inlineAbsent: plans.filter(p => p.method === 'inline' && p.status === 'absent').length, inlineInvalid: plans.filter(p => p.method === 'inline' && p.status === 'invalid').length, fallback: plans.filter(p => p.status === 'fallback').length, skipped: plans.filter(p => p.status === 'skipped').length },
|
|
42
50
|
callPurposeNotice: 'primary-initial is the first call of a retrieval-enabled stage, not only primary review; phase identifies the stage. Correction reason counts include actual correction invocation records only, not both failures and completion notices.',
|
|
@@ -52,7 +60,7 @@ export function diagnosticsMarkdown(d) {
|
|
|
52
60
|
output.push('', '累计调用墙钟可能并发重叠,且包含生命周期观察/初始检查点等待,不是纯提供方耗时,也不能从总耗时中相减当作本地开销。');
|
|
53
61
|
} else output.push('- 逐阶段模型观测不可用(未产生或旧报告未记录),不按零耗时解释。');
|
|
54
62
|
if (d.modelCallPurposes?.length) {
|
|
55
|
-
const labels = { 'primary-initial': '阶段初始', 'retrieval-followup': '检索后续', 'budget-finalization': '预算受限最终答复', 'json-correction': 'JSON纠正', 'business-grouping': '业务分组', 'risk-planning': '风险计划', 'window-synthesis': '窗口综合', 'file-interaction': '跨文件综合', verification: '复核' };
|
|
63
|
+
const labels = { 'primary-initial': '阶段初始', 'retrieval-followup': '检索后续', 'budget-finalization': '预算受限最终答复', 'json-correction': 'JSON纠正', 'business-grouping': '业务分组', 'risk-planning': '风险计划', 'group-primary': '分组主审', 'window-synthesis': '窗口综合', 'file-interaction': '跨文件综合', verification: '复核' };
|
|
56
64
|
output.push('- 调用目的:' + d.modelCallPurposes.filter(row => PURPOSES.includes(row.purpose)).map(row => labels[row.purpose] + ' ' + (number(row.calls) ?? '不可用')).join(';') + '。');
|
|
57
65
|
}
|
|
58
66
|
if (d.readerBudget && READER_FIELDS.some(field => number(d.readerBudget[field]) !== null)) {
|
|
@@ -63,6 +71,9 @@ export function diagnosticsMarkdown(d) {
|
|
|
63
71
|
if (d.readerLimitReasons?.length) output.push('- 读取限制原因:' + d.readerLimitReasons.filter(r => LIMIT_CODES.includes(r.code)).map(r => r.code + ' ' + (number(r.calls) ?? '不可用')).join(';') + '。');
|
|
64
72
|
if (number(d.readerLimits?.maxRounds) !== null) output.push('- 每阶段最大检索轮次:' + number(d.readerLimits.maxRounds) + ';调用数/输出字节耗尽与轮次耗尽分别记录,不能混同。');
|
|
65
73
|
if (d.riskPlanningFallbackReasons?.length) output.push('- 风险策略回退原因:' + d.riskPlanningFallbackReasons.filter(r => FALLBACK_REASONS.includes(r.reason)).map(r => r.reason + ' ' + (number(r.calls) ?? '不可用')).join(';') + '。');
|
|
74
|
+
if (d.transportRecoveries?.length) output.push('- 本地结果传输:' + d.transportRecoveries.filter(row => RECOVERY_KINDS.includes(row.kind)).map(row => row.kind + ' ' + (number(row.calls) ?? '不可用')).join(';') + ';仅计成功解析的传输事件,不表示额外模型调用或缺陷确认。');
|
|
75
|
+
if (number(d.resume?.reusedFiles) !== null) output.push('- 续审复用已完成文件:' + number(d.resume.reusedFiles) + ';复用成果不计为本次模型调用或新读取证据。');
|
|
76
|
+
if (number(d.artifacts?.retained) !== null || number(d.artifacts?.omitted) !== null) output.push('- 私有模型工件:已留存 ' + (number(d.artifacts.retained) ?? '不可用') + ',未留存 ' + (number(d.artifacts.omitted) ?? '不可用') + ';诊断仅含计数,不包含正文或访问标识。');
|
|
66
77
|
output.push('- primary-initial 表示启用检索的某阶段初始调用,不仅是主审;具体阶段以阶段统计为准。');
|
|
67
78
|
output.push('- 风险计划:完成 ' + d.planning.completed + ',回退 ' + d.planning.fallback + ',跳过 ' + d.planning.skipped + ';合并主审问题 ' + (d.planning.inline ?? 0) + ',缺失 ' + (d.planning.inlineAbsent ?? 0) + ',无效 ' + (d.planning.inlineInvalid ?? 0) + '(非独立规划)。');
|
|
68
79
|
return output;
|
package/src/review-jobs.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {createReviewArtifacts} from './review-artifacts.mjs';
|
|
1
2
|
import {jobProgress} from './job-progress.mjs';
|
|
2
3
|
import {recoveryCoverage} from './recovery-coverage.mjs';
|
|
3
4
|
import {randomUUID} from 'node:crypto';
|
|
@@ -7,6 +8,7 @@ import path from 'node:path';
|
|
|
7
8
|
// Report storage is host-owned, never inside a reviewed working copy.
|
|
8
9
|
export function createReviewJobs({directory}={}){
|
|
9
10
|
if(typeof directory!=='string'||!path.isAbsolute(directory))throw new Error('Absolute host report directory required');
|
|
11
|
+
const artifacts=createReviewArtifacts({directory:path.join(directory,'artifacts')});
|
|
10
12
|
const jobs=new Map(),listeners=new Map();const changed=id=>{for(const fn of [...(listeners.get(id)??[])])fn();};let disposed=false,canonicalDirectory;
|
|
11
13
|
const identity=exec=>{const a=exec?.agent,s=a?.session;if(!s||!a.options?.provider||!a.options?.model||!s.header?.cwd)throw new Error('Review job requires session identity');const id=s.header.id??s.id;if(typeof id!=='string'||!id)throw new Error('Durable session id required');return {sessionId:id,cwd:s.header.cwd,provider:a.options.provider,model:a.options.model,reasoningEffort:a.options.reasoningEffort??null};};
|
|
12
14
|
const same=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
|
|
@@ -59,7 +61,7 @@ export function createReviewJobs({directory}={}){
|
|
|
59
61
|
controller.signal.throwIfAborted();
|
|
60
62
|
job.record.status='running';await enqueue(()=>save(job));
|
|
61
63
|
controller.signal.throwIfAborted();
|
|
62
|
-
const result=await work({signal:controller.signal,checkpoint:async value=>enqueue(async()=>{job.record.stage=value.stage??'reviewing';job.record.checkpoints.push(structuredClone(value));await save(job);})});
|
|
64
|
+
const result=await work({signal:controller.signal,artifact:async value=>{const ref=await artifacts.record(id,job.record.owner,value);await enqueue(async()=>{(job.record.artifacts??=[]).push(ref);await save(job);});return ref;},saveResumeState:async value=>enqueue(async()=>{job.record.resumeState=structuredClone(value);await save(job);}),checkpoint:async value=>enqueue(async()=>{job.record.stage=value.stage??'reviewing';job.record.checkpoints.push(structuredClone(value));await save(job);})});
|
|
63
65
|
job.record.result=result;job.record.status=controller.signal.aborted?'cancelled':'completed';
|
|
64
66
|
}catch(error){job.record.status=controller.signal.aborted?'cancelled':'failed';job.record.error=error?.message??String(error);}
|
|
65
67
|
finally{
|
|
@@ -74,7 +76,9 @@ export function createReviewJobs({directory}={}){
|
|
|
74
76
|
await startup;
|
|
75
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.'};
|
|
76
78
|
},
|
|
77
|
-
async
|
|
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 artifacts(id,exec,page){const r=await owned(id,exec);return artifacts.read(id,r.owner,page);},
|
|
81
|
+
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?'Show preview and request user confirmation before execute.':'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}:({...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}: {})};},
|
|
78
82
|
async wait(id,exec,{after,waitFor='progress',timeoutMs=60000,signal}={}){
|
|
79
83
|
const r=await owned(id,exec);
|
|
80
84
|
if(!Number.isSafeInteger(timeoutMs)||timeoutMs<1||timeoutMs>60000)throw new Error('Invalid wait duration');
|