amness 0.0.0-stage → 0.1.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 (233) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +212 -2
  3. package/dist/cli/acpcli.d.ts +7 -0
  4. package/dist/cli/acpcli.js +14 -0
  5. package/dist/cli/amness.d.ts +2 -0
  6. package/dist/cli/amness.js +693 -0
  7. package/dist/cli/benchcli.d.ts +5 -0
  8. package/dist/cli/benchcli.js +160 -0
  9. package/dist/cli/config.d.ts +79 -0
  10. package/dist/cli/config.js +54 -0
  11. package/dist/cli/demo.d.ts +13 -0
  12. package/dist/cli/demo.js +44 -0
  13. package/dist/cli/detect.d.ts +2 -0
  14. package/dist/cli/detect.js +20 -0
  15. package/dist/cli/evalcli.d.ts +15 -0
  16. package/dist/cli/evalcli.js +198 -0
  17. package/dist/cli/format.d.ts +5 -0
  18. package/dist/cli/format.js +55 -0
  19. package/dist/cli/headless.d.ts +2 -0
  20. package/dist/cli/headless.js +169 -0
  21. package/dist/cli/model.d.ts +11 -0
  22. package/dist/cli/model.js +19 -0
  23. package/dist/cli/plugincli.d.ts +2 -0
  24. package/dist/cli/plugincli.js +176 -0
  25. package/dist/cli/schedulecli.d.ts +2 -0
  26. package/dist/cli/schedulecli.js +66 -0
  27. package/dist/cli/state.d.ts +11 -0
  28. package/dist/cli/state.js +19 -0
  29. package/dist/cli/tui/ansi.d.ts +38 -0
  30. package/dist/cli/tui/ansi.js +142 -0
  31. package/dist/cli/tui/app.d.ts +328 -0
  32. package/dist/cli/tui/app.js +1358 -0
  33. package/dist/cli/tui/blocks.d.ts +151 -0
  34. package/dist/cli/tui/blocks.js +128 -0
  35. package/dist/cli/tui/brand.d.ts +9 -0
  36. package/dist/cli/tui/brand.js +16 -0
  37. package/dist/cli/tui/canvas.d.ts +92 -0
  38. package/dist/cli/tui/canvas.js +199 -0
  39. package/dist/cli/tui/cards.d.ts +44 -0
  40. package/dist/cli/tui/cards.js +215 -0
  41. package/dist/cli/tui/clipboard.d.ts +5 -0
  42. package/dist/cli/tui/clipboard.js +29 -0
  43. package/dist/cli/tui/commands.d.ts +10 -0
  44. package/dist/cli/tui/commands.js +649 -0
  45. package/dist/cli/tui/complete.d.ts +39 -0
  46. package/dist/cli/tui/complete.js +113 -0
  47. package/dist/cli/tui/custom.d.ts +9 -0
  48. package/dist/cli/tui/custom.js +34 -0
  49. package/dist/cli/tui/diffview.d.ts +25 -0
  50. package/dist/cli/tui/diffview.js +130 -0
  51. package/dist/cli/tui/editor.d.ts +60 -0
  52. package/dist/cli/tui/editor.js +230 -0
  53. package/dist/cli/tui/full.d.ts +86 -0
  54. package/dist/cli/tui/full.js +630 -0
  55. package/dist/cli/tui/keys.d.ts +31 -0
  56. package/dist/cli/tui/keys.js +134 -0
  57. package/dist/cli/tui/markdown.d.ts +37 -0
  58. package/dist/cli/tui/markdown.js +155 -0
  59. package/dist/cli/tui/mentions.d.ts +15 -0
  60. package/dist/cli/tui/mentions.js +48 -0
  61. package/dist/cli/tui/modal.d.ts +26 -0
  62. package/dist/cli/tui/modal.js +107 -0
  63. package/dist/cli/tui/overlays.d.ts +98 -0
  64. package/dist/cli/tui/overlays.js +237 -0
  65. package/dist/cli/tui/pager.d.ts +15 -0
  66. package/dist/cli/tui/pager.js +66 -0
  67. package/dist/cli/tui/pricing.d.ts +9 -0
  68. package/dist/cli/tui/pricing.js +23 -0
  69. package/dist/cli/tui/rich.d.ts +16 -0
  70. package/dist/cli/tui/rich.js +72 -0
  71. package/dist/cli/tui/screen.d.ts +30 -0
  72. package/dist/cli/tui/screen.js +62 -0
  73. package/dist/cli/tui/session.d.ts +77 -0
  74. package/dist/cli/tui/session.js +103 -0
  75. package/dist/cli/tui/sidebar.d.ts +84 -0
  76. package/dist/cli/tui/sidebar.js +114 -0
  77. package/dist/cli/tui/text.d.ts +15 -0
  78. package/dist/cli/tui/text.js +94 -0
  79. package/dist/cli/tui/theme.d.ts +59 -0
  80. package/dist/cli/tui/theme.js +148 -0
  81. package/dist/cli/tui/themes.d.ts +56 -0
  82. package/dist/cli/tui/themes.js +80 -0
  83. package/dist/cli/tui/tokens.d.ts +39 -0
  84. package/dist/cli/tui/tokens.js +71 -0
  85. package/dist/cli/tui/view.d.ts +60 -0
  86. package/dist/cli/tui/view.js +178 -0
  87. package/dist/cli/ui.d.ts +34 -0
  88. package/dist/cli/ui.js +224 -0
  89. package/dist/src/acp.d.ts +28 -0
  90. package/dist/src/acp.js +111 -0
  91. package/dist/src/agents.d.ts +23 -0
  92. package/dist/src/agents.js +71 -0
  93. package/dist/src/anthropic.d.ts +30 -0
  94. package/dist/src/anthropic.js +91 -0
  95. package/dist/src/approver.d.ts +15 -0
  96. package/dist/src/approver.js +40 -0
  97. package/dist/src/bench.d.ts +67 -0
  98. package/dist/src/bench.js +201 -0
  99. package/dist/src/bundle.d.ts +36 -0
  100. package/dist/src/bundle.js +55 -0
  101. package/dist/src/chaos.d.ts +15 -0
  102. package/dist/src/chaos.js +26 -0
  103. package/dist/src/code.d.ts +187 -0
  104. package/dist/src/code.js +590 -0
  105. package/dist/src/codeindex.d.ts +65 -0
  106. package/dist/src/codeindex.js +300 -0
  107. package/dist/src/deep.d.ts +62 -0
  108. package/dist/src/deep.js +177 -0
  109. package/dist/src/diff.d.ts +8 -0
  110. package/dist/src/diff.js +74 -0
  111. package/dist/src/dynamic.d.ts +42 -0
  112. package/dist/src/dynamic.js +133 -0
  113. package/dist/src/embed.d.ts +8 -0
  114. package/dist/src/embed.js +25 -0
  115. package/dist/src/evalcases.d.ts +11 -0
  116. package/dist/src/evalcases.js +196 -0
  117. package/dist/src/evals.d.ts +197 -0
  118. package/dist/src/evals.js +340 -0
  119. package/dist/src/gitflow.d.ts +27 -0
  120. package/dist/src/gitflow.js +56 -0
  121. package/dist/src/grade.d.ts +42 -0
  122. package/dist/src/grade.js +96 -0
  123. package/dist/src/guidance.d.ts +15 -0
  124. package/dist/src/guidance.js +68 -0
  125. package/dist/src/hardcases.d.ts +2 -0
  126. package/dist/src/hardcases.js +94 -0
  127. package/dist/src/harness.d.ts +411 -0
  128. package/dist/src/harness.js +725 -0
  129. package/dist/src/hooks.d.ts +44 -0
  130. package/dist/src/hooks.js +116 -0
  131. package/dist/src/images.d.ts +13 -0
  132. package/dist/src/images.js +27 -0
  133. package/dist/src/index.d.ts +57 -0
  134. package/dist/src/index.js +57 -0
  135. package/dist/src/ledger.d.ts +58 -0
  136. package/dist/src/ledger.js +78 -0
  137. package/dist/src/live.d.ts +16 -0
  138. package/dist/src/live.js +80 -0
  139. package/dist/src/lsp.d.ts +31 -0
  140. package/dist/src/lsp.js +147 -0
  141. package/dist/src/memory.d.ts +128 -0
  142. package/dist/src/memory.js +390 -0
  143. package/dist/src/metrics.d.ts +3 -0
  144. package/dist/src/metrics.js +42 -0
  145. package/dist/src/mining.d.ts +49 -0
  146. package/dist/src/mining.js +126 -0
  147. package/dist/src/openai.d.ts +50 -0
  148. package/dist/src/openai.js +307 -0
  149. package/dist/src/parallel.d.ts +66 -0
  150. package/dist/src/parallel.js +135 -0
  151. package/dist/src/patch.d.ts +45 -0
  152. package/dist/src/patch.js +219 -0
  153. package/dist/src/permissions.d.ts +46 -0
  154. package/dist/src/permissions.js +245 -0
  155. package/dist/src/plugins.d.ts +148 -0
  156. package/dist/src/plugins.js +225 -0
  157. package/dist/src/pluginscan.d.ts +8 -0
  158. package/dist/src/pluginscan.js +20 -0
  159. package/dist/src/procs.d.ts +41 -0
  160. package/dist/src/procs.js +94 -0
  161. package/dist/src/profiles.d.ts +36 -0
  162. package/dist/src/profiles.js +61 -0
  163. package/dist/src/progress.d.ts +29 -0
  164. package/dist/src/progress.js +75 -0
  165. package/dist/src/prompts.d.ts +10 -0
  166. package/dist/src/prompts.js +28 -0
  167. package/dist/src/ratelimit.d.ts +34 -0
  168. package/dist/src/ratelimit.js +116 -0
  169. package/dist/src/replay.d.ts +25 -0
  170. package/dist/src/replay.js +99 -0
  171. package/dist/src/review.d.ts +20 -0
  172. package/dist/src/review.js +49 -0
  173. package/dist/src/route.d.ts +9 -0
  174. package/dist/src/route.js +30 -0
  175. package/dist/src/router.d.ts +61 -0
  176. package/dist/src/router.js +126 -0
  177. package/dist/src/sandbox.d.ts +13 -0
  178. package/dist/src/sandbox.js +68 -0
  179. package/dist/src/schedule.d.ts +20 -0
  180. package/dist/src/schedule.js +59 -0
  181. package/dist/src/schema.d.ts +13 -0
  182. package/dist/src/schema.js +36 -0
  183. package/dist/src/secrets.d.ts +10 -0
  184. package/dist/src/secrets.js +41 -0
  185. package/dist/src/shell.d.ts +34 -0
  186. package/dist/src/shell.js +95 -0
  187. package/dist/src/skills.d.ts +20 -0
  188. package/dist/src/skills.js +112 -0
  189. package/dist/src/snapshot.d.ts +20 -0
  190. package/dist/src/snapshot.js +93 -0
  191. package/dist/src/stats.d.ts +24 -0
  192. package/dist/src/stats.js +62 -0
  193. package/dist/src/strategies.d.ts +16 -0
  194. package/dist/src/strategies.js +35 -0
  195. package/dist/src/suites.d.ts +30 -0
  196. package/dist/src/suites.js +112 -0
  197. package/dist/src/trace.d.ts +63 -0
  198. package/dist/src/trace.js +153 -0
  199. package/dist/src/tune.d.ts +38 -0
  200. package/dist/src/tune.js +49 -0
  201. package/dist/src/web.d.ts +19 -0
  202. package/dist/src/web.js +56 -0
  203. package/dist/src/worktree.d.ts +24 -0
  204. package/dist/src/worktree.js +85 -0
  205. package/docs/agent-loop.md +222 -0
  206. package/docs/architecture.md +228 -0
  207. package/docs/bench.md +293 -0
  208. package/docs/budgets.md +185 -0
  209. package/docs/build-your-own-harness.md +424 -0
  210. package/docs/cookbook.md +375 -0
  211. package/docs/development.md +150 -0
  212. package/docs/evaluation.md +450 -0
  213. package/docs/features.md +1320 -0
  214. package/docs/getting-started.md +196 -0
  215. package/docs/headless.md +183 -0
  216. package/docs/images/approval.png +0 -0
  217. package/docs/images/chat.png +0 -0
  218. package/docs/images/daylight.png +0 -0
  219. package/docs/images/loop.png +0 -0
  220. package/docs/images/palette.png +0 -0
  221. package/docs/images/running.png +0 -0
  222. package/docs/images/subagent-done.png +0 -0
  223. package/docs/images/subagent-running.png +0 -0
  224. package/docs/images/welcome.png +0 -0
  225. package/docs/index.md +25 -0
  226. package/docs/library.md +276 -0
  227. package/docs/plugins.md +691 -0
  228. package/docs/providers.md +175 -0
  229. package/docs/safety.md +237 -0
  230. package/docs/usage.md +348 -0
  231. package/docs/why.md +155 -0
  232. package/docs/writing-guide.md +39 -0
  233. package/package.json +72 -4
