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.
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
 
@@ -181,6 +183,117 @@ Each command explains itself with `sphinx <command> --help`. `--json` gives mach
181
183
  - [Architecture](docs/architecture.md): the checkpoint sequence, the reviewer and its evidence, compaction, and state.
182
184
  - [Evaluation](eval/README.md) and [results](eval/RESULTS.md).
183
185
 
186
+ ## Configuring Sphinx
187
+
188
+ Sphinx has two kinds of settings:
189
+ - **Your own machine-wide settings** in `~/.config/sphinx/config.json`. Change them with `sphinx config`.
190
+ - **Per-repository files** that you commit so collaborators share them: `.sphinxconfig`, `.sphinxignore`, and `.sphinxcontext.md`. `sphinx init` creates them.
191
+
192
+ `sphinx config` with no flags shows the settings in effect for the current repository.
193
+
194
+ ### Switch the reviewer model
195
+
196
+ Each change gets a small, medium, or large route depending on its size. A preset fills all three:
197
+
198
+ ```sh
199
+ sphinx config --preset claude-economy # your Claude Code plan
200
+ sphinx config --preset codex-economy # your Codex plan
201
+ sphinx config --preset openrouter-value --allow-paid # paid OpenRouter credits
202
+ sphinx config --preset openrouter-free # free, slower, rate limited
203
+ ```
204
+
205
+ Or set each size yourself as `provider:model`:
206
+
207
+ ```sh
208
+ sphinx config --small claude:haiku --medium claude:haiku --large claude:sonnet
209
+ ```
210
+
211
+ To use any OpenAI-compatible endpoint (OpenAI, Groq, Together, Ollama, LM Studio, vLLM, and others):
212
+
213
+ ```sh
214
+ sphinx auth --provider openai --base-url https://api.openai.com/v1 # asks for the key
215
+ sphinx config --provider openai --model gpt-5-mini
216
+ ```
217
+
218
+ `sphinx auth` saves or replaces your OpenRouter key. Claude Code and Codex routes bill your signed-in plan, and Sphinx strips API keys from their environment. `--cli-billing api` lets them use API keys instead. A new route applies to new checks.
219
+
220
+ If a review is rate limited or a CLI fails mid-check, the questionnaire offers to switch to one of your installed coding plans.
221
+
222
+ ### `.sphinxconfig`: when checks run
223
+
224
+ ```json
225
+ {
226
+ "version": 1,
227
+ "events": ["pre-push"],
228
+ "branches": ["main", "release/*"],
229
+ "remotes": ["*"],
230
+ "level": "standard",
231
+ "distance": "high",
232
+ "exclude": ["vendor/**"]
233
+ }
234
+ ```
235
+
236
+ | Setting | What it means |
237
+ | --- | --- |
238
+ | `enabled` | `false` turns Sphinx off for this repository |
239
+ | `events` | `pre-push` (default), `pre-commit`, or both |
240
+ | `branches` | Branches that trigger a check. Pushes match the destination, commits match the current branch. `["*"]` checks every branch, which is useful for pull requests |
241
+ | `remotes` | Remote names that trigger push checks; `*` (default) means any |
242
+ | `level` | How many questions: `relaxed`, `standard` (default), or `thorough` |
243
+ | `distance` | `high` (default): what the change does and affects. `low`: its specific logic, conditions, and edge cases |
244
+ | `exclude` | Extra paths to keep from the reviewer, on top of `.sphinxignore` |
245
+ | `backgroundPreparation` | `false` stops preparing questions in the background after commits |
246
+ | `github` | GitHub commit status and pull request comment; see below |
247
+
248
+ ### `.sphinxignore`: files the reviewer never sees
249
+
250
+ It uses gitignore-style patterns:
251
+
252
+ ```gitignore
253
+ internal/customer-exports/
254
+ /config/production.json
255
+ *.generated.ts
256
+ dist/
257
+ ```
258
+
259
+ `.env` files, private keys, and common credential files are always excluded, and a repository can't undo that. A change that only touches ignored files needs no check. The rules match paths, so they won't catch a secret pasted into ordinary source.
260
+
261
+ ### `.sphinxcontext.md`: what the project is, and what to ask about
262
+
263
+ The reviewer reads this file with every request. It has two parts:
264
+
265
+ ```markdown
266
+ ## About this project
267
+ A booking service for shared equipment. Reservations must never overlap.
268
+
269
+ ## What to ask about
270
+ - Don't ask about styling, layout, or copy changes.
271
+ - Focus on reservation conflicts and billing.
272
+ ```
273
+
274
+ **About this project** gives background, which the reviewer checks against the code. **What to ask about** holds your preferences. Questions avoid the areas you skip and lean toward the ones you name. A change entirely in a skipped area, such as a CSS-only restyle, can pass with no questions. A second, independent audit can still overrule that when the change also affects behavior, data, or security.
275
+
276
+ For preferences in every repository, use `~/.config/sphinx/context.md`; `sphinx install` creates it. Where the two disagree, the repository's file wins. HTML comments in either file are guidance for you and are never sent to the model. Keep each under 16 KB and free of secrets. Editing either one means the next check is reviewed fresh.
277
+
278
+ `sphinx init` creates `.sphinxcontext.md` and keeps it out of Git on your machine (in `.git/info/exclude`), because preferences are personal. To share it with your team, run `git add -f .sphinxcontext.md` and commit it.
279
+
280
+ ### Your own settings
281
+
282
+ These live in `config.json`, and most can be set with `sphinx config`:
283
+
284
+ | Setting | Flag | What it means |
285
+ | --- | --- | --- |
286
+ | `models` | `--preset`, `--small`, `--medium`, `--large` | The reviewer for each change size |
287
+ | `interface` | `--interface` | Where questions appear. `auto` (default): the browser on a local Mac, otherwise the terminal you pushed from. Or `browser` / `terminal` |
288
+ | `maxModelCalls` | `--max-model-calls` | Optional cap on model requests per check (1–100, or `unlimited`, the default) |
289
+ | `maxOutputTokens` | `--max-output-tokens` | Output allowance per request, including reasoning (default 4096) |
290
+ | `allowPaid` | `--allow-paid` | Lets OpenRouter use paid models |
291
+ | `cliBilling` | `--cli-billing` | `plan` (default) bills your Claude or Codex plan; `api` lets those CLIs use API keys |
292
+ | `github` | edit the file | `status` (default on): mark pushed commits on GitHub. `comment`: keep a summary comment on the pull request. `answers`: include your answers in it. Needs a signed-in `gh` CLI and never blocks a push |
293
+ | `receiptHours` | edit the file | How long a pass stays valid for the same change (default 24) |
294
+
295
+ `sphinx yolo` turns checks off and on again for the current repository, or globally when run outside one. The full reference, including routing thresholds and pricing, is in [configuration and cost](docs/configuration.md).
296
+
184
297
  ## Development
