sphinx-understand 0.0.0-stage → 0.1.1
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/.sphinxconfig.example +10 -0
- package/.sphinxignore.example +22 -0
- package/CONTRIBUTING.md +19 -0
- package/LICENSE +21 -0
- package/README.md +186 -2
- package/bin/sphinx.js +9 -0
- package/docs/architecture.md +212 -0
- package/docs/configuration.md +241 -0
- package/docs/integrations.md +135 -0
- package/docs/logo.svg +3 -0
- package/eval/README.md +38 -0
- package/eval/RESULTS.md +205 -0
- package/eval/fixtures/known-false-negatives.json +115 -0
- package/examples/browser-demo.mjs +28 -0
- package/examples/config.json +59 -0
- package/package.json +50 -4
- package/src/activation-worker.js +10 -0
- package/src/activation.js +187 -0
- package/src/agent-settings.js +206 -0
- package/src/agent.js +45 -0
- package/src/browser-interview.js +344 -0
- package/src/browser-page.js +427 -0
- package/src/change-view.js +314 -0
- package/src/check-recovery.js +92 -0
- package/src/checkbox.js +148 -0
- package/src/cli.js +563 -0
- package/src/config.js +248 -0
- package/src/cursor-hook.js +18 -0
- package/src/evaluator.js +501 -0
- package/src/git-gate.js +116 -0
- package/src/git-interview.js +51 -0
- package/src/git.js +252 -0
- package/src/help.js +122 -0
- package/src/ignore.js +94 -0
- package/src/install.js +448 -0
- package/src/lifecycle-integrations.js +259 -0
- package/src/logo.js +6 -0
- package/src/mcp.js +352 -0
- package/src/model-presets.js +79 -0
- package/src/native-check.js +166 -0
- package/src/openrouter.js +269 -0
- package/src/overrides.js +68 -0
- package/src/pi-integration.js +179 -0
- package/src/pi-runtime.js +128 -0
- package/src/preparation.js +254 -0
- package/src/presentations.js +74 -0
- package/src/prompts.js +181 -0
- package/src/repo-config.js +72 -0
- package/src/repo-context.js +43 -0
- package/src/review-workspace.js +334 -0
- package/src/reviewer-limits.js +10 -0
- package/src/reviewer-response.js +22 -0
- package/src/reviewer.js +527 -0
- package/src/routing.js +40 -0
- package/src/sessions.js +377 -0
- package/src/stream.js +59 -0
- package/src/terminal-launch.js +288 -0
- package/src/terminal-worker.js +11 -0
- package/src/terminal.js +121 -0
- package/src/usage.js +31 -0
- package/src/version.js +4 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { basename } from 'node:path';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { loadIgnorePolicy } from './ignore.js';
|
|
6
|
+
import { workspaceDiffText } from './review-workspace.js';
|
|
7
|
+
|
|
8
|
+
const exec = promisify(execFile);
|
|
9
|
+
const OID = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u;
|
|
10
|
+
const REGULAR = new Set(['100644', '100755']);
|
|
11
|
+
// Display limits only. The reviewer's own evidence limits are unchanged.
|
|
12
|
+
export const VIEW_LIMITS = Object.freeze({ files: 2000, rows: 3000, bytes: 2 * 1024 * 1024, evidenceBytes: 8 * 1024 * 1024 });
|
|
13
|
+
export const CONTEXT_LEVELS = Object.freeze({ 3: 3, 25: 25, all: 1000000 });
|
|
14
|
+
|
|
15
|
+
class ChangeViewError extends Error {
|
|
16
|
+
constructor(message) { super(message); this.code = 'CHANGE_VIEW_UNAVAILABLE'; }
|
|
17
|
+
}
|
|
18
|
+
const unavailable = message => { throw new ChangeViewError(message); };
|
|
19
|
+
const decoder = () => new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
20
|
+
|
|
21
|
+
async function git(root, args, { env, buffer = false, maxBuffer = 16 * 1024 * 1024 } = {}) {
|
|
22
|
+
try {
|
|
23
|
+
const { stdout } = await exec('git', ['--no-pager', '--literal-pathspecs', '-c', 'core.fsmonitor=false', ...args], {
|
|
24
|
+
cwd: root, encoding: buffer ? 'buffer' : 'utf8', maxBuffer, timeout: 30000,
|
|
25
|
+
env: { ...process.env, ...env, GIT_OPTIONAL_LOCKS: '0', GIT_NO_REPLACE_OBJECTS: '1', GIT_TERMINAL_PROMPT: '0' },
|
|
26
|
+
});
|
|
27
|
+
return stdout;
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (error.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') return null;
|
|
30
|
+
unavailable('The checkpoint’s Git objects are unavailable.');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function treeEntries(raw) {
|
|
35
|
+
const entries = new Map();
|
|
36
|
+
for (const row of raw.split('\0').filter(Boolean)) {
|
|
37
|
+
const match = /^(\d{6}) (?:blob|tree|commit) ([a-f0-9]+)\t([\s\S]*)$/u.exec(row);
|
|
38
|
+
if (!match || !OID.test(match[2])) unavailable('The checkpoint inventory is malformed.');
|
|
39
|
+
entries.set(match[3], { mode: match[1], oid: match[2] });
|
|
40
|
+
}
|
|
41
|
+
return entries;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// `git diff --raw -z -M`: rename rows carry two paths.
|
|
45
|
+
function rawRows(raw) {
|
|
46
|
+
const fields = raw.split('\0'), rows = [];
|
|
47
|
+
for (let index = 0; index < fields.length - 1;) {
|
|
48
|
+
const match = /^:(\d{6}) (\d{6}) ([a-f0-9]+) ([a-f0-9]+) ([A-Z])\d*$/u.exec(fields[index++]);
|
|
49
|
+
if (!match) unavailable('The checkpoint comparison is malformed.');
|
|
50
|
+
const [, oldMode, newMode, oldOid, newOid, status] = match;
|
|
51
|
+
const first = fields[index++], second = status === 'R' || status === 'C' ? fields[index++] : undefined;
|
|
52
|
+
if (!first || (second === '' )) unavailable('The checkpoint comparison is malformed.');
|
|
53
|
+
rows.push({ status, oldMode, newMode, oldOid, newOid, oldPath: first, newPath: second ?? first });
|
|
54
|
+
}
|
|
55
|
+
return rows;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// `git diff --numstat -z -M`: renames use an empty path field followed by both paths.
|
|
59
|
+
function numstatRows(raw) {
|
|
60
|
+
const fields = raw.split('\0'), stats = new Map();
|
|
61
|
+
for (let index = 0; index < fields.length - 1;) {
|
|
62
|
+
const row = fields[index++], first = row.indexOf('\t'), second = row.indexOf('\t', first + 1);
|
|
63
|
+
if (first < 0 || second < 0) unavailable('The checkpoint line counts are malformed.');
|
|
64
|
+
const added = row.slice(0, first), deleted = row.slice(first + 1, second);
|
|
65
|
+
let path = row.slice(second + 1);
|
|
66
|
+
if (!path) { index++; path = fields[index++]; }
|
|
67
|
+
const binary = added === '-' || deleted === '-';
|
|
68
|
+
stats.set(path, binary ? { binary: true, additions: null, deletions: null } : { binary: false, additions: Number(added), deletions: Number(deleted) });
|
|
69
|
+
}
|
|
70
|
+
return stats;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const kind = mode => !mode || /^0+$/u.test(mode) ? null : REGULAR.has(mode) ? 'file' : mode === '120000' ? 'symlink' : mode === '160000' ? 'submodule' : 'other';
|
|
74
|
+
|
|
75
|
+
/** Parse unified diff text into hunks with old/new line numbers. */
|
|
76
|
+
export function parseHunks(text) {
|
|
77
|
+
const hunks = [];
|
|
78
|
+
let hunk, oldLine = 0, newLine = 0;
|
|
79
|
+
for (const line of text.split('\n')) {
|
|
80
|
+
const header = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/u.exec(line);
|
|
81
|
+
if (header) {
|
|
82
|
+
hunk = { oldStart: Number(header[1]), oldLines: header[2] === undefined ? 1 : Number(header[2]), newStart: Number(header[3]), newLines: header[4] === undefined ? 1 : Number(header[4]), section: header[5].trim(), rows: [] };
|
|
83
|
+
hunks.push(hunk); oldLine = hunk.oldStart; newLine = hunk.newStart; continue;
|
|
84
|
+
}
|
|
85
|
+
if (!hunk || !line) continue;
|
|
86
|
+
const sign = line[0], body = line.slice(1);
|
|
87
|
+
if (sign === ' ') hunk.rows.push({ type: 'context', old: oldLine++, new: newLine++, text: body });
|
|
88
|
+
else if (sign === '-') hunk.rows.push({ type: 'deletion', old: oldLine++, new: null, text: body });
|
|
89
|
+
else if (sign === '+') hunk.rows.push({ type: 'addition', old: null, new: newLine++, text: body });
|
|
90
|
+
else if (sign === '\\' && hunk.rows.length) hunk.rows.at(-1).noNewline = true;
|
|
91
|
+
}
|
|
92
|
+
return hunks;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function wholeFileHunk(text, side) {
|
|
96
|
+
const lines = text.split('\n'), noNewline = text.length > 0 && !text.endsWith('\n');
|
|
97
|
+
if (!noNewline) lines.pop();
|
|
98
|
+
if (!lines.length) return [];
|
|
99
|
+
const rows = lines.map((line, index) => side === 'new'
|
|
100
|
+
? { type: 'addition', old: null, new: index + 1, text: line }
|
|
101
|
+
: { type: 'deletion', old: index + 1, new: null, text: line });
|
|
102
|
+
if (noNewline) rows.at(-1).noNewline = true;
|
|
103
|
+
return [side === 'new'
|
|
104
|
+
? { oldStart: 0, oldLines: 0, newStart: 1, newLines: rows.length, section: '', rows }
|
|
105
|
+
: { oldStart: 1, oldLines: rows.length, newStart: 0, newLines: 0, section: '', rows }];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function mergeRanges(ranges) {
|
|
109
|
+
const merged = [];
|
|
110
|
+
for (const range of [...ranges].sort((a, b) => a.side.localeCompare(b.side) || a.start - b.start)) {
|
|
111
|
+
const last = merged.at(-1);
|
|
112
|
+
if (last && last.side === range.side && range.start <= last.end + 1) last.end = Math.max(last.end, range.end);
|
|
113
|
+
else merged.push({ ...range });
|
|
114
|
+
}
|
|
115
|
+
return merged;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A display model of exactly one frozen checkpoint comparison. Every byte comes
|
|
119
|
+
* from immutable blob IDs recorded for the checkpoint, never the working tree. */
|
|
120
|
+
export async function loadChangeView(snapshot, config, { env = process.env } = {}) {
|
|
121
|
+
const root = snapshot?.repoRoot;
|
|
122
|
+
if (typeof root !== 'string' || !Array.isArray(snapshot.files)) unavailable('This checkpoint has no recorded file inventory.');
|
|
123
|
+
if (snapshot.base && !OID.test(snapshot.base)) unavailable('The checkpoint base is not an immutable Git object.');
|
|
124
|
+
const commit = snapshot.event === 'pre-commit';
|
|
125
|
+
if (commit && !Array.isArray(snapshot.indexEntries)) unavailable('This older commit checkpoint did not record its staged file inventory.');
|
|
126
|
+
if (!commit && !OID.test(snapshot.head || '')) unavailable('The checkpoint head is not an immutable Git object.');
|
|
127
|
+
const run = (args, options = {}) => git(root, args, { ...options, env });
|
|
128
|
+
const ignore = await loadIgnorePolicy(root, { env, exclude: config.exclude });
|
|
129
|
+
if (snapshot.ignoreHash && ignore.hash !== snapshot.ignoreHash) unavailable('The exclusion rules changed after this checkpoint was created.');
|
|
130
|
+
const format = (await run(['rev-parse', '--show-object-format']))?.trim();
|
|
131
|
+
const emptyTree = createHash(format === 'sha256' ? 'sha256' : 'sha1').update('tree 0\0').digest('hex');
|
|
132
|
+
const base = snapshot.base ? treeEntries(await run(['ls-tree', '-r', '-z', '--full-tree', snapshot.base]) ?? '') : new Map();
|
|
133
|
+
const head = commit
|
|
134
|
+
? new Map(snapshot.indexEntries.map(entry => [entry.path, { mode: entry.mode, oid: entry.oid }]))
|
|
135
|
+
: treeEntries(await run(['ls-tree', '-r', '-z', '--full-tree', snapshot.head]) ?? '');
|
|
136
|
+
const comparison = ['diff', '--no-ext-diff', '--no-textconv', '--no-color', '-M', ...(commit ? ['--cached', snapshot.base || emptyTree] : [snapshot.base || emptyTree, snapshot.head])];
|
|
137
|
+
const raw = await run([...comparison, '--raw', '-z', '--no-abbrev', '--']);
|
|
138
|
+
const numstat = await run([...comparison, '--numstat', '-z', '--']);
|
|
139
|
+
if (raw === null || numstat === null) unavailable('The checkpoint comparison is too large to display.');
|
|
140
|
+
// A commit checkpoint compares against the live index. Accept it only when
|
|
141
|
+
// it still matches the pinned index entries and did not move mid-read.
|
|
142
|
+
if (commit && (await run([...comparison, '--raw', '-z', '--no-abbrev', '--'])) !== raw) unavailable('The staged changes moved while loading the checkpoint.');
|
|
143
|
+
const stats = numstatRows(numstat), files = [], seen = new Set();
|
|
144
|
+
const allowed = path => !ignore.matches(path);
|
|
145
|
+
for (const row of rawRows(raw)) {
|
|
146
|
+
const oldSide = row.status === 'A' ? null : { path: row.oldPath, mode: row.oldMode, oid: row.oldOid };
|
|
147
|
+
const newSide = row.status === 'D' ? null : { path: row.newPath, mode: row.newMode, oid: row.newOid };
|
|
148
|
+
for (const [side, tree] of [[oldSide, base], [newSide, head]]) {
|
|
149
|
+
if (side && allowed(side.path) && tree.get(side.path)?.oid !== side.oid) unavailable('The checkpoint comparison no longer matches its recorded Git objects.');
|
|
150
|
+
}
|
|
151
|
+
let from = oldSide && allowed(oldSide.path) ? oldSide : null, to = newSide && allowed(newSide.path) ? newSide : null;
|
|
152
|
+
if (!from && !to) continue;
|
|
153
|
+
// A rename across an exclusion boundary must not reveal the excluded name.
|
|
154
|
+
const split = row.status === 'R' && (!from || !to);
|
|
155
|
+
if (from) seen.add(from.path);
|
|
156
|
+
if (to) seen.add(to.path);
|
|
157
|
+
const status = from && to ? (row.status === 'R' ? 'renamed' : 'modified') : to ? 'added' : 'deleted';
|
|
158
|
+
const counted = split ? { binary: false, additions: null, deletions: null } : stats.get((to || from).path) ?? { binary: false, additions: null, deletions: null };
|
|
159
|
+
files.push({
|
|
160
|
+
path: (to || from).path, ...(status === 'renamed' ? { oldPath: from.path } : {}), status,
|
|
161
|
+
binary: counted.binary, additions: counted.additions, deletions: counted.deletions,
|
|
162
|
+
oldKind: kind(from?.mode), newKind: kind(to?.mode),
|
|
163
|
+
...(from && to && from.mode !== to.mode ? { modeChange: { from: from.mode, to: to.mode } } : {}),
|
|
164
|
+
oldOid: from?.oid ?? null, newOid: to?.oid ?? null,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
const recorded = new Set(snapshot.files);
|
|
168
|
+
if (seen.size !== recorded.size || [...seen].some(path => !recorded.has(path))) unavailable('The displayed comparison does not match the reviewed file list.');
|
|
169
|
+
files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
170
|
+
const byPath = new Map(files.map(file => [file.path, file]));
|
|
171
|
+
const totals = summarize(files);
|
|
172
|
+
const header = {
|
|
173
|
+
checkpoint: snapshot.id, repository: basename(root), event: snapshot.event,
|
|
174
|
+
remote: snapshot.target?.remote ?? null, destination: snapshot.target?.ref ?? null,
|
|
175
|
+
base: snapshot.base ?? null, head: commit ? null : snapshot.head, staged: commit,
|
|
176
|
+
excludedFiles: Array.isArray(snapshot.omitted) ? snapshot.omitted.length : 0,
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
async function blobText(oid) {
|
|
180
|
+
const body = await run(['cat-file', 'blob', oid], { buffer: true, maxBuffer: VIEW_LIMITS.bytes });
|
|
181
|
+
if (body === null) return { note: 'too-large' };
|
|
182
|
+
if (body.includes(0)) return { note: 'binary' };
|
|
183
|
+
try { return { text: decoder().decode(body) }; } catch { return { note: 'non-utf8' }; }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function diff(path, { context = 3 } = {}) {
|
|
187
|
+
const file = byPath.get(path);
|
|
188
|
+
if (!file) unavailable('That file is not part of this checkpoint.');
|
|
189
|
+
const level = Object.hasOwn(CONTEXT_LEVELS, context) ? context : 3;
|
|
190
|
+
const result = { path: file.path, ...(file.oldPath ? { oldPath: file.oldPath } : {}), status: file.status, binary: file.binary, additions: file.additions, deletions: file.deletions, ...(file.modeChange ? { modeChange: file.modeChange } : {}), context: String(level), hunks: [], truncated: null, note: null };
|
|
191
|
+
const kinds = [file.oldKind, file.newKind].filter(Boolean);
|
|
192
|
+
if (file.binary) return { ...result, note: 'binary' };
|
|
193
|
+
if (kinds.some(value => value !== 'file')) return { ...result, note: kinds.includes('submodule') ? 'submodule' : kinds.includes('symlink') ? 'symlink' : 'unsupported' };
|
|
194
|
+
let hunks;
|
|
195
|
+
if (file.oldOid && file.newOid) {
|
|
196
|
+
if (file.oldOid === file.newOid) return { ...result, note: file.status === 'renamed' ? 'rename-only' : 'mode-only' };
|
|
197
|
+
const body = await run(['diff', '--no-ext-diff', '--no-textconv', '--no-color', '--full-index', `--unified=${CONTEXT_LEVELS[level]}`, file.oldOid, file.newOid, '--'], { buffer: true, maxBuffer: VIEW_LIMITS.bytes });
|
|
198
|
+
if (body === null) return { ...result, note: 'too-large' };
|
|
199
|
+
let text;
|
|
200
|
+
try { text = decoder().decode(body); } catch { return { ...result, note: 'non-utf8' }; }
|
|
201
|
+
hunks = parseHunks(text);
|
|
202
|
+
} else {
|
|
203
|
+
const blob = await blobText(file.newOid || file.oldOid);
|
|
204
|
+
if (blob.note) return { ...result, note: blob.note };
|
|
205
|
+
hunks = wholeFileHunk(blob.text, file.newOid ? 'new' : 'old');
|
|
206
|
+
if (!hunks.length) return { ...result, note: 'empty' };
|
|
207
|
+
}
|
|
208
|
+
const total = hunks.reduce((sum, hunk) => sum + hunk.rows.length, 0);
|
|
209
|
+
if (total > VIEW_LIMITS.rows) {
|
|
210
|
+
let remaining = VIEW_LIMITS.rows;
|
|
211
|
+
hunks = hunks.filter(() => remaining > 0).map(hunk => {
|
|
212
|
+
const rows = hunk.rows.slice(0, remaining); remaining -= rows.length;
|
|
213
|
+
return { ...hunk, rows, ...(rows.length < hunk.rows.length ? { cut: true } : {}) };
|
|
214
|
+
});
|
|
215
|
+
result.truncated = { shownRows: VIEW_LIMITS.rows, totalRows: total };
|
|
216
|
+
}
|
|
217
|
+
return { ...result, hunks };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Map one retained read_diff page back to file lines, after checking that its
|
|
221
|
+
// exact bytes still occur at the recorded offset of the regenerated text.
|
|
222
|
+
const diffTexts = new Map();
|
|
223
|
+
async function workspaceText(path) {
|
|
224
|
+
if (diffTexts.has(path)) return diffTexts.get(path);
|
|
225
|
+
const chunks = []; let size = 0, text = null;
|
|
226
|
+
try {
|
|
227
|
+
for await (const chunk of workspaceDiffText(root, path, base.get(path), head.get(path), env)) {
|
|
228
|
+
size += chunk.length; if (size > VIEW_LIMITS.evidenceBytes) throw new Error('too large');
|
|
229
|
+
chunks.push(chunk);
|
|
230
|
+
}
|
|
231
|
+
text = Buffer.concat(chunks);
|
|
232
|
+
} catch { text = null; }
|
|
233
|
+
diffTexts.set(path, text); return text;
|
|
234
|
+
}
|
|
235
|
+
function diffLineMap(buffer, synthetic) {
|
|
236
|
+
const lines = [], starts = [];
|
|
237
|
+
let at = 0, oldLine = 0, newLine = 0, inHunk = synthetic, header = synthetic ? 0 : -1;
|
|
238
|
+
const text = buffer.toString('utf8').split('\n');
|
|
239
|
+
for (const line of text) {
|
|
240
|
+
starts.push(at); at += Buffer.byteLength(line) + 1;
|
|
241
|
+
let mapped = null;
|
|
242
|
+
if (line.startsWith('File modes: ') && lines.length === 0) { lines.push(null); continue; }
|
|
243
|
+
if (synthetic && header < 2) { header++; lines.push(null); if (header === 2) { oldLine = 1; newLine = 1; } continue; }
|
|
244
|
+
const hunk = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/u.exec(line);
|
|
245
|
+
if (!synthetic && hunk) { inHunk = true; oldLine = Number(hunk[1]); newLine = Number(hunk[2]); lines.push(null); continue; }
|
|
246
|
+
if (inHunk && line) {
|
|
247
|
+
if (line[0] === ' ') mapped = { side: 'new', line: newLine++, oldLine: oldLine++ };
|
|
248
|
+
else if (line[0] === '+') mapped = { side: 'new', line: newLine++ };
|
|
249
|
+
else if (line[0] === '-') mapped = { side: 'old', line: oldLine++ };
|
|
250
|
+
}
|
|
251
|
+
lines.push(mapped);
|
|
252
|
+
}
|
|
253
|
+
return { lines, starts };
|
|
254
|
+
}
|
|
255
|
+
async function diffRanges(path, observation) {
|
|
256
|
+
const result = observation.result, before = base.get(path), after = head.get(path);
|
|
257
|
+
if (result.path !== path || result.binary || typeof result.text !== 'string' || !Number.isSafeInteger(result.offset)) return [];
|
|
258
|
+
if ((result.baseOid ?? null) !== (before?.oid ?? null) || (result.headOid ?? null) !== (after?.oid ?? null)) return [];
|
|
259
|
+
const buffer = await workspaceText(path);
|
|
260
|
+
if (!buffer) return [];
|
|
261
|
+
const observed = Buffer.from(result.text);
|
|
262
|
+
if (!buffer.subarray(result.offset, result.offset + observed.length).equals(observed)) return [];
|
|
263
|
+
const { lines, starts } = diffLineMap(buffer, !(before && after));
|
|
264
|
+
const end = result.offset + observed.length, ranges = [];
|
|
265
|
+
for (let index = 0; index < lines.length; index++) {
|
|
266
|
+
const lineEnd = index + 1 < starts.length ? starts[index + 1] : buffer.length;
|
|
267
|
+
if (!lines[index] || lineEnd <= result.offset || starts[index] >= end) continue;
|
|
268
|
+
ranges.push({ side: lines[index].side, start: lines[index].line, end: lines[index].line });
|
|
269
|
+
}
|
|
270
|
+
return ranges;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Verified locations for one question: only exact observations whose blob
|
|
274
|
+
* IDs match this checkpoint become line ranges. Model text never does. */
|
|
275
|
+
async function evidenceFor(question, review) {
|
|
276
|
+
const cited = [...new Set((question?.evidence ?? []).map(item => item?.path).filter(path => typeof path === 'string' && allowed(path)))];
|
|
277
|
+
const observations = review?.snapshotId === snapshot.id && Array.isArray(review.evidence) ? review.evidence : [];
|
|
278
|
+
const refs = [];
|
|
279
|
+
for (const path of cited) {
|
|
280
|
+
const ranges = [];
|
|
281
|
+
for (const item of observations) {
|
|
282
|
+
if (!Array.isArray(item?.paths) || !item.paths.includes(path) || !item.result || typeof item.result !== 'object') continue;
|
|
283
|
+
if (item.tool === 'read_file') {
|
|
284
|
+
const side = item.result.revision === 'base' ? 'old' : 'new', entry = (side === 'old' ? base : head).get(path);
|
|
285
|
+
if (item.result.path === path && entry && item.result.oid === entry.oid && Number.isSafeInteger(item.result.startLine) && Number.isSafeInteger(item.result.endLine) && item.result.endLine >= item.result.startLine) ranges.push({ side, start: item.result.startLine, end: item.result.endLine });
|
|
286
|
+
} else if (item.tool === 'search' && Array.isArray(item.result.matches)) {
|
|
287
|
+
for (const match of item.result.matches) {
|
|
288
|
+
const side = match?.revision === 'base' ? 'old' : 'new', entry = (side === 'old' ? base : head).get(path);
|
|
289
|
+
if (match?.path === path && entry && match.oid === entry.oid && Number.isSafeInteger(match.line)) ranges.push({ side, start: match.line, end: match.line });
|
|
290
|
+
}
|
|
291
|
+
} else if (item.tool === 'read_diff') ranges.push(...await diffRanges(path, item));
|
|
292
|
+
}
|
|
293
|
+
const file = byPath.get(path) ?? files.find(candidate => candidate.oldPath === path);
|
|
294
|
+
refs.push({ path, inChange: Boolean(file), ...(file ? { viewPath: file.path } : {}), ranges: mergeRanges(ranges) });
|
|
295
|
+
}
|
|
296
|
+
return refs;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
header, totals,
|
|
301
|
+
summary: () => ({ header, totals, files: files.slice(0, VIEW_LIMITS.files).map(({ oldOid, newOid, oldKind, newKind, ...file }) => ({ ...file, ...(oldKind && oldKind !== 'file' || newKind && newKind !== 'file' ? { special: newKind !== 'file' ? newKind : oldKind } : {}) })), listed: Math.min(files.length, VIEW_LIMITS.files) }),
|
|
302
|
+
diff, evidenceFor,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function summarize(files) {
|
|
307
|
+
const totals = { files: files.length, additions: 0, deletions: 0, binaryFiles: 0, uncountedFiles: 0 };
|
|
308
|
+
for (const file of files) {
|
|
309
|
+
if (file.binary) totals.binaryFiles++;
|
|
310
|
+
else if (file.additions === null || file.deletions === null) totals.uncountedFiles++;
|
|
311
|
+
else { totals.additions += file.additions; totals.deletions += file.deletions; }
|
|
312
|
+
}
|
|
313
|
+
return totals;
|
|
314
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { questionLimit } from './routing.js';
|
|
2
|
+
import { modelUsageText } from './usage.js';
|
|
3
|
+
|
|
4
|
+
const setupCodes = new Set(['INVALID_CONFIG', 'INVALID_SNAPSHOT', 'STALE_SNAPSHOT', 'CONTEXT_LIMIT', 'TOOL_RESULT_LIMIT', 'OUTPUT_LIMIT', 'PROVIDER_NOT_FOUND', 'PROVIDER_START_FAILED', 'PROVIDER_FAILED']);
|
|
5
|
+
const setupTypes = new Set(['authentication', 'permission_denied', 'payment_required', 'context_length_exceeded', 'max_tokens_exceeded', 'token_limit_exceeded', 'string_too_long', 'invalid_request', 'invalid_prompt', 'not_found', 'precondition_failed', 'payload_too_large', 'unprocessable', 'content_policy_violation', 'refusal']);
|
|
6
|
+
|
|
7
|
+
function retryable(error, before, state, minimum) {
|
|
8
|
+
if (setupCodes.has(error.code) || setupTypes.has(error.errorType)) return false;
|
|
9
|
+
if (error.code === 'OPENROUTER_PROVIDER_ERROR' && [400, 401, 402, 403, 404, 413, 422].includes(error.status)) return false;
|
|
10
|
+
// A failed preflight cannot improve by spending more. Budget exhaustion is
|
|
11
|
+
// different: an explicit human choice can authorize a new allowance.
|
|
12
|
+
if (state.usage.calls === before.usage.calls) {
|
|
13
|
+
return state.usage.limit !== null && state.usage.limit - state.usage.calls < minimum
|
|
14
|
+
&& (error.code === 'REVIEWER_BUDGET' || /^Assessment call limit \(/u.test(error.message));
|
|
15
|
+
}
|
|
16
|
+
return !/API key|sign.in|model access|output (?:token )?limit|finish_reason: length|refused or filtered/iu.test(error.message);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function diagnostic(error, state) {
|
|
20
|
+
// The interactive menu replaces only Sphinx's own appended command advice;
|
|
21
|
+
// the underlying failure and its sanitized provider diagnostics stay visible.
|
|
22
|
+
const hints = [
|
|
23
|
+
` Run sphinx check --id ${state.id} --retry-review to explicitly authorize a fresh review allowance; prior usage will be retained.`,
|
|
24
|
+
` ${state.usage.limit - state.usage.calls} model calls remain in the existing assessment allowance (${state.usage.calls}/${state.usage.limit} used). When ready, retry within that allowance: sphinx check --id ${state.id}.`,
|
|
25
|
+
` When ready, continue the check: sphinx check --id ${state.id}.`,
|
|
26
|
+
];
|
|
27
|
+
let message = error.message || String(error);
|
|
28
|
+
for (const hint of hints) if (message.endsWith(hint)) message = message.slice(0, -hint.length);
|
|
29
|
+
return message;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Keep a human-owned terminal open after review failure. This function never
|
|
33
|
+
* chooses to retry, expands a budget, changes a route, or awards completion on
|
|
34
|
+
* its own. inspect() exposes public state only; validate() checks current Git
|
|
35
|
+
* content and policy before a recovery choice is shown. */
|
|
36
|
+
export async function prepareInteractiveCheck({ start, inspect, authorizeMore, validate, config, choose, ui, waitingGit = false, signal }) {
|
|
37
|
+
for (;;) {
|
|
38
|
+
signal?.throwIfAborted();
|
|
39
|
+
const before = await inspect();
|
|
40
|
+
signal?.throwIfAborted();
|
|
41
|
+
try { const result = await start(); signal?.throwIfAborted(); return result; }
|
|
42
|
+
catch (error) {
|
|
43
|
+
signal?.throwIfAborted();
|
|
44
|
+
if (error.name === 'AbortError' || ['SPHINX_CANCELLED', 'EVALUATOR_CANCELLED'].includes(error.code)) throw error;
|
|
45
|
+
const state = await inspect();
|
|
46
|
+
signal?.throwIfAborted();
|
|
47
|
+
if (state.status !== 'pending' || state.preparation?.status !== 'failed' || state.progress.total !== 0) throw error;
|
|
48
|
+
await validate();
|
|
49
|
+
signal?.throwIfAborted();
|
|
50
|
+
const reserve = questionLimit(config.level, state.route.tier);
|
|
51
|
+
const reviewMinimum = state.preparation.minimumReviewerCalls === 1 ? 1 : 2, minimum = reserve + reviewMinimum;
|
|
52
|
+
const unlimited = state.usage.limit === null;
|
|
53
|
+
const remaining = unlimited ? Infinity : state.usage.limit - state.usage.calls;
|
|
54
|
+
const choices = [];
|
|
55
|
+
ui.warning(diagnostic(error, state));
|
|
56
|
+
ui.note(`Used so far: ${modelUsageText(state.usage, state.tokens)}. No questions or approval were recorded.`);
|
|
57
|
+
if (retryable(error, before, state, minimum)) {
|
|
58
|
+
if (remaining >= minimum) choices.push({
|
|
59
|
+
value: 'retry', label: state.preparation.resumable ? 'Continue the saved review' : unlimited ? 'Retry the review' : 'Retry within the remaining allowance',
|
|
60
|
+
description: `${unlimited ? 'All usage so far is kept.' : `${remaining} calls remain, including ${reserve} reserved for grading.`} ${state.preparation.resumable ? 'Reuse the evidence and investigation already saved.' : 'Keep the same evaluator route and spending settings.'}`,
|
|
61
|
+
});
|
|
62
|
+
else if (config.maxModelCalls >= minimum) choices.push({
|
|
63
|
+
value: 'extend', label: `Allow up to ${config.maxModelCalls} more calls and retry`,
|
|
64
|
+
description: `${remaining} calls remain; review needs at least ${reviewMinimum} plus ${reserve} reserved for grading. Keep all ${state.usage.calls} used calls counted and record this new allowance.`,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
if (!choices.length) ui.note('Resolve the reported setup or evidence problem before resuming. Repeating this attempt is not offered here.');
|
|
68
|
+
choices.push({ value: 'pause', label: 'Pause this check', description: 'Save progress without making another model request. The Git operation remains unapproved.' });
|
|
69
|
+
const action = await choose({
|
|
70
|
+
title: 'Review did not finish',
|
|
71
|
+
description: waitingGit
|
|
72
|
+
? 'The original Git operation is waiting while this terminal stays open, subject to its time limit. Choose how to continue.'
|
|
73
|
+
: 'Choose how to continue. Nothing is retried automatically.',
|
|
74
|
+
choices, current: 'pause',
|
|
75
|
+
});
|
|
76
|
+
signal?.throwIfAborted();
|
|
77
|
+
if (action === 'pause') {
|
|
78
|
+
ui.note('Progress saved. No approval was recorded.');
|
|
79
|
+
ui.command(`sphinx check --id ${state.id}`);
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
if (!choices.some(choice => choice.value === action)) throw new Error('Invalid review recovery choice. No additional model request was made.');
|
|
83
|
+
if (action === 'extend') {
|
|
84
|
+
await authorizeMore();
|
|
85
|
+
signal?.throwIfAborted();
|
|
86
|
+
ui.note(`Authorized up to ${config.maxModelCalls} additional model calls, including grading. Earlier usage remains counted.`);
|
|
87
|
+
}
|
|
88
|
+
// The next start still performs the store's locked policy/snapshot and
|
|
89
|
+
// budget checks. A new failure always returns to the human, never a retry loop.
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
package/src/checkbox.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
2
|
+
import { createTerminal, colorEnabled, wrapText } from './terminal.js';
|
|
3
|
+
|
|
4
|
+
const CLEAR = '\r\x1b[J';
|
|
5
|
+
const NAVIGATION = new Map([
|
|
6
|
+
['\x1b[A', 'up'], ['\x1bOA', 'up'], ['\x1b[B', 'down'], ['\x1bOB', 'down'],
|
|
7
|
+
['\x1b[H', 'home'], ['\x1bOH', 'home'], ['\x1b[1~', 'home'], ['\x1b[7~', 'home'],
|
|
8
|
+
['\x1b[F', 'end'], ['\x1bOF', 'end'], ['\x1b[4~', 'end'], ['\x1b[8~', 'end'],
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
function cancelled() {
|
|
12
|
+
const error = new Error('Selection cancelled.');
|
|
13
|
+
error.code = 'SPHINX_CANCELLED'; error.exitCode = 130;
|
|
14
|
+
return error;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A small inline checkbox prompt. The caller must close its readline first. */
|
|
18
|
+
export async function checkbox({ choices, current = [], title = 'Connect your coding agents', description, ui, input = process.stdin, output = process.stdout, env = process.env, escapeTimeoutMs = 50, signalSource = process } = {}) {
|
|
19
|
+
if (!Array.isArray(choices) || choices.some(choice => !choice || typeof choice.value !== 'string' || !choice.value || typeof choice.label !== 'string' || !choice.label) || new Set(choices.map(choice => choice.value)).size !== choices.length || !Array.isArray(current)) throw new Error('Checkbox choices need unique values, labels, and an array of selected values.');
|
|
20
|
+
if (!input.isTTY || !output.isTTY || typeof input.setRawMode !== 'function' || env.TERM === 'dumb') {
|
|
21
|
+
const error = new Error('Checkbox selection requires an interactive terminal.'); error.code = 'SPHINX_TTY_REQUIRED'; throw error;
|
|
22
|
+
}
|
|
23
|
+
if (input.destroyed || input.readableEnded) throw cancelled();
|
|
24
|
+
const terminal = ui || createTerminal({ stream: output, env });
|
|
25
|
+
const style = colorEnabled({ stream: output, env }) ? terminal.style : { strong: String, amber: String, green: String, muted: String };
|
|
26
|
+
const selected = new Set(current.filter(value => choices.some(choice => choice.value === value)));
|
|
27
|
+
const rows = [{ label: 'All supported agents', description: 'Select or clear every supported agent. Leave everything unchecked for Git and terminal only.' }, ...choices];
|
|
28
|
+
// Untouched stdin has readableFlowing=null while isPaused() is false. Resume
|
|
29
|
+
// only a stream that was actually flowing; otherwise the prompt keeps Node
|
|
30
|
+
// alive after installation when it was the first interactive question.
|
|
31
|
+
const wasRaw = !!input.isRaw, wasPaused = input.readableFlowing !== true;
|
|
32
|
+
let focus = 0, renderedLines = 0, escapeTimer, pending = '', finished = false, rawChanged = false, cursorHidden = false;
|
|
33
|
+
const decoder = new StringDecoder('utf8');
|
|
34
|
+
const width = () => Math.max(10, Math.min(Number(output.columns) || terminal.width || 80, terminal.width || 100) - 1);
|
|
35
|
+
const clearFrame = () => {
|
|
36
|
+
if (renderedLines) output.write(`\x1b[${renderedLines}A${CLEAR}`);
|
|
37
|
+
renderedLines = 0;
|
|
38
|
+
};
|
|
39
|
+
const render = () => {
|
|
40
|
+
clearFrame();
|
|
41
|
+
const columns = width(), height = Math.max(5, (Number(output.rows) || 24) - 1);
|
|
42
|
+
const fullHint = 'Arrows move · Space toggles · A all · Enter confirms · Esc cancels';
|
|
43
|
+
let hint = wrapText(fullHint, columns).split('\n');
|
|
44
|
+
if (hint.length + rows.length + 3 > height) hint = wrapText('↑↓ move Space toggle Enter done Esc cancel', columns).split('\n');
|
|
45
|
+
const renderedRows = rows.map((row, index) => {
|
|
46
|
+
const check = index === 0 ? selected.size === 0 ? ' ' : selected.size === choices.length ? 'x' : '-' : selected.has(choices[index - 1].value) ? 'x' : ' ';
|
|
47
|
+
const prefix = `${index === focus ? '>' : ' '} [${check}] `;
|
|
48
|
+
return wrapText(row.label, Math.max(1, columns - prefix.length)).split('\n').map((line, part) => {
|
|
49
|
+
const text = (part ? ' '.repeat(prefix.length) : prefix) + line;
|
|
50
|
+
return index === focus ? style.amber(text) : text;
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
const rowRoom = Math.max(1, height - hint.length - 2);
|
|
54
|
+
let first = 0;
|
|
55
|
+
while (first < focus && renderedRows.slice(first, focus + 1).reduce((count, row) => count + row.length, 0) > rowRoom) first++;
|
|
56
|
+
const lines = [];
|
|
57
|
+
for (let index = first; index < renderedRows.length; index++) {
|
|
58
|
+
if (lines.length && lines.length + renderedRows[index].length > rowRoom) break;
|
|
59
|
+
lines.push(...renderedRows[index].slice(0, rowRoom - lines.length));
|
|
60
|
+
}
|
|
61
|
+
const detailRoom = Math.max(0, height - lines.length - hint.length - 1);
|
|
62
|
+
const detail = wrapText(rows[focus].description || '', columns).split('\n').slice(0, Math.min(3, detailRoom));
|
|
63
|
+
if (detail.length && detail.some(Boolean)) lines.push('', ...detail.map(style.muted));
|
|
64
|
+
lines.push(...hint.map(style.muted));
|
|
65
|
+
renderedLines = lines.length;
|
|
66
|
+
output.write(`${lines.join('\n')}\n`);
|
|
67
|
+
};
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
const cleanup = () => {
|
|
70
|
+
clearTimeout(escapeTimer);
|
|
71
|
+
input.removeListener('data', onData); input.removeListener('end', onEnd); input.removeListener('close', onEnd); input.removeListener('error', onError);
|
|
72
|
+
output.removeListener('resize', onResize); signalSource.removeListener('SIGINT', onInterrupt);
|
|
73
|
+
try { if (rawChanged) input.setRawMode(wasRaw); } catch {}
|
|
74
|
+
try { if (wasPaused) input.pause(); else input.resume(); } catch {}
|
|
75
|
+
try { if (cursorHidden) output.write('\x1b[?25h'); } catch {}
|
|
76
|
+
};
|
|
77
|
+
const finish = error => {
|
|
78
|
+
if (finished) return;
|
|
79
|
+
finished = true;
|
|
80
|
+
let failure = error;
|
|
81
|
+
const values = choices.filter(choice => selected.has(choice.value)).map(choice => choice.value);
|
|
82
|
+
try {
|
|
83
|
+
clearFrame();
|
|
84
|
+
if (!failure) {
|
|
85
|
+
const summary = values.length ? `Selected: ${choices.filter(choice => selected.has(choice.value)).map(choice => choice.label).join(', ')}` : 'Git and terminal only.';
|
|
86
|
+
output.write(`${wrapText(summary, width())}\n`);
|
|
87
|
+
}
|
|
88
|
+
} catch (error) { failure ||= error; }
|
|
89
|
+
cleanup();
|
|
90
|
+
if (failure) reject(failure); else resolve(values);
|
|
91
|
+
};
|
|
92
|
+
const toggleAll = () => {
|
|
93
|
+
if (selected.size === choices.length) selected.clear();
|
|
94
|
+
else for (const choice of choices) selected.add(choice.value);
|
|
95
|
+
};
|
|
96
|
+
const key = name => {
|
|
97
|
+
if (finished) return;
|
|
98
|
+
if (name === 'cancel') { finish(cancelled()); return; }
|
|
99
|
+
if (name === 'enter') { finish(); return; }
|
|
100
|
+
if (name === 'up') focus = (focus - 1 + rows.length) % rows.length;
|
|
101
|
+
else if (name === 'down') focus = (focus + 1) % rows.length;
|
|
102
|
+
else if (name === 'home') focus = 0;
|
|
103
|
+
else if (name === 'end') focus = rows.length - 1;
|
|
104
|
+
else if (name === 'all' || (name === 'space' && focus === 0)) toggleAll();
|
|
105
|
+
else if (name === 'space') {
|
|
106
|
+
const value = choices[focus - 1].value;
|
|
107
|
+
if (selected.has(value)) selected.delete(value); else selected.add(value);
|
|
108
|
+
} else return;
|
|
109
|
+
render();
|
|
110
|
+
};
|
|
111
|
+
const parse = () => {
|
|
112
|
+
clearTimeout(escapeTimer);
|
|
113
|
+
while (pending && !finished) {
|
|
114
|
+
if (pending[0] === '\x1b') {
|
|
115
|
+
const complete = [...NAVIGATION].find(([sequence]) => pending.startsWith(sequence));
|
|
116
|
+
if (complete) { pending = pending.slice(complete[0].length); key(complete[1]); continue; }
|
|
117
|
+
if ([...NAVIGATION.keys()].some(sequence => sequence.startsWith(pending))) {
|
|
118
|
+
escapeTimer = setTimeout(() => { pending = ''; key('cancel'); }, escapeTimeoutMs);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
// Ignore complete unrecognized CSI keys, such as Delete or PageDown.
|
|
122
|
+
const other = /^\x1b\[[0-?]*[ -/]*[@-~]/.exec(pending);
|
|
123
|
+
if (other) { pending = pending.slice(other[0].length); continue; }
|
|
124
|
+
key('cancel'); return;
|
|
125
|
+
}
|
|
126
|
+
const character = pending[0]; pending = pending.slice(1);
|
|
127
|
+
if (character === '\x03' || character === '\x04') key('cancel');
|
|
128
|
+
else if (character === '\r' || character === '\n') key('enter');
|
|
129
|
+
else if (character === ' ') key('space');
|
|
130
|
+
else if (character === 'a' || character === 'A') key('all');
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
const onData = chunk => { try { pending += typeof chunk === 'string' ? chunk : decoder.write(chunk); parse(); } catch (error) { finish(error); } };
|
|
134
|
+
const onEnd = () => finish(cancelled());
|
|
135
|
+
const onError = error => finish(error);
|
|
136
|
+
const onInterrupt = () => finish(cancelled());
|
|
137
|
+
const onResize = () => { try { render(); } catch (error) { finish(error); } };
|
|
138
|
+
try {
|
|
139
|
+
if (title) output.write(`${wrapText(style.strong(title), width())}\n`);
|
|
140
|
+
if (description) output.write(`${wrapText(description, width())}\n`);
|
|
141
|
+
output.write('\n\x1b[?25l'); cursorHidden = true;
|
|
142
|
+
input.setRawMode(true); rawChanged = true;
|
|
143
|
+
input.on('data', onData); input.on('end', onEnd); input.on('close', onEnd); input.on('error', onError);
|
|
144
|
+
output.on('resize', onResize); signalSource.on('SIGINT', onInterrupt);
|
|
145
|
+
render(); input.resume();
|
|
146
|
+
} catch (error) { finish(error); }
|
|
147
|
+
});
|
|
148
|
+
}
|