@progen-labs/spectra 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,18 +1,97 @@
1
1
  /**
2
- * The coding agent `spectra fix` hands one finding to. By default it is Claude
3
- * Code in headless mode, allowed only to read and edit files: no shell, no
4
- * web, no MCP servers. Any other agent works through --agent "<command>": it
5
- * gets the instructions on stdin (and in $SPECTRA_FIX_PROMPT_FILE) and runs in
6
- * the copy of the project.
2
+ * The coding agent `spectra improve` hands one finding to when it runs
3
+ * unattended. Spectra knows the common agent CLIs by name (AGENTS below) and
4
+ * finds one on the machine by itself; Claude Code runs allowed only to read
5
+ * and edit files: no shell, no web, no MCP servers. Any other agent works
6
+ * through --agent "<command>": it gets the instructions on stdin (and in
7
+ * $SPECTRA_FIX_PROMPT_FILE) and runs in the copy of the project.
7
8
  */
8
9
  import { spawn } from 'node:child_process';
9
10
  import fs from 'node:fs';
10
11
  import os from 'node:os';
11
12
  import path from 'node:path';
12
13
 
14
+ /**
15
+ * The agents Spectra can run by name: how each one takes a single prompt,
16
+ * edits files in the current directory without asking, and exits (the
17
+ * "print" mode every CLI agent has), and the signs that it is in use.
18
+ */
19
+ export const AGENTS = {
20
+ claude: { name: 'Claude Code', bin: 'claude', project: ['CLAUDE.md', '.claude'], home: ['.claude', '.claude.json'], also: ['.local/bin/claude', '.claude/local/claude'] },
21
+ codex: { name: 'Codex CLI', bin: 'codex', args: p => ['exec', '--full-auto', p], project: ['.codex'], home: ['.codex'] },
22
+ gemini: { name: 'Gemini CLI', bin: 'gemini', args: p => ['-p', p, '--yolo'], project: ['GEMINI.md', '.gemini'], home: ['.gemini'] },
23
+ copilot: { name: 'GitHub Copilot CLI', bin: 'copilot', args: p => ['-p', p, '--allow-all-tools'], project: ['.github/copilot-instructions.md', '.github/instructions'], home: ['.copilot'] },
24
+ cursor: { name: 'Cursor CLI', bin: 'cursor-agent', args: p => ['-p', p, '--force'], project: ['.cursor', '.cursorrules'], home: ['.cursor'] },
25
+ opencode: { name: 'OpenCode', bin: 'opencode', args: p => ['run', p], project: ['opencode.json', '.opencode'], home: ['.config/opencode'] },
26
+ amp: { name: 'Amp', bin: 'amp', args: p => ['-x', p], project: [], home: ['.config/amp'] },
27
+ };
28
+ export const AGENT_IDS = Object.keys(AGENTS);
29
+
30
+ const exists = p => {
31
+ try {
32
+ fs.statSync(p);
33
+ return true;
34
+ } catch {
35
+ return false;
36
+ }
37
+ };
38
+
39
+ /** Where an agent's executable is: on PATH, or in the places its installer uses. Null when it is not installed. */
40
+ export function findExecutable(id, { env = process.env, home = os.homedir() } = {}) {
41
+ const a = AGENTS[id];
42
+ if (id === 'claude' && env.SPECTRA_CLAUDE) return env.SPECTRA_CLAUDE;
43
+ const names = process.platform === 'win32' ? [`${a.bin}.cmd`, `${a.bin}.exe`, a.bin] : [a.bin];
44
+ for (const dir of (env.PATH || '').split(path.delimiter).filter(Boolean))
45
+ for (const n of names) if (exists(path.join(dir, n))) return path.join(dir, n);
46
+ for (const rel of a.also || []) if (home && exists(path.join(home, rel))) return path.join(home, rel);
47
+ return null;
48
+ }
49
+
50
+ /**
51
+ * The agent to run unattended, when none was named: first one the project or
52
+ * the machine shows signs of (so it is the agent the user already works with),
53
+ * then any that is installed. Null when there is none.
54
+ */
55
+ export function detectAgent({ root = process.cwd(), home = os.homedir(), env = process.env } = {}) {
56
+ const installed = AGENT_IDS.filter(id => findExecutable(id, { env, home }));
57
+ const inUse = id => (AGENTS[id].project || []).some(p => exists(path.join(root, p))) || (AGENTS[id].home || []).some(p => home && exists(path.join(home, p)));
58
+ const id = installed.find(inUse) || installed[0];
59
+ return id ? { id, ...AGENTS[id], bin: findExecutable(id, { env, home }) } : null;
60
+ }
61
+
62
+ /** The agent for --agent: a name from AGENTS, or any shell command. */
63
+ export function agentFromOption(value, opts = {}) {
64
+ return AGENTS[value] ? namedAgent(value, opts) : commandAgent(String(value), opts);
65
+ }
66
+
67
+ /** One of AGENTS, ready to run. */
68
+ export function namedAgent(id, { bin, model, budgetUsd, timeoutMs = 10 * 60_000, env, home } = {}) {
69
+ const a = AGENTS[id];
70
+ if (!a) throw new Error(`unknown agent "${id}"; the names are ${AGENT_IDS.join(', ')}, or pass a command.`);
71
+ const executable = bin || findExecutable(id, { env, home }) || a.bin;
72
+ if (id === 'claude') return claudeAgent({ bin: executable, model, budgetUsd, timeoutMs });
73
+ return {
74
+ id,
75
+ name: a.name,
76
+ async run(prompt, { cwd, signal }) {
77
+ let res;
78
+ try {
79
+ res = await run(executable, a.args(prompt), { cwd, signal, timeoutMs, env: { CI: '1' } });
80
+ } catch (err) {
81
+ throw new Error(`could not start ${a.name} (${err.message}). Install it, or pass --agent "<command>".`);
82
+ }
83
+ if (res.code !== 0) throw new Error(`${a.name} exited with code ${res.code}: ${(res.err || res.out).trim().split('\n').slice(-3).join(' ')}`);
84
+ return { summary: res.out.trim().split('\n').filter(Boolean).pop()?.slice(0, 300) || '', costUsd: null, turns: null, error: null };
85
+ },
86
+ };
87
+ }
88
+
89
+ // `input` goes to the agent's stdin; without it, stdin is closed from the start
90
+ // (the prompt is in the arguments). An agent that exits before reading its
91
+ // stdin makes the write fail with EPIPE, which must not take Spectra down.
13
92
  const run = (cmd, args, { cwd, input, env, signal, timeoutMs, shell = false }) =>
