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/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
- const recommended = agents.includes('codex') ? 'codex-economy' : agents.includes('claude') ? 'claude-economy' : undefined;
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
+
@@ -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
+ }