@orion-agents/orion-code 0.3.27 → 0.3.29

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.
Files changed (30) hide show
  1. package/CHANGELOG.md +115 -3
  2. package/README.md +9 -10
  3. package/README.zh-CN.md +7 -7
  4. package/dist/commands/workflow-command-handlers.d.ts +9 -1
  5. package/dist/commands/workflow-command-handlers.js +66 -0
  6. package/dist/commands/workflow-commands.js +5 -5
  7. package/dist/services/code-review/format.d.ts +12 -0
  8. package/dist/services/code-review/format.js +79 -0
  9. package/dist/services/code-review/runner.d.ts +76 -0
  10. package/dist/services/code-review/runner.js +368 -0
  11. package/dist/services/code-review/selector.d.ts +24 -0
  12. package/dist/services/code-review/selector.js +86 -0
  13. package/dist/services/code-review/snapshot.d.ts +7 -0
  14. package/dist/services/code-review/snapshot.js +11 -2
  15. package/dist/web/code-review-run-service.d.ts +68 -0
  16. package/dist/web/code-review-run-service.js +228 -0
  17. package/dist/web/server.js +37 -0
  18. package/dist/web/workbench-controller.d.ts +16 -0
  19. package/dist/web/workbench-controller.js +61 -0
  20. package/dist/web-client/assets/{FilesPanel-CdoCa8u0.js → FilesPanel-iBYj9ZRf.js} +1 -1
  21. package/dist/web-client/assets/{GitPanel-CRr_iIDz.js → GitPanel-D33R_bZr.js} +1 -1
  22. package/dist/web-client/assets/ReviewPanel-C_0upsDk.js +6 -0
  23. package/dist/web-client/assets/{TerminalPanel-BniCjatZ.js → TerminalPanel-C7A8dMNf.js} +1 -1
  24. package/dist/web-client/assets/{index-DCYKIeOj.css → index-BaOxrwGB.css} +1 -1
  25. package/dist/web-client/assets/{index-kQqA3li6.js → index-CqCgJ2ov.js} +13 -13
  26. package/dist/web-client/assets/{useAutoNotice-DPXFWIwK.js → useAutoNotice-C1qM-_Sg.js} +1 -1
  27. package/dist/web-client/index.html +2 -2
  28. package/npm-shrinkwrap.json +2 -2
  29. package/package.json +1 -1
  30. package/dist/web-client/assets/ReviewPanel-C1Cu2IRV.js +0 -5
