sphinx-understand 0.1.4 → 0.1.5

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
@@ -4,6 +4,11 @@
4
4
 
5
5
  <p align="center"><em>Outsource your thinking, not your understanding.</em></p>
6
6
 
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
+
9
+ <p align="center"><b>Works with</b> Claude Code · Codex · Cursor · OpenCode · Pi · or a plain <code>git push</code><br>
10
+ <b>Reviews run on</b> your Claude Code or Codex plan · OpenRouter · any OpenAI-compatible endpoint</p>
11
+
7
12
  Sphinx adds an understanding check to agent-assisted development. When you push to a protected branch, Git waits while Sphinx asks you to predict what your change will do. If you miss something, it explains and gives you a new scenario to try. Once you pass, the same push continues. This works whether you or a coding agent ran `git push`.
8
13
 
9
14
  The questions come from a separate reviewer model. It reads the committed change through read-only Git tools and does not see the conversation that produced the code.
@@ -56,6 +61,8 @@ You should see:
56
61
 
57
62
  Use `--preset codex-economy` for Codex, or `--agents none` if you don't use a coding agent. Other presets, OpenRouter, OpenAI-compatible endpoints, and custom routes are covered in [configuration](docs/configuration.md#reviewer-models).
58
63
 
64
+ Settings files symlinked into a dotfiles repository (Stow, Dotbot, chezmoi, home-manager) are supported: Sphinx edits the link's target in place and keeps the link. Hooks call the `sphinx` command your package manager installed (npm, pnpm, yarn, or Bun), so upgrading the package keeps them working.
65
+
59
66
  ### 2. Load Sphinx in your coding agent (required if you use one)
60
67
 
61
68
  Guided setup opens your selected agents in fresh sessions. Then:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sphinx-understand",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Outsource your thinking, not your understanding. A comprehension checkpoint for agent-assisted development.",
5
5
  "homepage": "https://github.com/parthh01/sphinx#readme",
6
6
  "repository": {
@@ -29,6 +29,7 @@
29
29
  "src",
30
30
  "scripts/postinstall.js",
31
31
  "docs",
32
+ "!docs/demo.gif",
32
33
  "eval/RESULTS.md",
33
34
  "eval/fixtures/known-false-negatives.json",
34
35
  "examples",
@@ -1,4 +1,5 @@
1
1
  import path from 'node:path';
2
+ import { settingsTarget } from './user-file.js';
2
3
  import { lstat, readFile, mkdir, writeFile, rename, rm, stat } from 'node:fs/promises';
3
4
  import { randomUUID } from 'node:crypto';
4
5
 
@@ -8,8 +9,7 @@ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
8
9
 
9
10
  async function readText(file) {
10
11
  try {
11
- if ((await lstat(file)).isSymbolicLink()) throw new Error(`Refusing to replace symlink: ${file}`);
12
- return await readFile(file, 'utf8');
12
+ return await readFile((await settingsTarget(file)).path, 'utf8');
13
13
  } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
14
14
  }
15
15
 
@@ -22,8 +22,11 @@ function parseJson(text, file) {
22
22
  } catch { throw new Error(`Cannot read ${file}: invalid JSON object. Fix the file and try again.`); }
23
23
  }
24
24
 
