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
@@ -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
+ }
@@ -0,0 +1,278 @@
1
+ import { createServer } from 'node:http';
2
+ import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
3
+ import { spawn } from 'node:child_process';
4
+ import { setTimeout as delay } from 'node:timers/promises';
5
+ import { loadConfig, policyHash } from './config.js';
6
+ import { revalidateSnapshot } from './git.js';
7
+ import { createSessionStore } from './sessions.js';
8
+ import { prepareInteractiveCheck } from './check-recovery.js';
9
+ import { claimCheckpointDialog, finishCheckpointDialog, waitForCheckpointTerminal } from './terminal-launch.js';
10
+ import { browserPage } from './browser-page.js';
11
+ import { recordOverride } from './overrides.js';
12
+
13
+ const ID = /^[a-f0-9]{64}$/u;
14
+ const cancelled = () => Object.assign(new Error('Check paused. Progress is saved.'), { code: 'SPHINX_CANCELLED' });
15
+ const equal = (a, b) => typeof a === 'string' && Buffer.byteLength(a) === Buffer.byteLength(b) && timingSafeEqual(Buffer.from(a), Buffer.from(b));
16
+ const offLimits = env => env.SPHINX_EVALUATOR === '1' || (env.CI && !['false', '0'].includes(env.CI));
17
+
18
+ /** macOS has an explicit background-open flag. Elsewhere print the local URL;
19
+ * do not guess at window-manager focus rules or invoke a foreground opener. */
20
+ export async function openQuestionnaireBrowser(url, { env = process.env, platform = process.platform, spawnImpl = spawn } = {}) {
21
+ if (offLimits(env) || env.SSH_CONNECTION || env.SSH_TTY || platform !== 'darwin') return false;
22
+ const parsed = new URL(url);
23
+ if (parsed.protocol !== 'http:' || parsed.hostname !== '127.0.0.1' || !parsed.port) throw new Error('Invalid local questionnaire URL.');
24
+ return new Promise(resolve => {
25
+ let child, timer, settled = false;
26
+ const finish = value => { if (!settled) { settled = true; clearTimeout(timer); resolve(value); } };
27
+ try { child = spawnImpl('/usr/bin/open', ['-g', url], { env, stdio: 'ignore' }); }
28
+ catch { finish(false); return; }
29
+ child.once('error', () => finish(false));
30
+ child.once('exit', code => finish(code === 0));
31
+ timer = setTimeout(() => { child.kill(); finish(false); }, 5000);
32
+ });
33
+ }
34
+
35
+ function publicFields(state) {
36
+ if (!state) return {};
37
+ return {
38
+ id: state.id, target: state.target,
39
+ route: state.route && { provider: state.route.provider, model: state.route.model, tier: state.route.tier },
40
+ progress: state.progress, usage: state.usage, tokens: state.tokens,
41
+ ...(state.question ? { question: { id: state.question.id, prompt: state.question.prompt, choices: state.question.choices || [] } } : {}),
42
+ ...(state.feedback ? { feedback: state.feedback } : {}),
43
+ };
44
+ }
45
+
46
+ /** One loopback page for a whole Git batch. The caller closes it after Git has
47
+ * independently verified the receipt. All form inputs come from the browser,
48
+ * never from tool arguments; no private evaluator data enters HTTP responses. */
49
+ export function createBrowserInterview({ env = process.env, signal, timeoutMs = 25 * 60 * 1000,
50
+ store: providedStore, readConfig = loadConfig, revalidate = revalidateSnapshot,
51
+ reserve = claimCheckpointDialog, release = finishCheckpointDialog, waitForExisting = waitForCheckpointTerminal,
52
+ openBrowser = openQuestionnaireBrowser, output = line => process.stderr.write(`${line}\n`), logOverride = recordOverride,
53
+ } = {}) {
54
+ const token = randomBytes(32).toString('base64url'), nonce = randomBytes(24).toString('base64');
55
+ let server, origin, url, opened = false, closed = false, active, skip, resolver, sequence = 0, waitingGit = false;
56
+ const pauseMessage = () => waitingGit
57
+ ? 'Paused. Progress is saved; the waiting Git operation was stopped.'
58
+ : 'Paused. Your progress and completed checkpoints are saved.';
59
+ let state = { phase: 'waiting', operationClosed: false, revision: randomUUID(), sequence, message: 'Waiting for the checkpoint.', messages: [] };
60
+ // The chat transcript for the current checkpoint lives only in this process.
61
+ // It contains public questions, feedback, and the human's own text.
62
+ let transcript = [], turns = 0;
63
+ const say = item => { transcript = [...transcript, { id: `m${++turns}`, ...item }]; };
64
+ const settle = status => { transcript = transcript.map(item => item.role === 'human' && item.status === 'checking' ? { ...item, status } : item); };
65
+ const showQuestion = visible => {
66
+ const question = visible?.question;
67
+ if (!question || transcript.some(item => item.kind === 'question' && item.questionId === question.id)) return;
68
+ const number = visible.progress.completed + 1;
69
+ say({ role: 'sphinx', kind: 'question', questionId: question.id, number, followUp: transcript.some(item => item.kind === 'question' && item.number === number), text: question.prompt, choices: question.choices || [] });
70
+ };
71
+ const streams = new Set();
72
+ const writeEvent = (stream, event) => {
73
+ if (stream.destroyed || stream.writableEnded) { streams.delete(stream); return; }
74
+ try {
75
+ // A disconnected or stalled page must not retain an ever-growing buffer
76
+ // or interrupt the assessment. EventSource reconnects with current state.
77
+ if (stream.writableLength > 256 * 1024) stream.destroy();
78
+ else stream.write(event);
79
+ } catch { stream.destroy(); streams.delete(stream); }
80
+ };
81
+ const publish = next => {
82
+ state = { ...next, messages: transcript, waitingGit, operationClosed: closed, revision: randomUUID(), sequence: ++sequence };
83
+ const event = `data: ${JSON.stringify(state)}\n\n`;
84
+ for (const stream of streams) writeEvent(stream, event);
85
+ };
86
+ const waitForAction = (next, allowed) => new Promise((resolve, reject) => {
87
+ active.signal.throwIfAborted();
88
+ const onAbort = () => { resolver = undefined; reject(active.signal.reason || cancelled()); };
89
+ const cleanup = () => active?.signal.removeEventListener('abort', onAbort);
90
+ resolver = { allowed, resolve: value => { cleanup(); resolver = undefined; resolve(value); }, reject: error => { cleanup(); resolver = undefined; reject(error); } };
91
+ active.signal.addEventListener('abort', onAbort, { once: true });
92
+ publish(next);
93
+ });
94
+ const headers = {
95
+ 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer',
96
+ 'Content-Security-Policy': `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`,
97
+ };
98
+ const send = (res, status, value) => {
99
+ if (res.destroyed || res.writableEnded) return;
100
+ res.writeHead(status, { ...headers, 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(value));
101
+ };
102
+ async function handle(req, res) {
103
+ if (req.headers.host !== new URL(origin).host || (req.headers['sec-fetch-site'] && !['same-origin', 'none'].includes(req.headers['sec-fetch-site']))) return send(res, 403, { error: 'Forbidden request.' });
104
+ const route = req.url?.split('?')[0];
105
+ const segments = route?.split('/') || [];
106
+ if (!equal(segments[1], token) || segments.length > 3 || (segments[2] && !['state', 'events', 'action'].includes(segments[2]))) return send(res, 404, { error: 'Not found.' });
107
+ if (req.method === 'GET' && !segments[2]) {
108
+ res.writeHead(200, { ...headers, 'Content-Type': 'text/html; charset=utf-8' }); res.end(browserPage({ token, nonce })); return;
109
+ }
110
+ if (req.method === 'GET' && segments[2] === 'state') return send(res, 200, state);
111
+ if (req.method === 'GET' && segments[2] === 'events') {
112
+ if (streams.size >= 8) return send(res, 429, { error: 'Too many open questionnaire pages.' });
113
+ res.writeHead(200, { ...headers, 'Content-Type': 'text/event-stream; charset=utf-8', Connection: 'keep-alive' });
114
+ streams.add(res); writeEvent(res, `data: ${JSON.stringify(state)}\n\n`);
115
+ const heartbeat = setInterval(() => writeEvent(res, ': keep-alive\n\n'), 10000); heartbeat.unref();
116
+ res.once('close', () => { clearInterval(heartbeat); streams.delete(res); }); return;
117
+ }
118
+ if (req.method !== 'POST' || segments[2] !== 'action') return send(res, 405, { error: 'Unsupported method.' });
119
+ if (req.headers.origin !== origin || !equal(req.headers['x-sphinx-token'], token) || req.headers['content-type']?.split(';')[0] !== 'application/json') return send(res, 403, { error: 'Forbidden request.' });
120
+ let bytes = 0; const chunks = [];
121
+ // 8,000 characters can occupy 24KB in UTF-8 or 48KB when JSON-escaped.
122
+ for await (const chunk of req) { bytes += chunk.length; if (bytes > 64 * 1024) { send(res, 413, { error: 'Keep your response below 8,000 characters.' }); req.destroy(); return; } chunks.push(chunk); }
123
+ let data; try { data = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { return send(res, 400, { error: 'Invalid response.' }); }
124
+ if (!data || typeof data !== 'object' || Array.isArray(data) || Object.keys(data).some(key => !['id', 'revision', 'action', 'response', 'questionId'].includes(key))
125
+ || typeof data.action !== 'string' || (data.response !== undefined && (typeof data.response !== 'string' || data.response.length > 8000))) return send(res, 400, { error: 'Invalid response.' });
126
+ // Live progress changes the revision often; Pause and Skip only need the checkpoint.
127
+ if (!active || active.signal.aborted || data.id !== state.id || (!['pause', 'skip'].includes(data.action) && data.revision !== state.revision)) return send(res, 409, { error: 'This checkpoint changed. Use the current question.' });
128
+ if (data.action === 'skip') {
129
+ // Available at any step, including while the review is still running.
130
+ skip = { reason: data.response };
131
+ active.abort(cancelled());
132
+ publish({ ...publicFields(state), phase: 'skipped', message: waitingGit ? 'Skipped. Git is continuing.' : 'Skipped.' });
133
+ return send(res, 200, state);
134
+ }
135
+ if (data.action === 'pause') {
136
+ active.abort(cancelled());
137
+ publish({ ...publicFields(state), phase: 'paused', message: pauseMessage() });
138
+ return send(res, 200, state);
139
+ }
140
+ if (!resolver || !resolver.allowed.includes(data.action)) return send(res, 409, { error: 'Wait for the current step to finish.' });
141
+ if (['answer', 'learn', 'dispute'].includes(data.action) && data.questionId !== state.question?.id) return send(res, 409, { error: 'The question changed. Use the current question.' });
142
+ if (['answer', 'dispute'].includes(data.action) && !data.response?.trim()) return send(res, 400, { error: 'Enter an answer or a dispute reason first.' });
143
+ const current = resolver;
144
+ if (data.action === 'answer') say({ role: 'human', kind: 'answer', text: data.response.trim(), status: 'checking' });
145
+ else if (data.action === 'dispute') say({ role: 'human', kind: 'dispute', text: `Dispute: ${data.response.trim()}`, status: 'checking' });
146
+ else if (data.action === 'learn') say({ role: 'human', kind: 'learn', text: 'Explain this concept.', status: 'checking' });
147
+ publish({ ...publicFields(state), phase: 'working', message: data.action === 'answer' ? 'Checking your reasoning…' : data.action === 'learn' ? 'Preparing an explanation and a new scenario…' : data.action === 'dispute' ? 'Reviewing the question…' : 'Continuing the review…' });
148
+ send(res, 202, state); current.resolve(data);
149
+ }
150
+ async function ensureServer(operationEnv) {
151
+ if (server) return;
152
+ server = createServer((req, res) => {
153
+ res.on('error', () => res.destroy());
154
+ handle(req, res).catch(() => { if (!res.headersSent) send(res, 400, { error: 'Could not process this response.' }); else res.destroy(); });
155
+ });
156
+ server.requestTimeout = 15000; server.headersTimeout = 10000; server.maxHeadersCount = 32;
157
+ // Port 0 asks the OS for a free ephemeral loopback port, so an occupied
158
+ // port cannot collide. If binding still fails, forget the half-made server
159
+ // so a later checkpoint retries instead of serving with no origin.
160
+ try { await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); }); }
161
+ catch (error) { server = undefined; throw error; }
162
+ origin = `http://127.0.0.1:${server.address().port}`; url = `${origin}/${token}`;
163
+ output(`Sphinx questionnaire: ${url}`);
164
+ if (!opened) {
165
+ opened = true;
166
+ const automatic = await Promise.resolve().then(() => openBrowser(url, { env: operationEnv })).catch(() => false);
167
+ output(automatic ? 'Sphinx: questionnaire opened in a background browser tab. Your coding window stays in place.' : 'Sphinx: open the local URL above to answer. No application was brought to the foreground.');
168
+ }
169
+ }
170
+ async function present({ request, env: operationEnv = env, signal: requestSignal = signal } = {}) {
171
+ if (offLimits(operationEnv)) return { status: 'unavailable', reason: 'noninteractive', id: request?.id };
172
+ if (closed || active) return { status: 'unavailable', id: request?.id };
173
+ if (!request || !ID.test(request.id) || typeof request.repoRoot !== 'string') throw new Error('Invalid browser checkpoint request.');
174
+ const controller = new AbortController(); active = controller; skip = undefined; waitingGit = request.delivery === 'hook';
175
+ transcript = [];
176
+ const cancel = () => controller.abort(requestSignal?.reason || cancelled());
177
+ requestSignal?.addEventListener('abort', cancel, { once: true });
178
+ if (requestSignal?.aborted) cancel();
179
+ const timer = setTimeout(() => controller.abort(cancelled()), timeoutMs);
180
+ let reservation, finalStatus = 'pending';
181
+ const skipped = async () => {
182
+ // A human chose to let Git continue. Close the check and log it; never write a receipt.
183
+ await store.skip?.(request.id).catch(() => {});
184
+ await logOverride({ kind: 'skip', repoRoot: request.repoRoot, target: state.target, id: request.id, reason: skip.reason }, operationEnv).catch(() => {});
185
+ return { status: 'skipped', id: request.id };
186
+ };
187
+ const localSignal = controller.signal;
188
+ const validate = async (snapshot, expected) => {
189
+ localSignal.throwIfAborted();
190
+ const current = await readConfig(snapshot.repoRoot, operationEnv);
191
+ if (policyHash(current) !== policyHash(expected) || !(await revalidate(snapshot, current, { env: operationEnv }))) throw new Error('The code or settings changed. Start a new Git operation for the updated change.');
192
+ localSignal.throwIfAborted();
193
+ };
194
+ const store = providedStore || createSessionStore({ env: operationEnv, validate });
195
+ try {
196
+ localSignal.throwIfAborted();
197
+ reservation = await reserve({ request, env: operationEnv, kind: 'browser' });
198
+ if (reservation?.status !== 'claimed') {
199
+ const status = await waitForExisting({ id: request.id, env: operationEnv, timeoutMs, signal: localSignal });
200
+ return { status: status === 'passed' ? 'passed' : 'pending', id: request.id };
201
+ }
202
+ const session = await store.read(request.id), config = await readConfig(request.repoRoot, operationEnv);
203
+ if (session.snapshot.repoRoot !== request.repoRoot) throw new Error('This checkpoint belongs to another repository.');
204
+ await validate(session.snapshot, config);
205
+ let visible = store.public(session);
206
+ publish({ ...publicFields(visible), repoRoot: request.repoRoot, phase: 'working', message: 'Preparing your understanding check…' });
207
+ await ensureServer(operationEnv);
208
+ const notes = [];
209
+ const recoveryUI = { warning: text => notes.push(text), note: text => notes.push(text), command: () => {} };
210
+ visible = await prepareInteractiveCheck({ config, signal: localSignal, waitingGit: request.delivery === 'hook', ui: recoveryUI,
211
+ inspect: async () => store.public(await store.read(request.id)),
212
+ validate: () => validate(session.snapshot, config),
213
+ authorizeMore: () => store.retryReview(request.id, config, { signal: localSignal }),
214
+ start: async () => {
215
+ notes.length = 0;
216
+ publish({ ...publicFields(store.public(await store.read(request.id))), repoRoot: request.repoRoot, phase: 'working', message: 'Reviewing the change…' });
217
+ return store.start(request.id, config, { signal: localSignal, onProgress: text => {
218
+ say({ role: 'sphinx', kind: 'activity', text }); publish(state);
219
+ // Refresh tokens and cost from the per-call save without blocking review.
220
+ store.read(request.id).then(saved => { if (active === controller && state.id === request.id) { const fresh = store.public(saved); publish({ ...state, usage: fresh.usage, tokens: fresh.tokens }); } }, () => {});
221
+ } });
222
+ },
223
+ choose: async prompt => {
224
+ const current = store.public(await store.read(request.id));
225
+ const action = await waitForAction({ ...publicFields(current), repoRoot: request.repoRoot, phase: 'recovery', message: prompt.title, error: notes.join('\n'), choices: prompt.choices }, prompt.choices.map(choice => choice.value));
226
+ return action.action;
227
+ },
228
+ });
229
+ if (!visible) return skip ? skipped() : { status: 'pending', id: request.id };
230
+ while (visible.status !== 'passed') {
231
+ localSignal.throwIfAborted();
232
+ showQuestion(visible);
233
+ const action = await waitForAction({ ...publicFields(visible), repoRoot: request.repoRoot, phase: 'question', ...(visible.browserError ? { error: visible.browserError } : {}) }, ['answer', 'learn', 'dispute']);
234
+ try {
235
+ await validate(session.snapshot, config);
236
+ visible = await store.answer(request.id, action.action === 'learn' ? 'I do not know. Explain this concept and give me a different scenario to apply it to.' : action.response.trim(), config, {
237
+ learn: action.action === 'learn', dispute: action.action === 'dispute', expectedQuestionId: action.questionId, signal: localSignal,
238
+ });
239
+ settle('graded');
240
+ if (visible.feedback) say({ role: 'sphinx', kind: 'feedback', text: visible.feedback });
241
+ } catch (error) {
242
+ settle('not-graded');
243
+ localSignal.throwIfAborted(); await validate(session.snapshot, config);
244
+ visible = store.public(await store.read(request.id));
245
+ publish({ ...publicFields(visible), repoRoot: request.repoRoot, phase: 'question', error: error.message, message: 'Your response was not accepted. Review the error before submitting again.' });
246
+ // Re-display through waitForAction on the next iteration; never resend.
247
+ visible = { ...visible, browserError: error.message };
248
+ }
249
+ }
250
+ localSignal.throwIfAborted(); await validate(session.snapshot, config);
251
+ finalStatus = 'passed';
252
+ publish({ ...publicFields(visible), repoRoot: request.repoRoot, phase: 'passed', message: 'Understanding check complete. Return to the original Git output for the operation’s result.' });
253
+ return { status: 'passed', id: request.id };
254
+ } catch (error) {
255
+ settle('not-graded');
256
+ if (skip) return skipped();
257
+ publish({ ...publicFields(state), repoRoot: request.repoRoot, phase: localSignal.aborted ? 'paused' : 'error', message: localSignal.aborted ? pauseMessage() : 'The check could not finish. Saved progress and completed checkpoints are retained.', ...(localSignal.aborted ? {} : { error: error.message }) });
258
+ return { status: 'pending', id: request.id };
259
+ } finally {
260
+ clearTimeout(timer); requestSignal?.removeEventListener('abort', cancel); resolver?.reject(cancelled()); resolver = undefined;
261
+ if (reservation?.status === 'claimed') await release({ id: request.id, token: reservation.token, status: finalStatus, env: operationEnv });
262
+ active = undefined;
263
+ }
264
+ }
265
+ async function close() {
266
+ closed = true; active?.abort(cancelled()); resolver?.reject(cancelled()); resolver = undefined;
267
+ if (server) {
268
+ // A passed checkpoint may be followed by another destination. Only this
269
+ // explicit end-of-operation update tells the page to stop reconnecting.
270
+ publish(state);
271
+ // Flush the final SSE update before closing the per-operation listener.
272
+ await delay(40);
273
+ for (const stream of streams) stream.end(); streams.clear();
274
+ await new Promise(resolve => { server.close(resolve); server.closeAllConnections(); });
275
+ }
276
+ }
277
+ return { present, close, get url() { return url; }, get state() { return structuredClone(state); } };
278
+ }