sphinx-understand 0.1.5 → 0.1.6

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.
@@ -0,0 +1,308 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { constants } from 'node:fs';
3
+ import { open } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ import { loadConfig, repositoryConfigInfo, readJson, REPOSITORY_CONFIG_KEYS, stableStringify, validateRepositoryConfig } from './config.js';
7
+ import { compileIgnore, loadIgnorePolicy } from './ignore.js';
8
+ import { PROJECT_CONTEXT_FILE, PROJECT_CONTEXT_MAX_BYTES, PROJECT_CONTEXT_TEMPLATE } from './repo-context.js';
9
+ import { initializeRepository } from './repo-config.js';
10
+
11
+ // Agent-driven repository setup: a read-only proposal, then a validated apply.
12
+ // No model calls. Writes only the three fixed policy files at the repository root.
13
+
14
+ export const CONTEXT_TODO = 'TODO(sphinx):';
15
+ export const APPLY_COMMAND = 'sphinx init --apply - --json';
16
+ export const SETUP_NUDGE = 'This repository has no Sphinx setup; if the user wants one, run `sphinx init --suggest --json` and apply the reviewed proposal with `sphinx init --apply - --json`.';
17
+ const IGNORE_HEADER = '# Added by sphinx init --apply';
18
+ const IGNORE_MAX_BYTES = 128 * 1024;
19
+ const PROPOSAL_KEYS = ['config', 'ignore', 'context'];
20
+ const INFORMATIONAL_KEYS = ['version', 'detected', 'nextStep'];
21
+
22
+ const run = promisify(execFile);
23
+ async function git(cwd, args, maxBuffer = 1024 * 1024) {
24
+ try { return (await run('git', args, { cwd, encoding: 'utf8', maxBuffer, timeout: 10000 })).stdout; }
25
+ catch { return null; }
26
+ }
27
+
28
+ // Never let a likely credential reach .sphinxcontext.md, which is sent to the reviewer.
29
+ const SECRET_PATTERNS = [
30
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----/,
31
+ /\bAKIA[0-9A-Z]{16}\b/,
32
+ /\b(?:sk|rk)-[A-Za-z0-9_-]{20,}/,
33
+ /\bgh[pousr]_[A-Za-z0-9]{30,}/,
34
+ /\bgithub_pat_[A-Za-z0-9_]{30,}/,
35
+ /\bxox[abprs]-[A-Za-z0-9-]{10,}/,
36
+ /\bAIza[0-9A-Za-z_-]{35}/,
37
+ /\b(?:api[_-]?key|secret|token|password|passwd)\b\s*[:=]\s*['"]?(?=[^\s'"]*\d)[^\s'"]{12,}/i,
38
+ ];
39
+ export const looksSecret = text => SECRET_PATTERNS.some(pattern => pattern.test(text));
40
+
41
+ /** Read a small regular file at the repository root without following symlinks. */
42
+ async function readSmall(root, name, max = 256 * 1024) {
43
+ let handle;
44
+ try {
45
+ handle = await open(path.join(root, name), constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
46
+ const info = await handle.stat();
47
+ if (!info.isFile() || info.size > max) return null;
48
+ return await handle.readFile('utf8');
49
+ } catch { return null; } finally { await handle?.close(); }
50
+ }
51
+
52
+ // --- default branch -------------------------------------------------------
53
+
54
+ async function detectBranch(root) {
55
+ const remotes = (await git(root, ['remote']))?.split('\n').filter(Boolean) || [];
56
+ for (const remote of [...new Set(['origin', ...remotes])].filter(name => remotes.includes(name))) {
57
+ const ref = (await git(root, ['symbolic-ref', '--quiet', `refs/remotes/${remote}/HEAD`]))?.trim();
58
+ if (ref?.startsWith(`refs/remotes/${remote}/`)) return { branches: [ref.slice(`refs/remotes/${remote}/`.length)], from: `refs/remotes/${remote}/HEAD` };
59
+ }
60
+ const refs = (await git(root, ['for-each-ref', '--format=%(refname)', 'refs/heads/main', 'refs/heads/master', 'refs/remotes/*/main', 'refs/remotes/*/master']))?.split('\n').filter(Boolean) || [];
61
+ const found = ['main', 'master'].filter(name => refs.some(ref => ref.endsWith(`/${name}`)));
62
+ return found.length ? { branches: found, from: 'existing main/master branches' } : null;
63
+ }
64
+
65
+ // --- ignore suggestions ---------------------------------------------------
66
+
67
+ const DIRECTORIES = [
68
+ ...['node_modules', 'bower_components', 'vendor', 'third_party', '.venv', 'venv'].map(name => [name, 'vendored dependencies']),
69
+ ...['dist', 'target', '.next', '.nuxt', '.turbo', 'coverage', '__pycache__', '.pytest_cache', '.mypy_cache', '.gradle'].map(name => [name, 'build output or cache']),
70
+ ];
71
+ const PATTERNS = [
72
+ ...['package-lock.json', 'npm-shrinkwrap.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lock', 'bun.lockb', 'Cargo.lock', 'poetry.lock', 'uv.lock', 'Pipfile.lock', 'Gemfile.lock', 'composer.lock', 'go.sum', 'flake.lock'].map(name => [name, 'lockfile']),
73
+ ...['*.min.js', '*.min.css', '*.map', '*.pyc', '*_pb2.py', '*_pb2_grpc.py', '*.pb.go', '*.generated.*'].map(name => [name, 'generated']),
74
+ ...['png', 'jpg', 'jpeg', 'gif', 'webp', 'ico', 'pdf', 'zip', 'gz', 'tgz', 'jar', 'mp4', 'mov', 'mp3', 'wav', 'woff', 'woff2', 'ttf', 'otf', 'eot', 'psd'].map(ext => [`*.${ext}`, 'binary asset']),
75
+ ...['.envrc', '*.tfvars', '*.tfstate', '*.tfstate.*', '.htpasswd', 'kubeconfig', '*.kdbx', '*.ovpn'].map(name => [name, 'secret-looking']),
76
+ ];
77
+ const SECRET_NAME = /secret|credential|passw(?:or)?d/i;
78
+ const DATA_FILE = /(?:^|\.)(?:json|ya?ml|toml|ini|cfg|conf|env|properties|txt|xml)$|^[^.]+$/i;
79
+
80
+ async function suggestIgnore(root, policy) {
81
+ const listed = await git(root, ['ls-files', '-z'], 64 * 1024 * 1024);
82
+ if (listed === null) return {};
83
+ const files = listed.split('\0').filter(file => file && !policy.matches(file));
84
+ const found = {};
85
+ const add = (pattern, reason, count) => { found[pattern] = `${reason} (${count} tracked file${count === 1 ? '' : 's'})`; };
86
+ for (const [name, reason] of DIRECTORIES) {
87
+ const prefixes = new Map();
88
+ for (const file of files) {
89
+ const parts = file.split('/'), index = parts.indexOf(name);
90
+ if (index < 0 || index === parts.length - 1) continue;
91
+ const prefix = parts.slice(0, index + 1).join('/');
92
+ prefixes.set(prefix, (prefixes.get(prefix) || 0) + 1);
93
+ }
94
+ // build/ and out/ are left out on purpose: they often hold build scripts, not output.
95
+ // Anchor a few concrete directories; a generic name could also match source elsewhere.
96
+ if (prefixes.size > 3) add(`${name}/`, reason, [...prefixes.values()].reduce((a, b) => a + b, 0));
97
+ else for (const [prefix, count] of prefixes) add(`/${prefix}/`, reason, count);
98
+ }
99
+ const covered = compileIgnore(Object.keys(found), []);
100
+ for (const [pattern, reason] of PATTERNS) {
101
+ const matches = compileIgnore([pattern], []);
102
+ const count = files.filter(file => matches(file) && !covered(file)).length;
103
+ if (count) add(pattern, reason, count);
104
+ }
105
+ const all = compileIgnore(Object.keys(found), []);
106
+ for (const file of files.filter(file => !all(file) && SECRET_NAME.test(path.posix.basename(file)) && DATA_FILE.test(path.posix.basename(file))).slice(0, 20)) add(`/${file}`, 'secret-looking', 1);
107
+ return found;
108
+ }
109
+
110
+ // --- context stub ---------------------------------------------------------
111
+
112
+ function tomlStrings(text, sections) {
113
+ const values = {};
114
+ let current = '';
115
+ for (const line of text.split(/\r?\n/)) {
116
+ const header = line.match(/^\s*\[([^\]]+)\]\s*$/);
117
+ if (header) { current = header[1].trim(); continue; }
118
+ const pair = sections.includes(current) && line.match(/^\s*(name|description)\s*=\s*"([^"]*)"\s*$/);
119
+ if (pair && !values[pair[1]]) values[pair[1]] = pair[2];
120
+ }
121
+ return values;
122
+ }
123
+
124
+ function firstParagraph(text) {
125
+ for (const block of text.split(/\r?\n\s*\r?\n/)) {
126
+ const value = block.trim();
127
+ if (!value || /^(?:#|<|!\[|\[!\[|```|\||[=\-*_~]{3,}|\.\.\s)/.test(value) || /^[=\-~^]{3,}$/m.test(value)) continue;
128
+ const flat = value.replace(/\s+/g, ' ');
129
+ if (flat.length < 20 || !/[a-z].* [a-z]/i.test(flat)) continue;
130
+ if (flat.length <= 500) return flat;
131
+ const cut = flat.slice(0, 500), end = cut.lastIndexOf('. ');
132
+ return end > 100 ? cut.slice(0, end + 1) : `${cut}…`;
133
+ }
134
+ return null;
135
+ }
136
+
137
+ async function suggestContext(root, policy) {
138
+ const readable = async name => policy.matches(name) ? null : readSmall(root, name);
139
+ const from = [];
140
+ let name, description;
141
+ const pkg = await readable('package.json');
142
+ if (pkg) try { const data = JSON.parse(pkg); if (typeof data.name === 'string') name = data.name; if (typeof data.description === 'string') description = data.description; from.push('package.json'); } catch {}
143
+ for (const [file, sections] of [['pyproject.toml', ['project', 'tool.poetry']], ['Cargo.toml', ['package']]]) {
144
+ if (name && description) break;
145
+ const text = await readable(file);
146
+ if (!text) continue;
147
+ const values = tomlStrings(text, sections);
148
+ if (!values.name && !values.description) continue;
149
+ name ||= values.name; description ||= values.description; from.push(file);
150
+ }
151
+ let readme;
152
+ for (const file of ['README.md', 'README.rst', 'README.txt', 'README']) {
153
+ const text = await readable(file);
154
+ if (text && (readme = firstParagraph(text))) { from.push(file); break; }
155
+ }
156
+ const lines = ['# Project context for Sphinx', ''];
157
+ const summary = [name, description].filter(Boolean).join(': ');
158
+ if (summary && !looksSecret(summary)) lines.push(summary, '');
159
+ if (readme && readme !== description && !looksSecret(readme)) lines.push(readme, '');
160
+ lines.push(`${CONTEXT_TODO} Replace this line with a few sentences from your knowledge of the codebase: what it does, who uses it, key workflows, architecture boundaries, and invariants a reviewer should check. No secrets.`);
161
+ return { text: `${lines.join('\n')}\n`, from };
162
+ }
163
+
164
+ // --- suggest --------------------------------------------------------------
165
+
166
+ /** Inspect a repository read-only and propose its Sphinx setup. */
167
+ export async function suggestRepositorySetup({ repoRoot, env = process.env }) {
168
+ const root = path.resolve(repoRoot);
169
+ const info = await repositoryConfigInfo(root);
170
+ const effective = await loadConfig(root, env);
171
+ const policy = await loadIgnorePolicy(root, { env, exclude: effective.exclude });
172
+ const branch = await detectBranch(root);
173
+ const existingContext = await readRegular(path.join(root, PROJECT_CONTEXT_FILE));
174
+ const contextIsTemplate = existingContext === null || existingContext === PROJECT_CONTEXT_TEMPLATE;
175
+ let config = null;
176
+ if (!info.exists || info.legacy) {
177
+ const defaults = info.exists ? null : await loadConfig(undefined, env);
178
+ const base = info.exists ? await readJson(info.path) : Object.fromEntries(REPOSITORY_CONFIG_KEYS.map(key => [key, structuredClone(defaults[key])]));
179
+ config = { ...base, ...(branch && !info.exists ? { branches: branch.branches } : {}) };
180
+ }
181
+ const ignore = await suggestIgnore(root, policy);
182
+ const context = contextIsTemplate ? await suggestContext(root, policy) : null;
183
+ return {
184
+ version: 1,
185
+ config,
186
+ ignore: Object.keys(ignore),
187
+ context: context?.text ?? null,
188
+ detected: {
189
+ defaultBranch: branch ? { branches: branch.branches, from: branch.from } : null,
190
+ existing: [info.exists && !info.legacy ? '.sphinxconfig' : null, info.legacy ? '.sphinx.json' : null, existingContext !== null && !contextIsTemplate ? PROJECT_CONTEXT_FILE : null].filter(Boolean),
191
+ ignore,
192
+ contextFrom: context?.from ?? [],
193
+ },
194
+ nextStep: !config && !context && !Object.keys(ignore).length ? 'Nothing to apply; this repository is already set up.'
195
+ : `${context ? `Replace the ${CONTEXT_TODO} line in context, then review` : 'Review'} the proposal and pipe this JSON to: ${APPLY_COMMAND}`,
196
+ };
197
+ }
198
+
199
+ // --- apply ----------------------------------------------------------------
200
+
201
+ function invalid(errors) {
202
+ return Object.assign(new Error(`Invalid proposal; nothing was written:\n${errors.map(error => `- ${error}`).join('\n')}`), { code: 'SPHINX_INVALID_PROPOSAL', errors });
203
+ }
204
+
205
+ /** Validate a proposal completely before any write. Collects every problem at once. */
206
+ export function validateProposal(proposal, { env = process.env } = {}) {
207
+ const errors = [];
208
+ if (!proposal || typeof proposal !== 'object' || Array.isArray(proposal)) throw invalid(['expected a JSON object with config, ignore, and context']);
209
+ for (const key of Object.keys(proposal)) if (![...PROPOSAL_KEYS, ...INFORMATIONAL_KEYS].includes(key)) errors.push(`unknown key "${key}"; allowed keys are ${PROPOSAL_KEYS.join(', ')}`);
210
+ const config = proposal.config ?? null;
211
+ if (config !== null) {
212
+ try { validateRepositoryConfig(config, { env, source: 'proposal config' }); }
213
+ catch (error) { errors.push(`config: ${error.message}`); }
214
+ // Agents set Sphinx up; only the human can switch its checks off (sphinx init, interactively).
215
+ if (config && typeof config === 'object') {
216
+ if (config.enabled === false) errors.push('config.enabled cannot be false here; turning Sphinx off is the user\'s decision (they can run sphinx init themselves)');
217
+ for (const key of ['branches', 'events']) if (Array.isArray(config[key]) && !config[key].length) errors.push(`config.${key} cannot be empty here; that would check nothing`);
218
+ }
219
+ }
220
+ const ignore = proposal.ignore ?? [];
221
+ if (!Array.isArray(ignore)) errors.push('ignore must be an array of path patterns');
222
+ else ignore.forEach((line, index) => {
223
+ if (typeof line !== 'string' || !line.trim()) errors.push(`ignore[${index}] must be a nonempty string`);
224
+ else if (/[\r\n\0]/.test(line)) errors.push(`ignore[${index}] must be a single line`);
225
+ else if (line.trim().startsWith('!')) errors.push(`ignore[${index}] "${line}" is a negation; proposals may only add exclusions`);
226
+ else if (line.trim().startsWith('#')) errors.push(`ignore[${index}] "${line}" is a comment; list path patterns only`);
227
+ else if (line.length > 1024) errors.push(`ignore[${index}] exceeds 1024 characters`);
228
+ });
229
+ const context = proposal.context ?? null;
230
+ if (context !== null) {
231
+ if (typeof context !== 'string' || !context.trim()) errors.push('context must be nonempty text, or null to leave .sphinxcontext.md alone');
232
+ else {
233
+ if (context.includes(CONTEXT_TODO)) errors.push(`context still contains the ${CONTEXT_TODO} line; replace it with a short project description`);
234
+ if (context.includes('\0')) errors.push('context must be UTF-8 text');
235
+ if (Buffer.byteLength(context) > PROJECT_CONTEXT_MAX_BYTES) errors.push(`context exceeds ${PROJECT_CONTEXT_MAX_BYTES} bytes; keep it brief`);
236
+ if (looksSecret(context)) errors.push('context looks like it contains a credential; remove it (the reviewer model receives this file)');
237
+ }
238
+ }
239
+ if (errors.length) throw invalid(errors);
240
+ return { config, ignore: [...new Set(ignore.map(line => line.trim()))], context: context === null ? null : `${context.trim()}\n` };
241
+ }
242
+
243
+ async function readRegular(file) {
244
+ let handle;
245
+ try {
246
+ handle = await open(file, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
247
+ if (!(await handle.stat()).isFile()) throw new Error(`Refusing to edit a non-regular file: ${file}`);
248
+ return await handle.readFile('utf8');
249
+ } catch (error) {
250
+ if (error.code === 'ENOENT') return null;
251
+ if (error.code === 'ELOOP') throw new Error(`Refusing to edit a symlink: ${file}`);
252
+ throw error;
253
+ } finally { await handle?.close(); }
254
+ }
255
+
256
+ async function writeRegular(file, text, flags) {
257
+ const handle = await open(file, flags | constants.O_WRONLY | (constants.O_NOFOLLOW || 0));
258
+ try {
259
+ if (!(await handle.stat()).isFile()) throw new Error(`Refusing to edit a non-regular file: ${file}`);
260
+ await handle.writeFile(text);
261
+ } finally { await handle.close(); }
262
+ }
263
+
264
+ /** Write a reviewed proposal. Existing policy and context are preserved; ignore lines are merged. Idempotent. */
265
+ export async function applyRepositorySetup({ repoRoot, proposal, config, env = process.env }) {
266
+ const root = path.resolve(repoRoot);
267
+ const plan = validateProposal(proposal, { env });
268
+ const ignorePath = path.join(root, '.sphinxignore'), contextPath = path.join(root, PROJECT_CONTEXT_FILE);
269
+ // Checks symlinks and sizes before initializeRepository makes its first write.
270
+ const [ignoreBefore, contextBefore] = await Promise.all([readRegular(ignorePath), readRegular(contextPath)]);
271
+ const replaceTemplate = contextBefore === PROJECT_CONTEXT_TEMPLATE && plan.context !== null;
272
+ const missingIgnore = plan.ignore.filter(line => !new Set((ignoreBefore ?? '').split(/\r?\n/).map(value => value.trim())).has(line));
273
+ if (Buffer.byteLength(`${ignoreBefore ?? ''}${missingIgnore.join('\n')}`) > IGNORE_MAX_BYTES) throw invalid([`.sphinxignore would exceed ${IGNORE_MAX_BYTES} bytes`]);
274
+
275
+ const init = await initializeRepository({ repoRoot: root, config, overrides: plan.config ?? {}, context: plan.context ?? undefined, env });
276
+ const name = file => path.relative(root, file);
277
+ const created = init.created.map(name), preserved = init.preserved.map(name), updated = [], warnings = [];
278
+
279
+ if (missingIgnore.length) {
280
+ const current = await readRegular(ignorePath) ?? '';
281
+ const missing = plan.ignore.filter(line => !new Set(current.split(/\r?\n/).map(value => value.trim())).has(line));
282
+ if (missing.length) {
283
+ await writeRegular(ignorePath, `${current && !current.endsWith('\n') ? '\n' : ''}${current.includes(IGNORE_HEADER) ? '' : `\n${IGNORE_HEADER}\n`}${missing.join('\n')}\n`, constants.O_APPEND);
284
+ if (!created.includes('.sphinxignore')) { updated.push('.sphinxignore'); preserved.splice(preserved.indexOf('.sphinxignore'), 1); }
285
+ }
286
+ }
287
+ if (replaceTemplate && await readRegular(contextPath) === PROJECT_CONTEXT_TEMPLATE) {
288
+ await writeRegular(contextPath, plan.context, constants.O_TRUNC);
289
+ updated.push(PROJECT_CONTEXT_FILE); preserved.splice(preserved.indexOf(PROJECT_CONTEXT_FILE), 1);
290
+ } else if (plan.context !== null && !created.includes(PROJECT_CONTEXT_FILE) && await readRegular(contextPath) !== plan.context) {
291
+ warnings.push(`${PROJECT_CONTEXT_FILE} already exists and was kept; edit it directly to change it.`);
292
+ }
293
+ if (plan.config && !created.includes('.sphinxconfig')) {
294
+ const differs = Object.keys(plan.config).filter(key => stableStringify(plan.config[key]) !== stableStringify(init.config[key]));
295
+ if (differs.length) warnings.push(`.sphinxconfig already exists and was kept (proposal differs in ${differs.join(', ')}); edit it directly to change it.`);
296
+ }
297
+ return { ok: true, repo: root, created, updated, preserved, ignoreAdded: missingIgnore, warnings, config: init.config };
298
+ }
299
+
300
+ /** One sentence for agent session start, only when the repository has no Sphinx policy. */
301
+ export async function setupNudge(cwd) {
302
+ if (typeof cwd !== 'string' || !cwd) return null;
303
+ try {
304
+ const root = (await git(cwd, ['rev-parse', '--show-toplevel']))?.trim();
305
+ if (!root) return null;
306
+ return (await repositoryConfigInfo(root)).exists ? null : SETUP_NUDGE;
307
+ } catch { return null; }
308
+ }
@@ -4,7 +4,7 @@ const plainObject = value => value !== null && typeof value === 'object'
4
4
  // Normalize representation only. Keep unknown fields and duplicate metadata so
5
5
  // the ordinary strict schema still rejects them; never repair malformed JSON or
6
6
  // modify a question, answer key, citation, or evidence identifier.
7
- export function normalizeFinishArguments(value) {
7
+ export function normalizeFinishArguments(value, { reasonField = false } = {}) {
8
8
  if (!plainObject(value)) return value;
9
9
  let assessment = value.assessment;
10
10
  if (typeof assessment === 'string') {
@@ -18,5 +18,8 @@ export function normalizeFinishArguments(value) {
18
18
  delete normalized.assessment[key];
19
19
  }
20
20
  }
21
+ // Small-change schemas carry noQuestionReason; when questions are asked it is
22
+ // simply empty, so a model that omits it is not sent around a repair loop.
23
+ if (reasonField && Array.isArray(normalized.assessment.questions) && normalized.assessment.questions.length && !Object.hasOwn(normalized.assessment, 'noQuestionReason')) normalized.assessment.noQuestionReason = '';
21
24
  return normalized;
22
25
  }
package/src/reviewer.js CHANGED
@@ -148,10 +148,11 @@ export async function runReviewer(options) {
148
148
 
149
149
  async function review({ snapshot, config, assessmentSchema, assessmentInstructions, validateAssessment,
150
150
  runModel, runToolModel, nativeTools = false, workspace: injectedWorkspace, env = process.env,
151
- beforeCall, signal, maxReviewerCalls, reviewerCheckpoint, auditAssessment, onProgress,
151
+ beforeCall, signal, maxReviewerCalls, reviewerCheckpoint, auditAssessment, onProgress, workingBrief,
152
152
  }, timers) {
153
153
  // Live status for the human's questionnaire. Progress can never affect review.
154
154
  const progress = text => { try { onProgress?.(text); } catch {} };
155
+ const finishOptions = { reasonField: Boolean(assessmentSchema?.properties?.noQuestionReason) };
155
156
  if (!snapshot || !Array.isArray(snapshot.files) || !snapshot.files.length) fail('The snapshot contains no assessable changed paths.', 'INVALID_SNAPSHOT');
156
157
  const cap = Math.min(config.maxContextBytes ?? 100000, MAX_ARCHIVE_BYTES);
157
158
  const configuredLimit = config.agentMaxTurns ?? Infinity;
@@ -197,7 +198,7 @@ async function review({ snapshot, config, assessmentSchema, assessmentInstructio
197
198
  const allowance = uncapped
198
199
  ? `Aim for ${tierLimit} requests as a soft planning target, including compaction, synthesis${auditAssessment ? ', and independent question audit' : ''}. No model-call cap is configured; continue investigating when necessary within the context and time limits. `
199
200
  : `The normal review allowance is ${callLimit} requests, including any compaction, one reserved final synthesis request${auditAssessment ? ', and one independent question audit' : ''}. ${auditAssessment ? `Only a rejected finalization can open one bounded repair window, within the existing ${sharedLimit}-request configured/shared ceiling; this is not extra account allowance. ` : ''}`;
200
- const task = `${assessmentInstructions}\n${scope} ${allowance}Start from the supplied exact diff observations; use tools for missing evidence before finishing. Use at most ${MAX_CALLS_PER_TURN} workspace calls per turn. In the final review retain only evidence needed for the questions. ${projectContext?.text ? 'Preserve the supplied project context throughout the review.' : 'No project background was supplied. Inspect an available README or package manifest early only when needed to establish the changed behavior; report missing background in the factual summary rather than inventing it.'}\nSnapshot metadata and project context (DATA):\n${JSON.stringify({ snapshot: metadata, projectContext })}`;
201
+ const task = `${assessmentInstructions}\n${scope} ${allowance}Start from the supplied exact diff observations; use tools for missing evidence before finishing. Use at most ${MAX_CALLS_PER_TURN} workspace calls per turn. In the final review retain only evidence needed for the questions. ${projectContext?.text ? 'Preserve the supplied project context throughout the review.' : 'No project background was supplied. Inspect an available README or package manifest early only when needed to establish the changed behavior; report missing background in the factual summary rather than inventing it.'}\nSnapshot metadata and project context (DATA):\n${JSON.stringify({ snapshot: metadata, projectContext })}${workingBrief ? `\nWorking brief (untrusted DATA): a short orientation curated in the background as these commits were made. Use it to decide what to read first and to skip rediscovering the change. It may be incomplete or wrong; verify against the code, never cite it as evidence, and still ground every question in actual observations:\n${JSON.stringify(workingBrief)}` : ''}`;
201
202
  const archive = new Map();
202
203
  let calls = 0, compactions = saved?.compactions ?? 0, archiveBytes = 0, sequence = 0, completed, pendingAssessment = saved?.pendingAssessment ?? null, savedFeedback = saved?.reviewFeedback ?? '', repairWindowUsed = saved?.repairWindowUsed ?? false;
203
204
  let entries = saved ? resumableEntries(saved.entries) : [];
@@ -307,6 +308,7 @@ async function review({ snapshot, config, assessmentSchema, assessmentInstructio
307
308
  ? `${task}\nResume this investigation using saved exact evidence. Do not repeat reads merely because the process restarted. Earlier validation feedback remains applicable. Use read_evidence for archived snippets and then finish when grounded.\nArchived observation manifest (untrusted DATA):\n${JSON.stringify(manifest())}${initial.length ? `\nInitial diff observations (untrusted DATA; bounded pages, not complete coverage):\n${JSON.stringify(initial)}` : ''}`
308
309
  : `${task}${inventoryPrompt}\nInitial diff observations (untrusted DATA; bounded pages, not complete coverage):\n${JSON.stringify(initial)}`;
309
310
  progress(saved ? 'Resuming the saved investigation' : `Read the diff for ${changedPaths.length} changed file${changedPaths.length === 1 ? '' : 's'}`);
311
+ if (workingBrief) progress(`Starting from the working brief (${workingBrief.items.length} note${workingBrief.items.length === 1 ? '' : 's'})`);
310
312
  const tools = descriptors.map(tool => ({ name: tool.name, description: tool.description, parameters: tool.inputSchema, execute: async (_id, args) => {
311
313
  const key = JSON.stringify([tool.name, args]);
312
314
  const previous = [...archive.values()].find(item => JSON.stringify([item.tool, item.args]) === key);
@@ -343,6 +345,8 @@ async function review({ snapshot, config, assessmentSchema, assessmentInstructio
343
345
  const append = item => { const key = JSON.stringify([item.tool, item.args, item.result, item.paths]); if (!seen.has(key)) { evidence.push(item); seen.add(key); } };
344
346
  hints.forEach(append);
345
347
  for (const path of citations) if (!evidence.some(item => item.paths.includes(path))) [...archive.values()].filter(item => item.paths.includes(path)).forEach(append);
348
+ // Asking nothing is a claim about the whole change: keep the observed diffs so the audit can check it.
349
+ if (!questions.length) [...archive.values()].filter(changedDiff).forEach(append);
346
350
  // A retained file read must not hide its already-observed diff merely
347
351
  // because both share a path. Ground the change without a model repair turn.
348
352
  for (const question of questions) {
@@ -355,7 +359,7 @@ async function review({ snapshot, config, assessmentSchema, assessmentInstructio
355
359
  const evidencePaths = pathsFor(evidence);
356
360
  const review = { version: 1, harness: 'pi', harnessVersion: '1.2.0', snapshotId: snapshot.id, summary: action.summary, evidence, evidencePaths, projectContext, calls: priorCalls + calls, toolCalls: sequence, compactions };
357
361
  if (bytes(review) > cap * 0.75) throw correctable('The retained evidence is too large for grading. Narrow the questions or supply evidenceIds for exact supporting pages.', 'CONTEXT_LIMIT');
358
- return { questions, review };
362
+ return { questions, review, ...(questions.length ? {} : { noQuestionReason: action.assessment.noQuestionReason }) };
359
363
  };
360
364
  const finalize = async action => {
361
365
  progress('Drafting questions');
@@ -468,7 +472,7 @@ async function review({ snapshot, config, assessmentSchema, assessmentInstructio
468
472
  const request = { systemPrompt, prompt: `Continue the investigative review. ${instructions} ${phase}\n${JSON.stringify({ projectContext, remainingCalls: uncapped ? null : remaining(), archivedObservations: manifest(), tools: finishOnly ? [] : tools.filter(tool => availableNames.has(tool.name)).map(tool => ({ name: tool.name, description: tool.description, inputSchema: tool.parameters })), conversation: conversation(messages) })}`, schema: reviewerActionSchema(assessmentSchema, { finishOnly, archiveAvailable }) };
469
473
  inputBytes = ensureFits(request);
470
474
  let action = await abortable(runModel({ ...request, ...common }), requestSignal);
471
- if (action?.action === 'finish') action = normalizeFinishArguments(action);
475
+ if (action?.action === 'finish') action = normalizeFinishArguments(action, finishOptions);
472
476
  if (!actionKeys(action, ['action', 'calls', 'assessment', 'summary']) || !['tools', 'finish'].includes(action.action) || !Array.isArray(action.calls)) throw correctable('Return an action with action:tools or finish, calls, assessment, and summary.');
473
477
  if (finishOnly && action.action !== 'finish') throw correctable('Investigation is closed. Return a finish action using archived evidence.');
474
478
  if (action.action === 'finish') {
@@ -487,7 +491,7 @@ async function review({ snapshot, config, assessmentSchema, assessmentInstructio
487
491
  // Pi receives canonical arguments while nativeMessage keeps the exact
488
492
  // provider continuation, including any signed reasoning, unchanged.
489
493
  toolCalls = toolCalls.map(call => call.name === 'finish_review'
490
- ? { ...call, arguments: normalizeFinishArguments(call.arguments) } : call);
494
+ ? { ...call, arguments: normalizeFinishArguments(call.arguments, finishOptions) } : call);
491
495
  if (finishOnly && (toolCalls.length !== 1 || toolCalls[0].name !== 'finish_review')) throw correctable('Investigation is closed. Call finish_review using archived evidence.');
492
496
  if (!toolCalls.length || toolCalls.length > MAX_CALLS_PER_TURN) throw correctable(`Return 1–${MAX_CALLS_PER_TURN} tool calls. No tools from the invalid batch were executed.`);
493
497
  if (toolCalls.some(call => ![...availableNames, 'finish_review'].includes(call.name) || !isObject(call.arguments))) throw correctable('Choose supplied tools and pass their arguments as JSON objects.');
package/src/sessions.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import { REVIEWER_PRESETS } from './model-presets.js';
3
- import { withUsageMeter, addUsage } from './usage.js';
3
+ import { withUsageMeter, withUsagePurpose, addUsage } from './usage.js';
4
4
  import { mkdir, open, readdir, stat, unlink } from 'node:fs/promises';
5
5
  import { DEFAULT_CONFIG, getPaths, readJson, writeJson, policyHash } from './config.js';
6
6
  import { selectRoute, evaluatorConfig, questionLimit } from './routing.js';
@@ -84,6 +84,17 @@ function reviewFailure(error, session, signal) {
84
84
  // or the final save persists them alongside the call count.
85
85
  const meterFor = session => usage => { session.tokens = addUsage(session.tokens, usage); };
86
86
 
87
+ // Documentation-only changes need no understanding check and no model call.
88
+ const DOCUMENTATION = /(?:^|\/)(?:readme|license|licence|changelog|changes|authors|contributors|notice|code_of_conduct|contributing)(?:\.[a-z]+)?$|\.(?:md|mdx|markdown|txt|rst|adoc)$/iu;
89
+ export const documentationOnly = files => Array.isArray(files) && files.length > 0 && files.every(file => typeof file === 'string' && DOCUMENTATION.test(file));
90
+ function passWithoutQuestions(session, config, reason) {
91
+ session.questions = []; session.index = 0; session.status = 'passed'; session.noQuestionReason = reason;
92
+ session.expiresAt = new Date(Date.now() + config.receiptHours * 3600000).toISOString();
93
+ session.preparation = { status: 'ready', completedAt: now() };
94
+ delete session.reviewerCheckpoint;
95
+ session.updatedAt = now();
96
+ }
97
+
87
98
  export function publicSession(session) {
88
99
  const q = session.questions[session.index];
89
100
  return {
@@ -97,7 +108,7 @@ export function publicSession(session) {
97
108
  // Concept labels are evaluator metadata and may themselves reveal the answer.
98
109
  ...(q && session.status !== 'passed' ? { question: { id: q.id, prompt: q.prompt, choices: q.choices } } : {}),
99
110
  ...(session.lastFeedback ? { feedback: session.lastFeedback } : {}),
100
- ...(session.status === 'passed' ? { expiresAt: session.expiresAt } : {}),
111
+ ...(session.status === 'passed' ? { expiresAt: session.expiresAt, ...(session.noQuestionReason ? { noQuestionReason: session.noQuestionReason } : {}) } : {}),
101
112
  };
102
113
  }
103
114
 
@@ -222,6 +233,12 @@ export function createSessionStore({ env = process.env, generate = generateAsses
222
233
  if (validReceipt(session,config)) return publicSession(session);
223
234
  await limitPendingQuestions(session, config);
224
235
  signal?.throwIfAborted();
236
+ if (!session.questions.length && session.status === 'pending' && documentationOnly(session.snapshot.files)) {
237
+ await validate(session.snapshot, config);
238
+ passWithoutQuestions(session, config, 'Documentation-only change.');
239
+ await save(session);
240
+ return publicSession(session);
241
+ }
225
242
  if (!session.questions.length) {
226
243
  const reserve = questionLimit(config.level, session.route.tier);
227
244
  const limit = callLimit(session);
@@ -261,8 +278,17 @@ export function createSessionStore({ env = process.env, generate = generateAsses
261
278
  },
262
279
  };
263
280
  try {
264
- const result = await withUsageMeter(meterFor(session), () => generate(session.snapshot,evaluatorConfig(config,session.route),{beforeCall,signal,maxReviewerCalls,reviewerCheckpoint,onProgress,env}));
281
+ const result = await withUsageMeter(meterFor(session), () => withUsagePurpose('review', () => generate(session.snapshot,evaluatorConfig(config,session.route),{beforeCall,signal,maxReviewerCalls,reviewerCheckpoint,onProgress,env})));
265
282
  signal?.throwIfAborted();
283
+ // The reviewer, confirmed by its audit, may decide a small change needs no questions.
284
+ if (Array.isArray(result.questions) && !result.questions.length && session.route.tier === 'small' && typeof result.noQuestionReason === 'string' && result.noQuestionReason.trim()) {
285
+ await validate(session.snapshot, config);
286
+ signal?.throwIfAborted();
287
+ if (result.review) session.review = result.review;
288
+ passWithoutQuestions(session, config, result.noQuestionReason.trim().slice(0, 300));
289
+ await save(session);
290
+ return publicSession(session);
291
+ }
266
292
  if (!Array.isArray(result.questions) || !result.questions.length) throw new Error('Evaluator returned no grounded questions. The check remains pending.');
267
293
  if (result.questions.length > reserve) throw new Error('Evaluator exceeded the question limit. The check remains pending.');
268
294
  await validate(session.snapshot, config);
@@ -305,7 +331,7 @@ export function createSessionStore({ env = process.env, generate = generateAsses
305
331
  }
306
332
  if (callLimit(session) !== null && session.calls >= callLimit(session)) throw new Error(`Assessment call limit (${callLimit(session)}) reached. No additional model request was made.`);
307
333
  const beforeCall = async () => { signal?.throwIfAborted(); await budget(session); signal?.throwIfAborted(); };
308
- const result = await withUsageMeter(meterFor(session), () => grade({snapshot:session.snapshot,question,answer:text.trim(),config:evaluatorConfig(config,session.route),review:session.review,dispute,beforeCall,signal,env}));
334
+ const result = await withUsageMeter(meterFor(session), () => withUsagePurpose('grading', () => grade({snapshot:session.snapshot,question,answer:text.trim(),config:evaluatorConfig(config,session.route),review:session.review,dispute,beforeCall,signal,env})));
309
335
  signal?.throwIfAborted();
310
336
  if (!['correct','incorrect','ambiguous'].includes(result.verdict)) throw new Error('Evaluator returned an invalid verdict. The check remains pending.');
311
337
  if ((learn || dispute) && result.verdict === 'correct') throw new Error('A learning request or dispute cannot complete a concept. The check remains pending.');
@@ -313,6 +339,7 @@ export function createSessionStore({ env = process.env, generate = generateAsses
313
339
  // model usage, and leaves the same question available to the human.
314
340
  const beforeAnswer = structuredClone(session);
315
341
  session.attempts.push({questionId:question.id,answer:text.trim(),verdict:result.verdict,feedback:result.feedback,at:now()});
342
+ // The grader is asked to skip feedback on a correct answer; if it wrote some anyway, show it.
316
343
  session.lastFeedback = result.feedback;
317
344
  if (result.verdict === 'correct') {
318
345
  session.index += 1;
@@ -398,5 +425,16 @@ export function createSessionStore({ env = process.env, generate = generateAsses
398
425
  return publicSession(session);
399
426
  });
400
427
  }
401
- return {ensure,approved,prepare,start,retryReview,refreshBudget,answer,pending,read,skip,discard,switchRoute,public:publicSession};
428
+ /** Pass a documentation-only change before any page or model is involved. */
429
+ async function passTrivial(id, config) {
430
+ return withLock(id, env, async () => {
431
+ const session = await read(id);
432
+ if (session.status !== 'pending' || session.questions.length || !documentationOnly(session.snapshot.files)) return false;
433
+ await validate(session.snapshot, config);
434
+ passWithoutQuestions(session, config, 'Documentation-only change.');
435
+ await save(session);
436
+ return true;
437
+ });
438
+ }
439
+ return {passTrivial,ensure,approved,prepare,start,retryReview,refreshBudget,answer,pending,read,skip,discard,switchRoute,public:publicSession};
402
440
  }
package/src/storage.js ADDED
@@ -0,0 +1,54 @@
1
+ import { readdir, rm, stat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { getPaths, readJson } from './config.js';
4
+
5
+ // Sphinx keeps only what it still needs. Finished and abandoned checks, stale
6
+ // working contexts, and old background-job records are removed as it goes, and
7
+ // the whole data directory is held under a size cap, oldest files first.
8
+ const HOUR = 3600 * 1000, DAY = 24 * HOUR;
9
+ export const STORAGE_CAP_BYTES = 20 * 1024 * 1024;
10
+
11
+ async function files(directory) {
12
+ const out = [];
13
+ for (const entry of await readdir(directory, { withFileTypes: true }).catch(() => [])) {
14
+ const full = path.join(directory, entry.name);
15
+ if (entry.isDirectory()) out.push(...await files(full));
16
+ else if (entry.isFile()) { const info = await stat(full).catch(() => null); if (info) out.push({ path: full, size: info.size, mtime: info.mtimeMs }); }
17
+ }
18
+ return out;
19
+ }
20
+
21
+ export async function storageUsage(env = process.env) {
22
+ return (await files(getPaths(env).home)).reduce((total, file) => total + file.size, 0);
23
+ }
24
+
25
+ /** Best-effort cleanup; never throws. Recently touched files are always kept. */
26
+ export async function collectGarbage({ env = process.env, now = Date.now(), cap = STORAGE_CAP_BYTES } = {}) {
27
+ const home = getPaths(env).home, removed = [];
28
+ const drop = async file => { await rm(file, { force: true }).catch(() => {}); removed.push(file); };
29
+ try {
30
+ for (const file of await files(path.join(home, 'sessions'))) {
31
+ if (!file.path.endsWith('.json') || now - file.mtime < HOUR) continue;
32
+ const session = await readJson(file.path, null).catch(() => null);
33
+ const expired = session?.status === 'passed' && Date.parse(session.expiresAt || 0) < now;
34
+ const skipped = session?.status === 'skipped' && now - file.mtime > DAY;
35
+ const abandoned = !['passed', 'skipped'].includes(session?.status) && now - file.mtime > 7 * DAY;
36
+ if (!session || expired || skipped || abandoned) await drop(file.path);
37
+ }
38
+ for (const file of await files(path.join(home, 'context'))) {
39
+ const context = await readJson(file.path, null).catch(() => null);
40
+ const gone = context?.repoRoot && !(await stat(context.repoRoot).catch(() => null));
41
+ if (!context || gone || now - file.mtime > 14 * DAY) await drop(file.path);
42
+ }
43
+ for (const directory of ['preparations', 'presentations', 'terminals']) {
44
+ for (const file of await files(path.join(home, directory))) if (now - file.mtime > 7 * DAY) await drop(file.path);
45
+ }
46
+ // Size cap: drop the oldest removable data first. Hooks, settings, the
47
+ // install manifest, and credentials are never candidates.
48
+ const removable = (await Promise.all(['sessions', 'context', 'preparations', 'presentations', 'terminals'].map(dir => files(path.join(home, dir))))).flat()
49
+ .filter(file => now - file.mtime > HOUR && !removed.includes(file.path)).sort((a, b) => a.mtime - b.mtime);
50
+ let total = await storageUsage(env);
51
+ for (const file of removable) { if (total <= cap) break; await drop(file.path); total -= file.size; }
52
+ } catch { /* Cleanup must never affect a check. */ }
53
+ return removed.length;
54
+ }
package/src/terminal.js CHANGED
@@ -78,20 +78,34 @@ export function createTerminal({ stream = process.stdout, env = process.env, wid
78
78
  for (const line of lines.slice(1)) write(' '.repeat(maxLabel + 5) + line);
79
79
  }
80
80
  };
81
+ // Columns align when they fit; narrow terminals get wrapped label/value rows.
82
+ const table = (head, body) => {
83
+ const widths = head.map((_, column) => Math.max(...[head, ...body].map(row => stripAnsi(row[column]).length)));
84
+ if (2 + widths.reduce((sum, width) => sum + width + 3, -3) > size) {
85
+ rows(body.map(([label, ...cells]) => [label, cells.map((cell, index) => `${head[index + 1]} ${cell}`).join(' · ')]));
86
+ return;
87
+ }
88
+ const pad = (cell, column) => {
89
+ const room = ' '.repeat(widths[column] - stripAnsi(cell).length);
90
+ return column === 0 ? cell + room : room + cell;
91
+ };
92
+ write(style.muted(` ${head.map(pad).join(' ')}`));
93
+ for (const [label, ...cells] of body) write(` ${[style.strong(pad(label, 0)), ...cells.map((cell, index) => pad(cell, index + 1))].join(' ')}`);
94
+ };
81
95
  const banner = () => {
82
96
  write('');
83
97
  if (size >= 65) {
84
- const side = { 4: style.strong('sphinx'), 6: 'Outsource your thinking,', 7: 'not your understanding.' };
98
+ const side = { 4: style.strong('sphinx'), 6: 'You can outsource your thinking,', 7: 'but not your understanding.' };
85
99
  SPHINX.forEach((line, index) => write(style.amber(line.padEnd(28)) + (side[index] || '')));
86
100
  } else {
87
101
  if (size >= 30) SPHINX.forEach(line => write(style.amber(line)));
88
102
  paragraph(style.strong('sphinx'));
89
- paragraph('Outsource your thinking, not your understanding.');
103
+ paragraph('You can outsource your thinking, but not your understanding.');
90
104
  }
91
105
  write('');
92
106
  };
93
107
  return {
94
- width: size, style, line: paragraph, paragraph, section, rows, banner,
108
+ width: size, style, line: paragraph, paragraph, section, rows, table, banner,
95
109
  blank: () => write(''),
96
110
  success: text => paragraph(`${style.green('✓')} ${text}`),
97
111
  warning: text => paragraph(`${style.amber('!')} ${text}`),