sphinx-understand 0.0.0-stage → 0.1.1
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/.sphinxconfig.example +10 -0
- package/.sphinxignore.example +22 -0
- package/CONTRIBUTING.md +19 -0
- package/LICENSE +21 -0
- package/README.md +186 -2
- package/bin/sphinx.js +9 -0
- package/docs/architecture.md +212 -0
- package/docs/configuration.md +241 -0
- package/docs/integrations.md +135 -0
- package/docs/logo.svg +3 -0
- package/eval/README.md +38 -0
- package/eval/RESULTS.md +205 -0
- package/eval/fixtures/known-false-negatives.json +115 -0
- package/examples/browser-demo.mjs +28 -0
- package/examples/config.json +59 -0
- package/package.json +50 -4
- package/src/activation-worker.js +10 -0
- package/src/activation.js +187 -0
- package/src/agent-settings.js +206 -0
- package/src/agent.js +45 -0
- package/src/browser-interview.js +344 -0
- package/src/browser-page.js +427 -0
- package/src/change-view.js +314 -0
- package/src/check-recovery.js +92 -0
- package/src/checkbox.js +148 -0
- package/src/cli.js +563 -0
- package/src/config.js +248 -0
- package/src/cursor-hook.js +18 -0
- package/src/evaluator.js +501 -0
- package/src/git-gate.js +116 -0
- package/src/git-interview.js +51 -0
- package/src/git.js +252 -0
- package/src/help.js +122 -0
- package/src/ignore.js +94 -0
- package/src/install.js +448 -0
- package/src/lifecycle-integrations.js +259 -0
- package/src/logo.js +6 -0
- package/src/mcp.js +352 -0
- package/src/model-presets.js +79 -0
- package/src/native-check.js +166 -0
- package/src/openrouter.js +269 -0
- package/src/overrides.js +68 -0
- package/src/pi-integration.js +179 -0
- package/src/pi-runtime.js +128 -0
- package/src/preparation.js +254 -0
- package/src/presentations.js +74 -0
- package/src/prompts.js +181 -0
- package/src/repo-config.js +72 -0
- package/src/repo-context.js +43 -0
- package/src/review-workspace.js +334 -0
- package/src/reviewer-limits.js +10 -0
- package/src/reviewer-response.js +22 -0
- package/src/reviewer.js +527 -0
- package/src/routing.js +40 -0
- package/src/sessions.js +377 -0
- package/src/stream.js +59 -0
- package/src/terminal-launch.js +288 -0
- package/src/terminal-worker.js +11 -0
- package/src/terminal.js +121 -0
- package/src/usage.js +31 -0
- package/src/version.js +4 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { access, lstat, mkdir, open, rm, stat } from 'node:fs/promises';
|
|
4
|
+
import { constants } from 'node:fs';
|
|
5
|
+
import { spawn, execFile } from 'node:child_process';
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
8
|
+
import { getPaths, readJson, writeJson } from './config.js';
|
|
9
|
+
import { terminalCommand, spawnTerminal, createTerminalEnvironmentBridge, readTerminalEnvironment } from './terminal-launch.js';
|
|
10
|
+
|
|
11
|
+
const WORKER = fileURLToPath(new URL('./activation-worker.js', import.meta.url));
|
|
12
|
+
const AGENTS = ['codex', 'claude', 'pi', 'cursor', 'opencode'];
|
|
13
|
+
const TOKEN = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
|
|
14
|
+
const SESSION_ENV = ['CODEX_THREAD_ID', 'CLAUDECODE', 'CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_ENTRYPOINT', 'PI_SESSION_ID', 'SPHINX_CLAUDE_SESSION', 'SPHINX_CURSOR_SESSION', 'SPHINX_OPENCODE_SESSION'];
|
|
15
|
+
const LABELS = { codex: 'Codex', claude: 'Claude Code', pi: 'Pi', cursor: 'Cursor', opencode: 'OpenCode' };
|
|
16
|
+
const quote = value => `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
17
|
+
const desktop = (env, platform) => !env.SSH_CONNECTION && !env.SSH_TTY && !(env.CI && env.CI !== 'false' && env.CI !== '0') && (platform === 'darwin' || (platform === 'linux' && !!(env.DISPLAY || env.WAYLAND_DISPLAY)));
|
|
18
|
+
const freshEnvironment = env => Object.fromEntries(Object.entries(env).filter(([key]) => !SESSION_ENV.includes(key)));
|
|
19
|
+
const fileFor = (home, token) => path.join(home, 'activations', `${token}.json`);
|
|
20
|
+
|
|
21
|
+
function clientEnvironment(env) {
|
|
22
|
+
const fresh = freshEnvironment(env);
|
|
23
|
+
if (!env.SPHINX_USER_HOME) return fresh;
|
|
24
|
+
const paths = getPaths(env);
|
|
25
|
+
return { ...fresh, HOME: paths.userHome, CODEX_HOME: paths.codexHome, CLAUDE_CONFIG_DIR: paths.claudeHome, PI_CODING_AGENT_DIR: path.join(paths.userHome, '.pi', 'agent'), OPENCODE_CONFIG_DIR: path.join(paths.userHome, '.config', 'opencode'), XDG_CONFIG_HOME: path.join(paths.userHome, '.config'), XDG_DATA_HOME: path.join(paths.userHome, '.local', 'share'), XDG_STATE_HOME: path.join(paths.userHome, '.local', 'state'), XDG_CACHE_HOME: path.join(paths.userHome, '.cache') };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function executable(name, env) {
|
|
29
|
+
for (const directory of (env.PATH || '').split(path.delimiter)) {
|
|
30
|
+
if (!directory) continue;
|
|
31
|
+
const file = path.resolve(directory, name);
|
|
32
|
+
try { if ((await stat(file)).isFile()) { await access(file, constants.X_OK); return file; } } catch {}
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function cursorExecutable(env, platform, findExecutable) {
|
|
38
|
+
const fromPath = await findExecutable('cursor', env);
|
|
39
|
+
if (fromPath || platform !== 'darwin') return fromPath;
|
|
40
|
+
// Use the editor's own launcher when "Install cursor command" was not run.
|
|
41
|
+
// Test homes must never fall through to the real machine's Applications.
|
|
42
|
+
const roots = env.SPHINX_USER_HOME ? [path.join(getPaths(env).userHome, 'Applications')] : ['/Applications', path.join(getPaths(env).userHome, 'Applications')];
|
|
43
|
+
for (const root of roots) {
|
|
44
|
+
const file = path.join(root, 'Cursor.app', 'Contents', 'Resources', 'app', 'bin', 'code');
|
|
45
|
+
try { if ((await stat(file)).isFile()) { await access(file, constants.X_OK); return file; } } catch {}
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function clientVersion(binary, env) {
|
|
51
|
+
return new Promise(resolve => {
|
|
52
|
+
execFile(binary, ['--version'], { env, timeout: 2000, maxBuffer: 4096, encoding: 'utf8' }, (error, stdout) => {
|
|
53
|
+
if (error) { resolve(null); return; }
|
|
54
|
+
const match = /^(?:Cursor\s+)?(\d+)\.(\d+)(?:\.(\d+))?(?:[-+][^\s]*)?\s*$/im.exec(stdout);
|
|
55
|
+
resolve(match ? `${match[1]}.${match[2]}.${match[3] || 0}` : null);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function activationDirectory(home) {
|
|
61
|
+
const directory = path.join(home, 'activations');
|
|
62
|
+
const info = await lstat(directory).catch(error => { if (error.code === 'ENOENT') return null; throw error; });
|
|
63
|
+
if (info && (info.isSymbolicLink() || !info.isDirectory())) throw new Error('Refusing an unsafe client activation directory.');
|
|
64
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
65
|
+
return directory;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Instructions stay in the terminal; they are never submitted as a model prompt. */
|
|
69
|
+
export function activationGuidance(agent) {
|
|
70
|
+
const steps = {
|
|
71
|
+
codex: 'In Codex, open /hooks to review and trust the Sphinx hooks.',
|
|
72
|
+
claude: 'Respond to any Claude setup or MCP permission prompts. Use /mcp to inspect Sphinx.',
|
|
73
|
+
pi: 'Blocking Git checks use a local browser page. Pi dialogs handle fallback checkpoints.',
|
|
74
|
+
opencode: 'When Git requires a checkpoint, Sphinx serves a local browser questionnaire without moving your coding window.',
|
|
75
|
+
};
|
|
76
|
+
return `Sphinx · ${LABELS[agent]}\n${steps[agent]}\nStarting a fresh session in this repository. No prompt has been submitted.\n\n`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Open configured clients only; the caller supplies the installed selection. */
|
|
80
|
+
export async function activateIntegrations({ agents = [], cwd = process.cwd(), env = process.env, platform = process.platform, findExecutable = executable, readClientVersion = clientVersion, launch = spawnTerminal, environmentBridge = createTerminalEnvironmentBridge, timeoutMs = 20000, pollMs = 50, wait = delay } = {}) {
|
|
81
|
+
if (!Array.isArray(agents) || agents.some(agent => !AGENTS.includes(agent))) throw new Error('Choose codex, claude, pi, cursor, or opencode for activation.');
|
|
82
|
+
if (typeof cwd !== 'string' || cwd.includes('\0') || !path.isAbsolute(cwd)) throw new Error('Client activation needs an absolute repository directory.');
|
|
83
|
+
if (!(await stat(cwd)).isDirectory()) throw new Error('Client activation needs an existing directory.');
|
|
84
|
+
const home = getPaths(env).home;
|
|
85
|
+
const results = await Promise.all([...new Set(agents)].map(async agent => {
|
|
86
|
+
const command = agent === 'cursor' ? `cursor --new-window ${quote(cwd)}` : agent;
|
|
87
|
+
const result = (status, detail) => ({ agent, status, detail, command, cwd });
|
|
88
|
+
if (env.SPHINX_EVALUATOR === '1') return result('unavailable', 'Client activation is disabled inside an evaluator.');
|
|
89
|
+
if (!desktop(env, platform)) return result('unavailable', `No local desktop is available. Open a terminal in this repository and run ${command}.`);
|
|
90
|
+
let binary;
|
|
91
|
+
try { binary = agent === 'cursor' ? await cursorExecutable(env, platform, findExecutable) : await findExecutable(agent, env); }
|
|
92
|
+
catch { return result('unavailable', `Could not locate ${LABELS[agent]}. Open it manually in this repository.`); }
|
|
93
|
+
if (binary && (!path.isAbsolute(binary) || binary.includes('\0'))) return result('unavailable', `Could not locate a valid ${LABELS[agent]} executable.`);
|
|
94
|
+
if (!binary) return result('unavailable', `${LABELS[agent]} is configured but its executable is not on PATH. Open it manually in this repository.`);
|
|
95
|
+
if (agent === 'cursor') {
|
|
96
|
+
// The editor CLI has this spelling; never substitute cursor-agent or agent.
|
|
97
|
+
try {
|
|
98
|
+
const version = await readClientVersion(binary, clientEnvironment(env));
|
|
99
|
+
if (/^\d+\.\d+\.\d+$/.test(version || '')) {
|
|
100
|
+
const [major, minor] = version.split('.').map(Number);
|
|
101
|
+
// Hooks first appeared in Cursor 1.7. Passing this floor does not
|
|
102
|
+
// certify support for every current hook event or verify permissions.
|
|
103
|
+
if (major < 1 || (major === 1 && minor < 7)) return result('unavailable', `Cursor ${version} predates hook support. Update Cursor, then run sphinx activate --agents cursor.`);
|
|
104
|
+
}
|
|
105
|
+
const opened = await launch({ command: binary, args: ['--new-window', cwd], waitForExit: true }, { env: clientEnvironment(env) });
|
|
106
|
+
return opened ? result('opened', 'Opened a new Cursor editor window. Start a new agent chat there; hook loading and permissions remain controlled by Cursor.') : result('failed', 'Cursor did not accept the window launch. Open the editor manually in this repository.');
|
|
107
|
+
} catch { return result('failed', 'Cursor could not be opened. Open the editor manually in this repository.'); }
|
|
108
|
+
}
|
|
109
|
+
let bridge, recordFile, claimFile;
|
|
110
|
+
try {
|
|
111
|
+
await activationDirectory(home);
|
|
112
|
+
const token = randomUUID();
|
|
113
|
+
recordFile = fileFor(home, token); claimFile = `${recordFile}.claim`;
|
|
114
|
+
const terminal = await terminalCommand({ recordFile, token, home, platform, env, findExecutable, workerPath: WORKER });
|
|
115
|
+
if (!terminal) return result('unavailable', `No supported desktop terminal was found. Open a terminal in this repository and run ${command}.`);
|
|
116
|
+
bridge = await environmentBridge(clientEnvironment(env), token);
|
|
117
|
+
await writeJson(recordFile, { version: 1, token, home, agent, command: binary, cwd, envSocket: bridge.socketPath, status: 'starting', createdAt: Date.now() });
|
|
118
|
+
const accepted = await launch(terminal, { env: clientEnvironment(env) });
|
|
119
|
+
if (!accepted) return result('failed', `Could not open a terminal for ${LABELS[agent]}. Run ${command} in this repository.`);
|
|
120
|
+
// Keep the one-shot IPC alive until the worker has received the environment
|
|
121
|
+
// and its interactive client emits spawn. Never wait for the session to end.
|
|
122
|
+
const deadline = Date.now() + timeoutMs;
|
|
123
|
+
do {
|
|
124
|
+
const current = await readJson(recordFile, null);
|
|
125
|
+
if (current?.token !== token) return result('failed', `The ${LABELS[agent]} launch could not be confirmed. Open it manually.`);
|
|
126
|
+
if (current.status === 'opened') return result('opened', agent === 'codex' ? 'Started a fresh Codex session. Open /hooks there to review and trust Sphinx hooks.' : `Started a fresh ${LABELS[agent]} session. Complete any setup or permissions in that terminal.`);
|
|
127
|
+
if (current.status === 'failed') return result('failed', `${LABELS[agent]} could not start in the terminal. Run ${command} in this repository.`);
|
|
128
|
+
if (Date.now() >= deadline) break;
|
|
129
|
+
await wait(Math.min(pollMs, deadline - Date.now()));
|
|
130
|
+
} while (true);
|
|
131
|
+
return result('failed', `The terminal opened, but ${LABELS[agent]} startup was not confirmed. Check that terminal or run ${command} manually.`);
|
|
132
|
+
} catch { return result('failed', `Could not start ${LABELS[agent]}. Run ${command} in this repository.`); }
|
|
133
|
+
finally {
|
|
134
|
+
await bridge?.close().catch(() => {});
|
|
135
|
+
if (recordFile) await rm(recordFile, { force: true }).catch(() => {});
|
|
136
|
+
if (claimFile) await rm(claimFile, { force: true }).catch(() => {});
|
|
137
|
+
}
|
|
138
|
+
}));
|
|
139
|
+
return { results };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Runs in the opened terminal, never in the installing coding-agent session. */
|
|
143
|
+
export async function runActivationTerminal({ recordFile, token, env = process.env, isTTY = !!process.stdin.isTTY, spawnImpl = spawn, write = text => process.stdout.write(text), startupGraceMs = 300 } = {}) {
|
|
144
|
+
const home = getPaths(env).home;
|
|
145
|
+
if (!TOKEN.test(token) || recordFile !== fileFor(home, token)) throw new Error('Invalid client activation worker.');
|
|
146
|
+
await activationDirectory(home);
|
|
147
|
+
let claim;
|
|
148
|
+
try { claim = await open(`${recordFile}.claim`, 'wx', 0o600); }
|
|
149
|
+
catch (error) { if (error.code === 'EEXIST') return { status: 'ignored', code: 1 }; throw error; }
|
|
150
|
+
try {
|
|
151
|
+
const record = await readJson(recordFile, null);
|
|
152
|
+
if (!record || record.version !== 1 || record.token !== token || record.home !== home || !AGENTS.filter(agent => agent !== 'cursor').includes(record.agent) || record.status !== 'starting' || !path.isAbsolute(record.command || '') || record.command.includes('\0') || !path.isAbsolute(record.cwd || '') || record.cwd.includes('\0')) return { status: 'ignored', code: 1 };
|
|
153
|
+
const status = async value => {
|
|
154
|
+
// Installation can time out or finish while this worker remains alive.
|
|
155
|
+
// It owns cleanup; do not recreate an already removed launch record.
|
|
156
|
+
try {
|
|
157
|
+
if ((await readJson(recordFile, null))?.token === token) await writeJson(recordFile, { ...record, status: value });
|
|
158
|
+
} catch { /* The parent reports unconfirmed startup if acknowledgment fails. */ }
|
|
159
|
+
};
|
|
160
|
+
if (!isTTY) { await status('failed'); return { status: 'failed', code: 1 }; }
|
|
161
|
+
let providerEnv;
|
|
162
|
+
try { providerEnv = await readTerminalEnvironment(record.envSocket, token); }
|
|
163
|
+
catch { await status('failed'); return { status: 'failed', code: 1 }; }
|
|
164
|
+
if (!(await readJson(recordFile, null))) return { status: 'ignored', code: 1 };
|
|
165
|
+
write(activationGuidance(record.agent));
|
|
166
|
+
let opened = false, stateWrite = Promise.resolve();
|
|
167
|
+
const code = await new Promise(resolve => {
|
|
168
|
+
let child, timer;
|
|
169
|
+
try { child = spawnImpl(record.command, [], { cwd: record.cwd, env: { ...freshEnvironment(env), ...providerEnv, SPHINX_HOME: home }, stdio: 'inherit' }); }
|
|
170
|
+
catch { stateWrite = status('failed'); resolve(1); return; }
|
|
171
|
+
child.once('spawn', () => {
|
|
172
|
+
timer = setTimeout(() => { opened = true; stateWrite = stateWrite.then(() => status('opened')); }, startupGraceMs);
|
|
173
|
+
});
|
|
174
|
+
child.once('error', () => { clearTimeout(timer); stateWrite = stateWrite.then(() => status('failed')); resolve(1); });
|
|
175
|
+
child.once('exit', code => {
|
|
176
|
+
clearTimeout(timer);
|
|
177
|
+
if (!opened) stateWrite = stateWrite.then(() => status('failed'));
|
|
178
|
+
resolve(code ?? 1);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
await stateWrite;
|
|
182
|
+
return { status: opened ? 'closed' : 'failed', code };
|
|
183
|
+
} finally {
|
|
184
|
+
await claim.close();
|
|
185
|
+
await rm(`${recordFile}.claim`, { force: true }).catch(() => {});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { lstat, readFile, mkdir, writeFile, rename, rm, stat } from 'node:fs/promises';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
const START = '# >>> Sphinx managed MCP server';
|
|
6
|
+
const END = '# <<< Sphinx managed MCP server';
|
|
7
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
8
|
+
|
|
9
|
+
async function readText(file) {
|
|
10
|
+
try {
|
|
11
|
+
if ((await lstat(file)).isSymbolicLink()) throw new Error(`Refusing to replace symlink: ${file}`);
|
|
12
|
+
return await readFile(file, 'utf8');
|
|
13
|
+
} catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function parseJson(text, file) {
|
|
17
|
+
if (text === null) return {};
|
|
18
|
+
try {
|
|
19
|
+
const value = JSON.parse(text);
|
|
20
|
+
if (!value || Array.isArray(value) || typeof value !== 'object') throw new Error();
|
|
21
|
+
return value;
|
|
22
|
+
} catch { throw new Error(`Cannot read ${file}: invalid JSON object. Fix the file and try again.`); }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function writeText(file, text) {
|
|
26
|
+
if (text === null) { await rm(file, { force: true }); return; }
|
|
27
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
28
|
+
const mode = await stat(file).then(s => s.mode & 0o777).catch(() => 0o600);
|
|
29
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
30
|
+
try { await writeFile(temporary, text, { mode, flag: 'wx' }); await rename(temporary, file); }
|
|
31
|
+
finally { await rm(temporary, { force: true }); }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Read TOML key paths without rewriting TOML or interpreting any setting values.
|
|
35
|
+
function keyPath(text) {
|
|
36
|
+
const keys = []; let position = 0;
|
|
37
|
+
while (position < text.length) {
|
|
38
|
+
while (/\s/.test(text[position] || '')) position++;
|
|
39
|
+
if (text[position] === '"') {
|
|
40
|
+
const match = text.slice(position).match(/^"(?:[^"\\]|\\.)*"/);
|
|
41
|
+
if (!match) return null;
|
|
42
|
+
try { keys.push(JSON.parse(match[0])); } catch { return null; }
|
|
43
|
+
position += match[0].length;
|
|
44
|
+
} else if (text[position] === "'") {
|
|
45
|
+
const end = text.indexOf("'", position + 1); if (end < 0) return null;
|
|
46
|
+
keys.push(text.slice(position + 1, end)); position = end + 1;
|
|
47
|
+
} else {
|
|
48
|
+
const match = text.slice(position).match(/^[\w-]+/); if (!match) return null;
|
|
49
|
+
keys.push(match[0]); position += match[0].length;
|
|
50
|
+
}
|
|
51
|
+
while (/\s/.test(text[position] || '')) position++;
|
|
52
|
+
if (position === text.length) return keys;
|
|
53
|
+
if (text[position++] !== '.') return null;
|
|
54
|
+
}
|
|
55
|
+
return keys;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function tomlLines(text, file) {
|
|
59
|
+
let mode = null, line = '', escaped = false;
|
|
60
|
+
const lines = [];
|
|
61
|
+
for (let i = 0; i < text.length; i++) {
|
|
62
|
+
const char = text[i], triple = text.slice(i, i + 3);
|
|
63
|
+
if (char === '\n') { lines.push(line); line = ''; if (mode === '"' || mode === "'") throw new Error(`Cannot safely update malformed TOML: ${file}`); continue; }
|
|
64
|
+
if (mode === '"""' || mode === "'''") {
|
|
65
|
+
if (triple === mode && !escaped) { mode = null; i += 2; }
|
|
66
|
+
escaped = char === '\\' && !escaped && mode === '"""'; continue;
|
|
67
|
+
}
|
|
68
|
+
if (mode) {
|
|
69
|
+
line += char;
|
|
70
|
+
if (char === mode && !escaped) mode = null;
|
|
71
|
+
escaped = char === '\\' && !escaped && mode === '"'; continue;
|
|
72
|
+
}
|
|
73
|
+
if (char === '#') { while (i + 1 < text.length && text[i + 1] !== '\n') i++; continue; }
|
|
74
|
+
if (triple === '"""' || triple === "'''") { mode = triple; line += '""'; i += 2; continue; }
|
|
75
|
+
if (char === '"' || char === "'") mode = char;
|
|
76
|
+
line += char;
|
|
77
|
+
}
|
|
78
|
+
if (mode) throw new Error(`Cannot safely update malformed TOML: ${file}`);
|
|
79
|
+
lines.push(line); return lines;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function assertNoCodexConflict(text, file) {
|
|
83
|
+
let table = [];
|
|
84
|
+
for (const line of tomlLines(text, file)) {
|
|
85
|
+
const header = line.match(/^\s*\[\[?(.+?)\]\]?\s*$/);
|
|
86
|
+
if (header) {
|
|
87
|
+
table = keyPath(header[1]);
|
|
88
|
+
if (!table) throw new Error(`Cannot safely inspect TOML table names in ${file}. Configure the Sphinx MCP server manually.`);
|
|
89
|
+
if (table[0] === 'mcp_servers' && table[1] === 'sphinx') throw new Error(`An existing sphinx MCP server is defined in ${file}. Rename or remove that entry before installing Sphinx.`);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const assignment = line.match(/^\s*((?:"(?:[^"\\]|\\.)*"|'[^']*'|[\w.-]|\s)+)=/);
|
|
93
|
+
if (!assignment) continue;
|
|
94
|
+
const keys = keyPath(assignment[1].trim());
|
|
95
|
+
if (!keys) continue;
|
|
96
|
+
const full = [...table, ...keys];
|
|
97
|
+
if (full[0] === 'mcp_servers' && (full.length === 1 || full[1] === 'sphinx')) throw new Error(`An existing or inline MCP definition in ${file} conflicts with sphinx. Move it to separate server tables before installing.`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function nativeHookSpecs(name, sessionHandler) {
|
|
102
|
+
const handler = {
|
|
103
|
+
type: 'mcp_tool', server: 'sphinx', tool: 'sphinx_checkpoint',
|
|
104
|
+
input: { cwd: '${cwd}', host: name, hookEvent: '${hook_event_name}', hostSession: '${session_id}' },
|
|
105
|
+
timeout: 1800, statusMessage: 'Sphinx understanding check',
|
|
106
|
+
};
|
|
107
|
+
return [
|
|
108
|
+
// Codex uses the handler's statusMessage as its /hooks display name too.
|
|
109
|
+
{ event: 'SessionStart', group: { hooks: [{ ...sessionHandler, statusMessage: 'Sphinx session setup' }] } },
|
|
110
|
+
{ event: 'PostToolUse', group: { matcher: 'Bash', hooks: [handler] } },
|
|
111
|
+
...(name === 'claude' ? [{ event: 'PostToolUseFailure', group: { matcher: 'Bash', hooks: [handler] } }] : []),
|
|
112
|
+
];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const installedHookSpecs = installed => installed.hooks || [{ event: 'SessionStart', group: { hooks: [installed.handler] } }];
|
|
116
|
+
|
|
117
|
+
export function hasHook(document, spec) {
|
|
118
|
+
return !!document.hooks?.[spec.event]?.some(group => {
|
|
119
|
+
const { hooks, ...metadata } = group;
|
|
120
|
+
const { hooks: expected, ...expectedMetadata } = spec.group;
|
|
121
|
+
return same(metadata, expectedMetadata) && hooks?.some(handler => same(handler, expected[0]));
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function addHook(document, spec) {
|
|
126
|
+
document.hooks ??= {}; document.hooks[spec.event] ??= [];
|
|
127
|
+
if (!hasHook(document, spec)) document.hooks[spec.event].push(spec.group);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function removeHook(document, spec) {
|
|
131
|
+
let removed = false;
|
|
132
|
+
const groups = document.hooks?.[spec.event];
|
|
133
|
+
if (!Array.isArray(groups)) return false;
|
|
134
|
+
document.hooks[spec.event] = groups.flatMap(group => {
|
|
135
|
+
const { hooks, ...metadata } = group;
|
|
136
|
+
const { hooks: expected, ...expectedMetadata } = spec.group;
|
|
137
|
+
if (!same(metadata, expectedMetadata) || !Array.isArray(hooks)) return [group];
|
|
138
|
+
const filtered = hooks.filter(handler => { if (!same(handler, expected[0])) return true; removed = true; return false; });
|
|
139
|
+
return filtered.length ? [{ ...group, hooks: filtered }] : [];
|
|
140
|
+
});
|
|
141
|
+
if (!document.hooks[spec.event].length) delete document.hooks[spec.event];
|
|
142
|
+
if (!Object.keys(document.hooks).length) delete document.hooks;
|
|
143
|
+
return removed;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function prepareMcp({ name, paths, env, cliPath, previous }) {
|
|
147
|
+
const file = name === 'codex' ? path.join(paths.codexHome, 'config.toml')
|
|
148
|
+
: path.join(!env.SPHINX_USER_HOME && env.CLAUDE_CONFIG_DIR ? paths.claudeHome : paths.userHome, '.claude.json');
|
|
149
|
+
if (previous && previous.file !== file) throw new Error(`The ${name} MCP configuration path changed. Uninstall using the previous environment before reinstalling.`);
|
|
150
|
+
const before = await readText(file), existed = previous?.existed ?? before !== null;
|
|
151
|
+
if (name === 'claude') {
|
|
152
|
+
const document = parseJson(before, file);
|
|
153
|
+
if (document.mcpServers !== undefined && (!document.mcpServers || Array.isArray(document.mcpServers) || typeof document.mcpServers !== 'object')) throw new Error(`Invalid mcpServers object in ${file}`);
|
|
154
|
+
const existing = document.mcpServers?.sphinx;
|
|
155
|
+
if (Object.hasOwn(document.mcpServers || {}, 'sphinx') && (!previous || !same(existing, previous.entry))) throw new Error(`Refusing to replace an existing or edited sphinx MCP server in ${file}. Remove or rename that entry first.`);
|
|
156
|
+
const entry = { type: 'stdio', command: process.execPath, args: [cliPath, 'mcp'], env: { SPHINX_HOME: paths.home }, timeout: 1800000 };
|
|
157
|
+
document.mcpServers ??= {}; document.mcpServers.sphinx = entry;
|
|
158
|
+
return { before, after: `${JSON.stringify(document, null, 2)}\n`, record: { kind: 'json', file, existed, entry } };
|
|
159
|
+
}
|
|
160
|
+
let base = before || '';
|
|
161
|
+
if (previous) {
|
|
162
|
+
if (base.includes(previous.block)) base = base.replace(previous.block, '');
|
|
163
|
+
else if (before !== null) throw new Error(`Refusing to replace an edited Sphinx MCP block in ${file}. Uninstall first to preserve your edits.`);
|
|
164
|
+
}
|
|
165
|
+
if (base.includes(START) || base.includes(END)) throw new Error(`Untracked Sphinx MCP block in ${file}. Remove or rename it before installing.`);
|
|
166
|
+
assertNoCodexConflict(base, file);
|
|
167
|
+
const separator = base ? base.endsWith('\n') ? '\n' : '\n\n' : '';
|
|
168
|
+
const block = `${separator}${START}\n[mcp_servers.sphinx]\ncommand = ${JSON.stringify(process.execPath)}\nargs = ${JSON.stringify([cliPath, 'mcp'])}\ntool_timeout_sec = 1800\nenv = { SPHINX_HOME = ${JSON.stringify(paths.home)} }\n${END}\n`;
|
|
169
|
+
return { before, after: base + block, record: { kind: 'toml', file, existed, block } };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function commitMcp(plan) {
|
|
173
|
+
if (await readText(plan.record.file) !== plan.before) throw new Error(`Settings changed during installation: ${plan.record.file}. Retry installation.`);
|
|
174
|
+
await writeText(plan.record.file, plan.after);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function rollbackMcp(plan) {
|
|
178
|
+
if (await readText(plan.record.file) === plan.after) await writeText(plan.record.file, plan.before);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function removeMcp(record) {
|
|
182
|
+
if (!record) return { removed: false, missing: true };
|
|
183
|
+
const text = await readText(record.file);
|
|
184
|
+
if (text === null) return { removed: false, missing: true };
|
|
185
|
+
if (record.kind === 'toml') {
|
|
186
|
+
if (!text.includes(record.block)) return { removed: false, edited: true };
|
|
187
|
+
const after = text.replace(record.block, '');
|
|
188
|
+
await writeText(record.file, !record.existed && !after.trim() ? null : after);
|
|
189
|
+
} else {
|
|
190
|
+
const document = parseJson(text, record.file);
|
|
191
|
+
if (!same(document.mcpServers?.sphinx, record.entry)) return { removed: false, edited: true };
|
|
192
|
+
delete document.mcpServers.sphinx;
|
|
193
|
+
if (!Object.keys(document.mcpServers).length) delete document.mcpServers;
|
|
194
|
+
await writeText(record.file, !record.existed && !Object.keys(document).length ? null : `${JSON.stringify(document, null, 2)}\n`);
|
|
195
|
+
}
|
|
196
|
+
return { removed: true };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function inspectMcp(record) {
|
|
200
|
+
if (!record) return false;
|
|
201
|
+
try {
|
|
202
|
+
const text = await readText(record.file);
|
|
203
|
+
if (text === null) return false;
|
|
204
|
+
return record.kind === 'toml' ? text.includes(record.block) : same(parseJson(text, record.file).mcpServers?.sphinx, record.entry);
|
|
205
|
+
} catch { return false; }
|
|
206
|
+
}
|
package/src/agent.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { open } from 'node:fs/promises';
|
|
3
|
+
|
|
4
|
+
/** Session setup only. Git and native lifecycle hooks control the actual checkpoint. */
|
|
5
|
+
export async function agentHook({ agent, event, input = '', env = process.env }) {
|
|
6
|
+
if (env.SPHINX_EVALUATOR === '1') return { output: null, exitCode: 0 };
|
|
7
|
+
if (!['codex', 'claude'].includes(agent)) throw new Error(`Unsupported agent: ${agent}`);
|
|
8
|
+
let payload;
|
|
9
|
+
try {
|
|
10
|
+
payload = typeof input === 'object' && !Buffer.isBuffer(input) ? input : JSON.parse(String(input || '{}'));
|
|
11
|
+
} catch {
|
|
12
|
+
throw new Error('Agent hook input must be valid JSON.');
|
|
13
|
+
}
|
|
14
|
+
const name = payload?.hook_event_name || event;
|
|
15
|
+
if (name !== 'SessionStart') return { output: null, exitCode: 0 };
|
|
16
|
+
if (agent === 'claude' && env.CLAUDE_ENV_FILE && /^[A-Za-z0-9_-]{1,200}$/.test(payload?.session_id || '')) {
|
|
17
|
+
// Claude's documented SessionStart environment channel tags later Bash calls,
|
|
18
|
+
// including `git -C` and `cd`, without reading the command or its output.
|
|
19
|
+
const file = await open(env.CLAUDE_ENV_FILE, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | (constants.O_NOFOLLOW || 0), 0o600);
|
|
20
|
+
try { await file.writeFile(`\nexport SPHINX_CLAUDE_SESSION='${payload.session_id}'\n`); }
|
|
21
|
+
finally { await file.close(); }
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
exitCode: 0,
|
|
25
|
+
output: {
|
|
26
|
+
hookSpecificOutput: {
|
|
27
|
+
hookEventName: 'SessionStart',
|
|
28
|
+
additionalContext: [
|
|
29
|
+
'Sphinx is installed to help the human retain understanding of agent-assisted changes.',
|
|
30
|
+
'Repository branch and event policy lives in .sphinxconfig (with .sphinx.json as a legacy fallback). Use `sphinx status --json` to inspect effective policy and pending assessments; do not change policy to avoid a checkpoint.',
|
|
31
|
+
'Git deterministically requires a checkpoint when repository policy matches a push or commit. For blocking checkpoints it serves a local browser questionnaire and keeps the original Git operation waiting. On macOS the page opens in the background without activating or minimizing the coding window; other environments print the local URL. After the human passes and Git verifies the exact change and policy, that same operation continues automatically. Do not start a duplicate Git operation while it waits.',
|
|
32
|
+
'Allow enough shell execution time for the human questionnaire. Sphinx waits up to 25 minutes, but the host may cancel the shell sooner. Keep the original process running using the host\'s supported yielding or background execution; do not terminate or duplicate it while the human answers.',
|
|
33
|
+
'If a local browser questionnaire is unavailable, the installed PostToolUse / PostToolUseFailure MCP hook offers a native questionnaire after Git exits. These fallback forms do not resume an exited Git process; you do not initiate or conduct the interview.',
|
|
34
|
+
'Sphinx collects the human\'s answers directly in its local browser page, explicit terminal check, or fallback native forms and grades them using an independent evaluator. Only the outcome is returned to you. Do not relay questions through chat, call sphinx answer/explain/dispute for the human, or open its local URL or automate the questionnaire UI.',
|
|
35
|
+
'Never answer on the human\'s behalf, fabricate a response, read private Sphinx session files or answer keys, or bypass the Git hook to finish the task.',
|
|
36
|
+
'Respect .sphinxignore and configured exclusions: do not read excluded files to supplement an assessment or send them to the evaluator.',
|
|
37
|
+
'The form offers learning, dispute, and pause actions. The human chooses them directly. A paused or cancelled check remains incomplete; do not repeatedly retry the Git operation to force another prompt.',
|
|
38
|
+
'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.',
|
|
39
|
+
'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.',
|
|
40
|
+
'The assessment is open-book. Helping the human inspect code is encouraged. The coding agent must not grade the response itself.',
|
|
41
|
+
].join('\n'),
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|