@autor3search/javascript 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +500 -0
  3. package/bin/autor3search-javascript.js +4 -0
  4. package/package.json +50 -0
  5. package/src/adapters/bench/index.js +31 -0
  6. package/src/adapters/bench/vitest.js +128 -0
  7. package/src/adapters/driver-child.js +72 -0
  8. package/src/adapters/driver-hooks.js +18 -0
  9. package/src/adapters/driver.js +192 -0
  10. package/src/adapters/gates/index.js +64 -0
  11. package/src/adapters/gates/lint.js +46 -0
  12. package/src/adapters/gates/test.js +27 -0
  13. package/src/adapters/gates/typecheck.js +98 -0
  14. package/src/adapters/gates/util.js +45 -0
  15. package/src/adapters/vitest-shim.js +51 -0
  16. package/src/bench/parse.js +120 -0
  17. package/src/bench/set.js +101 -0
  18. package/src/bench/stats.js +443 -0
  19. package/src/cli/cmd-baseline.js +136 -0
  20. package/src/cli/cmd-doctor.js +38 -0
  21. package/src/cli/cmd-eval.js +231 -0
  22. package/src/cli/cmd-init.js +136 -0
  23. package/src/cli/cmd-profile.js +38 -0
  24. package/src/cli/cmd-report.js +96 -0
  25. package/src/cli/cmd-status.js +68 -0
  26. package/src/cli/cmd-stop.js +98 -0
  27. package/src/cli/cmd-version.js +31 -0
  28. package/src/cli/context.js +98 -0
  29. package/src/cli/main.js +62 -0
  30. package/src/config.js +209 -0
  31. package/src/discover.js +239 -0
  32. package/src/doctor.js +282 -0
  33. package/src/duration.js +75 -0
  34. package/src/freeze.js +234 -0
  35. package/src/gitx.js +115 -0
  36. package/src/measure.js +127 -0
  37. package/src/pipeline.js +391 -0
  38. package/src/profile.js +100 -0
  39. package/src/results.js +124 -0
  40. package/src/runner.js +198 -0
  41. package/src/scope.js +92 -0
  42. package/src/state/index.js +312 -0
  43. package/src/state/lock.js +189 -0
  44. package/src/state/stop.js +56 -0
  45. package/src/verdict.js +214 -0
  46. package/templates/program.md +264 -0
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Runs one experiment and returns a verdict.
3
+ *
4
+ * With --json this prints ONE JSON object to stdout and nothing else. That is
5
+ * a contract program.md's loop depends on: the agent parses stdout directly,
6
+ * so a stray line of progress output would break every run. The subprocess
7
+ * transcript goes to run.log instead, opened by this command itself so the
8
+ * agent never has to redirect stdout (doing so would open a second
9
+ * descriptor on the same path and clobber whichever writes second).
10
+ */
11
+ import { createWriteStream } from 'node:fs'
12
+ import { join } from 'node:path'
13
+ import { parseArgs } from 'node:util'
14
+ import { headCommit } from '../gitx.js'
15
+ import { RUN_LOG_NAME, evalOnce } from '../pipeline.js'
16
+ import { RESULTS_PATH, appendRow, loadRows } from '../results.js'
17
+ import { BASELINE_FILE, WORKTREE_NAME, loadBaseline } from '../state/index.js'
18
+ import { claimEval } from '../state/lock.js'
19
+ import { stopRequested } from '../state/stop.js'
20
+ import { REASON, STATUS, exitCode } from '../verdict.js'
21
+ import { expandSingleDashFlags, loadRepoConfig, resolveRun } from './context.js'
22
+
23
+ /**
24
+ * @param {string[]} args
25
+ * @param {{out: {write(s: string): void}, err: {write(s: string): void}}} io
26
+ * @returns {Promise<number>}
27
+ */
28
+ export async function runEval(args, io) {
29
+ const { values } = parseArgs({
30
+ args: expandSingleDashFlags(args, ['desc']),
31
+ options: {
32
+ C: { type: 'string', default: '.' },
33
+ json: { type: 'boolean', default: false },
34
+ desc: { type: 'string', default: '' },
35
+ 'no-log': { type: 'boolean', default: false },
36
+ },
37
+ allowPositionals: false,
38
+ })
39
+
40
+ // --json's whole contract is that stdout carries nothing but the verdict.
41
+ // --no-log sends the transcript to stdout instead of run.log. Combining
42
+ // them would interleave subprocess chatter with the JSON object, breaking
43
+ // the one contract this command exists to keep — refuse before anything
44
+ // is claimed or touched.
45
+ if (values.json && values['no-log']) {
46
+ io.err.write('--json and --no-log cannot be combined: --json requires stdout to carry only the verdict\n')
47
+ return 2
48
+ }
49
+
50
+ const run = await resolveRun(values.C)
51
+ const cfg = await loadRepoConfig(run.root)
52
+ const baseline = await loadBaseline(join(run.stateDir, BASELINE_FILE))
53
+
54
+ // An interrupt must not leave Vitest workers running: the runner kills its
55
+ // process groups, and this abort signal is what tells evalOnce's measure
56
+ // phase to do it.
57
+ //
58
+ // Installed BEFORE the claim, and that order is load-bearing. claimEval
59
+ // creates the lock DIRECTORY first and writes its pid and heartbeat files
60
+ // afterwards, so a handler installed after it would leave a window in which
61
+ // the run already owns on-disk state while SIGINT still has Node's default
62
+ // action: the process dies outright, the `finally` below never runs, and the
63
+ // claim sits stranded until it goes stale ~30s later, refusing every eval in
64
+ // between. That window is also observable from outside — evalRunning reports
65
+ // `running` as soon as the directory exists — so anything waiting for the run
66
+ // to start could deliver the signal squarely into it.
67
+ //
68
+ // An interrupt arriving before there is anything to abort is harmless: the
69
+ // controller simply starts out aborted, and evalOnce stops at its first
70
+ // checkpoint without measuring anything.
71
+ const controller = new AbortController()
72
+ const onSignal = () => controller.abort()
73
+ process.on('SIGINT', onSignal)
74
+ process.on('SIGTERM', onSignal)
75
+ const offSignals = () => {
76
+ process.off('SIGINT', onSignal)
77
+ process.off('SIGTERM', onSignal)
78
+ }
79
+
80
+ let claim
81
+ try {
82
+ claim = await claimEval(run.stateDir)
83
+ } catch (err) {
84
+ // dispatch() also runs in-process under test, so a leaked listener here
85
+ // would accumulate across runs rather than dying with the process.
86
+ offSignals()
87
+ io.err.write(`${err.message}\n`)
88
+ return 2
89
+ }
90
+
91
+ // The transcript goes to run.log by default; --no-log streams it to stdout
92
+ // for a human watching interactively instead (never combined with --json,
93
+ // refused above).
94
+ const logStream = values['no-log'] ? null : createWriteStream(join(run.root, RUN_LOG_NAME), { flags: 'a' })
95
+ // A stream error (e.g. the directory vanishing mid-run) must not crash the
96
+ // process via an unhandled 'error' event; it surfaces as a missing
97
+ // transcript instead, which run.log's absence already makes visible.
98
+ logStream?.on('error', () => {})
99
+ const log = values['no-log'] ? io.out : logStream
100
+
101
+ try {
102
+ const { result, measurements } = await evalOnce({
103
+ root: run.root,
104
+ stateDir: run.stateDir,
105
+ cfg,
106
+ baseline,
107
+ log,
108
+ signal: controller.signal,
109
+ })
110
+
111
+ const experiment = (await loadRows(join(run.root, RESULTS_PATH))).length + 1
112
+ await appendRow(join(run.root, RESULTS_PATH), {
113
+ commit: await headCommit(run.root).catch(() => 'unknown'),
114
+ score: result.score,
115
+ bestBenchDelta: bestDelta(measurements?.time),
116
+ bytesDelta: bestDelta(measurements?.bytes),
117
+ status: result.status.toLowerCase(),
118
+ description: values.desc,
119
+ })
120
+
121
+ const stopped = await stopRequested(run.stateDir)
122
+ const context = buildContext(run, baseline, experiment)
123
+
124
+ if (values.json) writeJson(io, result, measurements, stopped, context)
125
+ else writeHuman(io, result, measurements, stopped, context)
126
+ return exitCode(result)
127
+ } catch (err) {
128
+ if (controller.signal.aborted) {
129
+ // ABORTED is not a verdict: nothing was measured, so NO results.tsv row
130
+ // is written. The agent treats it as it would a FAIL.
131
+ const aborted = {
132
+ status: STATUS.ABORTED,
133
+ reason: REASON.STOP_FORCED,
134
+ score: 0,
135
+ message: 'the experiment was interrupted before it could be measured',
136
+ regressions: [],
137
+ warnings: [],
138
+ }
139
+ // Still report whatever is actually pending — an interrupt is not
140
+ // itself evidence that a graceful stop was also requested.
141
+ const stopped = await stopRequested(run.stateDir).catch(() => false)
142
+ const context = buildContext(run, baseline)
143
+ if (values.json) writeJson(io, aborted, null, stopped, context)
144
+ else {
145
+ io.err.write(`ABORTED: ${aborted.message}\n`)
146
+ if (stopped) io.err.write('STOP REQUESTED: end the loop after applying this verdict\n')
147
+ }
148
+ return exitCode(aborted)
149
+ }
150
+ throw err
151
+ } finally {
152
+ offSignals()
153
+ await closeLog(logStream)
154
+ await claim.release()
155
+ }
156
+ }
157
+
158
+ /** The run context reported alongside a verdict; experiment is omitted when none was recorded. */
159
+ function buildContext(run, baseline, experiment) {
160
+ return {
161
+ tag: run.tag,
162
+ branch: run.branch,
163
+ baseline_commit: baseline.commit,
164
+ measure_commit: baseline.measureCommit,
165
+ worktree: join(run.stateDir, WORKTREE_NAME),
166
+ ...(experiment !== undefined ? { experiment } : {}),
167
+ }
168
+ }
169
+
170
+ /** Flushes and closes the transcript stream, if one was opened. */
171
+ async function closeLog(stream) {
172
+ if (!stream) return
173
+ await new Promise((resolve) => stream.end(resolve))
174
+ }
175
+
176
+ function writeJson(io, result, measurements, stopped, run) {
177
+ io.out.write(
178
+ `${JSON.stringify({
179
+ status: result.status,
180
+ reason: result.reason,
181
+ score: result.score,
182
+ message: result.message,
183
+ regressions: result.regressions ?? [],
184
+ warnings: result.warnings ?? [],
185
+ deltas: (measurements?.time ?? []).map(publicDelta),
186
+ bytes: (measurements?.bytes ?? []).map(publicDelta),
187
+ stop_requested: stopped,
188
+ run,
189
+ })}\n`,
190
+ )
191
+ }
192
+
193
+ function writeHuman(io, result, measurements, stopped, run) {
194
+ for (const delta of measurements?.time ?? []) {
195
+ io.out.write(
196
+ `${delta.name} ${(delta.baseCenter * 1e9).toFixed(0)} -> ${(delta.candCenter * 1e9).toFixed(0)} ns/op ` +
197
+ `${delta.pctChange >= 0 ? '+' : ''}${delta.pctChange.toFixed(2)}% p=${delta.p.toFixed(5)}` +
198
+ `${delta.significant ? '' : ' (not significant)'}\n`,
199
+ )
200
+ }
201
+ for (const delta of measurements?.bytes ?? []) {
202
+ io.out.write(
203
+ `${delta.name} ${delta.baseCenter.toFixed(0)} -> ${delta.candCenter.toFixed(0)} bytes/op ` +
204
+ `${delta.pctChange >= 0 ? '+' : ''}${delta.pctChange.toFixed(2)}% (approximate hint, never scored)\n`,
205
+ )
206
+ }
207
+ // Warnings go ABOVE the verdict: they qualify how far it can be read, and a
208
+ // reader who stops at the verdict line must not miss them.
209
+ for (const warning of result.warnings ?? []) io.out.write(`WARNING: ${warning}\n`)
210
+ io.out.write(`VERDICT: ${result.status} (${result.reason}) — ${result.message}\n`)
211
+ if (stopped) io.out.write('STOP REQUESTED: end the loop after applying this verdict\n')
212
+ if (run?.experiment) io.out.write(`experiment ${run.experiment} on ${run.branch}\n`)
213
+ }
214
+
215
+ /** The delta fields the agent and a human need, without internal detail. */
216
+ const publicDelta = (d) => ({
217
+ name: d.name,
218
+ unit: d.unit,
219
+ base: d.baseCenter,
220
+ candidate: d.candCenter,
221
+ ratio: d.ratio,
222
+ pct_change: d.pctChange,
223
+ p: d.p,
224
+ significant: d.significant,
225
+ })
226
+
227
+ /** The largest single-benchmark improvement, in percent; 0 when unmeasured. */
228
+ function bestDelta(deltas) {
229
+ if (!deltas || deltas.length === 0) return 0
230
+ return Math.min(...deltas.map((d) => d.pctChange))
231
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Scans the repository, discovers benchmarks, and writes the run
3
+ * configuration and the agent's instruction set.
4
+ *
5
+ * It writes files and commits NOTHING. `baseline` refuses an uncommitted
6
+ * tree, so the human commits what init produced — deliberately, because a
7
+ * baseline pinned against what is on disk rather than what is in git would
8
+ * not be reproducible.
9
+ */
10
+ import { copyFile, mkdir, readFile, stat, writeFile } from 'node:fs/promises'
11
+ import { dirname, join } from 'node:path'
12
+ import { fileURLToPath } from 'node:url'
13
+ import { parseArgs } from 'node:util'
14
+ import { CONFIG_PATH, defaultConfig } from '../config.js'
15
+ import { baseNames, benchmarks } from '../discover.js'
16
+ import { resolveRepo } from './context.js'
17
+
18
+ const TEMPLATE = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'templates', 'program.md')
19
+
20
+ /** Entries init adds to .gitignore, in order. */
21
+ const IGNORE_ENTRIES = ['results.tsv', 'run.log', '.autor3search/*', '!.autor3search/config.yaml']
22
+
23
+ export async function runInit(args, io) {
24
+ const { values } = parseArgs({
25
+ args,
26
+ options: { C: { type: 'string', default: '.' }, force: { type: 'boolean', default: false } },
27
+ allowPositionals: false,
28
+ })
29
+ const root = await resolveRepo(values.C)
30
+
31
+ const found = await benchmarks(root)
32
+ if (found.length === 0) {
33
+ // Refusing is deliberate. The verdict is entirely a function of the
34
+ // declared benchmarks' timings, so with none there is no signal to gate
35
+ // on — every candidate would be accepted or rejected for no reason.
36
+ io.err.write(
37
+ `no benchmarks found in ${root}.\n\n` +
38
+ `autor3search-javascript optimizes what it can measure, and refuses to guess. Write at least one\n` +
39
+ `Vitest benchmark covering the code you want made faster, in a *.bench.js file:\n\n` +
40
+ ` import { bench } from 'vitest'\n` +
41
+ ` import { thing } from './thing.js'\n\n` +
42
+ ` bench('thing', () => { thing() })\n\n` +
43
+ `Benchmark the path that actually dominates your workload — one that exercises a cold path or a\n` +
44
+ `trivial helper produces numbers that are entirely real and entirely useless. Then run init again.\n`,
45
+ )
46
+ return 2
47
+ }
48
+
49
+ const configPath = join(root, CONFIG_PATH)
50
+ const exists = await stat(configPath).then(
51
+ () => true,
52
+ () => false,
53
+ )
54
+ if (exists && !values.force) {
55
+ io.err.write(`${CONFIG_PATH} already exists; pass --force to overwrite it\n`)
56
+ return 2
57
+ }
58
+
59
+ await mkdir(dirname(configPath), { recursive: true })
60
+ await writeFile(configPath, renderConfig(baseNames(found)))
61
+ await copyFile(TEMPLATE, join(root, 'program.md'))
62
+ await addIgnoreEntries(join(root, '.gitignore'))
63
+
64
+ io.out.write(`discovered ${found.length} benchmark(s):\n`)
65
+ for (const b of found) io.out.write(` ${b.path.padEnd(40)} ${b.file}\n`)
66
+ io.out.write(`\nwrote ${CONFIG_PATH}, program.md and .gitignore entries.\n`)
67
+ io.out.write(`next: git add -A && git commit -m "autor3search-javascript init"\n`)
68
+ io.out.write(`then: autor3search-javascript doctor && autor3search-javascript baseline -tag <tag>\n`)
69
+ return 0
70
+ }
71
+
72
+ /**
73
+ * Renders the config as commented YAML rather than serialising the defaults
74
+ * object: the comments are the documentation a human reads when deciding
75
+ * what to change, and they are the reason this file lives in the repository
76
+ * at all.
77
+ */
78
+ function renderConfig(names) {
79
+ const d = defaultConfig()
80
+ return `# autor3search-javascript run configuration.
81
+ #
82
+ # This file is HUMAN-OWNED and version-controlled. Its hash is recorded at
83
+ # baseline time, so any change during a run fails the run rather than
84
+ # silently moving the goalposts.
85
+
86
+ # The declared benchmark set. An empty list means every discovered benchmark.
87
+ benchmarks:
88
+ ${names.map((n) => ` - ${JSON.stringify(n)}`).join('\n')}
89
+
90
+ # Glob patterns the agent may edit. Everything else is rejected before an
91
+ # experiment is even measured.
92
+ scope:
93
+ - "**"
94
+
95
+ # Measured rounds per side. Below 4 the significance test can never report
96
+ # p < 0.05 however large the improvement, so every experiment would discard.
97
+ # Capped at 1000: timeout bounds each measurement process, not the run, so a
98
+ # stray zero here does not fail — it runs for days.
99
+ count: ${d.count}
100
+
101
+ # The largest tolerated significant regression, in percent.
102
+ max_regress_pct: ${d.maxRegressPct}
103
+
104
+ # The smallest geomean improvement a KEEP will accept, in percent.
105
+ min_effect_pct: ${d.minEffectPct}
106
+
107
+ # Bound on each subprocess phase.
108
+ timeout: ${d.timeout}
109
+
110
+ # Test or bench files deliberately exempt from freezing.
111
+ unfreeze: []
112
+
113
+ # The bench adapter. Only "vitest" is registered in this version.
114
+ runner: ${d.runner}
115
+
116
+ # Sample approximate bytes/op alongside the timings. Never scored.
117
+ heap_hint: ${d.heapHint}
118
+
119
+ # auto runs each gate when the repository is set up for it, on requires it,
120
+ # off never runs it.
121
+ gates:
122
+ typecheck: ${d.gates.typecheck}
123
+ lint: ${d.gates.lint}
124
+ test: ${d.gates.test}
125
+ `
126
+ }
127
+
128
+ /** Appends any missing ignore entries, leaving existing content untouched. */
129
+ async function addIgnoreEntries(path) {
130
+ const existing = await readFile(path, 'utf8').catch(() => '')
131
+ const lines = new Set(existing.split('\n').map((l) => l.trim()))
132
+ const missing = IGNORE_ENTRIES.filter((entry) => !lines.has(entry))
133
+ if (missing.length === 0) return
134
+ const prefix = existing === '' || existing.endsWith('\n') ? '' : '\n'
135
+ await writeFile(path, `${existing}${prefix}\n# autor3search-javascript\n${missing.join('\n')}\n`)
136
+ }
@@ -0,0 +1,38 @@
1
+ /** Profiles the declared benchmarks and prints where the time actually goes. */
2
+ import { parseArgs } from 'node:util'
3
+ import { profile } from '../profile.js'
4
+ import { loadRepoConfig, resolveRepo } from './context.js'
5
+
6
+ export async function runProfile(args, io) {
7
+ const { values } = parseArgs({
8
+ args,
9
+ options: { C: { type: 'string', default: '.' }, top: { type: 'string', default: '15' } },
10
+ allowPositionals: false,
11
+ })
12
+
13
+ const root = await resolveRepo(values.C)
14
+ const cfg = await loadRepoConfig(root)
15
+ const reports = await profile(root, cfg, { top: Number(values.top) })
16
+
17
+ for (const report of reports) {
18
+ io.out.write(`\n${report.file}\n`)
19
+ if (report.top.length === 0) {
20
+ io.out.write(' (no samples attributable to benchmarked code)\n')
21
+ continue
22
+ }
23
+ io.out.write(`${'self ms'.padStart(9)} ${'%'.padStart(6)} function\n`)
24
+ for (const entry of report.top) {
25
+ io.out.write(
26
+ `${entry.selfMs.toFixed(1).padStart(9)} ${entry.pct.toFixed(1).padStart(6)} ` +
27
+ `${entry.name}${entry.file ? ` (${entry.file})` : ''}\n`,
28
+ )
29
+ }
30
+ io.out.write(`\n cpu ${report.cpuProfile}\n`)
31
+ io.out.write(` heap ${report.heapProfile}\n`)
32
+ }
33
+ io.out.write(
34
+ '\nopen a .cpuprofile or .heapprofile in Chrome DevTools (Performance > Load profile) or at\n' +
35
+ 'https://speedscope.app for a flame graph.\n',
36
+ )
37
+ return 0
38
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Summarizes results.tsv: what ran, what was kept, and how much faster.
3
+ *
4
+ * Cumulative speedup is the PRODUCT of every kept score, never the latest
5
+ * score and never an average. The harness re-points its measurement baseline
6
+ * at the commit just kept after every KEEP, so each kept `score` measures
7
+ * only that experiment's own incremental contribution — "did this change
8
+ * help, compared to the last thing we kept" — never "is the tree better than
9
+ * when the run started". Successive improvements compound the way
10
+ * percentage changes do, so multiplying is the only way to recover total
11
+ * progress; averaging or taking the last score would both understate a good
12
+ * night, in different ways.
13
+ *
14
+ * A DISCARD's score is real (it was measured) but was rejected, so it is
15
+ * excluded from the product — folding it in would invent progress that was
16
+ * explicitly not banked.
17
+ */
18
+ import { join } from 'node:path'
19
+ import { parseArgs } from 'node:util'
20
+ import { RESULTS_PATH, loadRows } from '../results.js'
21
+ import { resolveRepo } from './context.js'
22
+
23
+ /** How many of the largest wins to list. */
24
+ const TOP_N = 5
25
+
26
+ /** Every status appendRow may write, in the order counted and printed. */
27
+ const STATUSES = ['keep', 'discard', 'fail', 'crash']
28
+
29
+ /**
30
+ * @param {string[]} args
31
+ * @param {{out: {write(s: string): void}, err: {write(s: string): void}}} io
32
+ * @returns {Promise<number>}
33
+ */
34
+ export async function runReport(args, io) {
35
+ const { values } = parseArgs({
36
+ args,
37
+ options: { C: { type: 'string', default: '.' } },
38
+ allowPositionals: false,
39
+ })
40
+ const root = await resolveRepo(values.C)
41
+ const rows = await loadRows(join(root, RESULTS_PATH))
42
+
43
+ if (rows.length === 0) {
44
+ io.out.write(`no experiments recorded in ${RESULTS_PATH} yet — nothing to report\n`)
45
+ return 0
46
+ }
47
+
48
+ const counts = Object.fromEntries(STATUSES.map((s) => [s, 0]))
49
+ let other = 0
50
+ for (const row of rows) {
51
+ if (Object.hasOwn(counts, row.status)) counts[row.status]++
52
+ else other++
53
+ }
54
+
55
+ io.out.write('experiments\n')
56
+ for (const status of STATUSES) io.out.write(` ${status.padEnd(10)} ${counts[status]}\n`)
57
+ if (other > 0) io.out.write(` ${'other'.padEnd(10)} ${other} (unrecognised status)\n`)
58
+ io.out.write(` ${'total'.padEnd(10)} ${rows.length}\n\n`)
59
+
60
+ const kept = rows.filter((r) => r.status === 'keep')
61
+ if (kept.length === 0) {
62
+ io.out.write('nothing was kept — no measured improvement cleared the bar.\n')
63
+ return 0
64
+ }
65
+
66
+ // The PRODUCT of every kept score — see file header for why.
67
+ const cumulative = kept.reduce((acc, r) => acc * r.score, 1)
68
+ io.out.write(`cumulative speedup ${cumulative.toFixed(4)} (${formatMultiplier(cumulative)}, `)
69
+ io.out.write(`${formatPercent(cumulative)})\n`)
70
+ io.out.write(` the product of all ${kept.length} kept score(s); discards do not contribute\n\n`)
71
+
72
+ io.out.write('largest individual wins\n')
73
+ for (const row of [...kept].sort((a, b) => a.score - b.score).slice(0, TOP_N)) {
74
+ io.out.write(
75
+ ` ${row.commit.padEnd(9)} ${row.score.toFixed(4)} ` +
76
+ `${row.bestBenchDelta >= 0 ? '+' : ''}${row.bestBenchDelta.toFixed(1)}% ${row.description}\n`,
77
+ )
78
+ }
79
+ return 0
80
+ }
81
+
82
+ /**
83
+ * The cumulative ratio as a human-readable "Nx faster". A ratio of exactly
84
+ * zero (a kept experiment measured at effectively zero cost) has no finite
85
+ * reciprocal — report that plainly instead of printing "Infinityx".
86
+ */
87
+ function formatMultiplier(cumulative) {
88
+ if (cumulative <= 0) return 'immeasurably faster (measured cost of zero)'
89
+ return `${(1 / cumulative).toFixed(2)}x faster`
90
+ }
91
+
92
+ /** The cumulative ratio as a signed percent change, e.g. "-75.00%". */
93
+ function formatPercent(cumulative) {
94
+ const pct = (cumulative - 1) * 100
95
+ return `${pct >= 0 ? '+' : ''}${pct.toFixed(2)}%`
96
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Prints where a run is. READ-ONLY: checking on a run must never change it,
3
+ * which is why nothing here writes, claims, or clears anything.
4
+ */
5
+ import { join } from 'node:path'
6
+ import { parseArgs } from 'node:util'
7
+ import * as gitx from '../gitx.js'
8
+ import { RESULTS_PATH, loadRows } from '../results.js'
9
+ import { BASELINE_FILE, WORKTREE_NAME, loadBaseline } from '../state/index.js'
10
+ import { evalRunning } from '../state/lock.js'
11
+ import { stopRequested } from '../state/stop.js'
12
+ import { expandSingleDashFlags, resolveRun } from './context.js'
13
+
14
+ /**
15
+ * @param {string[]} args
16
+ * @param {{out: {write(s: string): void}, err: {write(s: string): void}}} io
17
+ * @returns {Promise<number>}
18
+ */
19
+ export async function runStatus(args, io) {
20
+ const { values } = parseArgs({
21
+ args: expandSingleDashFlags(args, ['tag']),
22
+ options: { C: { type: 'string', default: '.' }, tag: { type: 'string' } },
23
+ allowPositionals: false,
24
+ })
25
+
26
+ const run = await resolveRun(values.C, values.tag)
27
+ const baseline = await loadBaseline(join(run.stateDir, BASELINE_FILE))
28
+ const current = await gitx.currentBranch(run.root)
29
+ const rows = await loadRows(join(run.root, RESULTS_PATH))
30
+ const counts = { keep: 0, discard: 0, fail: 0, crash: 0 }
31
+ for (const row of rows) if (row.status in counts) counts[row.status]++
32
+
33
+ // A corrupt pid file is an error `evalRunning` deliberately throws rather
34
+ // than guessing at — but a human reaching for `status` to see the REST of
35
+ // the run must never be stopped cold by that. Report the eval line as
36
+ // unreadable and keep going with everything else.
37
+ let evalLine
38
+ try {
39
+ const { pid, running } = await evalRunning(run.stateDir)
40
+ evalLine = running ? `running (pid ${pid}) — an experiment is being measured` : 'idle'
41
+ } catch (err) {
42
+ evalLine = `unknown (${err.message})`
43
+ }
44
+ const stopped = await stopRequested(run.stateDir)
45
+
46
+ const field = (name, value) => io.out.write(`${name.padEnd(14)} ${value}\n`)
47
+ field('run tag', run.tag)
48
+ field('branch', `${run.branch} ${current === run.branch ? '(checked out)' : `(not checked out; on ${current})`}`)
49
+ field('baseline', `${baseline.commit} (run started here)`)
50
+ field(
51
+ 'measuring vs',
52
+ baseline.measureCommit === baseline.commit
53
+ ? `${baseline.measureCommit} (unchanged — nothing kept yet)`
54
+ : `${baseline.measureCommit} (advanced past the baseline by earlier KEEPs)`,
55
+ )
56
+ field('worktree', join(run.stateDir, WORKTREE_NAME))
57
+ field(
58
+ 'experiments',
59
+ `${rows.length} run (${counts.keep} keep, ${counts.discard} discard, ${counts.fail} fail, ` +
60
+ `${counts.crash} crash) — next is #${rows.length + 1}`,
61
+ )
62
+ field('eval', evalLine)
63
+ field('stop', stopped ? 'requested — the agent will end the run after the current experiment' : 'not requested')
64
+
65
+ io.out.write('\nto stop after the current experiment: autor3search-javascript stop\n')
66
+ io.out.write('to stop now, abandoning it: autor3search-javascript stop --force\n')
67
+ return 0
68
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Asks the agent to end the run.
3
+ *
4
+ * Plain `stop` is the one to use: it writes a request `eval` reports back, so
5
+ * the experiment under way finishes and is scored, its verdict is applied,
6
+ * and only then does the loop exit. Nothing is thrown away.
7
+ *
8
+ * `--force` is for when a long benchmark cannot be waited out. It writes the
9
+ * same request, then signals the running eval to abandon the experiment. It
10
+ * REPORTS what state that leaves the repository in; it does not drop anything
11
+ * for you, because deciding what to do with a half-finished experiment is the
12
+ * human's call.
13
+ */
14
+ import { parseArgs } from 'node:util'
15
+ import * as gitx from '../gitx.js'
16
+ import { evalRunning } from '../state/lock.js'
17
+ import { clearStop, requestStop } from '../state/stop.js'
18
+ import { expandSingleDashFlags, resolveRun } from './context.js'
19
+
20
+ /**
21
+ * @param {string[]} args
22
+ * @param {{out: {write(s: string): void}, err: {write(s: string): void}}} io
23
+ * @returns {Promise<number>}
24
+ */
25
+ export async function runStop(args, io) {
26
+ const { values } = parseArgs({
27
+ args: expandSingleDashFlags(args, ['tag']),
28
+ options: {
29
+ C: { type: 'string', default: '.' },
30
+ tag: { type: 'string' },
31
+ clear: { type: 'boolean', default: false },
32
+ force: { type: 'boolean', default: false },
33
+ },
34
+ allowPositionals: false,
35
+ })
36
+
37
+ if (values.clear && values.force) {
38
+ io.err.write('--clear and --force ask for opposite things; pass one or the other\n')
39
+ return 2
40
+ }
41
+
42
+ const run = await resolveRun(values.C, values.tag)
43
+
44
+ if (values.clear) {
45
+ await clearStop(run.stateDir)
46
+ io.out.write(`stop request for ${run.tag} cancelled — the loop may continue\n`)
47
+ return 0
48
+ }
49
+
50
+ await requestStop(run.stateDir)
51
+ io.out.write(`stop requested for ${run.tag}: the agent will end the run after the current experiment\n`)
52
+ if (!values.force) {
53
+ io.out.write('the experiment under way will still be measured, scored and applied\n')
54
+ io.out.write(`to cancel: autor3search-javascript stop -tag ${run.tag} --clear\n`)
55
+ return 0
56
+ }
57
+
58
+ // A corrupt pid file must not crash `stop --force` — it is refused by
59
+ // evalRunning rather than guessed at, but the request above already stands
60
+ // regardless, so fall back to "nothing known to signal" and keep going.
61
+ let pid = null
62
+ let running = false
63
+ try {
64
+ ;({ pid, running } = await evalRunning(run.stateDir))
65
+ } catch (err) {
66
+ io.err.write(`could not read the eval lock (${err.message}); assuming there is nothing to signal\n`)
67
+ }
68
+
69
+ if (running) {
70
+ // SIGTERM, not SIGKILL: eval installs a handler that tears down its child
71
+ // process groups. Killing it outright would leave Vitest workers running,
72
+ // burning CPU and corrupting every later measurement on this machine.
73
+ try {
74
+ process.kill(pid, 'SIGTERM')
75
+ io.out.write(`signalled eval (pid ${pid}) to abandon the current experiment\n`)
76
+ } catch (err) {
77
+ io.err.write(`could not signal eval (pid ${pid}): ${err.message}\n`)
78
+ }
79
+ } else {
80
+ io.out.write('no eval is running, so there was nothing to interrupt — the request stands\n')
81
+ }
82
+
83
+ // Report, do not act — whether or not anything was actually interrupted,
84
+ // this is what the repository looks like right now. The commit an
85
+ // abandoned experiment made, if any, is still on the branch and nothing
86
+ // scored it; dropping it is the human's call, not this command's.
87
+ const branch = await gitx.currentBranch(run.root).catch(() => 'unknown')
88
+ const head = await gitx.headCommit(run.root).catch(() => 'unknown')
89
+ const subject = await gitx.headSubject(run.root).catch(() => '')
90
+ io.out.write('\nwhat this leaves you with:\n')
91
+ io.out.write(` branch ${branch}\n`)
92
+ io.out.write(` HEAD ${head} ${subject}\n`)
93
+ io.out.write(' no results.tsv row is written for an experiment interrupted this way\n')
94
+ io.out.write('\nHEAD may be the commit for an experiment nothing scored. If it is, drop it yourself:\n')
95
+ io.out.write(' git reset --hard HEAD~1\n')
96
+ io.out.write('Every commit kept before it is untouched.\n')
97
+ return 0
98
+ }