@orion-agents/orion-code 0.3.25 → 0.3.27

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.
@@ -0,0 +1,184 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FINDING_TEXT_LIMITS = exports.DEFAULT_REVIEW_BUDGETS = void 0;
4
+ exports.validateReviewFinding = validateReviewFinding;
5
+ exports.resolveReviewConclusion = resolveReviewConclusion;
6
+ exports.resolveReviewBudgets = resolveReviewBudgets;
7
+ exports.reviewApplicability = reviewApplicability;
8
+ exports.computeReceiptDigest = computeReceiptDigest;
9
+ exports.verifyReceiptDigest = verifyReceiptDigest;
10
+ /**
11
+ * v0.3.27 PR0 — the shared code-review contract, frozen before any runtime
12
+ * wiring. This module is the single source of truth for review selectors,
13
+ * states, conclusions, findings, budgets and receipt digests; CLI and Web both
14
+ * consume it and neither may redefine the semantics locally (goal G3: one
15
+ * engine, one contract).
16
+ *
17
+ * Design sources: docs/plan/v0.3.27-plan.md §4 (identity, state, conclusions),
18
+ * §4.3 (bounded resources), §5 (read-only execution). The conclusion mapping
19
+ * encodes the plan's rule that tool success is not review success: a partial,
20
+ * failed, cancelled or untrustworthy output can never surface as a green
21
+ * "no findings".
22
+ */
23
+ const canonical_1 = require("../../runtime/protocol/canonical");
24
+ exports.DEFAULT_REVIEW_BUDGETS = Object.freeze({
25
+ maxFiles: 50,
26
+ maxBodyBytes: 2 * 1024 * 1024,
27
+ maxDiffBytes: 1024 * 1024,
28
+ maxReadToolCalls: 50,
29
+ maxDurationMs: 5 * 60 * 1000,
30
+ maxFindings: 100,
31
+ maxResultBytes: 256 * 1024,
32
+ });
33
+ /** Text size caps for finding fields, in characters. Chosen so 100 maximal
34
+ * findings stay well inside the 256 KiB result bound. */
35
+ exports.FINDING_TEXT_LIMITS = Object.freeze({
36
+ title: 200,
37
+ trigger: 2000,
38
+ impact: 2000,
39
+ evidence: 4000,
40
+ fileKey: 1024,
41
+ });
42
+ const FINDING_ID_PATTERN = /^[0-9a-f]{8,64}$/iu;
43
+ const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
44
+ /** Validates one model-produced finding against the frozen shape. Returns the
45
+ * finding unchanged when valid; a typed reason when not. Membership against
46
+ * the snapshot manifest is a separate Host check (PR1+). */
47
+ function validateReviewFinding(value) {
48
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
49
+ return { valid: false, reason: 'not_an_object' };
50
+ }
51
+ const candidate = value;
52
+ const text = (field) => typeof candidate[field] === 'string' ? candidate[field] : '';
53
+ if (typeof candidate.findingId !== 'string' || !FINDING_ID_PATTERN.test(candidate.findingId)) {
54
+ return { valid: false, reason: 'finding_id_invalid' };
55
+ }
56
+ if (candidate.priority !== 'P0' &&
57
+ candidate.priority !== 'P1' &&
58
+ candidate.priority !== 'P2' &&
59
+ candidate.priority !== 'P3') {
60
+ return { valid: false, reason: 'priority_invalid' };
61
+ }
62
+ const title = text('title').trim();
63
+ if (!title || title.length > exports.FINDING_TEXT_LIMITS.title) {
64
+ return { valid: false, reason: 'title_invalid' };
65
+ }
66
+ const trigger = text('trigger').trim();
67
+ if (!trigger || trigger.length > exports.FINDING_TEXT_LIMITS.trigger) {
68
+ return { valid: false, reason: 'trigger_invalid' };
69
+ }
70
+ const impact = text('impact').trim();
71
+ if (!impact || impact.length > exports.FINDING_TEXT_LIMITS.impact) {
72
+ return { valid: false, reason: 'impact_invalid' };
73
+ }
74
+ const fileKey = text('fileKey');
75
+ if (!fileKey ||
76
+ fileKey.length > exports.FINDING_TEXT_LIMITS.fileKey ||
77
+ fileKey.startsWith('/') ||
78
+ fileKey.includes('\0') ||
79
+ fileKey.split('/').includes('..')) {
80
+ return { valid: false, reason: 'file_key_invalid' };
81
+ }
82
+ if (candidate.side !== 'old' && candidate.side !== 'new') {
83
+ return { valid: false, reason: 'side_invalid' };
84
+ }
85
+ const startLine = candidate.startLine;
86
+ const endLine = candidate.endLine;
87
+ if (!Number.isSafeInteger(startLine) ||
88
+ startLine < 1 ||
89
+ !Number.isSafeInteger(endLine) ||
90
+ endLine < startLine) {
91
+ return { valid: false, reason: 'line_range_invalid' };
92
+ }
93
+ const evidence = text('evidence').trim();
94
+ if (!evidence || evidence.length > exports.FINDING_TEXT_LIMITS.evidence) {
95
+ return { valid: false, reason: 'evidence_invalid' };
96
+ }
97
+ return {
98
+ valid: true,
99
+ finding: Object.freeze({
100
+ findingId: candidate.findingId,
101
+ priority: candidate.priority,
102
+ title,
103
+ trigger,
104
+ impact,
105
+ fileKey,
106
+ side: candidate.side,
107
+ startLine: startLine,
108
+ endLine: endLine,
109
+ evidence,
110
+ }),
111
+ };
112
+ }
113
+ /**
114
+ * v0.3.27 §4.1 — the conclusion mapping, frozen as code so CLI and Web cannot
115
+ * diverge. The invariants:
116
+ * - `queued`/`running` results carry no conclusion (callers must not call this).
117
+ * - A state that is not `completed` can never conclude `no_findings`.
118
+ * - Invalid findings make the output untrustworthy: zero valid findings with
119
+ * invalid ones present is `inconclusive`, never a green pass.
120
+ * - Zero findings without complete coverage is `inconclusive`, never a green
121
+ * pass ("不静默截断后宣布全部完成").
122
+ */
123
+ function resolveReviewConclusion(input) {
124
+ if (input.state === 'queued' || input.state === 'running') {
125
+ throw new Error('A queued or running review cannot have a terminal conclusion.');
126
+ }
127
+ if (input.state === 'failed' || input.state === 'cancelled') {
128
+ return 'inconclusive';
129
+ }
130
+ if (input.validFindingCount > 0) {
131
+ return 'findings';
132
+ }
133
+ if (input.state === 'partial') {
134
+ return 'inconclusive';
135
+ }
136
+ // state === 'completed'
137
+ if (input.invalidFindingCount > 0) {
138
+ return 'inconclusive';
139
+ }
140
+ return input.coverageComplete ? 'no_findings' : 'inconclusive';
141
+ }
142
+ /** Applies the budgets with the tighten-only rule: every override must be a
143
+ * safe integer at or below the frozen default, otherwise the run is refused
144
+ * rather than silently widened. */
145
+ function resolveReviewBudgets(overrides = {}) {
146
+ const resolved = {};
147
+ for (const [field, ceiling] of Object.entries(exports.DEFAULT_REVIEW_BUDGETS)) {
148
+ const requested = overrides[field];
149
+ if (requested === undefined) {
150
+ resolved[field] = ceiling;
151
+ continue;
152
+ }
153
+ if (!Number.isSafeInteger(requested) || requested < 1 || requested > ceiling) {
154
+ return { ok: false, field };
155
+ }
156
+ resolved[field] = requested;
157
+ }
158
+ return {
159
+ ok: true,
160
+ budgets: Object.freeze(resolved),
161
+ };
162
+ }
163
+ /** Marks a stored result's applicability against the current input snapshot and
164
+ * rules digests. The receipt itself is never rewritten. */
165
+ function reviewApplicability(result, current) {
166
+ return result.inputSnapshotDigest === current.inputSnapshotDigest &&
167
+ result.rulesDigest === current.rulesDigest
168
+ ? 'current'
169
+ : 'stale';
170
+ }
171
+ /** The receipt digest covers the canonical form of the result with the digest
172
+ * field itself excluded, so verification is a recomputation, not a lookup. */
173
+ function computeReceiptDigest(result) {
174
+ const content = { ...result };
175
+ delete content.receiptDigest;
176
+ const digest = (0, canonical_1.digestRuntimeValue)(content);
177
+ return DIGEST_PATTERN.test(digest) ? digest : digest.toLowerCase();
178
+ }
179
+ /** Verifies a stored receipt by recomputing its digest. */
180
+ function verifyReceiptDigest(result) {
181
+ const { receiptDigest, ...content } = result;
182
+ return computeReceiptDigest(content) === receiptDigest;
183
+ }
184
+ //# sourceMappingURL=contract.js.map
@@ -0,0 +1,64 @@
1
+ import { type ReviewBudgetsV1, type ReviewSelectorV1 } from './contract';
2
+ export interface ReviewSnapshotFileSideV1 {
3
+ readonly source: 'staged' | 'unstaged' | 'base' | 'head';
4
+ readonly oid: string | null;
5
+ readonly contentDigest: string;
6
+ readonly byteSize: number;
7
+ readonly truncated: boolean;
8
+ }
9
+ export interface ReviewSnapshotHunkV1 {
10
+ readonly oldStart: number;
11
+ readonly oldLines: number;
12
+ readonly newStart: number;
13
+ readonly newLines: number;
14
+ readonly header: string;
15
+ }
16
+ export interface ReviewSnapshotFileV1 {
17
+ readonly fileKey: string;
18
+ readonly path: string;
19
+ readonly oldPath: string | null;
20
+ readonly sensitive: boolean;
21
+ readonly sides: readonly ReviewSnapshotFileSideV1[];
22
+ readonly hunks: readonly ReviewSnapshotHunkV1[];
23
+ }
24
+ export interface ReviewSnapshotSkipV1 {
25
+ readonly path: string;
26
+ readonly reason: 'over_budget_files' | 'over_budget_bytes' | 'binary' | 'sensitive' | 'unreadable' | 'changed_during_review' | 'other';
27
+ readonly detail?: string;
28
+ }
29
+ export interface CodeReviewSnapshotV1 {
30
+ readonly schemaVersion: 1;
31
+ readonly snapshotId: string;
32
+ readonly kind: 'worktree' | 'base';
33
+ readonly baseOid: string | null;
34
+ readonly headOid: string | null;
35
+ readonly requestedPaths: readonly string[];
36
+ readonly files: readonly ReviewSnapshotFileV1[];
37
+ readonly inputSnapshotDigest: string;
38
+ readonly skipped: readonly ReviewSnapshotSkipV1[];
39
+ }
40
+ export declare class ReviewSnapshotError extends Error {
41
+ readonly code: 'not_a_repository' | 'ref_unresolved' | 'no_merge_base' | 'path_invalid' | 'worktree_changed';
42
+ constructor(message: string, code: 'not_a_repository' | 'ref_unresolved' | 'no_merge_base' | 'path_invalid' | 'worktree_changed');
43
+ }
44
+ /** Runs a read-only git command with argument arrays. The caller injects this,
45
+ * so tests drive real temporary repositories without spawning beyond git. */
46
+ export type ReviewGitRunner = (args: readonly string[]) => Promise<string>;
47
+ export interface ReviewSnapshotOptionsV1 {
48
+ readonly root: string;
49
+ readonly selector: ReviewSelectorV1;
50
+ readonly budgets?: Partial<ReviewBudgetsV1>;
51
+ readonly runGit: ReviewGitRunner;
52
+ /** Reads one working-tree file relative to the root, or null when missing. */
53
+ readonly readWorktreeFile?: (relativePath: string) => Promise<string | null>;
54
+ readonly snapshotIdPrefix?: string;
55
+ }
56
+ /** Resolves the selector to a frozen snapshot. Every OID and digest in the
57
+ * returned object was taken at admission time; callers persist the returned
58
+ * digest as the review's input identity. */
59
+ export declare function captureReviewSnapshot(options: ReviewSnapshotOptionsV1): Promise<CodeReviewSnapshotV1>;
60
+ /** Reads one working-tree file for review purposes, refusing to leave the
61
+ * repository root (same containment discipline as the v0.3.25 conflict
62
+ * reader). Returns null when the file is missing or unreadable. */
63
+ export declare function createContainedWorktreeReader(rootInput: string): (relativePath: string) => Promise<string | null>;
64
+ //# sourceMappingURL=snapshot.d.ts.map
@@ -0,0 +1,321 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ReviewSnapshotError = void 0;
4
+ exports.captureReviewSnapshot = captureReviewSnapshot;
5
+ exports.createContainedWorktreeReader = createContainedWorktreeReader;
6
+ /**
7
+ * v0.3.27 PR1 — the minimal shared Git snapshot reader for code review
8
+ * (plan §4.2). One reader, two scopes:
9
+ *
10
+ * - `worktree`: staged, unstaged and untracked changes are recorded as
11
+ * separate sources for the same path and are never merged into one
12
+ * composite diff.
13
+ * - `base`: the ref, HEAD and merge-base are resolved to immutable OIDs at
14
+ * admission; later ref movement cannot change the frozen input.
15
+ *
16
+ * Security posture (§4.2.7): git runs with argument arrays through the read
17
+ * profile, never a shell; external diff drivers and textconv are disabled;
18
+ * blobs are read by OID; untracked working files are read directly under the
19
+ * same sensitive/binary/boundary policies as everything else. No optional
20
+ * locks, no arbitrary subcommands exposed to the model.
21
+ */
22
+ const crypto_1 = require("crypto");
23
+ const fs_1 = require("fs");
24
+ const promises_1 = require("fs/promises");
25
+ const path_1 = require("path");
26
+ const redaction_1 = require("../redaction");
27
+ const contract_1 = require("./contract");
28
+ class ReviewSnapshotError extends Error {
29
+ constructor(message, code) {
30
+ super(message);
31
+ this.code = code;
32
+ this.name = 'ReviewSnapshotError';
33
+ }
34
+ }
35
+ exports.ReviewSnapshotError = ReviewSnapshotError;
36
+ function sha256(value) {
37
+ return (0, crypto_1.createHash)('sha256').update(value, 'utf8').digest('hex');
38
+ }
39
+ function assertSafePath(value) {
40
+ if (!value ||
41
+ value.length > 1024 ||
42
+ value.startsWith('/') ||
43
+ value.includes('\0') ||
44
+ value.split('/').includes('..')) {
45
+ throw new ReviewSnapshotError(`Unsafe review path: ${value}`, 'path_invalid');
46
+ }
47
+ return value;
48
+ }
49
+ /** Parses `git diff --name-status -z` records into status/path groups. */
50
+ function parseNameStatusZ(records) {
51
+ const parsed = [];
52
+ let index = 0;
53
+ while (index < records.length) {
54
+ const record = records[index];
55
+ if (!record) {
56
+ index += 1;
57
+ continue;
58
+ }
59
+ const status = record.slice(0, 1);
60
+ const paths = [];
61
+ if (status === 'R' || status === 'C') {
62
+ paths.push(records[index + 1] ?? '', records[index + 2] ?? '');
63
+ index += 3;
64
+ }
65
+ else {
66
+ paths.push(records[index + 1] ?? '');
67
+ index += 2;
68
+ }
69
+ parsed.push({ status, paths });
70
+ }
71
+ return parsed;
72
+ }
73
+ function parseDiffHunks(diffText) {
74
+ const files = [];
75
+ let current;
76
+ const HUNK = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/;
77
+ for (const line of diffText.split('\n')) {
78
+ if (line.startsWith('diff --git ')) {
79
+ const rest = line.slice('diff --git '.length);
80
+ const split = rest.indexOf(' b/');
81
+ current = {
82
+ path: split >= 0 ? rest.slice(split + 3) : rest,
83
+ hunks: [],
84
+ diffBytes: 0,
85
+ };
86
+ files.push(current);
87
+ continue;
88
+ }
89
+ if (!current)
90
+ continue;
91
+ current.diffBytes += line.length + 1;
92
+ const header = HUNK.exec(line);
93
+ if (header) {
94
+ current.hunks.push({
95
+ oldStart: Number(header[1]),
96
+ oldLines: header[2] === undefined ? 1 : Number(header[2]),
97
+ newStart: Number(header[3]),
98
+ newLines: header[4] === undefined ? 1 : Number(header[4]),
99
+ header: line,
100
+ });
101
+ }
102
+ }
103
+ return files;
104
+ }
105
+ function pathMatches(path, requestedPaths) {
106
+ if (requestedPaths.length === 0)
107
+ return true;
108
+ return requestedPaths.some(filter => path === filter || path.startsWith(`${filter}/`));
109
+ }
110
+ /** Resolves the selector to a frozen snapshot. Every OID and digest in the
111
+ * returned object was taken at admission time; callers persist the returned
112
+ * digest as the review's input identity. */
113
+ async function captureReviewSnapshot(options) {
114
+ const budgets = { ...contract_1.DEFAULT_REVIEW_BUDGETS, ...options.budgets };
115
+ const { runGit } = options;
116
+ const requestedPaths = (options.selector.paths ?? []).map(assertSafePath);
117
+ let baseOid = null;
118
+ let headOid = null;
119
+ let nameStatusArgs;
120
+ let unifiedArgs;
121
+ if (options.selector.kind === 'base') {
122
+ headOid = (await runGit(['rev-parse', '--verify', 'HEAD'])).trim();
123
+ const refOid = (await runGit(['rev-parse', '--verify', `${options.selector.ref}^{commit}`]).catch(() => '')).trim();
124
+ if (!refOid) {
125
+ throw new ReviewSnapshotError(`Review base ref could not be resolved: ${options.selector.ref}`, 'ref_unresolved');
126
+ }
127
+ baseOid = (await runGit(['merge-base', refOid, headOid]).catch(() => '')).trim();
128
+ if (!baseOid) {
129
+ throw new ReviewSnapshotError(`No merge base between ${options.selector.ref} and HEAD`, 'no_merge_base');
130
+ }
131
+ nameStatusArgs = [
132
+ 'diff',
133
+ '--no-ext-diff',
134
+ '--no-textconv',
135
+ '--find-renames',
136
+ '--name-status',
137
+ '-z',
138
+ baseOid,
139
+ headOid,
140
+ ];
141
+ unifiedArgs = ['diff', '--no-ext-diff', '--no-textconv', baseOid, headOid];
142
+ }
143
+ else {
144
+ nameStatusArgs = [
145
+ 'diff',
146
+ '--no-ext-diff',
147
+ '--no-textconv',
148
+ '--find-renames',
149
+ '--name-status',
150
+ '-z',
151
+ 'HEAD',
152
+ ];
153
+ unifiedArgs = ['diff', '--no-ext-diff', '--no-textconv', 'HEAD'];
154
+ }
155
+ const nameStatusRaw = await runGit(nameStatusArgs);
156
+ const changed = parseNameStatusZ(nameStatusRaw.split('\0')).filter(entry => {
157
+ const path = entry.paths.at(-1);
158
+ return Boolean(path) && pathMatches(path, requestedPaths);
159
+ });
160
+ const files = [];
161
+ const skipped = [];
162
+ const seenPaths = new Set();
163
+ for (const entry of changed) {
164
+ const path = assertSafePath(entry.paths.at(-1));
165
+ if (seenPaths.has(path))
166
+ continue;
167
+ if (files.length >= budgets.maxFiles) {
168
+ skipped.push({ path, reason: 'over_budget_files' });
169
+ continue;
170
+ }
171
+ seenPaths.add(path);
172
+ const oldPath = entry.status === 'R' || entry.status === 'C' ? (entry.paths[0] ?? null) : null;
173
+ const sensitive = (0, redaction_1.isSensitiveFilePath)(path) || (oldPath ? (0, redaction_1.isSensitiveFilePath)(oldPath) : false);
174
+ if (sensitive) {
175
+ skipped.push({ path, reason: 'sensitive' });
176
+ continue;
177
+ }
178
+ if (entry.status === 'B') {
179
+ skipped.push({ path, reason: 'binary' });
180
+ continue;
181
+ }
182
+ files.push({
183
+ fileKey: sha256(`${entry.status}:${oldPath ?? ''}:${path}`).slice(0, 16),
184
+ path,
185
+ oldPath,
186
+ sensitive: false,
187
+ sides: [],
188
+ hunks: [],
189
+ });
190
+ }
191
+ const parsedFiles = parseDiffHunks(await runGit(unifiedArgs));
192
+ for (const file of files) {
193
+ const match = parsedFiles.find(candidate => candidate.path === file.path);
194
+ if (match)
195
+ file.hunks.push(...match.hunks);
196
+ }
197
+ for (const file of files) {
198
+ const totalDiff = file.hunks.reduce((sum, hunk) => sum + hunk.oldLines + hunk.newLines, 0);
199
+ if (totalDiff * 128 > budgets.maxDiffBytes) {
200
+ skipped.push({ path: file.path, reason: 'over_budget_bytes' });
201
+ continue;
202
+ }
203
+ if (options.selector.kind === 'base') {
204
+ const headOidForPath = (await runGit(['rev-parse', '--verify', `${headOid}:${file.path}`]).catch(() => '')).trim();
205
+ const baseOidForPath = (await runGit([
206
+ 'rev-parse',
207
+ '--verify',
208
+ `${baseOid}:${file.oldPath ?? file.path}`,
209
+ ]).catch(() => '')).trim();
210
+ if (headOidForPath) {
211
+ file.sides.push({
212
+ source: 'head',
213
+ oid: headOidForPath,
214
+ contentDigest: headOidForPath,
215
+ byteSize: Number((await runGit(['cat-file', '-s', headOidForPath])).trim()) || 0,
216
+ truncated: false,
217
+ });
218
+ }
219
+ if (baseOidForPath) {
220
+ file.sides.push({
221
+ source: 'base',
222
+ oid: baseOidForPath,
223
+ contentDigest: baseOidForPath,
224
+ byteSize: Number((await runGit(['cat-file', '-s', baseOidForPath])).trim()) || 0,
225
+ truncated: false,
226
+ });
227
+ }
228
+ }
229
+ else {
230
+ const indexOid = (await runGit(['ls-files', '-s', '-z', '--', file.path]).then(raw => {
231
+ const record = raw.split('\0').find(item => item.length > 0);
232
+ return record ? (record.split(' ')[1] ?? '') : '';
233
+ })).trim();
234
+ if (indexOid) {
235
+ file.sides.push({
236
+ source: 'staged',
237
+ oid: indexOid,
238
+ contentDigest: indexOid,
239
+ byteSize: Number((await runGit(['cat-file', '-s', indexOid])).trim()) || 0,
240
+ truncated: false,
241
+ });
242
+ }
243
+ if (options.readWorktreeFile) {
244
+ const before = await options.readWorktreeFile(file.path);
245
+ if (before === null) {
246
+ file.sides.push({
247
+ source: 'unstaged',
248
+ oid: null,
249
+ contentDigest: sha256(''),
250
+ byteSize: 0,
251
+ truncated: false,
252
+ });
253
+ }
254
+ else {
255
+ const beforeDigest = sha256(before);
256
+ const after = await options.readWorktreeFile(file.path);
257
+ if (after === null || sha256(after) !== beforeDigest) {
258
+ throw new ReviewSnapshotError(`Working file changed while snapshotting: ${file.path}`, 'worktree_changed');
259
+ }
260
+ file.sides.push({
261
+ source: 'unstaged',
262
+ oid: null,
263
+ contentDigest: beforeDigest,
264
+ byteSize: Buffer.byteLength(before, 'utf8'),
265
+ truncated: before.length > budgets.maxBodyBytes,
266
+ });
267
+ }
268
+ }
269
+ }
270
+ }
271
+ const manifest = {
272
+ kind: options.selector.kind,
273
+ baseOid,
274
+ headOid,
275
+ requestedPaths,
276
+ files: files.map(file => ({
277
+ fileKey: file.fileKey,
278
+ path: file.path,
279
+ oldPath: file.oldPath,
280
+ sides: file.sides.map(side => ({ source: side.source, contentDigest: side.contentDigest })),
281
+ })),
282
+ };
283
+ const inputSnapshotDigest = sha256(JSON.stringify(manifest));
284
+ const snapshotId = `${options.snapshotIdPrefix ?? 'rev'}-${inputSnapshotDigest.slice(0, 16)}`;
285
+ return Object.freeze({
286
+ schemaVersion: 1,
287
+ snapshotId,
288
+ kind: options.selector.kind,
289
+ baseOid,
290
+ headOid,
291
+ requestedPaths,
292
+ files: Object.freeze(files.map(file => Object.freeze({
293
+ ...file,
294
+ sides: Object.freeze(file.sides),
295
+ hunks: Object.freeze(file.hunks),
296
+ }))),
297
+ inputSnapshotDigest,
298
+ skipped: Object.freeze(skipped),
299
+ });
300
+ }
301
+ /** Reads one working-tree file for review purposes, refusing to leave the
302
+ * repository root (same containment discipline as the v0.3.25 conflict
303
+ * reader). Returns null when the file is missing or unreadable. */
304
+ function createContainedWorktreeReader(rootInput) {
305
+ const root = (0, fs_1.realpathSync)(rootInput);
306
+ return async (relativePath) => {
307
+ try {
308
+ const absolute = (0, path_1.resolve)(root, relativePath);
309
+ const fileReal = (0, fs_1.realpathSync)(absolute);
310
+ if (fileReal !== root && !fileReal.startsWith(root + path_1.sep))
311
+ return null;
312
+ if (!(0, fs_1.statSync)(fileReal).isFile())
313
+ return null;
314
+ return await (0, promises_1.readFile)(fileReal, 'utf8');
315
+ }
316
+ catch {
317
+ return null;
318
+ }
319
+ };
320
+ }
321
+ //# sourceMappingURL=snapshot.js.map
@@ -1858,6 +1858,21 @@ function updateSessionSummary(sessionId, _messages) {
1858
1858
  // Callers may hold a stale transcript snapshot. Re-read under the same lock
1859
1859
  // used by append/delete so reconciliation can never roll metadata backwards.
1860
1860
  withLockedSession(sessionId, session => {
1861
+ // v0.3.26 #277 — a cutover Session's summary comes from the authoritative
1862
+ // v2 Thread. Its legacy JSONL is frozen at cutover, so summarizing from it
1863
+ // (as /exit and the shutdown path do) would move messageCount and
1864
+ // historySizeBytes backwards while the Thread history is intact. Reopening
1865
+ // the Session repairs the read model from v2; this makes the exit path
1866
+ // agree instead of regressing it.
1867
+ const threadView = (0, thread_session_view_1.loadThreadSessionViewV1)(session.projectPath, session.id);
1868
+ if (threadView) {
1869
+ session.messageCount = threadView.messageCount;
1870
+ session.historySizeBytes = threadView.historySizeBytes;
1871
+ session.updatedAt = threadView.updatedAt;
1872
+ session.updatedAtIso = new Date(session.updatedAt).toISOString();
1873
+ writeSessionMetaUnlocked(session);
1874
+ return;
1875
+ }
1861
1876
  const summary = summarizeSessionMessages(readSessionMessagesForSession(session));
1862
1877
  session.toolsUsed = summary.toolsUsed;
1863
1878
  session.filesModified = summary.filesModified;
@@ -2553,6 +2568,12 @@ function deleteSession(sessionId) {
2553
2568
  updateSessionCatalog(catalog => {
2554
2569
  delete catalog.sessions[sessionId];
2555
2570
  });
2571
+ // v0.3.26 #276 — a cutover Session's v2 records are the authoritative
2572
+ // history; permanent deletion must remove them with the legacy files.
2573
+ // The Web deletion dialog promises exactly that.
2574
+ if ((0, legacy_thread_materializer_1.deleteSessionThreadRecordsV1)(session.projectPath, sessionId)) {
2575
+ deleted = true;
2576
+ }
2556
2577
  return deleted;
2557
2578
  }) ?? false);
2558
2579
  }
