sphinx-understand 0.1.6 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/docs/architecture.md +1 -1
- package/docs/configuration.md +39 -0
- package/package.json +1 -1
- package/src/browser-interview.js +28 -10
- package/src/browser-page.js +43 -6
- package/src/check-recovery.js +15 -5
- package/src/cli.js +26 -31
- package/src/config.js +11 -6
- package/src/evaluator.js +55 -7
- package/src/git-gate.js +14 -3
- package/src/git-interview.js +41 -2
- package/src/git.js +17 -1
- package/src/github-worker.js +4 -0
- package/src/github.js +174 -0
- package/src/help.js +2 -1
- package/src/model-presets.js +1 -1
- package/src/openrouter.js +1 -1
- package/src/prompts.js +12 -5
- package/src/sessions.js +15 -3
- package/src/stats.js +54 -0
- package/src/storage.js +4 -2
- package/src/terminal-check.js +190 -0
- package/src/terminal-launch.js +2 -2
- package/src/update.js +72 -0
package/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
|
|
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)
|
package/docs/architecture.md
CHANGED
|
@@ -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
|
|
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
|
|
package/docs/configuration.md
CHANGED
|
@@ -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.
|
|
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": {
|
package/src/browser-interview.js
CHANGED
|
@@ -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'
|
|
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 });
|
package/src/browser-page.js
CHANGED
|
@@ -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;}
|
package/src/check-recovery.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
validate: () => validateCheckpoint(session.snapshot, 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
|
-
|
|
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)) {
|