sphinx-understand 0.1.6 → 0.1.8
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/README.md +3 -1
- package/docs/architecture.md +1 -1
- package/docs/configuration.md +39 -0
- package/package.json +1 -1
- package/src/browser-interview.js +28 -10
- package/src/browser-page.js +43 -6
- package/src/check-recovery.js +15 -5
- package/src/cli.js +26 -31
- package/src/config.js +11 -6
- package/src/evaluator.js +55 -7
- package/src/git-gate.js +14 -3
- package/src/git-interview.js +41 -2
- package/src/git.js +17 -1
- package/src/github-worker.js +4 -0
- package/src/github.js +174 -0
- package/src/help.js +2 -1
- package/src/model-presets.js +1 -1
- package/src/openrouter.js +1 -1
- package/src/prompts.js +12 -5
- package/src/sessions.js +15 -3
- package/src/stats.js +54 -0
- package/src/storage.js +4 -2
- package/src/terminal-check.js +190 -0
- package/src/terminal-launch.js +2 -2
- package/src/update.js +72 -0
package/src/prompts.js
CHANGED
|
@@ -94,10 +94,14 @@ export async function askPolicy(config, initial, prompts, { scope = 'repository'
|
|
|
94
94
|
{ value: 'standard', label: 'Standard', description: `Caps: ${questionCapsText('standard')}. Behavior, failures, and system interactions.` },
|
|
95
95
|
{ value: 'thorough', label: 'Thorough', description: `Caps: ${questionCapsText('thorough')}. Also consider rollout, rollback, and compatibility.` },
|
|
96
96
|
] });
|
|
97
|
+
if (values.distance === undefined) values.distance = await prompts.choose({ title: 'How close to the code should questions get?', current: config.distance ?? 'high', choices: [
|
|
98
|
+
{ value: 'high', label: 'High level', description: 'What the change does and what it affects.' },
|
|
99
|
+
{ value: 'low', label: 'Low level', description: 'The specific logic, conditions, and edge cases.' },
|
|
100
|
+
] });
|
|
97
101
|
return values;
|
|
98
102
|
}
|
|
99
103
|
|
|
100
|
-
export async function askInstall(config, initial, prompts, { detectedAgents = [], hasExistingConfig = true } = {}) {
|
|
104
|
+
export async function askInstall(config, initial, prompts, { detectedAgents = [], hasExistingConfig = true, installedPlans = [] } = {}) {
|
|
101
105
|
let values = { ...initial };
|
|
102
106
|
if (values.agents === undefined) {
|
|
103
107
|
const selected = await prompts.multiselect({ title: 'Which coding agents do you use?', description: 'Connect any combination. Detected agents are preselected; clear all for Git-only use. Previously connected agents remain installed.', current: detectedAgents, choices: [
|
|
@@ -115,7 +119,10 @@ export async function askInstall(config, initial, prompts, { detectedAgents = []
|
|
|
115
119
|
|| explicitTiers.length === tiers.length || (hasExistingConfig && explicitTiers.length > 0);
|
|
116
120
|
if (!explicitRoute) {
|
|
117
121
|
const agents = values.agents === 'auto' ? detectedAgents : splitList(values.agents);
|
|
118
|
-
|
|
122
|
+
// A coding plan reviews far faster than OpenRouter's free router. Prefer the
|
|
123
|
+
// agent the user works in, then any Codex or Claude Code CLI installed here.
|
|
124
|
+
const plan = ['codex', 'claude'].find(name => agents.includes(name)) ?? ['claude', 'codex'].find(name => installedPlans.includes(name));
|
|
125
|
+
const recommended = plan ? `${plan}-economy` : undefined;
|
|
119
126
|
const current = hasExistingConfig ? 'keep' : recommended;
|
|
120
127
|
for (;;) {
|
|
121
128
|
const choice = await prompts.choose({
|
|
@@ -124,7 +131,7 @@ export async function askInstall(config, initial, prompts, { detectedAgents = []
|
|
|
124
131
|
current, requireSelection: !current,
|
|
125
132
|
choices: [
|
|
126
133
|
...(hasExistingConfig ? [{ value: 'keep', label: 'Keep current model routes', description: `Small: ${routeText(config.models.small)}. Medium: ${routeText(config.models.medium)}. Large: ${routeText(config.models.large)}.` }] : []),
|
|
127
|
-
...REVIEWER_PRESETS.map(preset => ({ value: preset.id, label: preset.label, description: preset.description })),
|
|
134
|
+
...[...REVIEWER_PRESETS].sort((a, b) => (b.id === recommended) - (a.id === recommended)).map(preset => ({ value: preset.id, label: preset.id === recommended ? `${preset.label} (recommended)` : preset.label, description: preset.description })),
|
|
128
135
|
{ value: 'custom', label: 'Choose by change size', description: 'Set separate provider:model routes for small, medium, and large changes.' },
|
|
129
136
|
],
|
|
130
137
|
});
|
|
@@ -154,7 +161,7 @@ export async function askInstall(config, initial, prompts, { detectedAgents = []
|
|
|
154
161
|
}
|
|
155
162
|
}
|
|
156
163
|
// Offer the saved (or default) policy as one choice; branch, remote, and depth details stay optional.
|
|
157
|
-
if (['events', 'branches', 'remotes', 'level'].every(key => values[key] === undefined)) {
|
|
164
|
+
if (['events', 'branches', 'remotes', 'level', 'distance'].every(key => values[key] === undefined)) {
|
|
158
165
|
const policy = await prompts.choose({
|
|
159
166
|
title: 'When should Sphinx check your understanding?',
|
|
160
167
|
description: 'Each repository can override this later with sphinx init.',
|
|
@@ -177,5 +184,5 @@ export function policyText(config) {
|
|
|
177
184
|
if (config.events.includes('pre-push')) parts.push(`before pushes to ${branches} on ${remotes}`);
|
|
178
185
|
if (config.events.includes('pre-commit')) parts.push(`before commits on ${branches}`);
|
|
179
186
|
const text = parts.join(' and ');
|
|
180
|
-
return `${text[0].toUpperCase()}${text.slice(1)}, at ${config.level} depth.`;
|
|
187
|
+
return `${text[0].toUpperCase()}${text.slice(1)}, at ${config.level} depth, ${config.distance === 'low' ? 'low' : 'high'} level.`;
|
|
181
188
|
}
|
package/src/sessions.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { REVIEWER_PRESETS } from './model-presets.js';
|
|
3
|
+
import { recordAnswer, recordCheck } from './stats.js';
|
|
3
4
|
import { withUsageMeter, withUsagePurpose, addUsage } from './usage.js';
|
|
4
5
|
import { mkdir, open, readdir, stat, unlink } from 'node:fs/promises';
|
|
5
6
|
import { DEFAULT_CONFIG, getPaths, readJson, writeJson, policyHash } from './config.js';
|
|
@@ -338,7 +339,12 @@ export function createSessionStore({ env = process.env, generate = generateAsses
|
|
|
338
339
|
// Capture after charging the grade: cancellation rolls back progress, not
|
|
339
340
|
// model usage, and leaves the same question available to the human.
|
|
340
341
|
const beforeAnswer = structuredClone(session);
|
|
341
|
-
session.attempts.push({questionId:question.id,answer:text.trim(),verdict:result.verdict,feedback:result.feedback,at:now()});
|
|
342
|
+
session.attempts.push({questionId:question.id,index:session.index,kind:dispute?'dispute':learn?'learn':'answer',prompt:question.prompt,answer:text.trim(),verdict:result.verdict,feedback:result.feedback,at:now()});
|
|
343
|
+
// Score each question once, on its first real grade. A dispute or an
|
|
344
|
+
// ambiguous grade leaves it unscored; asking for an explanation counts as a miss.
|
|
345
|
+
const scoredIndex = session.index;
|
|
346
|
+
const scoreThis = !dispute && result.verdict !== 'ambiguous' && !(session.scored || []).includes(scoredIndex);
|
|
347
|
+
if (scoreThis) session.scored = [...(session.scored || []), scoredIndex];
|
|
342
348
|
// The grader is asked to skip feedback on a correct answer; if it wrote some anyway, show it.
|
|
343
349
|
session.lastFeedback = result.feedback;
|
|
344
350
|
if (result.verdict === 'correct') {
|
|
@@ -370,6 +376,8 @@ export function createSessionStore({ env = process.env, generate = generateAsses
|
|
|
370
376
|
}
|
|
371
377
|
throw error;
|
|
372
378
|
}
|
|
379
|
+
if (scoreThis) await recordAnswer({ repoRoot: session.snapshot.repoRoot, correct: result.verdict === 'correct' && !learn, env });
|
|
380
|
+
if (session.status === 'passed') await recordCheck({ repoRoot: session.snapshot.repoRoot, outcome: 'completed', env });
|
|
373
381
|
return {...publicSession(session), verdict:result.verdict};
|
|
374
382
|
});
|
|
375
383
|
}
|
|
@@ -389,6 +397,7 @@ export function createSessionStore({ env = process.env, generate = generateAsses
|
|
|
389
397
|
if (session.status === 'passed') return publicSession(session);
|
|
390
398
|
session.status = 'skipped'; session.updatedAt = now();
|
|
391
399
|
await save(session);
|
|
400
|
+
await recordCheck({ repoRoot: session.snapshot.repoRoot, outcome: 'skipped', env });
|
|
392
401
|
return publicSession(session);
|
|
393
402
|
});
|
|
394
403
|
}
|
|
@@ -426,7 +435,10 @@ export function createSessionStore({ env = process.env, generate = generateAsses
|
|
|
426
435
|
});
|
|
427
436
|
}
|
|
428
437
|
/** Pass a documentation-only change before any page or model is involved. */
|
|
429
|
-
async function passTrivial(id, config) {
|
|
438
|
+
async function passTrivial(id, config, snapshot) {
|
|
439
|
+
// Decide from the file list without the lock; a check that background
|
|
440
|
+
// preparation is holding is simply not passed here and follows the normal flow.
|
|
441
|
+
if (snapshot && !documentationOnly(snapshot.files)) return false;
|
|
430
442
|
return withLock(id, env, async () => {
|
|
431
443
|
const session = await read(id);
|
|
432
444
|
if (session.status !== 'pending' || session.questions.length || !documentationOnly(session.snapshot.files)) return false;
|
|
@@ -434,7 +446,7 @@ export function createSessionStore({ env = process.env, generate = generateAsses
|
|
|
434
446
|
passWithoutQuestions(session, config, 'Documentation-only change.');
|
|
435
447
|
await save(session);
|
|
436
448
|
return true;
|
|
437
|
-
});
|
|
449
|
+
}).catch(error => { if (error.code === 'SPHINX_SESSION_BUSY') return false; throw error; });
|
|
438
450
|
}
|
|
439
451
|
return {passTrivial,ensure,approved,prepare,start,retryReview,refreshBudget,answer,pending,read,skip,discard,switchRoute,public:publicSession};
|
|
440
452
|
}
|
package/src/stats.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { getPaths, readJson, writeJson } from './config.js';
|
|
4
|
+
|
|
5
|
+
// A small per-repository understanding score. Checks are cleaned up after a
|
|
6
|
+
// day, so the score keeps its own counters: questions answered correctly on
|
|
7
|
+
// the first try, questions answered, the current first-try streak, and how
|
|
8
|
+
// many checks were completed or skipped. Updates are best-effort.
|
|
9
|
+
export const CALIBRATION_QUESTIONS = 5;
|
|
10
|
+
const RECENT = 10;
|
|
11
|
+
const digest = value => createHash('sha256').update(value).digest('hex');
|
|
12
|
+
export const statsFile = (repoRoot, env = process.env) => path.join(getPaths(env).home, 'stats', `${digest(repoRoot)}.json`);
|
|
13
|
+
const count = value => Number.isSafeInteger(value) && value >= 0 ? value : 0;
|
|
14
|
+
|
|
15
|
+
export async function readStats(repoRoot, env = process.env) {
|
|
16
|
+
const stored = await readJson(statsFile(repoRoot, env), null).catch(() => null);
|
|
17
|
+
return {
|
|
18
|
+
version: 1, repoRoot,
|
|
19
|
+
answered: count(stored?.answered), firstTry: count(stored?.firstTry), streak: count(stored?.streak), bestStreak: count(stored?.bestStreak),
|
|
20
|
+
completed: count(stored?.completed), skipped: count(stored?.skipped), updatedAt: stored?.updatedAt ?? null,
|
|
21
|
+
recent: Array.isArray(stored?.recent) ? stored.recent.filter(item => typeof item === 'boolean').slice(-RECENT) : [],
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function update(repoRoot, env, change) {
|
|
26
|
+
try {
|
|
27
|
+
const stats = change(await readStats(repoRoot, env));
|
|
28
|
+
await writeJson(statsFile(repoRoot, env), { ...stats, updatedAt: new Date().toISOString() });
|
|
29
|
+
} catch { /* The score never affects a check. */ }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** One question's first graded answer: correct on the first try, or not. */
|
|
33
|
+
export const recordAnswer = ({ repoRoot, correct, env = process.env }) => update(repoRoot, env, stats => {
|
|
34
|
+
const streak = correct ? stats.streak + 1 : 0;
|
|
35
|
+
return { ...stats, answered: stats.answered + 1, firstTry: stats.firstTry + (correct ? 1 : 0), streak, bestStreak: Math.max(stats.bestStreak, streak), recent: [...stats.recent, Boolean(correct)].slice(-RECENT) };
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/** A check that asked questions was completed, or the human skipped it. */
|
|
39
|
+
export const recordCheck = ({ repoRoot, outcome, env = process.env }) => update(repoRoot, env, stats => ({
|
|
40
|
+
...stats, completed: stats.completed + (outcome === 'completed' ? 1 : 0), skipped: stats.skipped + (outcome === 'skipped' ? 1 : 0),
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
/** What the questionnaire and CLI show. Accuracy stays hidden while calibrating. */
|
|
44
|
+
export function scoreSummary(stats) {
|
|
45
|
+
const calibrating = stats.answered < CALIBRATION_QUESTIONS;
|
|
46
|
+
const checks = stats.completed + stats.skipped;
|
|
47
|
+
return {
|
|
48
|
+
calibrating, answered: stats.answered, needed: Math.max(0, CALIBRATION_QUESTIONS - stats.answered),
|
|
49
|
+
accuracy: calibrating ? null : Math.round((stats.firstTry / stats.answered) * 100),
|
|
50
|
+
streak: stats.streak, bestStreak: stats.bestStreak,
|
|
51
|
+
skipRate: checks ? Math.round((stats.skipped / checks) * 100) : null, checks,
|
|
52
|
+
recent: stats.recent,
|
|
53
|
+
};
|
|
54
|
+
}
|
package/src/storage.js
CHANGED
|
@@ -40,12 +40,14 @@ export async function collectGarbage({ env = process.env, now = Date.now(), cap
|
|
|
40
40
|
const gone = context?.repoRoot && !(await stat(context.repoRoot).catch(() => null));
|
|
41
41
|
if (!context || gone || now - file.mtime > 14 * DAY) await drop(file.path);
|
|
42
42
|
}
|
|
43
|
-
for (const directory of ['preparations', 'presentations', 'terminals']) {
|
|
43
|
+
for (const directory of ['preparations', 'presentations', 'terminals', 'github/jobs']) {
|
|
44
44
|
for (const file of await files(path.join(home, directory))) if (now - file.mtime > 7 * DAY) await drop(file.path);
|
|
45
45
|
}
|
|
46
|
+
// A branch's check records feed its pull request comment while it is active.
|
|
47
|
+
for (const file of await files(path.join(home, 'github', 'branches'))) if (now - file.mtime > 30 * DAY) await drop(file.path);
|
|
46
48
|
// Size cap: drop the oldest removable data first. Hooks, settings, the
|
|
47
49
|
// install manifest, and credentials are never candidates.
|
|
48
|
-
const removable = (await Promise.all(['sessions', 'context', 'preparations', 'presentations', 'terminals'].map(dir => files(path.join(home, dir))))).flat()
|
|
50
|
+
const removable = (await Promise.all(['sessions', 'context', 'preparations', 'presentations', 'terminals', 'github'].map(dir => files(path.join(home, dir))))).flat()
|
|
49
51
|
.filter(file => now - file.mtime > HOUR && !removed.includes(file.path)).sort((a, b) => a.mtime - b.mtime);
|
|
50
52
|
let total = await storageUsage(env);
|
|
51
53
|
for (const file of removable) { if (total <= cap) break; await drop(file.path); total -= file.size; }
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { readStats, scoreSummary } from './stats.js';
|
|
3
|
+
import { prepareInteractiveCheck } from './check-recovery.js';
|
|
4
|
+
import { VERSION } from './version.js';
|
|
5
|
+
import { wrapText, stripAnsi } from './terminal.js';
|
|
6
|
+
|
|
7
|
+
// The terminal questionnaire: the same check as the browser page, laid out for
|
|
8
|
+
// a terminal. Used by `sphinx check` and when Git asks inline in a headless
|
|
9
|
+
// session. Model text is printed as plain text; it never sets terminal state.
|
|
10
|
+
const FRAMES = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏';
|
|
11
|
+
const LEARN = 'I do not know. Explain this concept and give me a different scenario to apply it to.';
|
|
12
|
+
// Strip control characters from model text so it cannot move the cursor or recolor the terminal.
|
|
13
|
+
const plain = text => String(text ?? '').replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '');
|
|
14
|
+
const PROVIDERS = { openrouter: 'OpenRouter', openai: 'Endpoint', claude: 'Claude Code', codex: 'Codex' };
|
|
15
|
+
const routeText = route => route?.provider ? [PROVIDERS[route.provider] || route.provider, route.model].filter(Boolean).join(' ') : '';
|
|
16
|
+
const count = n => Number(n || 0).toLocaleString('en-US');
|
|
17
|
+
|
|
18
|
+
/** One status line that redraws in place on a TTY, and prints once otherwise. */
|
|
19
|
+
export function createSpinner(stream, style) {
|
|
20
|
+
let timer = null, label = '', started = 0, index = 0;
|
|
21
|
+
const width = () => Math.max(20, (stream.columns || 80) - 4);
|
|
22
|
+
const draw = () => {
|
|
23
|
+
const seconds = Math.floor((Date.now() - started) / 1000);
|
|
24
|
+
const tail = seconds >= 2 ? ` · ${seconds}s` : '';
|
|
25
|
+
const room = width() - 2 - tail.length;
|
|
26
|
+
const text = label.length > room ? `${label.slice(0, room - 1)}…` : label;
|
|
27
|
+
stream.write(`\r\x1b[2K ${style.amber(FRAMES[index++ % FRAMES.length])} ${text}${style.muted(tail)}`);
|
|
28
|
+
};
|
|
29
|
+
return {
|
|
30
|
+
start(text) {
|
|
31
|
+
this.stop(); label = plain(text); started = Date.now();
|
|
32
|
+
if (stream.isTTY) { draw(); timer = setInterval(draw, 90); timer.unref?.(); }
|
|
33
|
+
else stream.write(` ${label}…\n`);
|
|
34
|
+
},
|
|
35
|
+
update(text) { label = plain(text); },
|
|
36
|
+
stop() { if (timer) { clearInterval(timer); timer = null; stream.write('\r\x1b[2K'); } },
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function scoreLine(score, style) {
|
|
41
|
+
if (!score) return '';
|
|
42
|
+
const recent = score.recent || [];
|
|
43
|
+
const slots = score.calibrating ? score.answered + score.needed : Math.max(recent.length, 1);
|
|
44
|
+
const dots = Array.from({ length: slots }, (_, i) => i < recent.length ? (recent[i] ? style.green('●') : style.amber('●')) : style.muted('○')).join('');
|
|
45
|
+
const value = score.calibrating ? style.muted(`warming up · ${score.needed} to go`) : style.strong(`${score.accuracy}%`);
|
|
46
|
+
const extra = !score.calibrating && score.streak > 1 ? style.muted(` · ${score.streak} in a row`) : '';
|
|
47
|
+
return ` ${style.muted('Your understanding')} ${dots} ${value}${extra}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A quoted block with a left rule, wrapped to the terminal. */
|
|
51
|
+
function block(terminal, text, rule) {
|
|
52
|
+
const { style } = terminal;
|
|
53
|
+
for (const line of wrapText(plain(text), terminal.width - 4, '').split('\n')) terminal.line(` ${rule(style)} ${line}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function printHeader(terminal, { session, score }) {
|
|
57
|
+
const { style } = terminal, snapshot = session.snapshot || {};
|
|
58
|
+
const repo = snapshot.repoRoot ? path.basename(snapshot.repoRoot) : '';
|
|
59
|
+
const destination = snapshot.target?.ref ? `${snapshot.target.remote ? `${snapshot.target.remote}/` : ''}${snapshot.target.ref.replace(/^refs\/heads\//, '')}` : '';
|
|
60
|
+
const stats = snapshot.stats ? `${count(snapshot.stats.files)} file${snapshot.stats.files === 1 ? '' : 's'} ${style.green(`+${count(snapshot.stats.additions)}`)} ${style.amber(`−${count(snapshot.stats.deletions)}`)}` : '';
|
|
61
|
+
const title = ` ${style.amber('◆')} ${style.strong('sphinx')} ${style.muted(`v${VERSION}`)}`;
|
|
62
|
+
const right = style.muted('understanding check');
|
|
63
|
+
const gap = Math.max(2, terminal.width - stripAnsi(title).length - stripAnsi(right).length);
|
|
64
|
+
terminal.line('');
|
|
65
|
+
terminal.line(`${title}${' '.repeat(gap)}${right}`);
|
|
66
|
+
const facts = [[repo, destination].filter(Boolean).join(' → '), stats, routeText(session.route)].filter(Boolean);
|
|
67
|
+
if (facts.length) terminal.line(` ${facts.join(style.muted(' · '))}`);
|
|
68
|
+
const line = scoreLine(score, style);
|
|
69
|
+
if (line) terminal.line(line);
|
|
70
|
+
terminal.line(style.muted(` ${'─'.repeat(Math.max(10, terminal.width - 4))}`));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function printQuestion(terminal, state, { followUp }) {
|
|
74
|
+
const { style } = terminal;
|
|
75
|
+
terminal.line('');
|
|
76
|
+
const label = `Question ${state.progress.completed + 1} of ${state.progress.total}${followUp ? ' · follow-up' : ''}`;
|
|
77
|
+
terminal.line(` ${style.strong(label)}`);
|
|
78
|
+
block(terminal, state.question.prompt, s => s.amber('┃'));
|
|
79
|
+
if (state.question.choices?.length) state.question.choices.forEach((choice, index) => terminal.line(` ${style.muted(`${index + 1}.`)} ${plain(choice)}`));
|
|
80
|
+
terminal.line('');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function commandsLine(terminal, { waitingGit }) {
|
|
84
|
+
const { style } = terminal;
|
|
85
|
+
const items = ['/hint', '/learn', '/dispute <reason>', ...(waitingGit ? ['/skip'] : []), '/quit'];
|
|
86
|
+
terminal.line(style.muted(` Enter to send · ${items.join(' ')}`));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Run the check in this terminal. Resolves 0 when passed, 1 when paused or
|
|
91
|
+
* quit, and 3 when the human skipped a waiting Git operation.
|
|
92
|
+
*/
|
|
93
|
+
export async function runTerminalCheck({ store, session, config, ask, terminal: base, stream = process.stdout, signal, waitingGit = false, planProviders = [], validate, choose, skip, env = process.env }) {
|
|
94
|
+
// Lines keep their own indentation; the shared helpers would rewrap it away.
|
|
95
|
+
const terminal = { ...base, line: text => stream.write(`${text}\n`), note: text => stream.write(`${base.style.muted(` ${text}`)}\n`), warning: text => stream.write(` ${base.style.amber('!')} ${text}\n`) };
|
|
96
|
+
const { style } = terminal;
|
|
97
|
+
const spinner = createSpinner(stream, style);
|
|
98
|
+
const repoRoot = session.snapshot.repoRoot;
|
|
99
|
+
const score = async () => scoreSummary(await readStats(repoRoot, env));
|
|
100
|
+
printHeader(terminal, { session, score: await score().catch(() => null) });
|
|
101
|
+
terminal.line(style.muted(wrapText('Open-book: look at the code whenever it helps, then explain what it does in your own words.', terminal.width, ' ')));
|
|
102
|
+
|
|
103
|
+
const withSpinner = async (label, operation) => {
|
|
104
|
+
spinner.start(label);
|
|
105
|
+
try { return await operation(); } finally { spinner.stop(); }
|
|
106
|
+
};
|
|
107
|
+
let state;
|
|
108
|
+
try {
|
|
109
|
+
state = await prepareInteractiveCheck({
|
|
110
|
+
config, signal, waitingGit, planProviders, ui: terminal, choose,
|
|
111
|
+
start: () => withSpinner(session.questions?.length ? 'Loading prepared questions' : `Reviewing the change with ${routeText(session.route)}`, () => store.start(session.id, config, {
|
|
112
|
+
signal, onProgress: text => spinner.update(`Reviewing · ${text}`),
|
|
113
|
+
})),
|
|
114
|
+
inspect: async () => store.public(await store.read(session.id)),
|
|
115
|
+
authorizeMore: () => store.retryReview(session.id, config, { signal }),
|
|
116
|
+
validate, switchRoute: provider => store.switchRoute(session.id, config, provider, { signal }),
|
|
117
|
+
});
|
|
118
|
+
} finally { spinner.stop(); }
|
|
119
|
+
signal?.throwIfAborted();
|
|
120
|
+
if (!state) return 1;
|
|
121
|
+
if (state.status === 'passed' && state.noQuestionReason) {
|
|
122
|
+
terminal.line(''); terminal.line(` ${style.green('✓')} No questions needed: ${plain(state.noQuestionReason)}`);
|
|
123
|
+
return finish(terminal, { waitingGit });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let shownId = null, shownNumber = 0;
|
|
127
|
+
while (state.status !== 'passed') {
|
|
128
|
+
if (state.question && state.question.id !== shownId) {
|
|
129
|
+
const number = state.progress.completed + 1;
|
|
130
|
+
printQuestion(terminal, state, { followUp: number === shownNumber });
|
|
131
|
+
commandsLine(terminal, { waitingGit });
|
|
132
|
+
shownId = state.question.id; shownNumber = number;
|
|
133
|
+
}
|
|
134
|
+
const answer = plain(await ask(` ${style.amber('›')} `)).trim();
|
|
135
|
+
signal?.throwIfAborted();
|
|
136
|
+
if (!answer) continue;
|
|
137
|
+
if (answer === '/quit') { terminal.line(''); terminal.note('Progress saved. Run sphinx check when you are ready to continue.'); return 1; }
|
|
138
|
+
if (answer === '/hint') { await printHint(terminal, store, session.id, state.question.id); continue; }
|
|
139
|
+
if (answer === '/skip') {
|
|
140
|
+
if (!waitingGit || !skip) { terminal.warning('Skipping is only available while a Git operation is waiting.'); continue; }
|
|
141
|
+
const reason = plain(await ask(` ${style.muted('Skip this check? The push goes through and the skip is logged. Reason (optional), or n to go back:')} `)).trim();
|
|
142
|
+
if (/^n(o)?$/i.test(reason)) continue;
|
|
143
|
+
await skip(reason || undefined);
|
|
144
|
+
terminal.line(''); terminal.line(` ${style.amber('↷')} Skipped. Git is continuing.`);
|
|
145
|
+
return 3;
|
|
146
|
+
}
|
|
147
|
+
const dispute = answer.startsWith('/dispute '), learn = answer === '/learn';
|
|
148
|
+
if (answer === '/dispute') { terminal.warning('Add a reason: /dispute <what is ambiguous or unsupported>'); continue; }
|
|
149
|
+
if (answer.startsWith('/') && !dispute && !learn) { terminal.warning('Use /hint, /learn, /dispute <reason>, /quit, or type your answer.'); continue; }
|
|
150
|
+
const previous = state;
|
|
151
|
+
state = await withSpinner(learn ? 'Preparing an explanation and a new scenario' : dispute ? 'Reviewing the question' : 'Checking your reasoning', () => store.answer(session.id, learn ? LEARN : dispute ? answer.slice(9) : answer, config, { dispute, learn, expectedQuestionId: previous.question.id, signal }));
|
|
152
|
+
signal?.throwIfAborted();
|
|
153
|
+
printVerdict(terminal, state, { learn, dispute });
|
|
154
|
+
}
|
|
155
|
+
terminal.line('');
|
|
156
|
+
terminal.line(` ${style.green('✓')} ${style.strong('Understanding check complete.')}`);
|
|
157
|
+
const final = await score().catch(() => null);
|
|
158
|
+
const line = scoreLine(final, style);
|
|
159
|
+
if (line) terminal.line(line);
|
|
160
|
+
return finish(terminal, { waitingGit });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function finish(terminal, { waitingGit }) {
|
|
164
|
+
terminal.line(terminal.style.muted(waitingGit ? ' Approval saved. Git is continuing.' : ' Approval saved for this exact change and destination. A waiting Git operation can now continue.'));
|
|
165
|
+
terminal.line('');
|
|
166
|
+
return 0;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function printVerdict(terminal, state, { learn, dispute }) {
|
|
170
|
+
const { style } = terminal;
|
|
171
|
+
terminal.line('');
|
|
172
|
+
if (state.verdict === 'correct') { terminal.line(` ${style.green('✓')} Got it.`); if (state.feedback) block(terminal, state.feedback, s => s.muted('│')); return; }
|
|
173
|
+
const heading = learn ? `${style.amber('◇')} Here's how it works` : dispute ? `${style.amber('◇')} About that question` : state.verdict === 'ambiguous' ? `${style.amber('?')} Needs a little more` : `${style.amber('✗')} Not quite`;
|
|
174
|
+
terminal.line(` ${heading}`);
|
|
175
|
+
if (state.feedback) block(terminal, state.feedback, s => s.muted('│'));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function printHint(terminal, store, id, questionId) {
|
|
179
|
+
const { style } = terminal;
|
|
180
|
+
// Only the cited file paths: evidence reasons can give the answer away.
|
|
181
|
+
const session = await store.read(id).catch(() => null);
|
|
182
|
+
const question = session?.questions?.find(item => item.id === questionId);
|
|
183
|
+
const paths = [...new Set((question?.evidence || []).map(item => item.path).filter(Boolean))];
|
|
184
|
+
terminal.line('');
|
|
185
|
+
if (!paths.length) { terminal.note('No specific files are cited for this question. Start from the diff.'); return; }
|
|
186
|
+
terminal.line(` ${style.muted('Look at')}`);
|
|
187
|
+
for (const file of paths) terminal.line(` ${style.amber(plain(file))}`);
|
|
188
|
+
terminal.line('');
|
|
189
|
+
}
|
|
190
|
+
|
package/src/terminal-launch.js
CHANGED
|
@@ -10,7 +10,7 @@ import { setTimeout as delay } from 'node:timers/promises';
|
|
|
10
10
|
import { getPaths, readJson, writeJson } from './config.js';
|
|
11
11
|
|
|
12
12
|
const WORKER = fileURLToPath(new URL('./terminal-worker.js', import.meta.url));
|
|
13
|
-
const CLI = fileURLToPath(new URL('../bin/sphinx.js', import.meta.url));
|
|
13
|
+
export const CLI = fileURLToPath(new URL('../bin/sphinx.js', import.meta.url));
|
|
14
14
|
const ID = /^[a-f0-9]{64}$/;
|
|
15
15
|
const TOKEN = /^[a-f0-9-]{36}$/;
|
|
16
16
|
const STARTUP_MS = 30000;
|
|
@@ -282,7 +282,7 @@ export async function runCheckpointTerminal({ recordFile, token, env = process.e
|
|
|
282
282
|
if (Number.isInteger(child.pid)) { running.childPid = child.pid; childWrite = writeJson(recordFile, running).catch(() => {}); }
|
|
283
283
|
});
|
|
284
284
|
await childWrite;
|
|
285
|
-
const result = { ...running, status: code === 0 ? 'passed' : 'pending', updatedAt: Date.now(), finishedAt: Date.now() };
|
|
285
|
+
const result = { ...running, status: code === 0 ? 'passed' : code === 3 ? 'skipped' : 'pending', updatedAt: Date.now(), finishedAt: Date.now() };
|
|
286
286
|
if ((await readJson(recordFile, null))?.token === token) await writeJson(recordFile, result);
|
|
287
287
|
return { status: result.status, code };
|
|
288
288
|
}
|
package/src/update.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { realpath } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { getPaths, readJson, writeJson } from './config.js';
|
|
6
|
+
import { VERSION } from './version.js';
|
|
7
|
+
|
|
8
|
+
// The questionnaire shows the installed version and, when npm has a newer one,
|
|
9
|
+
// offers a one-click update. The update waits until this Sphinx process exits,
|
|
10
|
+
// so a check in progress never runs on half-replaced files.
|
|
11
|
+
export const PACKAGE = 'sphinx-understand';
|
|
12
|
+
const CACHE_MS = 12 * 3600 * 1000;
|
|
13
|
+
const PACKAGE_ROOT = fileURLToPath(new URL('..', import.meta.url));
|
|
14
|
+
const cacheFile = env => path.join(getPaths(env).home, 'version.json');
|
|
15
|
+
const parts = version => String(version).split('-')[0].split('.').map(Number);
|
|
16
|
+
|
|
17
|
+
/** True when `latest` is a higher x.y.z release than `current`. */
|
|
18
|
+
export function isNewer(latest, current) {
|
|
19
|
+
if (!/^\d+\.\d+\.\d+$/.test(String(latest))) return false;
|
|
20
|
+
const [a, b] = [parts(latest), parts(current)];
|
|
21
|
+
for (let index = 0; index < 3; index++) if (a[index] !== b[index]) return a[index] > (b[index] || 0);
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The latest published version, cached for 12 hours. Never throws; null when unknown. */
|
|
26
|
+
export async function latestVersion({ env = process.env, fetchImpl = globalThis.fetch, now = Date.now() } = {}) {
|
|
27
|
+
if (env.SPHINX_UPDATE_CHECK === '0') return null;
|
|
28
|
+
const cached = await readJson(cacheFile(env), null).catch(() => null);
|
|
29
|
+
if (cached && now - Date.parse(cached.checkedAt) < CACHE_MS) return cached.latest ?? null;
|
|
30
|
+
try {
|
|
31
|
+
const response = await fetchImpl(`https://registry.npmjs.org/${PACKAGE}/latest`, { signal: AbortSignal.timeout(4000), headers: { accept: 'application/json' } });
|
|
32
|
+
const latest = response.ok ? (await response.json())?.version : null;
|
|
33
|
+
if (typeof latest !== 'string') return null;
|
|
34
|
+
await writeJson(cacheFile(env), { latest, checkedAt: new Date(now).toISOString() }).catch(() => {});
|
|
35
|
+
return latest;
|
|
36
|
+
} catch { return null; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** How this copy was installed, and so how to update it. Linked checkouts update with git. */
|
|
40
|
+
export async function updatePlan({ root = PACKAGE_ROOT } = {}) {
|
|
41
|
+
const real = await realpath(root).catch(() => root);
|
|
42
|
+
if (!real.split(path.sep).includes('node_modules')) return { manager: null, note: 'This is a linked development copy. Update it with git pull.' };
|
|
43
|
+
if (/[\\/](?:\.pnpm|pnpm)[\\/]/.test(real)) return { manager: 'pnpm', command: ['pnpm', 'add', '-g', `${PACKAGE}@latest`] };
|
|
44
|
+
if (/[\\/]\.bun[\\/]/.test(real)) return { manager: 'bun', command: ['bun', 'add', '-g', `${PACKAGE}@latest`] };
|
|
45
|
+
return { manager: 'npm', command: ['npm', 'install', '-g', `${PACKAGE}@latest`] };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** What the questionnaire shows. Never throws. */
|
|
49
|
+
export async function updateStatus({ env = process.env, fetchImpl, root } = {}) {
|
|
50
|
+
const latest = await latestVersion({ env, fetchImpl });
|
|
51
|
+
const available = !!latest && isNewer(latest, VERSION);
|
|
52
|
+
const plan = available ? await updatePlan({ root }).catch(() => ({ manager: null })) : null;
|
|
53
|
+
return { current: VERSION, latest, available, ...(plan ? { command: plan.command?.join(' ') ?? null, note: plan.note ?? null } : {}) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const shellQuote = value => `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Install the latest version once process `waitFor` exits. Output goes to
|
|
60
|
+
* update.log in Sphinx's data directory. Resolves false if it cannot start.
|
|
61
|
+
*/
|
|
62
|
+
export async function scheduleUpdate({ command, waitFor = process.pid, env = process.env, spawnImpl = spawn } = {}) {
|
|
63
|
+
if (!Array.isArray(command) || !command.length) return false;
|
|
64
|
+
const log = path.join(getPaths(env).home, 'update.log');
|
|
65
|
+
const script = `while kill -0 ${Number(waitFor)} 2>/dev/null; do sleep 1; done; { date; ${command.map(shellQuote).join(' ')}; echo "exit $?"; } >> ${shellQuote(log)} 2>&1`;
|
|
66
|
+
try {
|
|
67
|
+
const child = spawnImpl('/bin/sh', ['-c', script], { env, detached: true, stdio: 'ignore' });
|
|
68
|
+
await new Promise((resolve, reject) => { child.once('error', reject); child.once('spawn', resolve); });
|
|
69
|
+
child.unref();
|
|
70
|
+
return true;
|
|
71
|
+
} catch { return false; }
|
|
72
|
+
}
|