185
298
 
186
299
  ```sh
@@ -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
 
@@ -21,7 +23,8 @@ This page is the reference for reviewer models, usage limits, repository policy,
21
23
  | Skip and yolo log | `~/.config/sphinx/overrides.jsonl` |
22
24
  | Repository policy | `.sphinxconfig` (legacy `.sphinx.json` is read when `.sphinxconfig` is absent) |
23
25
  | Repository exclusions | `.sphinxignore` |
24
- | Project context | `.sphinxcontext.md` |
26
+ | Project context and question preferences | `.sphinxcontext.md` (kept out of Git by default) |
27
+ | Your question preferences for every repository | `~/.config/sphinx/context.md` |
25
28
 
26
29
  `SPHINX_HOME` relocates all machine-wide settings, credentials, and assessment state. `sphinx config` shows effective settings and paths. A complete global example using the paid Haiku preset is in [examples/config.json](../examples/config.json).
27
30
 
@@ -180,6 +183,7 @@ Call caps bound requests and byte limits bound evidence. Neither is a dollar or
180
183
  | `distance` | `high` (default): questions about what the change does and affects; `low`: questions about its specific logic, conditions, and edge cases |
181
184
  | `exclude` | Extra ignore patterns, added to machine-wide ones |
182
185
  | `backgroundPreparation` | `false` opts this repository out |
186
+ | `github` | GitHub stamps; see [below](#github-commit-status-and-pull-requests) |
183
187
 
184
188
  Branch defaults come from machine-wide settings (initially `main` and `master`), not from the branch you happen to have checked out. Without prompts:
185
189
 
@@ -193,18 +197,24 @@ If both `.sphinxconfig` and legacy `.sphinx.json` exist, `.sphinxconfig` wins an
193
197
 
194
198
  **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.
195
199
 
200
+ **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.
201
+
196
202
  ## Project context
197
203
 
198
- `.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.
204
+ `.sphinxcontext.md` has two sections. **About this project** covers what the project does, who uses it, important workflows, and architecture boundaries. **What to ask about** holds question preferences, such as "don't ask about styling" or "focus on billing". Your preferences for every repository go in `~/.config/sphinx/context.md`, which `sphinx install` creates; the repository's file wins where they disagree. Both are included in every reviewer, audit, compaction, and grading request.
205
+
206
+ - **Preferences choose topics, not facts.** The reviewer avoids areas you skip and leans toward the ones you name. When an entire change falls in a skipped area and alters no behavior, data, security, or failure handling outside it, the check can pass with no questions at any size. The independent audit verifies that decision and can add a question instead. Without any context or preferences, only small changes can pass with no questions.
207
+ - **Comments are for you.** HTML comments (`<!-- -->`) are stripped before anything reaches a model, so the template's guidance costs nothing. A file that is still only the template counts as empty.
208
+ - **Personal by default.** `sphinx init` adds a new `.sphinxcontext.md` to `.git/info/exclude`, which changes nothing outside your machine. To share it, run `git add -f .sphinxcontext.md` and commit it. Files that are already committed are unaffected.
199
209
 
