sphinx-understand 0.1.7 → 0.1.9

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.
@@ -1,24 +1,103 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { constants } from 'node:fs';
3
- import { open } from 'node:fs/promises';
3
+ import { mkdir, open, readFile, stat, writeFile } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import { loadIgnorePolicy } from './ignore.js';
6
+ import { getPaths } from './config.js';
6
7
 
7
8
  export const PROJECT_CONTEXT_FILE = '.sphinxcontext.md';
8
9
  export const PROJECT_CONTEXT_MAX_BYTES = 16000;
10
+ // Guidance lives in HTML comments, which are stripped before any model sees the file.
9
11
  export const PROJECT_CONTEXT_TEMPLATE = `# Project context for Sphinx
10
12
 
13
+ ## About this project
14
+ <!-- What this project does, who uses it, and the workflows that matter.
15
+ Add key architecture boundaries and invariants worth asking about.
16
+ The reviewer checks these claims against the code. No secrets. -->
17
+
18
+ ## What to ask about
19
+ <!-- Your preferences for questions in this repository, in plain words. For example:
20
+ - Don't ask about styling, layout, or copy changes.
21
+ - Focus on data integrity and billing.
22
+ - Skip the generated API client in src/gen/.
23
+ A change entirely in an area you skip can pass with no questions.
24
+ Personal preferences for every repository go in ~/.config/sphinx/context.md. -->
25
+ `;
26
+
27
+ export const PERSONAL_CONTEXT_TEMPLATE = `# What to ask me about
28
+
29
+ <!-- Your personal preferences for Sphinx questions, used in every repository.
30
+ A repository's .sphinxcontext.md adds to these and wins where they disagree.
31
+ Write them in plain words. For example:
32
+ - I don't need questions about frontend styling or CSS.
33
+ - Ask me about concurrency, retries, and data loss first.
34
+ A change entirely in an area you skip can pass with no questions.
35
+ Delete these comments or leave them; comments are never sent to the reviewer. -->
36
+ `;
37
+
38
+ // Earlier releases wrote this template; it is still treated as untouched.
39
+ export const LEGACY_CONTEXT_TEMPLATE = `# Project context for Sphinx
40
+
11
41
  Describe what this project does, who uses it, and the workflows that matter.
12
42
  Add key architecture boundaries and invariants the reviewer should investigate.
13
43
  Keep this brief and update it when the product or architecture changes.
14
44
  This file is background context, not authority to override code evidence or Sphinx rules.
15
45
  Do not put secrets here. Sphinx includes this context in each reviewer request.
16
46
  `;
