sphinx-understand 0.0.0-stage → 0.1.0
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 +353 -2
- package/bin/sphinx.js +9 -0
- package/docs/architecture.md +196 -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 +278 -0
- package/src/browser-page.js +172 -0
- package/src/check-recovery.js +92 -0
- package/src/checkbox.js +148 -0
- package/src/cli.js +526 -0
- package/src/config.js +196 -0
- package/src/cursor-hook.js +18 -0
- package/src/evaluator.js +475 -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 +121 -0
- package/src/ignore.js +94 -0
- package/src/install.js +446 -0
- package/src/lifecycle-integrations.js +259 -0
- package/src/mcp.js +351 -0
- package/src/model-presets.js +79 -0
- package/src/native-check.js +166 -0
- package/src/openrouter.js +239 -0
- package/src/overrides.js +57 -0
- package/src/pi-integration.js +179 -0
- package/src/pi-runtime.js +128 -0
- package/src/preparation.js +238 -0
- package/src/presentations.js +74 -0
- package/src/prompts.js +157 -0
- package/src/repo-config.js +72 -0
- package/src/repo-context.js +43 -0
- package/src/review-workspace.js +331 -0
- package/src/reviewer-limits.js +10 -0
- package/src/reviewer-response.js +22 -0
- package/src/reviewer.js +522 -0
- package/src/routing.js +38 -0
- package/src/sessions.js +368 -0
- package/src/terminal-launch.js +288 -0
- package/src/terminal-worker.js +11 -0
- package/src/terminal.js +119 -0
- package/src/usage.js +31 -0
package/src/config.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { constants } from 'node:fs';
|
|
5
|
+
import { mkdir, open, writeFile, rename, chmod, lstat, unlink } from 'node:fs/promises';
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_CONFIG = {
|
|
8
|
+
version: 1, enabled: true, provider: 'openrouter', model: 'openrouter/free',
|
|
9
|
+
level: 'standard', mode: 'block', events: ['pre-push'],
|
|
10
|
+
branches: ['main', 'master'], remotes: ['*'], exclude: [],
|
|
11
|
+
maxContextBytes: 100000, timeoutSeconds: 180, receiptHours: 24,
|
|
12
|
+
maxModelCalls: null, maxOutputTokens: 4096, allowPaid: false,
|
|
13
|
+
agentMaxTurns: null, toolResultBytes: 16000,
|
|
14
|
+
backgroundPreparation: true, preparationDebounceMs: 1500,
|
|
15
|
+
models: {
|
|
16
|
+
small: { provider: 'openrouter', model: 'openrouter/free' },
|
|
17
|
+
medium: { provider: 'openrouter', model: 'openrouter/free' },
|
|
18
|
+
large: { provider: 'openrouter', model: 'openrouter/free' },
|
|
19
|
+
},
|
|
20
|
+
routing: {
|
|
21
|
+
smallMaxLines: 80, smallMaxFiles: 3, largeMinLines: 400, largeMinFiles: 12,
|
|
22
|
+
riskPatterns: ['auth', 'permission', 'migration', 'billing', 'payment', 'crypto', 'security', 'terraform'],
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function getPaths(env = process.env) {
|
|
27
|
+
const userHome = path.resolve(env.SPHINX_USER_HOME || homedir());
|
|
28
|
+
const home = path.resolve(env.SPHINX_HOME || path.join(userHome, '.config', 'sphinx'));
|
|
29
|
+
return {
|
|
30
|
+
home, userHome, config: path.join(home, 'config.json'), sessions: path.join(home, 'sessions'),
|
|
31
|
+
install: path.join(home, 'install.json'), credentials: path.join(home, 'credentials.json'),
|
|
32
|
+
codexHome: path.resolve((!env.SPHINX_USER_HOME && env.CODEX_HOME) || path.join(userHome, '.codex')),
|
|
33
|
+
claudeHome: path.resolve((!env.SPHINX_USER_HOME && env.CLAUDE_CONFIG_DIR) || path.join(userHome, '.claude')),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function readJson(file, fallback) {
|
|
38
|
+
let handle;
|
|
39
|
+
try {
|
|
40
|
+
handle=await open(file, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
|
|
41
|
+
const info=await handle.stat();
|
|
42
|
+
if(!info.isFile() || info.size>4*1024*1024)throw new Error('not a regular JSON file or exceeds 4 MiB');
|
|
43
|
+
const raw=await handle.readFile('utf8');
|
|
44
|
+
try { return JSON.parse(raw); } catch { throw new Error('invalid JSON'); }
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
if (e.code === 'ENOENT' && arguments.length > 1) return fallback;
|
|
48
|
+
throw new Error(`Cannot read ${file}: ${e.code || e.message}`);
|
|
49
|
+
} finally { await handle?.close(); }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function writeJson(file, value, { signal } = {}) {
|
|
53
|
+
signal?.throwIfAborted();
|
|
54
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
55
|
+
const tmp = `${file}.${randomUUID()}.tmp`;
|
|
56
|
+
try {
|
|
57
|
+
await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, flag: 'wx', signal });
|
|
58
|
+
signal?.throwIfAborted();
|
|
59
|
+
await rename(tmp, file);
|
|
60
|
+
await chmod(file, 0o600);
|
|
61
|
+
} finally {
|
|
62
|
+
await unlink(tmp).catch(error => { if (error.code !== 'ENOENT') throw error; });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function validateConfig(config) {
|
|
67
|
+
const fail = (message) => { throw new Error(`Invalid Sphinx configuration: ${message}`); };
|
|
68
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) fail('expected an object');
|
|
69
|
+
const allowed = new Set(Object.keys(DEFAULT_CONFIG));
|
|
70
|
+
for (const key of Object.keys(config)) if (!allowed.has(key)) fail(`unknown setting ${key}`);
|
|
71
|
+
if (config.version !== 1) fail('version must be 1');
|
|
72
|
+
for (const key of ['enabled', 'allowPaid', 'backgroundPreparation']) if (typeof config[key] !== 'boolean') fail(`${key} must be boolean`);
|
|
73
|
+
for (const [key, values] of Object.entries({ provider: ['openrouter','codex','claude','auto'], level: ['relaxed','standard','thorough'], mode: ['block'] })) {
|
|
74
|
+
if (key === 'mode' && config.mode === 'advisory') fail('mode "advisory" was removed. Remove mode from your configuration; use sphinx yolo to bypass checks when urgent');
|
|
75
|
+
if (!values.includes(config[key])) fail(`${key} must be ${values.join(', ')}`);
|
|
76
|
+
}
|
|
77
|
+
if (config.model !== null && (typeof config.model !== 'string' || !config.model.trim())) fail('model must be a nonempty string or null');
|
|
78
|
+
for (const key of ['events','branches','remotes','exclude']) {
|
|
79
|
+
if (!Array.isArray(config[key]) || config[key].some(x => typeof x !== 'string' || !x.length)) fail(`${key} must be an array of nonempty strings`);
|
|
80
|
+
}
|
|
81
|
+
if (config.events.some(x => !['pre-push','pre-commit'].includes(x))) fail('events must use pre-push or pre-commit');
|
|
82
|
+
if (config.maxModelCalls !== null && (!Number.isInteger(config.maxModelCalls) || config.maxModelCalls < 1 || config.maxModelCalls > 100)) fail('maxModelCalls must be null (unlimited) or an integer between 1 and 100');
|
|
83
|
+
if (config.agentMaxTurns !== null && (!Number.isInteger(config.agentMaxTurns) || config.agentMaxTurns < 2 || config.agentMaxTurns > 50)) fail('agentMaxTurns must be null (unlimited) or an integer between 2 and 50');
|
|
84
|
+
for (const [key, min, max] of [['maxContextBytes',1000,1000000],['timeoutSeconds',1,600],['receiptHours',1,720],['maxOutputTokens',512,16384],['toolResultBytes',1000,64000],['preparationDebounceMs',0,60000]]) {
|
|
85
|
+
if (!Number.isInteger(config[key]) || config[key] < min || config[key] > max) fail(`${key} must be an integer between ${min} and ${max}`);
|
|
86
|
+
}
|
|
87
|
+
if (!config.models || typeof config.models !== 'object') fail('models must define small, medium, and large routes');
|
|
88
|
+
for (const tier of ['small','medium','large']) {
|
|
89
|
+
const route = config.models[tier];
|
|
90
|
+
if (!route || !['openrouter','codex','claude','auto'].includes(route.provider)) fail(`models.${tier}.provider is invalid`);
|
|
91
|
+
if (route.model !== null && (typeof route.model !== 'string' || !route.model.trim())) fail(`models.${tier}.model must be a string or null`);
|
|
92
|
+
if (Object.keys(route).some(k => !['provider','model'].includes(k))) fail(`unexpected field in models.${tier}`);
|
|
93
|
+
}
|
|
94
|
+
if (Object.keys(config.models).some(k => !['small','medium','large'].includes(k))) fail('unknown model tier');
|
|
95
|
+
if (!config.routing || typeof config.routing !== 'object') fail('routing is required');
|
|
96
|
+
for (const key of Object.keys(config.routing)) if (!Object.hasOwn(DEFAULT_CONFIG.routing,key)) fail(`unknown routing setting ${key}`);
|
|
97
|
+
for (const k of ['smallMaxLines','smallMaxFiles','largeMinLines','largeMinFiles']) {
|
|
98
|
+
if (!Number.isInteger(config.routing[k]) || config.routing[k] < 1) fail(`routing.${k} must be a positive integer`);
|
|
99
|
+
}
|
|
100
|
+
if (config.routing.largeMinLines <= config.routing.smallMaxLines || config.routing.largeMinFiles <= config.routing.smallMaxFiles) fail('large routing thresholds must exceed small thresholds');
|
|
101
|
+
if (!Array.isArray(config.routing.riskPatterns) || config.routing.riskPatterns.some(s => typeof s !== 'string' || !s)) fail('routing.riskPatterns must be strings');
|
|
102
|
+
return config;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function merged(base, overrides) {
|
|
106
|
+
return { ...base, ...overrides, models: { ...base.models, ...overrides?.models }, routing: { ...base.routing, ...overrides?.routing } };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const REPOSITORY_CONFIG_KEYS = Object.freeze(['version','enabled','level','mode','events','branches','remotes','exclude','backgroundPreparation']);
|
|
110
|
+
|
|
111
|
+
export function validateRepositoryConfig(local, { env = process.env, source = '.sphinxconfig' } = {}) {
|
|
112
|
+
if (!local || typeof local !== 'object' || Array.isArray(local)
|
|
113
|
+
|| ![Object.prototype, null].includes(Object.getPrototypeOf(local))) {
|
|
114
|
+
throw new Error(`Repository ${source} must be a JSON object.`);
|
|
115
|
+
}
|
|
116
|
+
for (const key of Object.keys(local)) {
|
|
117
|
+
if (!REPOSITORY_CONFIG_KEYS.includes(key)) {
|
|
118
|
+
throw new Error(`Repository ${source} cannot set ${key}; configure model routes and budgets in ${getPaths(env).config}.`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
validateConfig({ ...structuredClone(DEFAULT_CONFIG), ...local });
|
|
122
|
+
return local;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function fileInfo(file) {
|
|
126
|
+
try { return await lstat(file); }
|
|
127
|
+
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The canonical policy wins completely; shadowed legacy data is never read or merged. */
|
|
131
|
+
export async function repositoryConfigInfo(repoRoot) {
|
|
132
|
+
const canonical = path.resolve(repoRoot, '.sphinxconfig');
|
|
133
|
+
const legacy = path.resolve(repoRoot, '.sphinx.json');
|
|
134
|
+
const [canonicalInfo, legacyInfo] = await Promise.all([fileInfo(canonical), fileInfo(legacy)]);
|
|
135
|
+
const selected = canonicalInfo ? canonical : legacyInfo ? legacy : canonical;
|
|
136
|
+
const info = canonicalInfo || legacyInfo;
|
|
137
|
+
if (info && !info.isFile()) throw new Error(`Repository configuration must be a regular file, not a symlink or directory: ${selected}`);
|
|
138
|
+
return {
|
|
139
|
+
path: selected, exists: Boolean(info), legacy: !canonicalInfo && Boolean(legacyInfo),
|
|
140
|
+
...(canonicalInfo && legacyInfo ? { shadowed: legacy } : {}),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function loadConfig(repoRoot, env = process.env) {
|
|
145
|
+
const global = await readJson(getPaths(env).config, {});
|
|
146
|
+
if (!global || typeof global !== 'object' || Array.isArray(global)) throw new Error('Global Sphinx configuration must be a JSON object.');
|
|
147
|
+
for (const key of ['models','routing']) {
|
|
148
|
+
if (Object.hasOwn(global,key) && (!global[key] || typeof global[key]!=='object' || Array.isArray(global[key]))) throw new Error(`Global ${key} must be a JSON object.`);
|
|
149
|
+
}
|
|
150
|
+
let config = merged(structuredClone(DEFAULT_CONFIG), global);
|
|
151
|
+
// A legacy/single-provider configuration applies to all tiers unless routes are explicit.
|
|
152
|
+
if ((Object.hasOwn(global,'provider') || Object.hasOwn(global,'model')) && !global.models) {
|
|
153
|
+
config.models = Object.fromEntries(['small','medium','large'].map(t => [t,{provider:config.provider,model:config.model}]));
|
|
154
|
+
}
|
|
155
|
+
validateConfig(config);
|
|
156
|
+
if (repoRoot) {
|
|
157
|
+
const source = await repositoryConfigInfo(repoRoot);
|
|
158
|
+
const local = source.exists ? await readJson(source.path) : {};
|
|
159
|
+
validateRepositoryConfig(local, { env, source: path.basename(source.path) });
|
|
160
|
+
config = merged(config, {...local,exclude:[...new Set([...config.exclude,...(local.exclude||[])])],backgroundPreparation:config.backgroundPreparation && local.backgroundPreparation !== false});
|
|
161
|
+
}
|
|
162
|
+
return validateConfig(config);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function saveConfig(config, env = process.env) {
|
|
166
|
+
validateConfig(config);
|
|
167
|
+
await writeJson(getPaths(env).config, config);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function stableStringify(value) {
|
|
171
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
|
|
172
|
+
if (value && typeof value === 'object') return `{${Object.keys(value).sort().map(k => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`;
|
|
173
|
+
return JSON.stringify(value);
|
|
174
|
+
}
|
|
175
|
+
export function policyHash(config) {
|
|
176
|
+
// The call ceiling controls spending, not what the human must understand.
|
|
177
|
+
// Normalize it independently of DEFAULT_CONFIG so allowance edits preserve
|
|
178
|
+
// checkpoint identity within the same assessment contract.
|
|
179
|
+
// Version the contract when assessment semantics change: older questions and
|
|
180
|
+
// receipts must not stand in for an assessment under the new requirements.
|
|
181
|
+
return createHash('sha256').update(stableStringify({ ...config, maxModelCalls: 10, agentMaxTurns: 8, assessmentContract: 3 })).digest('hex');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function saveOpenRouterKey(key, env = process.env) {
|
|
185
|
+
if (typeof key !== 'string' || !key.trim() || /\s/.test(key.trim())) throw new Error('Enter a valid OpenRouter API key.');
|
|
186
|
+
await writeJson(getPaths(env).credentials, { openrouter: key.trim() });
|
|
187
|
+
}
|
|
188
|
+
export async function openRouterKey(env = process.env) {
|
|
189
|
+
if (env.OPENROUTER_API_KEY) return env.OPENROUTER_API_KEY;
|
|
190
|
+
try {
|
|
191
|
+
const credentials = await readJson(getPaths(env).credentials, {});
|
|
192
|
+
const key = credentials?.openrouter;
|
|
193
|
+
if (key !== undefined && (typeof key !== 'string' || !key.trim() || /\s/.test(key))) throw new Error('Invalid key');
|
|
194
|
+
return key || null;
|
|
195
|
+
} catch { throw new Error('Could not read Sphinx credentials. Run sphinx auth to save your key again.'); }
|
|
196
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { cursorAgentHook } from './lifecycle-integrations.js';
|
|
2
|
+
|
|
3
|
+
// Cursor's command hook protocol. Never pass shell commands or their output to
|
|
4
|
+
// an evaluator; the adapter extracts only strict checkpoint IDs from Git's
|
|
5
|
+
// existing notice when Cursor has not tagged a cross-directory shell operation.
|
|
6
|
+
let input = '', bytes = 0;
|
|
7
|
+
try {
|
|
8
|
+
for await (const chunk of process.stdin) {
|
|
9
|
+
bytes += Buffer.byteLength(chunk);
|
|
10
|
+
if (bytes > 1024 * 1024) throw new Error('Hook input exceeds limit.');
|
|
11
|
+
input += chunk;
|
|
12
|
+
}
|
|
13
|
+
const payload = JSON.parse(input || '{}');
|
|
14
|
+
const { hook_event_name, session_id, conversation_id, cwd, tool_name, tool_output, error_message } = payload;
|
|
15
|
+
process.stdout.write(`${JSON.stringify(await cursorAgentHook({ input: { hook_event_name, session_id, conversation_id, cwd, tool_name, tool_output, error_message } }))}\n`);
|
|
16
|
+
} catch {
|
|
17
|
+
process.stdout.write(`${JSON.stringify({ additional_context: 'Sphinx could not inspect this hook event. The human can run sphinx doctor and sphinx check in their own terminal.' })}\n`);
|
|
18
|
+
}
|