14
93
  new Promise((resolve, reject) => {
15
- const child = spawn(cmd, args, { cwd, env: { ...process.env, ...env }, stdio: ['pipe', 'pipe', 'pipe'], shell });
94
+ const child = spawn(cmd, args, { cwd, env: { ...process.env, ...env }, stdio: [input == null ? 'ignore' : 'pipe', 'pipe', 'pipe'], shell });
16
95
  let out = '';
17
96
  let err = '';
18
97
  child.stdout.on('data', d => (out += d));
@@ -29,12 +108,16 @@ const run = (cmd, args, { cwd, input, env, signal, timeoutMs, shell = false }) =
29
108
  signal?.removeEventListener('abort', abort);
30
109
  resolve({ code, out, err });
31
110
  });
32
- child.stdin.end(input ?? '');
111
+ if (input != null) {
112
+ child.stdin.on('error', () => {}); // the agent closed its end first
113
+ child.stdin.end(input);
114
+ }
33
115
  });
34
116
 
35
117
  /** Claude Code, headless. */
36
118
  export function claudeAgent({ bin = process.env.SPECTRA_CLAUDE || 'claude', model, budgetUsd = 2, timeoutMs = 10 * 60_000 } = {}) {
37
119
  return {
120
+ id: 'claude',
38
121
  name: `Claude Code${model ? ` (${model})` : ''}`,
39
122
  async run(prompt, { cwd, signal }) {
40
123
  const args = [
package/server/fix/cli.js CHANGED
@@ -1,9 +1,10 @@
1
1
  /**
2
- * spectra fix — the AI fix harness. It runs in the user's project: profiles a
3
- * copy of the last commit, hands the costliest findings in the project's own
4
- * code to a coding agent one at a time, and keeps a change only when the
5
- * project's checks pass and Spectra measures it faster beyond run-to-run
6
- * noise. Kept changes are commits on a new branch; nothing else is touched.
2
+ * The unattended mode of `spectra improve` (and `spectra fix`, its old name).
3
+ * It runs in the user's project: profiles a copy of the last commit, hands
4
+ * the costliest findings in the project's own code to a coding agent one at
5
+ * a time, and keeps a change only when the project's checks pass and Spectra
6
+ * measures it faster beyond run-to-run noise. Kept changes are commits on a
7
+ * new branch; nothing else is touched.
7
8
  */
8
9
  import { spawn, spawnSync } from 'node:child_process';
9
10
  import fs from 'node:fs';
@@ -11,227 +12,198 @@ import path from 'node:path';
11
12
  import { fileURLToPath } from 'node:url';
12
13
  import { intOption, parseArgs } from '../args.js';
13
14
  import { bootDevServer } from '../explore/boot.js';
14
- import { SPECTRA_VERSION } from '../output.js';
15
- import { claudeAgent, commandAgent } from './agent.js';
15
+ import { EXIT, SPECTRA_VERSION } from '../output.js';
16
+ import { agentFromOption, claudeAgent, detectAgent } from './agent.js';
16
17
  import { detectChecks, runChecks } from './checks.js';
17
18
  import { runFix } from './harness.js';
18
19
  import { reportMarkdown } from './report.js';
19
20
  import { changesIn, commitChange, commitsOn, createWorkspace, diffOf, ignoreSpectra, removeWorkspace, repoOf, uncommitted, undoChanges } from './workspace.js';
20
21
 
21
22
  const BIN = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../bin/spectra.js');
22
- const argv = process.argv.slice(3);
23
- const { opt, flag } = parseArgs(argv, { values: ['runs', 'max-fixes', 'agent', 'model', 'max-budget-usd', 'check', 'command', 'boot-timeout', 'branch', 'out', 'max-lines'] });
24
- const log = line => console.error(`spectra: ${line}`);
25
- const json = flag('json');
26
-
27
- const HELP = `usage: spectra fix [options]
28
-
29
- The AI fix harness. In an isolated copy of your last commit, it hands the
30
- costliest findings in your own code to a coding agent one at a time, then
31
- keeps a change only if your checks pass and Spectra measures it faster
32
- beyond run-to-run noise. Kept changes are commits on a new branch.
33
-
34
- --max-fixes <n> Findings to attempt (default 3)
35
- --runs <n> Page loads per measurement (default 3)
36
- --check "<cmd>" A check that must pass after every change (repeat it
37
- for several). Default: your test, typecheck and
38
- build scripts. Checks can test the running app at
39
- $SPECTRA_FIX_URL.
40
- --no-checks Rely on measurements alone (not recommended)
41
- --agent "<cmd>" Your own agent: gets the instructions on stdin and in
42
- $SPECTRA_FIX_PROMPT_FILE, runs in the copy
43
- --model <name> Model for Claude Code, the default agent
44
- --max-budget-usd <n> Claude Code's spending cap per finding (default 2)
45
- --command "<cmd>" The dev-server command (default: your npm dev script)
46
- --boot-timeout <s> How long to wait for the dev server (default 60)
47
- --install Install dependencies in the copy instead of linking yours
48
- --branch <name> Branch for the fixes (default spectra/fix-<time>)
49
- --max-lines <n> Largest change accepted (default 150 lines)
50
- --retry Try findings that failed in an earlier session
51
- --keep-worktree Keep the copy in .spectra/fix/ for inspection
52
- --json, --out <file> Machine-readable session report`;
53
-
54
- if (flag('help') || flag('h')) {
55
- console.log(HELP);
56
- process.exit(0);
57
- }
23
+ const VALUES = ['runs', 'max-fixes', 'agent', 'model', 'max-budget-usd', 'check', 'command', 'boot-timeout', 'branch', 'out', 'max-lines', 'url'];
58
24
 
59
- const fail = (message, code = 1) => {
60
- console.error(`spectra: ${message}`);
61
- process.exit(code);
62
- };
63
-
64
- const checksFrom = () => {
65
- const list = [];
66
- for (let i = 0; i < argv.length; i++) if (argv[i] === '--check' && argv[i + 1]) list.push({ name: argv[i + 1], cmd: argv[++i] });
67
- return list;
68
- };
69
-
70
- let runs;
71
- let maxFixes;
72
- let maxLines;
73
- try {
74
- runs = intOption(opt('runs'), 'runs', { min: 1, max: 10, fallback: 3 });
75
- maxFixes = intOption(opt('max-fixes'), 'max-fixes', { min: 1, max: 20, fallback: 3 });
76
- maxLines = intOption(opt('max-lines'), 'max-lines', { min: 1, max: 5000, fallback: 150 });
77
- } catch (err) {
78
- fail(err.message);
79
- }
80
- if (opt('url')) fail('spectra fix starts its own dev server in a copy of your project, so it does not take --url. Use --command if the dev server needs a special command.');
81
-
82
- const projectDir = process.cwd();
83
- let repo;
84
- try {
85
- repo = repoOf(projectDir);
86
- } catch (err) {
87
- fail(err.message);
88
- }
89
- const dirty = uncommitted(repo.root);
90
- if (dirty.length)
91
- fail(`commit or stash your changes first: spectra fix works on a copy of your last commit, and these would not be in it:\n ${dirty.slice(0, 6).join('\n ')}${dirty.length > 6 ? '\n …' : ''}`);
92
-
93
- let checks = checksFrom();
94
- if (!checks.length && !flag('no-checks')) checks = detectChecks(projectDir);
95
- if (!checks.length && !flag('no-checks'))
96
- fail('no checks to run. A faster page must not be a broken one, so spectra fix needs at least one check that proves the app still works: add a test script, pass --check "<cmd>" (it can test the running app at $SPECTRA_FIX_URL), or pass --no-checks to rely on measurements alone.');
97
- if (flag('no-checks')) checks = [];
98
-
99
- const agent = opt('agent')
100
- ? commandAgent(String(opt('agent')))
101
- : claudeAgent({ model: opt('model') === true ? undefined : opt('model'), budgetUsd: Number(opt('max-budget-usd') ?? 2) });
102
-
103
- const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '').replace('T', '-');
104
- const sessionRoot = path.join(projectDir, '.spectra', 'fix');
105
- const sessionDir = path.join(sessionRoot, stamp);
106
- const historyFile = path.join(sessionRoot, 'history.json');
107
- let branch = typeof opt('branch') === 'string' ? opt('branch') : `spectra/fix-${stamp}`;
108
- fs.mkdirSync(path.join(sessionDir, 'results'), { recursive: true });
109
- ignoreSpectra(repo.root);
110
-
111
- const controller = new AbortController();
112
- let ws = null;
113
- let server = null;
114
- let child = null;
115
- let session = null;
116
-
117
- async function cleanup() {
118
- await server?.stop().catch(() => {});
119
- if (ws && !flag('keep-worktree')) removeWorkspace(repo.root, ws, { keepBranch: commitsOn(repo.root, ws.branch) > 0 });
120
- }
121
- for (const signal of ['SIGINT', 'SIGTERM']) {
122
- process.on(signal, () => {
25
+ /**
26
+ * Runs a session. `agent` is the agent to use when the options name none;
27
+ * without it, the agent found on the machine, or Claude Code.
28
+ * @returns {Promise<number>} the exit code
29
+ */
30
+ export async function runFixCli(argv, { agent: given = null } = {}) {
31
+ const { opt, flag } = parseArgs(argv, { values: VALUES });
32
+ const log = line => console.error(`spectra: ${line}`);
33
+ const json = flag('json');
34
+ const fail = message => {
35
+ throw Object.assign(new Error(message), { early: true });
36
+ };
37
+
38
+ const checksFrom = () => {
39
+ const list = [];
40
+ for (let i = 0; i < argv.length; i++) if (argv[i] === '--check' && argv[i + 1]) list.push({ name: argv[i + 1], cmd: argv[++i] });
41
+ return list;
42
+ };
43
+
44
+ const controller = new AbortController();
45
+ let ws = null;
46
+ let server = null;
47
+ let child = null;
48
+ let session = null;
49
+ let repo = null;
50
+ let branch = null;
51
+ let sessionDir = null;
52
+ const projectDir = process.cwd();
53
+
54
+ async function cleanup() {
55
+ await server?.stop().catch(() => {});
56
+ if (ws && !flag('keep-worktree')) removeWorkspace(repo.root, ws, { keepBranch: commitsOn(repo.root, ws.branch) > 0 });
57
+ }
58
+ const onSignal = signal => {
123
59
  controller.abort();
124
60
  child?.kill('SIGTERM');
125
61
  log('stopping …');
126
62
  cleanup().finally(() => process.exit(signal === 'SIGINT' ? 130 : 143));
127
- });
128
- }
129
-
130
- /** `spectra <args> --json` in the copy; resolves with the parsed result. */
131
- function spectra(args) {
132
- return new Promise((resolve, reject) => {
133
- child = spawn(process.execPath, [BIN, ...args, '--json'], {
134
- cwd: ws.projectDir,
135
- env: { ...process.env, PERF_FRONTEND_DIR: ws.projectDir, PERF_REPORTS_DIR: path.join(sessionDir, 'reports') },
136
- stdio: ['ignore', 'pipe', 'pipe'],
137
- });
138
- let out = '';
139
- let err = '';
140
- child.stdout.on('data', d => (out += d));
141
- child.stderr.on('data', d => {
142
- err += d;
143
- for (const line of String(d).split('\n')) if (/^spectra: run \d+\/\d+/.test(line)) console.error(` ${line.replace(/^spectra:\s*/, '')}`);
63
+ };
64
+ for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, onSignal);
65
+
66
+ /** `spectra <args> --json` in the copy; resolves with the parsed result. */
67
+ function spectra(args) {
68
+ return new Promise((resolve, reject) => {
69
+ child = spawn(process.execPath, [BIN, ...args, '--json'], {
70
+ cwd: ws.projectDir,
71
+ env: { ...process.env, PERF_FRONTEND_DIR: ws.projectDir, PERF_REPORTS_DIR: path.join(sessionDir, 'reports') },
72
+ stdio: ['ignore', 'pipe', 'pipe'],
73
+ });
74
+ let out = '';
75
+ let err = '';
76
+ child.stdout.on('data', d => (out += d));
77
+ child.stderr.on('data', d => {
78
+ err += d;
79
+ for (const line of String(d).split('\n')) if (/^spectra: run \d+\/\d+/.test(line)) console.error(` ${line.replace(/^spectra:\s*/, '')}`);
80
+ });
81
+ child.on('error', reject);
82
+ child.on('close', code => {
83
+ if (code === 1) return reject(new Error(err.trim().split('\n').filter(l => l.startsWith('spectra:')).pop()?.replace(/^spectra:\s*/, '') || `spectra ${args[0]} failed`));
84
+ try {
85
+ resolve(JSON.parse(out));
86
+ } catch {
87
+ reject(new Error(`spectra ${args[0]} printed no result`));
88
+ }
89
+ });
144
90
  });
145
- child.on('error', reject);
146
- child.on('close', code => {
147
- if (code === 1) return reject(new Error(err.trim().split('\n').filter(l => l.startsWith('spectra:')).pop()?.replace(/^spectra:\s*/, '') || `spectra ${args[0]} failed`));
148
- try {
149
- resolve(JSON.parse(out));
150
- } catch {
151
- reject(new Error(`spectra ${args[0]} printed no result`));
152
- }
153
- });
154
- });
155
- }
156
-
157
- const slug = s => s.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '').toLowerCase();
158
-
159
- try {
160
- if (branchExists(branch)) throw new Error(`branch ${branch} already exists; pass --branch <name>.`);
161
- log(`working in a copy of ${shortHead()} on branch ${branch} …`);
162
- ws = createWorkspace({ root: repo.root, sub: repo.sub, dir: path.join(sessionDir, 'worktree'), branch, install: flag('install'), log });
163
- ws.exists = file => fs.existsSync(path.join(ws.projectDir, file));
164
- ws.changes = () => changesIn(ws);
165
- ws.diff = () => diffOf(ws);
166
- ws.commit = (files, message) => commitChange(ws, files, message);
167
- ws.undo = () => undoChanges(ws);
168
-
169
- const command = typeof opt('command') === 'string' ? opt('command') : undefined;
170
- log(`starting the dev server in the copy${command ? ` (${command})` : ''} …`);
171
- server = await bootDevServer({ root: ws.projectDir, command, timeoutMs: (Number(opt('boot-timeout')) || 60) * 1000 });
172
- log(`dev server ready at ${server.url}`);
91
+ }
92
+ const slug = s => s.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '').toLowerCase();
173
93
 
174
- let history = {};
94
+ let agent = given;
175
95
  try {
176
- history = JSON.parse(fs.readFileSync(historyFile, 'utf8'));
177
- } catch {
178
- /* first session */
96
+ const runs = intOption(opt('runs'), 'runs', { min: 1, max: 10, fallback: 3 });
97
+ const maxFixes = intOption(opt('max-fixes'), 'max-fixes', { min: 1, max: 20, fallback: 3 });
98
+ const maxLines = intOption(opt('max-lines'), 'max-lines', { min: 1, max: 5000, fallback: 150 });
99
+ if (opt('url')) fail('spectra improve starts its own dev server in a copy of your project, so it does not take --url. Use --command if the dev server needs a special command, or `spectra improve next` to work on a running page.');
100
+
101
+ repo = repoOf(projectDir);
102
+ const dirty = uncommitted(repo.root);
103
+ if (dirty.length)
104
+ fail(`commit or stash your changes first: spectra improve works on a copy of your last commit, and these would not be in it:\n ${dirty.slice(0, 6).join('\n ')}${dirty.length > 6 ? '\n …' : ''}`);
105
+
106
+ let checks = checksFrom();
107
+ if (!checks.length && !flag('no-checks')) checks = detectChecks(projectDir);
108
+ if (!checks.length && !flag('no-checks'))
109
+ fail('no checks to run. A faster page must not be a broken one, so spectra improve needs at least one check that proves the app still works: add a test script, pass --check "<cmd>" (it can test the running app at $SPECTRA_FIX_URL), or pass --no-checks to rely on measurements alone.');
110
+ if (flag('no-checks')) checks = [];
111
+
112
+ if (!agent) {
113
+ const agentOpts = { model: opt('model') === true ? undefined : opt('model'), budgetUsd: Number(opt('max-budget-usd') ?? 2) };
114
+ const found = opt('agent') ? null : detectAgent();
115
+ agent = opt('agent') ? agentFromOption(String(opt('agent')), agentOpts) : found ? agentFromOption(found.id, { ...agentOpts, bin: found.bin }) : claudeAgent(agentOpts);
116
+ }
117
+
118
+ const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '').replace('T', '-');
119
+ const sessionRoot = path.join(projectDir, '.spectra', 'improve');
120
+ sessionDir = path.join(sessionRoot, stamp);
121
+ const historyFile = path.join(sessionRoot, 'history.json');
122
+ branch = typeof opt('branch') === 'string' ? opt('branch') : `spectra/improve-${stamp}`;
123
+ fs.mkdirSync(path.join(sessionDir, 'results'), { recursive: true });
124
+ ignoreSpectra(repo.root);
125
+
126
+ if (branchExists(repo.root, branch)) fail(`branch ${branch} already exists; pass --branch <name>.`);
127
+ log(`working in a copy of ${shortHead(repo.root)} on branch ${branch}, with ${agent.name} …`);
128
+ ws = createWorkspace({ root: repo.root, sub: repo.sub, dir: path.join(sessionDir, 'worktree'), branch, install: flag('install'), log });
129
+ ws.exists = file => fs.existsSync(path.join(ws.projectDir, file));
130
+ ws.changes = () => changesIn(ws);
131
+ ws.diff = () => diffOf(ws);
132
+ ws.commit = (files, message) => commitChange(ws, files, message);
133
+ ws.undo = () => undoChanges(ws);
134
+
135
+ const command = typeof opt('command') === 'string' ? opt('command') : undefined;
136
+ log(`starting the dev server in the copy${command ? ` (${command})` : ''} …`);
137
+ server = await bootDevServer({ root: ws.projectDir, command, timeoutMs: (Number(opt('boot-timeout')) || 60) * 1000 });
138
+ log(`dev server ready at ${server.url}`);
139
+
140
+ let history = {};
141
+ try {
142
+ history = JSON.parse(fs.readFileSync(historyFile, 'utf8'));
143
+ } catch {
144
+ /* first session */
145
+ }
146
+
147
+ session = await runFix({
148
+ agent,
149
+ workspace: ws,
150
+ checks,
151
+ history,
152
+ retry: flag('retry'),
153
+ maxFixes,
154
+ maxLines,
155
+ url: server.url,
156
+ signal: controller.signal,
157
+ log,
158
+ runChecks: () => runChecks(checks, { cwd: ws.projectDir, env: { SPECTRA_FIX_URL: server.url }, signal: controller.signal }),
159
+ profile: async label => {
160
+ const file = path.join(sessionDir, 'results', `${slug(label)}.json`);
161
+ const result = await spectra(['performance', '--quick', '--runs', String(runs), '--url', server.url, '--out', file]);
162
+ return { file, result };
163
+ },
164
+ compare: async (before, after) => (await spectra(['compare', before.join(','), after.join(',')])).comparison,
165
+ });
166
+ Object.assign(session, { kind: 'fix', spectraVersion: SPECTRA_VERSION, project: projectDir, branch, runs, startedAt: stamp, sessionDir });
167
+
168
+ for (const a of session.attempts) history[a.finding.key] = { title: a.finding.title, outcome: a.outcome, at: new Date().toISOString() };
169
+ fs.writeFileSync(historyFile, `${JSON.stringify(history, null, 2)}\n`);
170
+ } catch (err) {
171
+ console.error(`spectra: ${err.message}`);
172
+ if (err.tail) console.error(err.tail.replace(/^/gm, ' | '));
173
+ if (/cannot work/.test(err.message) && agent?.id === 'claude' && !opt('agent'))
174
+ console.error('spectra: sign in to Claude Code first (run `claude` once), set SPECTRA_CLAUDE to its path, or pass --agent <name|"command">.');
175
+ await cleanup();
176
+ for (const signal of ['SIGINT', 'SIGTERM']) process.off(signal, onSignal);
177
+ return EXIT.ERROR;
179
178
  }
180
179
 
181
- session = await runFix({
182
- agent,
183
- workspace: ws,
184
- checks,
185
- history,
186
- retry: flag('retry'),
187
- maxFixes,
188
- maxLines,
189
- url: server.url,
190
- signal: controller.signal,
191
- log,
192
- runChecks: () => runChecks(checks, { cwd: ws.projectDir, env: { SPECTRA_FIX_URL: server.url }, signal: controller.signal }),
193
- profile: async label => {
194
- const file = path.join(sessionDir, 'results', `${slug(label)}.json`);
195
- const result = await spectra(['performance', '--quick', '--runs', String(runs), '--url', server.url, '--out', file]);
196
- return { file, result };
197
- },
198
- compare: async (before, after) => (await spectra(['compare', before.join(','), after.join(',')])).comparison,
199
- });
200
- Object.assign(session, { kind: 'fix', spectraVersion: SPECTRA_VERSION, project: projectDir, branch, runs, startedAt: stamp, sessionDir });
201
-
202
- for (const a of session.attempts) history[a.finding.key] = { title: a.finding.title, outcome: a.outcome, at: new Date().toISOString() };
203
- fs.writeFileSync(historyFile, `${JSON.stringify(history, null, 2)}\n`);
204
- } catch (err) {
205
- console.error(`spectra: ${err.message}`);
206
- if (err.tail) console.error(err.tail.replace(/^/gm, ' | '));
207
- if (/cannot work/.test(err.message) && !opt('agent'))
208
- console.error('spectra: sign in to Claude Code first (run `claude` once), set SPECTRA_CLAUDE to its path, or pass --agent "<command>".');
209
180
  await cleanup();
210
- process.exit(1);
211
- }
212
-
213
- await cleanup();
214
- const md = reportMarkdown(session);
215
- fs.writeFileSync(path.join(sessionDir, 'report.md'), md);
216
- fs.writeFileSync(path.join(sessionDir, 'report.json'), `${JSON.stringify(session, null, 2)}\n`);
217
- if (opt('out') && typeof opt('out') === 'string') fs.writeFileSync(path.resolve(opt('out')), `${JSON.stringify(session, null, 2)}\n`);
218
-
219
- const rel = p => path.relative(projectDir, p) || '.';
220
- console.error('');
221
- console.error(`Spectra fix — ${session.kept} of ${session.attempts.length} finding${session.attempts.length === 1 ? '' : 's'} fixed`);
222
- for (const a of session.attempts) console.error(` ${a.outcome === 'kept' ? '✓ kept ' : '✗ undone '} ${a.finding.title} (${a.finding.file}${a.finding.line ? `:${a.finding.line}` : ''})${a.outcome === 'kept' ? '' : ` — ${a.reason}`}`);
223
- if (session.final) {
224
- console.error(`\nOverall: ${session.final.verdict.toUpperCase()} (confidence: ${session.final.confidence})`);
225
- for (const h of session.final.headlines) console.error(` ${h.kind === 'improved' ? '✓' : '✗'} ${h.text}`);
181
+ for (const signal of ['SIGINT', 'SIGTERM']) process.off(signal, onSignal);
182
+ const md = reportMarkdown(session);
183
+ fs.writeFileSync(path.join(sessionDir, 'report.md'), md);
184
+ fs.writeFileSync(path.join(sessionDir, 'report.json'), `${JSON.stringify(session, null, 2)}\n`);
185
+ if (opt('out') && typeof opt('out') === 'string') fs.writeFileSync(path.resolve(opt('out')), `${JSON.stringify(session, null, 2)}\n`);
186
+
187
+ const rel = p => path.relative(projectDir, p) || '.';
188
+ const n = session.attempts.length;
189
+ console.error('');
190
+ if (!n && !session.skipped.length) console.error("Spectra improve — nothing to fix: nothing in your own code crossed Spectra's thresholds.");
191
+ else console.error(`Spectra improve — ${session.kept} of ${n} problem${n === 1 ? '' : 's'} fixed`);
192
+ for (const a of session.attempts) console.error(` ${a.outcome === 'kept' ? '✓ fixed ' : '✗ undone '} ${a.finding.title} (${a.finding.file}${a.finding.line ? `:${a.finding.line}` : ''})${a.outcome === 'kept' ? `: ${a.reason}` : ` — ${a.reason}`}`);
193
+ for (const s of session.skipped) console.error(` – skipped ${s.title} — ${s.reason}`);
194
+ if (session.final) {
195
+ console.error(`\nOverall, before → after: ${session.final.verdict.toUpperCase()} (confidence: ${session.final.confidence})`);
196
+ for (const h of session.final.headlines) console.error(` ${h.kind === 'improved' ? '✓' : '✗'} ${h.text}`);
197
+ }
198
+ if (session.kept) console.error(`\nThe fixes are on branch ${branch}, one commit each, with the measurements in the commit messages.\nReview them with: git log -p HEAD..${branch}\nthen merge the branch or open a pull request. Your own files were not touched.`);
199
+ console.error(`\nReport: ${rel(path.join(sessionDir, 'report.md'))}`);
200
+ if (json) process.stdout.write(`${JSON.stringify(session)}\n`);
201
+ return EXIT.OK;
226
202
  }
227
- if (session.kept) console.error(`\nThe fixes are on branch ${branch}. Review them with:\n git log -p HEAD..${branch}\nthen merge the branch or open a pull request.`);
228
- console.error(`\nReport: ${rel(path.join(sessionDir, 'report.md'))}`);
229
- if (json) process.stdout.write(`${JSON.stringify(session)}\n`);
230
- process.exit(0);
231
203
 
232
- function branchExists(name) {
233
- return spawnSync('git', ['rev-parse', '--verify', '--quiet', `refs/heads/${name}`], { cwd: repo.root }).status === 0;
204
+ function branchExists(root, name) {
205
+ return spawnSync('git', ['rev-parse', '--verify', '--quiet', `refs/heads/${name}`], { cwd: root }).status === 0;
234
206
  }
235
- function shortHead() {
236
- return spawnSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: repo.root, encoding: 'utf8' }).stdout.trim();
207
+ function shortHead(root) {
208
+ return spawnSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root, encoding: 'utf8' }).stdout.trim();
237
209
  }