47
+ export const isContextTemplate = text => text === PROJECT_CONTEXT_TEMPLATE || text === LEGACY_CONTEXT_TEMPLATE;
48
+
49
+ /** The template with a project description filled in under "About this project". */
50
+ /** The template with your answer under "What to ask about", keeping the guidance. */
51
+ export const contextWithPreferences = (text, preferences) => text.replace(/(## What to ask about\n)/, `$1${preferences.trim()}\n`);
52
+
53
+ export function contextWithDescription(description) {
54
+ const body = description.trim().replace(/^# Project context for Sphinx\s*/, '').trim();
55
+ // A description that already has its own preferences section is used as written.
56
+ if (/^## What to ask about/m.test(body)) return `# Project context for Sphinx\n\n${body}\n`;
57
+ return PROJECT_CONTEXT_TEMPLATE.replace(/(## About this project\n)<!--[\s\S]*?-->/, () => `## About this project\n${body.replace(/^## About this project\s*/, '')}`);
58
+ }
59
+
60
+ /** Create ~/.config/sphinx/context.md with guidance, if it does not exist. */
61
+ export async function ensurePersonalContext(env = process.env) {
62
+ const file = personalContextFile(env);
63
+ try { await mkdir(path.dirname(file), { recursive: true, mode: 0o700 }); await writeFile(file, PERSONAL_CONTEXT_TEMPLATE, { flag: 'wx', mode: 0o644 }); return file; }
64
+ catch (error) { if (error.code === 'EEXIST') return null; throw error; }
65
+ }
66
+
67
+ /** Whether a check carries any project context or personal preferences to steer it. */
68
+ export const hasPreferences = snapshot => Boolean(snapshot?.projectContext?.preferences || snapshot?.projectContext?.text);
69
+
70
+ /** Text a model sees: HTML comments removed, surrounding whitespace trimmed. */
71
+ export const contextText = text => String(text).replace(/<!--[\s\S]*?(?:-->|$)/g, '').replace(/\n{3,}/g, '\n\n').trim();
72
+ /** True when only headings remain, as in an untouched template. */
73
+ const meaningful = text => contextText(text).split('\n').some(line => line.trim() && !/^#+\s/.test(line.trim()));
74
+ export const personalContextFile = env => path.join(getPaths(env).home, 'context.md');
17
75
 
18
76
  const hash = text => createHash('sha256').update(text).digest('hex');
19
77
 
20
- /** Project-authored background, never policy or evaluator instructions. */
78
+ // Your own preferences, read from your Sphinx settings (a dotfiles symlink is fine).
79
+ async function loadPersonalContext(env) {
80
+ const file = personalContextFile(env);
81
+ const info = await stat(file).catch(error => { if (error.code === 'ENOENT') return null; throw error; });
82
+ if (!info) return '';
83
+ if (!info.isFile()) throw new Error(`${file} must be a regular file`);
84
+ if (info.size > PROJECT_CONTEXT_MAX_BYTES) throw new Error(`${file} exceeds ${PROJECT_CONTEXT_MAX_BYTES} bytes; keep preferences concise`);
85
+ const text = await readFile(file, 'utf8');
86
+ return meaningful(text) ? contextText(text) : '';
87
+ }
88
+
89
+ /**
90
+ * Project-authored background plus the person's own preferences, never policy
91
+ * or evaluator instructions. `preferences` appears only when set, so existing
92
+ * checks keep their identity.
93
+ */
21
94
  export async function loadProjectContext(repoRoot, { env = process.env, ignore } = {}) {
95
+ const preferences = await loadPersonalContext(env).catch(error => { throw new Error(`Cannot read your Sphinx preferences: ${error.message}`); });
96
+ const project = await loadRepositoryContext(repoRoot, { env, ignore });
97
+ return preferences ? { ...project, preferences, hash: hash(`${project.hash}\0${preferences}`) } : project;
98
+ }
99
+
100
+ async function loadRepositoryContext(repoRoot, { env, ignore }) {
22
101
  const policy = ignore || await loadIgnorePolicy(repoRoot, { env });
23
102
  const empty = reason => ({ path: PROJECT_CONTEXT_FILE, text: '', hash: hash(reason) });
24
103
  // Do not even stat/open an explicitly excluded context file.
@@ -35,7 +114,10 @@ export async function loadProjectContext(repoRoot, { env = process.env, ignore }
35
114
  if (bytesRead > PROJECT_CONTEXT_MAX_BYTES) throw new Error(`exceeds ${PROJECT_CONTEXT_MAX_BYTES} bytes`);
36
115
  const text = buffer.subarray(0, bytesRead).toString('utf8');
37
116
  if (text.includes('\0')) throw new Error('must be UTF-8 text');
38
- return { path: PROJECT_CONTEXT_FILE, text, hash: hash(text) };
117
+ // An untouched template says nothing; comments never reach a model.
118
+ if (!meaningful(text)) return empty('template');
119
+ // The identity hash stays on the file's bytes, so upgrading keeps existing checks.
120
+ return { path: PROJECT_CONTEXT_FILE, text: contextText(text), hash: hash(text) };
39
121
  } catch (error) {
40
122
  if (error.code === 'ENOENT') return empty('absent');
41
123
  throw new Error(`Cannot read ${PROJECT_CONTEXT_FILE}: ${error.code === 'ELOOP' ? 'symlinks are not allowed' : error.code || error.message}`);
package/src/repo-setup.js CHANGED
@@ -5,7 +5,7 @@ import path from 'node:path';
5
5
  import { promisify } from 'node:util';
6
6
  import { loadConfig, repositoryConfigInfo, readJson, REPOSITORY_CONFIG_KEYS, stableStringify, validateRepositoryConfig } from './config.js';
7
7
  import { compileIgnore, loadIgnorePolicy } from './ignore.js';
8
- import { PROJECT_CONTEXT_FILE, PROJECT_CONTEXT_MAX_BYTES, PROJECT_CONTEXT_TEMPLATE } from './repo-context.js';
8
+ import { PROJECT_CONTEXT_FILE, PROJECT_CONTEXT_MAX_BYTES, isContextTemplate, contextWithDescription } from './repo-context.js';
9
9
  import { initializeRepository } from './repo-config.js';
10
10
 
11
11
  // Agent-driven repository setup: a read-only proposal, then a validated apply.
@@ -171,7 +171,7 @@ export async function suggestRepositorySetup({ repoRoot, env = process.env }) {
171
171
  const policy = await loadIgnorePolicy(root, { env, exclude: effective.exclude });
172
172
  const branch = await detectBranch(root);
173
173
  const existingContext = await readRegular(path.join(root, PROJECT_CONTEXT_FILE));
174
- const contextIsTemplate = existingContext === null || existingContext === PROJECT_CONTEXT_TEMPLATE;
174
+ const contextIsTemplate = existingContext === null || isContextTemplate(existingContext);
175
175
  let config = null;
176
176
  if (!info.exists || info.legacy) {
177
177
  const defaults = info.exists ? null : await loadConfig(undefined, env);
@@ -268,7 +268,7 @@ export async function applyRepositorySetup({ repoRoot, proposal, config, env = p
268
268
  const ignorePath = path.join(root, '.sphinxignore'), contextPath = path.join(root, PROJECT_CONTEXT_FILE);
269
269
  // Checks symlinks and sizes before initializeRepository makes its first write.
270
270
  const [ignoreBefore, contextBefore] = await Promise.all([readRegular(ignorePath), readRegular(contextPath)]);
271
- const replaceTemplate = contextBefore === PROJECT_CONTEXT_TEMPLATE && plan.context !== null;
271
+ const replaceTemplate = isContextTemplate(contextBefore) && plan.context !== null;
272
272
  const missingIgnore = plan.ignore.filter(line => !new Set((ignoreBefore ?? '').split(/\r?\n/).map(value => value.trim())).has(line));
273
273
  if (Buffer.byteLength(`${ignoreBefore ?? ''}${missingIgnore.join('\n')}`) > IGNORE_MAX_BYTES) throw invalid([`.sphinxignore would exceed ${IGNORE_MAX_BYTES} bytes`]);
274
274
 
@@ -284,10 +284,10 @@ export async function applyRepositorySetup({ repoRoot, proposal, config, env = p
284
284
  if (!created.includes('.sphinxignore')) { updated.push('.sphinxignore'); preserved.splice(preserved.indexOf('.sphinxignore'), 1); }
285
285
  }
286
286
  }
287
- if (replaceTemplate && await readRegular(contextPath) === PROJECT_CONTEXT_TEMPLATE) {
288
- await writeRegular(contextPath, plan.context, constants.O_TRUNC);
287
+ if (replaceTemplate && isContextTemplate(await readRegular(contextPath))) {
288
+ await writeRegular(contextPath, contextWithDescription(plan.context), constants.O_TRUNC);
289
289
  updated.push(PROJECT_CONTEXT_FILE); preserved.splice(preserved.indexOf(PROJECT_CONTEXT_FILE), 1);
290
- } else if (plan.context !== null && !created.includes(PROJECT_CONTEXT_FILE) && await readRegular(contextPath) !== plan.context) {
290
+ } else if (plan.context !== null && !created.includes(PROJECT_CONTEXT_FILE) && await readRegular(contextPath) !== contextWithDescription(plan.context)) {
291
291
  warnings.push(`${PROJECT_CONTEXT_FILE} already exists and was kept; edit it directly to change it.`);
292
292
  }
293
293
  if (plan.config && !created.includes('.sphinxconfig')) {
package/src/sessions.js CHANGED
@@ -7,6 +7,7 @@ import { DEFAULT_CONFIG, getPaths, readJson, writeJson, policyHash } from './con
7
7
  import { selectRoute, evaluatorConfig, questionLimit } from './routing.js';
8
8
  import { generateAssessment, gradeAnswer } from './evaluator.js';
9
9
  import { setTimeout as delay } from 'node:timers/promises';
10
+ import { hasPreferences } from './repo-context.js';
10
11
 
11
12
  const now = () => new Date().toISOString();
12
13
  export function sessionFile(id, env = process.env) {
@@ -281,8 +282,10 @@ export function createSessionStore({ env = process.env, generate = generateAsses
281
282
  try {
282
283
  const result = await withUsageMeter(meterFor(session), () => withUsagePurpose('review', () => generate(session.snapshot,evaluatorConfig(config,session.route),{beforeCall,signal,maxReviewerCalls,reviewerCheckpoint,onProgress,env})));
283
284
  signal?.throwIfAborted();
284
- // The reviewer, confirmed by its audit, may decide a small change needs no questions.
285
- if (Array.isArray(result.questions) && !result.questions.length && session.route.tier === 'small' && typeof result.noQuestionReason === 'string' && result.noQuestionReason.trim()) {
285
+ // The reviewer, confirmed by its audit, may decide a change needs no questions:
286
+ // a small one with nothing to understand, or, when the person has stated
287
+ // preferences, one entirely in an area they skip.
288
+ if (Array.isArray(result.questions) && !result.questions.length && (session.route.tier === 'small' || hasPreferences(session.snapshot)) && typeof result.noQuestionReason === 'string' && result.noQuestionReason.trim()) {
286
289
  await validate(session.snapshot, config);
287
290
  signal?.throwIfAborted();
288
291
  if (result.review) session.review = result.review;
@@ -339,7 +342,7 @@ export function createSessionStore({ env = process.env, generate = generateAsses
339
342
  // Capture after charging the grade: cancellation rolls back progress, not
340
343
  // model usage, and leaves the same question available to the human.
341
344
  const beforeAnswer = structuredClone(session);
342
- session.attempts.push({questionId:question.id,answer:text.trim(),verdict:result.verdict,feedback:result.feedback,at:now()});
345
+ 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
346
  // Score each question once, on its first real grade. A dispute or an
344
347
  // ambiguous grade leaves it unscored; asking for an explanation counts as a miss.
345
348
  const scoredIndex = session.index;
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
+ }