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.
Files changed (54) hide show
  1. package/.sphinxconfig.example +10 -0
  2. package/.sphinxignore.example +22 -0
  3. package/CONTRIBUTING.md +19 -0
  4. package/LICENSE +21 -0
  5. package/README.md +353 -2
  6. package/bin/sphinx.js +9 -0
  7. package/docs/architecture.md +196 -0
  8. package/eval/README.md +38 -0
  9. package/eval/RESULTS.md +205 -0
  10. package/eval/fixtures/known-false-negatives.json +115 -0
  11. package/examples/browser-demo.mjs +28 -0
  12. package/examples/config.json +59 -0
  13. package/package.json +50 -4
  14. package/src/activation-worker.js +10 -0
  15. package/src/activation.js +187 -0
  16. package/src/agent-settings.js +206 -0
  17. package/src/agent.js +45 -0
  18. package/src/browser-interview.js +278 -0
  19. package/src/browser-page.js +172 -0
  20. package/src/check-recovery.js +92 -0
  21. package/src/checkbox.js +148 -0
  22. package/src/cli.js +526 -0
  23. package/src/config.js +196 -0
  24. package/src/cursor-hook.js +18 -0
  25. package/src/evaluator.js +475 -0
  26. package/src/git-gate.js +116 -0
  27. package/src/git-interview.js +51 -0
  28. package/src/git.js +252 -0
  29. package/src/help.js +121 -0
  30. package/src/ignore.js +94 -0
  31. package/src/install.js +446 -0
  32. package/src/lifecycle-integrations.js +259 -0
  33. package/src/mcp.js +351 -0
  34. package/src/model-presets.js +79 -0
  35. package/src/native-check.js +166 -0
  36. package/src/openrouter.js +239 -0
  37. package/src/overrides.js +57 -0
  38. package/src/pi-integration.js +179 -0
  39. package/src/pi-runtime.js +128 -0
  40. package/src/preparation.js +238 -0
  41. package/src/presentations.js +74 -0
  42. package/src/prompts.js +157 -0
  43. package/src/repo-config.js +72 -0
  44. package/src/repo-context.js +43 -0
  45. package/src/review-workspace.js +331 -0
  46. package/src/reviewer-limits.js +10 -0
  47. package/src/reviewer-response.js +22 -0
  48. package/src/reviewer.js +522 -0
  49. package/src/routing.js +38 -0
  50. package/src/sessions.js +368 -0
  51. package/src/terminal-launch.js +288 -0
  52. package/src/terminal-worker.js +11 -0
  53. package/src/terminal.js +119 -0
  54. package/src/usage.js +31 -0
