sphinx-understand 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) 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 +186 -2
  6. package/bin/sphinx.js +9 -0
  7. package/docs/architecture.md +212 -0
  8. package/docs/configuration.md +241 -0
  9. package/docs/integrations.md +135 -0
  10. package/docs/logo.svg +3 -0
  11. package/eval/README.md +38 -0
  12. package/eval/RESULTS.md +205 -0
  13. package/eval/fixtures/known-false-negatives.json +115 -0
  14. package/examples/browser-demo.mjs +28 -0
  15. package/examples/config.json +59 -0
  16. package/package.json +50 -4
  17. package/src/activation-worker.js +10 -0
  18. package/src/activation.js +187 -0
  19. package/src/agent-settings.js +206 -0
  20. package/src/agent.js +45 -0
  21. package/src/browser-interview.js +344 -0
  22. package/src/browser-page.js +427 -0
  23. package/src/change-view.js +314 -0
  24. package/src/check-recovery.js +92 -0
  25. package/src/checkbox.js +148 -0
  26. package/src/cli.js +563 -0
  27. package/src/config.js +248 -0
  28. package/src/cursor-hook.js +18 -0
  29. package/src/evaluator.js +501 -0
  30. package/src/git-gate.js +116 -0
  31. package/src/git-interview.js +51 -0
  32. package/src/git.js +252 -0
  33. package/src/help.js +122 -0
  34. package/src/ignore.js +94 -0
  35. package/src/install.js +448 -0
  36. package/src/lifecycle-integrations.js +259 -0
  37. package/src/logo.js +6 -0
  38. package/src/mcp.js +352 -0
  39. package/src/model-presets.js +79 -0
  40. package/src/native-check.js +166 -0
  41. package/src/openrouter.js +269 -0
  42. package/src/overrides.js +68 -0
  43. package/src/pi-integration.js +179 -0
  44. package/src/pi-runtime.js +128 -0
  45. package/src/preparation.js +254 -0
  46. package/src/presentations.js +74 -0
  47. package/src/prompts.js +181 -0
  48. package/src/repo-config.js +72 -0
  49. package/src/repo-context.js +43 -0
  50. package/src/review-workspace.js +334 -0
  51. package/src/reviewer-limits.js +10 -0
  52. package/src/reviewer-response.js +22 -0
  53. package/src/reviewer.js +527 -0
  54. package/src/routing.js +40 -0
  55. package/src/sessions.js +377 -0
  56. package/src/stream.js +59 -0
  57. package/src/terminal-launch.js +288 -0
  58. package/src/terminal-worker.js +11 -0
  59. package/src/terminal.js +121 -0
  60. package/src/usage.js +31 -0
  61. package/src/version.js +4 -0