@@ -41,6 +41,18 @@ export interface GitHunkV2 {
41
41
  readonly complete: boolean;
42
42
  }
43
43
  export type GitDiffDocumentKindV2 = 'text' | 'binary' | 'symlink' | 'submodule' | 'metadata';
44
+ /**
45
+ * v0.3.26 #275 — parser state for a hunk that a page boundary split. Carried in
46
+ * the continuation cursor so the next page re-opens the hunk with its stable
47
+ * identity (`index:oldStart:newStart`) and correct running line cursors instead
48
+ * of demoting the continuation rows to document meta.
49
+ */
50
+ export interface GitDiffHunkResumeV1 {
51
+ readonly header: string;
52
+ readonly index: number;
53
+ readonly oldCursor: number;
54
+ readonly newCursor: number;
55
+ }
44
56
  export interface GitDiffCapabilitiesV2 {
45
57
  readonly stageFile: boolean;
46
58
  readonly unstageFile: boolean;
@@ -63,6 +75,11 @@ export interface GitDiffDocumentV2 {
63
75
  readonly hunks: readonly GitHunkV2[];
64
76
  /** Metadata lines that are not part of any hunk (file headers, mode changes, …). */
65
77
  readonly meta: readonly string[];
78
+ /**
79
+ * v0.3.26 #275 — the hunk still open when this page ended, for the
80
+ * continuation cursor. Null when the page ends on a hunk boundary.
81
+ */
82
+ readonly pendingHunk: GitDiffHunkResumeV1 | null;
66
83
  readonly completeness: 'complete' | 'paged' | 'limited';
67
84
  readonly additions: number;
68
85
  readonly deletions: number;
@@ -79,5 +96,7 @@ export declare function parseGitDiffDocument(input: {
79
96
  readonly hasMore: boolean;
80
97
  /** Host-signed continuation cursor, or null. Never synthesised here. */
81
98
  readonly nextCursor: string | null;
99
+ /** v0.3.26 #275 — hunk state from the previous page's `pendingHunk`. */
100
+ readonly resume?: GitDiffHunkResumeV1 | null;
82
101
  }): GitDiffDocumentV2;
83
102
  //# sourceMappingURL=git-diff-document.d.ts.map