@@ -0,0 +1,197 @@
1
+ import { type Model, type Tool } from "./harness.ts";
2
+ import type { Profile } from "./profiles.ts";
3
+ import type { Plugin, VariantSpec } from "./plugins.ts";
4
+ import { type Interval } from "./stats.ts";
5
+ export type Case = {
6
+ name: string;
7
+ /** Fixture files written into a fresh temp dir. */
8
+ files: Record<string, string>;
9
+ task: string;
10
+ /** Shown to the agent: a command it may run to check its work (and the `finish` gate in the full variant). */
11
+ verify?: string;
12
+ /** Held-out judge: shell command (exit 0 = pass) or function. The agent never sees this. */
13
+ check: string | ((dir: string) => boolean | string | Promise<boolean | string>);
14
+ /** A known-good fix (files to overwrite). Used by `selfTest` to prove the case is solvable and its check is not trivially true. */
15
+ solution?: Record<string, string>;
16
+ /** Extra tools for this case (a stubbed web page, a fake MCP server): the agent gets them next to its own. */
17
+ tools?: (dir: string) => Tool[];
18
+ /** Prepare the workspace beyond writing `files` (clone a repository, check out a commit, apply a patch). A failure excludes the case from the statistics instead of counting as a fail. */
19
+ setup?: (dir: string) => Promise<void>;
20
+ };
21
+ export type Outcome = {
22
+ text: string;
23
+ steps: number;
24
+ tokens: number; /** prompt tokens sent, and how many of them the provider served from its cache */
25
+ prompt?: number;
26
+ cached?: number; /** how often each harness event fired during the run (nudge, loop, repair, rejected, trim, ...) */
27
+ events?: Record<string, number>;
28
+ };
29
+ /** A number computed for every run, beyond pass/fail (cost, diff size, tool calls, anything): the report shows its mean per variant and the paired difference against the baseline. */
30
+ export type MetricFn = (ctx: {
31
+ case: Case;
32
+ row: Row;
33
+ outcome: Outcome;
34
+ dir: string;
35
+ }) => number | Promise<number>;
36
+ export type Variant = {
37
+ name: string;
38
+ run: (c: Case, dir: string) => Promise<Outcome>;
39
+ };
40
+ export type Row = {
41
+ excluded?: string;
42
+ case: string;
43
+ variant: string;
44
+ trial: number;
45
+ pass: boolean;
46
+ steps: number;
47
+ tokens: number;
48
+ ms: number;
49
+ note?: string; /** values of the custom metrics for this run */
50
+ metrics?: Record<string, number>;
51
+ events?: Record<string, number>;
52
+ prompt?: number;
53
+ cached?: number;
54
+ };
55
+ /** Run every case x variant x trial in a fresh temp workspace and judge each result with the case's held-out check. `jobs` runs that many sessions at once. */
56
+ export declare function evaluate(cases: Case[], variants: Variant[], o?: {
57
+ metrics?: Record<string, MetricFn>;
58
+ trials?: number;
59
+ jobs?: number;
60
+ onRow?: (r: Row) => void;
61
+ baseline?: string;
62
+ k?: number;
63
+ seed?: number;
64
+ }): Promise<{
65
+ rows: Row[];
66
+ summary: {
67
+ variant: string;
68
+ runs: number;
69
+ passRate: number;
70
+ steps: number;
71
+ tokens: number;
72
+ secs: number;
73
+ }[];
74
+ table: string;
75
+ analysis: Analysis;
76
+ }>;
77
+ export declare function formatTable(s: {
78
+ variant: string;
79
+ runs: number;
80
+ passRate: number;
81
+ steps: number;
82
+ tokens: number;
83
+ secs: number;
84
+ }[]): string;
85
+ export type VariantStats = {
86
+ variant: string;
87
+ tasks: number;
88
+ runs: number;
89
+ trials: number;
90
+ passRate: number;
91
+ ci: Interval; /** reliability: chance that k independent attempts at a task all pass */
92
+ passPowK: number;
93
+ k: number;
94
+ /** mean of each custom metric (per-task means, then the mean over tasks) */ metrics: Record<string, number>;
95
+ tokensPerSolved: number; /** share of prompt tokens served from the provider cache (NaN when the provider reports none) */
96
+ cacheHit: number;
97
+ steps: number;
98
+ secs: number;
99
+ events: Record<string, number>; /** not beaten on both pass rate and cost by another variant */
100
+ pareto: boolean;
101
+ };
102
+ export type Comparison = {
103
+ variant: string;
104
+ vs: string;
105
+ dPass: number;
106
+ ci: Interval;
107
+ wins: number;
108
+ losses: number;
109
+ ties: number;
110
+ p: number;
111
+ pAdj: number;
112
+ dTokensPerSolved: number;
113
+ verdict: "better" | "worse" | "inconclusive";
114
+ /** paired difference of each custom metric against the baseline, with a 95% interval over tasks */ metrics: Record<string, {
115
+ d: number;
116
+ ci: Interval;
117
+ }>;
118
+ };
119
+ export type Analysis = {
120
+ excluded: {
121
+ case: string;
122
+ variant: string;
123
+ reason: string;
124
+ }[];
125
+ baseline: string;
126
+ tasks: number;
127
+ trials: number;
128
+ stats: VariantStats[];
129
+ comparisons: Comparison[];
130
+ metricNames: string[];
131
+ minDetectable: number;
132
+ };
133
+ /**
134
+ * Turn raw rows into a statistical comparison. Tasks are the unit: each task contributes its pass fraction across trials, intervals are
135
+ * task-level bootstrap intervals, the significance test is an exact sign test on the tasks where two variants differ, and p-values are
136
+ * Holm-adjusted across the variants compared. Cost is reported as tokens per solved task, not per run, so a cheap variant that fails is not "efficient".
137
+ */
138
+ export declare function analyze(allRows: Row[], o?: {
139
+ baseline?: string;
140
+ k?: number;
141
+ seed?: number;
142
+ }): Analysis;
143
+ /** Plain-text report: per-variant table, paired comparison against the baseline, and an honest reading of what the data can support. */
144
+ export declare function formatReport(a: Analysis): string;
145
+ export declare const VARIANT_HELP: string;
146
+ /** Build a named variant of the code agent. Names: see VARIANT_HELP. */
147
+ /** A plugin-defined variant: the plugin's variants are looked up by `name` or `plugin:name`. */
148
+ export declare function findPluginVariant(plugins: Plugin[] | undefined, name: string): {
149
+ spec: VariantSpec;
150
+ owner: Plugin;
151
+ } | undefined;
152
+ export declare function harnessVariant(model: Model, name: string, o?: {
153
+ maxSteps?: number;
154
+ maxTokens?: number; /** the cheaper model for the `full+route` variant */
155
+ cheap?: Model; /** models by id, for `model:<id>` variants */
156
+ modelFor?: (id: string) => Model;
157
+ wrapTools?: (tools: Tool[]) => Tool[]; /** harness settings to run under (used by `amness tune`) */
158
+ profile?: Profile; /** loaded plugins: a variant name may refer to one of their `variants` (as `name` or `plugin:name`) */
159
+ plugins?: Plugin[];
160
+ prices?: {
161
+ strong: {
162
+ input: number;
163
+ output: number;
164
+ };
165
+ cheap: {
166
+ input: number;
167
+ output: number;
168
+ };
169
+ };
170
+ }): Variant;
171
+ /** Ready-made A/B for coding agents: `baseline` (no guards, no verify gate) vs `full-harness` (all guards + the case's verify command). */
172
+ export declare function codeVariants(model: Model, o?: {
173
+ maxSteps?: number;
174
+ maxTokens?: number;
175
+ }): Variant[];
176
+ /** Expand a preset or comma list into variant names: `ab`, `ablate` (full plus each guard removed in turn), `all`. */
177
+ export declare function variantNames(spec: string): string[];
178
+ /** Prove each case is sound: its check must FAIL on the starting files and PASS once the reference solution is applied. */
179
+ export declare function selfTest(cases: Case[]): Promise<{
180
+ case: string;
181
+ ok: boolean;
182
+ why?: string;
183
+ }[]>;
184
+ export type GateVerdict = {
185
+ variant: string;
186
+ ok: boolean;
187
+ lines: string[];
188
+ };
189
+ /**
190
+ * A regression gate for CI: compare a new run with a saved one, variant by variant, on the tasks both have. It fails when the pass rate dropped by more than `tol`
191
+ * AND the drop is clear (the 95% interval over tasks is below zero), or when tokens per solved task grew by more than `costPct` percent. A drop that noise could explain is a warning, not a failure.
192
+ */
193
+ export declare function gateCompare(current: Row[], baseline: Row[], o?: {
194
+ tol?: number;
195
+ costPct?: number;
196
+ seed?: number;
197
+ }): GateVerdict[];
@@ -0,0 +1,340 @@
1
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { createCodeAgent, runShell } from "./code.js";
5
+ import { routedModel } from "./router.js";
6
+ import { Budget } from "./harness.js";
7
+ import { STRATEGIES } from "./strategies.js";
8
+ import { CHAOS_KINDS, chaosModel, chaosTools } from "./chaos.js";
9
+ import { promptPlugin } from "./prompts.js";
10
+ import { Gate, rateLimited } from "./ratelimit.js";
11
+ import { bootstrap, holm, mean, minDetectable, passPowK, signTest } from "./stats.js";
12
+ /** Run every case x variant x trial in a fresh temp workspace and judge each result with the case's held-out check. `jobs` runs that many sessions at once. */
13
+ export async function evaluate(cases, variants, o = {}) {
14
+ const rows = [], work = [];
15
+ for (const c of cases)
16
+ for (const v of variants)
17
+ for (let trial = 0; trial < (o.trials ?? 1); trial++)
18
+ work.push(async () => {
19
+ const dir = mkdtempSync(join(tmpdir(), "amness-eval-"));
20
+ for (const [f, content] of Object.entries(c.files)) {
21
+ mkdirSync(dirname(join(dir, f)), { recursive: true });
22
+ writeFileSync(join(dir, f), content);
23
+ }
24
+ const t0 = Date.now();
25
+ const row = { case: c.name, variant: v.name, trial, pass: false, steps: 0, tokens: 0, ms: 0 };
26
+ if (c.setup) {
27
+ try {
28
+ await c.setup(dir);
29
+ }
30
+ catch (e) {
31
+ row.excluded = e.message.slice(0, 300);
32
+ row.ms = Date.now() - t0;
33
+ rows.push(row);
34
+ o.onRow?.(row);
35
+ rmSync(dir, { recursive: true, force: true });
36
+ return;
37
+ }
38
+ }
39
+ try {
40
+ const out = await v.run(c, dir);
41
+ Object.assign(row, { steps: out.steps, tokens: out.tokens, events: out.events, prompt: out.prompt, cached: out.cached });
42
+ const j = typeof c.check === "string" ? ((await runShell(c.check, dir, 120_000)).startsWith("exit=0") || "check command failed") : await c.check(dir);
43
+ row.pass = j === true;
44
+ if (j !== true)
45
+ row.note = String(j || "failed");
46
+ if (o.metrics) {
47
+ row.metrics = {};
48
+ for (const [name, fn] of Object.entries(o.metrics)) {
49
+ try {
50
+ const v = await fn({ case: c, row, outcome: out, dir });
51
+ if (Number.isFinite(v))
52
+ row.metrics[name] = v;
53
+ }
54
+ catch { /* a broken metric must not fail the run */ }
55
+ }
56
+ }
57
+ }
58
+ catch (e) {
59
+ row.note = "crashed: " + e.message;
60
+ }
61
+ row.ms = Date.now() - t0;
62
+ rows.push(row);
63
+ o.onRow?.(row);
64
+ rmSync(dir, { recursive: true, force: true });
65
+ });
66
+ let next = 0;
67
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(o.jobs ?? 1, work.length)) }, async () => { while (next < work.length)
68
+ await work[next++](); }));
69
+ rows.sort((a, b) => cases.findIndex((c) => c.name === a.case) - cases.findIndex((c) => c.name === b.case) || variants.findIndex((v) => v.name === a.variant) - variants.findIndex((v) => v.name === b.variant) || a.trial - b.trial);
70
+ const summary = variants.map((v) => {
71
+ const r = rows.filter((x) => x.variant === v.name), avg = (f) => Math.round(r.reduce((n, x) => n + f(x), 0) / (r.length || 1));
72
+ return { variant: v.name, runs: r.length, passRate: r.filter((x) => x.pass).length / (r.length || 1), steps: avg((x) => x.steps), tokens: avg((x) => x.tokens), secs: avg((x) => x.ms) / 1000 };
73
+ });
74
+ return { rows, summary, table: formatTable(summary), analysis: analyze(rows, { baseline: o.baseline, k: o.k, seed: o.seed }) };
75
+ }
76
+ export function formatTable(s) {
77
+ const line = (a) => a.map((x, i) => String(x).padEnd([22, 6, 9, 7, 9, 7][i])).join(" ");
78
+ return [line(["variant", "runs", "pass", "steps", "tokens", "secs"]), ...s.map((x) => line([x.variant, x.runs, (x.passRate * 100).toFixed(0) + "%", x.steps, x.tokens, x.secs.toFixed(1)]))].join("\n");
79
+ }
80
+ /**
81
+ * Turn raw rows into a statistical comparison. Tasks are the unit: each task contributes its pass fraction across trials, intervals are
82
+ * task-level bootstrap intervals, the significance test is an exact sign test on the tasks where two variants differ, and p-values are
83
+ * Holm-adjusted across the variants compared. Cost is reported as tokens per solved task, not per run, so a cheap variant that fails is not "efficient".
84
+ */
85
+ export function analyze(allRows, o = {}) {
86
+ const rows = allRows.filter((r) => !r.excluded), excluded = allRows.filter((r) => r.excluded);
87
+ const variants = [...new Set(rows.map((r) => r.variant))], tasks = [...new Set(rows.map((r) => r.case))];
88
+ const trials = Math.max(1, ...rows.map((r) => r.trial + 1)), k = Math.min(o.k ?? 3, trials) || 1, baseline = o.baseline && variants.includes(o.baseline) ? o.baseline : variants[0];
89
+ const cell = new Map();
90
+ for (const r of rows) {
91
+ const key = r.variant + "\0" + r.case, c = cell.get(key) ?? { n: 0, pass: 0, tokens: 0 };
92
+ c.n++;
93
+ c.pass += r.pass ? 1 : 0;
94
+ c.tokens += r.tokens;
95
+ cell.set(key, c);
96
+ }
97
+ const of = (v) => tasks.map((t) => cell.get(v + "\0" + t) ?? { n: 0, pass: 0, tokens: 0 });
98
+ const frac = (xs) => mean(xs.filter((x) => x.n).map((x) => x.pass / x.n));
99
+ const tps = (xs) => { const p = xs.reduce((n, x) => n + x.pass, 0); return p ? xs.reduce((n, x) => n + x.tokens, 0) / p : Infinity; };
100
+ const metricNames = [...new Set(rows.flatMap((r) => Object.keys(r.metrics ?? {})))];
101
+ const perTask = (v, m) => tasks.map((t) => { const xs = rows.filter((r) => r.variant === v && r.case === t && r.metrics?.[m] !== undefined).map((r) => r.metrics[m]); return xs.length ? mean(xs) : NaN; });
102
+ const stats = variants.map((v) => {
103
+ const r = rows.filter((x) => x.variant === v), per = of(v), ev = {};
104
+ for (const x of r)
105
+ for (const [e, n] of Object.entries(x.events ?? {}))
106
+ ev[e] = (ev[e] ?? 0) + n / r.length;
107
+ return { variant: v, tasks: tasks.length, runs: r.length, trials, passRate: frac(per), ci: bootstrap(per, frac, { seed: o.seed }), passPowK: mean(per.filter((x) => x.n >= k).map((x) => passPowK(x.pass, x.n, k))), k, metrics: Object.fromEntries(metricNames.map((m) => [m, mean(perTask(v, m).filter((x) => !Number.isNaN(x)))])),
108
+ tokensPerSolved: tps(per), cacheHit: (() => { const p = r.reduce((n, x) => n + (x.prompt ?? 0), 0); return p ? r.reduce((n, x) => n + (x.cached ?? 0), 0) / p : NaN; })(), steps: mean(r.map((x) => x.steps)), secs: mean(r.map((x) => x.ms)) / 1000, events: ev, pareto: true };
109
+ });
110
+ for (const a of stats)
111
+ a.pareto = !stats.some((b) => b !== a && b.passRate >= a.passRate && b.tokensPerSolved <= a.tokensPerSolved && (b.passRate > a.passRate || b.tokensPerSolved < a.tokensPerSolved));
112
+ const base = of(baseline), raw = variants.filter((v) => v !== baseline).map((v) => {
113
+ const cur = of(v), pairs = tasks.map((_, i) => [cur[i], base[i]]);
114
+ let wins = 0, losses = 0;
115
+ for (const [a, b] of pairs) {
116
+ const x = a.n && b.n ? a.pass / a.n - b.pass / b.n : 0;
117
+ if (x > 1e-9)
118
+ wins++;
119
+ else if (x < -1e-9)
120
+ losses++;
121
+ }
122
+ const d = (ps) => frac(ps.map((p) => p[0])) - frac(ps.map((p) => p[1]));
123
+ const dt = (ps) => tps(ps.map((p) => p[0])) - tps(ps.map((p) => p[1]));
124
+ return { variant: v, vs: baseline, dPass: d([...pairs]), ci: bootstrap([...pairs], d, { seed: o.seed }), wins, losses, ties: tasks.length - wins - losses, p: signTest(wins, losses), dTokensPerSolved: dt([...pairs]),
125
+ metrics: Object.fromEntries(metricNames.map((m) => { const a = perTask(v, m), b = perTask(baseline, m), idx = tasks.map((_, i) => i).filter((i) => !Number.isNaN(a[i]) && !Number.isNaN(b[i])); const dm = (is) => mean(is.map((i) => a[i] - b[i])); return [m, { d: dm(idx), ci: bootstrap(idx, dm, { seed: o.seed }) }]; })) };
126
+ });
127
+ const adj = holm(raw.map((c) => c.p));
128
+ const comparisons = raw.map((c, i) => ({ ...c, pAdj: adj[i], verdict: c.ci.lo > 0 && adj[i] < 0.05 ? "better" : c.ci.hi < 0 && adj[i] < 0.05 ? "worse" : "inconclusive" }));
129
+ return { excluded: excluded.map((r) => ({ case: r.case, variant: r.variant, reason: r.excluded })), baseline, tasks: tasks.length, trials, stats, comparisons, metricNames, minDetectable: minDetectable(tasks.length) };
130
+ }
131
+ const pct = (x, d = 0) => (Number.isNaN(x) ? "-" : (x * 100).toFixed(d) + "%");
132
+ const sgn = (x, d = 0) => (x >= 0 ? "+" : "") + (x * 100).toFixed(d) + " pts";
133
+ const kfmt = (n) => (!Number.isFinite(n) ? "n/a" : Math.abs(n) >= 1000 ? (n / 1000).toFixed(1) + "k" : String(Math.round(n)));
134
+ /** Plain-text report: per-variant table, paired comparison against the baseline, and an honest reading of what the data can support. */
135
+ export function formatReport(a) {
136
+ const pad = (x, n) => String(x).padEnd(n), out = [];
137
+ out.push(`${a.tasks} tasks × ${a.trials} trial${a.trials === 1 ? "" : "s"}; intervals are 95% bootstrap over tasks; baseline = ${a.baseline}`, "");
138
+ out.push([pad("variant", 22), pad("pass [95% CI]", 20), pad(`pass^${a.stats[0]?.k ?? 1}`, 7), pad("tok/solved", 11), pad("cache", 6), pad("steps", 6), pad("secs", 6), "pareto"].join(" "));
139
+ for (const s of a.stats)
140
+ out.push([pad(s.variant, 22), pad(`${pct(s.passRate)} [${pct(s.ci.lo)}-${pct(s.ci.hi)}]`, 20), pad(pct(s.passPowK), 7), pad(kfmt(s.tokensPerSolved), 11), pad(pct(s.cacheHit), 6), pad(s.steps.toFixed(1), 6), pad(s.secs.toFixed(1), 6), s.pareto ? "●" : ""].join(" "));
141
+ if (a.comparisons.length) {
142
+ out.push("", `vs ${a.baseline} (paired by task)`, [pad("variant", 22), pad("Δ pass [95% CI]", 26), pad("W/L/T", 9), pad("p (Holm)", 9), pad("Δ tok/solved", 13), "verdict"].join(" "));
143
+ for (const c of a.comparisons)
144
+ out.push([pad(c.variant, 22), pad(`${sgn(c.dPass)} [${sgn(c.ci.lo)}, ${sgn(c.ci.hi)}]`, 26), pad(`${c.wins}/${c.losses}/${c.ties}`, 9), pad(c.pAdj.toFixed(3), 9), pad((c.dTokensPerSolved >= 0 ? "+" : "") + kfmt(c.dTokensPerSolved), 13), c.verdict].join(" "));
145
+ }
146
+ if (a.metricNames.length) {
147
+ const f = (x) => (Number.isFinite(x) ? (Math.abs(x) >= 100 ? x.toFixed(0) : x.toFixed(2)) : "n/a");
148
+ out.push("", "custom metrics (mean per run; Δ against " + a.baseline + " with 95% interval over tasks)", [pad("variant", 22), ...a.metricNames.map((m) => pad(m, 30))].join(" "));
149
+ for (const s of a.stats) {
150
+ const c = a.comparisons.find((x) => x.variant === s.variant);
151
+ out.push([pad(s.variant, 22), ...a.metricNames.map((m) => pad(f(s.metrics[m]) + (c ? ` Δ ${c.metrics[m].d >= 0 ? "+" : ""}${f(c.metrics[m].d)} [${f(c.metrics[m].ci.lo)}, ${f(c.metrics[m].ci.hi)}]` : ""), 30))].join(" "));
152
+ }
153
+ }
154
+ const fired = a.stats.filter((s) => Object.keys(s.events).length);
155
+ if (fired.length) {
156
+ out.push("", "guards fired per run:");
157
+ for (const s of fired)
158
+ out.push(` ${pad(s.variant, 22)} ${Object.entries(s.events).filter(([, n]) => n >= 0.01).map(([k, n]) => `${k} ${n.toFixed(2)}`).join(" · ")}`);
159
+ }
160
+ if (a.excluded.length) {
161
+ out.push("", `exclusion ledger: ${a.excluded.length} run(s) could not be set up and are NOT counted anywhere above:`);
162
+ for (const x of a.excluded.slice(0, 10))
163
+ out.push(` ${x.case} / ${x.variant}: ${x.reason}`);
164
+ }
165
+ out.push("", `reading: with ${a.tasks} task${a.tasks === 1 ? "" : "s"} only pass-rate differences above ~${(a.minDetectable * 100).toFixed(0)} points can be detected reliably; "inconclusive" means the data cannot tell, not that the variants are equal.`);
166
+ if (a.trials < 3)
167
+ out.push(` ${a.trials} trial${a.trials === 1 ? "" : "s"} per task: model sampling noise is not averaged out; use --trials 3 or more for decisions.`);
168
+ return out.join("\n");
169
+ }
170
+ // ------------------------------------------------------------------------------------------------------------------ variants
171
+ const ALL_GUARDS = ["nudge", "loop", "compact", "stuck", "wrapup", "condense", "schedule", "ledger", "stagnation"];
172
+ /** Features that are not loop guards but can be switched off the same way for ablations. */
173
+ const FEATURES = ["index", "taint"];
174
+ export const VARIANT_HELP = "baseline (no guards, no gate) · full (all guards + verify gate) · full+check (adds a post-edit check) · full+review (adds the independent reviewer) · full+route (exploration on a cheaper model, needs --cheap-model) · full+plugins (every loaded plugin active, needs --plugin) · prompt:<id> (a system prompt from .amness/prompts) · model:<id> (the full harness on another model) · chaos:tools|429|args|slow-tools (the full harness while things break on purpose) · strategy:<name> (the full harness with a loop strategy: plan-first, explore-then-act) · any variant a plugin defines · no-gate (guards, no verify gate) · full-<guard> to switch one guard or feature off: " + [...ALL_GUARDS, ...FEATURES].join(", ");
175
+ /** Build a named variant of the code agent. Names: see VARIANT_HELP. */
176
+ /** A plugin-defined variant: the plugin's variants are looked up by `name` or `plugin:name`. */
177
+ export function findPluginVariant(plugins, name) {
178
+ for (const p of plugins ?? [])
179
+ for (const [k, spec] of Object.entries(p.variants ?? {}))
180
+ if (name === k || name === `${p.name}:${k}`)
181
+ return { spec, owner: p };
182
+ return undefined;
183
+ }
184
+ function pluginVariant(model, name, found, o) {
185
+ const { spec, owner } = found;
186
+ if (spec.build)
187
+ return { name, run: (c, dir) => spec.build(model, { maxSteps: o.maxSteps, maxTokens: o.maxTokens }).run(c, dir) };
188
+ const active = [owner, ...(spec.plugins ?? []).map((n) => (o.plugins ?? []).find((p) => p.name === n)).filter((p) => !!p && p !== owner)];
189
+ if (spec.system)
190
+ active.push({ name: `${owner.name}-variant-system`, system: spec.system });
191
+ return {
192
+ name,
193
+ async run(c, dir) {
194
+ const budget = new Budget({ maxTokens: o.maxTokens ?? 400_000 }), events = {};
195
+ const r = await createCodeAgent({
196
+ model, cwd: dir, budget, maxSteps: spec.maxSteps ?? o.maxSteps ?? 30, off: spec.off ?? [], memory: false, plugins: active, tools: c.tools?.(dir),
197
+ verify: spec.verify === false ? undefined : c.verify, review: spec.review || undefined,
198
+ onEvent: (e) => { if (e.kind !== "text" && e.kind !== "thinking" && e.kind !== "assistant" && e.kind !== "toolstart" && e.kind !== "phase")
199
+ events[e.kind] = (events[e.kind] ?? 0) + 1; },
200
+ }).run(c.task);
201
+ return { text: r.text, steps: r.steps, tokens: budget.tokens, events, prompt: budget.promptTokens, cached: budget.cachedTokens };
202
+ },
203
+ };
204
+ }
205
+ export function harnessVariant(model, name, o = {}) {
206
+ const fromPlugin = findPluginVariant(o.plugins, name);
207
+ if (fromPlugin)
208
+ return pluginVariant(model, name, fromPlugin, o);
209
+ const mid = /^model:(.+)$/.exec(name)?.[1];
210
+ if (mid) {
211
+ if (!o.modelFor)
212
+ throw new Error(`variant ${name} needs models by id (use amness eval, or pass modelFor)`);
213
+ return { ...harnessVariant(o.modelFor(mid), "full", { ...o, modelFor: undefined }), name };
214
+ }
215
+ const pid = /^prompt:(.+)$/.exec(name)?.[1];
216
+ if (pid)
217
+ return pluginVariant(model, name, { spec: { verify: true }, owner: promptPlugin(process.cwd(), pid) }, o);
218
+ const chaos = /^chaos:(.+)$/.exec(name)?.[1];
219
+ if (chaos) {
220
+ if (!CHAOS_KINDS.includes(chaos))
221
+ throw new Error(`unknown chaos kind "${chaos}". Known: ${CHAOS_KINDS.join(", ")}`);
222
+ const m = chaos === "429" || chaos === "args" ? (chaos === "429" ? rateLimited(chaosModel(model, { kind: "429" }), { gate: new Gate(), retries: 8 }) : chaosModel(model, { kind: "args" })) : model;
223
+ return { ...harnessVariant(m, "full", { ...o, wrapTools: chaos === "tools" ? (ts) => chaosTools(ts, { rate: 0.25 }) : chaos === "slow-tools" ? (ts) => chaosTools(ts, { rate: 0, delayMs: 400 }) : undefined }), name };
224
+ }
225
+ const strat = /^strategy:(.+)$/.exec(name)?.[1];
226
+ if (strat) {
227
+ if (!STRATEGIES[strat])
228
+ throw new Error(`unknown strategy "${strat}". Known: ${Object.keys(STRATEGIES).join(", ")}`);
229
+ return pluginVariant(model, name, { spec: { verify: true }, owner: STRATEGIES[strat]() }, o);
230
+ }
231
+ const m = /^full-(.+)$/.exec(name), droppedRaw = m ? m[1] : undefined, dropped = droppedRaw && ALL_GUARDS.includes(droppedRaw) ? droppedRaw : undefined, feature = droppedRaw && FEATURES.includes(droppedRaw) ? droppedRaw : undefined;
232
+ if (droppedRaw && !dropped && !feature)
233
+ throw new Error(`unknown guard "${droppedRaw}" in variant "${name}". Guards: ${[...ALL_GUARDS, ...FEATURES].join(", ")}`);
234
+ if (!droppedRaw && !["baseline", "full", "full+check", "full+review", "full+route", "full+plugins", "no-gate"].includes(name))
235
+ throw new Error(`unknown variant "${name}". Known: ${VARIANT_HELP}`);
236
+ return {
237
+ name,
238
+ async run(c, dir) {
239
+ const budget = new Budget({ maxTokens: o.maxTokens ?? 400_000 }), events = {};
240
+ const off = name === "baseline" ? ALL_GUARDS : [...(dropped ? [dropped] : []), ...(o.profile?.off ?? [])];
241
+ if (name === "full+route" && !o.cheap)
242
+ throw new Error("the full+route variant needs a cheap model: pass --cheap-model <id>");
243
+ const r = await createCodeAgent({
244
+ model: name === "full+route" ? routedModel({ strong: model, cheap: o.cheap, prices: o.prices, onRoute: (d) => { events[d.switched ? `route-${d.tier}` : "route-stay"] = (events[d.switched ? `route-${d.tier}` : "route-stay"] ?? 0) + 1; } }) : model, cwd: dir, budget, maxSteps: o.profile?.maxSteps ?? o.maxSteps ?? 30, stagnationTurns: o.profile?.stagnationTurns, off, memory: false, index: name === "baseline" || feature === "index" ? false : undefined, taint: name === "baseline" || feature === "taint" ? "off" : undefined, tools: c.tools?.(dir),
245
+ plugins: name === "full+plugins" ? o.plugins : undefined, wrapTools: o.wrapTools, verify: name === "baseline" || name === "no-gate" ? undefined : c.verify, check: name === "full+check" ? c.verify : undefined, review: name === "full+review" || undefined,
246
+ onEvent: (e) => { if (e.kind !== "text" && e.kind !== "thinking" && e.kind !== "assistant" && e.kind !== "toolstart" && e.kind !== "phase")
247
+ events[e.kind] = (events[e.kind] ?? 0) + 1; },
248
+ }).run(c.task);
249
+ return { text: r.text, steps: r.steps, tokens: budget.tokens, events, prompt: budget.promptTokens, cached: budget.cachedTokens };
250
+ },
251
+ };
252
+ }
253
+ /** Ready-made A/B for coding agents: `baseline` (no guards, no verify gate) vs `full-harness` (all guards + the case's verify command). */
254
+ export function codeVariants(model, o = {}) {
255
+ return [{ ...harnessVariant(model, "baseline", o), name: "baseline" }, { ...harnessVariant(model, "full", o), name: "full-harness" }];
256
+ }
257
+ /** Expand a preset or comma list into variant names: `ab`, `ablate` (full plus each guard removed in turn), `all`. */
258
+ export function variantNames(spec) {
259
+ const out = [];
260
+ for (const p of spec.split(",").map((s) => s.trim()).filter(Boolean)) {
261
+ if (p === "ab")
262
+ out.push("baseline", "full");
263
+ else if (p === "ablate")
264
+ out.push("full", ...[...ALL_GUARDS, ...FEATURES].map((g) => `full-${g}`));
265
+ else if (p === "all")
266
+ out.push("baseline", "no-gate", "full", "full+check", "full+review", ...[...ALL_GUARDS, ...FEATURES].map((g) => `full-${g}`));
267
+ else
268
+ out.push(p);
269
+ }
270
+ return [...new Set(out)];
271
+ }
272
+ /** Prove each case is sound: its check must FAIL on the starting files and PASS once the reference solution is applied. */
273
+ export async function selfTest(cases) {
274
+ const out = [];
275
+ for (const c of cases) {
276
+ const dir = mkdtempSync(join(tmpdir(), "amness-self-"));
277
+ try {
278
+ for (const [f, t] of Object.entries(c.files)) {
279
+ mkdirSync(dirname(join(dir, f)), { recursive: true });
280
+ writeFileSync(join(dir, f), t);
281
+ }
282
+ const judge = async () => (typeof c.check === "string" ? (await runShell(c.check, dir, 60_000)).startsWith("exit=0") : (await c.check(dir)) === true);
283
+ if (await judge()) {
284
+ out.push({ case: c.name, ok: false, why: "check already passes before any fix" });
285
+ continue;
286
+ }
287
+ if (!c.solution) {
288
+ out.push({ case: c.name, ok: true, why: "unverified: no reference solution, only checked that the check fails on the starting files" });
289
+ continue;
290
+ }
291
+ for (const [f, t] of Object.entries(c.solution)) {
292
+ mkdirSync(dirname(join(dir, f)), { recursive: true });
293
+ writeFileSync(join(dir, f), t);
294
+ }
295
+ out.push((await judge()) ? { case: c.name, ok: true } : { case: c.name, ok: false, why: "check still fails with the reference solution" });
296
+ }
297
+ finally {
298
+ rmSync(dir, { recursive: true, force: true });
299
+ }
300
+ }
301
+ return out;
302
+ }
303
+ /**
304
+ * A regression gate for CI: compare a new run with a saved one, variant by variant, on the tasks both have. It fails when the pass rate dropped by more than `tol`
305
+ * AND the drop is clear (the 95% interval over tasks is below zero), or when tokens per solved task grew by more than `costPct` percent. A drop that noise could explain is a warning, not a failure.
306
+ */
307
+ export function gateCompare(current, baseline, o = {}) {
308
+ const tol = o.tol ?? 0.05, costPct = o.costPct ?? 25, ok = (r) => !r.excluded, out = [];
309
+ for (const v of [...new Set(current.filter(ok).map((r) => r.variant))]) {
310
+ const cur = current.filter((r) => ok(r) && r.variant === v), old = baseline.filter((r) => ok(r) && r.variant === v);
311
+ if (!old.length) {
312
+ out.push({ variant: v, ok: true, lines: ["not in the baseline file: nothing to compare"] });
313
+ continue;
314
+ }
315
+ const tasks = [...new Set(cur.map((r) => r.case))].filter((t) => old.some((r) => r.case === t));
316
+ const frac = (rows, t) => { const xs = rows.filter((r) => r.case === t); return xs.length ? xs.filter((r) => r.pass).length / xs.length : NaN; };
317
+ const idx = tasks.map((_, i) => i), a = tasks.map((t) => frac(cur, t)), b = tasks.map((t) => frac(old, t)), dm = (is) => mean(is.map((i) => a[i] - b[i]));
318
+ const d = dm(idx), ci = bootstrap(idx, dm, { seed: o.seed }), lines = [];
319
+ let pass = true;
320
+ lines.push(`pass rate ${(mean(a) * 100).toFixed(0)}% now, ${(mean(b) * 100).toFixed(0)}% in the baseline (${d >= 0 ? "+" : ""}${(d * 100).toFixed(0)} pts, 95% interval ${(ci.lo * 100).toFixed(0)} to ${(ci.hi * 100).toFixed(0)}, ${tasks.length} common tasks)`);
321
+ if (d < -tol && ci.hi < 0) {
322
+ pass = false;
323
+ lines.push(`FAIL: the pass rate dropped by more than ${(tol * 100).toFixed(0)} points and the drop is clear`);
324
+ }
325
+ else if (d < -tol)
326
+ lines.push(`warning: the pass rate is lower by more than ${(tol * 100).toFixed(0)} points, but noise could explain it (interval includes zero)`);
327
+ const tps = (rows) => { const p = rows.filter((r) => tasks.includes(r.case) && r.pass).length; return p ? rows.filter((r) => tasks.includes(r.case)).reduce((n, r) => n + r.tokens, 0) / p : Infinity; };
328
+ const tn = tps(cur), to = tps(old);
329
+ if (Number.isFinite(tn) && Number.isFinite(to)) {
330
+ const up = (tn / to - 1) * 100;
331
+ lines.push(`tokens per solved task ${Math.round(tn)} now, ${Math.round(to)} in the baseline (${up >= 0 ? "+" : ""}${up.toFixed(0)}%)`);
332
+ if (up > costPct) {
333
+ pass = false;
334
+ lines.push(`FAIL: cost per solved task grew by more than ${costPct}%`);
335
+ }
336
+ }
337
+ out.push({ variant: v, ok: pass, lines });
338
+ }
339
+ return out;
340
+ }
@@ -0,0 +1,27 @@
1
+ import type { Model } from "./harness.ts";
2
+ /** The branch a PR would target: origin's default branch, else main/master, else HEAD's parent branch is unknown. */
3
+ export declare function defaultBase(cwd: string): Promise<string | undefined>;
4
+ export declare const currentBranch: (cwd: string) => Promise<string>;
5
+ /** Everything changed relative to `base` (default: the PR base, or HEAD when none): committed work on the branch plus uncommitted and untracked files, as one diff. */
6
+ export declare function diffForReview(cwd: string, base?: string): Promise<{
7
+ diff: string;
8
+ against: string;
9
+ }>;
10
+ export declare function commitLog(cwd: string, base: string, max?: number): Promise<string>;
11
+ export type PrDraft = {
12
+ title: string;
13
+ body: string;
14
+ };
15
+ /** Ask a model for a PR title and body from the commits and the diff. The text is a DRAFT for the user to see before anything is created. */
16
+ export declare function draftPr(model: Model, o: {
17
+ diff: string;
18
+ log: string;
19
+ branch: string;
20
+ }): Promise<PrDraft>;
21
+ /** `gh pr create`. Only call this after the user confirmed. Returns the URL gh prints. */
22
+ export declare function createPr(cwd: string, d: PrDraft, o?: {
23
+ base?: string;
24
+ draft?: boolean;
25
+ }): Promise<string>;
26
+ export declare function hasUpstream(cwd: string): Promise<boolean>;
27
+ export declare const pushBranch: (cwd: string) => Promise<string>;
@@ -0,0 +1,56 @@
1
+ // Review and pull-request helpers around git and the GitHub CLI. Nothing here talks to the network without being asked: a PR is only created after the caller confirms.
2
+ import { execFile } from "node:child_process";
3
+ import { parseLooseJson } from "./schema.js";
4
+ const run = (cwd, file, args) => new Promise((res, rej) => execFile(file, args, { cwd, maxBuffer: 64 << 20 }, (e, out, err) => (e ? rej(new Error((err || e.message).trim().split("\n")[0])) : res(out))));
5
+ /** The branch a PR would target: origin's default branch, else main/master, else HEAD's parent branch is unknown. */
6
+ export async function defaultBase(cwd) {
7
+ try {
8
+ const r = (await run(cwd, "git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"])).trim();
9
+ if (r)
10
+ return r;
11
+ }
12
+ catch { /* no origin/HEAD */ }
13
+ for (const b of ["origin/main", "origin/master", "main", "master"]) {
14
+ try {
15
+ await run(cwd, "git", ["rev-parse", "--verify", "--quiet", b]);
16
+ return b;
17
+ }
18
+ catch { /* try next */ }
19
+ }
20
+ return undefined;
21
+ }
22
+ export const currentBranch = async (cwd) => (await run(cwd, "git", ["rev-parse", "--abbrev-ref", "HEAD"]).catch(() => "")).trim();
23
+ /** Everything changed relative to `base` (default: the PR base, or HEAD when none): committed work on the branch plus uncommitted and untracked files, as one diff. */
24
+ export async function diffForReview(cwd, base) {
25
+ const against = base ?? (await defaultBase(cwd)) ?? "HEAD";
26
+ await run(cwd, "git", ["add", "-A", "-N", "."]).catch(() => { });
27
+ const mergeBase = against === "HEAD" ? "HEAD" : (await run(cwd, "git", ["merge-base", against, "HEAD"]).catch(() => against)).trim();
28
+ return { diff: (await run(cwd, "git", ["diff", "--no-color", "--no-ext-diff", mergeBase])).trimEnd(), against };
29
+ }
30
+ export async function commitLog(cwd, base, max = 30) { return (await run(cwd, "git", ["log", "--no-color", `--max-count=${max}`, "--format=%s", `${base}..HEAD`]).catch(() => "")).trim(); }
31
+ /** Ask a model for a PR title and body from the commits and the diff. The text is a DRAFT for the user to see before anything is created. */
32
+ export async function draftPr(model, o) {
33
+ const m = await model([{ role: "system", content: "You write pull request descriptions. Reply with ONLY JSON: {\"title\": string (under 70 characters, imperative mood), \"body\": string (markdown: what changed and why, how it was verified if the diff shows tests, anything a reviewer should look at closely)}. Describe what the diff really does; do not invent tests or claims." }, { role: "user", content: `Branch: ${o.branch}\n\nCommit subjects:\n${o.log || "(none)"}\n\nDiff:\n${o.diff.slice(0, 40_000)}${o.diff.length > 40_000 ? "\n[... diff truncated]" : ""}` }], []);
34
+ let j = null;
35
+ try {
36
+ j = parseLooseJson(m.content ?? "");
37
+ }
38
+ catch { /* not JSON */ }
39
+ if (!j || typeof j.title !== "string" || !j.title.trim())
40
+ throw new Error("the model did not return a usable title and body");
41
+ return { title: j.title.trim().slice(0, 120), body: typeof j.body === "string" ? j.body.trim() : "" };
42
+ }
43
+ /** `gh pr create`. Only call this after the user confirmed. Returns the URL gh prints. */
44
+ export async function createPr(cwd, d, o = {}) {
45
+ const base = o.base?.replace(/^origin\//, "");
46
+ const out = await run(cwd, "gh", ["pr", "create", "--title", d.title, "--body", d.body, ...(base ? ["--base", base] : []), ...(o.draft ? ["--draft"] : [])]);
47
+ return out.trim().split("\n").pop() ?? "";
48
+ }
49
+ export async function hasUpstream(cwd) { try {
50
+ await run(cwd, "git", ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
51
+ return true;
52
+ }
53
+ catch {
54
+ return false;
55
+ } }
56
+ export const pushBranch = (cwd) => run(cwd, "git", ["push", "-u", "origin", "HEAD"]);