@@ -92,7 +92,9 @@ export function judge(c) {
92
92
  if (c.verdict === 'regressed') return { decision: 'discard', outcome: 'slower', reason: `slower: ${worse[0] || 'a key metric got worse'}` };
93
93
  if (c.verdict === 'mixed') return { decision: 'discard', outcome: 'mixed', reason: `mixed: ${worse[0] || 'something got worse'}` };
94
94
  if (c.verdict !== 'improved') return { decision: 'discard', outcome: 'no-gain', reason: 'no measurable improvement: every change was within run-to-run noise' };
95
- const critical = c.findings.added.find(f => f.severity === 'critical');
95
+ // a new critical problem in the project's own code counts; one in a dependency or a network
96
+ // resource (a vendor chunk the dev server renamed between runs) is not something the change made
97
+ const critical = c.findings.added.find(f => f.severity === 'critical' && ownFile(f));
96
98
  if (critical) return { decision: 'discard', outcome: 'new-problem', reason: `introduced a critical finding: ${critical.title}` };
97
99
  if (c.confidence === 'low') return { decision: 'measure-again', outcome: 'unproven', reason: 'improved, but with low confidence' };
98
100
  return { decision: 'keep', outcome: 'kept', reason: better[0] || 'improved', headlines: better };
@@ -6,7 +6,7 @@ import fs from 'node:fs';
6
6
  import os from 'node:os';
7
7
  import path from 'node:path';
8
8
  import { test } from 'node:test';
9
- import { commandAgent } from './agent.js';
9
+ import { AGENTS, agentFromOption, commandAgent, detectAgent, findExecutable, namedAgent } from './agent.js';
10
10
  import { detectChecks } from './checks.js';
11
11
  import { runFix } from './harness.js';
12
12
  import { fixPrompt, judge, rankFindings, scopeProblem } from './policy.js';
@@ -57,7 +57,10 @@ test('judges a change from the comparison', () => {
57
57
  assert.equal(judge(comparison('neutral')).outcome, 'no-gain');
58
58
  assert.equal(judge(comparison('regressed')).outcome, 'slower');
59
59
  assert.equal(judge(comparison('mixed')).outcome, 'mixed');
60
- assert.equal(judge(comparison('improved', 'high', { added: [{ severity: 'critical', title: 'Long task' }] })).outcome, 'new-problem');
60
+ assert.equal(judge(comparison('improved', 'high', { added: [{ severity: 'critical', title: 'Long task', file: 'src/App.jsx' }] })).outcome, 'new-problem');
61
+ // a new critical finding outside the project's code (a renamed vendor chunk, a request) is not the change's doing
62
+ assert.equal(judge(comparison('improved', 'high', { added: [{ severity: 'critical', title: 'react-dom-Zq1x.js is 2.47 MB over the wire', file: null, culprits: [] }] })).decision, 'keep');
63
+ assert.equal(judge(comparison('improved', 'high', { added: [{ severity: 'critical', title: 'big', file: 'node_modules/x/index.js' }] })).decision, 'keep');
61
64
  });
62
65
 
63
66
  test('tells the agent the finding and the rules', () => {
@@ -225,3 +228,41 @@ test('finds checks in package.json and runs any agent command', async () => {
225
228
  assert.equal(fs.readFileSync(path.join(root, 'app/src/Row.jsx'), 'utf8'), '// Finding: Row is slow\n');
226
229
  fs.rmSync(root, { recursive: true, force: true });
227
230
  });
231
+
232
+ test('finds the coding agent on the machine: the one in use first, then any installed; runs it by name', async () => {
233
+ const bin = fs.mkdtempSync(path.join(os.tmpdir(), 'spectra-agents-bin-'));
234
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'spectra-agents-home-'));
235
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spectra-agents-root-'));
236
+ const fake = (name, body = 'echo "done: $*"') => {
237
+ fs.writeFileSync(path.join(bin, name), `#!/bin/sh\n${body}\n`);
238
+ fs.chmodSync(path.join(bin, name), 0o755);
239
+ };
240
+ const env = { PATH: bin };
241
+ assert.equal(detectAgent({ root, home, env }), null, 'nothing installed');
242
+ fake('codex');
243
+ fake('gemini');
244
+ assert.equal(detectAgent({ root, home, env }).id, 'codex', 'the first installed one, in the order of AGENTS');
245
+ fs.mkdirSync(path.join(home, '.gemini'));
246
+ assert.equal(detectAgent({ root, home, env }).id, 'gemini', 'the one the machine shows signs of');
247
+ fs.mkdirSync(path.join(root, '.codex'));
248
+ assert.equal(detectAgent({ root, home, env }).id, 'codex', 'the one the project shows signs of');
249
+ assert.equal(findExecutable('gemini', { env, home }), path.join(bin, 'gemini'));
250
+ assert.equal(findExecutable('claude', { env: { PATH: bin, SPECTRA_CLAUDE: '/x/claude' }, home }), '/x/claude', 'SPECTRA_CLAUDE wins');
251
+ fs.mkdirSync(path.join(home, '.local/bin'), { recursive: true });
252
+ fs.writeFileSync(path.join(home, '.local/bin/claude'), '');
253
+ assert.equal(findExecutable('claude', { env, home }), path.join(home, '.local/bin/claude'), "the installer's folder, off PATH");
254
+
255
+ // a named agent gets the prompt the way its CLI takes it, and its last line is the summary
256
+ const gemini = namedAgent('gemini', { env, home });
257
+ assert.equal(gemini.name, 'Gemini CLI');
258
+ const res = await gemini.run('Fix one thing.', { cwd: root });
259
+ assert.equal(res.summary, 'done: -p Fix one thing. --yolo');
260
+ fake('codex', 'echo "boom" >&2; exit 3');
261
+ await assert.rejects(namedAgent('codex', { env, home }).run('x', { cwd: root }), /Codex CLI exited with code 3: boom/);
262
+ await assert.rejects(namedAgent('amp', { env, home }).run('x', { cwd: root }), /could not start Amp/);
263
+ assert.equal(agentFromOption('./my-agent.sh').name, './my-agent.sh', 'anything else is a command');
264
+ assert.equal(agentFromOption('claude', { env, home }).id, 'claude');
265
+ assert.throws(() => namedAgent('nope'), /unknown agent "nope"/);
266
+ assert.deepEqual(Object.keys(AGENTS).slice(0, 3), ['claude', 'codex', 'gemini']);
267
+ for (const d of [bin, home, root]) fs.rmSync(d, { recursive: true, force: true });
268
+ });