@@ -0,0 +1,344 @@
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
+ import { withTextStream, partialField } from './stream.js';
13
+ import { loadChangeView } from './change-view.js';
14
+
15
+ const ID = /^[a-f0-9]{64}$/u;
16
+ const cancelled = () => Object.assign(new Error('Check paused. Progress is saved.'), { code: 'SPHINX_CANCELLED' });
17
+ const equal = (a, b) => typeof a === 'string' && Buffer.byteLength(a) === Buffer.byteLength(b) && timingSafeEqual(Buffer.from(a), Buffer.from(b));
18
+ const offLimits = env => env.SPHINX_EVALUATOR === '1' || (env.CI && !['false', '0'].includes(env.CI));
19
+
20
+ /** macOS has an explicit background-open flag. Elsewhere print the local URL;
21
+ * do not guess at window-manager focus rules or invoke a foreground opener. */
22
+ export async function openQuestionnaireBrowser(url, { env = process.env, platform = process.platform, spawnImpl = spawn } = {}) {
23
+ if (offLimits(env) || env.SSH_CONNECTION || env.SSH_TTY || platform !== 'darwin') return false;
24
+ const parsed = new URL(url);
25
+ if (parsed.protocol !== 'http:' || parsed.hostname !== '127.0.0.1' || !parsed.port) throw new Error('Invalid local questionnaire URL.');
26
+ return new Promise(resolve => {
27
+ let child, timer, settled = false;
28
+ const finish = value => { if (!settled) { settled = true; clearTimeout(timer); resolve(value); } };
29
+ try { child = spawnImpl('/usr/bin/open', ['-g', url], { env, stdio: 'ignore' }); }
30
+ catch { finish(false); return; }
31
+ child.once('error', () => finish(false));
32
+ child.once('exit', code => finish(code === 0));
33
+ timer = setTimeout(() => { child.kill(); finish(false); }, 5000);
34
+ });
35
+ }
36
+
37
+ function publicFields(state) {
38
+ if (!state) return {};
39
+ return {
40
+ id: state.id, target: state.target, event: state.event,
41
+ route: state.route && { provider: state.route.provider, model: state.route.model, tier: state.route.tier },
42
+ progress: state.progress, usage: state.usage, tokens: state.tokens,
43
+ ...(state.question ? { question: { id: state.question.id, prompt: state.question.prompt, choices: state.question.choices || [] } } : {}),
44
+ ...(state.feedback ? { feedback: state.feedback } : {}),
45
+ };
46
+ }
47
+
48
+ /** One loopback page for a whole Git batch. The caller closes it after Git has
49
+ * independently verified the receipt. All form inputs come from the browser,
50
+ * never from tool arguments; no private evaluator data enters HTTP responses. */
51
+ export function createBrowserInterview({ env = process.env, signal, timeoutMs = 25 * 60 * 1000,
52
+ store: providedStore, readConfig = loadConfig, revalidate = revalidateSnapshot,
53
+ reserve = claimCheckpointDialog, release = finishCheckpointDialog, waitForExisting = waitForCheckpointTerminal,
54
+ openBrowser = openQuestionnaireBrowser, output = line => process.stderr.write(`${line}\n`), logOverride = recordOverride,
55
+ loadView = loadChangeView,
56
+ } = {}) {
57
+ const token = randomBytes(32).toString('base64url'), nonce = randomBytes(24).toString('base64');
58
+ let server, origin, url, opened = false, closed = false, active, skip, resolver, sequence = 0, waitingGit = false;
59
+ const pauseMessage = () => waitingGit
60
+ ? 'Paused. Progress is saved; the waiting Git operation was stopped.'
61
+ : 'Paused. Your progress and completed checkpoints are saved.';
62
+ let state = { phase: 'waiting', operationClosed: false, revision: randomUUID(), sequence, message: 'Waiting for the checkpoint.', messages: [] };
63
+ // The chat transcript for the current checkpoint lives only in this process.
64
+ // It contains public questions, feedback, and the human's own text.
65
+ let transcript = [], turns = 0;
66
+ // Read-only display of the checkpoint's frozen comparison. It is loaded
67
+ // beside the questionnaire and can never block, grade, or approve it.
68
+ let view, evidence, evidenceKey;
69
+ const viewState = () => !view ? undefined : view.status === 'ready'
70
+ ? { status: 'ready', header: view.model.header, totals: view.model.totals }
71
+ : { status: view.status, ...(view.reason ? { reason: view.reason } : {}) };
72
+ const say = item => { transcript = [...transcript, { id: `m${++turns}`, ...item }]; };
73
+ const settle = status => { transcript = transcript.map(item => item.role === 'human' && item.status === 'checking' ? { ...item, status } : item); };
74
+ const showQuestion = visible => {
75
+ const question = visible?.question;
76
+ if (!question || transcript.some(item => item.kind === 'question' && item.questionId === question.id)) return;
77
+ const number = visible.progress.completed + 1;
78
+ 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 || [] });
79
+ };
80
+ const streams = new Set();
81
+ const writeEvent = (stream, event) => {
82
+ if (stream.destroyed || stream.writableEnded) { streams.delete(stream); return; }
83
+ try {
84
+ // A disconnected or stalled page must not retain an ever-growing buffer
85
+ // or interrupt the assessment. EventSource reconnects with current state.
86
+ if (stream.writableLength > 256 * 1024) stream.destroy();
87
+ else stream.write(event);
88
+ } catch { stream.destroy(); streams.delete(stream); }
89
+ };
90
+ // Display-only updates (change view, evidence) keep the revision, so they
91
+ // never turn an answer already in flight into a stale submission.
92
+ const publish = (next, { display = false } = {}) => {
93
+ state = { ...next, messages: transcript, change: viewState(), evidence: evidence && evidence.checkpoint === next.id ? { questionId: evidence.questionId, refs: evidence.refs } : undefined, waitingGit, operationClosed: closed, revision: display ? next.revision : randomUUID(), sequence: ++sequence };
94
+ const event = `data: ${JSON.stringify(state)}\n\n`;
95
+ for (const stream of streams) writeEvent(stream, event);
96
+ };
97
+ const waitForAction = (next, allowed) => new Promise((resolve, reject) => {
98
+ active.signal.throwIfAborted();
99
+ const onAbort = () => { resolver = undefined; reject(active.signal.reason || cancelled()); };
100
+ const cleanup = () => active?.signal.removeEventListener('abort', onAbort);
101
+ resolver = { allowed, resolve: value => { cleanup(); resolver = undefined; resolve(value); }, reject: error => { cleanup(); resolver = undefined; reject(error); } };
102
+ active.signal.addEventListener('abort', onAbort, { once: true });
103
+ publish(next);
104
+ });
105
+ const headers = {
106
+ 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer',
107
+ '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'`,
108
+ };
109
+ const send = (res, status, value) => {
110
+ if (res.destroyed || res.writableEnded) return;
111
+ res.writeHead(status, { ...headers, 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(value));
112
+ };
113
+ async function handle(req, res) {
114
+ 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.' });
115
+ const route = req.url?.split('?')[0];
116
+ const segments = route?.split('/') || [];
117
+ if (!equal(segments[1], token) || segments.length > 3 || (segments[2] && !['state', 'events', 'action', 'change', 'diff'].includes(segments[2]))) return send(res, 404, { error: 'Not found.' });
118
+ if (req.method === 'GET' && !segments[2]) {
119
+ res.writeHead(200, { ...headers, 'Content-Type': 'text/html; charset=utf-8' }); res.end(browserPage({ token, nonce })); return;
120
+ }
121
+ if (req.method === 'GET' && segments[2] === 'state') return send(res, 200, state);
122
+ if (req.method === 'GET' && ['change', 'diff'].includes(segments[2])) {
123
+ const query = new URL(req.url, origin).searchParams, current = view;
124
+ if (!current || query.get('id') !== current.id || state.id !== current.id) return send(res, 409, { error: 'This checkpoint changed. Reload the current change.' });
125
+ if (current.status !== 'ready') return send(res, current.status === 'loading' ? 202 : 200, viewState());
126
+ try { await current.check(); } catch { return send(res, 409, { error: 'The code or settings changed. This view no longer describes the checkpoint.' }); }
127
+ if (segments[2] === 'change') return send(res, 200, { status: 'ready', id: current.id, ...current.model.summary() });
128
+ try { return send(res, 200, await current.model.diff(query.get('path') ?? '', { context: query.get('context') ?? '3' })); }
129
+ catch (error) { return send(res, 404, { error: error.code === 'CHANGE_VIEW_UNAVAILABLE' ? error.message : 'This file diff is unavailable.' }); }
130
+ }
131
+ if (req.method === 'GET' && segments[2] === 'events') {
132
+ if (streams.size >= 8) return send(res, 429, { error: 'Too many open questionnaire pages.' });
133
+ res.writeHead(200, { ...headers, 'Content-Type': 'text/event-stream; charset=utf-8', Connection: 'keep-alive' });
134
+ streams.add(res); writeEvent(res, `data: ${JSON.stringify(state)}\n\n`);
135
+ const heartbeat = setInterval(() => writeEvent(res, ': keep-alive\n\n'), 10000); heartbeat.unref();
136
+ res.once('close', () => { clearInterval(heartbeat); streams.delete(res); }); return;
137
+ }
138
+ if (req.method !== 'POST' || segments[2] !== 'action') return send(res, 405, { error: 'Unsupported method.' });
139
+ 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.' });
140
+ let bytes = 0; const chunks = [];
141
+ // 8,000 characters can occupy 24KB in UTF-8 or 48KB when JSON-escaped.
142
+ 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); }
143
+ let data; try { data = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { return send(res, 400, { error: 'Invalid response.' }); }
144
+ if (!data || typeof data !== 'object' || Array.isArray(data) || Object.keys(data).some(key => !['id', 'revision', 'action', 'response', 'questionId'].includes(key))
145
+ || typeof data.action !== 'string' || (data.response !== undefined && (typeof data.response !== 'string' || data.response.length > 8000))) return send(res, 400, { error: 'Invalid response.' });
146
+ // Live progress changes the revision often; Pause and Skip only need the checkpoint.
147
+ 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.' });
148
+ if (data.action === 'skip') {
149
+ // Available at any step, including while the review is still running.
150
+ skip = { reason: data.response };
151
+ active.abort(cancelled());
152
+ publish({ ...publicFields(state), phase: 'skipped', message: waitingGit ? 'Skipped. Git is continuing.' : 'Skipped.' });
153
+ return send(res, 200, state);
154
+ }
155
+ if (data.action === 'pause') {
156
+ active.abort(cancelled());
157
+ publish({ ...publicFields(state), phase: 'paused', message: pauseMessage() });
158
+ return send(res, 200, state);
159
+ }
160
+ if (!resolver || !resolver.allowed.includes(data.action)) return send(res, 409, { error: 'Wait for the current step to finish.' });
161
+ if (['answer', 'learn', 'dispute'].includes(data.action) && data.questionId !== state.question?.id) return send(res, 409, { error: 'The question changed. Use the current question.' });
162
+ if (['answer', 'dispute'].includes(data.action) && !data.response?.trim()) return send(res, 400, { error: 'Enter an answer or a dispute reason first.' });
163
+ const current = resolver;
164
+ if (data.action === 'answer') say({ role: 'human', kind: 'answer', text: data.response.trim(), status: 'checking' });
165
+ else if (data.action === 'dispute') say({ role: 'human', kind: 'dispute', text: `Dispute: ${data.response.trim()}`, status: 'checking' });
166
+ else if (data.action === 'learn') say({ role: 'human', kind: 'learn', text: 'Explain this concept.', status: 'checking' });
167
+ 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…' });
168
+ send(res, 202, state); current.resolve(data);
169
+ }
170
+ async function ensureServer(operationEnv) {
171
+ if (server) return;
172
+ server = createServer((req, res) => {
173
+ res.on('error', () => res.destroy());
174
+ handle(req, res).catch(() => { if (!res.headersSent) send(res, 400, { error: 'Could not process this response.' }); else res.destroy(); });
175
+ });
176
+ server.requestTimeout = 15000; server.headersTimeout = 10000; server.maxHeadersCount = 32;
177
+ // Port 0 asks the OS for a free ephemeral loopback port, so an occupied
178
+ // port cannot collide. If binding still fails, forget the half-made server
179
+ // so a later checkpoint retries instead of serving with no origin.
180
+ try { await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); }); }
181
+ catch (error) { server = undefined; throw error; }
182
+ origin = `http://127.0.0.1:${server.address().port}`; url = `${origin}/${token}`;
183
+ output(`Sphinx questionnaire: ${url}`);
184
+ if (!opened) {
185
+ opened = true;
186
+ const automatic = await Promise.resolve().then(() => openBrowser(url, { env: operationEnv })).catch(() => false);
187
+ 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.');
188
+ }
189
+ }
190
+ function startView(request, snapshot, config, operationEnv, store) {
191
+ evidence = undefined; evidenceKey = undefined;
192
+ const current = view = { id: request.id, status: 'loading', store, check: async () => {
193
+ const latest = await readConfig(snapshot.repoRoot, operationEnv);
194
+ if (policyHash(latest) !== policyHash(config) || !(await revalidate(snapshot, latest, { env: operationEnv }))) throw new Error('stale');
195
+ } };
196
+ Promise.resolve().then(() => loadView(snapshot, config, { env: operationEnv })).then(model => {
197
+ if (view !== current) return;
198
+ current.status = 'ready'; current.model = model; publish(state, { display: true }); refreshEvidence(request.id);
199
+ }, error => {
200
+ if (view !== current) return;
201
+ current.status = 'unavailable';
202
+ current.reason = error?.code === 'CHANGE_VIEW_UNAVAILABLE' ? error.message : 'The changed files could not be loaded for display.';
203
+ publish(state, { display: true });
204
+ });
205
+ }
206
+ function refreshEvidence(id) {
207
+ const current = view;
208
+ if (current?.status !== 'ready' || current.id !== id || !current.store) return;
209
+ current.store.read(id).then(async session => {
210
+ const question = session.questions?.[session.index];
211
+ if (!question || session.status === 'passed') return;
212
+ const key = `${id}:${question.id}`;
213
+ if (evidenceKey === key) return;
214
+ evidenceKey = key;
215
+ const refs = await current.model.evidenceFor(question, session.review).catch(() => []);
216
+ if (view !== current || evidenceKey !== key) return;
217
+ evidence = { checkpoint: id, questionId: question.id, refs };
218
+ publish(state, { display: true });
219
+ }).catch(() => {});
220
+ }
221
+ async function present({ request, env: operationEnv = env, signal: requestSignal = signal } = {}) {
222
+ if (offLimits(operationEnv)) return { status: 'unavailable', reason: 'noninteractive', id: request?.id };
223
+ if (closed || active) return { status: 'unavailable', id: request?.id };
224
+ if (!request || !ID.test(request.id) || typeof request.repoRoot !== 'string') throw new Error('Invalid browser checkpoint request.');
225
+ const controller = new AbortController(); active = controller; skip = undefined; waitingGit = request.delivery === 'hook';
226
+ transcript = [];
227
+ const cancel = () => controller.abort(requestSignal?.reason || cancelled());
228
+ requestSignal?.addEventListener('abort', cancel, { once: true });
229
+ if (requestSignal?.aborted) cancel();
230
+ const timer = setTimeout(() => controller.abort(cancelled()), timeoutMs);
231
+ let reservation, finalStatus = 'pending';
232
+ const skipped = async () => {
233
+ // A human chose to let Git continue. Close the check and log it; never write a receipt.
234
+ await store.skip?.(request.id).catch(() => {});
235
+ await logOverride({ kind: 'skip', repoRoot: request.repoRoot, target: state.target, id: request.id, reason: skip.reason }, operationEnv).catch(() => {});
236
+ return { status: 'skipped', id: request.id };
237
+ };
238
+ const localSignal = controller.signal;
239
+ const validate = async (snapshot, expected) => {
240
+ localSignal.throwIfAborted();
241
+ const current = await readConfig(snapshot.repoRoot, operationEnv);
242
+ 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.');
243
+ localSignal.throwIfAborted();
244
+ };
245
+ const store = providedStore || createSessionStore({ env: operationEnv, validate });
246
+ try {
247
+ localSignal.throwIfAborted();
248
+ reservation = await reserve({ request, env: operationEnv, kind: 'browser' });
249
+ if (reservation?.status !== 'claimed') {
250
+ const status = await waitForExisting({ id: request.id, env: operationEnv, timeoutMs, signal: localSignal });
251
+ return { status: status === 'passed' ? 'passed' : 'pending', id: request.id };
252
+ }
253
+ const session = await store.read(request.id), config = await readConfig(request.repoRoot, operationEnv);
254
+ if (session.snapshot.repoRoot !== request.repoRoot) throw new Error('This checkpoint belongs to another repository.');
255
+ await validate(session.snapshot, config);
256
+ startView(request, session.snapshot, config, operationEnv, store);
257
+ let visible = store.public(session);
258
+ publish({ ...publicFields(visible), repoRoot: request.repoRoot, phase: 'working', message: 'Preparing your understanding check…' });
259
+ await ensureServer(operationEnv);
260
+ const notes = [];
261
+ const recoveryUI = { warning: text => notes.push(text), note: text => notes.push(text), command: () => {} };
262
+ visible = await prepareInteractiveCheck({ config, signal: localSignal, waitingGit: request.delivery === 'hook', ui: recoveryUI,
263
+ inspect: async () => store.public(await store.read(request.id)),
264
+ validate: () => validate(session.snapshot, config),
265
+ authorizeMore: () => store.retryReview(request.id, config, { signal: localSignal }),
266
+ start: async () => {
267
+ notes.length = 0;
268
+ publish({ ...publicFields(store.public(await store.read(request.id))), repoRoot: request.repoRoot, phase: 'working', message: 'Reviewing the change…' });
269
+ return store.start(request.id, config, { signal: localSignal, onProgress: text => {
270
+ say({ role: 'sphinx', kind: 'activity', text }); publish(state);
271
+ // Refresh tokens and cost from the per-call save without blocking review.
272
+ 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 }); } }, () => {});
273
+ } });
274
+ },
275
+ choose: async prompt => {
276
+ const current = store.public(await store.read(request.id));
277
+ 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));
278
+ return action.action;
279
+ },
280
+ });
281
+ if (!visible) return skip ? skipped() : { status: 'pending', id: request.id };
282
+ while (visible.status !== 'passed') {
283
+ localSignal.throwIfAborted();
284
+ showQuestion(visible); refreshEvidence(request.id);
285
+ const action = await waitForAction({ ...publicFields(visible), repoRoot: request.repoRoot, phase: 'question', ...(visible.browserError ? { error: visible.browserError } : {}) }, ['answer', 'learn', 'dispute']);
286
+ try {
287
+ await validate(session.snapshot, config);
288
+ // Type the grader's feedback into the conversation as it streams.
289
+ // Only the first streamed feedback is shown; the saved result replaces it.
290
+ let draft, frozen = false, lastPublish = 0;
291
+ const onText = raw => {
292
+ if (frozen) return;
293
+ const field = partialField(raw, 'feedback');
294
+ if (!field?.text) return;
295
+ if (!draft) { say({ role: 'sphinx', kind: 'feedback', streaming: true, text: field.text }); draft = transcript.at(-1).id; }
296
+ else transcript = transcript.map(item => item.id === draft ? { ...item, text: field.text } : item);
297
+ if (field.complete) frozen = true;
298
+ const now = Date.now();
299
+ if (field.complete || now - lastPublish > 60) { lastPublish = now; publish(state); }
300
+ };
301
+ const dropDraft = () => { if (draft) transcript = transcript.filter(item => item.id !== draft); };
302
+ visible = await withTextStream(onText, () => 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, {
303
+ learn: action.action === 'learn', dispute: action.action === 'dispute', expectedQuestionId: action.questionId, signal: localSignal,
304
+ })).finally(dropDraft);
305
+ settle('graded');
306
+ if (visible.feedback) say({ role: 'sphinx', kind: 'feedback', text: visible.feedback });
307
+ } catch (error) {
308
+ settle('not-graded');
309
+ localSignal.throwIfAborted(); await validate(session.snapshot, config);
310
+ visible = store.public(await store.read(request.id));
311
+ publish({ ...publicFields(visible), repoRoot: request.repoRoot, phase: 'question', error: error.message, message: 'Your response was not accepted. Review the error before submitting again.' });
312
+ // Re-display through waitForAction on the next iteration; never resend.
313
+ visible = { ...visible, browserError: error.message };
314
+ }
315
+ }
316
+ localSignal.throwIfAborted(); await validate(session.snapshot, config);
317
+ finalStatus = 'passed';
318
+ publish({ ...publicFields(visible), repoRoot: request.repoRoot, phase: 'passed', message: 'Understanding check passed for this checkpoint. It records your answers to these questions; it is not a review of every changed line or a claim that the code is correct. Return to the original Git output for the operation’s result.' });
319
+ return { status: 'passed', id: request.id };
320
+ } catch (error) {
321
+ settle('not-graded');
322
+ if (skip) return skipped();
323
+ 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 }) });
324
+ return { status: 'pending', id: request.id };
325
+ } finally {
326
+ clearTimeout(timer); requestSignal?.removeEventListener('abort', cancel); resolver?.reject(cancelled()); resolver = undefined;
327
+ if (reservation?.status === 'claimed') await release({ id: request.id, token: reservation.token, status: finalStatus, env: operationEnv });
328
+ active = undefined;
329
+ }
330
+ }
331
+ async function close() {
332
+ closed = true; active?.abort(cancelled()); resolver?.reject(cancelled()); resolver = undefined;
333
+ if (server) {
334
+ // A passed checkpoint may be followed by another destination. Only this
335
+ // explicit end-of-operation update tells the page to stop reconnecting.
336
+ publish(state);
337
+ // Flush the final SSE update before closing the per-operation listener.
338
+ await delay(40);
339
+ for (const stream of streams) stream.end(); streams.clear();
340
+ await new Promise(resolve => { server.close(resolve); server.closeAllConnections(); });
341
+ }
342
+ }
343
+ return { present, close, get url() { return url; }, get state() { return structuredClone(state); } };
344
+ }