@@ -0,0 +1,368 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_REVIEW_BUDGETS = void 0;
4
+ exports.createProcessGitRunner = createProcessGitRunner;
5
+ exports.computeReviewRulesDigest = computeReviewRulesDigest;
6
+ exports.buildReviewSystemPrompt = buildReviewSystemPrompt;
7
+ exports.runCodeReview = runCodeReview;
8
+ /**
9
+ * v0.3.28 — the bounded single-pass code-review runner (docs/plan/
10
+ * v0.3.28-plan.md S2). Wires the frozen PR0 contract and the PR1 snapshot
11
+ * reader into an executable review: a typed selector is resolved to a frozen
12
+ * Git snapshot, the snapshot material is assembled into a bounded prompt, one
13
+ * model completion is requested, and the output is validated against the
14
+ * contract before a digest-verifiable `CodeReviewResultV1` receipt is built.
15
+ *
16
+ * Read-only posture: the runner performs no writes inside the reviewed
17
+ * repository — git runs read-only with argument arrays, working files are read
18
+ * through the contained reader, and the model receives the frozen material
19
+ * inline instead of an open tool surface. `REVIEW_WORKFLOW_CONSTRAINTS` is
20
+ * bound into the receipt's rules digest so receipts record the policy that
21
+ * governed the run.
22
+ */
23
+ const child_process_1 = require("child_process");
24
+ const crypto_1 = require("crypto");
25
+ const crypto_2 = require("crypto");
26
+ const promises_1 = require("fs/promises");
27
+ const path_1 = require("path");
28
+ const contract_1 = require("./contract");
29
+ Object.defineProperty(exports, "DEFAULT_REVIEW_BUDGETS", { enumerable: true, get: function () { return contract_1.DEFAULT_REVIEW_BUDGETS; } });
30
+ const constraints_1 = require("./constraints");
31
+ const snapshot_1 = require("./snapshot");
32
+ function sha256(value) {
33
+ return (0, crypto_1.createHash)('sha256').update(value, 'utf8').digest('hex');
34
+ }
35
+ /** Production git runner: argument arrays, no shell, read-only profile is the
36
+ * caller's responsibility (the runner only issues read commands). */
37
+ function createProcessGitRunner(cwd) {
38
+ return async (args) => new Promise((resolvePromise, reject) => {
39
+ (0, child_process_1.execFile)('git', args, { cwd, maxBuffer: 64 * 1024 * 1024 }, (error, stdout, stderr) => {
40
+ if (error) {
41
+ reject(new Error(`git ${args.join(' ')} failed: ${stderr || error.message}`));
42
+ return;
43
+ }
44
+ resolvePromise(stdout);
45
+ });
46
+ });
47
+ }
48
+ /** Rules provenance: AGENTS.md contents from cwd up to the filesystem root
49
+ * (bounded to 16 levels), combined with the frozen read-only constraints
50
+ * digest. Changing the rules or the policy invalidates applicability. */
51
+ async function computeReviewRulesDigest(cwd) {
52
+ const chunks = [];
53
+ let current = (0, path_1.resolve)(cwd);
54
+ for (let depth = 0; depth < 16; depth += 1) {
55
+ try {
56
+ const content = await (0, promises_1.readFile)((0, path_1.join)(current, 'AGENTS.md'), 'utf8');
57
+ chunks.push(`${current}::${content}`);
58
+ }
59
+ catch {
60
+ // No AGENTS.md at this level — fine.
61
+ }
62
+ const parent = (0, path_1.dirname)(current);
63
+ if (parent === current)
64
+ break;
65
+ current = parent;
66
+ }
67
+ return sha256(`${constraints_1.REVIEW_WORKFLOW_CONSTRAINTS.constraintsDigest}\n${chunks.join('\n---\n')}`);
68
+ }
69
+ /** The frozen review instruction set assembled for the model. */
70
+ function buildReviewSystemPrompt(rulesDigest) {
71
+ return [
72
+ 'You are a strict code reviewer operating inside Orion Code.',
73
+ 'You receive a frozen snapshot of the changes under review.',
74
+ 'Report only defects introduced or worsened by these changes, with concrete evidence.',
75
+ 'Exclude pure style opinions, speculation without evidence, and duplicates.',
76
+ 'Never suggest running commands, editing files, or any other action: this review is read-only.',
77
+ 'Respond with a single JSON object of the shape {"findings":[...]} and nothing else.',
78
+ 'Each finding object needs exactly these fields:',
79
+ ' findingId: 8-64 hex characters, stable within this response;',
80
+ ' priority: "P0" (blocks usage, proven) | "P1" | "P2" | "P3";',
81
+ ' title: short imperative summary;',
82
+ ' trigger: the concrete condition under which the defect manifests;',
83
+ ' impact: what breaks and for whom;',
84
+ ' fileKey: copy the file key from the snapshot material verbatim;',
85
+ ' side: "old" for removed/base lines, "new" for added/current lines;',
86
+ ' startLine, endLine: 1-based inclusive line numbers in that side;',
87
+ ' evidence: the code excerpt or reasoning that proves the finding.',
88
+ `Active policy digest: ${constraints_1.REVIEW_WORKFLOW_CONSTRAINTS.constraintsDigest}.`,
89
+ `Rules digest: ${rulesDigest}.`,
90
+ ].join('\n');
91
+ }
92
+ /** Assembles the bounded user prompt material from the frozen snapshot plus
93
+ * the diff text captured inside the admission window. */
94
+ function buildSnapshotMaterial(snapshot, diffText, budgets) {
95
+ const sections = [
96
+ `Snapshot: ${snapshot.snapshotId} (kind=${snapshot.kind}`,
97
+ snapshot.kind === 'base'
98
+ ? `base=${snapshot.baseOid} head=${snapshot.headOid})`
99
+ : 'worktree scope)',
100
+ `Input snapshot digest: ${snapshot.inputSnapshotDigest}`,
101
+ 'Changed files (fileKey -> path):',
102
+ ...snapshot.files.map(file => {
103
+ const sides = file.sides.map(side => side.source).join('+') || 'no-content';
104
+ return ` ${file.fileKey} ${file.path}${file.oldPath ? ` (from ${file.oldPath})` : ''} [${sides}]`;
105
+ }),
106
+ snapshot.skipped.length > 0
107
+ ? `Skipped by the host: ${snapshot.skipped.map(skip => `${skip.path} (${skip.reason})`).join('; ')}`
108
+ : 'Skipped by the host: none',
109
+ 'Unified diff:',
110
+ diffText,
111
+ ];
112
+ const text = sections.join('\n');
113
+ if (Buffer.byteLength(text, 'utf8') <= budgets.maxBodyBytes) {
114
+ return { text, truncated: false };
115
+ }
116
+ const clipped = Buffer.alloc(budgets.maxBodyBytes);
117
+ const written = Buffer.from(text, 'utf8').subarray(0, budgets.maxBodyBytes);
118
+ clipped.set(written);
119
+ return { text: clipped.toString('utf8'), truncated: true };
120
+ }
121
+ function extractFindingsPayload(content) {
122
+ const trimmed = content.trim();
123
+ const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/);
124
+ const candidate = fenced ? fenced[1].trim() : trimmed;
125
+ const start = candidate.indexOf('{');
126
+ const end = candidate.lastIndexOf('}');
127
+ if (start < 0 || end <= start)
128
+ return null;
129
+ try {
130
+ const parsed = JSON.parse(candidate.slice(start, end + 1));
131
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
132
+ return null;
133
+ const record = parsed;
134
+ if (!Array.isArray(record.findings))
135
+ return null;
136
+ return { findings: record.findings };
137
+ }
138
+ catch {
139
+ return null;
140
+ }
141
+ }
142
+ function snapshotAuthorizesFile(snapshot, finding) {
143
+ const file = snapshot.files.find(candidate => candidate.fileKey === finding.fileKey);
144
+ if (!file)
145
+ return false;
146
+ if (finding.side === 'old' && !file.oldPath && file.hunks.every(hunk => hunk.oldLines === 0)) {
147
+ return false;
148
+ }
149
+ const relevant = file.hunks.filter(hunk => finding.side === 'old'
150
+ ? hunk.oldLines > 0 &&
151
+ finding.startLine <= hunk.oldStart + hunk.oldLines - 1 &&
152
+ finding.endLine >= hunk.oldStart
153
+ : hunk.newLines > 0 &&
154
+ finding.startLine <= hunk.newStart + hunk.newLines - 1 &&
155
+ finding.endLine >= hunk.newStart);
156
+ // A finding whose lines touch no diff hunk is not anchored in the frozen
157
+ // material; the model must cite the changes under review.
158
+ return relevant.length > 0;
159
+ }
160
+ /**
161
+ * Runs one bounded review. Never throws for expected review failures — the
162
+ * outcome is typed; unexpected internal errors surface as `model_error`.
163
+ */
164
+ async function runCodeReview(options) {
165
+ const now = options.now ?? Date.now;
166
+ const startedAt = now();
167
+ const budgets = (0, contract_1.resolveReviewBudgets)(options.budgets);
168
+ if (!budgets.ok)
169
+ return { ok: false, failure: { kind: 'budget', field: budgets.field } };
170
+ const aborted = () => options.abortSignal?.aborted === true;
171
+ if (aborted())
172
+ return { ok: false, failure: { kind: 'cancelled' } };
173
+ const runGit = options.runGit ?? createProcessGitRunner(options.cwd);
174
+ let snapshot;
175
+ let diffText;
176
+ try {
177
+ snapshot = await (0, snapshot_1.captureReviewSnapshot)({
178
+ root: options.cwd,
179
+ selector: options.selector,
180
+ budgets: budgets.budgets,
181
+ runGit,
182
+ readWorktreeFile: (0, snapshot_1.createContainedWorktreeReader)(options.cwd),
183
+ });
184
+ options.onSnapshotCaptured?.(snapshot);
185
+ if (aborted())
186
+ return { ok: false, failure: { kind: 'cancelled' } };
187
+ // The exact same frozen diff arguments the snapshot used, against the
188
+ // OIDs the snapshot resolved; captured once inside the admission window
189
+ // and never re-read afterwards.
190
+ diffText = await runGit((0, snapshot_1.reviewUnifiedDiffArgs)(options.selector, {
191
+ baseOid: snapshot.baseOid,
192
+ headOid: snapshot.headOid,
193
+ }));
194
+ }
195
+ catch (error) {
196
+ const code = error instanceof snapshot_1.ReviewSnapshotError ? error.code : 'not_a_repository';
197
+ return {
198
+ ok: false,
199
+ failure: {
200
+ kind: 'snapshot',
201
+ code,
202
+ message: error instanceof Error ? error.message : String(error),
203
+ },
204
+ };
205
+ }
206
+ const rulesDigest = await computeReviewRulesDigest(options.cwd);
207
+ const limits = [];
208
+ const budgetExhausted = [];
209
+ if (snapshot.files.length === 0) {
210
+ limits.push('empty_scope_no_changes');
211
+ const endedAt = now();
212
+ const coverage = {
213
+ inspectedFiles: [],
214
+ inspectedRanges: [],
215
+ skipped: [],
216
+ truncated: false,
217
+ budgetExhausted: [],
218
+ };
219
+ const base = {
220
+ schemaVersion: 1,
221
+ reviewId: `cr-${(0, crypto_2.randomBytes)(8).toString('hex')}`,
222
+ workspaceIdentity: options.identity.workspaceIdentity,
223
+ sessionId: options.identity.sessionId,
224
+ threadId: options.identity.threadId,
225
+ turnId: options.identity.turnId,
226
+ requestedSelector: options.selector,
227
+ resolvedTarget: {
228
+ kind: snapshot.kind,
229
+ ...(snapshot.baseOid ? { baseOid: snapshot.baseOid } : {}),
230
+ ...(snapshot.headOid ? { headOid: snapshot.headOid } : {}),
231
+ snapshotId: snapshot.snapshotId,
232
+ },
233
+ inputSnapshotDigest: snapshot.inputSnapshotDigest,
234
+ rulesDigest,
235
+ state: 'completed',
236
+ conclusion: 'no_findings',
237
+ findings: [],
238
+ invalidFindingCount: 0,
239
+ coverage,
240
+ coverageAssertion: { complete: true },
241
+ limits,
242
+ model: { provider: 'none', model: 'none' },
243
+ usage: { inputTokens: 0, outputTokens: 0 },
244
+ startedAt,
245
+ endedAt,
246
+ };
247
+ return {
248
+ ok: true,
249
+ result: Object.freeze({ ...base, receiptDigest: (0, contract_1.computeReceiptDigest)(base) }),
250
+ };
251
+ }
252
+ const material = buildSnapshotMaterial(snapshot, diffText, budgets.budgets);
253
+ if (material.truncated) {
254
+ budgetExhausted.push('maxBodyBytes');
255
+ limits.push('prompt_material_truncated');
256
+ }
257
+ if (aborted())
258
+ return { ok: false, failure: { kind: 'cancelled' } };
259
+ let completion;
260
+ try {
261
+ completion = await options.adapter.complete([
262
+ { role: 'system', content: buildReviewSystemPrompt(rulesDigest) },
263
+ { role: 'user', content: material.text },
264
+ ]);
265
+ }
266
+ catch (error) {
267
+ return {
268
+ ok: false,
269
+ failure: {
270
+ kind: 'model_error',
271
+ message: error instanceof Error ? error.message : String(error),
272
+ },
273
+ };
274
+ }
275
+ const durationExceeded = now() - startedAt > budgets.budgets.maxDurationMs;
276
+ if (durationExceeded)
277
+ budgetExhausted.push('maxDurationMs');
278
+ const payload = extractFindingsPayload(completion.content);
279
+ if (!payload) {
280
+ return {
281
+ ok: false,
282
+ failure: {
283
+ kind: 'unparsable_output',
284
+ message: 'The model response did not contain a {"findings":[...]} JSON object.',
285
+ },
286
+ };
287
+ }
288
+ const validFindings = [];
289
+ let invalidFindingCount = 0;
290
+ for (const raw of payload.findings) {
291
+ const validated = (0, contract_1.validateReviewFinding)(raw);
292
+ if (!validated.valid) {
293
+ invalidFindingCount += 1;
294
+ continue;
295
+ }
296
+ if (validFindings.length >= budgets.budgets.maxFindings) {
297
+ invalidFindingCount += 1;
298
+ budgetExhausted.push('maxFindings');
299
+ continue;
300
+ }
301
+ if (!snapshotAuthorizesFile(snapshot, validated.finding)) {
302
+ invalidFindingCount += 1;
303
+ continue;
304
+ }
305
+ validFindings.push(validated.finding);
306
+ }
307
+ const truncated = material.truncated || budgetExhausted.includes('maxFindings');
308
+ const coverage = {
309
+ inspectedFiles: snapshot.files.map(file => file.path),
310
+ inspectedRanges: [],
311
+ skipped: snapshot.skipped.map(skip => ({
312
+ fileKey: skip.path,
313
+ reason: skip.reason,
314
+ ...(skip.detail ? { detail: skip.detail } : {}),
315
+ })),
316
+ truncated,
317
+ budgetExhausted: [...new Set(budgetExhausted)],
318
+ };
319
+ const coverageComplete = snapshot.skipped.length === 0 && !truncated && !durationExceeded && !material.truncated;
320
+ let state = 'completed';
321
+ if (aborted())
322
+ return { ok: false, failure: { kind: 'cancelled' } };
323
+ if (truncated || durationExceeded || snapshot.skipped.length > 0)
324
+ state = 'partial';
325
+ const conclusion = (0, contract_1.resolveReviewConclusion)({
326
+ state,
327
+ validFindingCount: validFindings.length,
328
+ invalidFindingCount,
329
+ coverageComplete,
330
+ });
331
+ const base = {
332
+ schemaVersion: 1,
333
+ reviewId: `cr-${(0, crypto_2.randomBytes)(8).toString('hex')}`,
334
+ workspaceIdentity: options.identity.workspaceIdentity,
335
+ sessionId: options.identity.sessionId,
336
+ threadId: options.identity.threadId,
337
+ turnId: options.identity.turnId,
338
+ requestedSelector: options.selector,
339
+ resolvedTarget: {
340
+ kind: snapshot.kind,
341
+ ...(snapshot.baseOid ? { baseOid: snapshot.baseOid } : {}),
342
+ ...(snapshot.headOid ? { headOid: snapshot.headOid } : {}),
343
+ snapshotId: snapshot.snapshotId,
344
+ },
345
+ inputSnapshotDigest: snapshot.inputSnapshotDigest,
346
+ rulesDigest,
347
+ state,
348
+ conclusion,
349
+ findings: Object.freeze(validFindings),
350
+ invalidFindingCount,
351
+ coverage,
352
+ coverageAssertion: { complete: coverageComplete },
353
+ limits,
354
+ model: { provider: 'orion-llm', model: completion.model },
355
+ usage: {
356
+ inputTokens: completion.usage?.inputTokens ?? 0,
357
+ outputTokens: completion.usage?.outputTokens ?? 0,
358
+ },
359
+ startedAt,
360
+ endedAt: now(),
361
+ };
362
+ const result = Object.freeze({
363
+ ...base,
364
+ receiptDigest: (0, contract_1.computeReceiptDigest)(base),
365
+ });
366
+ return { ok: true, result };
367
+ }
368
+ //# sourceMappingURL=runner.js.map
@@ -0,0 +1,24 @@
1
+ /**
2
+ * v0.3.28 — typed `/review` selector parsing (docs/plan/v0.3.28-plan.md S1).
3
+ *
4
+ * Slash arguments are parsed once into the frozen `ReviewSelectorV1` contract
5
+ * from PR0. Unknown arguments produce a typed usage error instead of falling
6
+ * back to an ordinary chat turn (v0.3.27 plan §3.1: "不能当成分支、路径或任意
7
+ * 普通聊天悄悄继续").
8
+ */
9
+ import type { ReviewSelectorV1 } from './contract';
10
+ export type ReviewSelectorParseResult = {
11
+ readonly ok: true;
12
+ readonly selector: ReviewSelectorV1;
13
+ } | {
14
+ readonly ok: false;
15
+ readonly usage: string;
16
+ };
17
+ export declare const REVIEW_USAGE_TEXT: string;
18
+ /**
19
+ * Parses `/review` arguments. Tokens: `all` alias, `--base <ref>` (once),
20
+ * `--path <path>` (repeatable, also `--path=<path>`), a single bare relative
21
+ * path (back-compat path filter). Anything else is a usage error.
22
+ */
23
+ export declare function parseReviewSelector(rawArgs: string): ReviewSelectorParseResult;
24
+ //# sourceMappingURL=selector.d.ts.map
@@ -0,0 +1,86 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.REVIEW_USAGE_TEXT = void 0;
4
+ exports.parseReviewSelector = parseReviewSelector;
5
+ exports.REVIEW_USAGE_TEXT = [
6
+ 'Usage: /review [all | --base <ref>] [--path <path>]...',
7
+ ' /review Review the current worktree (staged + unstaged + untracked)',
8
+ ' /review all Alias for the current worktree',
9
+ ' /review --base main Review committed changes merge-base(main, HEAD)..HEAD',
10
+ ' /review --path src/foo.ts Restrict the review to a path (repeatable)',
11
+ ].join('\n');
12
+ function ok(selector) {
13
+ return { ok: true, selector: Object.freeze({ ...selector }) };
14
+ }
15
+ function usageError(reason) {
16
+ return { ok: false, usage: `${reason}\n\n${exports.REVIEW_USAGE_TEXT}` };
17
+ }
18
+ function looksLikeRelativePath(value) {
19
+ return (value.length > 0 &&
20
+ !value.startsWith('/') &&
21
+ !value.startsWith('-') &&
22
+ !value.includes('\\') &&
23
+ value.split('/').every(segment => segment && segment !== '.' && segment !== '..'));
24
+ }
25
+ /**
26
+ * Parses `/review` arguments. Tokens: `all` alias, `--base <ref>` (once),
27
+ * `--path <path>` (repeatable, also `--path=<path>`), a single bare relative
28
+ * path (back-compat path filter). Anything else is a usage error.
29
+ */
30
+ function parseReviewSelector(rawArgs) {
31
+ const tokens = rawArgs
32
+ .trim()
33
+ .split(/\s+/)
34
+ .filter(token => token.length > 0);
35
+ let base = null;
36
+ const paths = [];
37
+ for (let index = 0; index < tokens.length; index += 1) {
38
+ const token = tokens[index];
39
+ if (token === 'all') {
40
+ if (tokens.length !== 1) {
41
+ return usageError('`all` cannot be combined with other arguments.');
42
+ }
43
+ return ok({ kind: 'worktree' });
44
+ }
45
+ if (token === '--base' || token.startsWith('--base=')) {
46
+ if (base !== null)
47
+ return usageError('--base may only be given once.');
48
+ const value = token.startsWith('--base=') ? token.slice('--base='.length) : tokens[index + 1];
49
+ if (!value)
50
+ return usageError('--base requires a ref argument.');
51
+ if (token === '--base')
52
+ index += 1;
53
+ base = value;
54
+ continue;
55
+ }
56
+ if (token === '--path' || token.startsWith('--path=')) {
57
+ const value = token.startsWith('--path=') ? token.slice('--path='.length) : tokens[index + 1];
58
+ if (!value)
59
+ return usageError('--path requires a path argument.');
60
+ if (token === '--path')
61
+ index += 1;
62
+ if (!looksLikeRelativePath(value)) {
63
+ return usageError(`Invalid --path value: ${value}`);
64
+ }
65
+ paths.push(value);
66
+ continue;
67
+ }
68
+ if (token.startsWith('-')) {
69
+ return usageError(`Unknown option: ${token}`);
70
+ }
71
+ // Bare argument: back-compat single existing-path filter. Multiple bare
72
+ // arguments are ambiguous and rejected rather than guessed.
73
+ if (paths.length > 0 || base !== null) {
74
+ return usageError(`Unexpected argument: ${token}`);
75
+ }
76
+ if (!looksLikeRelativePath(token)) {
77
+ return usageError(`Invalid path argument: ${token}`);
78
+ }
79
+ paths.push(token);
80
+ }
81
+ if (base !== null) {
82
+ return ok(paths.length > 0 ? { kind: 'base', ref: base, paths } : { kind: 'base', ref: base });
83
+ }
84
+ return ok(paths.length > 0 ? { kind: 'worktree', paths } : { kind: 'worktree' });
85
+ }
86
+ //# sourceMappingURL=selector.js.map
@@ -53,6 +53,13 @@ export interface ReviewSnapshotOptionsV1 {
53
53
  readonly readWorktreeFile?: (relativePath: string) => Promise<string | null>;
54
54
  readonly snapshotIdPrefix?: string;
55
55
  }