package/src/cli.js ADDED
@@ -0,0 +1,526 @@
1
+ import path from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { lstat } from 'node:fs/promises';
4
+ import { parseArgs, promisify } from 'node:util';
5
+ import { execFile } from 'node:child_process';
6
+ import { createInterface } from 'node:readline/promises';
7
+ import { Writable } from 'node:stream';
8
+ import { getPaths, loadConfig, saveConfig, saveOpenRouterKey, openRouterKey, validateConfig, repositoryConfigInfo, policyHash } from './config.js';
9
+ import { initializeRepository } from './repo-config.js';
10
+ import * as git from './git.js';
11
+ import { install, uninstall, doctor, dispatchHook, installedAgents, detectAgents } from './install.js';
12
+ import { activateIntegrations } from './activation.js';
13
+ import { checkbox } from './checkbox.js';
14
+ import { agentHook } from './agent.js';
15
+ import { createSessionStore } from './sessions.js';
16
+ import { prepareInteractiveCheck } from './check-recovery.js';
17
+ import { parseRoute } from './routing.js';
18
+ import { createTerminal, withProgress } from './terminal.js';
19
+ import { HELP, printHelp } from './help.js';
20
+ import { askInstall, askPolicy, createPrompts, questionCapsText, reviewCapsText, routeText, splitList } from './prompts.js';
21
+ import { runGitGate } from './git-gate.js';
22
+ import { loadProjectContext } from './repo-context.js';
23
+ import { REVIEWER_PRESETS, presetOptions } from './model-presets.js';
24
+ import { modelUsageText, modelLimitText } from './usage.js';
25
+ import { toggleYolo, yoloStatus, recentOverrides } from './overrides.js';
26
+
27
+ const CLI_PATH = fileURLToPath(new URL('../bin/sphinx.js', import.meta.url));
28
+ const POLICY_FLAGS = ['branches', 'events', 'level', 'remotes'];
29
+ const MODEL_FLAGS = ['preset', 'provider', 'model', 'small', 'medium', 'large', 'allow-paid', 'max-model-calls', 'max-output-tokens'];
30
+ const OPTIONS = {
31
+ install: ['yes', 'repo', 'agents', ...MODEL_FLAGS, 'launch', 'no-launch', ...POLICY_FLAGS],
32
+ activate: ['agents', 'json'],
33
+ init: ['yes', 'json', 'context', ...POLICY_FLAGS], check: ['json', 'id', 'remote', 'branch', 'staged', 'retry-review'],
34
+ prepare: ['json', 'worker', 'retry'],
35
+ answer: ['json', 'text'], explain: ['json'], dispute: ['json', 'text'], pending: ['json'],
36
+ status: ['json'], config: ['json', ...MODEL_FLAGS], doctor: ['json'], auth: [], uninstall: ['repo'],
37
+ mcp: [], yolo: ['json', 'reason'],
38
+ };
39
+ const json = value => console.log(JSON.stringify(value, null, 2));
40
+ const ui = () => createTerminal();
41
+ const cancelled = () => Object.assign(new Error('Cancelled. No further changes were made.'), { code: 'SPHINX_CANCELLED', exitCode: 130 });
42
+
43
+ async function readStdin() {
44
+ let value = '';
45
+ for await (const chunk of process.stdin) { value += chunk; if (value.length > 2e6) throw new Error('Hook input too large.'); }
46
+ return value;
47
+ }
48
+
49
+ async function withReadline(operation, { output = process.stdout, terminal = !!(process.stdin.isTTY && output.isTTY && process.env.TERM !== 'dumb'), controller } = {}) {
50
+ let rl, closed = false;
51
+ const release = () => { const previous = rl; rl = undefined; previous?.close(); };
52
+ const interrupt = () => { controller?.abort(cancelled()); rl?.close(); };
53
+ const acquire = () => {
54
+ if (closed || process.stdin.readableEnded || process.stdin.destroyed) throw cancelled();
55
+ if (!rl) {
56
+ const current = createInterface({ input: process.stdin, output, terminal });
57
+ rl = current;
58
+ current.on('close', () => { if (rl === current) { closed = true; rl = undefined; controller?.abort(cancelled()); } });
59
+ current.on('SIGINT', () => current.close());
60
+ }
61
+ return rl;
62
+ };
63
+ const ask = prompt => {
64
+ const current = acquire();
65
+ return new Promise((resolve, reject) => {
66
+ const onClose = () => reject(cancelled());
67
+ current.once('close', onClose);
68
+ current.question(prompt).then(answer => { current.off('close', onClose); resolve(answer); }, error => { current.off('close', onClose); reject(closed ? cancelled() : error); });
69
+ });
70
+ };
71
+ ask.checkbox = options => {
72
+ if (closed || process.stdin.readableEnded || process.stdin.destroyed) throw cancelled();
73
+ release(); return checkbox({ ...options, ui: ui(), output });
74
+ };
75
+ const signals = controller ? ['SIGINT', 'SIGTERM', 'SIGHUP'] : [];
76
+ for (const event of signals) process.on(event, interrupt);
77
+ try {
78
+ // During an interview, Ctrl+C/EOF must cancel investigation and grading as
79
+ // well as a pending question. Other prompt flows retain lazy acquisition.
80
+ if (controller) acquire();
81
+ return await operation(ask);
82
+ } finally {
83
+ for (const event of signals) process.off(event, interrupt);
84
+ release();
85
+ }
86
+ }
87
+
88
+ async function repoConfig(cwd = process.cwd()) {
89
+ const repo = await git.repository(cwd);
90
+ return { repo, config: await loadConfig(repo.root) };
91
+ }
92
+ async function validateCheckpoint(snapshot, config) {
93
+ if (policyHash(await loadConfig(snapshot.repoRoot)) !== policyHash(config) || !(await git.revalidateSnapshot(snapshot, config))) throw new Error('The code, project context, or policy changed since this checkpoint. Retry the Git operation or run sphinx check with an explicit target.');
94
+ }
95
+ const store = createSessionStore({ validate: validateCheckpoint });
96
+
97
+ async function gate({ event, args, input, cwd }) {
98
+ const controller = new AbortController();
99
+ const cancel = () => controller.abort(Object.assign(new Error('Git operation cancelled. Progress is saved.'), { exitCode: 130 }));
100
+ const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'];
101
+ for (const event of signals) process.on(event, cancel);
102
+ try {
103
+ return await runGitGate({ event, args, input, cwd, signal: controller.signal });
104
+ } finally {
105
+ for (const event of signals) process.off(event, cancel);
106
+ }
107
+ }
108
+
109
+ function parseModelCalls(value) {
110
+ if (['unlimited', 'none'].includes(String(value).toLowerCase())) return null;
111
+ if (!/^(?:[1-9]|[1-9][0-9]|100)$/u.test(String(value))) throw new Error('--max-model-calls must be a whole number from 1 to 100, or unlimited (none is an alias).');
112
+ return Number(value);
113
+ }
114
+
115
+ function parseOutputTokens(value) {
116
+ if (!/^\d+$/u.test(String(value)) || Number(value) < 512 || Number(value) > 16384) throw new Error('--max-output-tokens must be a whole number from 512 to 16384.');
117
+ return Number(value);
118
+ }
119
+
120
+ export function applyInstallOptions(current, values) {
121
+ const config = structuredClone(current);
122
+ if (values.preset !== undefined) {
123
+ if (values.provider !== undefined || values.model !== undefined) throw new Error('--preset cannot be combined with --provider or --model. Use --small, --medium, or --large to override a preset tier.');
124
+ // A preset describes paid routes, but never grants spending permission.
125
+ // Guided setup must return an explicit --allow-paid after confirmation.
126
+ const selected = presetOptions(values.preset);
127
+ for (const tier of ['small', 'medium', 'large']) config.models[tier] = parseRoute(selected[tier]);
128
+ config.provider = config.models.small.provider;
129
+ config.model = config.models.small.model;
130
+ if (selected['max-output-tokens'] !== undefined) config.maxOutputTokens = parseOutputTokens(selected['max-output-tokens']);
131
+ }
132
+ if (values['max-model-calls'] !== undefined) config.maxModelCalls = parseModelCalls(values['max-model-calls']);
133
+ if (values['max-output-tokens'] !== undefined) config.maxOutputTokens = parseOutputTokens(values['max-output-tokens']);
134
+ for (const key of ['provider', 'model', 'level', 'mode']) if (values[key] !== undefined) config[key] = values[key];
135
+ for (const key of ['branches', 'events', 'remotes']) if (values[key] !== undefined) config[key] = splitList(values[key]);
136
+ if (values.provider !== undefined || values.model !== undefined) {
137
+ if (values.provider !== undefined && values.model === undefined) {
138
+ const economical = REVIEWER_PRESETS.find(preset => preset.id === `${values.provider}-economy`);
139
+ config.model = economical ? economical.models.small.model : values.provider === 'openrouter' ? 'openrouter/free' : null;
140
+ }
141
+ for (const tier of ['small', 'medium', 'large']) config.models[tier] = { provider: config.provider, model: config.model };
142
+ }
143
+ for (const tier of ['small', 'medium', 'large']) if (values[tier] !== undefined) config.models[tier] = parseRoute(values[tier]);
144
+ if (values['allow-paid']) config.allowPaid = true;
145
+ validateConfig(config);
146
+ if (values.agents !== undefined && !['auto', 'none'].includes(values.agents)) {
147
+ const selected = values.agents.split(',');
148
+ if (!selected.length || new Set(selected).size !== selected.length || selected.some(name => !['codex', 'claude', 'pi', 'cursor', 'opencode'].includes(name))) throw new Error('--agents must be auto, none, or a comma-separated selection of codex, claude, pi, cursor, opencode.');
149
+ }
150
+ for (const route of Object.values(config.models)) {
151
+ if (route.provider === 'openrouter' && route.model && route.model !== 'openrouter/free' && !route.model.endsWith(':free') && !config.allowPaid) throw new Error(`Model ${route.model} may be paid. Explicitly pass --allow-paid or choose a free route.`);
152
+ }
153
+ return config;
154
+ }
155
+
156
+ function policyRows(config) {
157
+ const enabled = config.enabled;
158
+ return [
159
+ ['Push checkpoints', enabled && config.events.includes('pre-push') ? `Destination branches: ${config.branches.join(', ') || 'none'}. Remotes: ${config.remotes.join(', ') || 'none'}.` : 'Off. Local pushes are not checked by this policy.'],
160
+ ['Commit checkpoints', enabled && config.events.includes('pre-commit') ? `Staged changes on local branches: ${config.branches.join(', ') || 'none'}.` : 'Off. Local commits are not checked by this policy.'],
161
+ ['Depth', `${config.level} · question caps: ${questionCapsText(config.level)}`],
162
+ ['Preparation', config.backgroundPreparation ? 'Background review after commits and branch updates' : 'On demand when the understanding check starts'],
163
+ ['Behavior', !enabled ? 'Disabled for this repository' : 'The Git operation only goes through after you answer the understanding questions correctly'],
164
+ ];
165
+ }
166
+
167
+ function printConfiguration(terminal, config, { routes = true } = {}) {
168
+ terminal.rows(policyRows(config));
169
+ if (routes) {
170
+ terminal.section('Evaluator routes');
171
+ terminal.rows(['small', 'medium', 'large'].map(tier => [tier[0].toUpperCase() + tier.slice(1), routeText(config.models[tier])]));
172
+ terminal.blank(); terminal.note(`Model calls: ${modelLimitText(config.maxModelCalls)}. Review, compaction, grading, learning, and failed requests stay counted. Completion lasts ${config.receiptHours} hours unless code, destination, or policy changes.`);
173
+ terminal.note(`Review planning targets: ${reviewCapsText(config)} calls. These are soft targets; only explicitly configured request limits stop further calls. Context limits and deadlines still apply.`);
174
+ terminal.note('A call cap is optional. Set one with sphinx config --max-model-calls 30, or clear it with --max-model-calls unlimited.');
175
+ }
176
+ }
177
+
178
+ function printInstallationResult(terminal, result) {
179
+ terminal.blank();
180
+ for (const line of result.messages) terminal.success(line);
181
+ for (const line of result.warnings) terminal.warning(line);
182
+ }
183
+
184
+ function printActivationResult(terminal, result) {
185
+ terminal.section('Open your coding agents');
186
+ if (!result.results.length) { terminal.note('No coding-agent integrations are configured. Choose agents with sphinx install.'); return; }
187
+ const labels = { codex: 'Codex', claude: 'Claude Code', pi: 'Pi', cursor: 'Cursor', opencode: 'OpenCode' };
188
+ terminal.rows(result.results.map(item => [labels[item.agent] || item.agent, `${item.status === 'opened' ? 'Opened' : 'Not opened'}. ${item.detail}`]));
189
+ terminal.blank();
190
+ terminal.note('Opened means a client was launched; hook trust and MCP connections are confirmed inside the client.');
191
+ const remaining = result.results.filter(item => item.status !== 'opened').map(item => item.agent);
192
+ if (remaining.length) terminal.command(`sphinx activate --agents ${remaining.join(',')}`);
193
+ }
194
+
195
+ async function activateCommand(values) {
196
+ const configured = await installedAgents();
197
+ const agents = values.agents === undefined || values.agents === 'auto' ? configured : splitList(values.agents);
198
+ if (values.agents !== undefined && values.agents !== 'auto' && (!agents.length || new Set(agents).size !== agents.length || agents.some(name => !['codex', 'claude', 'pi', 'cursor', 'opencode'].includes(name)))) {
199
+ throw new Error('--agents must be auto or a comma-separated selection of codex, claude, pi, cursor, opencode.');
200
+ }
201
+ const missing = agents.filter(name => !configured.includes(name));
202
+ if (missing.length) throw new Error(`Sphinx is not installed for ${missing.join(', ')}. Run sphinx install --agents ${missing.join(',')} first.`);
203
+ const result = await withProgress('Opening configured coding agents', () => activateIntegrations({ agents }), { enabled: !values.json });
204
+ if (values.json) json(result); else printActivationResult(ui(), result);
205
+ return result.results.some(item => item.status !== 'opened') ? 1 : 0;
206
+ }
207
+
208
+ async function auth() {
209
+ if (!process.stdin.isTTY) throw new Error('Use OPENROUTER_API_KEY in noninteractive environments; sphinx auth requires a terminal.');
210
+ const terminal = ui();
211
+ terminal.section('OpenRouter API key'); terminal.paragraph('Free models also require a key. Create one at:');
212
+ terminal.command('https://openrouter.ai/settings/keys');
213
+ terminal.blank(); terminal.note('Input is hidden. The key stays in a user-only credentials file outside your repositories.');
214
+ for (;;) {
215
+ const hidden = new Writable({ write(_chunk, _encoding, callback) { callback(); } });
216
+ process.stdout.write('\nAPI key: ');
217
+ let key;
218
+ try { key = await withReadline(ask => ask(''), { output: hidden, terminal: true }); } finally { process.stdout.write('\n'); }
219
+ if (!key.trim() || /\s/.test(key.trim())) { terminal.warning('Enter a nonempty key without spaces, or press Ctrl+C to cancel.'); continue; }
220
+ await saveOpenRouterKey(key);
221
+ terminal.success('OpenRouter key saved.'); terminal.note(getPaths().credentials); return;
222
+ }
223
+ }
224
+
225
+ async function installCommand(initial) {
226
+ const terminal = ui(), current = await loadConfig();
227
+ const hasExistingConfig = await lstat(getPaths().config).then(() => true, error => { if (error.code === 'ENOENT') return false; throw error; });
228
+ if (initial.launch && initial['no-launch']) throw new Error('Choose either --launch or --no-launch, not both.');
229
+ if (initial.repo) {
230
+ const incompatible = Object.keys(initial).filter(key => !['repo', 'yes'].includes(key));
231
+ if (incompatible.length) throw new Error(`install --repo only repairs repository hook coverage. Remove ${incompatible.map(key => `--${key}`).join(', ')} or run global sphinx install separately.`);
232
+ terminal.banner(); terminal.section('Connect this repository');
233
+ printInstallationResult(terminal, await install({ config: current, agents: 'none', repo: true, cliPath: CLI_PATH })); return 0;
234
+ }
235
+ applyInstallOptions(current, initial);
236
+ const completeRouteChoice = ['preset', 'provider', 'model'].some(key => initial[key] !== undefined)
237
+ || ['small', 'medium', 'large'].every(key => initial[key] !== undefined);
238
+ if (initial.yes && !hasExistingConfig && !completeRouteChoice) {
239
+ throw new Error('First installation needs an explicit reviewer choice for every change size. Run sphinx install for guided setup, or use --yes --preset codex-economy, --preset claude-economy, or --preset openrouter-value --allow-paid. Custom routes need all three flags: --small, --medium, and --large.');
240
+ }
241
+ if (!initial.yes && !process.stdin.isTTY) throw new Error('Run sphinx install in a terminal for guided setup, or use --yes with your chosen options. See sphinx install --help.');
242
+ terminal.banner(); terminal.paragraph('Connect Git and your coding agents once for this machine. Existing hooks and unrelated agent settings are preserved.');
243
+ const launch = !initial['no-launch'] && (initial.launch || !initial.yes);
244
+ if (launch) terminal.paragraph('After setup, Sphinx opens selected installed clients in fresh sessions. Existing sessions stay open. Use --no-launch to defer this step.');
245
+ const detected = !initial.yes && (initial.agents === undefined || initial.agents === 'auto') ? await detectAgents() : [];
246
+ const values = initial.yes ? initial : await withReadline(ask => askInstall(current, initial, createPrompts({ ask, ui: terminal,
247
+ select: process.stdin.isTTY && process.stdout.isTTY && process.env.TERM !== 'dumb' ? ask.checkbox : undefined,
248
+ }), { detectedAgents: detected, hasExistingConfig }));
249
+ const config = applyInstallOptions(current, values);
250
+ terminal.section('Machine-wide defaults');
251
+ terminal.paragraph('Each repository can override these branches and remotes in .sphinxconfig to match its own production setup.');
252
+ printConfiguration(terminal, config);
253
+ terminal.rows([['Agent integrations', values.agents || 'auto (detect installed agents)']]);
254
+ if (Object.values(config.models).some(route => route.provider === 'openrouter') && !(await openRouterKey())) {
255
+ if (process.stdin.isTTY && !values.yes) await auth();
256
+ else throw new Error('OpenRouter requires a key before installation. Set OPENROUTER_API_KEY or run sphinx auth. Alternatively choose --provider codex or --provider claude.');
257
+ }
258
+ const result = await install({ config, agents: values.agents || 'auto', cliPath: CLI_PATH });
259
+ await saveConfig(config); printInstallationResult(terminal, result);
260
+ if (result.agents.length) {
261
+ if (launch) printActivationResult(terminal, await withProgress('Opening your coding agents', () => activateIntegrations({ agents: result.agents })));
262
+ else {
263
+ terminal.section('Open your coding agents');
264
+ terminal.command(`sphinx activate --agents ${result.agents.join(',')}`);
265
+ terminal.note('Opens fresh clients to load Sphinx.');
266
+ if (result.agents.includes('codex')) terminal.note('Codex hook trust still requires your approval in /hooks.');
267
+ }
268
+ }
269
+ terminal.section('Repository setup'); terminal.command('sphinx init');
270
+ terminal.note('Choose repository policy, project context, and excluded paths. Use sphinx status to check Git coverage.');
271
+ terminal.blank(); terminal.note(`Global settings: ${getPaths().config}`); return 0;
272
+ }
273
+
274
+ async function initCommand(initial) {
275
+ const { repo, config } = await repoConfig(), info = await repositoryConfigInfo(repo.root), terminal = ui();
276
+ let values = initial;
277
+ if (!initial.json) { terminal.banner(); terminal.section('Repository setup'); terminal.note(repo.root); }
278
+ if (!info.exists || info.legacy) {
279
+ applyInstallOptions(config, initial);
280
+ if (!initial.yes && !initial.json) {
281
+ if (!process.stdin.isTTY) throw new Error('Run sphinx init in a terminal, or use --yes to accept inherited defaults. See sphinx init --help.');
282
+ if (info.legacy) terminal.note('Existing .sphinx.json policy will be carried into .sphinxconfig; the legacy file remains.');
283
+ values = await withReadline(ask => askPolicy(config, initial, createPrompts({ ask, ui: terminal })));
284
+ }
285
+ }
286
+ const overrides = {};
287
+ for (const key of ['level']) if (values[key] !== undefined) overrides[key] = values[key];
288
+ for (const key of ['branches', 'events', 'remotes']) if (values[key] !== undefined) overrides[key] = splitList(values[key]);
289
+ let context = values.context;
290
+ if (context === undefined && !initial.yes && !initial.json && process.stdin.isTTY && !(await loadProjectContext(repo.root)).text) {
291
+ terminal.section('Project context');
292
+ terminal.paragraph('Describe what this project does and its key workflows. This background is included in every review; you can expand .sphinxcontext.md later.');
293
+ const answer = await withReadline(ask => ask('\nProject description (Enter to create a template): '));
294
+ if (answer.trim()) context = answer.trim();
295
+ }
296
+ const result = await initializeRepository({ repoRoot: repo.root, config, overrides, context });
297
+ if (initial.json) { json(result); return 0; }
298
+ terminal.blank();
299
+ for (const file of result.created) terminal.success(`Created ${path.basename(file)}`);
300
+ for (const file of result.preserved) terminal.note(`Preserved ${path.basename(file)}`);
301
+ if (info.exists && !info.legacy && Object.keys(overrides).length) terminal.warning('The existing .sphinxconfig was preserved; command-line overrides were not applied. Edit that file to change its policy.');
302
+ terminal.section('Effective repository policy'); printConfiguration(terminal, await loadConfig(repo.root), { routes: false });
303
+ terminal.blank(); terminal.paragraph('Add project-specific sensitive or generated paths to .sphinxignore before an assessment.');
304
+ terminal.paragraph('Keep .sphinxcontext.md current with the project purpose, workflows, and architecture boundaries. The reviewer receives it every time.');
305
+ terminal.note('This command does not install Git hooks. Check coverage with sphinx status.'); return 0;
306
+ }
307
+
308
+ function printSession(session, terminal = ui()) {
309
+ if (session.feedback) {
310
+ terminal.section(session.verdict === 'incorrect' ? 'Review this concept' : session.verdict === 'ambiguous' ? 'Clarification needed' : 'Feedback'); terminal.paragraph(session.feedback);
311
+ }
312
+ if (session.status === 'passed') {
313
+ terminal.blank(); terminal.success('Understanding check complete.');
314
+ terminal.paragraph(process.env.SPHINX_WAITING_GIT === '1'
315
+ ? "Approval saved. Return to the original Git output for the operation's result."
316
+ : 'Approval saved for this exact change and destination. A waiting Git operation can now continue.');
317
+ return;
318
+ }
319
+ terminal.blank(); terminal.note(`${session.route.tier} change · ${routeText(session.route)} · ${modelUsageText(session.usage, session.tokens)}`);
320
+ if (!session.question) { terminal.paragraph('Assessment queued. Run sphinx check to begin.'); return; }
321
+ terminal.section(`Question ${session.progress.completed + 1} of ${session.progress.total}`); terminal.paragraph(session.question.prompt);
322
+ if (session.question.choices.length) { terminal.blank(); terminal.rows(session.question.choices.map((choice, index) => [`${index + 1}.`, choice])); }
323
+ terminal.blank(); terminal.note('Give a prediction and a short reason. /learn for help · /dispute <reason> · /quit to save progress');
324
+ }
325
+
326
+ async function checkCommand(values) {
327
+ if (values['retry-review'] && !values.id) throw new Error('--retry-review requires --id to select the failed checkpoint. Find it with sphinx pending.');
328
+ const { repo, config } = await repoConfig(); let session;
329
+ if (values.id) session = await store.read(values.id);
330
+ else if (!values.staged && !values.remote && !values.branch) {
331
+ for (const candidate of await store.pending(repo.root)) if (await git.revalidateSnapshot(candidate.snapshot, config)) { session = candidate; break; }
332
+ }
333
+ if (session && session.snapshot.repoRoot !== repo.root) throw new Error('This checkpoint belongs to a different repository.');
334
+ if (!session) {
335
+ const snapshot = values.staged ? await git.commitSnapshot({ cwd: repo.root, config }) : await git.manualSnapshot({ cwd: repo.root, config, remote: values.remote, branch: values.branch });
336
+ if (!snapshot) { if (values.json) json({ status: 'not-required' }); else ui().success('No assessment required by the current policy.'); return 0; }
337
+ session = await store.ensure(snapshot, config);
338
+ }
339
+ if (!values.json && !process.stdin.isTTY) throw new Error('Use sphinx check --json from a coding agent, or run in an interactive terminal.');
340
+ const start = signal => withProgress(session.questions.length ? 'Loading prepared questions' : `Reviewing the change with ${routeText(session.route)}`, () => store.start(session.id, config, { signal }), { enabled: !values.json });
341
+ if (values.json) {
342
+ if (values['retry-review']) await store.retryReview(session.id, config);
343
+ json(await start()); return 0;
344
+ }
345
+ const terminal = ui(); terminal.section('Understanding check'); terminal.paragraph('This is open-book. Inspect the code when useful; explain the behavior in your own words.');
346
+ const controller = new AbortController(), { signal } = controller;
347
+ return withReadline(async ask => {
348
+ if (values['retry-review']) {
349
+ await store.retryReview(session.id, config, { signal });
350
+ signal.throwIfAborted();
351
+ terminal.note(config.maxModelCalls === null
352
+ ? 'Resuming the review with no model-call limit. Previous usage remains counted.'
353
+ : `Starting a new review attempt with up to ${config.maxModelCalls} additional model calls, including grading. Previous usage remains counted.`);
354
+ }
355
+ let state = await prepareInteractiveCheck({
356
+ start: () => start(signal), inspect: async () => store.public(await store.read(session.id)),
357
+ authorizeMore: () => store.retryReview(session.id, config, { signal }),
358
+ validate: () => validateCheckpoint(session.snapshot, config), config,
359
+ choose: createPrompts({ ask, ui: terminal }).choose, ui: terminal,
360
+ waitingGit: process.env.SPHINX_WAITING_GIT === '1', signal,
361
+ });
362
+ signal.throwIfAborted();
363
+ if (!state) return 1;
364
+ while (state.status !== 'passed') {
365
+ printSession(state, terminal);
366
+ const answer = (await ask('\nYour answer: ')).trim();
367
+ if (answer === '/quit') { terminal.note('Progress saved. Run sphinx check when you are ready to continue.'); return 1; }
368
+ if (!answer) continue;
369
+ const dispute = answer.startsWith('/dispute '), learn = answer === '/learn';
370
+ if (answer === '/dispute') { terminal.warning('Add a reason: /dispute <what is ambiguous or unsupported>'); continue; }
371
+ if (answer.startsWith('/') && !dispute && !learn) { terminal.warning('Use /learn, /dispute <reason>, or /quit; otherwise enter your answer.'); continue; }
372
+ state = await withProgress(learn ? 'Preparing an explanation and new scenario' : dispute ? 'Reviewing the question' : 'Checking your reasoning', () => store.answer(session.id, learn ? 'I do not know. Explain this concept and give me a different scenario to apply it to.' : dispute ? answer.slice(9) : answer, config, { dispute, learn, expectedQuestionId: state.question.id, signal }));
373
+ signal.throwIfAborted();
374
+ }
375
+ signal.throwIfAborted(); printSession(state, terminal); return 0;
376
+ }, { controller });
377
+ }
378
+
379
+ function printPending(terminal, pending) {
380
+ if (!pending.length) { terminal.success('No pending assessments.'); return; }
381
+ for (const session of pending) {
382
+ terminal.section(`${session.target} · ${session.status}`);
383
+ terminal.paragraph(`${session.route.tier} change · ${routeText(session.route)} · ${session.progress.completed}/${session.progress.total} concepts complete · ${modelUsageText(session.usage, session.tokens)}`);
384
+ terminal.command(`sphinx check --id ${session.id}`);
385
+ }
386
+ }
387
+
388
+ async function statusCommand(command, values) {
389
+ const { repo, config } = await repoConfig(), pending = [];
390
+ const { preparationStatus } = await import('./preparation.js');
391
+ const preparation = command === 'status' ? await preparationStatus({ cwd: repo.root }) : null;
392
+ for (const session of await store.pending(repo.root)) if (await git.revalidateSnapshot(session.snapshot, config)) pending.push(store.public(session));
393
+ const yolo = command === 'status' ? await yoloStatus(repo.root) : null, overrides = command === 'status' ? await recentOverrides({ repoRoot: repo.root }) : [];
394
+ if (values.json) { json(command === 'status' ? { repo: repo.root, config, pending, preparation, yolo, overrides } : pending); return 0; }
395
+ const terminal = ui();
396
+ if (command === 'pending') { terminal.section('Pending assessments'); printPending(terminal, pending); return 0; }
397
+ const info = await repositoryConfigInfo(repo.root), report = await doctor();
398
+ const coverage = report.checks.filter(check => check.name === 'Current repository' || check.name.endsWith('dispatcher'));
399
+ const active = coverage.length > 0 && coverage.every(check => check.ok);
400
+ const branch = await promisify(execFile)('git', ['symbolic-ref', '--quiet', '--short', 'HEAD'], { cwd: repo.root }).then(result => result.stdout.trim()).catch(() => 'detached HEAD');
401
+ terminal.section('Repository status');
402
+ terminal.rows([['Repository', repo.root], ['Current local branch', branch], ['Git integration', active ? 'Active · Sphinx dispatchers are configured' : 'Not active or needs repair · run sphinx doctor'], ['Repository policy', info.exists ? `${info.path}${info.legacy ? ' (legacy)' : ''}` : 'Global defaults; use sphinx init to create .sphinxconfig']]);
403
+ if (info.shadowed) terminal.warning(`${info.shadowed} is ignored because .sphinxconfig takes precedence.`);
404
+ terminal.section('Configured checkpoints'); printConfiguration(terminal, config, { routes: false });
405
+ if (preparation) terminal.rows([['Background review', preparation.status], ...(preparation.error ? [['Details', preparation.error]] : [])]);
406
+ if (!active) { terminal.blank(); terminal.warning('Policy alone does not install hooks. Use sphinx install, or sphinx install --repo when this repository overrides core.hooksPath.'); }
407
+ if (yolo) { terminal.blank(); terminal.warning(`Yolo mode is on ${yolo.scope === 'repo' ? 'for this repository' : 'globally'} since ${yolo.since}: checks are skipped. Run sphinx yolo${yolo.scope === 'global' ? ' outside a repository' : ''} to turn it off.`); }
408
+ terminal.section('Pending assessments'); printPending(terminal, pending);
409
+ if (overrides.length) { terminal.section('Recent overrides'); terminal.rows(overrides.map(row => [row.at, `${row.kind}${row.target ? ` · ${row.target}` : ''}${row.reason ? ` · ${row.reason}` : ''}`])); }
410
+ terminal.blank(); terminal.note('Status uses local Git state only. Agent hook trust is not verified here.'); return 0;
411
+ }
412
+
413
+ export async function main(argv) {
414
+ if (argv[0] === 'mcp' && argv.length === 1) {
415
+ const { runMcpServer } = await import('./mcp.js');
416
+ await runMcpServer(); return 0;
417
+ }
418
+ if (argv[0] === 'hook') {
419
+ if (process.env.SPHINX_EVALUATOR === '1') return 0;
420
+ if (argv[1] === 'git') {
421
+ const event = argv[2], input = event === 'pre-push' ? await readStdin() : '';
422
+ return dispatchHook({ event, args: argv.slice(3), input, cwd: process.cwd(), env: process.env, gate });
423
+ }
424
+ if (argv[1] === 'agent') {
425
+ const result = await agentHook({ agent: argv[2], input: await readStdin(), cwd: process.cwd(), env: process.env });
426
+ if (result.output) console.log(JSON.stringify(result.output)); return result.exitCode;
427
+ }
428
+ throw new Error('Unknown hook entry point.');
429
+ }
430
+ const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: {
431
+ help: { type: 'boolean', short: 'h' }, version: { type: 'boolean', short: 'v' }, json: { type: 'boolean' }, yes: { type: 'boolean' }, repo: { type: 'boolean' }, staged: { type: 'boolean' },
432
+ preset: { type: 'string' }, provider: { type: 'string' }, model: { type: 'string' }, small: { type: 'string' }, medium: { type: 'string' }, large: { type: 'string' }, level: { type: 'string' }, branches: { type: 'string' }, events: { type: 'string' }, agents: { type: 'string' }, remotes: { type: 'string' },
433
+ remote: { type: 'string' }, branch: { type: 'string' }, id: { type: 'string' }, text: { type: 'string' }, 'allow-paid': { type: 'boolean' },
434
+ context: { type: 'string' }, worker: { type: 'boolean' }, retry: { type: 'boolean' }, reason: { type: 'string' },
435
+ launch: { type: 'boolean' }, 'no-launch': { type: 'boolean' }, 'retry-review': { type: 'boolean' }, 'max-model-calls': { type: 'string' }, 'max-output-tokens': { type: 'string' },
436
+ } });
437
+ const [command, id] = positionals;
438
+ if (values.version) { if (values.json) json({ version: '0.1.0' }); else console.log('0.1.0'); return 0; }
439
+ if (values.help || !command || command === 'help') { printHelp(ui(), command === 'help' ? id : command, { json: values.json }); return 0; }
440
+ if (!HELP[command]) throw new Error(`Unknown command ${command}. Run sphinx --help.`);
441
+ for (const key of Object.keys(values)) if (!OPTIONS[command].includes(key)) throw new Error(`--${key} is not an option for ${command}. Run sphinx ${command} --help.`);
442
+ const needsId = ['answer', 'explain', 'dispute'].includes(command);
443
+ if (positionals.length > (needsId ? 2 : 1)) throw new Error(`Unexpected argument. Usage: ${HELP[command].usage}`);
444
+ if (needsId && !id) throw new Error(`An assessment ID is required. Run sphinx pending to find it. Usage: ${HELP[command].usage}`);
445
+ if (['answer', 'dispute'].includes(command) && !values.text?.trim()) throw new Error(`--text is required. Usage: ${HELP[command].usage}`);
446
+ if (command === 'install') return installCommand(values);
447
+ if (command === 'activate') return activateCommand(values);
448
+ if (command === 'init') return initCommand(values);
449
+ if (command === 'prepare') {
450
+ if (values.worker && values.retry) throw new Error('--retry is for an explicit preparation request, not a worker.');
451
+ const { schedulePreparation, runPreparation } = await import('./preparation.js');
452
+ const result = values.worker
453
+ ? await runPreparation({ cwd: process.cwd() })
454
+ : await schedulePreparation({ cwd: process.cwd(), cliPath: CLI_PATH, retry: !!values.retry });
455
+ if (values.json) json(result);
456
+ else if (!values.worker) {
457
+ const terminal = ui(); terminal.section('Background preparation');
458
+ terminal.paragraph(`Review ${result.status}.${result.reason ? ` ${result.reason}` : ''}`);
459
+ terminal.note('Use sphinx status to inspect progress. Preparation never approves a push.');
460
+ }
461
+ return 0;
462
+ }
463
+ if (command === 'auth') { await auth(); return 0; }
464
+ if (command === 'uninstall') { printInstallationResult(ui(), await uninstall({ repo: !!values.repo })); return 0; }
465
+ if (command === 'doctor') {
466
+ const result = await doctor();
467
+ if (values.json) json(result);
468
+ else {
469
+ const terminal = ui(); terminal.section('Integration health');
470
+ for (const check of result.checks) { terminal.blank(); (check.ok ? terminal.success : terminal.warning)(check.name); terminal.paragraph(check.detail, ' '); }
471
+ terminal.blank(); (result.checks.every(check => check.ok) ? terminal.success : terminal.warning)(result.checks.every(check => check.ok) ? 'Configured integrations are in place.' : 'Some integrations need attention. See the steps above.');
472
+ }
473
+ return result.checks.every(check => check.ok) ? 0 : 1;
474
+ }
475
+ if (command === 'config') {
476
+ if (MODEL_FLAGS.some(key => values[key] !== undefined)) {
477
+ const current = await loadConfig(), previousMaxModelCalls = current.maxModelCalls;
478
+ const config = applyInstallOptions(current, values), maxModelCalls = config.maxModelCalls;
479
+ await saveConfig(config);
480
+ const result = { scope: 'global', path: getPaths().config, previousMaxModelCalls, maxModelCalls, config };
481
+ if (values.json) { json(result); return 0; }
482
+ const terminal = ui();
483
+ if (values['max-model-calls'] !== undefined) terminal.success(`Saved machine-wide limit: ${modelLimitText(maxModelCalls)} (previously ${modelLimitText(previousMaxModelCalls)}).`);
484
+ if (values['max-output-tokens'] !== undefined) terminal.success(`Saved output allowance: ${config.maxOutputTokens} tokens per request, including model reasoning. Model calls: ${modelLimitText(maxModelCalls)}.`);
485
+ if (MODEL_FLAGS.some(key => !['max-model-calls', 'max-output-tokens'].includes(key) && values[key] !== undefined)) {
486
+ terminal.success('Saved machine-wide evaluator settings.');
487
+ terminal.rows(['small', 'medium', 'large'].map(tier => [tier[0].toUpperCase() + tier.slice(1), routeText(config.models[tier])]));
488
+ terminal.note('Changing evaluator routes applies to fresh checkpoints. Existing checkpoints remain bound to their original assessment policy.');
489
+ }
490
+ terminal.note(result.path);
491
+ terminal.paragraph(`Model calls: ${modelLimitText(maxModelCalls)}. Review, compaction, grading, learning follow-ups, and failed requests stay counted. Saving configuration makes no model request.`);
492
+ terminal.note(`Review planning targets: ${reviewCapsText(config)} calls. Explicit request caps, context limits, and deadlines still apply.`);
493
+ return 0;
494
+ }
495
+ const repo = await git.repository(process.cwd()).catch(() => null), config = await loadConfig(repo?.root);
496
+ if (values.json) { json(config); return 0; }
497
+ const terminal = ui(); terminal.section('Effective configuration'); terminal.rows([['Global settings', getPaths().config]]);
498
+ if (repo) {
499
+ const info = await repositoryConfigInfo(repo.root);
500
+ terminal.rows([['Repository policy', info.exists ? `${info.path}${info.legacy ? ' (legacy)' : ''}` : 'None; global policy applies']]);
501
+ if (info.shadowed) terminal.warning(`Legacy ${info.shadowed} is shadowed by .sphinxconfig.`);
502
+ }
503
+ terminal.section('Checkpoint policy'); printConfiguration(terminal, config);
504
+ terminal.section('Limits and exclusions'); terminal.rows([['Reviewer context', `${config.maxContextBytes.toLocaleString()} bytes in active context; older investigation is compacted`], ['Tool result', `${config.toolResultBytes.toLocaleString()} bytes per page`], ['Reviewer turns', config.agentMaxTurns === null ? 'No request limit; shared limits apply when explicitly configured' : `Up to ${config.agentMaxTurns}, also subject to an explicitly configured total call limit`], ['Reviewer timeout', `${config.timeoutSeconds} seconds per investigation`], ['Configured exclusions', config.exclude.join(', ') || 'None beyond built-in rules and ignore files']]);
505
+ terminal.blank(); terminal.note('Use sphinx config --preset <name> to choose evaluator models, or --max-model-calls <1-100|unlimited> to set or clear an optional call cap. See sphinx config --help for tier overrides and paid routes.'); return 0;
506
+ }
507
+ if (command === 'check') return checkCommand(values);
508
+ if (command === 'yolo') {
509
+ const repo = await git.repository(process.cwd()).catch(() => null);
510
+ const result = await toggleYolo({ repoRoot: repo?.root ?? null, reason: values.reason });
511
+ if (values.json) { json(result); return 0; }
512
+ const where = result.scope === 'repo' ? `for ${result.repoRoot}` : 'globally (every repository without its own setting)';
513
+ const terminal = ui();
514
+ if (result.enabled) { terminal.warning(`Yolo mode is ON ${where}. Git operations skip the understanding check; each skip is logged.`); terminal.command('sphinx yolo # run again to turn it off'); }
515
+ else terminal.success(`Yolo mode is OFF ${where}. Understanding checks are back on.`);
516
+ return 0;
517
+ }
518
+ if (['pending', 'status'].includes(command)) return statusCommand(command, values);
519
+ if (needsId) {
520
+ const { repo, config } = await repoConfig(), existing = await store.read(id);
521
+ if (existing.snapshot.repoRoot !== repo.root) throw new Error('This checkpoint belongs to a different repository.');
522
+ const learn = command === 'explain', dispute = command === 'dispute';
523
+ const result = await withProgress(learn ? 'Preparing an explanation and new scenario' : dispute ? 'Reviewing the question' : 'Checking your reasoning', () => store.answer(id, learn ? 'I do not know. Explain this concept and give me a different scenario to apply it to.' : values.text, config, { learn, dispute }), { enabled: !values.json });
524
+ if (values.json) json(result); else printSession(result); return 0;
525
+ }
526
+ }