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 +113 -0
- package/docs/configuration.md +46 -4
- package/package.json +1 -1
- package/src/browser-interview.js +15 -2
- package/src/browser-page.js +12 -2
- package/src/cli.js +28 -32
- package/src/config.js +11 -6
- package/src/evaluator.js +17 -7
- package/src/git-gate.js +13 -2
- package/src/git-interview.js +41 -2
- package/src/git.js +34 -3
- package/src/github-worker.js +4 -0
- package/src/github.js +174 -0
- package/src/help.js +1 -1
- package/src/repo-config.js +25 -5
- package/src/repo-context.js +85 -3
- package/src/repo-setup.js +6 -6
- package/src/sessions.js +6 -3
- 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
|
|
|
@@ -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
|
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
|
|
|
@@ -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`
|
|
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
|
|
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.
|
|
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": {
|
package/src/browser-interview.js
CHANGED
|
@@ -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;
|
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,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 =>
|
|
351
|
-
|
|
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('
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
validate: () => validateCheckpoint(session.snapshot, 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
|
-
|
|
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(
|
|
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.
|