56
+ /** The read-only git arguments for the scope's unified diff. Exported so the
57
+ * v0.3.28 runner captures the exact same diff the snapshot froze instead of
58
+ * re-deriving OIDs after admission. */
59
+ export declare function reviewUnifiedDiffArgs(selector: ReviewSelectorV1, oids: {
60
+ readonly baseOid: string | null;
61
+ readonly headOid: string | null;
62
+ }): string[];
56
63
  /** Resolves the selector to a frozen snapshot. Every OID and digest in the
57
64
  * returned object was taken at admission time; callers persist the returned
58
65
  * digest as the review's input identity. */
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ReviewSnapshotError = void 0;
4
+ exports.reviewUnifiedDiffArgs = reviewUnifiedDiffArgs;
4
5
  exports.captureReviewSnapshot = captureReviewSnapshot;
5
6
  exports.createContainedWorktreeReader = createContainedWorktreeReader;
6
7
  /**
@@ -107,6 +108,14 @@ function pathMatches(path, requestedPaths) {
107
108
  return true;
108
109
  return requestedPaths.some(filter => path === filter || path.startsWith(`${filter}/`));
109
110
  }
111
+ /** The read-only git arguments for the scope's unified diff. Exported so the
112
+ * v0.3.28 runner captures the exact same diff the snapshot froze instead of
113
+ * re-deriving OIDs after admission. */
114
+ function reviewUnifiedDiffArgs(selector, oids) {
115
+ return selector.kind === 'base'
116
+ ? ['diff', '--no-ext-diff', '--no-textconv', oids.baseOid, oids.headOid]
117
+ : ['diff', '--no-ext-diff', '--no-textconv', 'HEAD'];
118
+ }
110
119
  /** Resolves the selector to a frozen snapshot. Every OID and digest in the
111
120
  * returned object was taken at admission time; callers persist the returned
112
121
  * digest as the review's input identity. */