200
210
  ```sh
201
- sphinx init --yes --context 'A booking service for shared equipment. Reservations must never overlap.'
211
+ sphinx init --yes --context 'A booking service for shared equipment. Reservations must never overlap.' # fills About this project
202
212
  ```
203
213
 
204
214
  - Keep it under 16,000 bytes and free of secrets. It respects `.sphinxignore`.
205
215
  - The reviewer checks its claims against code. It cannot override Sphinx policy or authorize reading ignored files.
206
216
  - Without context, the reviewer establishes purpose from documentation and source rather than inventing requirements.
207
- - Changing it invalidates prepared questions and completion records.
217
+ - Changing either file invalidates prepared questions and completion records.
208
218
 
209
219
  ## Files Sphinx never reads
210
220
 
@@ -241,3 +251,35 @@ After commits, merges, rewrites, and checkouts, Sphinx schedules a detached revi
241
251
  - Set `backgroundPreparation: false` in `config.json` or `.sphinxconfig` to opt out. A repository cannot re-enable preparation disabled machine-wide.
242
252
  - While yolo is on, preparation is paused: no new model request starts, although one already in progress may finish.
243
253
  - 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.
254
+
255
+ ## GitHub commit status and pull requests
256
+
257
+ 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.
258
+
259
+ ```json
260
+ { "github": { "status": true, "comment": false, "answers": false } }
261
+ ```
262
+
263
+ | Key | Default | Meaning |
264
+ | --- | --- | --- |
265
+ | `status` | `true` | Post the commit status |
266
+ | `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 |
267
+ | `answers` | `false` | Also include your answers in that comment |
268
+
269
+ Set these in `config.json` for yourself or in `.sphinxconfig` for a repository. Private answer keys are never posted.
270
+
271
+ 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.
272
+
273
+ **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.
274
+
275
+ ## Browser or terminal
276
+
277
+ `interface` in `config.json` chooses where a push's questions appear. Set it with `sphinx config --interface <auto|browser|terminal>`.
278
+
279
+ | Value | Behavior |
280
+ | --- | --- |
281
+ | `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 |
282
+ | `terminal` | Always the terminal |
283
+ | `browser` | Always the browser (on Linux and over SSH, open the printed local URL) |
284
+
285
+ 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.7",
3
+ "version": "0.1.9",
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": {
@@ -13,6 +13,7 @@ import { browserPage } from './browser-page.js';
13
13
  import { recordOverride } from './overrides.js';
14
14
  import { withTextStream, partialField } from './stream.js';
15
15
  import { loadChangeView } from './change-view.js';
16
+ import { updateStatus, updatePlan, scheduleUpdate } from './update.js';
16
17
 
17
18
  const ID = /^[a-f0-9]{64}$/u;
18
19
  const cancelled = () => Object.assign(new Error('Check paused. Progress is saved.'), { code: 'SPHINX_CANCELLED' });