25
- async function writeText(file, text) {
26
- if (text === null) { await rm(file, { force: true }); return; }
25
+ async function writeText(link, text) {
26
+ const { path: file, linked } = await settingsTarget(link);
27
+ // Never delete a dotfiles target through its link; leave it empty instead.
28
+ if (text === null && !linked) { await rm(file, { force: true }); return; }
29
+ text ??= '';
27
30
  await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
28
31
  const mode = await stat(file).then(s => s.mode & 0o777).catch(() => 0o600);
29
32
  const temporary = `${file}.${randomUUID()}.tmp`;
@@ -1,11 +1,12 @@
1
1
  import { createServer } from 'node:http';
2
+ import { planProviders } from './evaluator.js';
2
3
  import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
3
4
  import { spawn } from 'node:child_process';
4
5
  import { setTimeout as delay } from 'node:timers/promises';
5
6
  import { loadConfig, policyHash } from './config.js';
6
7
  import { revalidateSnapshot } from './git.js';
7
8
  import { createSessionStore } from './sessions.js';
8
- import { prepareInteractiveCheck } from './check-recovery.js';
9
+ import { prepareInteractiveCheck, isRateLimit, switchChoices, PLAN_NAMES } from './check-recovery.js';
9
10
  import { claimCheckpointDialog, finishCheckpointDialog, waitForCheckpointTerminal } from './terminal-launch.js';
10
11
  import { browserPage } from './browser-page.js';
11
12
  import { recordOverride } from './overrides.js';
@@ -51,7 +52,7 @@ function publicFields(state) {
51
52
  export function createBrowserInterview({ env = process.env, signal, timeoutMs = 25 * 60 * 1000,
52
53
  store: providedStore, readConfig = loadConfig, revalidate = revalidateSnapshot,
53
54
  reserve = claimCheckpointDialog, release = finishCheckpointDialog, waitForExisting = waitForCheckpointTerminal,
54
- openBrowser = openQuestionnaireBrowser, output = line => process.stderr.write(`${line}\n`), logOverride = recordOverride,
55
+ openBrowser = openQuestionnaireBrowser, output = line => process.stderr.write(`${line}\n`), logOverride = recordOverride, findPlans = planProviders,
55
56
  loadView = loadChangeView,
56
57
  } = {}) {
57
58
  const token = randomBytes(32).toString('base64url'), nonce = randomBytes(24).toString('base64');
@@ -259,7 +260,9 @@ export function createBrowserInterview({ env = process.env, signal, timeoutMs =
259
260
  await ensureServer(operationEnv);
260
261
  const notes = [];
261
262
  const recoveryUI = { warning: text => notes.push(text), note: text => notes.push(text), command: () => {} };
263
+ const plans = await findPlans(operationEnv).catch(() => []);
262
264
  visible = await prepareInteractiveCheck({ config, signal: localSignal, waitingGit: request.delivery === 'hook', ui: recoveryUI,
265
+ planProviders: plans, switchRoute: provider => store.switchRoute(request.id, config, provider, { signal: localSignal }),
263
266
  inspect: async () => store.public(await store.read(request.id)),
264
267
  validate: () => validate(session.snapshot, config),
265
268
  authorizeMore: () => store.retryReview(request.id, config, { signal: localSignal }),
@@ -308,6 +311,17 @@ export function createBrowserInterview({ env = process.env, signal, timeoutMs =
308
311
  settle('not-graded');
309
312
  localSignal.throwIfAborted(); await validate(session.snapshot, config);
310
313
  visible = store.public(await store.read(request.id));
314
+ // A rate-limited API route can hand grading to the user's coding-agent plan.
315
+ if (isRateLimit(error, visible.route) && plans.length) {
316
+ 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));
318
+ if (picked.action.startsWith('switch-')) {
319
+ const provider = picked.action.slice('switch-'.length);
320
+ visible = await store.switchRoute(request.id, config, provider, { signal: localSignal });
321
+ say({ role: 'sphinx', kind: 'notice', text: `Switched this check to ${PLAN_NAMES[provider][0]}. Send your answer again.` });
322
+ continue;
323
+ }
324
+ }
311
325
  publish({ ...publicFields(visible), repoRoot: request.repoRoot, phase: 'question', error: error.message, message: 'Your response was not accepted. Review the error before submitting again.' });
312
326
  // Re-display through waitForAction on the next iteration; never resend.
313
327
  visible = { ...visible, browserError: error.message };
@@ -4,6 +4,16 @@ import { modelUsageText } from './usage.js';
4
4
  const setupCodes = new Set(['INVALID_CONFIG', 'INVALID_SNAPSHOT', 'STALE_SNAPSHOT', 'CONTEXT_LIMIT', 'TOOL_RESULT_LIMIT', 'OUTPUT_LIMIT', 'PROVIDER_NOT_FOUND', 'PROVIDER_START_FAILED', 'PROVIDER_FAILED']);
5
5
  const setupTypes = new Set(['authentication', 'permission_denied', 'payment_required', 'context_length_exceeded', 'max_tokens_exceeded', 'token_limit_exceeded', 'string_too_long', 'invalid_request', 'invalid_prompt', 'not_found', 'precondition_failed', 'payload_too_large', 'unprocessable', 'content_policy_violation', 'refusal']);
6
6
 
7
+ /** A rate limit from an API route that a signed-in coding-agent plan could take over. */
8
+ export const isRateLimit = (error, route) => ['openrouter', 'openai'].includes(route?.provider)
9
+ && (error?.status === 429 || error?.errorType === 'rate_limit_exceeded');
10
+
11
+ export const PLAN_NAMES = { claude: ['Claude Code', 'Claude'], codex: ['Codex', 'ChatGPT'] };
12
+ export const switchChoices = (providers, from) => providers.map(provider => ({
13
+ 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
+ }));
16
+
7
17
  function retryable(error, before, state, minimum) {
8
18
  if (setupCodes.has(error.code) || setupTypes.has(error.errorType)) return false;
9
19
  if (error.code === 'OPENROUTER_PROVIDER_ERROR' && [400, 401, 402, 403, 404, 413, 422].includes(error.status)) return false;
@@ -33,7 +43,7 @@ function diagnostic(error, state) {
33
43
  * chooses to retry, expands a budget, changes a route, or awards completion on
34
44
  * its own. inspect() exposes public state only; validate() checks current Git
35
45
  * content and policy before a recovery choice is shown. */
36
- export async function prepareInteractiveCheck({ start, inspect, authorizeMore, validate, config, choose, ui, waitingGit = false, signal }) {
46
+ export async function prepareInteractiveCheck({ start, inspect, authorizeMore, validate, config, choose, ui, waitingGit = false, signal, planProviders = [], switchRoute }) {
37
47
  for (;;) {
38
48
  signal?.throwIfAborted();
39
49
  const before = await inspect();
@@ -64,6 +74,9 @@ export async function prepareInteractiveCheck({ start, inspect, authorizeMore, v
64
74
  description: `${remaining} calls remain; review needs at least ${reviewMinimum} plus ${reserve} reserved for grading. Keep all ${state.usage.calls} used calls counted and record this new allowance.`,
65
75
  });
66
76
  }
77
+ // 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) : [];
79
+ choices.unshift(...switches);
67
80
  if (!choices.length) ui.note('Resolve the reported setup or evidence problem before resuming. Repeating this attempt is not offered here.');
68
81
  choices.push({ value: 'pause', label: 'Pause this check', description: 'Save progress without making another model request. The Git operation remains unapproved.' });
69
82
  const action = await choose({
@@ -80,6 +93,13 @@ export async function prepareInteractiveCheck({ start, inspect, authorizeMore, v
80
93
  return null;
81
94
  }
82
95
  if (!choices.some(choice => choice.value === action)) throw new Error('Invalid review recovery choice. No additional model request was made.');
96
+ if (action.startsWith('switch-')) {
97
+ const provider = action.slice('switch-'.length);
98
+ await switchRoute(provider);
99
+ signal?.throwIfAborted();
100
+ ui.note(`Switched this check to ${PLAN_NAMES[provider][0]}. Your saved model settings are unchanged.`);
101
+ continue;
102
+ }
83
103
  if (action === 'extend') {
84
104
  await authorizeMore();
85
105
  signal?.throwIfAborted();
package/src/cli.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import path from 'node:path';
2
+ import { planProviders } from './evaluator.js';
2
3
  import { VERSION } from './version.js';
3
4
  import { fileURLToPath } from 'node:url';
4
5
  import { lstat } from 'node:fs/promises';
@@ -381,6 +382,8 @@ async function checkCommand(values) {
381
382
  validate: () => validateCheckpoint(session.snapshot, config), config,
382
383
  choose: createPrompts({ ask, ui: terminal }).choose, ui: terminal,
383
384
  waitingGit: process.env.SPHINX_WAITING_GIT === '1', signal,
385
+ planProviders: await planProviders().catch(() => []),
386
+ switchRoute: provider => store.switchRoute(session.id, config, provider, { signal }),
384
387
  });
385
388
  signal.throwIfAborted();
386
389
  if (!state) return 1;
@@ -468,6 +471,8 @@ export async function main(argv) {
468
471
  const result = await agentHook({ agent: argv[2], input: await readStdin(), cwd: process.cwd(), env: process.env });
469
472
  if (result.output) console.log(JSON.stringify(result.output)); return result.exitCode;
470
473
  }
474
+ // Cursor's command hook protocol lives in its own module and runs on import.
475
+ if (argv[1] === 'cursor') { await import('./cursor-hook.js'); return 0; }
471
476
  throw new Error('Unknown hook entry point.');
472
477
  }
473
478
  const { values, command, id } = parseCommandLine(argv);
package/src/evaluator.js CHANGED
@@ -227,6 +227,13 @@ async function planSignInProblem(provider, { runner, cwd, env, signal }) {
227
227
  return 'Codex is not signed in with a ChatGPT plan, and Sphinx ignores API keys in your environment so reviews bill your plan. Run codex login, or run sphinx config --cli-billing api to use API keys instead.';
228
228
  }
229
229
 
230
+ /** Coding-agent CLIs installed on PATH that can take over a check on the user's plan. */
231
+ export async function planProviders(env = process.env) {
232
+ const found = [];
233
+ for (const name of ['claude', 'codex']) if (await findExecutable(name, env)) found.push(name);
234
+ return found;
235
+ }
236
+
230
237
  async function findExecutable(command, env) {
231
238
  for (const directory of (env.PATH ?? '').split(delimiter).filter(Boolean)) {
232
239
  const candidate = resolve(directory, command);
package/src/git-gate.js CHANGED
@@ -106,7 +106,8 @@ export async function runGitGate({ event, args = [], input = '', cwd = process.c
106
106
  }
107
107
  }
108
108
  signal?.throwIfAborted();
109
- if (requests.length) output('Sphinx: understanding check complete. Continuing this Git operation.');
109
+ // Skipped checks already reported themselves; only a pass is a completed check.
110
+ if (requests.length > skipped.size) output('Sphinx: understanding check complete. Continuing this Git operation.');
110
111
  return 0;
111
112
  } finally {
112
113
  // A paused or cancelled batch must not reopen from an after-tool callback.
package/src/install.js CHANGED
@@ -1,4 +1,5 @@
1
- import { access, chmod, lstat, mkdir, readFile, readdir, rename, rm, rmdir, stat, writeFile } from 'node:fs/promises';
1
+ import { access, chmod, lstat, mkdir, readFile, readdir, realpath, rename, rm, rmdir, stat, writeFile } from 'node:fs/promises';
2
+ import { settingsTarget } from './user-file.js';
2
3
  import { constants } from 'node:fs';
3
4
  import { spawn } from 'node:child_process';
4
5
  import { createHash, randomUUID } from 'node:crypto';
@@ -22,6 +23,29 @@ const GATED = new Set(['pre-push', 'pre-commit']);
22
23
  const PREPARED = new Set(['post-commit', 'post-merge', 'post-rewrite', 'post-checkout']);
23
24
  const hash = value => createHash('sha256').update(value).digest('hex');
24
25
  const quote = value => `'${String(value).replaceAll("'", "'\\''")}'`;
26
+
27
+ /**
28
+ * The shell command hooks use to run Sphinx. Prefer the package manager's own
29
+ * `sphinx` on PATH (an npm/yarn symlink or a pnpm/bun shim): it survives
30
+ * upgrades, unlike a versioned store path. Node's directory is put first on
31
+ * PATH so GUI-launched agents without a login PATH still find node. A checkout
32
+ * without such a shim falls back to absolute node and CLI paths.
33
+ * Returns shell `env` assignments (placed before any exec) and the `command`.
34
+ */
35
+ export async function sphinxLauncher(cliPath, env = process.env, execPath = process.execPath) {
36
+ const real = await realpath(cliPath).catch(() => cliPath);
37
+ for (const directory of String(env.PATH || '').split(path.delimiter).filter(dir => path.isAbsolute(dir))) {
38
+ const candidate = path.join(directory, 'sphinx');
39
+ try {
40
+ const info = await stat(candidate);
41
+ if (!info.isFile() || !(info.mode & 0o111)) continue;
42
+ const matches = await realpath(candidate) === real
43
+ || (info.size < 64 * 1024 && (await readFile(candidate, 'utf8')).includes('sphinx-understand/bin/sphinx.js'));
44
+ if (matches) return { env: `PATH=${quote(path.dirname(execPath))}:"$PATH" `, command: quote(candidate) };
45
+ } catch { /* Not a usable shim. */ }
46
+ }
47
+ return { env: '', command: `${quote(execPath)} ${quote(cliPath)}` };
48
+ }
25
49
  const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
26
50
  const AGENTS = ['codex', 'claude', 'pi', 'cursor', 'opencode'];
27
51
 
@@ -55,8 +79,7 @@ async function exists(file) {
55
79
 
56
80
  async function readJson(file, fallback) {
57
81
  try {
58
- if ((await lstat(file)).isSymbolicLink()) throw new Error(`Refusing to replace symlink: ${file}`);
59
- const value = JSON.parse(await readFile(file, 'utf8'));
82
+ const value = JSON.parse(await readFile((await settingsTarget(file)).path, 'utf8'));
60
83
  if (!value || Array.isArray(value) || typeof value !== 'object') throw new Error('expected an object');
61
84
  return value;
62
85
  } catch (error) {
@@ -66,7 +89,9 @@ async function readJson(file, fallback) {
66
89
  }
67
90
  }
68
91
 
69
- async function writeJson(file, value) {
92
+ async function writeJson(link, value) {
93
+ // Write through a dotfiles symlink to its target, atomically, keeping the link.
94
+ const file = (await settingsTarget(link)).path;
70
95
  await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
71
96
  const mode = await stat(file).then(s => s.mode & 0o777).catch(() => 0o600);
72
97
  const temporary = `${file}.${randomUUID()}.tmp`;
@@ -119,15 +144,15 @@ function sourceShell(source) {
119
144
  : 'if ! hook_dir=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null); then\n if [ -z "${GIT_DIR:-}" ] || [ ! -d "$GIT_DIR" ]; then\n git rev-parse --git-common-dir >/dev/null\n exit 128\n fi\n hook_dir=$GIT_DIR\nfi\nhook_dir="$hook_dir/hooks"\n';
120
145
  }
121
146
 
122
- function hookScript({ event, source, cliPath, home, id }) {
147
+ function hookScript({ event, source, launcher, home, id }) {
123
148
  const header = '#!/bin/sh\n# Managed by Sphinx. Existing hooks remain in their original directory.\n';
124
149
  if (GATED.has(event)) {
125
- return `${header}SPHINX_HOME=${quote(home)} SPHINX_INSTALL_ID=${quote(id)} exec ${quote(process.execPath)} ${quote(cliPath)} hook git ${quote(event)} "$@"\n`;
150
+ return `${header}SPHINX_HOME=${quote(home)} SPHINX_INSTALL_ID=${quote(id)} ${launcher.env}exec ${launcher.command} hook git ${quote(event)} "$@"\n`;
126
151
  }
127
152
  if (PREPARED.has(event)) {
128
153
  // The existing hook retains streaming stdin and its exit status. Scheduling
129
154
  // has no terminal, runs asynchronously, and never gates the Git operation.
130
- return `${header}${sourceShell(source)}hook="$hook_dir/${event}"\nstatus=0\nif [ -x "$hook" ]; then\n "$hook" "$@"\n status=$?\nfi\nif [ "\${SPHINX_EVALUATOR:-}" != "1" ]; then\n SPHINX_HOME=${quote(home)} ${quote(process.execPath)} ${quote(cliPath)} prepare </dev/null >/dev/null 2>&1 &\nfi\nexit "$status"\n`;
155
+ return `${header}${sourceShell(source)}hook="$hook_dir/${event}"\nstatus=0\nif [ -x "$hook" ]; then\n "$hook" "$@"\n status=$?\nfi\nif [ "\${SPHINX_EVALUATOR:-}" != "1" ]; then\n SPHINX_HOME=${quote(home)} ${launcher.env}${launcher.command} prepare </dev/null >/dev/null 2>&1 &\nfi\nexit "$status"\n`;
131
156
  }
132
157
  // Protocol hooks such as proc-receive must retain streaming stdin/stdout.
133
158
  // They exec the prior hook directly rather than buffering input in Node.
@@ -169,7 +194,7 @@ export async function detectAgents({ env = process.env } = {}) {
169
194
  return detectedAgents({ paths, env, manifest });
170
195
  }
171
196
 
172
- async function prepareAgents({ agents, paths, env, cliPath, manifest }) {
197
+ async function prepareAgents({ agents, paths, env, cliPath, launcher, manifest }) {
173
198
  const selected = agents === 'auto'
174
199
  ? await detectedAgents({ paths, env, manifest })
175
200
  : agents === 'none' ? [] : Array.isArray(agents) ? agents : agents.split(',').map(x => x.trim());
@@ -178,13 +203,13 @@ async function prepareAgents({ agents, paths, env, cliPath, manifest }) {
178
203
  for (const name of new Set(selected)) {
179
204
  if (['pi', 'cursor', 'opencode'].includes(name)) {
180
205
  const prepare = name === 'pi' ? preparePiIntegration : name === 'cursor' ? prepareCursorIntegration : prepareOpenCodeIntegration;
181
- result.push(await prepare(paths, { env, cliPath, previous: manifest.agents[name] }));
206
+ result.push(await prepare(paths, { env, cliPath, launcher, previous: manifest.agents[name] }));
182
207
  continue;
183
208
  }
184
209
  const file = path.join(paths[`${name}Home`], name === 'codex' ? 'hooks.json' : 'settings.json');
185
210
  const document = await readJson(file, {});
186
211
  if (document.hooks !== undefined && (!document.hooks || typeof document.hooks !== 'object' || Array.isArray(document.hooks))) throw new Error(`Invalid hooks object in ${file}`);
187
- const command = `SPHINX_HOME=${quote(paths.home)} ${quote(process.execPath)} ${quote(cliPath)} hook agent ${name}`;
212
+ const command = `SPHINX_HOME=${quote(paths.home)} ${launcher.env}${launcher.command} hook agent ${name}`;
188
213
  const handler = { type: 'command', command, timeout: 10 };
189
214
  const hooks = nativeHookSpecs(name, handler);
190
215
  for (const { event } of hooks) {
@@ -218,7 +243,8 @@ export async function install({ config, agents = 'auto', repo = false, cwd = pro
218
243
  const id = info.id;
219
244
  const previous = manifest.installs[id];
220
245
  // Validate every selected settings file before mutating any integration.
221
- const agentPlans = await prepareAgents({ agents, paths, env, cliPath, manifest });
246
+ const launcher = await sphinxLauncher(cliPath, env);
247
+ const agentPlans = await prepareAgents({ agents, paths, env, cliPath, launcher, manifest });
222
248
  const scopeValues = await currentConfig(info.scope, cwd, env);
223
249
  if (previous && scopeValues.at(-1) !== previous.hookDir) {
224
250
  throw new Error('Git hooks changed since Sphinx was installed. Run sphinx uninstall first; your current configuration will be preserved.');
@@ -235,7 +261,7 @@ export async function install({ config, agents = 'auto', repo = false, cwd = pro
235
261
  });
236
262
  if (directoryState?.isSymbolicLink()) throw new Error(`Refusing to replace a symlinked hook directory: ${entry.hookDir}`);
237
263
  const names = await hookNames(entry.source, cwd, env);
238
- const scripts = names.map(event => ({ event, content: hookScript({ event, source: entry.source, cliPath, home: paths.home, id }) }));
264
+ const scripts = names.map(event => ({ event, content: hookScript({ event, source: entry.source, launcher, home: paths.home, id }) }));
239
265
  for (const { event, content } of scripts) {
240
266
  const file = path.join(entry.hookDir, event);
241
267
  const fileState = await lstat(file).catch(error => {
@@ -1,4 +1,5 @@
1
1
  import path from 'node:path';
2
+ import { settingsTarget } from './user-file.js';
2
3
  import { fileURLToPath, pathToFileURL } from 'node:url';
3
4
  import { lstat, readFile, mkdir, writeFile, rename, rm, stat } from 'node:fs/promises';
4
5
  import { createHash, randomUUID } from 'node:crypto';
@@ -36,9 +37,9 @@ export function lifecycleHome(name, paths, env = process.env) {
36
37
 
37
38
  async function text(file) {
38
39
  try {
39
- const info = await lstat(file);
40
- if (!info.isFile() || info.isSymbolicLink() || info.size > 4 * 1024 * 1024) throw new Error(`Refusing to replace a symlink, non-file, or oversized integration: ${file}`);
41
- return await readFile(file, 'utf8');
40
+ const target = (await settingsTarget(file)).path, info = await lstat(target);
41
+ if (!info.isFile() || info.size > 4 * 1024 * 1024) throw new Error(`Refusing to replace a non-file or oversized integration: ${file}`);
42
+ return await readFile(target, 'utf8');
42
43
  } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
43
44
  }
44
45
 
@@ -55,8 +56,10 @@ function json(content, file) {
55
56
  catch { throw new Error(`Cannot read ${file}: invalid JSON object. Fix the file and try again.`); }
56
57
  }
57
58
 
58
- async function write(file, content) {
59
- if (content === null) { await rm(file, { force: true }); return; }
59
+ async function write(link, content) {
60
+ const { path: file, linked } = await settingsTarget(link);
61
+ if (content === null && !linked) { await rm(file, { force: true }); return; }
62
+ content ??= '';
60
63
  await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
61
64
  const mode = await stat(file).then(info => info.mode & 0o777).catch(() => 0o600);
62
65
  const temporary = `${file}.${randomUUID()}.tmp`;
@@ -77,14 +80,17 @@ function plan({ name, file, before, after, record }) {
77
80
  }
78
81
 
79
82
  /** Cursor exposes command hooks, but no documented hook that invokes MCP forms. */
80
- export async function prepareCursorIntegration(paths, { env = process.env, previous } = {}) {
83
+ export async function prepareCursorIntegration(paths, { env = process.env, previous, launcher } = {}) {
81
84
  const home = lifecycleHome('cursor', paths, env), file = path.join(home, 'hooks.json');
82
85
  await checkDirectories(home, file);
83
86
  if (previous && previous.file !== file) throw new Error('Cursor integration home changed. Uninstall from the original home before reinstalling.');
84
87
  const before = await text(file), document = json(before, file);
85
88
  if (document.version !== undefined && document.version !== 1) throw new Error(`Unsupported Cursor hooks version in ${file}`);
86
89
  if (document.hooks !== undefined && !object(document.hooks)) throw new Error(`Invalid hooks object in ${file}`);
87
- const command = `SPHINX_HOME=${quote(paths.home)} ${quote(process.execPath)} ${quote(fileURLToPath(new URL('./cursor-hook.js', import.meta.url)))}`;
90
+ // Through the stable sphinx launcher when available, so package upgrades keep working.
91
+ const command = launcher
92
+ ? `SPHINX_HOME=${quote(paths.home)} ${launcher.env}${launcher.command} hook cursor`
93
+ : `SPHINX_HOME=${quote(paths.home)} ${quote(process.execPath)} ${quote(fileURLToPath(new URL('./cursor-hook.js', import.meta.url)))}`;
88
94
  const hooks = [
89
95
  { event: 'sessionStart', handler: { command, timeout: 10 } },
90
96
  { event: 'postToolUse', handler: { command, timeout: 1800, matcher: '^Shell$' } },
package/src/openrouter.js CHANGED
@@ -14,7 +14,7 @@ const failureAdvice = {
14
14
  401: 'API key was rejected. Run sphinx auth.',
15
15
  402: 'OpenRouter account has insufficient credits.',
16
16
  404: 'No compatible provider was available for this request. Select a route supporting JSON schema or retry later.',
17
- 429: 'Provider or account rate limit reached. Wait before retrying; Sphinx will not switch models.',
17
+ 429: 'Provider or account rate limit reached. Wait before retrying, or switch this check to your Claude Code or Codex plan when offered.',
18
18
  503: 'Selected model is unavailable. Retry later or explicitly select another route.',
19
19
  };
20
20
 
package/src/sessions.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import path from 'node:path';
2
+ import { REVIEWER_PRESETS } from './model-presets.js';
2
3
  import { withUsageMeter, addUsage } from './usage.js';
3
4
  import { mkdir, open, readdir, stat, unlink } from 'node:fs/promises';
4
5
  import { DEFAULT_CONFIG, getPaths, readJson, writeJson, policyHash } from './config.js';
@@ -373,5 +374,29 @@ export function createSessionStore({ env = process.env, generate = generateAsses
373
374
  return true;
374
375
  });
375
376
  }
376
- return {ensure,approved,prepare,start,retryReview,refreshBudget,answer,pending,read,skip,discard,public:publicSession};
377
+ /**
378
+ * Move one check to a coding agent's signed-in plan, for example after an
379
+ * OpenRouter rate limit. Only this check changes; saved routes stay as they
380
+ * are. Unanswered investigation is dropped because its conversation belongs
381
+ * to the previous transport; existing questions and progress are kept.
382
+ */
383
+ async function switchRoute(id, config, provider, { signal } = {}) {
384
+ if (!['claude', 'codex'].includes(provider)) throw new Error('Only Claude Code or Codex can take over a check on your plan.');
385
+ return withLock(id, env, async () => {
386
+ signal?.throwIfAborted();
387
+ const session = await read(id);
388
+ if (session.policy !== policyHash(config)) throw new Error('Sphinx settings changed. Start a fresh check.');
389
+ if (!['pending', 'active'].includes(session.status)) throw new Error('Only an unfinished check can switch models.');
390
+ const preset = REVIEWER_PRESETS.find(item => item.id === `${provider}-economy`);
391
+ const model = preset?.models[session.route.tier]?.model ?? null;
392
+ const at = now();
393
+ session.routeChanges = [...(session.routeChanges || []), { at, calls: session.calls, from: { provider: session.route.provider, model: session.route.model }, to: { provider, model } }];
394
+ session.route = { ...session.route, provider, model };
395
+ if (!session.questions.length) delete session.reviewerCheckpoint;
396
+ session.updatedAt = at;
397
+ await save(session);
398
+ return publicSession(session);
399
+ });
400
+ }
401
+ return {ensure,approved,prepare,start,retryReview,refreshBudget,answer,pending,read,skip,discard,switchRoute,public:publicSession};
377
402
  }
@@ -0,0 +1,23 @@
1
+ import { lstat, realpath, stat } from 'node:fs/promises';
2
+
3
+ /**
4
+ * Agent settings are often symlinks into a dotfiles repository (Stow, Dotbot,
5
+ * chezmoi, home-manager). Resolve a symlink to the user's own regular file so
6
+ * Sphinx edits that target in place and the link stays intact. A missing file
7
+ * resolves to itself; anything else that is not the user's regular file fails.
8
+ */
9
+ export async function settingsTarget(file) {
10
+ let info;
11
+ try { info = await lstat(file); } catch (error) { if (error.code === 'ENOENT') return { path: file, linked: false }; throw error; }
12
+ if (!info.isSymbolicLink()) return { path: file, linked: false };
13
+ let target;
14
+ try { target = await realpath(file); }
15
+ catch (error) {
16
+ if (error.code === 'ENOENT') throw new Error(`${file} is a symlink to a missing file. Create the target or remove the link, then retry.`);
17
+ throw error;
18
+ }
19
+ const resolved = await stat(target);
20
+ if (!resolved.isFile()) throw new Error(`${file} links to ${target}, which is not a regular file.`);
21
+ if (typeof process.getuid === 'function' && resolved.uid !== process.getuid()) throw new Error(`${file} links to ${target}, which you do not own. Sphinx only edits your own files.`);
22
+ return { path: target, linked: true };
23
+ }