sphinx-understand 0.1.4 → 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.
package/src/git-gate.js CHANGED
@@ -5,6 +5,8 @@ import { createSessionStore } from './sessions.js';
5
5
  import { queuePresentation, claimPresentation, listPresentations } from './presentations.js';
6
6
  import { createBrowserInterview } from './browser-interview.js';
7
7
  import { yoloStatus, recordOverride } from './overrides.js';
8
+ import { dropContext } from './working-context.js';
9
+ import { collectGarbage } from './storage.js';
8
10
 
9
11
  async function snapshotsFor({ event, args, input, cwd, config, env }) {
10
12
  if (event === 'pre-push') return pushSnapshots({ cwd, remote: args[0], remoteUrl: args[1], input, config, env });
@@ -35,6 +37,11 @@ export async function runGitGate({ event, args = [], input = '', cwd = process.c
35
37
  const config = await readConfig(repo.root, env);
36
38
  const snapshots = await collectSnapshots({ event, args, input, cwd, config, env });
37
39
  if (!snapshots.length) return 0;
40
+ // Once Git continues, the branch's working context has served its purpose.
41
+ const finished = async () => {
42
+ for (const snapshot of snapshots) if (snapshot.event === 'pre-push') await dropContext({ repoRoot: snapshot.repoRoot, sourceRef: snapshot.sourceRef, env }).catch(() => {});
43
+ await collectGarbage({ env });
44
+ };
38
45
  const validate = async (snapshot, expected) => {
39
46
  signal?.throwIfAborted();
40
47
  const current = await readConfig(snapshot.repoRoot, env);
@@ -48,6 +55,7 @@ export async function runGitGate({ event, args = [], input = '', cwd = process.c
48
55
  if (yolo) {
49
56
  for (const snapshot of snapshots) await logOverride({ kind: 'yolo', repoRoot: repo.root, target: snapshot.target.ref, id: snapshot.id, head: snapshot.head, reason: yolo.reason }, env);
50
57
  output(`Sphinx: yolo mode is on ${yolo.scope === 'repo' ? 'for this repository' : 'globally'}; skipping the understanding check. Run sphinx yolo${yolo.scope === 'global' ? ' outside a repository' : ''} to turn it off.`);
58
+ await finished();
51
59
  return 0;
52
60
  }
53
61
  const store = providedStore ?? createSessionStore({ env, validate });
@@ -56,6 +64,7 @@ export async function runGitGate({ event, args = [], input = '', cwd = process.c
56
64
  signal?.throwIfAborted();
57
65
  if (await store.approved(snapshot, config)) continue;
58
66
  const session = await store.ensure(snapshot, config);
67
+ if (await store.passTrivial?.(session.id, config)) { output(`Sphinx: documentation-only change to ${snapshot.target.ref}; no questions needed.`); continue; }
59
68
  pending.push({ snapshot, session });
60
69
  output(`Sphinx: understanding check needed for ${snapshot.target.ref} (${session.route.tier} change).`);
61
70
  }
@@ -106,7 +115,9 @@ export async function runGitGate({ event, args = [], input = '', cwd = process.c
106
115
  }
107
116
  }
108
117
  signal?.throwIfAborted();
109
- if (requests.length) output('Sphinx: understanding check complete. Continuing this Git operation.');
118
+ // Skipped checks already reported themselves; only a pass is a completed check.
119
+ if (requests.length > skipped.size) output('Sphinx: understanding check complete. Continuing this Git operation.');
120
+ await finished();
110
121
  return 0;
111
122
  } finally {
112
123
  // A paused or cancelled batch must not reopen from an after-tool callback.
package/src/help.js CHANGED
@@ -68,8 +68,8 @@ export const HELP = {
68
68
  summary: 'Set up a repository policy and content exclusions.',
69
69
  usage: 'sphinx init [options]',
70
70
  paragraphs: ['Creates .sphinxconfig (JSON policy), .sphinxignore, and .sphinxcontext.md (project purpose, workflows, and architecture). The reviewer receives project context every time. Existing files are preserved. Defaults inherit your effective global policy, not the current checkout branch.', 'Legacy .sphinx.json policy is copied into a new .sphinxconfig when needed; the original remains. This command does not install hooks, change global settings, or call a model.'],
71
- groups: [['Setup', [['--yes', 'Accept inherited defaults without prompts.'], ['--context <description>', 'Initial project context when .sphinxcontext.md does not exist. Otherwise the existing file is preserved.'], ...policy]], ['Output', common]],
72
- examples: ['sphinx init', 'sphinx init --yes --branches main', 'sphinx init --events pre-push,pre-commit --level thorough', 'sphinx init --yes --json'],
71
+ groups: [['Setup', [['--yes', 'Accept inherited defaults without prompts.'], ['--context <description>', 'Initial project context when .sphinxcontext.md does not exist. Otherwise the existing file is preserved.'], ...policy]], ['For coding agents', [['--suggest', 'Print a JSON proposal (branches, ignore paths, context stub) from the repository. Writes nothing.'], ['--apply <file|->', 'Validate a reviewed proposal and write it. Keeps existing files, merges ignore lines, and is safe to rerun.']]], ['Output', common]],
72
+ examples: ['sphinx init', 'sphinx init --yes --branches main', 'sphinx init --events pre-push,pre-commit --level thorough', 'sphinx init --yes --json', 'sphinx init --suggest --json > proposal.json', 'sphinx init --apply proposal.json --json'],
73
73
  footer: 'Destination branches are where a push lands: pushing feature:main matches main. Add sensitive project paths to .sphinxignore before your first assessment.',
74
74
  },
75
75
  check: {
@@ -92,13 +92,14 @@ export const HELP = {
92
92
  config: { summary: 'Inspect effective settings or change evaluator models and call limits.', usage: 'sphinx config [--preset <name>] [--small <provider:model>] [--max-model-calls <1-100|unlimited>] [--json]', paragraphs: ['Without a setter, this command only displays effective configuration. Repository .sphinxconfig overrides supported global policy fields; legacy .sphinx.json is used only when the canonical file is absent. Model routes, credentials, and optional call caps remain machine-wide.', 'Save evaluator models or the total assessment allowance without reinstalling or invoking a model. This works outside a Git repository and preserves unrelated global settings. With --json, a save reports the global path, previous and new limit, and resulting global configuration. Size-based planning targets do not impose a hidden request cap. Existing explicitly configured limits are preserved unless changed.', 'Evaluator changes apply to fresh checkpoints; existing checkpoints are bound to their original route and policy. Budget-only changes preserve their usage and questions. OpenRouter presets require a saved key or OPENROUTER_API_KEY before the next assessment.'], groups: [['Evaluators and usage', evaluatorOptions], ['Output', common]], examples: ['sphinx config', 'sphinx config --json', 'sphinx config --preset openrouter-value --allow-paid', 'sphinx config --preset claude-economy --large claude:sonnet', 'sphinx config --max-model-calls unlimited', 'sphinx config --max-model-calls 30 --json'] },
93
93
  yolo: { summary: 'Toggle skipping understanding checks when something is urgent.', usage: 'sphinx yolo [--reason <text>] [--json]', paragraphs: ['Run inside a Git repository to toggle yolo mode for that repository only; turning it off there keeps checks on even while global yolo is on. Run outside a repository to toggle it globally. Run the same command again to turn it off.', 'While on, Git operations continue without a questionnaire and background review is paused: no new model request starts, although one already in progress when yolo is turned on may finish. It is a straight bypass: no check is created. Each skip is logged with an optional reason; sphinx status shows recent overrides. For a one-off, use Skip this time in the questionnaire instead.'], groups: [['Reason', [['--reason <text>', 'Optional note recorded with the toggle, e.g. a hotfix ticket.']]], ['Output', common]], examples: ['sphinx yolo --reason "prod hotfix"', 'sphinx yolo', 'cd ~ && sphinx yolo'] },
94
94
  doctor: { summary: 'Check Git dispatchers and coding-agent integration settings.', usage: 'sphinx doctor [--json]', paragraphs: ['Finds local hook overrides, missing dispatchers, and edited integration settings. Optional agents that were not selected do not count as failures. Codex trust must be reviewed in /hooks; Sphinx cannot verify it.'], groups: [['Output', common]], examples: ['sphinx doctor', 'sphinx install --repo'], footer: 'Exit status is 0 when checks pass and 1 when something needs attention. No model calls or configuration changes.' },
95
+ usage: { summary: 'See tokens and spend from Sphinx model calls.', usage: 'sphinx usage [--json]', paragraphs: ['Shows tokens and cost for today, the last 7 and 30 days, and all recorded history, split into free OpenRouter models, paid OpenRouter models, OpenAI-compatible endpoints, and Claude Code or Codex coding plans, then by purpose such as review, grading, and context.', 'Coding-plan routes show tokens only: they count against your Claude or ChatGPT plan, not a per-call bill. Endpoints show cost only when they report it.'], groups: [['Output', common]], examples: ['sphinx usage', 'sphinx usage --json'], footer: 'Each model call appends one small line to usage/YYYY-MM.jsonl in your Sphinx home. Only the last 12 monthly files are kept; older months are deleted automatically. Reading usage makes no model calls.' },
95
96
  auth: { summary: 'Save an OpenRouter key or an OpenAI-compatible endpoint.', usage: 'sphinx auth [--provider openrouter|openai] [--base-url <url>]', paragraphs: ['Prompts for a key with hidden input. The key is stored in a user-only credentials.json file outside repositories; this is a local file, not an OS keychain.', 'For scripts, set OPENROUTER_API_KEY instead. Free models also require a key. Codex/Claude use their own CLI authentication.'], groups: [['Provider', [['--provider openai', 'Save a base URL and API key for any OpenAI chat-completions-compatible server (OpenAI, Groq, Together, vLLM, Ollama, LM Studio). The key is only sent to that URL. For scripts, set SPHINX_OPENAI_BASE_URL and SPHINX_OPENAI_API_KEY.'], ['--base-url <url>', 'Endpoint base URL, e.g. https://api.openai.com/v1. https only, except localhost.']]], ['Output', [common[0]]]], examples: ['sphinx auth'], footer: 'Create a key at https://openrouter.ai/settings/keys. Never put it in .sphinxconfig or commit it to Git.' },
96
97
  uninstall: { summary: 'Remove Sphinx integrations and restore previous hooks.', usage: 'sphinx uninstall [--repo]', paragraphs: ['Removes unchanged Sphinx files and agent handlers, preserving unrelated settings and later user edits. Configuration, credentials, and assessment state remain for you to keep or delete.', 'Global uninstall leaves explicit repository integrations in place. Remove those in each repository before removing the npm package.'], groups: [['Scope', [['--repo', 'Remove only this repository’s explicit hook integration.']]], ['Output', [common[0]]]], examples: ['sphinx uninstall --repo', 'sphinx uninstall', 'npm uninstall -g sphinx-understand'], footer: 'Run Sphinx uninstall before npm uninstall. Resolve warnings about preserved handlers that still refer to Sphinx before removing its executable.' },
97
98
  };
98
99
 
99
100
  export function printHelp(ui, command, { json = false } = {}) {
100
101
  if (command && !HELP[command]) throw new Error(`Unknown command ${command}. Run sphinx --help.`);
101
- const data = command ? { command, ...HELP[command] } : { command: 'sphinx', summary: 'Outsource your thinking, not your understanding.', commands: Object.fromEntries(Object.entries(HELP).map(([name, help]) => [name, help.summary])) };
102
+ const data = command ? { command, ...HELP[command] } : { command: 'sphinx', summary: 'You can outsource your thinking, but not your understanding.', commands: Object.fromEntries(Object.entries(HELP).map(([name, help]) => [name, help.summary])) };
102
103
  if (json) { console.log(JSON.stringify(data, null, 2)); return; }
103
104
  if (!command) {
104
105
  ui.banner();
@@ -108,7 +109,7 @@ export function printHelp(ui, command, { json = false } = {}) {
108
109
  ui.section('At a checkpoint');
109
110
  ui.rows([['sphinx check', 'Start or resume the interview.'], ['sphinx pending', 'List unfinished checks.'], ['sphinx explain <id>', 'Get a walkthrough and a new scenario.']]);
110
111
  ui.section('Settings and troubleshooting');
111
- ui.rows([['sphinx activate', 'Open fresh coding-agent sessions with Sphinx.'], ['sphinx config', 'Inspect effective policy, model routes, and file paths.'], ['sphinx doctor', 'Check Git and agent integrations.'], ['sphinx auth', 'Save an OpenRouter key with hidden input.'], ['sphinx uninstall', 'Restore previous hooks and remove integrations.']]);
112
+ ui.rows([['sphinx activate', 'Open fresh coding-agent sessions with Sphinx.'], ['sphinx config', 'Inspect effective policy, model routes, and file paths.'], ['sphinx doctor', 'Check Git and agent integrations.'], ['sphinx usage', 'See tokens and spend by model type and purpose.'], ['sphinx auth', 'Save an OpenRouter key with hidden input.'], ['sphinx uninstall', 'Restore previous hooks and remove integrations.']]);
112
113
  ui.blank(); ui.paragraph('Use sphinx <command> --help for options and examples. Git uses a local browser questionnaire; client adapters provide native fallback dialogs where supported. Terminal commands also include answer and dispute; use --json for structured output.');
113
114
  ui.blank(); ui.note(`Default policy: pushes to main/master · question caps: ${questionCapsText('standard')}. Guided setup helps you choose economical evaluator models.`);
114
115
  return;
package/src/install.js CHANGED
@@ -1,4 +1,5 @@
1
- import { access, chmod, lstat, mkdir, readFile, readdir, rename, rm, rmdir, stat, writeFile } from 'node:fs/promises';
1
+ import { access, chmod, lstat, mkdir, readFile, readdir, realpath, rename, rm, rmdir, stat, writeFile } from 'node:fs/promises';
2
+ import { settingsTarget } from './user-file.js';
2
3
  import { constants } from 'node:fs';
3
4
  import { spawn } from 'node:child_process';
4
5
  import { createHash, randomUUID } from 'node:crypto';
@@ -22,6 +23,29 @@ const GATED = new Set(['pre-push', 'pre-commit']);
22
23
  const PREPARED = new Set(['post-commit', 'post-merge', 'post-rewrite', 'post-checkout']);
23
24
  const hash = value => createHash('sha256').update(value).digest('hex');
24
25
  const quote = value => `'${String(value).replaceAll("'", "'\\''")}'`;
26
+
27
+ /**
28
+ * The shell command hooks use to run Sphinx. Prefer the package manager's own
29
+ * `sphinx` on PATH (an npm/yarn symlink or a pnpm/bun shim): it survives
30
+ * upgrades, unlike a versioned store path. Node's directory is put first on
31
+ * PATH so GUI-launched agents without a login PATH still find node. A checkout
32
+ * without such a shim falls back to absolute node and CLI paths.
33
+ * Returns shell `env` assignments (placed before any exec) and the `command`.
34
+ */
35
+ export async function sphinxLauncher(cliPath, env = process.env, execPath = process.execPath) {
36
+ const real = await realpath(cliPath).catch(() => cliPath);
37
+ for (const directory of String(env.PATH || '').split(path.delimiter).filter(dir => path.isAbsolute(dir))) {
38
+ const candidate = path.join(directory, 'sphinx');
39
+ try {
40
+ const info = await stat(candidate);
41
+ if (!info.isFile() || !(info.mode & 0o111)) continue;
42
+ const matches = await realpath(candidate) === real
43
+ || (info.size < 64 * 1024 && (await readFile(candidate, 'utf8')).includes('sphinx-understand/bin/sphinx.js'));
44
+ if (matches) return { env: `PATH=${quote(path.dirname(execPath))}:"$PATH" `, command: quote(candidate) };
45
+ } catch { /* Not a usable shim. */ }
46
+ }
47
+ return { env: '', command: `${quote(execPath)} ${quote(cliPath)}` };
48
+ }
25
49
  const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
26
50
  const AGENTS = ['codex', 'claude', 'pi', 'cursor', 'opencode'];
27
51
 
@@ -55,8 +79,7 @@ async function exists(file) {
55
79
 
56
80
  async function readJson(file, fallback) {
57
81
  try {
58
- if ((await lstat(file)).isSymbolicLink()) throw new Error(`Refusing to replace symlink: ${file}`);
59
- const value = JSON.parse(await readFile(file, 'utf8'));
82
+ const value = JSON.parse(await readFile((await settingsTarget(file)).path, 'utf8'));
60
83
  if (!value || Array.isArray(value) || typeof value !== 'object') throw new Error('expected an object');
61
84
  return value;
62
85
  } catch (error) {
@@ -66,7 +89,9 @@ async function readJson(file, fallback) {
66
89
  }
67
90
  }
68
91
 
69
- async function writeJson(file, value) {
92
+ async function writeJson(link, value) {
93
+ // Write through a dotfiles symlink to its target, atomically, keeping the link.
94
+ const file = (await settingsTarget(link)).path;
70
95
  await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
71
96
  const mode = await stat(file).then(s => s.mode & 0o777).catch(() => 0o600);
72
97
  const temporary = `${file}.${randomUUID()}.tmp`;
@@ -119,15 +144,15 @@ function sourceShell(source) {
119
144
  : 'if ! hook_dir=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null); then\n if [ -z "${GIT_DIR:-}" ] || [ ! -d "$GIT_DIR" ]; then\n git rev-parse --git-common-dir >/dev/null\n exit 128\n fi\n hook_dir=$GIT_DIR\nfi\nhook_dir="$hook_dir/hooks"\n';
120
145
  }
121
146
 
122
- function hookScript({ event, source, cliPath, home, id }) {
147
+ function hookScript({ event, source, launcher, home, id }) {
123
148
  const header = '#!/bin/sh\n# Managed by Sphinx. Existing hooks remain in their original directory.\n';
124
149
  if (GATED.has(event)) {
125
- return `${header}SPHINX_HOME=${quote(home)} SPHINX_INSTALL_ID=${quote(id)} exec ${quote(process.execPath)} ${quote(cliPath)} hook git ${quote(event)} "$@"\n`;
150
+ return `${header}SPHINX_HOME=${quote(home)} SPHINX_INSTALL_ID=${quote(id)} ${launcher.env}exec ${launcher.command} hook git ${quote(event)} "$@"\n`;
126
151
  }
127
152
  if (PREPARED.has(event)) {
128
153
  // The existing hook retains streaming stdin and its exit status. Scheduling
129
154
  // has no terminal, runs asynchronously, and never gates the Git operation.
130
- return `${header}${sourceShell(source)}hook="$hook_dir/${event}"\nstatus=0\nif [ -x "$hook" ]; then\n "$hook" "$@"\n status=$?\nfi\nif [ "\${SPHINX_EVALUATOR:-}" != "1" ]; then\n SPHINX_HOME=${quote(home)} ${quote(process.execPath)} ${quote(cliPath)} prepare </dev/null >/dev/null 2>&1 &\nfi\nexit "$status"\n`;
155
+ return `${header}${sourceShell(source)}hook="$hook_dir/${event}"\nstatus=0\nif [ -x "$hook" ]; then\n "$hook" "$@"\n status=$?\nfi\nif [ "\${SPHINX_EVALUATOR:-}" != "1" ]; then\n SPHINX_HOME=${quote(home)} ${launcher.env}${launcher.command} prepare </dev/null >/dev/null 2>&1 &\nfi\nexit "$status"\n`;
131
156
  }
132
157
  // Protocol hooks such as proc-receive must retain streaming stdin/stdout.
133
158
  // They exec the prior hook directly rather than buffering input in Node.
@@ -169,7 +194,7 @@ export async function detectAgents({ env = process.env } = {}) {
169
194
  return detectedAgents({ paths, env, manifest });
170
195
  }
171
196
 
172
- async function prepareAgents({ agents, paths, env, cliPath, manifest }) {
197
+ async function prepareAgents({ agents, paths, env, cliPath, launcher, manifest }) {
173
198
  const selected = agents === 'auto'
174
199
  ? await detectedAgents({ paths, env, manifest })
175
200
  : agents === 'none' ? [] : Array.isArray(agents) ? agents : agents.split(',').map(x => x.trim());
@@ -178,13 +203,13 @@ async function prepareAgents({ agents, paths, env, cliPath, manifest }) {
178
203
  for (const name of new Set(selected)) {
179
204
  if (['pi', 'cursor', 'opencode'].includes(name)) {
180
205
  const prepare = name === 'pi' ? preparePiIntegration : name === 'cursor' ? prepareCursorIntegration : prepareOpenCodeIntegration;
181
- result.push(await prepare(paths, { env, cliPath, previous: manifest.agents[name] }));
206
+ result.push(await prepare(paths, { env, cliPath, launcher, previous: manifest.agents[name] }));
182
207
  continue;
183
208
  }
184
209
  const file = path.join(paths[`${name}Home`], name === 'codex' ? 'hooks.json' : 'settings.json');
185
210
  const document = await readJson(file, {});
186
211
  if (document.hooks !== undefined && (!document.hooks || typeof document.hooks !== 'object' || Array.isArray(document.hooks))) throw new Error(`Invalid hooks object in ${file}`);
187
- const command = `SPHINX_HOME=${quote(paths.home)} ${quote(process.execPath)} ${quote(cliPath)} hook agent ${name}`;
212
+ const command = `SPHINX_HOME=${quote(paths.home)} ${launcher.env}${launcher.command} hook agent ${name}`;
188
213
  const handler = { type: 'command', command, timeout: 10 };
189
214
  const hooks = nativeHookSpecs(name, handler);
190
215
  for (const { event } of hooks) {
@@ -218,7 +243,8 @@ export async function install({ config, agents = 'auto', repo = false, cwd = pro
218
243
  const id = info.id;
219
244
  const previous = manifest.installs[id];
220
245
  // Validate every selected settings file before mutating any integration.
221
- const agentPlans = await prepareAgents({ agents, paths, env, cliPath, manifest });
246
+ const launcher = await sphinxLauncher(cliPath, env);
247
+ const agentPlans = await prepareAgents({ agents, paths, env, cliPath, launcher, manifest });
222
248
  const scopeValues = await currentConfig(info.scope, cwd, env);
223
249
  if (previous && scopeValues.at(-1) !== previous.hookDir) {
224
250
  throw new Error('Git hooks changed since Sphinx was installed. Run sphinx uninstall first; your current configuration will be preserved.');
@@ -235,7 +261,7 @@ export async function install({ config, agents = 'auto', repo = false, cwd = pro
235
261
  });
236
262
  if (directoryState?.isSymbolicLink()) throw new Error(`Refusing to replace a symlinked hook directory: ${entry.hookDir}`);
237
263
  const names = await hookNames(entry.source, cwd, env);
238
- const scripts = names.map(event => ({ event, content: hookScript({ event, source: entry.source, cliPath, home: paths.home, id }) }));
264
+ const scripts = names.map(event => ({ event, content: hookScript({ event, source: entry.source, launcher, home: paths.home, id }) }));
239
265
  for (const { event, content } of scripts) {
240
266
  const file = path.join(entry.hookDir, event);
241
267
  const fileState = await lstat(file).catch(error => {
@@ -1,10 +1,12 @@
1
1
  import path from 'node:path';
2
+ import { settingsTarget } from './user-file.js';
2
3
  import { fileURLToPath, pathToFileURL } from 'node:url';
3
4
  import { lstat, readFile, mkdir, writeFile, rename, rm, stat } from 'node:fs/promises';
4
5
  import { createHash, randomUUID } from 'node:crypto';
5
6
  import { listPresentations, claimPresentation } from './presentations.js';
6
7
  import { launchCheckpointTerminal, waitForCheckpointTerminal } from './terminal-launch.js';
7
8
  import { createBrowserInterview } from './browser-interview.js';
9
+ import { setupNudge } from './repo-setup.js';
8
10
 
9
11
  const hash = value => createHash('sha256').update(value).digest('hex');
10
12
  const same = (left, right) => JSON.stringify(left) === JSON.stringify(right);
@@ -36,9 +38,9 @@ export function lifecycleHome(name, paths, env = process.env) {
36
38
 
37
39
  async function text(file) {
38
40
  try {
39
- const info = await lstat(file);
40
- if (!info.isFile() || info.isSymbolicLink() || info.size > 4 * 1024 * 1024) throw new Error(`Refusing to replace a symlink, non-file, or oversized integration: ${file}`);
41
- return await readFile(file, 'utf8');
41
+ const target = (await settingsTarget(file)).path, info = await lstat(target);
42
+ if (!info.isFile() || info.size > 4 * 1024 * 1024) throw new Error(`Refusing to replace a non-file or oversized integration: ${file}`);
43
+ return await readFile(target, 'utf8');
42
44
  } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
43
45
  }
44
46
 
@@ -55,8 +57,10 @@ function json(content, file) {
55
57
  catch { throw new Error(`Cannot read ${file}: invalid JSON object. Fix the file and try again.`); }
56
58
  }
57
59
 
58
- async function write(file, content) {
59
- if (content === null) { await rm(file, { force: true }); return; }
60
+ async function write(link, content) {
61
+ const { path: file, linked } = await settingsTarget(link);
62
+ if (content === null && !linked) { await rm(file, { force: true }); return; }
63
+ content ??= '';
60
64
  await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
61
65
  const mode = await stat(file).then(info => info.mode & 0o777).catch(() => 0o600);
62
66
  const temporary = `${file}.${randomUUID()}.tmp`;
@@ -77,14 +81,17 @@ function plan({ name, file, before, after, record }) {
77
81
  }
78
82
 
79
83
  /** Cursor exposes command hooks, but no documented hook that invokes MCP forms. */
80
- export async function prepareCursorIntegration(paths, { env = process.env, previous } = {}) {
84
+ export async function prepareCursorIntegration(paths, { env = process.env, previous, launcher } = {}) {
81
85
  const home = lifecycleHome('cursor', paths, env), file = path.join(home, 'hooks.json');
82
86
  await checkDirectories(home, file);
83
87
  if (previous && previous.file !== file) throw new Error('Cursor integration home changed. Uninstall from the original home before reinstalling.');
84
88
  const before = await text(file), document = json(before, file);
85
89
  if (document.version !== undefined && document.version !== 1) throw new Error(`Unsupported Cursor hooks version in ${file}`);
86
90
  if (document.hooks !== undefined && !object(document.hooks)) throw new Error(`Invalid hooks object in ${file}`);
87
- const command = `SPHINX_HOME=${quote(paths.home)} ${quote(process.execPath)} ${quote(fileURLToPath(new URL('./cursor-hook.js', import.meta.url)))}`;
91
+ // Through the stable sphinx launcher when available, so package upgrades keep working.
92
+ const command = launcher
93
+ ? `SPHINX_HOME=${quote(paths.home)} ${launcher.env}${launcher.command} hook cursor`
94
+ : `SPHINX_HOME=${quote(paths.home)} ${quote(process.execPath)} ${quote(fileURLToPath(new URL('./cursor-hook.js', import.meta.url)))}`;
88
95
  const hooks = [
89
96
  { event: 'sessionStart', handler: { command, timeout: 10 } },
90
97
  { event: 'postToolUse', handler: { command, timeout: 1800, matcher: '^Shell$' } },
@@ -226,7 +233,10 @@ export async function browserHandoff({ cwd, host, hostSession, checkpointIds, en
226
233
  export async function cursorAgentHook({ input = {}, env = process.env, handoff = browserHandoff } = {}) {
227
234
  if (env.SPHINX_EVALUATOR === '1') return {};
228
235
  const event = input.hook_event_name;
229
- if (event === 'sessionStart') return { additional_context: TERMINAL_GUIDANCE, ...(sessionId(input.session_id || input.conversation_id) ? { env: { SPHINX_CURSOR_SESSION: sessionId(input.session_id || input.conversation_id) } } : {}) };
236
+ if (event === 'sessionStart') {
237
+ const nudge = await setupNudge(typeof input.cwd === 'string' ? input.cwd : input.workspace_roots?.[0]);
238
+ return { additional_context: nudge ? `${TERMINAL_GUIDANCE}\n${nudge}` : TERMINAL_GUIDANCE, ...(sessionId(input.session_id || input.conversation_id) ? { env: { SPHINX_CURSOR_SESSION: sessionId(input.session_id || input.conversation_id) } } : {}) };
239
+ }
230
240
  if (!['postToolUse', 'postToolUseFailure'].includes(event) || input.tool_name !== 'Shell') return {};
231
241
  try {
232
242
  const checkpointIds = cursorCheckpointIds(input);
package/src/openrouter.js CHANGED
@@ -14,7 +14,7 @@ const failureAdvice = {
14
14
  401: 'API key was rejected. Run sphinx auth.',
15
15
  402: 'OpenRouter account has insufficient credits.',
16
16
  404: 'No compatible provider was available for this request. Select a route supporting JSON schema or retry later.',
17
- 429: 'Provider or account rate limit reached. Wait before retrying; Sphinx will not switch models.',
17
+ 429: 'Provider or account rate limit reached. Wait before retrying, or switch this check to your Claude Code or Codex plan when offered.',
18
18
  503: 'Selected model is unavailable. Retry later or explicitly select another route.',
19
19
  };
20
20
 
@@ -226,7 +226,7 @@ async function requestOpenRouter({messages, schema, tools, toolChoice, config, e
226
226
  if (!data || typeof data!=='object' || Array.isArray(data)) throw new Error(`${name} returned an invalid response object.`);
227
227
  requestSignal.throwIfAborted();
228
228
  // Billed even when the choice is later rejected, so record it first.
229
- reportUsage({ inputTokens: data.usage?.prompt_tokens, outputTokens: data.usage?.completion_tokens, cost: data.usage?.cost });
229
+ reportUsage({ inputTokens: data.usage?.prompt_tokens, outputTokens: data.usage?.completion_tokens, cost: data.usage?.cost, provider: openai ? 'openai' : 'openrouter', model });
230
230
  return requireFinishedChoice(data, config, Boolean(tools), response.headers);
231
231
  }
232
232
 
@@ -8,6 +8,8 @@ import { getPaths, loadConfig, policyHash, readJson, writeJson } from './config.
8
8
  import { matches, pushSnapshots, repository, revalidateSnapshot } from './git.js';
9
9
  import { yoloStatus } from './overrides.js';
10
10
  import { createSessionStore } from './sessions.js';
11
+ import { curateQuietly } from './working-context.js';
12
+ import { collectGarbage } from './storage.js';
11
13
 
12
14
  const exec = promisify(execFile);
13
15
  const digest = value => createHash('sha256').update(value).digest('hex');
@@ -196,7 +198,7 @@ export async function schedulePreparation({ cwd = process.cwd(), env = process.e
196
198
  });
197
199
  }
198
200
 
199
- export async function runPreparation({ cwd = process.cwd(), env = process.env, config, store, generate, wait = delay } = {}) {
201
+ export async function runPreparation({ cwd = process.cwd(), env = process.env, config, store, generate, wait = delay, curate } = {}) {
200
202
  if (env.SPHINX_EVALUATOR === '1') return { status: 'skipped', prepared: [], reason: 'Evaluator subprocesses do not prepare assessments.' };
201
203
  const repo = await repository(cwd), files = filesFor(repo.root, env);
202
204
  const latest = await readJson(files.latest, null);
@@ -219,6 +221,7 @@ export async function runPreparation({ cwd = process.cwd(), env = process.env, c
219
221
  claimed.status = status; claimed.updatedAt = new Date().toISOString();
220
222
  if (errorCode) claimed.errorCode = errorCode;
221
223
  await locked(files, () => writeJson(files.job(id), claimed));
224
+ await collectGarbage({ env });
222
225
  return { status, prepared: claimed.prepared, ...(errorCode ? { error: errors[errorCode] } : {}) };
223
226
  };
224
227
  // Yolo can be turned on while this worker runs. Every reviewer request
@@ -237,6 +240,10 @@ export async function runPreparation({ cwd = process.cwd(), env = process.env, c
237
240
  const effective = await loadConfig(snapshot.repoRoot, env);
238
241
  if (policyHash(effective) !== policyHash(currentConfig) || !await revalidateSnapshot(snapshot, effective, { env })) throw new Error('The snapshot or policy changed before preparation.');
239
242
  } });
243
+ // One cheap free-model call keeps the branch's working context current, so
244
+ // the review below and the one at push time start already oriented.
245
+ const lead = plan.snapshots[0];
246
+ await curateQuietly({ repoRoot: repo.root, sourceRef: lead.sourceRef, base: lead.base, head: lead.head, config: plan.config, env, wait, call: curate });
240
247
  for (const snapshot of plan.snapshots) {
241
248
  const current = await readJson(files.latest, null);
242
249
  if (current?.id !== id) return finish('stale');