sphinx-understand 0.1.5 → 0.1.7
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 +2 -2
- package/bin/sphinx.js +3 -1
- package/docs/architecture.md +1 -1
- package/docs/configuration.md +2 -0
- package/package.json +2 -2
- package/src/agent.js +3 -0
- package/src/browser-interview.js +15 -10
- package/src/browser-page.js +35 -6
- package/src/check-recovery.js +15 -5
- package/src/cli.js +52 -12
- package/src/config.js +6 -6
- package/src/cursor-hook.js +2 -2
- package/src/evaluator.js +87 -25
- package/src/git-gate.js +10 -0
- package/src/help.js +6 -4
- package/src/lifecycle-integrations.js +5 -1
- package/src/model-presets.js +1 -1
- package/src/openrouter.js +2 -2
- package/src/preparation.js +8 -1
- package/src/prompts.js +12 -5
- package/src/repo-setup.js +308 -0
- package/src/reviewer-response.js +4 -1
- package/src/reviewer.js +9 -5
- package/src/sessions.js +55 -5
- package/src/stats.js +54 -0
- package/src/storage.js +54 -0
- package/src/terminal.js +17 -3
- package/src/usage.js +118 -5
- package/src/working-context.js +144 -0
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
<h1 align="center">Sphinx</h1>
|
|
4
4
|
|
|
5
|
-
<p align="center"><em>
|
|
5
|
+
<p align="center"><em>You can outsource your thinking, but not your understanding.</em></p>
|
|
6
6
|
|
|
7
7
|
<p align="center"><img src="docs/demo.gif" width="860" alt="A git push pauses while Sphinx asks a question about the change in a local browser page; after a correct answer the push continues."></p>
|
|
8
8
|
|
|
@@ -144,7 +144,7 @@ Your answers go straight from the page to Sphinx. Your coding agent sees only th
|
|
|
144
144
|
|
|
145
145
|
## Limits and cost
|
|
146
146
|
|
|
147
|
-
Question count depends on the size of the change and the depth you choose
|
|
147
|
+
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
148
|
|
|
149
149
|
```sh
|
|
150
150
|
sphinx config --max-model-calls 30 # optional cap per check (1–100)
|
package/bin/sphinx.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { main } from '../src/cli.js';
|
|
3
3
|
import { formatError } from '../src/terminal.js';
|
|
4
|
+
import { withUsageLedger } from '../src/usage.js';
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
// Only the real CLI process records model usage to the on-disk ledger.
|
|
7
|
+
try { process.exitCode = await withUsageLedger(process.env, () => main(process.argv.slice(2))); }
|
|
6
8
|
catch (error) {
|
|
7
9
|
formatError(error, { plain: process.argv.includes('--json') || ['hook', 'mcp'].includes(process.argv[2]) });
|
|
8
10
|
process.exitCode = error.exitCode || 1;
|
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
|
@@ -165,6 +165,7 @@ Call caps bound requests and byte limits bound evidence. Neither is a dollar or
|
|
|
165
165
|
"events": ["pre-push"],
|
|
166
166
|
"branches": ["main", "release/*"],
|
|
167
167
|
"level": "standard",
|
|
168
|
+
"distance": "high",
|
|
168
169
|
"exclude": ["vendor/**"]
|
|
169
170
|
}
|
|
170
171
|
```
|
|
@@ -176,6 +177,7 @@ Call caps bound requests and byte limits bound evidence. Neither is a dollar or
|
|
|
176
177
|
| `branches` | Push policies match the **destination** branch; commit policies match the current local branch |
|
|
177
178
|
| `remotes` | Remote name patterns for pushes; default `*` |
|
|
178
179
|
| `level` | `relaxed`, `standard`, or `thorough` |
|
|
180
|
+
| `distance` | `high` (default): questions about what the change does and affects; `low`: questions about its specific logic, conditions, and edge cases |
|
|
179
181
|
| `exclude` | Extra ignore patterns, added to machine-wide ones |
|
|
180
182
|
| `backgroundPreparation` | `false` opts this repository out |
|
|
181
183
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sphinx-understand",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.7",
|
|
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": {
|
|
7
7
|
"type": "git",
|
package/src/agent.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { constants } from 'node:fs';
|
|
2
2
|
import { browserHandoff } from './lifecycle-integrations.js';
|
|
3
3
|
import { open } from 'node:fs/promises';
|
|
4
|
+
import { setupNudge } from './repo-setup.js';
|
|
4
5
|
|
|
5
6
|
/** Session setup only. Git and native lifecycle hooks control the actual checkpoint. */
|
|
6
7
|
export async function agentHook({ agent, event, input = '', env = process.env }) {
|
|
@@ -30,6 +31,7 @@ export async function agentHook({ agent, event, input = '', env = process.env })
|
|
|
30
31
|
try { await file.writeFile(`\nexport SPHINX_CLAUDE_SESSION='${payload.session_id}'\n`); }
|
|
31
32
|
finally { await file.close(); }
|
|
32
33
|
}
|
|
34
|
+
const nudge = await setupNudge(payload?.cwd);
|
|
33
35
|
return {
|
|
34
36
|
exitCode: 0,
|
|
35
37
|
output: {
|
|
@@ -48,6 +50,7 @@ export async function agentHook({ agent, event, input = '', env = process.env })
|
|
|
48
50
|
'After a fallback questionnaire passes, verify that the original Git operation has exited with a Sphinx blocking failure before retrying it. Never duplicate a waiting operation or repeat a successful operation. A changed commit or destination may require another assessment. Reopen a paused blocking checkpoint only when the human asks and the previous Git process has exited.',
|
|
49
51
|
'If neither the local page nor native forms are available, report the integration issue and use sphinx doctor to diagnose it. The human can run sphinx check in their own terminal. Do not substitute a chat-based questionnaire or submit answers yourself.',
|
|
50
52
|
'The assessment is open-book. Helping the human inspect code is encouraged. The coding agent must not grade the response itself.',
|
|
53
|
+
...(nudge ? [nudge] : []),
|
|
51
54
|
].join('\n'),
|
|
52
55
|
},
|
|
53
56
|
},
|
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,7 +7,7 @@ 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';
|
|
@@ -58,12 +59,14 @@ export function createBrowserInterview({ env = process.env, signal, timeoutMs =
|
|
|
58
59
|
const token = randomBytes(32).toString('base64url'), nonce = randomBytes(24).toString('base64');
|
|
59
60
|
let server, origin, url, opened = false, closed = false, active, skip, resolver, sequence = 0, waitingGit = false;
|
|
60
61
|
const pauseMessage = () => waitingGit
|
|
61
|
-
? 'Paused. Progress is saved; the waiting Git operation was stopped.'
|
|
62
|
-
: 'Paused. Your progress and completed checkpoints are saved.';
|
|
62
|
+
? 'Paused. Progress is saved; the waiting Git operation was stopped. You can close this tab.'
|
|
63
|
+
: 'Paused. Your progress and completed checkpoints are saved. You can close this tab.';
|
|
63
64
|
let state = { phase: 'waiting', operationClosed: false, revision: randomUUID(), sequence, message: 'Waiting for the checkpoint.', messages: [] };
|
|
64
65
|
// The chat transcript for the current checkpoint lives only in this process.
|
|
65
66
|
// It contains public questions, feedback, and the human's own text.
|
|
66
|
-
let transcript = [], turns = 0;
|
|
67
|
+
let transcript = [], turns = 0, score = null;
|
|
68
|
+
// The repository's understanding score, refreshed after every graded answer.
|
|
69
|
+
const refreshScore = async (repoRoot, operationEnv) => { score = scoreSummary(await readStats(repoRoot, operationEnv)); };
|
|
67
70
|
// Read-only display of the checkpoint's frozen comparison. It is loaded
|
|
68
71
|
// beside the questionnaire and can never block, grade, or approve it.
|
|
69
72
|
let view, evidence, evidenceKey;
|
|
@@ -91,7 +94,7 @@ export function createBrowserInterview({ env = process.env, signal, timeoutMs =
|
|
|
91
94
|
// Display-only updates (change view, evidence) keep the revision, so they
|
|
92
95
|
// never turn an answer already in flight into a stale submission.
|
|
93
96
|
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 };
|
|
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 };
|
|
95
98
|
const event = `data: ${JSON.stringify(state)}\n\n`;
|
|
96
99
|
for (const stream of streams) writeEvent(stream, event);
|
|
97
100
|
};
|
|
@@ -150,7 +153,7 @@ export function createBrowserInterview({ env = process.env, signal, timeoutMs =
|
|
|
150
153
|
// Available at any step, including while the review is still running.
|
|
151
154
|
skip = { reason: data.response };
|
|
152
155
|
active.abort(cancelled());
|
|
153
|
-
publish({ ...publicFields(state), phase: 'skipped', message: waitingGit ? 'Skipped. Git is continuing.' : 'Skipped.' });
|
|
156
|
+
publish({ ...publicFields(state), phase: 'skipped', message: waitingGit ? 'Skipped. Git is continuing; you can close this tab.' : 'Skipped. You can close this tab.' });
|
|
154
157
|
return send(res, 200, state);
|
|
155
158
|
}
|
|
156
159
|
if (data.action === 'pause') {
|
|
@@ -255,6 +258,7 @@ export function createBrowserInterview({ env = process.env, signal, timeoutMs =
|
|
|
255
258
|
if (session.snapshot.repoRoot !== request.repoRoot) throw new Error('This checkpoint belongs to another repository.');
|
|
256
259
|
await validate(session.snapshot, config);
|
|
257
260
|
startView(request, session.snapshot, config, operationEnv, store);
|
|
261
|
+
await refreshScore(request.repoRoot, operationEnv).catch(() => {});
|
|
258
262
|
let visible = store.public(session);
|
|
259
263
|
publish({ ...publicFields(visible), repoRoot: request.repoRoot, phase: 'working', message: 'Preparing your understanding check…' });
|
|
260
264
|
await ensureServer(operationEnv);
|
|
@@ -277,7 +281,7 @@ export function createBrowserInterview({ env = process.env, signal, timeoutMs =
|
|
|
277
281
|
},
|
|
278
282
|
choose: async prompt => {
|
|
279
283
|
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));
|
|
284
|
+
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
285
|
return action.action;
|
|
282
286
|
},
|
|
283
287
|
});
|
|
@@ -306,15 +310,16 @@ export function createBrowserInterview({ env = process.env, signal, timeoutMs =
|
|
|
306
310
|
learn: action.action === 'learn', dispute: action.action === 'dispute', expectedQuestionId: action.questionId, signal: localSignal,
|
|
307
311
|
})).finally(dropDraft);
|
|
308
312
|
settle('graded');
|
|
313
|
+
await refreshScore(request.repoRoot, operationEnv).catch(() => {});
|
|
309
314
|
if (visible.feedback) say({ role: 'sphinx', kind: 'feedback', text: visible.feedback });
|
|
310
315
|
} catch (error) {
|
|
311
316
|
settle('not-graded');
|
|
312
317
|
localSignal.throwIfAborted(); await validate(session.snapshot, config);
|
|
313
318
|
visible = store.public(await store.read(request.id));
|
|
314
319
|
// A rate-limited API route can hand grading to the user's coding-agent plan.
|
|
315
|
-
if (isRateLimit(error, visible.route) && plans.length) {
|
|
320
|
+
if ((isRateLimit(error, visible.route) || isCliFailure(error, visible.route)) && switchChoices(plans, visible.route.provider).length) {
|
|
316
321
|
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'
|
|
322
|
+
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
323
|
if (picked.action.startsWith('switch-')) {
|
|
319
324
|
const provider = picked.action.slice('switch-'.length);
|
|
320
325
|
visible = await store.switchRoute(request.id, config, provider, { signal: localSignal });
|
|
@@ -329,7 +334,7 @@ export function createBrowserInterview({ env = process.env, signal, timeoutMs =
|
|
|
329
334
|
}
|
|
330
335
|
localSignal.throwIfAborted(); await validate(session.snapshot, config);
|
|
331
336
|
finalStatus = 'passed';
|
|
332
|
-
publish({ ...publicFields(visible), repoRoot: request.repoRoot, phase: 'passed', message: waitingGit ? 'You passed. You can close this tab; Git is finishing the push on its own.\nThis records your answers, not a review of every changed line.' : 'You passed. You can close this tab.\nThis records your answers, not a review of every changed line.' });
|
|
337
|
+
publish({ ...publicFields(visible), repoRoot: request.repoRoot, phase: 'passed', message: visible.noQuestionReason ? `No questions needed: ${visible.noQuestionReason} You can close this tab${waitingGit ? '; Git is finishing the push on its own' : ''}.` : waitingGit ? 'You passed. You can close this tab; Git is finishing the push on its own.\nThis records your answers, not a review of every changed line.' : 'You passed. You can close this tab.\nThis records your answers, not a review of every changed line.' });
|
|
333
338
|
return { status: 'passed', id: request.id };
|
|
334
339
|
} catch (error) {
|
|
335
340
|
settle('not-graded');
|
package/src/browser-page.js
CHANGED
|
@@ -17,7 +17,7 @@ header{min-height:52px;border-bottom:1px solid var(--line);padding:8px 24px;disp
|
|
|
17
17
|
.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
18
|
.tabs{display:none}.scope-short{display:none}
|
|
19
19
|
.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}
|
|
20
|
+
#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
21
|
.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
22
|
.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
23
|
.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 +61,17 @@ button.primary{background:var(--amber);color:#fff;border-color:var(--amber);marg
|
|
|
61
61
|
button:hover:not(:disabled){background:#eee7dc}button.primary:hover:not(:disabled){background:#8b4208}
|
|
62
62
|
.disputing textarea{border-color:#8d271e}.disputing #submit{background:#8d271e;border-color:#8d271e}
|
|
63
63
|
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)}
|
|
64
|
+
.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
65
|
header .tools{display:flex;gap:4px}
|
|
66
|
+
.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}
|
|
67
|
+
.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}
|
|
68
|
+
.score-value.pending{font-family:inherit;font-size:13px;font-weight:500;line-height:1.6;color:var(--muted)}
|
|
69
|
+
.score-dots{display:flex;gap:4px}.score-dots i{width:11px;height:11px;border-radius:50%;border:1.5px solid #cfc4b6;background:transparent}
|
|
70
|
+
.score-dots i.hit{background:var(--pass);border-color:var(--pass)}.score-dots i.miss{border-color:#c0574a;background:#f6e2df}
|
|
71
|
+
.score-dots i.fresh{animation:pop .5s ease}@keyframes pop{0%{transform:scale(.3)}70%{transform:scale(1.25)}100%{transform:scale(1)}}
|
|
72
|
+
.score-meta{font-size:11px;color:var(--muted);white-space:nowrap}
|
|
73
|
+
@media(prefers-reduced-motion:reduce){.score-dots i.fresh{animation:none}}
|
|
74
|
+
@media(max-width:600px){.score{margin-right:4px;padding:4px 10px}.score-meta{display:none}}
|
|
66
75
|
.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
76
|
.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
77
|
button.danger{background:#8d271e;border-color:#8d271e;color:#fff}button.danger:hover:not(:disabled){background:#6f1f18}
|
|
@@ -81,7 +90,7 @@ footer{flex-shrink:0;display:flex;flex-wrap:wrap;gap:2px 8px;padding:0 0 14px;co
|
|
|
81
90
|
#question-pane{overflow-y:auto}.conversation{flex:none;overflow:visible;padding-right:0}.evidence{max-height:none;overflow:visible}}
|
|
82
91
|
@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
92
|
</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>
|
|
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>
|
|
85
94
|
<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
95
|
<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
96
|
<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 +111,7 @@ footer{flex-shrink:0;display:flex;flex-wrap:wrap;gap:2px 8px;padding:0 0 14px;co
|
|
|
102
111
|
<div id="conversation" class="conversation" aria-label="Understanding check conversation">
|
|
103
112
|
<ol id="messages" aria-label="Conversation"></ol>
|
|
104
113
|
<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>
|
|
114
|
+
<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
115
|
</div>
|
|
107
116
|
<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
117
|
<div id="composer" class="composer"><p id="error" class="error" role="alert" hidden></p>
|
|
@@ -118,6 +127,26 @@ const text=(id,value)=>{$(id).textContent=value||'';$(id).hidden=!value;};
|
|
|
118
127
|
const compact=n=>n>=1e6?(n/1e6).toFixed(1)+'M':n>=1e3?(n/1e3).toFixed(1)+'k':String(n);
|
|
119
128
|
const usageText=(u,t)=>{if(!t)return '';const total=(t.input||0)+(t.output||0);if(!total&&t.cost==null)return '';
|
|
120
129
|
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(' · ');};
|
|
130
|
+
const PROVIDERS={openrouter:'OpenRouter',openai:'Endpoint',claude:'Claude Code',codex:'Codex'};
|
|
131
|
+
const routeText=r=>r?.provider?[PROVIDERS[r.provider]||r.provider,r.model].filter(Boolean).join(' '):'';
|
|
132
|
+
// The repository's understanding score, shown rather than explained: one dot
|
|
133
|
+
// per recent answer (green = right first time), filling empty slots while calibrating.
|
|
134
|
+
let lastScored=-1;
|
|
135
|
+
function scoreView(sc){
|
|
136
|
+
$('score').hidden=!sc;if(!sc)return;
|
|
137
|
+
const recent=sc.recent||[],slots=sc.calibrating?sc.answered+sc.needed:Math.max(recent.length,1);
|
|
138
|
+
const dots=[];
|
|
139
|
+
for(let i=0;i<slots;i++){const dot=document.createElement('i'),hit=recent[i];
|
|
140
|
+
if(i<recent.length){dot.className=hit?'hit':'miss';dot.title=hit?'Right first time':'Needed another try';}else dot.title='Not answered yet';
|
|
141
|
+
if(i===recent.length-1&&lastScored!==-1&&sc.answered>lastScored)dot.className+=' fresh';
|
|
142
|
+
dots.push(dot);}
|
|
143
|
+
$('score-dots').replaceChildren(...dots);
|
|
144
|
+
$('score-dots').setAttribute('aria-label',recent.filter(Boolean).length+' of the last '+recent.length+' answers right first time');
|
|
145
|
+
$('score-value').textContent=sc.calibrating?'warming up':sc.accuracy+'%';$('score-value').className='score-value'+(sc.calibrating?' pending':'');
|
|
146
|
+
$('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.';
|
|
147
|
+
const meta=[];if(sc.streak>1)meta.push(sc.streak+' in a row');if(sc.skipRate!=null)meta.push('skipped '+sc.skipRate+'%');
|
|
148
|
+
$('score-meta').textContent=meta.join(' · ');lastScored=sc.answered;
|
|
149
|
+
}
|
|
121
150
|
const resize=()=>{$('response').style.height='auto';$('response').style.height=Math.min($('response').scrollHeight||76,180)+'px';};
|
|
122
151
|
function controls(){
|
|
123
152
|
if(!current)return;
|
|
@@ -167,11 +196,11 @@ function render(state){
|
|
|
167
196
|
const previous=current;current=state;finished=state.operationClosed||['passed','paused','error','skipped'].includes(state.phase);
|
|
168
197
|
const repo=state.repoRoot?.split(/[\\\\/]/).filter(Boolean).at(-1),branch=state.target?.replace(/^refs\\/heads\\//,'');
|
|
169
198
|
text('meta',repo||state.change?.header?.repository||'');$('meta').title=state.repoRoot||'';
|
|
170
|
-
text('usage',usageText(state.usage,state.tokens));const messages=state.messages||[];
|
|
199
|
+
text('usage',[routeText(state.route),usageText(state.usage,state.tokens)].filter(Boolean).join(' · '));scoreView(state.score);const messages=state.messages||[];
|
|
171
200
|
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.');
|
|
172
201
|
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…';
|
|
173
202
|
const retry=state.phase==='question'&&[...messages].reverse().find(item=>item.kind==='question')?.followUp;
|
|
174
|
-
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';}
|
|
203
|
+
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';}
|
|
175
204
|
history(messages);
|
|
176
205
|
// Live activity already shows progress; avoid stacking generic spinners under it.
|
|
177
206
|
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,14 +1,16 @@
|
|
|
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';
|
|
5
|
-
import { lstat } from 'node:fs/promises';
|
|
6
|
+
import { lstat, readFile } from 'node:fs/promises';
|
|
6
7
|
import { parseArgs, promisify } from 'node:util';
|
|
7
8
|
import { execFile } from 'node:child_process';
|
|
8
9
|
import { createInterface } from 'node:readline/promises';
|
|
9
10
|
import { Writable } from 'node:stream';
|
|
10
11
|
import { getPaths, loadConfig, saveConfig, saveOpenRouterKey, openRouterKey, saveOpenAIEndpoint, openAIEndpoint, validateConfig, repositoryConfigInfo, policyHash } from './config.js';
|
|
11
12
|
import { initializeRepository } from './repo-config.js';
|
|
13
|
+
import { applyRepositorySetup, suggestRepositorySetup } from './repo-setup.js';
|
|
12
14
|
import * as git from './git.js';
|
|
13
15
|
import { install, uninstall, doctor, dispatchHook, installedAgents, detectAgents } from './install.js';
|
|
14
16
|
import { activateIntegrations } from './activation.js';
|
|
@@ -23,20 +25,20 @@ import { askInstall, askPolicy, createPrompts, questionCapsText, reviewCapsText,
|
|
|
23
25
|
import { runGitGate } from './git-gate.js';
|
|
24
26
|
import { loadProjectContext } from './repo-context.js';
|
|
25
27
|
import { REVIEWER_PRESETS, presetOptions } from './model-presets.js';
|
|
26
|
-
import { modelUsageText, modelLimitText } from './usage.js';
|
|
28
|
+
import { modelUsageText, modelLimitText, readUsage, summarizeUsage, printUsage, usageDir } from './usage.js';
|
|
27
29
|
import { toggleYolo, yoloStatus, recentOverrides } from './overrides.js';
|
|
28
30
|
|
|
29
31
|
const CLI_PATH = fileURLToPath(new URL('../bin/sphinx.js', import.meta.url));
|
|
30
|
-
const POLICY_FLAGS = ['branches', 'events', 'level', 'remotes'];
|
|
32
|
+
const POLICY_FLAGS = ['branches', 'events', 'level', 'distance', 'remotes'];
|
|
31
33
|
const MODEL_FLAGS = ['preset', 'provider', 'model', 'small', 'medium', 'large', 'allow-paid', 'max-model-calls', 'max-output-tokens', 'cli-billing'];
|
|
32
34
|
const OPTIONS = {
|
|
33
35
|
install: ['yes', 'repo', 'agents', ...MODEL_FLAGS, 'launch', 'no-launch', ...POLICY_FLAGS],
|
|
34
36
|
activate: ['agents', 'json'],
|
|
35
|
-
init: ['yes', 'json', 'context', ...POLICY_FLAGS], check: ['json', 'id', 'remote', 'branch', 'staged', 'retry-review'],
|
|
37
|
+
init: ['yes', 'json', 'context', 'suggest', 'apply', ...POLICY_FLAGS], check: ['json', 'id', 'remote', 'branch', 'staged', 'retry-review'],
|
|
36
38
|
prepare: ['json', 'worker', 'retry'],
|
|
37
39
|
answer: ['json', 'text'], explain: ['json'], dispute: ['json', 'text'], pending: ['json'],
|
|
38
|
-
status: ['json'], config: ['json', ...MODEL_FLAGS], doctor: ['json'], auth: ['provider', 'base-url'], uninstall: ['repo'],
|
|
39
|
-
mcp: [], yolo: ['json', 'reason'],
|
|
40
|
+
status: ['json'], config: ['json', 'distance', ...MODEL_FLAGS], doctor: ['json'], auth: ['provider', 'base-url'], uninstall: ['repo'],
|
|
41
|
+
mcp: [], yolo: ['json', 'reason'], usage: ['json'],
|
|
40
42
|
};
|
|
41
43
|
const json = value => console.log(JSON.stringify(value, null, 2));
|
|
42
44
|
const ui = () => createTerminal();
|
|
@@ -133,7 +135,7 @@ export function applyInstallOptions(current, values) {
|
|
|
133
135
|
}
|
|
134
136
|
if (values['max-model-calls'] !== undefined) config.maxModelCalls = parseModelCalls(values['max-model-calls']);
|
|
135
137
|
if (values['max-output-tokens'] !== undefined) config.maxOutputTokens = parseOutputTokens(values['max-output-tokens']);
|
|
136
|
-
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', 'mode']) if (values[key] !== undefined) config[key] = values[key];
|
|
137
139
|
for (const key of ['branches', 'events', 'remotes']) if (values[key] !== undefined) config[key] = splitList(values[key]);
|
|
138
140
|
if (values.provider !== undefined || values.model !== undefined) {
|
|
139
141
|
if (values.provider !== undefined && values.model === undefined) {
|
|
@@ -162,6 +164,7 @@ function policyRows(config) {
|
|
|
162
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.'],
|
|
163
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.'],
|
|
164
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'],
|
|
165
168
|
['Preparation', config.backgroundPreparation ? 'Background review after commits and branch updates' : 'On demand when the understanding check starts'],
|
|
166
169
|
['Behavior', !enabled ? 'Disabled for this repository' : 'The Git operation only goes through after you answer the understanding questions correctly'],
|
|
167
170
|
];
|
|
@@ -267,9 +270,10 @@ async function installCommand(initial) {
|
|
|
267
270
|
const launch = !initial['no-launch'] && (initial.launch || !initial.yes);
|
|
268
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.');
|
|
269
272
|
const detected = !initial.yes && (initial.agents === undefined || initial.agents === 'auto') ? await detectAgents() : [];
|
|
273
|
+
const installedPlans = initial.yes ? [] : await planProviders().catch(() => []);
|
|
270
274
|
const values = initial.yes ? initial : await withReadline(ask => askInstall(current, initial, createPrompts({ ask, ui: terminal,
|
|
271
275
|
select: process.stdin.isTTY && process.stdout.isTTY && process.env.TERM !== 'dumb' ? ask.checkbox : undefined,
|
|
272
|
-
}), { detectedAgents: detected, hasExistingConfig }));
|
|
276
|
+
}), { detectedAgents: detected, hasExistingConfig, installedPlans }));
|
|
273
277
|
const config = applyInstallOptions(current, values);
|
|
274
278
|
terminal.section('Machine-wide defaults');
|
|
275
279
|
terminal.paragraph('Each repository can override these branches and remotes in .sphinxconfig to match its own production setup.');
|
|
@@ -295,7 +299,36 @@ async function installCommand(initial) {
|
|
|
295
299
|
terminal.blank(); terminal.note(`Global settings: ${getPaths().config}`); return 0;
|
|
296
300
|
}
|
|
297
301
|
|
|
302
|
+
async function readProposal(source) {
|
|
303
|
+
let text = '';
|
|
304
|
+
if (source === '-') { for await (const chunk of process.stdin) { text += chunk; if (text.length > 1e6) throw new Error('Proposal exceeds 1 MB.'); } }
|
|
305
|
+
else text = await readFile(path.resolve(source), 'utf8');
|
|
306
|
+
try { return JSON.parse(text); } catch (error) { throw new Error(`Proposal is not valid JSON: ${error.message}`); }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Agent setup: a read-only proposal, then a validated, idempotent apply.
|
|
310
|
+
async function agentSetupCommand(values) {
|
|
311
|
+
const extra = Object.keys(values).filter(key => !['suggest', 'apply', 'json'].includes(key));
|
|
312
|
+
if (extra.length || (values.suggest && values.apply !== undefined)) throw new Error('Use either --suggest or --apply <file|->, optionally with --json, and no other init options.');
|
|
313
|
+
const { repo, config } = await repoConfig();
|
|
314
|
+
if (values.suggest) { json(await suggestRepositorySetup({ repoRoot: repo.root })); return 0; }
|
|
315
|
+
let result;
|
|
316
|
+
try { result = await applyRepositorySetup({ repoRoot: repo.root, proposal: await readProposal(values.apply), config }); }
|
|
317
|
+
catch (error) {
|
|
318
|
+
if (!values.json || error.code !== 'SPHINX_INVALID_PROPOSAL') throw error;
|
|
319
|
+
json({ ok: false, errors: error.errors }); return 1;
|
|
320
|
+
}
|
|
321
|
+
if (values.json) { json(result); return 0; }
|
|
322
|
+
const terminal = ui();
|
|
323
|
+
for (const file of result.created) terminal.success(`Created ${file}`);
|
|
324
|
+
for (const file of result.updated) terminal.success(`Updated ${file}`);
|
|
325
|
+
for (const file of result.preserved) terminal.note(`Preserved ${file}`);
|
|
326
|
+
for (const line of result.warnings) terminal.warning(line);
|
|
327
|
+
return 0;
|
|
328
|
+
}
|
|
329
|
+
|
|
298
330
|
async function initCommand(initial) {
|
|
331
|
+
if (initial.suggest || initial.apply !== undefined) return agentSetupCommand(initial);
|
|
299
332
|
const { repo, config } = await repoConfig(), info = await repositoryConfigInfo(repo.root), terminal = ui();
|
|
300
333
|
let values = initial;
|
|
301
334
|
if (!initial.json) { terminal.banner(); terminal.section('Repository setup'); terminal.note(repo.root); }
|
|
@@ -308,7 +341,7 @@ async function initCommand(initial) {
|
|
|
308
341
|
}
|
|
309
342
|
}
|
|
310
343
|
const overrides = {};
|
|
311
|
-
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];
|
|
312
345
|
for (const key of ['branches', 'events', 'remotes']) if (values[key] !== undefined) overrides[key] = splitList(values[key]);
|
|
313
346
|
let context = values.context;
|
|
314
347
|
if (context === undefined && !initial.yes && !initial.json && process.stdin.isTTY && !(await loadProjectContext(repo.root)).text) {
|
|
@@ -430,6 +463,8 @@ async function statusCommand(command, values) {
|
|
|
430
463
|
terminal.section('Configured checkpoints'); printConfiguration(terminal, config, { routes: false });
|
|
431
464
|
if (preparation) terminal.rows([['Background review', preparation.status], ...(preparation.error ? [['Details', preparation.error]] : [])]);
|
|
432
465
|
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.'); }
|
|
466
|
+
const score = scoreSummary(await readStats(repo.root));
|
|
467
|
+
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` : ''}`]]);
|
|
433
468
|
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.`); }
|
|
434
469
|
terminal.section('Pending assessments'); printPending(terminal, pending);
|
|
435
470
|
if (overrides.length) { terminal.section('Recent overrides'); terminal.rows(overrides.map(row => [row.at, `${row.kind}${row.target ? ` · ${row.target}` : ''}${row.reason ? ` · ${row.reason}` : ''}`])); }
|
|
@@ -440,9 +475,9 @@ async function statusCommand(command, values) {
|
|
|
440
475
|
export function parseCommandLine(argv) {
|
|
441
476
|
const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: {
|
|
442
477
|
help: { type: 'boolean', short: 'h' }, version: { type: 'boolean', short: 'v' }, json: { type: 'boolean' }, yes: { type: 'boolean' }, repo: { type: 'boolean' }, staged: { type: 'boolean' },
|
|
443
|
-
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' },
|
|
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' },
|
|
444
479
|
remote: { type: 'string' }, branch: { type: 'string' }, id: { type: 'string' }, text: { type: 'string' }, 'allow-paid': { type: 'boolean' },
|
|
445
|
-
context: { type: 'string' }, worker: { type: 'boolean' }, retry: { type: 'boolean' }, reason: { type: 'string' }, 'base-url': { type: 'string' }, 'cli-billing': { type: 'string' },
|
|
480
|
+
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' },
|
|
446
481
|
launch: { type: 'boolean' }, 'no-launch': { type: 'boolean' }, 'retry-review': { type: 'boolean' }, 'max-model-calls': { type: 'string' }, 'max-output-tokens': { type: 'string' },
|
|
447
482
|
} });
|
|
448
483
|
const [command, id] = positionals;
|
|
@@ -513,7 +548,7 @@ export async function main(argv) {
|
|
|
513
548
|
return result.checks.every(check => check.ok) ? 0 : 1;
|
|
514
549
|
}
|
|
515
550
|
if (command === 'config') {
|
|
516
|
-
if (MODEL_FLAGS.some(key => values[key] !== undefined)) {
|
|
551
|
+
if ([...MODEL_FLAGS, 'distance'].some(key => values[key] !== undefined)) {
|
|
517
552
|
const current = await loadConfig(), previousMaxModelCalls = current.maxModelCalls;
|
|
518
553
|
const config = applyInstallOptions(current, values), maxModelCalls = config.maxModelCalls;
|
|
519
554
|
await saveConfig(config);
|
|
@@ -546,6 +581,11 @@ export async function main(argv) {
|
|
|
546
581
|
terminal.blank(); terminal.note('Use sphinx config --preset <name> to choose evaluator models, or --max-model-calls <1-100|unlimited> to set or clear an optional call cap. See sphinx config --help for tier overrides and paid routes.'); return 0;
|
|
547
582
|
}
|
|
548
583
|
if (command === 'check') return checkCommand(values);
|
|
584
|
+
if (command === 'usage') {
|
|
585
|
+
const summary = { ledger: usageDir(process.env), ...summarizeUsage(readUsage()) };
|
|
586
|
+
if (values.json) json(summary); else printUsage(ui(), summary, { ledgerPath: summary.ledger });
|
|
587
|
+
return 0;
|
|
588
|
+
}
|
|
549
589
|
if (command === 'yolo') {
|
|
550
590
|
const repo = await git.repository(process.cwd()).catch(() => null);
|
|
551
591
|
const result = await toggleYolo({ repoRoot: repo?.root ?? null, reason: values.reason });
|
package/src/config.js
CHANGED
|
@@ -9,12 +9,12 @@ 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', mode: 'block', events: ['pre-push'],
|
|
12
|
+
level: 'standard', distance: 'high', mode: 'block', 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
|
-
backgroundPreparation: true, preparationDebounceMs: 1500,
|
|
17
|
+
backgroundPreparation: true, preparationDebounceMs: 1500, workingContext: true,
|
|
18
18
|
models: {
|
|
19
19
|
small: { provider: 'openrouter', model: 'openrouter/free' },
|
|
20
20
|
medium: { provider: 'openrouter', model: 'openrouter/free' },
|
|
@@ -72,8 +72,8 @@ export function validateConfig(config) {
|
|
|
72
72
|
const allowed = new Set(Object.keys(DEFAULT_CONFIG));
|
|
73
73
|
for (const key of Object.keys(config)) if (!allowed.has(key)) fail(`unknown setting ${key}`);
|
|
74
74
|
if (config.version !== 1) fail('version must be 1');
|
|
75
|
-
for (const key of ['enabled', 'allowPaid', 'backgroundPreparation']) if (typeof config[key] !== 'boolean') fail(`${key} must be boolean`);
|
|
76
|
-
for (const [key, values] of Object.entries({ provider: PROVIDERS, level: ['relaxed','standard','thorough'], mode: ['block'], cliBilling: ['plan','api'] })) {
|
|
75
|
+
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'] })) {
|
|
77
77
|
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
78
|
if (!values.includes(config[key])) fail(`${key} must be ${values.join(', ')}`);
|
|
79
79
|
}
|
|
@@ -110,7 +110,7 @@ function merged(base, overrides) {
|
|
|
110
110
|
return { ...base, ...overrides, models: { ...base.models, ...overrides?.models }, routing: { ...base.routing, ...overrides?.routing } };
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
export const REPOSITORY_CONFIG_KEYS = Object.freeze(['version','enabled','level','mode','events','branches','remotes','exclude','backgroundPreparation']);
|
|
113
|
+
export const REPOSITORY_CONFIG_KEYS = Object.freeze(['version','enabled','level','distance','mode','events','branches','remotes','exclude','backgroundPreparation']);
|
|
114
114
|
|
|
115
115
|
export function validateRepositoryConfig(local, { env = process.env, source = '.sphinxconfig' } = {}) {
|
|
116
116
|
if (!local || typeof local !== 'object' || Array.isArray(local)
|
|
@@ -182,7 +182,7 @@ export function policyHash(config) {
|
|
|
182
182
|
// checkpoint identity within the same assessment contract.
|
|
183
183
|
// Version the contract when assessment semantics change: older questions and
|
|
184
184
|
// receipts must not stand in for an assessment under the new requirements.
|
|
185
|
-
return createHash('sha256').update(stableStringify({ ...config, maxModelCalls: 10, agentMaxTurns: 8, cliBilling: 'plan', assessmentContract: 3 })).digest('hex');
|
|
185
|
+
return createHash('sha256').update(stableStringify({ ...config, maxModelCalls: 10, agentMaxTurns: 8, cliBilling: 'plan', workingContext: true, assessmentContract: 3 })).digest('hex');
|
|
186
186
|
}
|
|
187
187
|
|
|
188
188
|
const isRecord = value => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
package/src/cursor-hook.js
CHANGED
|
@@ -11,8 +11,8 @@ try {
|
|
|
11
11
|
input += chunk;
|
|
12
12
|
}
|
|
13
13
|
const payload = JSON.parse(input || '{}');
|
|
14
|
-
const { hook_event_name, session_id, conversation_id, cwd, tool_name, tool_output, error_message } = payload;
|
|
15
|
-
process.stdout.write(`${JSON.stringify(await cursorAgentHook({ input: { hook_event_name, session_id, conversation_id, cwd, tool_name, tool_output, error_message } }))}\n`);
|
|
14
|
+
const { hook_event_name, session_id, conversation_id, cwd, workspace_roots, tool_name, tool_output, error_message } = payload;
|
|
15
|
+
process.stdout.write(`${JSON.stringify(await cursorAgentHook({ input: { hook_event_name, session_id, conversation_id, cwd, workspace_roots, tool_name, tool_output, error_message } }))}\n`);
|
|
16
16
|
} catch {
|
|
17
17
|
process.stdout.write(`${JSON.stringify({ additional_context: 'Sphinx could not inspect this hook event. The human can run sphinx doctor and sphinx check in their own terminal.' })}\n`);
|
|
18
18
|
}
|