sphinx-understand 0.1.7 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/docs/configuration.md +37 -0
- package/package.json +1 -1
- package/src/browser-interview.js +15 -2
- package/src/browser-page.js +12 -2
- package/src/cli.js +18 -28
- package/src/config.js +11 -6
- package/src/git-gate.js +13 -2
- package/src/git-interview.js +41 -2
- package/src/git.js +17 -1
- package/src/github-worker.js +4 -0
- package/src/github.js +174 -0
- package/src/help.js +1 -1
- package/src/sessions.js +1 -1
- 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
|
|
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
|
|
|
@@ -180,6 +182,7 @@ Call caps bound requests and byte limits bound evidence. Neither is a dollar or
|
|
|
180
182
|
| `distance` | `high` (default): questions about what the change does and affects; `low`: questions about its specific logic, conditions, and edge cases |
|
|
181
183
|
| `exclude` | Extra ignore patterns, added to machine-wide ones |
|
|
182
184
|
| `backgroundPreparation` | `false` opts this repository out |
|
|
185
|
+
| `github` | GitHub stamps; see [below](#github-commit-status-and-pull-requests) |
|
|
183
186
|
|
|
184
187
|
Branch defaults come from machine-wide settings (initially `main` and `master`), not from the branch you happen to have checked out. Without prompts:
|
|
185
188
|
|
|
@@ -193,6 +196,8 @@ If both `.sphinxconfig` and legacy `.sphinx.json` exist, `.sphinxconfig` wins an
|
|
|
193
196
|
|
|
194
197
|
**Manual push checks.** `sphinx check --remote origin --branch main` selects the destination; otherwise Sphinx uses the upstream. Multiple destination refs are assessed separately. Tag-only pushes and deletions do not trigger checks in v0.1.
|
|
195
198
|
|
|
199
|
+
**Pull request branches.** Set `"branches": ["*"]` to check pushes to every branch. The first push of a new branch is checked from where it left the remote's default branch (the same range the pull request shows), using local remote-tracking refs; later pushes check only the new commits.
|
|
200
|
+
|
|
196
201
|
## Project context
|
|
197
202
|
|
|
198
203
|
`.sphinxcontext.md` describes what the project does, who uses it, important workflows, and architecture boundaries. It is included in every reviewer, compaction, and grading request.
|
|
@@ -241,3 +246,35 @@ After commits, merges, rewrites, and checkouts, Sphinx schedules a detached revi
|
|
|
241
246
|
- Set `backgroundPreparation: false` in `config.json` or `.sphinxconfig` to opt out. A repository cannot re-enable preparation disabled machine-wide.
|
|
242
247
|
- While yolo is on, preparation is paused: no new model request starts, although one already in progress may finish.
|
|
243
248
|
- A first push, a branch without local tracking history, or a push to an unexpected destination needs a cold review at push time. Pre-commit checks always review on demand. Run `git fetch` if remote history is missing.
|
|
249
|
+
|
|
250
|
+
## GitHub commit status and pull requests
|
|
251
|
+
|
|
252
|
+
When you push to GitHub and a check passes, Sphinx marks the pushed commit with a `sphinx/understanding` commit status, for example "Understood: 3 questions, 2 first try". It shows next to the commit and in the pull request's checks. A skipped check is marked as a failure ("Understanding check skipped"). Yolo pushes and commit checkpoints are not marked.
|
|
253
|
+
|
|
254
|
+
```json
|
|
255
|
+
{ "github": { "status": true, "comment": false, "answers": false } }
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
| Key | Default | Meaning |
|
|
259
|
+
| --- | --- | --- |
|
|
260
|
+
| `status` | `true` | Post the commit status |
|
|
261
|
+
| `comment` | `false` | Keep one comment on the branch's open pull request listing every check on that branch: questions, first-try results, and the model. It is edited in place on later pushes |
|
|
262
|
+
| `answers` | `false` | Also include your answers in that comment |
|
|
263
|
+
|
|
264
|
+
Set these in `config.json` for yourself or in `.sphinxconfig` for a repository. Private answer keys are never posted.
|
|
265
|
+
|
|
266
|
+
It uses the [GitHub CLI](https://cli.github.com) and its existing login, in a detached process after Git continues, so it never slows or blocks a push. If `gh` is missing or not signed in, the remote isn't GitHub, or any request fails, nothing is posted and nothing else changes. The worker waits a few minutes for the push to land and up to 15 minutes for a pull request to be opened; the result of the last run is in `github/last.json` in Sphinx's data directory.
|
|
267
|
+
|
|
268
|
+
**Requiring it for a team.** In the repository's branch protection or ruleset, add `sphinx/understanding` as a required status check. Pull requests whose head commit was not pushed through a passing check then cannot merge. The status is self-reported by the pusher's machine: it shows the check happened, but it is not a cryptographic proof, and anyone with write access can set a commit status.
|
|
269
|
+
|
|
270
|
+
## Browser or terminal
|
|
271
|
+
|
|
272
|
+
`interface` in `config.json` chooses where a push's questions appear. Set it with `sphinx config --interface <auto|browser|terminal>`.
|
|
273
|
+
|
|
274
|
+
| Value | Behavior |
|
|
275
|
+
| --- | --- |
|
|
276
|
+
| `auto` (default) | The browser on a local Mac, where Sphinx can open it in the background. Elsewhere (SSH, Linux, WSL), inline in the terminal you ran Git from |
|
|
277
|
+
| `terminal` | Always the terminal |
|
|
278
|
+
| `browser` | Always the browser (on Linux and over SSH, open the printed local URL) |
|
|
279
|
+
|
|
280
|
+
Inline questions need a terminal a person is looking at. When Git runs from a coding agent's shell there isn't one, so Sphinx opens a terminal window on a desktop instead, and falls back to the browser when it can't do either. `interface` is a personal setting; it can't be set in `.sphinxconfig` and doesn't change what a check asks.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sphinx-understand",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "You can outsource your thinking, but not your understanding. A comprehension checkpoint for agent-assisted development.",
|
|
5
5
|
"homepage": "https://github.com/parthh01/sphinx#readme",
|
|
6
6
|
"repository": {
|
package/src/browser-interview.js
CHANGED
|
@@ -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,7 +17,6 @@ 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';
|
|
@@ -26,7 +25,8 @@ import { runGitGate } from './git-gate.js';
|
|
|
26
25
|
import { loadProjectContext } 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) {
|
|
@@ -399,9 +399,10 @@ async function checkCommand(values) {
|
|
|
399
399
|
if (values['retry-review']) await store.retryReview(session.id, config);
|
|
400
400
|
json(await start()); return 0;
|
|
401
401
|
}
|
|
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
402
|
const controller = new AbortController(), { signal } = controller;
|
|
403
|
+
const waitingGit = process.env.SPHINX_WAITING_GIT === '1';
|
|
404
404
|
return withReadline(async ask => {
|
|
405
|
+
const terminal = ui();
|
|
405
406
|
if (values['retry-review']) {
|
|
406
407
|
await store.retryReview(session.id, config, { signal });
|
|
407
408
|
signal.throwIfAborted();
|
|
@@ -409,29 +410,17 @@ async function checkCommand(values) {
|
|
|
409
410
|
? 'Resuming the review with no model-call limit. Previous usage remains counted.'
|
|
410
411
|
: `Starting a new review attempt with up to ${config.maxModelCalls} additional model calls, including grading. Previous usage remains counted.`);
|
|
411
412
|
}
|
|
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,
|
|
413
|
+
return runTerminalCheck({
|
|
414
|
+
store, session: await store.read(session.id), config, ask, terminal, signal, waitingGit,
|
|
415
|
+
choose: createPrompts({ ask, ui: terminal }).choose,
|
|
416
|
+
validate: () => validateCheckpoint(session.snapshot, config),
|
|
418
417
|
planProviders: await planProviders().catch(() => []),
|
|
419
|
-
|
|
418
|
+
// A skip lets the waiting Git operation through; it is logged like the browser's.
|
|
419
|
+
skip: async reason => {
|
|
420
|
+
await store.skip(session.id);
|
|
421
|
+
await recordOverride({ kind: 'skip', repoRoot: repo.root, target: session.snapshot.target.ref, id: session.id, reason });
|
|
422
|
+
},
|
|
420
423
|
});
|
|
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
424
|
}, { controller });
|
|
436
425
|
}
|
|
437
426
|
|
|
@@ -475,7 +464,7 @@ async function statusCommand(command, values) {
|
|
|
475
464
|
export function parseCommandLine(argv) {
|
|
476
465
|
const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: {
|
|
477
466
|
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' },
|
|
467
|
+
preset: { type: 'string' }, provider: { type: 'string' }, model: { type: 'string' }, small: { type: 'string' }, medium: { type: 'string' }, large: { type: 'string' }, level: { type: 'string' }, distance: { type: 'string' }, interface: { type: 'string' }, branches: { type: 'string' }, events: { type: 'string' }, agents: { type: 'string' }, remotes: { type: 'string' },
|
|
479
468
|
remote: { type: 'string' }, branch: { type: 'string' }, id: { type: 'string' }, text: { type: 'string' }, 'allow-paid': { type: 'boolean' },
|
|
480
469
|
context: { type: 'string' }, suggest: { type: 'boolean' }, apply: { type: 'string' }, worker: { type: 'boolean' }, retry: { type: 'boolean' }, reason: { type: 'string' }, 'base-url': { type: 'string' }, 'cli-billing': { type: 'string' },
|
|
481
470
|
launch: { type: 'boolean' }, 'no-launch': { type: 'boolean' }, 'retry-review': { type: 'boolean' }, 'max-model-calls': { type: 'string' }, 'max-output-tokens': { type: 'string' },
|
|
@@ -548,13 +537,14 @@ export async function main(argv) {
|
|
|
548
537
|
return result.checks.every(check => check.ok) ? 0 : 1;
|
|
549
538
|
}
|
|
550
539
|
if (command === 'config') {
|
|
551
|
-
if ([...MODEL_FLAGS, 'distance'].some(key => values[key] !== undefined)) {
|
|
540
|
+
if ([...MODEL_FLAGS, 'distance', 'interface'].some(key => values[key] !== undefined)) {
|
|
552
541
|
const current = await loadConfig(), previousMaxModelCalls = current.maxModelCalls;
|
|
553
542
|
const config = applyInstallOptions(current, values), maxModelCalls = config.maxModelCalls;
|
|
554
543
|
await saveConfig(config);
|
|
555
544
|
const result = { scope: 'global', path: getPaths().config, previousMaxModelCalls, maxModelCalls, config };
|
|
556
545
|
if (values.json) { json(result); return 0; }
|
|
557
546
|
const terminal = ui();
|
|
547
|
+
if (values.interface !== undefined) terminal.success(`Questions appear ${config.interface === 'terminal' ? 'in the terminal (the browser is the fallback)' : config.interface === 'browser' ? 'in the browser' : 'in the browser on a local Mac, otherwise in the terminal'}.`);
|
|
558
548
|
if (values['max-model-calls'] !== undefined) terminal.success(`Saved machine-wide limit: ${modelLimitText(maxModelCalls)} (previously ${modelLimitText(previousMaxModelCalls)}).`);
|
|
559
549
|
if (values['max-output-tokens'] !== undefined) terminal.success(`Saved output allowance: ${config.maxOutputTokens} tokens per request, including model reasoning. Model calls: ${modelLimitText(maxModelCalls)}.`);
|
|
560
550
|
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.
|
package/src/git-gate.js
CHANGED
|
@@ -7,6 +7,8 @@ import { createBrowserInterview } from './browser-interview.js';
|
|
|
7
7
|
import { yoloStatus, recordOverride } from './overrides.js';
|
|
8
8
|
import { dropContext } from './working-context.js';
|
|
9
9
|
import { collectGarbage } from './storage.js';
|
|
10
|
+
import { scheduleStamp } from './github.js';
|
|
11
|
+
import { prefersTerminal, runGitTerminalCheck } from './git-interview.js';
|
|
10
12
|
|
|
11
13
|
async function snapshotsFor({ event, args, input, cwd, config, env }) {
|
|
12
14
|
if (event === 'pre-push') return pushSnapshots({ cwd, remote: args[0], remoteUrl: args[1], input, config, env });
|
|
@@ -29,7 +31,7 @@ export async function runGitGate({ event, args = [], input = '', cwd = process.c
|
|
|
29
31
|
store: providedStore, readConfig = loadConfig, snapshotsFor: collectSnapshots = snapshotsFor,
|
|
30
32
|
revalidate = revalidateSnapshot, present, readYolo = yoloStatus, logOverride = recordOverride,
|
|
31
33
|
queue = queuePresentation, claim = claimPresentation, listRequests = listPresentations,
|
|
32
|
-
output = line => process.stderr.write(`${line}\n`),
|
|
34
|
+
output = line => process.stderr.write(`${line}\n`), stamp = scheduleStamp, askInTerminal = runGitTerminalCheck,
|
|
33
35
|
} = {}) {
|
|
34
36
|
if (env.SPHINX_EVALUATOR === '1') return 0;
|
|
35
37
|
signal?.throwIfAborted();
|
|
@@ -85,7 +87,9 @@ export async function runGitGate({ event, args = [], input = '', cwd = process.c
|
|
|
85
87
|
const interviewEnv = interviewEnvironment(cwd, env);
|
|
86
88
|
for (let index = 0; index < requests.length; index++) {
|
|
87
89
|
signal?.throwIfAborted();
|
|
88
|
-
|
|
90
|
+
// Headless sessions (or interface: terminal) ask in the terminal first; the browser is the fallback.
|
|
91
|
+
let result = !present && prefersTerminal(config, { env }) ? await askInTerminal({ request: requests[index], env: interviewEnv, signal, cwd }) : null;
|
|
92
|
+
if (!result || result.status === 'unavailable') result = await (present || browser.present)({ request: requests[index], env: interviewEnv, signal });
|
|
89
93
|
signal?.throwIfAborted();
|
|
90
94
|
if (result?.status === 'unavailable') {
|
|
91
95
|
// Native callbacks only run after Git exits. Keep that explicit fallback
|
|
@@ -117,6 +121,13 @@ export async function runGitGate({ event, args = [], input = '', cwd = process.c
|
|
|
117
121
|
signal?.throwIfAborted();
|
|
118
122
|
// Skipped checks already reported themselves; only a pass is a completed check.
|
|
119
123
|
if (requests.length > skipped.size) output('Sphinx: understanding check complete. Continuing this Git operation.');
|
|
124
|
+
// GitHub stamps run detached after Git continues; they can never fail this push.
|
|
125
|
+
for (const snapshot of snapshots) {
|
|
126
|
+
try {
|
|
127
|
+
const session = await Promise.resolve(store.read?.(snapshot.id)).catch(() => null);
|
|
128
|
+
await stamp({ snapshot, session, outcome: skipped.has(snapshot.id) ? 'skipped' : 'passed', config, env });
|
|
129
|
+
} catch { /* Best-effort only. */ }
|
|
130
|
+
}
|
|
120
131
|
await finished();
|
|
121
132
|
return 0;
|
|
122
133
|
} finally {
|
package/src/git-interview.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
2
|
-
import {
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { openSync, closeSync } from 'node:fs';
|
|
4
|
+
import { CLI, launchCheckpointTerminal, waitForCheckpointTerminal } from './terminal-launch.js';
|
|
3
5
|
|
|
4
6
|
/** Collect human input in a separate terminal while the original Git hook waits.
|
|
5
7
|
* The caller owns queue cleanup and must validate the receipt before returning 0.
|
|
@@ -38,7 +40,7 @@ export async function runGitCheckpointTerminal({ request, env = process.env, sig
|
|
|
38
40
|
controller.signal.throwIfAborted();
|
|
39
41
|
const status = await waitForResult({ id: request.id, env, timeoutMs: Math.max(1, deadline - Date.now()), signal: controller.signal });
|
|
40
42
|
controller.signal.throwIfAborted();
|
|
41
|
-
return result(['passed', 'pending', 'unavailable', 'interrupted'].includes(status) ? status : 'interrupted');
|
|
43
|
+
return result(['passed', 'skipped', 'pending', 'unavailable', 'interrupted'].includes(status) ? status : 'interrupted');
|
|
42
44
|
} catch { return stopped ? result('pending', stopped) : result('interrupted'); }
|
|
43
45
|
};
|
|
44
46
|
try { return await Promise.race([operation(), interrupted]); }
|
|
@@ -49,3 +51,40 @@ export async function runGitCheckpointTerminal({ request, env = process.env, sig
|
|
|
49
51
|
controller.abort();
|
|
50
52
|
}
|
|
51
53
|
}
|
|
54
|
+
|
|
55
|
+
/** Whether to ask in the terminal instead of the browser. `auto` uses the
|
|
56
|
+
* browser only where Sphinx can open it in the background (local macOS). */
|
|
57
|
+
export function prefersTerminal(config, { env = process.env, platform = process.platform } = {}) {
|
|
58
|
+
if (config.interface === 'terminal') return true;
|
|
59
|
+
if (config.interface === 'browser') return false;
|
|
60
|
+
return !!(env.SSH_CONNECTION || env.SSH_TTY) || platform !== 'darwin';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The terminal Git was run from, if a person is looking at it. A coding
|
|
64
|
+
// agent's shell has no visible terminal, so its pipes never qualify.
|
|
65
|
+
function openVisibleTerminal(stderr = process.stderr) {
|
|
66
|
+
if (!stderr.isTTY) return null;
|
|
67
|
+
try { return openSync('/dev/tty', 'r+'); } catch { return null; }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Ask in the terminal: inline in Git's own terminal when a person is there,
|
|
72
|
+
* otherwise in a new terminal window. Resolves { status: 'unavailable' } when
|
|
73
|
+
* neither is possible, so the caller can fall back to the browser.
|
|
74
|
+
*/
|
|
75
|
+
export async function runGitTerminalCheck({ request, env = process.env, signal, cwd = process.cwd(), openTty = openVisibleTerminal, spawnImpl = spawn, openWindow = runGitCheckpointTerminal } = {}) {
|
|
76
|
+
const tty = openTty();
|
|
77
|
+
if (tty === null) return openWindow({ request, env, signal });
|
|
78
|
+
try {
|
|
79
|
+
const code = await new Promise(resolve => {
|
|
80
|
+
let child;
|
|
81
|
+
try { child = spawnImpl(process.execPath, [CLI, 'check', '--id', request.id], { cwd, env: { ...env, SPHINX_WAITING_GIT: '1' }, stdio: [tty, tty, tty] }); }
|
|
82
|
+
catch { resolve(null); return; }
|
|
83
|
+
const onAbort = () => child.kill('SIGINT');
|
|
84
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
85
|
+
child.once('error', () => resolve(null));
|
|
86
|
+
child.once('exit', exit => { signal?.removeEventListener('abort', onAbort); resolve(exit); });
|
|
87
|
+
});
|
|
88
|
+
return { id: request.id, status: code === 0 ? 'passed' : code === 3 ? 'skipped' : code === null ? 'unavailable' : 'pending' };
|
|
89
|
+
} finally { try { closeSync(tty); } catch { /* Already closed. */ } }
|
|
90
|
+
}
|
package/src/git.js
CHANGED
|
@@ -191,6 +191,22 @@ export async function revalidateSnapshot(snapshot, config, { env = process.env }
|
|
|
191
191
|
return true;
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
+
// A new branch is checked from where it left the remote's default branch, the
|
|
195
|
+
// same range a pull request shows, instead of from an empty tree. That only
|
|
196
|
+
// holds when the policy also checks the default branch; otherwise code that
|
|
197
|
+
// reached it unchecked would be skipped, so the whole tree is assessed.
|
|
198
|
+
async function branchPoint(root, remote, head, config) {
|
|
199
|
+
if (!remote || remote === '.' || !/^[\w.-]+$/.test(remote)) return null;
|
|
200
|
+
const named = (await git(root, ['symbolic-ref', '--quiet', `refs/remotes/${remote}/HEAD`], { allowFailure: true }))?.trim();
|
|
201
|
+
for (const ref of [named, `refs/remotes/${remote}/main`, `refs/remotes/${remote}/master`].filter(Boolean)) {
|
|
202
|
+
if (!(await git(root, ['rev-parse', '--verify', '--quiet', ref], { allowFailure: true }))) continue;
|
|
203
|
+
if (!triggered(config, 'pre-push', ref.slice(`refs/remotes/${remote}/`.length), remote)) return null;
|
|
204
|
+
const base = (await git(root, ['merge-base', head, ref], { allowFailure: true }))?.trim();
|
|
205
|
+
if (base && OID.test(base)) return base; // Equal to head: nothing new to check.
|
|
206
|
+
}
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
|
|
194
210
|
export async function pushSnapshots({ cwd = process.cwd(), remote, remoteUrl, input, config, env = process.env }) {
|
|
195
211
|
if (!config.enabled || !config.events.includes('pre-push')) return [];
|
|
196
212
|
const repo = await repository(cwd);
|
|
@@ -203,7 +219,7 @@ export async function pushSnapshots({ cwd = process.cwd(), remote, remoteUrl, in
|
|
|
203
219
|
if (ZERO.test(head) || head === old || !ref.startsWith('refs/heads/')) continue;
|
|
204
220
|
if (!triggered(config, 'pre-push', ref.slice('refs/heads/'.length), remote)) continue;
|
|
205
221
|
await ensureCommit(repo.root, head, remote);
|
|
206
|
-
const base = ZERO.test(old) ?
|
|
222
|
+
const base = ZERO.test(old) ? await branchPoint(repo.root, remote, head, config) : old;
|
|
207
223
|
if (base) await ensureCommit(repo.root, base, remote, true);
|
|
208
224
|
const tree = (await git(repo.root, ['rev-parse', `${head}^{tree}`])).trim();
|
|
209
225
|
const snapshot = await buildSnapshot({ repo, event: 'pre-push', target: { remote, url: remoteUrl || '', ref }, base, head, tree, sourceRef, config, env });
|
package/src/github.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { unlink } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { getPaths, readJson, writeJson } from './config.js';
|
|
7
|
+
|
|
8
|
+
// After Git continues, a detached worker marks the pushed commit on GitHub with
|
|
9
|
+
// a `sphinx/understanding` commit status and, if the user opts in, keeps one
|
|
10
|
+
// summary comment on the branch's open pull request. All of it is best-effort:
|
|
11
|
+
// no GitHub remote, no gh CLI, no login, or any API error just means no stamp.
|
|
12
|
+
export const STATUS_CONTEXT = 'sphinx/understanding';
|
|
13
|
+
export const COMMENT_MARKER = '<!-- sphinx-understanding -->';
|
|
14
|
+
const RECORDS_KEPT = 20;
|
|
15
|
+
const WORKER = fileURLToPath(new URL('./github-worker.js', import.meta.url));
|
|
16
|
+
const digest = value => createHash('sha256').update(value).digest('hex');
|
|
17
|
+
const githubDirectory = env => path.join(getPaths(env).home, 'github');
|
|
18
|
+
const branchFile = (repoRoot, ref, env) => path.join(githubDirectory(env), 'branches', `${digest(`${repoRoot}\0${ref}`)}.json`);
|
|
19
|
+
|
|
20
|
+
/** owner/repo on a GitHub host from an https, ssh, or scp-style remote URL. */
|
|
21
|
+
export function githubRepository(url) {
|
|
22
|
+
const match = /^(?:https?:\/\/(?:[^@/]+@)?|ssh:\/\/(?:[^@/]+@)?|[^@/\s]+@)([^/:\s]+)(?::\d+)?[/:]([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/.exec(String(url || '').trim());
|
|
23
|
+
if (!match || !/(^|\.)github\.com$|^github\./.test(match[1])) return null;
|
|
24
|
+
return { host: match[1], owner: match[2], repo: match[3] };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** What one check means for the stamp. Never includes private answer keys. */
|
|
28
|
+
export function checkRecord({ session, snapshot, outcome, includeAnswers = false, at = new Date().toISOString() }) {
|
|
29
|
+
const questions = [];
|
|
30
|
+
const attempts = session?.attempts || [];
|
|
31
|
+
// Attempts carry their question slot from 0.1.8; older records only know the final prompts.
|
|
32
|
+
const tracked = attempts.length && attempts.every(item => Number.isInteger(item.index));
|
|
33
|
+
for (const [index, question] of (session?.questions || []).entries()) {
|
|
34
|
+
// Matches the understanding score: disputes and ambiguous grades don't count; asking for an explanation is a miss.
|
|
35
|
+
const graded = tracked ? attempts.filter(item => item.index === index && item.kind !== 'dispute' && item.verdict !== 'ambiguous') : [];
|
|
36
|
+
const first = attempts.find(item => tracked && item.index === index);
|
|
37
|
+
questions.push({
|
|
38
|
+
prompt: first?.prompt || question.prompt,
|
|
39
|
+
...(tracked ? { tries: graded.length, firstTry: graded[0]?.kind === 'answer' && graded[0]?.verdict === 'correct' } : {}),
|
|
40
|
+
...(includeAnswers && tracked ? { answers: attempts.filter(item => item.index === index).map(item => ({ kind: item.kind, prompt: item.prompt, answer: item.answer, verdict: item.verdict })) } : {}),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
at, outcome, head: snapshot.head, base: snapshot.base, ref: snapshot.target.ref,
|
|
45
|
+
stats: snapshot.stats, level: session?.config?.level ?? null, distance: session?.config?.distance ?? null,
|
|
46
|
+
model: session?.route ? [session.route.provider, session.route.model].filter(Boolean).join(' ') : null,
|
|
47
|
+
noQuestionReason: session?.noQuestionReason || null, questions,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function statusFor(record) {
|
|
52
|
+
if (record.outcome === 'skipped') return { state: 'failure', description: 'Understanding check skipped' };
|
|
53
|
+
const asked = record.questions.length;
|
|
54
|
+
if (!asked) return { state: 'success', description: 'Passed without questions' };
|
|
55
|
+
const first = record.questions.filter(item => item.firstTry).length;
|
|
56
|
+
const known = record.questions.every(item => typeof item.firstTry === 'boolean');
|
|
57
|
+
return { state: 'success', description: `Understood: ${asked} question${asked === 1 ? '' : 's'}${known ? `, ${first} first try` : ''}` };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const quote = text => String(text).trim().split('\n').map(line => `> ${line}`).join('\n');
|
|
61
|
+
const short = sha => String(sha || '').slice(0, 7);
|
|
62
|
+
|
|
63
|
+
/** One PR comment covering every check recorded for the branch, newest first. */
|
|
64
|
+
export function commentBody(records) {
|
|
65
|
+
const lines = [COMMENT_MARKER, '### Sphinx understanding checks', ''];
|
|
66
|
+
for (const record of [...records].reverse()) {
|
|
67
|
+
const status = statusFor(record);
|
|
68
|
+
const range = record.base ? `${short(record.base)}..${short(record.head)}` : short(record.head);
|
|
69
|
+
lines.push(`<details><summary>${status.state === 'success' ? '✅' : '⚠️'} <code>${range}</code> · ${status.description}${record.model ? ` · ${record.model}` : ''}</summary>`, '');
|
|
70
|
+
if (record.stats) lines.push(`${record.stats.files} files, +${record.stats.additions} −${record.stats.deletions}. Depth ${record.level}, distance ${record.distance}. ${record.at.slice(0, 16).replace('T', ' ')} UTC.`, '');
|
|
71
|
+
if (record.noQuestionReason) lines.push(`No questions: ${record.noQuestionReason}`, '');
|
|
72
|
+
for (const [index, question] of record.questions.entries()) {
|
|
73
|
+
const tries = typeof question.tries === 'number' ? ` _(${question.firstTry ? 'first try' : `${question.tries} tries`})_` : '';
|
|
74
|
+
lines.push(`**Q${index + 1}.**${tries}`, quote(question.prompt), '');
|
|
75
|
+
for (const item of question.answers || []) {
|
|
76
|
+
if (item.prompt && item.prompt !== question.prompt) lines.push(`Follow-up:`, quote(item.prompt), '');
|
|
77
|
+
lines.push(`${item.kind === 'answer' ? `Answer (${item.verdict})` : item.kind === 'dispute' ? 'Dispute' : 'Asked for an explanation'}:`, quote(item.answer || '(empty)'), '');
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
lines.push('</details>', '');
|
|
81
|
+
}
|
|
82
|
+
lines.push('<sub>Recorded by <a href="https://github.com/parthh01/sphinx">Sphinx</a> on the pusher\'s machine. Self-reported, not a cryptographic attestation.</sub>');
|
|
83
|
+
return lines.join('\n');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Record the check and hand GitHub work to a detached worker so Git never
|
|
88
|
+
* waits on the network. Never throws.
|
|
89
|
+
*/
|
|
90
|
+
export async function scheduleStamp({ snapshot, session, outcome, config, env = process.env, spawnImpl = spawn }) {
|
|
91
|
+
try {
|
|
92
|
+
const settings = config.github || {};
|
|
93
|
+
if (snapshot.event !== 'pre-push' || (!settings.status && !settings.comment)) return false;
|
|
94
|
+
const repo = githubRepository(snapshot.target.url);
|
|
95
|
+
if (!repo) return false;
|
|
96
|
+
const file = branchFile(snapshot.repoRoot, snapshot.target.ref, env);
|
|
97
|
+
const stored = await readJson(file, null).catch(() => null);
|
|
98
|
+
const record = checkRecord({ session, snapshot, outcome, includeAnswers: !!settings.answers });
|
|
99
|
+
const records = [...(Array.isArray(stored?.records) ? stored.records : []).filter(item => item.head !== record.head), record].slice(-RECORDS_KEPT);
|
|
100
|
+
await writeJson(file, { version: 1, repoRoot: snapshot.repoRoot, ref: snapshot.target.ref, records });
|
|
101
|
+
const job = path.join(githubDirectory(env), 'jobs', `${randomUUID()}.json`);
|
|
102
|
+
await writeJson(job, { version: 1, repo, sha: snapshot.head, branch: snapshot.target.ref.replace(/^refs\/heads\//, ''), branchFile: file, status: !!settings.status, comment: !!settings.comment });
|
|
103
|
+
const child = spawnImpl(process.execPath, [WORKER, job], { env, detached: true, stdio: 'ignore' });
|
|
104
|
+
await new Promise((resolve, reject) => { child.once('error', reject); child.once('spawn', resolve); });
|
|
105
|
+
child.unref();
|
|
106
|
+
return true;
|
|
107
|
+
} catch { return false; }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
111
|
+
|
|
112
|
+
/** gh with an optional JSON body on stdin. Resolves { ok, stdout }; never rejects. */
|
|
113
|
+
export function ghCli(args, { env = process.env, host, input, timeoutMs = 30000 } = {}) {
|
|
114
|
+
return new Promise(resolve => {
|
|
115
|
+
let child, stdout = '';
|
|
116
|
+
try { child = spawn('gh', args, { env: { ...env, GH_HOST: host, GH_PROMPT_DISABLED: '1', NO_COLOR: '1' }, stdio: ['pipe', 'pipe', 'ignore'] }); }
|
|
117
|
+
catch { resolve({ ok: false, stdout: '' }); return; }
|
|
118
|
+
const timer = setTimeout(() => child.kill(), timeoutMs);
|
|
119
|
+
child.stdout.on('data', chunk => { if (stdout.length < 4 * 1024 * 1024) stdout += chunk; });
|
|
120
|
+
child.once('error', () => { clearTimeout(timer); resolve({ ok: false, stdout: '' }); });
|
|
121
|
+
child.once('close', code => { clearTimeout(timer); resolve({ ok: code === 0, stdout }); });
|
|
122
|
+
child.stdin.on('error', () => {});
|
|
123
|
+
child.stdin.end(input === undefined ? '' : JSON.stringify(input));
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The worker body: wait for the push to land, stamp it, then update the PR comment. */
|
|
128
|
+
export async function runStampJob(job, { gh = ghCli, env = process.env, wait = sleep, landTries = 36, landEveryMs = 5000, prTries = 30, prEveryMs = 30000 } = {}) {
|
|
129
|
+
const { host, owner, repo } = job.repo;
|
|
130
|
+
const api = (args, input) => gh(['api', ...args, ...(input ? ['--input', '-'] : [])], { env, host, input });
|
|
131
|
+
if (!(await gh(['auth', 'status', '--hostname', host], { env, host })).ok) return 'not-signed-in';
|
|
132
|
+
// The hook runs before Git uploads, so the commit appears on GitHub a little later.
|
|
133
|
+
let landed = false;
|
|
134
|
+
for (let attempt = 0; attempt < landTries && !landed; attempt++) {
|
|
135
|
+
if (attempt) await wait(landEveryMs);
|
|
136
|
+
landed = (await api([`repos/${owner}/${repo}/commits/${job.sha}`, '--jq', '.sha'])).ok;
|
|
137
|
+
}
|
|
138
|
+
if (!landed) return 'not-pushed';
|
|
139
|
+
const records = (await readJson(job.branchFile, null).catch(() => null))?.records || [];
|
|
140
|
+
const record = records.find(item => item.head === job.sha);
|
|
141
|
+
if (!record) return 'no-record';
|
|
142
|
+
const status = statusFor(record);
|
|
143
|
+
const postStatus = targetUrl => api([`repos/${owner}/${repo}/statuses/${job.sha}`, '--method', 'POST'], { state: status.state, context: STATUS_CONTEXT, description: status.description, ...(targetUrl ? { target_url: targetUrl } : {}) });
|
|
144
|
+
if (job.status) await postStatus();
|
|
145
|
+
if (!job.comment) return 'done';
|
|
146
|
+
|
|
147
|
+
// A pull request is usually opened right after the first push of a branch.
|
|
148
|
+
let pull = null;
|
|
149
|
+
for (let attempt = 0; attempt < prTries && !pull; attempt++) {
|
|
150
|
+
if (attempt) await wait(prEveryMs);
|
|
151
|
+
const listed = await gh(['pr', 'list', '--repo', `${owner}/${repo}`, '--head', job.branch, '--state', 'open', '--json', 'number', '--limit', '1'], { env, host });
|
|
152
|
+
try { pull = listed.ok ? JSON.parse(listed.stdout || '[]')[0] || null : null; } catch { pull = null; }
|
|
153
|
+
}
|
|
154
|
+
if (!pull) return 'no-pull-request';
|
|
155
|
+
const body = commentBody((await readJson(job.branchFile, null).catch(() => null))?.records || records);
|
|
156
|
+
const existing = await api([`repos/${owner}/${repo}/issues/${pull.number}/comments`, '--paginate', '--jq', `.[] | select(.body | startswith("${COMMENT_MARKER}")) | .id`]);
|
|
157
|
+
const id = existing.ok ? existing.stdout.split('\n').find(Boolean) : null;
|
|
158
|
+
const written = id
|
|
159
|
+
? await api([`repos/${owner}/${repo}/issues/comments/${id}`, '--method', 'PATCH', '--jq', '.html_url'], { body })
|
|
160
|
+
: await api([`repos/${owner}/${repo}/issues/${pull.number}/comments`, '--method', 'POST', '--jq', '.html_url'], { body });
|
|
161
|
+
if (written.ok && job.status) await postStatus(written.stdout.trim());
|
|
162
|
+
return written.ok ? 'done' : 'comment-failed';
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function runStampWorker(jobFile, { env = process.env } = {}) {
|
|
166
|
+
let result = 'failed';
|
|
167
|
+
try {
|
|
168
|
+
const job = await readJson(jobFile, null);
|
|
169
|
+
if (job) result = await runStampJob(job, { env });
|
|
170
|
+
} catch { /* Best-effort only. */ }
|
|
171
|
+
finally { await unlink(jobFile).catch(() => {}); }
|
|
172
|
+
await writeJson(path.join(githubDirectory(env), 'last.json'), { result, at: new Date().toISOString() }).catch(() => {});
|
|
173
|
+
return result;
|
|
174
|
+
}
|
package/src/help.js
CHANGED
|
@@ -90,7 +90,7 @@ export const HELP = {
|
|
|
90
90
|
dispute: { summary: 'Challenge an ambiguous or unsupported question.', usage: 'sphinx dispute <assessment-id> --text <reason> [--json]', paragraphs: ['Explain the missing assumption or conflicting evidence. The independent evaluator reviews the question and may replace it. A dispute never awards completion. Uses one model call, plus one independent audit if a replacement is proposed. Both count toward the same allowance.'], groups: [['Reason', [['--text <reason>', 'What is ambiguous or contradicted by the evidence?']]], ['Output', common]], examples: ["sphinx dispute <assessment-id> --text 'The question assumes retries, but this path does not retry.' --json"] },
|
|
91
91
|
pending: { summary: 'List unfinished checkpoints for this repository.', usage: 'sphinx pending [--json]', paragraphs: ['Shows valid pending and active checks, progress, selected route, counted calls, and any configured call limit. Stale snapshots are filtered out. Makes no model calls.'], groups: [['Output', common]], examples: ['sphinx pending', 'sphinx pending --json'] },
|
|
92
92
|
status: { summary: 'See repository coverage, effective policy, and pending checks.', usage: 'sphinx status [--json]', paragraphs: ['Shows whether Git is using a Sphinx hook dispatcher and which events, destinations, and remotes the current policy covers. It does not contact remotes, call a model, or establish Codex hook trust.'], groups: [['Output', common]], examples: ['sphinx status', 'sphinx status --json'] },
|
|
93
|
-
config: { summary: 'Inspect effective settings or change evaluator models and call limits.', usage: 'sphinx config [--preset <name>] [--small <provider:model>] [--max-model-calls <1-100|unlimited>] [--json]', paragraphs: ['Without a setter, this command only displays effective configuration. Repository .sphinxconfig overrides supported global policy fields; legacy .sphinx.json is used only when the canonical file is absent. Model routes, credentials, and optional call caps remain machine-wide.', 'Save evaluator models or the total assessment allowance without reinstalling or invoking a model. This works outside a Git repository and preserves unrelated global settings. With --json, a save reports the global path, previous and new limit, and resulting global configuration. Size-based planning targets do not impose a hidden request cap. Existing explicitly configured limits are preserved unless changed.', 'Evaluator changes apply to fresh checkpoints; existing checkpoints are bound to their original route and policy. Budget-only changes preserve their usage and questions. OpenRouter presets require a saved key or OPENROUTER_API_KEY before the next assessment.'], groups: [['Evaluators and usage', evaluatorOptions], ['Output', common]], examples: ['sphinx config', 'sphinx config --json', 'sphinx config --preset openrouter-value --allow-paid', 'sphinx config --preset claude-economy --large claude:sonnet', 'sphinx config --max-model-calls unlimited', 'sphinx config --max-model-calls 30 --json'] },
|
|
93
|
+
config: { summary: 'Inspect effective settings or change evaluator models and call limits.', usage: 'sphinx config [--preset <name>] [--small <provider:model>] [--max-model-calls <1-100|unlimited>] [--json]', paragraphs: ['Without a setter, this command only displays effective configuration. Repository .sphinxconfig overrides supported global policy fields; legacy .sphinx.json is used only when the canonical file is absent. Model routes, credentials, and optional call caps remain machine-wide.', 'Save evaluator models or the total assessment allowance without reinstalling or invoking a model. This works outside a Git repository and preserves unrelated global settings. With --json, a save reports the global path, previous and new limit, and resulting global configuration. Size-based planning targets do not impose a hidden request cap. Existing explicitly configured limits are preserved unless changed.', 'Evaluator changes apply to fresh checkpoints; existing checkpoints are bound to their original route and policy. Budget-only changes preserve their usage and questions. OpenRouter presets require a saved key or OPENROUTER_API_KEY before the next assessment.'], groups: [['Evaluators and usage', evaluatorOptions], ['Questionnaire', [['--interface <auto|browser|terminal>', 'Where questions appear. auto (default): the browser on a local Mac, otherwise inline in the terminal you ran Git from. terminal: always the terminal, falling back to the browser when no terminal is visible. browser: always the browser.']]], ['Output', common]], examples: ['sphinx config', 'sphinx config --interface terminal', 'sphinx config --json', 'sphinx config --preset openrouter-value --allow-paid', 'sphinx config --preset claude-economy --large claude:sonnet', 'sphinx config --max-model-calls unlimited', 'sphinx config --max-model-calls 30 --json'] },
|
|
94
94
|
yolo: { summary: 'Toggle skipping understanding checks when something is urgent.', usage: 'sphinx yolo [--reason <text>] [--json]', paragraphs: ['Run inside a Git repository to toggle yolo mode for that repository only; turning it off there keeps checks on even while global yolo is on. Run outside a repository to toggle it globally. Run the same command again to turn it off.', 'While on, Git operations continue without a questionnaire and background review is paused: no new model request starts, although one already in progress when yolo is turned on may finish. It is a straight bypass: no check is created. Each skip is logged with an optional reason; sphinx status shows recent overrides. For a one-off, use Skip this time in the questionnaire instead.'], groups: [['Reason', [['--reason <text>', 'Optional note recorded with the toggle, e.g. a hotfix ticket.']]], ['Output', common]], examples: ['sphinx yolo --reason "prod hotfix"', 'sphinx yolo', 'cd ~ && sphinx yolo'] },
|
|
95
95
|
doctor: { summary: 'Check Git dispatchers and coding-agent integration settings.', usage: 'sphinx doctor [--json]', paragraphs: ['Finds local hook overrides, missing dispatchers, and edited integration settings. Optional agents that were not selected do not count as failures. Codex trust must be reviewed in /hooks; Sphinx cannot verify it.'], groups: [['Output', common]], examples: ['sphinx doctor', 'sphinx install --repo'], footer: 'Exit status is 0 when checks pass and 1 when something needs attention. No model calls or configuration changes.' },
|
|
96
96
|
usage: { summary: 'See tokens and spend from Sphinx model calls.', usage: 'sphinx usage [--json]', paragraphs: ['Shows tokens and cost for today, the last 7 and 30 days, and all recorded history, split into free OpenRouter models, paid OpenRouter models, OpenAI-compatible endpoints, and Claude Code or Codex coding plans, then by purpose such as review, grading, and context.', 'Coding-plan routes show tokens only: they count against your Claude or ChatGPT plan, not a per-call bill. Endpoints show cost only when they report it.'], groups: [['Output', common]], examples: ['sphinx usage', 'sphinx usage --json'], footer: 'Each model call appends one small line to usage/YYYY-MM.jsonl in your Sphinx home. Only the last 12 monthly files are kept; older months are deleted automatically. Reading usage makes no model calls.' },
|
package/src/sessions.js
CHANGED
|
@@ -339,7 +339,7 @@ export function createSessionStore({ env = process.env, generate = generateAsses
|
|
|
339
339
|
// Capture after charging the grade: cancellation rolls back progress, not
|
|
340
340
|
// model usage, and leaves the same question available to the human.
|
|
341
341
|
const beforeAnswer = structuredClone(session);
|
|
342
|
-
session.attempts.push({questionId:question.id,answer:text.trim(),verdict:result.verdict,feedback:result.feedback,at:now()});
|
|
342
|
+
session.attempts.push({questionId:question.id,index:session.index,kind:dispute?'dispute':learn?'learn':'answer',prompt:question.prompt,answer:text.trim(),verdict:result.verdict,feedback:result.feedback,at:now()});
|
|
343
343
|
// Score each question once, on its first real grade. A dispute or an
|
|
344
344
|
// ambiguous grade leaves it unscored; asking for an explanation counts as a miss.
|
|
345
345
|
const scoredIndex = session.index;
|
package/src/storage.js
CHANGED
|
@@ -40,12 +40,14 @@ export async function collectGarbage({ env = process.env, now = Date.now(), cap
|
|
|
40
40
|
const gone = context?.repoRoot && !(await stat(context.repoRoot).catch(() => null));
|
|
41
41
|
if (!context || gone || now - file.mtime > 14 * DAY) await drop(file.path);
|
|
42
42
|
}
|
|
43
|
-
for (const directory of ['preparations', 'presentations', 'terminals']) {
|
|
43
|
+
for (const directory of ['preparations', 'presentations', 'terminals', 'github/jobs']) {
|
|
44
44
|
for (const file of await files(path.join(home, directory))) if (now - file.mtime > 7 * DAY) await drop(file.path);
|
|
45
45
|
}
|
|
46
|
+
// A branch's check records feed its pull request comment while it is active.
|
|
47
|
+
for (const file of await files(path.join(home, 'github', 'branches'))) if (now - file.mtime > 30 * DAY) await drop(file.path);
|
|
46
48
|
// Size cap: drop the oldest removable data first. Hooks, settings, the
|
|
47
49
|
// install manifest, and credentials are never candidates.
|
|
48
|
-
const removable = (await Promise.all(['sessions', 'context', 'preparations', 'presentations', 'terminals'].map(dir => files(path.join(home, dir))))).flat()
|
|
50
|
+
const removable = (await Promise.all(['sessions', 'context', 'preparations', 'presentations', 'terminals', 'github'].map(dir => files(path.join(home, dir))))).flat()
|
|
49
51
|
.filter(file => now - file.mtime > HOUR && !removed.includes(file.path)).sort((a, b) => a.mtime - b.mtime);
|
|
50
52
|
let total = await storageUsage(env);
|
|
51
53
|
for (const file of removable) { if (total <= cap) break; await drop(file.path); total -= file.size; }
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { readStats, scoreSummary } from './stats.js';
|
|
3
|
+
import { prepareInteractiveCheck } from './check-recovery.js';
|
|
4
|
+
import { VERSION } from './version.js';
|
|
5
|
+
import { wrapText, stripAnsi } from './terminal.js';
|
|
6
|
+
|
|
7
|
+
// The terminal questionnaire: the same check as the browser page, laid out for
|
|
8
|
+
// a terminal. Used by `sphinx check` and when Git asks inline in a headless
|
|
9
|
+
// session. Model text is printed as plain text; it never sets terminal state.
|
|
10
|
+
const FRAMES = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏';
|
|
11
|
+
const LEARN = 'I do not know. Explain this concept and give me a different scenario to apply it to.';
|
|
12
|
+
// Strip control characters from model text so it cannot move the cursor or recolor the terminal.
|
|
13
|
+
const plain = text => String(text ?? '').replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '');
|
|
14
|
+
const PROVIDERS = { openrouter: 'OpenRouter', openai: 'Endpoint', claude: 'Claude Code', codex: 'Codex' };
|
|
15
|
+
const routeText = route => route?.provider ? [PROVIDERS[route.provider] || route.provider, route.model].filter(Boolean).join(' ') : '';
|
|
16
|
+
const count = n => Number(n || 0).toLocaleString('en-US');
|
|
17
|
+
|
|
18
|
+
/** One status line that redraws in place on a TTY, and prints once otherwise. */
|
|
19
|
+
export function createSpinner(stream, style) {
|
|
20
|
+
let timer = null, label = '', started = 0, index = 0;
|
|
21
|
+
const width = () => Math.max(20, (stream.columns || 80) - 4);
|
|
22
|
+
const draw = () => {
|
|
23
|
+
const seconds = Math.floor((Date.now() - started) / 1000);
|
|
24
|
+
const tail = seconds >= 2 ? ` · ${seconds}s` : '';
|
|
25
|
+
const room = width() - 2 - tail.length;
|
|
26
|
+
const text = label.length > room ? `${label.slice(0, room - 1)}…` : label;
|
|
27
|
+
stream.write(`\r\x1b[2K ${style.amber(FRAMES[index++ % FRAMES.length])} ${text}${style.muted(tail)}`);
|
|
28
|
+
};
|
|
29
|
+
return {
|
|
30
|
+
start(text) {
|
|
31
|
+
this.stop(); label = plain(text); started = Date.now();
|
|
32
|
+
if (stream.isTTY) { draw(); timer = setInterval(draw, 90); timer.unref?.(); }
|
|
33
|
+
else stream.write(` ${label}…\n`);
|
|
34
|
+
},
|
|
35
|
+
update(text) { label = plain(text); },
|
|
36
|
+
stop() { if (timer) { clearInterval(timer); timer = null; stream.write('\r\x1b[2K'); } },
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function scoreLine(score, style) {
|
|
41
|
+
if (!score) return '';
|
|
42
|
+
const recent = score.recent || [];
|
|
43
|
+
const slots = score.calibrating ? score.answered + score.needed : Math.max(recent.length, 1);
|
|
44
|
+
const dots = Array.from({ length: slots }, (_, i) => i < recent.length ? (recent[i] ? style.green('●') : style.amber('●')) : style.muted('○')).join('');
|
|
45
|
+
const value = score.calibrating ? style.muted(`warming up · ${score.needed} to go`) : style.strong(`${score.accuracy}%`);
|
|
46
|
+
const extra = !score.calibrating && score.streak > 1 ? style.muted(` · ${score.streak} in a row`) : '';
|
|
47
|
+
return ` ${style.muted('Your understanding')} ${dots} ${value}${extra}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A quoted block with a left rule, wrapped to the terminal. */
|
|
51
|
+
function block(terminal, text, rule) {
|
|
52
|
+
const { style } = terminal;
|
|
53
|
+
for (const line of wrapText(plain(text), terminal.width - 4, '').split('\n')) terminal.line(` ${rule(style)} ${line}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function printHeader(terminal, { session, score }) {
|
|
57
|
+
const { style } = terminal, snapshot = session.snapshot || {};
|
|
58
|
+
const repo = snapshot.repoRoot ? path.basename(snapshot.repoRoot) : '';
|
|
59
|
+
const destination = snapshot.target?.ref ? `${snapshot.target.remote ? `${snapshot.target.remote}/` : ''}${snapshot.target.ref.replace(/^refs\/heads\//, '')}` : '';
|
|
60
|
+
const stats = snapshot.stats ? `${count(snapshot.stats.files)} file${snapshot.stats.files === 1 ? '' : 's'} ${style.green(`+${count(snapshot.stats.additions)}`)} ${style.amber(`−${count(snapshot.stats.deletions)}`)}` : '';
|
|
61
|
+
const title = ` ${style.amber('◆')} ${style.strong('sphinx')} ${style.muted(`v${VERSION}`)}`;
|
|
62
|
+
const right = style.muted('understanding check');
|
|
63
|
+
const gap = Math.max(2, terminal.width - stripAnsi(title).length - stripAnsi(right).length);
|
|
64
|
+
terminal.line('');
|
|
65
|
+
terminal.line(`${title}${' '.repeat(gap)}${right}`);
|
|
66
|
+
const facts = [[repo, destination].filter(Boolean).join(' → '), stats, routeText(session.route)].filter(Boolean);
|
|
67
|
+
if (facts.length) terminal.line(` ${facts.join(style.muted(' · '))}`);
|
|
68
|
+
const line = scoreLine(score, style);
|
|
69
|
+
if (line) terminal.line(line);
|
|
70
|
+
terminal.line(style.muted(` ${'─'.repeat(Math.max(10, terminal.width - 4))}`));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function printQuestion(terminal, state, { followUp }) {
|
|
74
|
+
const { style } = terminal;
|
|
75
|
+
terminal.line('');
|
|
76
|
+
const label = `Question ${state.progress.completed + 1} of ${state.progress.total}${followUp ? ' · follow-up' : ''}`;
|
|
77
|
+
terminal.line(` ${style.strong(label)}`);
|
|
78
|
+
block(terminal, state.question.prompt, s => s.amber('┃'));
|
|
79
|
+
if (state.question.choices?.length) state.question.choices.forEach((choice, index) => terminal.line(` ${style.muted(`${index + 1}.`)} ${plain(choice)}`));
|
|
80
|
+
terminal.line('');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function commandsLine(terminal, { waitingGit }) {
|
|
84
|
+
const { style } = terminal;
|
|
85
|
+
const items = ['/hint', '/learn', '/dispute <reason>', ...(waitingGit ? ['/skip'] : []), '/quit'];
|
|
86
|
+
terminal.line(style.muted(` Enter to send · ${items.join(' ')}`));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Run the check in this terminal. Resolves 0 when passed, 1 when paused or
|
|
91
|
+
* quit, and 3 when the human skipped a waiting Git operation.
|
|
92
|
+
*/
|
|
93
|
+
export async function runTerminalCheck({ store, session, config, ask, terminal: base, stream = process.stdout, signal, waitingGit = false, planProviders = [], validate, choose, skip, env = process.env }) {
|
|
94
|
+
// Lines keep their own indentation; the shared helpers would rewrap it away.
|
|
95
|
+
const terminal = { ...base, line: text => stream.write(`${text}\n`), note: text => stream.write(`${base.style.muted(` ${text}`)}\n`), warning: text => stream.write(` ${base.style.amber('!')} ${text}\n`) };
|
|
96
|
+
const { style } = terminal;
|
|
97
|
+
const spinner = createSpinner(stream, style);
|
|
98
|
+
const repoRoot = session.snapshot.repoRoot;
|
|
99
|
+
const score = async () => scoreSummary(await readStats(repoRoot, env));
|
|
100
|
+
printHeader(terminal, { session, score: await score().catch(() => null) });
|
|
101
|
+
terminal.line(style.muted(wrapText('Open-book: look at the code whenever it helps, then explain what it does in your own words.', terminal.width, ' ')));
|
|
102
|
+
|
|
103
|
+
const withSpinner = async (label, operation) => {
|
|
104
|
+
spinner.start(label);
|
|
105
|
+
try { return await operation(); } finally { spinner.stop(); }
|
|
106
|
+
};
|
|
107
|
+
let state;
|
|
108
|
+
try {
|
|
109
|
+
state = await prepareInteractiveCheck({
|
|
110
|
+
config, signal, waitingGit, planProviders, ui: terminal, choose,
|
|
111
|
+
start: () => withSpinner(session.questions?.length ? 'Loading prepared questions' : `Reviewing the change with ${routeText(session.route)}`, () => store.start(session.id, config, {
|
|
112
|
+
signal, onProgress: text => spinner.update(`Reviewing · ${text}`),
|
|
113
|
+
})),
|
|
114
|
+
inspect: async () => store.public(await store.read(session.id)),
|
|
115
|
+
authorizeMore: () => store.retryReview(session.id, config, { signal }),
|
|
116
|
+
validate, switchRoute: provider => store.switchRoute(session.id, config, provider, { signal }),
|
|
117
|
+
});
|
|
118
|
+
} finally { spinner.stop(); }
|
|
119
|
+
signal?.throwIfAborted();
|
|
120
|
+
if (!state) return 1;
|
|
121
|
+
if (state.status === 'passed' && state.noQuestionReason) {
|
|
122
|
+
terminal.line(''); terminal.line(` ${style.green('✓')} No questions needed: ${plain(state.noQuestionReason)}`);
|
|
123
|
+
return finish(terminal, { waitingGit });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let shownId = null, shownNumber = 0;
|
|
127
|
+
while (state.status !== 'passed') {
|
|
128
|
+
if (state.question && state.question.id !== shownId) {
|
|
129
|
+
const number = state.progress.completed + 1;
|
|
130
|
+
printQuestion(terminal, state, { followUp: number === shownNumber });
|
|
131
|
+
commandsLine(terminal, { waitingGit });
|
|
132
|
+
shownId = state.question.id; shownNumber = number;
|
|
133
|
+
}
|
|
134
|
+
const answer = plain(await ask(` ${style.amber('›')} `)).trim();
|
|
135
|
+
signal?.throwIfAborted();
|
|
136
|
+
if (!answer) continue;
|
|
137
|
+
if (answer === '/quit') { terminal.line(''); terminal.note('Progress saved. Run sphinx check when you are ready to continue.'); return 1; }
|
|
138
|
+
if (answer === '/hint') { await printHint(terminal, store, session.id, state.question.id); continue; }
|
|
139
|
+
if (answer === '/skip') {
|
|
140
|
+
if (!waitingGit || !skip) { terminal.warning('Skipping is only available while a Git operation is waiting.'); continue; }
|
|
141
|
+
const reason = plain(await ask(` ${style.muted('Skip this check? The push goes through and the skip is logged. Reason (optional), or n to go back:')} `)).trim();
|
|
142
|
+
if (/^n(o)?$/i.test(reason)) continue;
|
|
143
|
+
await skip(reason || undefined);
|
|
144
|
+
terminal.line(''); terminal.line(` ${style.amber('↷')} Skipped. Git is continuing.`);
|
|
145
|
+
return 3;
|
|
146
|
+
}
|
|
147
|
+
const dispute = answer.startsWith('/dispute '), learn = answer === '/learn';
|
|
148
|
+
if (answer === '/dispute') { terminal.warning('Add a reason: /dispute <what is ambiguous or unsupported>'); continue; }
|
|
149
|
+
if (answer.startsWith('/') && !dispute && !learn) { terminal.warning('Use /hint, /learn, /dispute <reason>, /quit, or type your answer.'); continue; }
|
|
150
|
+
const previous = state;
|
|
151
|
+
state = await withSpinner(learn ? 'Preparing an explanation and a new scenario' : dispute ? 'Reviewing the question' : 'Checking your reasoning', () => store.answer(session.id, learn ? LEARN : dispute ? answer.slice(9) : answer, config, { dispute, learn, expectedQuestionId: previous.question.id, signal }));
|
|
152
|
+
signal?.throwIfAborted();
|
|
153
|
+
printVerdict(terminal, state, { learn, dispute });
|
|
154
|
+
}
|
|
155
|
+
terminal.line('');
|
|
156
|
+
terminal.line(` ${style.green('✓')} ${style.strong('Understanding check complete.')}`);
|
|
157
|
+
const final = await score().catch(() => null);
|
|
158
|
+
const line = scoreLine(final, style);
|
|
159
|
+
if (line) terminal.line(line);
|
|
160
|
+
return finish(terminal, { waitingGit });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function finish(terminal, { waitingGit }) {
|
|
164
|
+
terminal.line(terminal.style.muted(waitingGit ? ' Approval saved. Git is continuing.' : ' Approval saved for this exact change and destination. A waiting Git operation can now continue.'));
|
|
165
|
+
terminal.line('');
|
|
166
|
+
return 0;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function printVerdict(terminal, state, { learn, dispute }) {
|
|
170
|
+
const { style } = terminal;
|
|
171
|
+
terminal.line('');
|
|
172
|
+
if (state.verdict === 'correct') { terminal.line(` ${style.green('✓')} Got it.`); if (state.feedback) block(terminal, state.feedback, s => s.muted('│')); return; }
|
|
173
|
+
const heading = learn ? `${style.amber('◇')} Here's how it works` : dispute ? `${style.amber('◇')} About that question` : state.verdict === 'ambiguous' ? `${style.amber('?')} Needs a little more` : `${style.amber('✗')} Not quite`;
|
|
174
|
+
terminal.line(` ${heading}`);
|
|
175
|
+
if (state.feedback) block(terminal, state.feedback, s => s.muted('│'));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function printHint(terminal, store, id, questionId) {
|
|
179
|
+
const { style } = terminal;
|
|
180
|
+
// Only the cited file paths: evidence reasons can give the answer away.
|
|
181
|
+
const session = await store.read(id).catch(() => null);
|
|
182
|
+
const question = session?.questions?.find(item => item.id === questionId);
|
|
183
|
+
const paths = [...new Set((question?.evidence || []).map(item => item.path).filter(Boolean))];
|
|
184
|
+
terminal.line('');
|
|
185
|
+
if (!paths.length) { terminal.note('No specific files are cited for this question. Start from the diff.'); return; }
|
|
186
|
+
terminal.line(` ${style.muted('Look at')}`);
|
|
187
|
+
for (const file of paths) terminal.line(` ${style.amber(plain(file))}`);
|
|
188
|
+
terminal.line('');
|
|
189
|
+
}
|
|
190
|
+
|
package/src/terminal-launch.js
CHANGED
|
@@ -10,7 +10,7 @@ import { setTimeout as delay } from 'node:timers/promises';
|
|
|
10
10
|
import { getPaths, readJson, writeJson } from './config.js';
|
|
11
11
|
|
|
12
12
|
const WORKER = fileURLToPath(new URL('./terminal-worker.js', import.meta.url));
|
|
13
|
-
const CLI = fileURLToPath(new URL('../bin/sphinx.js', import.meta.url));
|
|
13
|
+
export const CLI = fileURLToPath(new URL('../bin/sphinx.js', import.meta.url));
|
|
14
14
|
const ID = /^[a-f0-9]{64}$/;
|
|
15
15
|
const TOKEN = /^[a-f0-9-]{36}$/;
|
|
16
16
|
const STARTUP_MS = 30000;
|
|
@@ -282,7 +282,7 @@ export async function runCheckpointTerminal({ recordFile, token, env = process.e
|
|
|
282
282
|
if (Number.isInteger(child.pid)) { running.childPid = child.pid; childWrite = writeJson(recordFile, running).catch(() => {}); }
|
|
283
283
|
});
|
|
284
284
|
await childWrite;
|
|
285
|
-
const result = { ...running, status: code === 0 ? 'passed' : 'pending', updatedAt: Date.now(), finishedAt: Date.now() };
|
|
285
|
+
const result = { ...running, status: code === 0 ? 'passed' : code === 3 ? 'skipped' : 'pending', updatedAt: Date.now(), finishedAt: Date.now() };
|
|
286
286
|
if ((await readJson(recordFile, null))?.token === token) await writeJson(recordFile, result);
|
|
287
287
|
return { status: result.status, code };
|
|
288
288
|
}
|
package/src/update.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { realpath } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { getPaths, readJson, writeJson } from './config.js';
|
|
6
|
+
import { VERSION } from './version.js';
|
|
7
|
+
|
|
8
|
+
// The questionnaire shows the installed version and, when npm has a newer one,
|
|
9
|
+
// offers a one-click update. The update waits until this Sphinx process exits,
|
|
10
|
+
// so a check in progress never runs on half-replaced files.
|
|
11
|
+
export const PACKAGE = 'sphinx-understand';
|
|
12
|
+
const CACHE_MS = 12 * 3600 * 1000;
|
|
13
|
+
const PACKAGE_ROOT = fileURLToPath(new URL('..', import.meta.url));
|
|
14
|
+
const cacheFile = env => path.join(getPaths(env).home, 'version.json');
|
|
15
|
+
const parts = version => String(version).split('-')[0].split('.').map(Number);
|
|
16
|
+
|
|
17
|
+
/** True when `latest` is a higher x.y.z release than `current`. */
|
|
18
|
+
export function isNewer(latest, current) {
|
|
19
|
+
if (!/^\d+\.\d+\.\d+$/.test(String(latest))) return false;
|
|
20
|
+
const [a, b] = [parts(latest), parts(current)];
|
|
21
|
+
for (let index = 0; index < 3; index++) if (a[index] !== b[index]) return a[index] > (b[index] || 0);
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The latest published version, cached for 12 hours. Never throws; null when unknown. */
|
|
26
|
+
export async function latestVersion({ env = process.env, fetchImpl = globalThis.fetch, now = Date.now() } = {}) {
|
|
27
|
+
if (env.SPHINX_UPDATE_CHECK === '0') return null;
|
|
28
|
+
const cached = await readJson(cacheFile(env), null).catch(() => null);
|
|
29
|
+
if (cached && now - Date.parse(cached.checkedAt) < CACHE_MS) return cached.latest ?? null;
|
|
30
|
+
try {
|
|
31
|
+
const response = await fetchImpl(`https://registry.npmjs.org/${PACKAGE}/latest`, { signal: AbortSignal.timeout(4000), headers: { accept: 'application/json' } });
|
|
32
|
+
const latest = response.ok ? (await response.json())?.version : null;
|
|
33
|
+
if (typeof latest !== 'string') return null;
|
|
34
|
+
await writeJson(cacheFile(env), { latest, checkedAt: new Date(now).toISOString() }).catch(() => {});
|
|
35
|
+
return latest;
|
|
36
|
+
} catch { return null; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** How this copy was installed, and so how to update it. Linked checkouts update with git. */
|
|
40
|
+
export async function updatePlan({ root = PACKAGE_ROOT } = {}) {
|
|
41
|
+
const real = await realpath(root).catch(() => root);
|
|
42
|
+
if (!real.split(path.sep).includes('node_modules')) return { manager: null, note: 'This is a linked development copy. Update it with git pull.' };
|
|
43
|
+
if (/[\\/](?:\.pnpm|pnpm)[\\/]/.test(real)) return { manager: 'pnpm', command: ['pnpm', 'add', '-g', `${PACKAGE}@latest`] };
|
|
44
|
+
if (/[\\/]\.bun[\\/]/.test(real)) return { manager: 'bun', command: ['bun', 'add', '-g', `${PACKAGE}@latest`] };
|
|
45
|
+
return { manager: 'npm', command: ['npm', 'install', '-g', `${PACKAGE}@latest`] };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** What the questionnaire shows. Never throws. */
|
|
49
|
+
export async function updateStatus({ env = process.env, fetchImpl, root } = {}) {
|
|
50
|
+
const latest = await latestVersion({ env, fetchImpl });
|
|
51
|
+
const available = !!latest && isNewer(latest, VERSION);
|
|
52
|
+
const plan = available ? await updatePlan({ root }).catch(() => ({ manager: null })) : null;
|
|
53
|
+
return { current: VERSION, latest, available, ...(plan ? { command: plan.command?.join(' ') ?? null, note: plan.note ?? null } : {}) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const shellQuote = value => `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Install the latest version once process `waitFor` exits. Output goes to
|
|
60
|
+
* update.log in Sphinx's data directory. Resolves false if it cannot start.
|
|
61
|
+
*/
|
|
62
|
+
export async function scheduleUpdate({ command, waitFor = process.pid, env = process.env, spawnImpl = spawn } = {}) {
|
|
63
|
+
if (!Array.isArray(command) || !command.length) return false;
|
|
64
|
+
const log = path.join(getPaths(env).home, 'update.log');
|
|
65
|
+
const script = `while kill -0 ${Number(waitFor)} 2>/dev/null; do sleep 1; done; { date; ${command.map(shellQuote).join(' ')}; echo "exit $?"; } >> ${shellQuote(log)} 2>&1`;
|
|
66
|
+
try {
|
|
67
|
+
const child = spawnImpl('/bin/sh', ['-c', script], { env, detached: true, stdio: 'ignore' });
|
|
68
|
+
await new Promise((resolve, reject) => { child.once('error', reject); child.once('spawn', resolve); });
|
|
69
|
+
child.unref();
|
|
70
|
+
return true;
|
|
71
|
+
} catch { return false; }
|
|
72
|
+
}
|