@@ -138,7 +147,7 @@ async function captureReviewSnapshot(options) {
138
147
  baseOid,
139
148
  headOid,
140
149
  ];
141
- unifiedArgs = ['diff', '--no-ext-diff', '--no-textconv', baseOid, headOid];
150
+ unifiedArgs = reviewUnifiedDiffArgs(options.selector, { baseOid, headOid });
142
151
  }
143
152
  else {
144
153
  nameStatusArgs = [
@@ -150,7 +159,7 @@ async function captureReviewSnapshot(options) {
150
159
  '-z',
151
160
  'HEAD',
152
161
  ];
153
- unifiedArgs = ['diff', '--no-ext-diff', '--no-textconv', 'HEAD'];
162
+ unifiedArgs = reviewUnifiedDiffArgs(options.selector, { baseOid, headOid });
154
163
  }
155
164
  const nameStatusRaw = await runGit(nameStatusArgs);
156
165
  const changed = parseNameStatusZ(nameStatusRaw.split('\0')).filter(entry => {
@@ -0,0 +1,68 @@
1
+ import { type CodeReviewResultV1, type ReviewRunStateV1, type ReviewSelectorV1 } from '../services/code-review/contract';
2
+ import { runCodeReview, type ReviewModelAdapterV1 } from '../services/code-review/runner';
3
+ export interface WebCodeReviewRunRecordV1 {
4
+ readonly runId: string;
5
+ readonly selector: ReviewSelectorV1;
6
+ readonly status: ReviewRunStateV1 | 'running';
7
+ readonly startedAt: number;
8
+ readonly endedAt: number | null;
9
+ /** Repository revision captured at admission; compared against the current
10
+ * value on read to mark a stored receipt stale. */
11
+ readonly repositoryRevisionAtAdmission: string;
12
+ /** fileKey -> path mapping captured with the frozen snapshot, so findings
13
+ * can be anchored to a diff without changing the receipt contract. */
14
+ readonly files: ReadonlyArray<{
15
+ readonly fileKey: string;
16
+ readonly path: string;
17
+ }>;
18
+ /** Present only for finished runs whose receipt passed digest re-verification. */
19
+ readonly result: CodeReviewResultV1 | null;
20
+ readonly failure: {
21
+ readonly kind: string;
22
+ readonly message: string;
23
+ } | null;
24
+ /** Recomputed on read: true when the repository changed since admission. */
25
+ readonly stale: boolean;
26
+ }
27
+ export interface WebCodeReviewRunServiceOptionsV1 {
28
+ /** Returns the review model adapter, or null when the Host has no model. */
29
+ readonly adapterProvider: () => ReviewModelAdapterV1 | null;
30
+ /** Workspace root used for the review snapshot; read at start time. */
31
+ readonly cwdProvider: () => string;
32
+ /** Current repository revision, sampled on every read for staleness. */
33
+ readonly repositoryRevisionProvider: () => Promise<string>;
34
+ /** Injectable runner (tests use fakes); defaults to the shared runner. */
35
+ readonly runReview?: typeof runCodeReview;
36
+ readonly now?: () => number;
37
+ /** Bounded receipt history; oldest finished records are evicted first. */
38
+ readonly maxRuns?: number;
39
+ }
40
+ export declare class WebCodeReviewRunServiceV1 {
41
+ private readonly records;
42
+ private readonly inflight;
43
+ private readonly adapterProvider;
44
+ private readonly cwdProvider;
45
+ private readonly repositoryRevisionProvider;
46
+ private readonly runReview;
47
+ private readonly now;
48
+ private readonly maxRuns;
49
+ constructor(options: WebCodeReviewRunServiceOptionsV1);
50
+ /** Admission is synchronous and fast: validate, record, return. The model
51
+ * completion continues in the background. */
52
+ start(selector: ReviewSelectorV1): Promise<{
53
+ readonly runId: string;
54
+ readonly status: 'running';
55
+ }>;
56
+ /** Cancels a running review; finished or unknown runs are a no-op. */
57
+ cancel(runId: string): void;
58
+ list(): Promise<readonly WebCodeReviewRunRecordV1[]>;
59
+ get(runId: string): Promise<WebCodeReviewRunRecordV1>;
60
+ private execute;
61
+ private evictFinished;
62
+ private present;
63
+ }
64
+ /** v0.3.29 — validates the JSON selector accepted by `POST /review/run`.
65
+ * Mirrors the CLI selector contract: worktree scope, base ref scope, optional
66
+ * path filters. Unknown shapes are typed 400s, never guesses. */
67
+ export declare function parseWebReviewSelectorV1(value: unknown): ReviewSelectorV1;
68
+ //# sourceMappingURL=code-review-run-service.d.ts.map