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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  <h1 align="center">Sphinx</h1>
4
4
 
5
- <p align="center"><em>Outsource your thinking, not your understanding.</em></p>
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. Small changes get one question, and large ones get up to three at the default depth. 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.
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
- try { process.exitCode = await main(process.argv.slice(2)); }
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;
@@ -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 must return at least one question and may use fewer than the ceiling when fewer distinct behaviors are supported. 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.
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
 
@@ -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.5",
4
- "description": "Outsource your thinking, not your understanding. A comprehension checkpoint for agent-assisted development.",
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
  },
@@ -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', error: error.message, choices }, choices.map(choice => choice.value));
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');
@@ -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;}
@@ -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
- export const switchChoices = (providers, from) => providers.map(provider => ({
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 === 'openai' ? 'your API endpoint' : 'OpenRouter'}. Only this check changes; your saved model settings stay the same.`,
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
- title: 'Review did not finish',
84
- description: waitingGit
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);
@@ -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
  }