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 CHANGED
@@ -137,6 +137,8 @@ Pushing the same change again does not ask again. A pass lasts 24 hours, unless
137
137
  - **Skip this time.** Lets the push through immediately, even while the review is still running. Skips never count as passing.
138
138
  - **`sphinx yolo`** turns checks off for the current repository, or globally when run outside one. Run it again to turn checks back on. A repository's own setting wins over the global one, so turning yolo off inside a repository keeps checks on there even while global yolo is on. `--reason "prod hotfix"` is optional.
139
139
  - **`sphinx check`** runs the same check in your terminal instead of the browser.
140
+ - **Headless or terminal-first.** Over SSH, or anywhere Sphinx can't open the browser in the background, the questions appear right in the terminal where you ran `git push`. Type `/hint`, `/learn`, `/dispute <reason>`, `/skip`, or `/quit` there. To always use the terminal, run `sphinx config --interface terminal`; to always use the browser, use `--interface browser`.
141
+ - **On GitHub.** If the GitHub CLI (`gh`) is signed in, a pass marks the pushed commit with a `sphinx/understanding` status. You can also opt in to a summary comment on the pull request. This never blocks a push. See [GitHub commit status and pull requests](docs/configuration.md#github-commit-status-and-pull-requests).
140
142
 
141
143
  Every skip and yolo toggle is logged with its reason, and `sphinx status` lists recent ones. If Git exited before you finished, for example because your agent's shell timed out, run the push once more. Never start a second copy while one is still waiting. For more cases, see [troubleshooting](docs/integrations.md#troubleshooting).
142
144
 
@@ -144,7 +146,7 @@ Your answers go straight from the page to Sphinx. Your coding agent sees only th
144
146
 
145
147
  ## Limits and cost
146
148
 
147
- Question count depends on the size of the change and the depth you choose. Small changes get one question, and large ones get up to three at the default depth. After commits, Sphinx prepares questions in the background, so they are often ready by the time you push. Calls, tokens, and (when reported) cost appear in the questionnaire.
149
+ Question count depends on the size of the change and the depth you choose: small changes get up to one question (none for documentation or trivial edits), and large ones up to three at the default depth. Understanding distance sets how close to the code questions get: `high` (default) asks what your change does and affects, `low` asks about its specific logic and edge cases. Set it in setup, with `sphinx config --distance low`, or per repository in `.sphinxconfig`. After commits, Sphinx prepares questions in the background, so they are often ready by the time you push. Calls, tokens, and (when reported) cost appear in the questionnaire.
148
150
 
149
151
  ```sh
150
152
  sphinx config --max-model-calls 30 # optional cap per check (1–100)
@@ -146,7 +146,7 @@ The existing line, file-count, and consequential-path rules deterministically se
146
146
  | Medium | 2 | 2 | 3 |
147
147
  | Large | 2 | 3 | 5 |
148
148
 
149
- The evaluator must return at least one question and may use fewer than the ceiling when fewer distinct behaviors are supported. It must not invent extra concerns to fill the budget. Model routes remain independent of depth. `maxOutputTokens` is preserved across all tiers; OpenRouter enforces it, while CLI evaluators receive concise-output instructions and local process limits.
149
+ The evaluator may use fewer questions than the ceiling when fewer distinct behaviors are supported. Medium and large changes get at least one; a small change may get none when the audited review finds nothing a developer must understand, and documentation-only changes pass without a model call. The configured understanding distance (`high` or `low`) sets whether questions address the change's behavior and effects or its specific implementation logic. It must not invent extra concerns to fill the budget. Model routes remain independent of depth. `maxOutputTokens` is preserved across all tiers; OpenRouter enforces it, while CLI evaluators receive concise-output instructions and local process limits.
150
150
 
151
151
  The economy CLI presets select `codex:gpt-6-luna` or `claude:haiku` for all tiers, preserving the cheaper model while effort and review planning targets scale. The paid OpenRouter value preset selects `anthropic/claude-haiku-5.5` for every tier and sets an 8,192-token output ceiling; an explicit output flag takes precedence. Applying the preset preserves the total call limit. The experimental open-weight alternative selects `deepseek/deepseek-v3.2`. Existing routes are never migrated implicitly. See [the evaluation record](../eval/RESULTS.md) for measured results and failures; these starting configurations are not guarantees of assessment quality. OpenRouter routes consume API credits separately from coding-agent plans; Codex and Claude routes consume the selected CLI account's allowance. Pi, Cursor, and OpenCode client integrations do not supply a model transport or reuse those clients' credentials.
152
152
 
@@ -10,6 +10,8 @@ This page is the reference for reviewer models, usage limits, repository policy,
10
10
  - [Project context](#project-context)
11
11
  - [Files Sphinx never reads](#files-sphinx-never-reads)
12
12
  - [Background preparation](#background-preparation)
13
+ - [GitHub commit status and pull requests](#github-commit-status-and-pull-requests)
14
+ - [Browser or terminal](#browser-or-terminal)
13
15
 
14
16
  ## Settings locations
15
17
 
@@ -165,6 +167,7 @@ Call caps bound requests and byte limits bound evidence. Neither is a dollar or
165
167
  "events": ["pre-push"],
166
168
  "branches": ["main", "release/*"],
167
169
  "level": "standard",
170
+ "distance": "high",
168
171
  "exclude": ["vendor/**"]
169
172
  }
170
173
  ```
@@ -176,8 +179,10 @@ Call caps bound requests and byte limits bound evidence. Neither is a dollar or
176
179
  | `branches` | Push policies match the **destination** branch; commit policies match the current local branch |
177
180
  | `remotes` | Remote name patterns for pushes; default `*` |
178
181
  | `level` | `relaxed`, `standard`, or `thorough` |
182
+ | `distance` | `high` (default): questions about what the change does and affects; `low`: questions about its specific logic, conditions, and edge cases |
179
183
  | `exclude` | Extra ignore patterns, added to machine-wide ones |
180
184
  | `backgroundPreparation` | `false` opts this repository out |
185
+ | `github` | GitHub stamps; see [below](#github-commit-status-and-pull-requests) |
181
186
 
182
187
  Branch defaults come from machine-wide settings (initially `main` and `master`), not from the branch you happen to have checked out. Without prompts:
183
188
 
@@ -191,6 +196,8 @@ If both `.sphinxconfig` and legacy `.sphinx.json` exist, `.sphinxconfig` wins an
191
196
 
192
197
  **Manual push checks.** `sphinx check --remote origin --branch main` selects the destination; otherwise Sphinx uses the upstream. Multiple destination refs are assessed separately. Tag-only pushes and deletions do not trigger checks in v0.1.
193
198
 
199
+ **Pull request branches.** Set `"branches": ["*"]` to check pushes to every branch. The first push of a new branch is checked from where it left the remote's default branch (the same range the pull request shows), using local remote-tracking refs; later pushes check only the new commits.
200
+
194
201
  ## Project context
195
202
 
196
203
  `.sphinxcontext.md` describes what the project does, who uses it, important workflows, and architecture boundaries. It is included in every reviewer, compaction, and grading request.
@@ -239,3 +246,35 @@ After commits, merges, rewrites, and checkouts, Sphinx schedules a detached revi
239
246
  - Set `backgroundPreparation: false` in `config.json` or `.sphinxconfig` to opt out. A repository cannot re-enable preparation disabled machine-wide.
240
247
  - While yolo is on, preparation is paused: no new model request starts, although one already in progress may finish.
241
248
  - A first push, a branch without local tracking history, or a push to an unexpected destination needs a cold review at push time. Pre-commit checks always review on demand. Run `git fetch` if remote history is missing.
249
+
250
+ ## GitHub commit status and pull requests
251
+
252
+ When you push to GitHub and a check passes, Sphinx marks the pushed commit with a `sphinx/understanding` commit status, for example "Understood: 3 questions, 2 first try". It shows next to the commit and in the pull request's checks. A skipped check is marked as a failure ("Understanding check skipped"). Yolo pushes and commit checkpoints are not marked.
253
+
254
+ ```json
255
+ { "github": { "status": true, "comment": false, "answers": false } }
256
+ ```
257
+
258
+ | Key | Default | Meaning |
259
+ | --- | --- | --- |
260
+ | `status` | `true` | Post the commit status |
261
+ | `comment` | `false` | Keep one comment on the branch's open pull request listing every check on that branch: questions, first-try results, and the model. It is edited in place on later pushes |
262
+ | `answers` | `false` | Also include your answers in that comment |
263
+
264
+ Set these in `config.json` for yourself or in `.sphinxconfig` for a repository. Private answer keys are never posted.
265
+
266
+ It uses the [GitHub CLI](https://cli.github.com) and its existing login, in a detached process after Git continues, so it never slows or blocks a push. If `gh` is missing or not signed in, the remote isn't GitHub, or any request fails, nothing is posted and nothing else changes. The worker waits a few minutes for the push to land and up to 15 minutes for a pull request to be opened; the result of the last run is in `github/last.json` in Sphinx's data directory.
267
+
268
+ **Requiring it for a team.** In the repository's branch protection or ruleset, add `sphinx/understanding` as a required status check. Pull requests whose head commit was not pushed through a passing check then cannot merge. The status is self-reported by the pusher's machine: it shows the check happened, but it is not a cryptographic proof, and anyone with write access can set a commit status.
269
+
270
+ ## Browser or terminal
271
+
272
+ `interface` in `config.json` chooses where a push's questions appear. Set it with `sphinx config --interface <auto|browser|terminal>`.
273
+
274
+ | Value | Behavior |
275
+ | --- | --- |
276
+ | `auto` (default) | The browser on a local Mac, where Sphinx can open it in the background. Elsewhere (SSH, Linux, WSL), inline in the terminal you ran Git from |
277
+ | `terminal` | Always the terminal |
278
+ | `browser` | Always the browser (on Linux and over SSH, open the printed local URL) |
279
+
280
+ Inline questions need a terminal a person is looking at. When Git runs from a coding agent's shell there isn't one, so Sphinx opens a terminal window on a desktop instead, and falls back to the browser when it can't do either. `interface` is a personal setting; it can't be set in `.sphinxconfig` and doesn't change what a check asks.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sphinx-understand",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "You can outsource your thinking, but not your understanding. A comprehension checkpoint for agent-assisted development.",
5
5
  "homepage": "https://github.com/parthh01/sphinx#readme",
6
6
  "repository": {
@@ -1,4 +1,5 @@
1
1
  import { createServer } from 'node:http';
2
+ import { readStats, scoreSummary } from './stats.js';
2
3
  import { planProviders } from './evaluator.js';
3
4
  import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
4
5
  import { spawn } from 'node:child_process';
@@ -6,12 +7,13 @@ import { setTimeout as delay } from 'node:timers/promises';
6
7
  import { loadConfig, policyHash } from './config.js';
7
8
  import { revalidateSnapshot } from './git.js';
8
9
  import { createSessionStore } from './sessions.js';
9
- import { prepareInteractiveCheck, isRateLimit, switchChoices, PLAN_NAMES } from './check-recovery.js';
10
+ import { prepareInteractiveCheck, isRateLimit, isCliFailure, switchChoices, PLAN_NAMES } from './check-recovery.js';
10
11
  import { claimCheckpointDialog, finishCheckpointDialog, waitForCheckpointTerminal } from './terminal-launch.js';
11
12
  import { browserPage } from './browser-page.js';
12
13
  import { recordOverride } from './overrides.js';
13
14
  import { withTextStream, partialField } from './stream.js';
14
15
  import { loadChangeView } from './change-view.js';
16
+ import { updateStatus, updatePlan, scheduleUpdate } from './update.js';
15
17
 
16
18
  const ID = /^[a-f0-9]{64}$/u;
17
19
  const cancelled = () => Object.assign(new Error('Check paused. Progress is saved.'), { code: 'SPHINX_CANCELLED' });
@@ -53,17 +55,21 @@ export function createBrowserInterview({ env = process.env, signal, timeoutMs =
53
55
  store: providedStore, readConfig = loadConfig, revalidate = revalidateSnapshot,
54
56
  reserve = claimCheckpointDialog, release = finishCheckpointDialog, waitForExisting = waitForCheckpointTerminal,
55
57
  openBrowser = openQuestionnaireBrowser, output = line => process.stderr.write(`${line}\n`), logOverride = recordOverride, findPlans = planProviders,
56
- loadView = loadChangeView,
58
+ loadView = loadChangeView, checkUpdate = updateStatus, planUpdate = updatePlan, startUpdate = scheduleUpdate,
57
59
  } = {}) {
58
60
  const token = randomBytes(32).toString('base64url'), nonce = randomBytes(24).toString('base64');
59
61
  let server, origin, url, opened = false, closed = false, active, skip, resolver, sequence = 0, waitingGit = false;
60
62
  const pauseMessage = () => waitingGit
61
- ? 'Paused. Progress is saved; the waiting Git operation was stopped.'
62
- : 'Paused. Your progress and completed checkpoints are saved.';
63
+ ? 'Paused. Progress is saved; the waiting Git operation was stopped. You can close this tab.'
64
+ : 'Paused. Your progress and completed checkpoints are saved. You can close this tab.';
63
65
  let state = { phase: 'waiting', operationClosed: false, revision: randomUUID(), sequence, message: 'Waiting for the checkpoint.', messages: [] };
64
66
  // The chat transcript for the current checkpoint lives only in this process.
65
67
  // It contains public questions, feedback, and the human's own text.
66
- let transcript = [], turns = 0;
68
+ let transcript = [], turns = 0, score = null;
69
+ // The installed version and whether npm has a newer one. Display only.
70
+ let update = null;
71
+ // The repository's understanding score, refreshed after every graded answer.
72
+ const refreshScore = async (repoRoot, operationEnv) => { score = scoreSummary(await readStats(repoRoot, operationEnv)); };
67
73
  // Read-only display of the checkpoint's frozen comparison. It is loaded
68
74
  // beside the questionnaire and can never block, grade, or approve it.
69
75
  let view, evidence, evidenceKey;
@@ -91,7 +97,7 @@ export function createBrowserInterview({ env = process.env, signal, timeoutMs =
91
97
  // Display-only updates (change view, evidence) keep the revision, so they
92
98
  // never turn an answer already in flight into a stale submission.
93
99
  const publish = (next, { display = false } = {}) => {
94
- state = { ...next, messages: transcript, change: viewState(), evidence: evidence && evidence.checkpoint === next.id ? { questionId: evidence.questionId, refs: evidence.refs } : undefined, waitingGit, operationClosed: closed, revision: display ? next.revision : randomUUID(), sequence: ++sequence };
100
+ state = { ...next, messages: transcript, ...(score ? { score } : {}), ...(update ? { update } : {}), change: viewState(), evidence: evidence && evidence.checkpoint === next.id ? { questionId: evidence.questionId, refs: evidence.refs } : undefined, waitingGit, operationClosed: closed, revision: display ? next.revision : randomUUID(), sequence: ++sequence };
95
101
  const event = `data: ${JSON.stringify(state)}\n\n`;
96
102
  for (const stream of streams) writeEvent(stream, event);
97
103
  };
@@ -138,6 +144,15 @@ export function createBrowserInterview({ env = process.env, signal, timeoutMs =
138
144
  }
139
145
  if (req.method !== 'POST' || segments[2] !== 'action') return send(res, 405, { error: 'Unsupported method.' });
140
146
  if (req.headers.origin !== origin || !equal(req.headers['x-sphinx-token'], token) || req.headers['content-type']?.split(';')[0] !== 'application/json') return send(res, 403, { error: 'Forbidden request.' });
147
+ if (new URL(req.url, origin).searchParams.get('update') === '1') {
148
+ // Independent of the check: the install runs only after this process exits.
149
+ if (!update?.available || update.status === 'scheduled') return send(res, 409, { error: 'No update to install.' });
150
+ const plan = await planUpdate().catch(() => null);
151
+ const started = !!plan?.command && await startUpdate({ command: plan.command, env });
152
+ update = { ...update, status: started ? 'scheduled' : 'failed' };
153
+ publish(state, { display: true });
154
+ return send(res, 200, { update });
155
+ }
141
156
  let bytes = 0; const chunks = [];
142
157
  // 8,000 characters can occupy 24KB in UTF-8 or 48KB when JSON-escaped.
143
158
  for await (const chunk of req) { bytes += chunk.length; if (bytes > 64 * 1024) { send(res, 413, { error: 'Keep your response below 8,000 characters.' }); req.destroy(); return; } chunks.push(chunk); }
@@ -150,7 +165,7 @@ export function createBrowserInterview({ env = process.env, signal, timeoutMs =
150
165
  // Available at any step, including while the review is still running.
151
166
  skip = { reason: data.response };
152
167
  active.abort(cancelled());
153
- publish({ ...publicFields(state), phase: 'skipped', message: waitingGit ? 'Skipped. Git is continuing.' : 'Skipped.' });
168
+ publish({ ...publicFields(state), phase: 'skipped', message: waitingGit ? 'Skipped. Git is continuing; you can close this tab.' : 'Skipped. You can close this tab.' });
154
169
  return send(res, 200, state);
155
170
  }
156
171
  if (data.action === 'pause') {
@@ -181,6 +196,7 @@ export function createBrowserInterview({ env = process.env, signal, timeoutMs =
181
196
  try { await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); }); }
182
197
  catch (error) { server = undefined; throw error; }
183
198
  origin = `http://127.0.0.1:${server.address().port}`; url = `${origin}/${token}`;
199
+ Promise.resolve().then(() => checkUpdate({ env: operationEnv })).then(result => { update = result; publish(state, { display: true }); }).catch(() => {});
184
200
  output(`Sphinx questionnaire: ${url}`);
185
201
  if (!opened) {
186
202
  opened = true;
@@ -255,6 +271,7 @@ export function createBrowserInterview({ env = process.env, signal, timeoutMs =
255
271
  if (session.snapshot.repoRoot !== request.repoRoot) throw new Error('This checkpoint belongs to another repository.');
256
272
  await validate(session.snapshot, config);
257
273
  startView(request, session.snapshot, config, operationEnv, store);
274
+ await refreshScore(request.repoRoot, operationEnv).catch(() => {});
258
275
  let visible = store.public(session);
259
276
  publish({ ...publicFields(visible), repoRoot: request.repoRoot, phase: 'working', message: 'Preparing your understanding check…' });
260
277
  await ensureServer(operationEnv);
@@ -277,7 +294,7 @@ export function createBrowserInterview({ env = process.env, signal, timeoutMs =
277
294
  },
278
295
  choose: async prompt => {
279
296
  const current = store.public(await store.read(request.id));
280
- const action = await waitForAction({ ...publicFields(current), repoRoot: request.repoRoot, phase: 'recovery', message: prompt.title, error: notes.join('\n'), choices: prompt.choices }, prompt.choices.map(choice => choice.value));
297
+ const action = await waitForAction({ ...publicFields(current), repoRoot: request.repoRoot, phase: 'recovery', message: prompt.title, error: [prompt.description, ...notes].filter(Boolean).join('\n'), choices: prompt.choices }, prompt.choices.map(choice => choice.value));
281
298
  return action.action;
282
299
  },
283
300
  });
@@ -306,15 +323,16 @@ export function createBrowserInterview({ env = process.env, signal, timeoutMs =
306
323
  learn: action.action === 'learn', dispute: action.action === 'dispute', expectedQuestionId: action.questionId, signal: localSignal,
307
324
  })).finally(dropDraft);
308
325
  settle('graded');
326
+ await refreshScore(request.repoRoot, operationEnv).catch(() => {});
309
327
  if (visible.feedback) say({ role: 'sphinx', kind: 'feedback', text: visible.feedback });
310
328
  } catch (error) {
311
329
  settle('not-graded');
312
330
  localSignal.throwIfAborted(); await validate(session.snapshot, config);
313
331
  visible = store.public(await store.read(request.id));
314
332
  // A rate-limited API route can hand grading to the user's coding-agent plan.
315
- if (isRateLimit(error, visible.route) && plans.length) {
333
+ if ((isRateLimit(error, visible.route) || isCliFailure(error, visible.route)) && switchChoices(plans, visible.route.provider).length) {
316
334
  const choices = [...switchChoices(plans, visible.route.provider), { value: 'back', label: 'Back to the question', description: 'Keep the current model and try again later. Your draft answer is kept.' }];
317
- const picked = await waitForAction({ ...publicFields(visible), repoRoot: request.repoRoot, phase: 'recovery', message: 'Grading was rate limited', error: error.message, choices }, choices.map(choice => choice.value));
335
+ const picked = await waitForAction({ ...publicFields(visible), repoRoot: request.repoRoot, phase: 'recovery', message: isRateLimit(error, visible.route) ? 'Grading was rate limited' : `${PLAN_NAMES[visible.route.provider][0]} could not grade this answer`, error: error.message, choices }, choices.map(choice => choice.value));
318
336
  if (picked.action.startsWith('switch-')) {
319
337
  const provider = picked.action.slice('switch-'.length);
320
338
  visible = await store.switchRoute(request.id, config, provider, { signal: localSignal });
@@ -1,4 +1,5 @@
1
1
  import { sphinxMark } from './logo.js';
2
+ import { VERSION } from './version.js';
2
3
  /** Static UI only. Model-authored content always enters the DOM as textContent. */
3
4
  export function browserPage({ token, nonce }) {
4
5
  return `<!doctype html>
@@ -11,13 +12,14 @@ button:focus-visible,textarea:focus-visible,input:focus-visible,[tabindex]:focus
11
12
  .shell{height:100vh;height:100dvh;display:flex;flex-direction:column}
12
13
  header{min-height:52px;border-bottom:1px solid var(--line);padding:8px 24px;display:flex;align-items:center;justify-content:space-between;gap:16px}
13
14
  .brand{display:flex;align-items:center;gap:14px}.brand strong{display:flex;align-items:center;gap:9px;font:600 19px ui-monospace,SFMono-Regular,Menlo,monospace}.brand svg{color:var(--amber);flex-shrink:0}.brand span{color:var(--muted);font-size:14px}
15
+ .brand small{font:12px var(--mono);color:var(--muted);margin-left:-8px}.update{font-size:12px;padding:2px 10px;border:1px solid var(--line);border-radius:999px;background:transparent;color:var(--amber);cursor:pointer;white-space:nowrap}.update:hover:not(:disabled){border-color:var(--amber)}.update:disabled{color:var(--muted);cursor:default}
14
16
  .scope{border-bottom:1px solid var(--line);padding:7px 24px;font-size:13px;line-height:1.45;color:var(--muted)}
15
17
  .facts dt{display:none}.facts dd{font-weight:400!important}.facts div:has(#scope-range){display:none}.facts div+div::before{content:"·";margin-right:8px;color:#b7ab9c}.facts #meta{color:var(--ink);font-weight:600!important}.scope-note{display:none!important}
16
18
  .facts{display:flex;flex-wrap:wrap;gap:2px 20px;margin:0;align-items:baseline}.facts div{display:flex;gap:6px;min-width:0}.facts dt{color:var(--muted)}.facts dd{margin:0;font-weight:600;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}
17
19
  .facts .mono{font:600 12.5px var(--mono)}.scope-note{margin:2px 0 0;color:var(--muted);font-size:12px}#usage{color:var(--muted);font-weight:400;font-variant-numeric:tabular-nums}
18
20
  .tabs{display:none}.scope-short{display:none}
19
21
  .workspace{flex:1;min-height:0;display:grid;grid-template-columns:minmax(400px,46%) minmax(0,1fr);grid-template-rows:minmax(0,auto) minmax(0,1fr);grid-template-areas:"q files" "q diff"}
20
- #question-pane{grid-area:q;border-right:1px solid var(--line)!important}#files-pane{grid-area:files;max-height:30vh;border-right:0;border-bottom:1px solid var(--line)}#diff-pane{grid-area:diff;border-right:0}
22
+ #question-pane{grid-area:q;border-right:1px solid var(--line)!important}#files-pane{grid-area:files;max-height:30vh;overflow:hidden;border-right:0;border-bottom:1px solid var(--line)}#diff-pane{grid-area:diff;border-right:0}
21
23
  .pane{min-width:0;min-height:0;display:flex;flex-direction:column;border-right:1px solid var(--line)}.pane:last-child{border-right:0}
22
24
  .pane h2{font-size:13px;font-weight:600;margin:0;overflow-wrap:anywhere;line-height:1.4}.pane-note{font-size:12px;color:var(--muted);margin:0;padding:8px 14px;overflow-wrap:anywhere}
23
25
  .filters{display:grid;grid-template-columns:1fr 1fr;gap:6px;padding:10px 12px 6px}.filters button{min-height:44px;font-size:12.5px;line-height:1.25;padding:4px 8px}
@@ -61,8 +63,17 @@ button.primary{background:var(--amber);color:#fff;border-color:var(--amber);marg
61
63
  button:hover:not(:disabled){background:#eee7dc}button.primary:hover:not(:disabled){background:#8b4208}
62
64
  .disputing textarea{border-color:#8d271e}.disputing #submit{background:#8d271e;border-color:#8d271e}
63
65
  button.quiet{border-color:transparent;color:var(--muted)}button.quiet:hover:not(:disabled){color:var(--ink)}
64
- .hint{font-size:12px;color:var(--muted);margin:10px 0 0}.recovery{display:grid;gap:12px;margin:0 0 20px}.recovery button{text-align:left;width:100%}.recovery p{margin:4px 0 0;font-size:13px;color:var(--muted)}
66
+ .hint{font-size:12px;color:var(--muted);margin:10px 0 0}.recovery-reason{margin:-8px 0 18px;padding:10px 14px;border-left:2px solid #8d271e;background:#f8ece9;color:#6f1f18;font-size:13.5px;white-space:pre-wrap;overflow-wrap:anywhere}.recovery{display:grid;gap:12px;margin:0 0 20px}.recovery button{text-align:left;width:100%}.recovery p{margin:4px 0 0;font-size:13px;color:var(--muted)}
65
67
  header .tools{display:flex;gap:4px}
68
+ .score{display:grid;grid-template-columns:auto auto;grid-template-rows:auto auto;column-gap:14px;align-items:center;margin-left:auto;margin-right:14px;padding:6px 16px;border:1px solid var(--line);border-radius:12px;background:#fff}
69
+ .score-head{display:flex;flex-direction:column;line-height:1.15;grid-row:1/3}.score-label{font-size:11.5px;color:var(--muted)}.score-value{font:600 20px/1.1 var(--mono);font-variant-numeric:tabular-nums}
70
+ .score-value.pending{font-family:inherit;font-size:13px;font-weight:500;line-height:1.6;color:var(--muted)}
71
+ .score-dots{display:flex;gap:4px}.score-dots i{width:11px;height:11px;border-radius:50%;border:1.5px solid #cfc4b6;background:transparent}
72
+ .score-dots i.hit{background:var(--pass);border-color:var(--pass)}.score-dots i.miss{border-color:#c0574a;background:#f6e2df}
73
+ .score-dots i.fresh{animation:pop .5s ease}@keyframes pop{0%{transform:scale(.3)}70%{transform:scale(1.25)}100%{transform:scale(1)}}
74
+ .score-meta{font-size:11px;color:var(--muted);white-space:nowrap}
75
+ @media(prefers-reduced-motion:reduce){.score-dots i.fresh{animation:none}}
76
+ @media(max-width:600px){.score{margin-right:4px;padding:4px 10px}.score-meta{display:none}}
66
77
  .skipbar{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding:12px 0;border-bottom:1px solid var(--line);font-size:14px}.skipbar p{flex-basis:100%;margin:0;color:var(--muted);font-size:13px}
67
78
  .skipbar input{flex:1;min-width:180px;font:inherit;font-size:14px;padding:6px 10px;border:1px solid #b9afa3;border-radius:6px;background:#fff;color:var(--ink)}
68
79
  button.danger{background:#8d271e;border-color:#8d271e;color:#fff}button.danger:hover:not(:disabled){background:#6f1f18}
@@ -81,7 +92,7 @@ footer{flex-shrink:0;display:flex;flex-wrap:wrap;gap:2px 8px;padding:0 0 14px;co
81
92
  #question-pane{overflow-y:auto}.conversation{flex:none;overflow:visible;padding-right:0}.evidence{max-height:none;overflow:visible}}
82
93
  @media(max-width:600px){header{padding:8px 12px}.brand{gap:8px}.brand span{display:none}.scope{padding:6px 12px 7px;font-size:12.5px}.facts{gap:0 12px}.facts .mono{font-size:12px}#scope-note{display:none}.scope-short{display:block}.hint,footer{font-size:11.5px}main{padding:0 14px}.conversation{padding-top:16px}.turn.human{margin-left:16px}.actions{gap:4px}button{padding:7px 10px}.hint{max-width:34em}.diffhead{padding:8px 12px}.legend{padding:6px 12px}}
83
94
  </style></head><body><div class="shell">
84
- <header><div class="brand"><strong>${sphinxMark({ size: 26, id: 'brand-mark' })}sphinx</strong><span>Understanding check</span></div><div class="tools"><button id="skip" class="quiet" type="button">Skip this time</button><button id="pause" class="quiet" type="button">Pause check</button></div></header>
95
+ <header><div class="brand"><strong>${sphinxMark({ size: 26, id: 'brand-mark' })}sphinx</strong><small id="version">v${VERSION}</small><button id="update" class="update" type="button" hidden></button><span>Understanding check</span></div><div id="score" class="score" hidden><span class="score-head"><span class="score-label">Your understanding</span><strong id="score-value" class="score-value"></strong></span><span id="score-dots" class="score-dots" role="img"></span><span id="score-meta" class="score-meta"></span></div><div class="tools"><button id="skip" class="quiet" type="button">Skip this time</button><button id="pause" class="quiet" type="button">Pause check</button></div></header>
85
96
  <section class="scope" aria-label="Change under review"><dl class="facts"><div><dt>Repository</dt><dd id="meta"></dd></div><div><dt id="scope-op-label">Operation</dt><dd id="scope-op">Loading…</dd></div><div><dt>Comparison</dt><dd id="scope-range" class="mono">Loading…</dd></div><div><dt>Changed</dt><dd id="scope-stats">Loading…</dd></div><div id="scope-excluded-row" hidden><dt>Excluded</dt><dd id="scope-excluded"></dd></div><div><dt>Progress</dt><dd id="scope-progress">Connecting…</dd></div><div><dd id="usage"></dd></div></dl>
86
97
  <p id="scope-note" class="scope-note">Checkpoint changes: the exact range Sphinx froze for this check, not necessarily an entire pull request.</p><p class="scope-note scope-short">Checkpoint range only — not necessarily a whole pull request.</p></section>
87
98
  <nav class="tabs" role="tablist" aria-label="Questionnaire views"><button id="tab-question" role="tab" type="button" aria-controls="question-pane" aria-selected="true">Question</button><button id="tab-files" role="tab" type="button" aria-controls="files-pane" aria-selected="false">Files</button><button id="tab-diff" role="tab" type="button" aria-controls="diff-pane" aria-selected="false">Diff</button></nav>
@@ -102,7 +113,7 @@ footer{flex-shrink:0;display:flex;flex-wrap:wrap;gap:2px 8px;padding:0 0 14px;co
102
113
  <div id="conversation" class="conversation" aria-label="Understanding check conversation">
103
114
  <ol id="messages" aria-label="Conversation"></ol>
104
115
  <p id="feedback" class="feedback" hidden></p><section id="question" hidden><p id="prompt" class="question"></p><ul id="choices" hidden></ul></section>
105
- <p id="message" class="message" role="status" hidden></p><div id="status" class="status" role="status" aria-live="polite">Connecting to your local checkpoint…</div><div id="recovery" class="recovery" hidden></div>
116
+ <p id="message" class="message" role="status" hidden></p><div id="status" class="status" role="status" aria-live="polite">Connecting to your local checkpoint…</div><p id="recovery-reason" class="recovery-reason" hidden></p><div id="recovery" class="recovery" hidden></div>
106
117
  </div>
107
118
  <section id="evidence" class="evidence" aria-labelledby="evidence-title" hidden><h2 id="evidence-title">Code behind this question</h2><ul id="evidence-list"></ul><p id="evidence-note" class="pane-note"></p></section>
108
119
  <div id="composer" class="composer"><p id="error" class="error" role="alert" hidden></p>
@@ -120,6 +131,32 @@ const usageText=(u,t)=>{if(!t)return '';const total=(t.input||0)+(t.output||0);i
120
131
  return [compact(total)+' tokens',...(t.cost!=null?[t.cost===0?'$0':t.cost<0.0001?'<$0.0001':t.cost<0.01?'$'+t.cost.toFixed(4):'$'+t.cost.toFixed(2)]:[]),...(u?.limit!=null?[u.calls+'/'+u.limit+' calls']:[])].join(' · ');};
121
132
  const PROVIDERS={openrouter:'OpenRouter',openai:'Endpoint',claude:'Claude Code',codex:'Codex'};
122
133
  const routeText=r=>r?.provider?[PROVIDERS[r.provider]||r.provider,r.model].filter(Boolean).join(' '):'';
134
+ // The repository's understanding score, shown rather than explained: one dot
135
+ // per recent answer (green = right first time), filling empty slots while calibrating.
136
+ let lastScored=-1;
137
+ function scoreView(sc){
138
+ $('score').hidden=!sc;if(!sc)return;
139
+ const recent=sc.recent||[],slots=sc.calibrating?sc.answered+sc.needed:Math.max(recent.length,1);
140
+ const dots=[];
141
+ for(let i=0;i<slots;i++){const dot=document.createElement('i'),hit=recent[i];
142
+ if(i<recent.length){dot.className=hit?'hit':'miss';dot.title=hit?'Right first time':'Needed another try';}else dot.title='Not answered yet';
143
+ if(i===recent.length-1&&lastScored!==-1&&sc.answered>lastScored)dot.className+=' fresh';
144
+ dots.push(dot);}
145
+ $('score-dots').replaceChildren(...dots);
146
+ $('score-dots').setAttribute('aria-label',recent.filter(Boolean).length+' of the last '+recent.length+' answers right first time');
147
+ $('score-value').textContent=sc.calibrating?'warming up':sc.accuracy+'%';$('score-value').className='score-value'+(sc.calibrating?' pending':'');
148
+ $('score').title=sc.calibrating?'Answer '+sc.needed+' more question'+(sc.needed===1?'':'s')+' in this repository to get a score.':'Share of questions in this repository you answered right on the first try.';
149
+ const meta=[];if(sc.streak>1)meta.push(sc.streak+' in a row');if(sc.skipRate!=null)meta.push('skipped '+sc.skipRate+'%');
150
+ $('score-meta').textContent=meta.join(' · ');lastScored=sc.answered;
151
+ }
152
+ // A newer release is offered quietly; installing waits until this check ends.
153
+ function updateView(u){const b=$('update');if(!u?.available){b.hidden=true;return;}b.hidden=false;
154
+ if(u.status==='scheduled'){b.textContent='Updating to v'+u.latest+' after this check';b.disabled=true;b.title='';}
155
+ else if(u.status==='failed'){b.textContent='Update could not start';b.disabled=true;b.title='Run '+(u.command||'npm install -g sphinx-understand@latest')+' in a terminal.';}
156
+ else if(!u.command){b.textContent='v'+u.latest+' available';b.disabled=true;b.title=u.note||'';}
157
+ else{b.textContent='v'+u.latest+' available · Update';b.disabled=false;b.title='Runs '+u.command+' once this check finishes. Your settings are kept.';}}
158
+ $('update').onclick=async()=>{const b=$('update');b.disabled=true;b.textContent='Scheduling update…';
159
+ try{const res=await fetch(base+'/action?update=1',{method:'POST',headers:{'Content-Type':'application/json','X-Sphinx-Token':token},body:'{}'});const data=await res.json();updateView(data.update||{available:true,status:'failed'});}catch{updateView({available:true,status:'failed'});}};
123
160
  const resize=()=>{$('response').style.height='auto';$('response').style.height=Math.min($('response').scrollHeight||76,180)+'px';};
124
161
  function controls(){
125
162
  if(!current)return;
@@ -169,11 +206,11 @@ function render(state){
169
206
  const previous=current;current=state;finished=state.operationClosed||['passed','paused','error','skipped'].includes(state.phase);
170
207
  const repo=state.repoRoot?.split(/[\\\\/]/).filter(Boolean).at(-1),branch=state.target?.replace(/^refs\\/heads\\//,'');
171
208
  text('meta',repo||state.change?.header?.repository||'');$('meta').title=state.repoRoot||'';
172
- text('usage',[routeText(state.route),usageText(state.usage,state.tokens)].filter(Boolean).join(' · '));const messages=state.messages||[];
209
+ text('usage',[routeText(state.route),usageText(state.usage,state.tokens)].filter(Boolean).join(' · '));scoreView(state.score);updateView(state.update);const messages=state.messages||[];
173
210
  text('waiting',finished?'':state.waitingGit?'Git continues after you pass. Closing this tab leaves it waiting; Pause check stops it.':'Closing this tab leaves the check waiting. Use Pause check to stop.');
174
211
  const label=state.phase==='question'?'Question '+(state.progress.completed+1)+' of '+state.progress.total:state.phase==='working'?'Reviewing…':state.phase==='recovery'?'Review needs your decision':state.phase==='passed'?'Passed':state.phase==='paused'?'Paused':state.phase==='skipped'?'Skipped':state.phase==='error'?'Check stopped':'Waiting…';
175
212
  const retry=state.phase==='question'&&[...messages].reverse().find(item=>item.kind==='question')?.followUp;
176
- text('status',label+(retry?' · Needs another answer':''));$('message').className='message'+(state.phase==='passed'?' passed':'');scope(state,branch,label);$('status').className='status phase-'+state.phase+(state.phase==='working'?' busy':'')+(retry?' retry':'');if(state.phase==='question'&&!retry)$('status').hidden=true;text('message',state.message);text('error',state.error); if(state.phase==='passed'&&String(state.message||'').includes('\\n')){const [lead,...rest]=state.message.split('\\n');text('message',lead);text('status',rest.join(' '));$('status').className='status passed-note';}
213
+ text('status',label+(retry?' · Needs another answer':''));$('message').className='message'+(state.phase==='passed'?' passed':'');scope(state,branch,label);$('status').className='status phase-'+state.phase+(state.phase==='working'?' busy':'')+(retry?' retry':'');if(state.phase==='question'&&!retry)$('status').hidden=true;text('message',state.message);text('error',state.phase==='recovery'?'':state.error);text('recovery-reason',state.phase==='recovery'?state.error:''); if(state.phase==='passed'&&String(state.message||'').includes('\\n')){const [lead,...rest]=state.message.split('\\n');text('message',lead);text('status',rest.join(' '));$('status').className='status passed-note';}
177
214
  history(messages);
178
215
  // Live activity already shows progress; avoid stacking generic spinners under it.
179
216
  if(state.phase==='working'&&(messages.at(-1)?.kind==='activity'||messages.at(-1)?.streaming)){$('status').hidden=true;$('message').hidden=true;}
@@ -9,10 +9,14 @@ export const isRateLimit = (error, route) => ['openrouter', 'openai'].includes(r
9
9
  && (error?.status === 429 || error?.errorType === 'rate_limit_exceeded');
10
10
 
11
11
  export const PLAN_NAMES = { claude: ['Claude Code', 'Claude'], codex: ['Codex', 'ChatGPT'] };
12
- export const switchChoices = (providers, from) => providers.map(provider => ({
12
+ const ROUTE_NAMES = { openrouter: 'OpenRouter', openai: 'your API endpoint', claude: 'Claude Code', codex: 'Codex' };
13
+ export const switchChoices = (providers, from) => providers.filter(provider => provider !== from).map(provider => ({
13
14
  value: `switch-${provider}`, label: `Switch this check to ${PLAN_NAMES[provider][0]}`,
14
- description: `Continue on your signed-in ${PLAN_NAMES[provider][1]} plan instead of ${from === 'openai' ? 'your API endpoint' : 'OpenRouter'}. Only this check changes; your saved model settings stay the same.`,
15
+ description: `Continue on your signed-in ${PLAN_NAMES[provider][1]} plan instead of ${ROUTE_NAMES[from] ?? from}. Only this check changes; your saved model settings stay the same.`,
15
16
  }));
17
+ /** A coding-agent CLI that failed to run; another plan can take over. */
18
+ export const isCliFailure = (error, route) => ['claude', 'codex'].includes(route?.provider)
19
+ && ['PROVIDER_FAILED', 'PROVIDER_NO_OUTPUT', 'PROVIDER_AUTH', 'EVALUATOR_TIMEOUT'].includes(error?.code);
16
20
 
17
21
  function retryable(error, before, state, minimum) {
18
22
  if (setupCodes.has(error.code) || setupTypes.has(error.errorType)) return false;
@@ -75,13 +79,19 @@ export async function prepareInteractiveCheck({ start, inspect, authorizeMore, v
75
79
  });
76
80
  }
77
81
  // Offer the user's own coding-agent plan first when an API route is rate limited.
78
- const switches = switchRoute && isRateLimit(error, state.route) ? switchChoices(planProviders, state.route.provider) : [];
82
+ const switches = switchRoute && (isRateLimit(error, state.route) || isCliFailure(error, state.route)) ? switchChoices(planProviders, state.route.provider) : [];
79
83
  choices.unshift(...switches);
80
84
  if (!choices.length) ui.note('Resolve the reported setup or evidence problem before resuming. Repeating this attempt is not offered here.');
85
+ const failedCli = isCliFailure(error, state.route) ? PLAN_NAMES[state.route.provider][0] : null;
81
86
  choices.push({ value: 'pause', label: 'Pause this check', description: 'Save progress without making another model request. The Git operation remains unapproved.' });
82
87
  const action = await choose({
83
- title: 'Review did not finish',
84
- description: waitingGit
88
+ // Lead with the cause when it is a rate limit, so the choices make sense.
89
+ title: isRateLimit(error, state.route) ? `${state.route.provider === 'openai' ? 'Your API endpoint' : 'OpenRouter'} is rate limiting this review`
90
+ : failedCli ? `${failedCli} could not run this review` : 'Review did not finish',
91
+ description: isRateLimit(error, state.route)
92
+ ? `It returned too many requests (HTTP 429). ${switches.length ? 'Switch this check to your coding plan, or wait and continue.' : 'Wait a moment, then continue.'}`
93
+ : failedCli ? `${switches.length ? `Switch this check to ${PLAN_NAMES[switches[0].value.slice(7)][0]}, or pause and` : 'Pause and'} check that ${failedCli} is installed, signed in, and up to date. The details are below.`
94
+ : waitingGit
85
95
  ? 'The original Git operation is waiting while this terminal stays open, subject to its time limit. Choose how to continue.'
86
96
  : 'Choose how to continue. Nothing is retried automatically.',
87
97
  choices, current: 'pause',
package/src/cli.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import path from 'node:path';
2
+ import { readStats, scoreSummary } from './stats.js';
2
3
  import { planProviders } from './evaluator.js';
3
4
  import { VERSION } from './version.js';
4
5
  import { fileURLToPath } from 'node:url';
@@ -16,7 +17,6 @@ import { activateIntegrations } from './activation.js';
16
17
  import { checkbox } from './checkbox.js';
17
18
  import { agentHook } from './agent.js';
18
19
  import { createSessionStore } from './sessions.js';
19
- import { prepareInteractiveCheck } from './check-recovery.js';
20
20
  import { parseRoute } from './routing.js';
21
21
  import { createTerminal, withProgress } from './terminal.js';
22
22
  import { HELP, printHelp } from './help.js';
@@ -25,10 +25,11 @@ import { runGitGate } from './git-gate.js';
25
25
  import { loadProjectContext } from './repo-context.js';
26
26
  import { REVIEWER_PRESETS, presetOptions } from './model-presets.js';
27
27
  import { modelUsageText, modelLimitText, readUsage, summarizeUsage, printUsage, usageDir } from './usage.js';
28
- import { toggleYolo, yoloStatus, recentOverrides } from './overrides.js';
28
+ import { toggleYolo, yoloStatus, recentOverrides, recordOverride } from './overrides.js';
29
+ import { runTerminalCheck } from './terminal-check.js';
29
30
 
30
31
  const CLI_PATH = fileURLToPath(new URL('../bin/sphinx.js', import.meta.url));
31
- const POLICY_FLAGS = ['branches', 'events', 'level', 'remotes'];
32
+ const POLICY_FLAGS = ['branches', 'events', 'level', 'distance', 'remotes'];
32
33
  const MODEL_FLAGS = ['preset', 'provider', 'model', 'small', 'medium', 'large', 'allow-paid', 'max-model-calls', 'max-output-tokens', 'cli-billing'];
33
34
  const OPTIONS = {
34
35
  install: ['yes', 'repo', 'agents', ...MODEL_FLAGS, 'launch', 'no-launch', ...POLICY_FLAGS],
@@ -36,7 +37,7 @@ const OPTIONS = {
36
37
  init: ['yes', 'json', 'context', 'suggest', 'apply', ...POLICY_FLAGS], check: ['json', 'id', 'remote', 'branch', 'staged', 'retry-review'],
37
38
  prepare: ['json', 'worker', 'retry'],
38
39
  answer: ['json', 'text'], explain: ['json'], dispute: ['json', 'text'], pending: ['json'],
39
- status: ['json'], config: ['json', ...MODEL_FLAGS], doctor: ['json'], auth: ['provider', 'base-url'], uninstall: ['repo'],
40
+ status: ['json'], config: ['json', 'distance', 'interface', ...MODEL_FLAGS], doctor: ['json'], auth: ['provider', 'base-url'], uninstall: ['repo'],
40
41
  mcp: [], yolo: ['json', 'reason'], usage: ['json'],
41
42
  };
42
43
  const json = value => console.log(JSON.stringify(value, null, 2));
@@ -134,7 +135,7 @@ export function applyInstallOptions(current, values) {
134
135
  }
135
136
  if (values['max-model-calls'] !== undefined) config.maxModelCalls = parseModelCalls(values['max-model-calls']);
136
137
  if (values['max-output-tokens'] !== undefined) config.maxOutputTokens = parseOutputTokens(values['max-output-tokens']);
137
- for (const key of ['provider', 'model', 'level', 'mode']) if (values[key] !== undefined) config[key] = values[key];
138
+ for (const key of ['provider', 'model', 'level', 'distance', 'interface', 'mode']) if (values[key] !== undefined) config[key] = values[key];
138
139
  for (const key of ['branches', 'events', 'remotes']) if (values[key] !== undefined) config[key] = splitList(values[key]);
139
140
  if (values.provider !== undefined || values.model !== undefined) {
140
141
  if (values.provider !== undefined && values.model === undefined) {
@@ -163,6 +164,7 @@ function policyRows(config) {
163
164
  ['Push checkpoints', enabled && config.events.includes('pre-push') ? `Destination branches: ${config.branches.join(', ') || 'none'}. Remotes: ${config.remotes.join(', ') || 'none'}.` : 'Off. Local pushes are not checked by this policy.'],
164
165
  ['Commit checkpoints', enabled && config.events.includes('pre-commit') ? `Staged changes on local branches: ${config.branches.join(', ') || 'none'}.` : 'Off. Local commits are not checked by this policy.'],
165
166
  ['Depth', `${config.level} · question caps: ${questionCapsText(config.level)}`],
167
+ ['Distance', config.distance === 'low' ? 'low · implementation details: specific logic, conditions, and edge cases' : 'high · behavior and consequences of the change as a whole'],
166
168
  ['Preparation', config.backgroundPreparation ? 'Background review after commits and branch updates' : 'On demand when the understanding check starts'],
167
169
  ['Behavior', !enabled ? 'Disabled for this repository' : 'The Git operation only goes through after you answer the understanding questions correctly'],
168
170
  ];
@@ -268,9 +270,10 @@ async function installCommand(initial) {
268
270
  const launch = !initial['no-launch'] && (initial.launch || !initial.yes);
269
271
  if (launch) terminal.paragraph('After setup, Sphinx opens selected installed clients in fresh sessions. Existing sessions stay open. Use --no-launch to defer this step.');
270
272
  const detected = !initial.yes && (initial.agents === undefined || initial.agents === 'auto') ? await detectAgents() : [];
273
+ const installedPlans = initial.yes ? [] : await planProviders().catch(() => []);
271
274
  const values = initial.yes ? initial : await withReadline(ask => askInstall(current, initial, createPrompts({ ask, ui: terminal,
272
275
  select: process.stdin.isTTY && process.stdout.isTTY && process.env.TERM !== 'dumb' ? ask.checkbox : undefined,
273
- }), { detectedAgents: detected, hasExistingConfig }));
276
+ }), { detectedAgents: detected, hasExistingConfig, installedPlans }));
274
277
  const config = applyInstallOptions(current, values);
275
278
  terminal.section('Machine-wide defaults');
276
279
  terminal.paragraph('Each repository can override these branches and remotes in .sphinxconfig to match its own production setup.');
@@ -338,7 +341,7 @@ async function initCommand(initial) {
338
341
  }
339
342
  }
340
343
  const overrides = {};
341
- for (const key of ['level']) if (values[key] !== undefined) overrides[key] = values[key];
344
+ for (const key of ['level', 'distance']) if (values[key] !== undefined) overrides[key] = values[key];
342
345
  for (const key of ['branches', 'events', 'remotes']) if (values[key] !== undefined) overrides[key] = splitList(values[key]);
343
346
  let context = values.context;
344
347
  if (context === undefined && !initial.yes && !initial.json && process.stdin.isTTY && !(await loadProjectContext(repo.root)).text) {
@@ -396,9 +399,10 @@ async function checkCommand(values) {
396
399
  if (values['retry-review']) await store.retryReview(session.id, config);
397
400
  json(await start()); return 0;
398
401
  }
399
- const terminal = ui(); terminal.section('Understanding check'); terminal.paragraph('This is open-book. Inspect the code when useful; explain the behavior in your own words.');
400
402
  const controller = new AbortController(), { signal } = controller;
403
+ const waitingGit = process.env.SPHINX_WAITING_GIT === '1';
401
404
  return withReadline(async ask => {
405
+ const terminal = ui();
402
406
  if (values['retry-review']) {
403
407
  await store.retryReview(session.id, config, { signal });
404
408
  signal.throwIfAborted();
@@ -406,29 +410,17 @@ async function checkCommand(values) {
406
410
  ? 'Resuming the review with no model-call limit. Previous usage remains counted.'
407
411
  : `Starting a new review attempt with up to ${config.maxModelCalls} additional model calls, including grading. Previous usage remains counted.`);
408
412
  }
409
- let state = await prepareInteractiveCheck({
410
- start: () => start(signal), inspect: async () => store.public(await store.read(session.id)),
411
- authorizeMore: () => store.retryReview(session.id, config, { signal }),
412
- validate: () => validateCheckpoint(session.snapshot, config), config,
413
- choose: createPrompts({ ask, ui: terminal }).choose, ui: terminal,
414
- waitingGit: process.env.SPHINX_WAITING_GIT === '1', signal,
413
+ return runTerminalCheck({
414
+ store, session: await store.read(session.id), config, ask, terminal, signal, waitingGit,
415
+ choose: createPrompts({ ask, ui: terminal }).choose,
416
+ validate: () => validateCheckpoint(session.snapshot, config),
415
417
  planProviders: await planProviders().catch(() => []),
416
- switchRoute: provider => store.switchRoute(session.id, config, provider, { signal }),
418
+ // A skip lets the waiting Git operation through; it is logged like the browser's.
419
+ skip: async reason => {
420
+ await store.skip(session.id);
421
+ await recordOverride({ kind: 'skip', repoRoot: repo.root, target: session.snapshot.target.ref, id: session.id, reason });
422
+ },
417
423
  });
418
- signal.throwIfAborted();
419
- if (!state) return 1;
420
- while (state.status !== 'passed') {
421
- printSession(state, terminal);
422
- const answer = (await ask('\nYour answer: ')).trim();
423
- if (answer === '/quit') { terminal.note('Progress saved. Run sphinx check when you are ready to continue.'); return 1; }
424
- if (!answer) continue;
425
- const dispute = answer.startsWith('/dispute '), learn = answer === '/learn';
426
- if (answer === '/dispute') { terminal.warning('Add a reason: /dispute <what is ambiguous or unsupported>'); continue; }
427
- if (answer.startsWith('/') && !dispute && !learn) { terminal.warning('Use /learn, /dispute <reason>, or /quit; otherwise enter your answer.'); continue; }
428
- state = await withProgress(learn ? 'Preparing an explanation and new scenario' : dispute ? 'Reviewing the question' : 'Checking your reasoning', () => store.answer(session.id, learn ? 'I do not know. Explain this concept and give me a different scenario to apply it to.' : dispute ? answer.slice(9) : answer, config, { dispute, learn, expectedQuestionId: state.question.id, signal }));
429
- signal.throwIfAborted();
430
- }
431
- signal.throwIfAborted(); printSession(state, terminal); return 0;
432
424
  }, { controller });
433
425
  }
434
426
 
@@ -460,6 +452,8 @@ async function statusCommand(command, values) {
460
452
  terminal.section('Configured checkpoints'); printConfiguration(terminal, config, { routes: false });
461
453
  if (preparation) terminal.rows([['Background review', preparation.status], ...(preparation.error ? [['Details', preparation.error]] : [])]);
462
454
  if (!active) { terminal.blank(); terminal.warning('Policy alone does not install hooks. Use sphinx install, or sphinx install --repo when this repository overrides core.hooksPath.'); }
455
+ const score = scoreSummary(await readStats(repo.root));
456
+ terminal.rows([['Understanding', score.calibrating ? `Calibrating · ${score.answered} of ${score.answered + score.needed} answers` : `${score.accuracy}% first try · ${score.answered} questions${score.streak > 1 ? ` · ${score.streak} in a row` : ''}${score.skipRate !== null ? ` · skipped ${score.skipRate}% of checks` : ''}`]]);
463
457
  if (yolo) { terminal.blank(); terminal.warning(`Yolo mode is on ${yolo.scope === 'repo' ? 'for this repository' : 'globally'} since ${yolo.since}: checks are skipped. Run sphinx yolo${yolo.scope === 'global' ? ' outside a repository' : ''} to turn it off.`); }
464
458
  terminal.section('Pending assessments'); printPending(terminal, pending);
465
459
  if (overrides.length) { terminal.section('Recent overrides'); terminal.rows(overrides.map(row => [row.at, `${row.kind}${row.target ? ` · ${row.target}` : ''}${row.reason ? ` · ${row.reason}` : ''}`])); }
@@ -470,7 +464,7 @@ async function statusCommand(command, values) {
470
464
  export function parseCommandLine(argv) {
471
465
  const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: {
472
466
  help: { type: 'boolean', short: 'h' }, version: { type: 'boolean', short: 'v' }, json: { type: 'boolean' }, yes: { type: 'boolean' }, repo: { type: 'boolean' }, staged: { type: 'boolean' },
473
- preset: { type: 'string' }, provider: { type: 'string' }, model: { type: 'string' }, small: { type: 'string' }, medium: { type: 'string' }, large: { type: 'string' }, level: { type: 'string' }, branches: { type: 'string' }, events: { type: 'string' }, agents: { type: 'string' }, remotes: { type: 'string' },
467
+ preset: { type: 'string' }, provider: { type: 'string' }, model: { type: 'string' }, small: { type: 'string' }, medium: { type: 'string' }, large: { type: 'string' }, level: { type: 'string' }, distance: { type: 'string' }, interface: { type: 'string' }, branches: { type: 'string' }, events: { type: 'string' }, agents: { type: 'string' }, remotes: { type: 'string' },
474
468
  remote: { type: 'string' }, branch: { type: 'string' }, id: { type: 'string' }, text: { type: 'string' }, 'allow-paid': { type: 'boolean' },
475
469
  context: { type: 'string' }, suggest: { type: 'boolean' }, apply: { type: 'string' }, worker: { type: 'boolean' }, retry: { type: 'boolean' }, reason: { type: 'string' }, 'base-url': { type: 'string' }, 'cli-billing': { type: 'string' },
476
470
  launch: { type: 'boolean' }, 'no-launch': { type: 'boolean' }, 'retry-review': { type: 'boolean' }, 'max-model-calls': { type: 'string' }, 'max-output-tokens': { type: 'string' },
@@ -543,13 +537,14 @@ export async function main(argv) {
543
537
  return result.checks.every(check => check.ok) ? 0 : 1;
544
538
  }
545
539
  if (command === 'config') {
546
- if (MODEL_FLAGS.some(key => values[key] !== undefined)) {
540
+ if ([...MODEL_FLAGS, 'distance', 'interface'].some(key => values[key] !== undefined)) {
547
541
  const current = await loadConfig(), previousMaxModelCalls = current.maxModelCalls;
548
542
  const config = applyInstallOptions(current, values), maxModelCalls = config.maxModelCalls;
549
543
  await saveConfig(config);
550
544
  const result = { scope: 'global', path: getPaths().config, previousMaxModelCalls, maxModelCalls, config };
551
545
  if (values.json) { json(result); return 0; }
552
546
  const terminal = ui();
547
+ if (values.interface !== undefined) terminal.success(`Questions appear ${config.interface === 'terminal' ? 'in the terminal (the browser is the fallback)' : config.interface === 'browser' ? 'in the browser' : 'in the browser on a local Mac, otherwise in the terminal'}.`);
553
548
  if (values['max-model-calls'] !== undefined) terminal.success(`Saved machine-wide limit: ${modelLimitText(maxModelCalls)} (previously ${modelLimitText(previousMaxModelCalls)}).`);
554
549
  if (values['max-output-tokens'] !== undefined) terminal.success(`Saved output allowance: ${config.maxOutputTokens} tokens per request, including model reasoning. Model calls: ${modelLimitText(maxModelCalls)}.`);
555
550
  if (MODEL_FLAGS.some(key => !['max-model-calls', 'max-output-tokens'].includes(key) && values[key] !== undefined)) {