@@ -54,7 +55,7 @@ export function createBrowserInterview({ env = process.env, signal, timeoutMs =
54
55
  store: providedStore, readConfig = loadConfig, revalidate = revalidateSnapshot,
55
56
  reserve = claimCheckpointDialog, release = finishCheckpointDialog, waitForExisting = waitForCheckpointTerminal,
56
57
  openBrowser = openQuestionnaireBrowser, output = line => process.stderr.write(`${line}\n`), logOverride = recordOverride, findPlans = planProviders,
57
- loadView = loadChangeView,
58
+ loadView = loadChangeView, checkUpdate = updateStatus, planUpdate = updatePlan, startUpdate = scheduleUpdate,
58
59
  } = {}) {
59
60
  const token = randomBytes(32).toString('base64url'), nonce = randomBytes(24).toString('base64');
60
61
  let server, origin, url, opened = false, closed = false, active, skip, resolver, sequence = 0, waitingGit = false;
@@ -65,6 +66,8 @@ export function createBrowserInterview({ env = process.env, signal, timeoutMs =
65
66
  // The chat transcript for the current checkpoint lives only in this process.
66
67
  // It contains public questions, feedback, and the human's own text.
67
68
  let transcript = [], turns = 0, score = null;
69
+ // The installed version and whether npm has a newer one. Display only.
70
+ let update = null;
68
71
  // The repository's understanding score, refreshed after every graded answer.
69
72
  const refreshScore = async (repoRoot, operationEnv) => { score = scoreSummary(await readStats(repoRoot, operationEnv)); };
70
73
  // Read-only display of the checkpoint's frozen comparison. It is loaded
@@ -94,7 +97,7 @@ export function createBrowserInterview({ env = process.env, signal, timeoutMs =
94
97
  // Display-only updates (change view, evidence) keep the revision, so they
95
98
  // never turn an answer already in flight into a stale submission.
96
99
  const publish = (next, { display = false } = {}) => {
97
- state = { ...next, messages: transcript, ...(score ? { score } : {}), 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 };
98
101
  const event = `data: ${JSON.stringify(state)}\n\n`;
99
102
  for (const stream of streams) writeEvent(stream, event);
100
103
  };
@@ -141,6 +144,15 @@ export function createBrowserInterview({ env = process.env, signal, timeoutMs =
141
144
  }
142
145
  if (req.method !== 'POST' || segments[2] !== 'action') return send(res, 405, { error: 'Unsupported method.' });
143
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
+ }
144
156
  let bytes = 0; const chunks = [];
145
157
  // 8,000 characters can occupy 24KB in UTF-8 or 48KB when JSON-escaped.
146
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); }
@@ -184,6 +196,7 @@ export function createBrowserInterview({ env = process.env, signal, timeoutMs =
184
196
  try { await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); }); }
185
197
  catch (error) { server = undefined; throw error; }
186
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(() => {});
187
200
  output(`Sphinx questionnaire: ${url}`);
188
201
  if (!opened) {
189
202
  opened = true;
@@ -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,6 +12,7 @@ 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}
@@ -90,7 +92,7 @@ footer{flex-shrink:0;display:flex;flex-wrap:wrap;gap:2px 8px;padding:0 0 14px;co
90
92
  #question-pane{overflow-y:auto}.conversation{flex:none;overflow:visible;padding-right:0}.evidence{max-height:none;overflow:visible}}
91
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}}
92
94
  </style></head><body><div class="shell">
93
- <header><div class="brand"><strong>${sphinxMark({ size: 26, id: 'brand-mark' })}sphinx</strong><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>
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>
94
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>
95
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>
96
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>
@@ -147,6 +149,14 @@ function scoreView(sc){
147
149
  const meta=[];if(sc.streak>1)meta.push(sc.streak+' in a row');if(sc.skipRate!=null)meta.push('skipped '+sc.skipRate+'%');
148
150
  $('score-meta').textContent=meta.join(' · ');lastScored=sc.answered;
149
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'});}};
150
160
  const resize=()=>{$('response').style.height='auto';$('response').style.height=Math.min($('response').scrollHeight||76,180)+'px';};
151
161
  function controls(){
152
162
  if(!current)return;
@@ -196,7 +206,7 @@ function render(state){
196
206
  const previous=current;current=state;finished=state.operationClosed||['passed','paused','error','skipped'].includes(state.phase);
197
207
  const repo=state.repoRoot?.split(/[\\\\/]/).filter(Boolean).at(-1),branch=state.target?.replace(/^refs\\/heads\\//,'');
198
208
  text('meta',repo||state.change?.header?.repository||'');$('meta').title=state.repoRoot||'';
199
- text('usage',[routeText(state.route),usageText(state.usage,state.tokens)].filter(Boolean).join(' · '));scoreView(state.score);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||[];
200
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.');
201
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…';
202
212
  const retry=state.phase==='question'&&[...messages].reverse().find(item=>item.kind==='question')?.followUp;
package/src/cli.js CHANGED
@@ -17,16 +17,16 @@ import { activateIntegrations } from './activation.js';
17
17
  import { checkbox } from './checkbox.js';
18
18
  import { agentHook } from './agent.js';
19
19
  import { createSessionStore } from './sessions.js';
20
- import { prepareInteractiveCheck } from './check-recovery.js';
21
20
  import { parseRoute } from './routing.js';
22
21
  import { createTerminal, withProgress } from './terminal.js';
23
22
  import { HELP, printHelp } from './help.js';
24
23
  import { askInstall, askPolicy, createPrompts, questionCapsText, reviewCapsText, routeText, splitList } from './prompts.js';
25
24
  import { runGitGate } from './git-gate.js';
26
- import { loadProjectContext } from './repo-context.js';
25
+ import { loadProjectContext, contextWithDescription, contextWithPreferences, ensurePersonalContext, PROJECT_CONTEXT_TEMPLATE } from './repo-context.js';
27
26
  import { REVIEWER_PRESETS, presetOptions } from './model-presets.js';
28
27
  import { modelUsageText, modelLimitText, readUsage, summarizeUsage, printUsage, usageDir } from './usage.js';
29
- import { toggleYolo, yoloStatus, recentOverrides } from './overrides.js';
28
+ import { toggleYolo, yoloStatus, recentOverrides, recordOverride } from './overrides.js';
29
+ import { runTerminalCheck } from './terminal-check.js';
30
30
 
31
31
  const CLI_PATH = fileURLToPath(new URL('../bin/sphinx.js', import.meta.url));
32
32
  const POLICY_FLAGS = ['branches', 'events', 'level', 'distance', 'remotes'];
@@ -37,7 +37,7 @@ const OPTIONS = {
37
37
  init: ['yes', 'json', 'context', 'suggest', 'apply', ...POLICY_FLAGS], check: ['json', 'id', 'remote', 'branch', 'staged', 'retry-review'],
38
38
  prepare: ['json', 'worker', 'retry'],
39
39
  answer: ['json', 'text'], explain: ['json'], dispute: ['json', 'text'], pending: ['json'],
40
- status: ['json'], config: ['json', 'distance', ...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'],
41
41
  mcp: [], yolo: ['json', 'reason'], usage: ['json'],
42
42
  };
43
43
  const json = value => console.log(JSON.stringify(value, null, 2));
@@ -135,7 +135,7 @@ export function applyInstallOptions(current, values) {
135
135
  }
136
136
  if (values['max-model-calls'] !== undefined) config.maxModelCalls = parseModelCalls(values['max-model-calls']);
137
137
  if (values['max-output-tokens'] !== undefined) config.maxOutputTokens = parseOutputTokens(values['max-output-tokens']);
138
- for (const key of ['provider', 'model', 'level', 'distance', '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];
139
139
  for (const key of ['branches', 'events', 'remotes']) if (values[key] !== undefined) config[key] = splitList(values[key]);
140
140
  if (values.provider !== undefined || values.model !== undefined) {
141
141
  if (values.provider !== undefined && values.model === undefined) {
@@ -285,6 +285,8 @@ async function installCommand(initial) {
285
285
  }
286
286
  const result = await install({ config, agents: values.agents || 'auto', cliPath: CLI_PATH });
287
287
  await saveConfig(config); printInstallationResult(terminal, result);
288
+ const personal = await ensurePersonalContext().catch(() => null);
289
+ if (personal) terminal.note(`Created ${personal}: tell Sphinx what you do and don't want questions about, in every repository.`);
288
290
  if (result.agents.length) {
289
291
  if (launch) printActivationResult(terminal, await withProgress('Opening your coding agents', () => activateIntegrations({ agents: result.agents })));
290
292
  else {
@@ -347,8 +349,11 @@ async function initCommand(initial) {
347
349
  if (context === undefined && !initial.yes && !initial.json && process.stdin.isTTY && !(await loadProjectContext(repo.root)).text) {
348
350
  terminal.section('Project context');
349
351
  terminal.paragraph('Describe what this project does and its key workflows. This background is included in every review; you can expand .sphinxcontext.md later.');
350
- const answer = await withReadline(ask => ask('\nProject description (Enter to create a template): '));
351
- if (answer.trim()) context = answer.trim();
352
+ const [answer, preferences] = await withReadline(async ask => [
353
+ await ask('\nProject description (Enter to fill in later): '),
354
+ await ask('Anything Sphinx should skip or focus on here? For example "don\'t ask about styling" (Enter to skip): '),
355
+ ]);
356
+ if (answer.trim() || preferences.trim()) context = contextWithPreferences(answer.trim() ? contextWithDescription(answer) : PROJECT_CONTEXT_TEMPLATE, preferences.trim() ? `- ${preferences.trim()}` : '');
352
357
  }
353
358
  const result = await initializeRepository({ repoRoot: repo.root, config, overrides, context });
354
359
  if (initial.json) { json(result); return 0; }
@@ -358,7 +363,8 @@ async function initCommand(initial) {
358
363
  if (info.exists && !info.legacy && Object.keys(overrides).length) terminal.warning('The existing .sphinxconfig was preserved; command-line overrides were not applied. Edit that file to change its policy.');
359
364
  terminal.section('Effective repository policy'); printConfiguration(terminal, await loadConfig(repo.root), { routes: false });
360
365
  terminal.blank(); terminal.paragraph('Add project-specific sensitive or generated paths to .sphinxignore before an assessment.');
361
- terminal.paragraph('Keep .sphinxcontext.md current with the project purpose, workflows, and architecture boundaries. The reviewer receives it every time.');
366
+ terminal.paragraph('.sphinxcontext.md tells the reviewer what this project is and what you want to be asked about, for example "don\'t ask about styling". Your preferences for every repository go in ~/.config/sphinx/context.md.');
367
+ if (result.contextExcluded) terminal.note('.sphinxcontext.md is kept out of Git on this machine. To share it with your team, run git add -f .sphinxcontext.md.');
362
368
  terminal.note('This command does not install Git hooks. Check coverage with sphinx status.'); return 0;
363
369
  }
364
370
 
@@ -399,9 +405,10 @@ async function checkCommand(values) {
399
405
  if (values['retry-review']) await store.retryReview(session.id, config);
400
406
  json(await start()); return 0;
401
407
  }
402
- 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.');
403
408
  const controller = new AbortController(), { signal } = controller;
409
+ const waitingGit = process.env.SPHINX_WAITING_GIT === '1';
404
410
  return withReadline(async ask => {
411
+ const terminal = ui();
405
412
  if (values['retry-review']) {
406
413
  await store.retryReview(session.id, config, { signal });
407
414
  signal.throwIfAborted();
@@ -409,29 +416,17 @@ async function checkCommand(values) {
409
416
  ? 'Resuming the review with no model-call limit. Previous usage remains counted.'
410
417
  : `Starting a new review attempt with up to ${config.maxModelCalls} additional model calls, including grading. Previous usage remains counted.`);
411
418
  }
412
- let state = await prepareInteractiveCheck({
413
- start: () => start(signal), inspect: async () => store.public(await store.read(session.id)),
414
- authorizeMore: () => store.retryReview(session.id, config, { signal }),
415
- validate: () => validateCheckpoint(session.snapshot, config), config,
416
- choose: createPrompts({ ask, ui: terminal }).choose, ui: terminal,
417
- waitingGit: process.env.SPHINX_WAITING_GIT === '1', signal,
419
+ return runTerminalCheck({
420
+ store, session: await store.read(session.id), config, ask, terminal, signal, waitingGit,
421
+ choose: createPrompts({ ask, ui: terminal }).choose,
422
+ validate: () => validateCheckpoint(session.snapshot, config),
418
423
  planProviders: await planProviders().catch(() => []),
419
- switchRoute: provider => store.switchRoute(session.id, config, provider, { signal }),
424
+ // A skip lets the waiting Git operation through; it is logged like the browser's.
425
+ skip: async reason => {
426
+ await store.skip(session.id);
427
+ await recordOverride({ kind: 'skip', repoRoot: repo.root, target: session.snapshot.target.ref, id: session.id, reason });
428
+ },
420
429
  });
421
- signal.throwIfAborted();
422
- if (!state) return 1;
423
- while (state.status !== 'passed') {
424
- printSession(state, terminal);
425
- const answer = (await ask('\nYour answer: ')).trim();
426
- if (answer === '/quit') { terminal.note('Progress saved. Run sphinx check when you are ready to continue.'); return 1; }
427
- if (!answer) continue;
428
- const dispute = answer.startsWith('/dispute '), learn = answer === '/learn';
429
- if (answer === '/dispute') { terminal.warning('Add a reason: /dispute <what is ambiguous or unsupported>'); continue; }
430
- if (answer.startsWith('/') && !dispute && !learn) { terminal.warning('Use /learn, /dispute <reason>, or /quit; otherwise enter your answer.'); continue; }
431
- 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 }));
432
- signal.throwIfAborted();
433
- }
434
- signal.throwIfAborted(); printSession(state, terminal); return 0;
435
430
  }, { controller });
436
431
  }
437
432
 
@@ -475,7 +470,7 @@ async function statusCommand(command, values) {
475
470
  export function parseCommandLine(argv) {
476
471
  const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: {
477
472
  help: { type: 'boolean', short: 'h' }, version: { type: 'boolean', short: 'v' }, json: { type: 'boolean' }, yes: { type: 'boolean' }, repo: { type: 'boolean' }, staged: { type: 'boolean' },
478
- preset: { type: 'string' }, provider: { type: 'string' }, model: { type: 'string' }, small: { type: 'string' }, medium: { type: 'string' }, large: { type: 'string' }, level: { type: 'string' }, distance: { type: 'string' }, branches: { type: 'string' }, events: { type: 'string' }, agents: { type: 'string' }, remotes: { type: 'string' },
473
+ 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' },
479
474
  remote: { type: 'string' }, branch: { type: 'string' }, id: { type: 'string' }, text: { type: 'string' }, 'allow-paid': { type: 'boolean' },
480
475
  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' },
481
476
  launch: { type: 'boolean' }, 'no-launch': { type: 'boolean' }, 'retry-review': { type: 'boolean' }, 'max-model-calls': { type: 'string' }, 'max-output-tokens': { type: 'string' },
@@ -548,13 +543,14 @@ export async function main(argv) {
548
543
  return result.checks.every(check => check.ok) ? 0 : 1;
549
544
  }
550
545
  if (command === 'config') {
551
- if ([...MODEL_FLAGS, 'distance'].some(key => values[key] !== undefined)) {
546
+ if ([...MODEL_FLAGS, 'distance', 'interface'].some(key => values[key] !== undefined)) {
552
547
  const current = await loadConfig(), previousMaxModelCalls = current.maxModelCalls;
553
548
  const config = applyInstallOptions(current, values), maxModelCalls = config.maxModelCalls;
554
549
  await saveConfig(config);
555
550
  const result = { scope: 'global', path: getPaths().config, previousMaxModelCalls, maxModelCalls, config };
556
551
  if (values.json) { json(result); return 0; }
557
552
  const terminal = ui();
553
+ 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'}.`);
558
554
  if (values['max-model-calls'] !== undefined) terminal.success(`Saved machine-wide limit: ${modelLimitText(maxModelCalls)} (previously ${modelLimitText(previousMaxModelCalls)}).`);
559
555
  if (values['max-output-tokens'] !== undefined) terminal.success(`Saved output allowance: ${config.maxOutputTokens} tokens per request, including model reasoning. Model calls: ${modelLimitText(maxModelCalls)}.`);
560
556
  if (MODEL_FLAGS.some(key => !['max-model-calls', 'max-output-tokens'].includes(key) && values[key] !== undefined)) {
package/src/config.js CHANGED
@@ -9,12 +9,14 @@ export const PROVIDERS = Object.freeze(['openrouter', 'codex', 'claude', 'openai
9
9
 
10
10
  export const DEFAULT_CONFIG = {
11
11
  version: 1, enabled: true, provider: 'openrouter', model: 'openrouter/free',
12
- level: 'standard', distance: 'high', mode: 'block', events: ['pre-push'],
12
+ level: 'standard', distance: 'high', mode: 'block', interface: 'auto', events: ['pre-push'],
13
13
  branches: ['main', 'master'], remotes: ['*'], exclude: [],
14
14
  maxContextBytes: 100000, timeoutSeconds: 180, receiptHours: 24,
15
15
  maxModelCalls: null, maxOutputTokens: 4096, allowPaid: false, cliBilling: 'plan',
16
16
  agentMaxTurns: null, toolResultBytes: 16000,
17
17
  backgroundPreparation: true, preparationDebounceMs: 1500, workingContext: true,
18
+ // GitHub stamps never affect a check; see src/github.js.
19
+ github: { status: true, comment: false, answers: false },
18
20
  models: {
19
21
  small: { provider: 'openrouter', model: 'openrouter/free' },
20
22
  medium: { provider: 'openrouter', model: 'openrouter/free' },
@@ -73,7 +75,7 @@ export function validateConfig(config) {
73
75
  for (const key of Object.keys(config)) if (!allowed.has(key)) fail(`unknown setting ${key}`);
74
76
  if (config.version !== 1) fail('version must be 1');
75
77
  for (const key of ['enabled', 'allowPaid', 'backgroundPreparation', 'workingContext']) if (typeof config[key] !== 'boolean') fail(`${key} must be boolean`);
76
- for (const [key, values] of Object.entries({ provider: PROVIDERS, level: ['relaxed','standard','thorough'], distance: ['high','low'], mode: ['block'], cliBilling: ['plan','api'] })) {
78
+ for (const [key, values] of Object.entries({ provider: PROVIDERS, level: ['relaxed','standard','thorough'], distance: ['high','low'], interface: ['auto','browser','terminal'], mode: ['block'], cliBilling: ['plan','api'] })) {
77
79
  if (key === 'mode' && config.mode === 'advisory') fail('mode "advisory" was removed. Remove mode from your configuration; use sphinx yolo to bypass checks when urgent');
78
80
  if (!values.includes(config[key])) fail(`${key} must be ${values.join(', ')}`);
79
81
  }
@@ -96,6 +98,8 @@ export function validateConfig(config) {
96
98
  if (Object.keys(route).some(k => !['provider','model'].includes(k))) fail(`unexpected field in models.${tier}`);
97
99
  }
98
100
  if (Object.keys(config.models).some(k => !['small','medium','large'].includes(k))) fail('unknown model tier');
101
+ if (!config.github || typeof config.github !== 'object' || Array.isArray(config.github)) fail('github must be an object');
102
+ for (const key of Object.keys(config.github)) if (!Object.hasOwn(DEFAULT_CONFIG.github, key) || typeof config.github[key] !== 'boolean') fail(`github.${key} must be one of status, comment, answers set to true or false`);
99
103
  if (!config.routing || typeof config.routing !== 'object') fail('routing is required');
100
104
  for (const key of Object.keys(config.routing)) if (!Object.hasOwn(DEFAULT_CONFIG.routing,key)) fail(`unknown routing setting ${key}`);
101
105
  for (const k of ['smallMaxLines','smallMaxFiles','largeMinLines','largeMinFiles']) {
@@ -107,10 +111,10 @@ export function validateConfig(config) {
107
111
  }
108
112
 
109
113
  function merged(base, overrides) {
110
- return { ...base, ...overrides, models: { ...base.models, ...overrides?.models }, routing: { ...base.routing, ...overrides?.routing } };
114
+ return { ...base, ...overrides, models: { ...base.models, ...overrides?.models }, routing: { ...base.routing, ...overrides?.routing }, github: { ...base.github, ...overrides?.github } };
111
115
  }
112
116
 
113
- export const REPOSITORY_CONFIG_KEYS = Object.freeze(['version','enabled','level','distance','mode','events','branches','remotes','exclude','backgroundPreparation']);
117
+ export const REPOSITORY_CONFIG_KEYS = Object.freeze(['version','enabled','level','distance','mode','events','branches','remotes','exclude','backgroundPreparation','github']);
114
118
 
115
119
  export function validateRepositoryConfig(local, { env = process.env, source = '.sphinxconfig' } = {}) {
116
120
  if (!local || typeof local !== 'object' || Array.isArray(local)
@@ -122,7 +126,7 @@ export function validateRepositoryConfig(local, { env = process.env, source = '.
122
126
  throw new Error(`Repository ${source} cannot set ${key}; configure model routes and budgets in ${getPaths(env).config}.`);
123
127
  }
124
128
  }
125
- validateConfig({ ...structuredClone(DEFAULT_CONFIG), ...local });
129
+ validateConfig(merged(structuredClone(DEFAULT_CONFIG), local));
126
130
  return local;
127
131
  }
128
132
 
@@ -176,7 +180,8 @@ export function stableStringify(value) {
176
180
  if (value && typeof value === 'object') return `{${Object.keys(value).sort().map(k => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`;
177
181
  return JSON.stringify(value);
178
182
  }
179
- export function policyHash(config) {
183
+ export function policyHash({ github, interface: _interface, ...config }) {
184
+ // GitHub stamps and where the questions appear are not part of the check.
180
185
  // The call ceiling controls spending, not what the human must understand.
181
186
  // Normalize it independently of DEFAULT_CONFIG so allowance edits preserve
182
187
  // checkpoint identity within the same assessment contract.