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 +7 -0
- package/package.json +2 -1
- package/src/agent-settings.js +7 -4
- package/src/browser-interview.js +16 -2
- package/src/check-recovery.js +21 -1
- package/src/cli.js +5 -0
- package/src/evaluator.js +7 -0
- package/src/git-gate.js +2 -1
- package/src/install.js +38 -12
- package/src/lifecycle-integrations.js +13 -7
- package/src/openrouter.js +1 -1
- package/src/sessions.js +26 -1
- package/src/user-file.js +23 -0
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.
|
|
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",
|
package/src/agent-settings.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
26
|
-
|
|
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`;
|
package/src/browser-interview.js
CHANGED
|
@@ -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 };
|
package/src/check-recovery.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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)}
|
|
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)} ${
|
|
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)} ${
|
|
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
|
|
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,
|
|
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(
|
|
40
|
-
if (!info.isFile() || info.
|
|
41
|
-
return await readFile(
|
|
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(
|
|
59
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/src/user-file.js
ADDED
|
@@ -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
|
+
}
|