@staix/agent-research 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 (52) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/LICENSE +21 -0
  3. package/README.md +572 -2
  4. package/bin/ares.js +11 -0
  5. package/package.json +35 -4
  6. package/src/cli.ts +295 -0
  7. package/src/commands-table.ts +72 -0
  8. package/src/commands.ts +520 -0
  9. package/src/console/registry.ts +97 -0
  10. package/src/exp/ablate.ts +140 -0
  11. package/src/exp/artifacts.ts +229 -0
  12. package/src/exp/attempts.ts +475 -0
  13. package/src/exp/executor.ts +207 -0
  14. package/src/exp/final.ts +197 -0
  15. package/src/exp/ideas.ts +117 -0
  16. package/src/exp/investigate.ts +182 -0
  17. package/src/exp/logs.ts +63 -0
  18. package/src/exp/metrics.ts +132 -0
  19. package/src/exp/protocol.ts +145 -0
  20. package/src/exp/selection.ts +53 -0
  21. package/src/exp/snapshot.ts +147 -0
  22. package/src/http.ts +62 -0
  23. package/src/index.ts +4 -0
  24. package/src/lit/bibtex.ts +61 -0
  25. package/src/lit/candidates.ts +148 -0
  26. package/src/lit/cards.ts +21 -0
  27. package/src/lit/cite.ts +26 -0
  28. package/src/lit/sources.ts +122 -0
  29. package/src/lit/verify.ts +110 -0
  30. package/src/lit/watch.ts +109 -0
  31. package/src/manifest.ts +38 -0
  32. package/src/mcp-tools.ts +200 -0
  33. package/src/mcp.ts +111 -0
  34. package/src/memory/amem.ts +97 -0
  35. package/src/memory/archive.ts +63 -0
  36. package/src/memory/lessons.ts +88 -0
  37. package/src/memory/skills.ts +99 -0
  38. package/src/paper/build.ts +70 -0
  39. package/src/paper/check.ts +180 -0
  40. package/src/paper/placeholders.ts +116 -0
  41. package/src/paper/review.ts +115 -0
  42. package/src/plan/controller.ts +192 -0
  43. package/src/plan/definition.ts +156 -0
  44. package/src/plan/hub.ts +56 -0
  45. package/src/plan/state.ts +234 -0
  46. package/src/playbook.ts +129 -0
  47. package/src/scope.ts +53 -0
  48. package/src/setup.ts +296 -0
  49. package/src/study.ts +296 -0
  50. package/src/ui/panel.css +11 -0
  51. package/src/ui/panel.js +156 -0
  52. package/src/ui/plugin.ts +361 -0
@@ -0,0 +1,207 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { join, posix } from "node:path";
4
+ import { commit, openStudy, plain, readJsonFile, requireExperiment, type Actor } from "../study.ts";
5
+ import { readProtocol, validateProtocol, type Protocol } from "./protocol.ts";
6
+
7
+ export interface Executor { v: 1; kind: "local" | "ssh"; host?: string; workdir: string }
8
+ export interface ProcessRequest {
9
+ command: string; args: string[]; stdin?: Uint8Array | AsyncIterable<Uint8Array>;
10
+ stdoutSink?: (chunk: Uint8Array) => void | Promise<void>; signal?: AbortSignal;
11
+ timeoutMs?: number; maxOutputBytes?: number; cwd?: string; env?: NodeJS.ProcessEnv;
12
+ }
13
+ export interface ProcessResult { exitCode: number; stdout: string; stderr: string; timedOut?: boolean; truncated?: boolean }
14
+ export type ProcessRunner = (request: ProcessRequest) => Promise<ProcessResult>;
15
+ export const EXECUTOR_BYTES = 8192, PROCESS_OUTPUT_BYTES = 65_536;
16
+
17
+ /** Opt-in context for native CLI attribution and config discovery; no API credentials. */
18
+ export const PROCESS_CONTEXT_ENV_KEYS = ["AGENTHUB_PEER_ID", "CLAUDECODE", "CLAUDE_CODE_SESSION_ID", "CODEX_THREAD_ID", "PI_SESSION_ID",
19
+ "AMEM_HOME", "AGENTHUB_HOME", "CLAUDE_CONFIG_DIR", "CODEX_HOME", "XDG_CONFIG_HOME", "KIMI_CODE_HOME", "PI_CODING_AGENT_DIR", "QWEN_HOME", "JCODE_HOME"] as const;
20
+ const BASE_ENV_KEYS = ["PATH", "HOME", "USER", "LOGNAME", "TMPDIR", "SSH_AUTH_SOCK", "DOCKER_HOST", "DOCKER_CONTEXT"];
21
+ /** Context is opt-in; executor children keep their existing minimal environment by default. */
22
+ export function processEnvironment(env: NodeJS.ProcessEnv, extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
23
+ return Object.fromEntries([...BASE_ENV_KEYS, ...PROCESS_CONTEXT_ENV_KEYS].flatMap(key => {
24
+ const value = extra[key] ?? (BASE_ENV_KEYS.includes(key) ? env[key] : undefined);
25
+ return value === undefined ? [] : [[key, value]];
26
+ }));
27
+ }
28
+
29
+ /** Bounded tails and a deadline; callers must never persist raw process diagnostics. */
30
+ export const spawnRunner: ProcessRunner = async request => {
31
+ const max = request.maxOutputBytes ?? PROCESS_OUTPUT_BYTES, timeout = request.timeoutMs ?? 30_000;
32
+ if (!Number.isSafeInteger(max) || max < 1 || max > 1024 * 1024 || !Number.isSafeInteger(timeout) || timeout < 1 || timeout > 604_800_000) {
33
+ throw new Error("process bounds: invalid; use 1..1048576 output bytes and 1..604800000 timeout milliseconds");
34
+ }
35
+ if (request.signal?.aborted) throw new Error("process cancelled; retry the operation when cancellation is cleared");
36
+ const child = spawn(request.command, request.args, { env: processEnvironment(process.env, request.env), cwd: request.cwd, detached: true, stdio: ["pipe", "pipe", "pipe"] });
37
+ let stdout: Buffer = Buffer.alloc(0), stderr: Buffer = Buffer.alloc(0), truncated = false, timedOut = false;
38
+ const append = (old: Buffer, chunk: Buffer): Buffer => {
39
+ if (old.length + chunk.length > max) truncated = true;
40
+ return chunk.length >= max ? Buffer.from(chunk.subarray(chunk.length - max)) : Buffer.concat([old.subarray(Math.max(0, old.length + chunk.length - max)), chunk]);
41
+ };
42
+ child.stderr.on("data", (chunk: Buffer) => { stderr = append(stderr, chunk); });
43
+ const stop = () => {
44
+ try { if (child.pid) process.kill(-child.pid, "SIGKILL"); }
45
+ catch { child.kill("SIGKILL"); }
46
+ };
47
+ const timer = setTimeout(() => { timedOut = true; stop(); }, timeout);
48
+ request.signal?.addEventListener("abort", stop, { once: true });
49
+ const closed = new Promise<number>((resolve, reject) => {
50
+ child.once("error", reject);
51
+ child.once("close", code => resolve(code ?? -1));
52
+ });
53
+ // EPIPE is normal if a child exits before consuming its snapshot input.
54
+ child.stdin.on("error", () => {});
55
+ const input = async () => {
56
+ if (request.stdin && Symbol.asyncIterator in request.stdin) {
57
+ const iterator = request.stdin[Symbol.asyncIterator]();
58
+ try {
59
+ for (;;) {
60
+ const next = await Promise.race([iterator.next().then(value => ({ value })), closed.then(() => ({ value: undefined }))]);
61
+ if (!next.value || next.value.done || request.signal?.aborted || child.stdin.destroyed) break;
62
+ await new Promise<void>((resolve, reject) => child.stdin.write(next.value!.value, error => error ? reject(error) : resolve()));
63
+ }
64
+ } finally {
65
+ // A cancelled producer can have a pending next(); do not wait forever for its return().
66
+ void iterator.return?.().catch(() => {});
67
+ }
68
+ child.stdin.end();
69
+ } else child.stdin.end(request.stdin);
70
+ };
71
+ const output = async () => {
72
+ for await (const chunk of child.stdout) {
73
+ if (request.stdoutSink) await request.stdoutSink(chunk as Buffer);
74
+ else stdout = append(stdout, chunk as Buffer);
75
+ }
76
+ };
77
+ let ioError: unknown;
78
+ const pumps = [input(), output()].map(pump => pump.catch(error => { ioError ??= error; stop(); }));
79
+ try {
80
+ const code = await closed;
81
+ await Promise.all(pumps);
82
+ if (ioError) throw ioError;
83
+ return { exitCode: code, stdout: stdout.toString("utf8"), stderr: stderr.toString("utf8"), ...(timedOut ? { timedOut } : {}), ...(truncated ? { truncated } : {}) };
84
+ } finally {
85
+ clearTimeout(timer); request.signal?.removeEventListener("abort", stop);
86
+ }
87
+ };
88
+
89
+ function host(executor: Executor): string { return executor.kind === "ssh" ? executor.host! : "local"; }
90
+ function failed(executor: Executor, step: string): Error {
91
+ return new Error(`executor ${host(executor)}: ${step} failed; check that step on the executor host and rerun ares executor check <dir>`);
92
+ }
93
+ function directory(value: unknown, field: string): string {
94
+ if (typeof value !== "string" || value.length > 4096 || !value.startsWith("/") || /[\0\r\n,"]/.test(value)) throw new Error(`${field}: invalid directory; use an absolute path of at most 4096 characters without NUL, newlines, commas or double quotes`);
95
+ return posix.normalize(value);
96
+ }
97
+ export function validateExecutor(raw: unknown): Executor {
98
+ if (!plain(raw)) throw new Error("executor: object required; pass kind and workdir to ares executor set");
99
+ const extra = Object.keys(raw).find(key => !["v", "kind", "host", "workdir"].includes(key));
100
+ if (extra !== undefined) throw new Error(`executor: unknown field ${extra}; use only v, kind, host and workdir`);
101
+ if (raw.v !== 1 || (raw.kind !== "local" && raw.kind !== "ssh")) throw new Error("executor: invalid version or kind; set v to 1 and kind to local or ssh");
102
+ if (raw.kind === "ssh" && (typeof raw.host !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(raw.host))) throw new Error("executor.host: invalid SSH alias; configure an SSH alias and pass its name only (letters, digits, dot, underscore or hyphen)");
103
+ if (raw.kind === "local" && raw.host !== undefined) throw new Error("executor.host: local executor has no SSH host; remove host or set kind to ssh");
104
+ if (raw.workdir === undefined || raw.workdir === null) throw new Error("executor.workdir: required with no default; run ares executor set <dir> --kind local|ssh [--host ALIAS] --workdir PATH using a private directory on the executor host");
105
+ return { v: 1, kind: raw.kind, ...(raw.kind === "ssh" ? { host: raw.host as string } : {}), workdir: directory(raw.workdir, "executor.workdir") };
106
+ }
107
+ export function readExecutor(dir: string): Executor | undefined {
108
+ const file = join(dir, "executor.json");
109
+ return existsSync(file) ? validateExecutor(readJsonFile(file, EXECUTOR_BYTES)) : undefined;
110
+ }
111
+ /** Caller holds the study lock. Config contains no credentials and is outside the design seal. */
112
+ export function setExecutor(dir: string, raw: unknown, actor: Actor): Executor {
113
+ requireExperiment(openStudy(dir).study);
114
+ const executor = validateExecutor(raw);
115
+ commit(dir, [[join(dir, "executor.json"), JSON.stringify(executor, null, 2) + "\n", EXECUTOR_BYTES]], [{ actor, type: "executor.set", data: { kind: executor.kind, ...(executor.host ? { host: executor.host } : {}) } }]);
116
+ return executor;
117
+ }
118
+
119
+ /** SSH concatenates its remote command argv into a shell command, so quote every unsafe byte. */
120
+ export function shellQuote(value: string): string {
121
+ return /^[A-Za-z0-9_./:@=+-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
122
+ }
123
+ export async function runOnExecutor(executorArg: Executor, command: string, args: string[], runner: ProcessRunner = spawnRunner, options: Omit<ProcessRequest, "command" | "args"> = {}): Promise<ProcessResult> {
124
+ const executor = validateExecutor(executorArg);
125
+ try { return await runner(executor.kind === "local" ? { ...options, command, args } : { ...options, command: "ssh", args: ["-o", "BatchMode=yes", executor.host!, shellQuote(command), ...args.map(shellQuote)] }); }
126
+ catch { throw failed(executor, "process start"); }
127
+ }
128
+ async function step(executor: Executor, name: string, command: string, args: string[], runner: ProcessRunner, options: Omit<ProcessRequest, "command" | "args"> = {}): Promise<ProcessResult> {
129
+ try {
130
+ const result = await runOnExecutor(executor, command, args, runner, options);
131
+ if (result.exitCode !== 0 || result.timedOut || result.truncated) throw failed(executor, name);
132
+ return result;
133
+ } catch { throw failed(executor, name); }
134
+ }
135
+ export interface ExecutorCheck { executor: Executor; serverVersion: string; user: string; images: { image: string; available: true }[]; pull: "missing" }
136
+ export async function checkExecutor(dir: string, runner: ProcessRunner = spawnRunner): Promise<ExecutorCheck> {
137
+ requireExperiment(openStudy(dir).study);
138
+ const executor = readExecutor(dir), protocol = readProtocol(dir);
139
+ if (!executor) throw new Error("executor: not configured; run ares executor set <dir> --kind local|ssh [--host ALIAS] --workdir PATH");
140
+ if (!protocol) throw new Error("protocol: missing; run ares protocol <dir> --file PATH before executor check");
141
+ return checkExecutorInputs(executor, protocol, runner);
142
+ }
143
+ /** Check a frozen attempt's configuration rather than mutable study files. */
144
+ export async function checkExecutorInputs(executor: Executor, protocol: Protocol, runner: ProcessRunner = spawnRunner): Promise<ExecutorCheck> {
145
+ const version = (await step(executor, "Docker server version", "docker", ["version", "--format", "{{.Server.Version}}"], runner)).stdout.trim();
146
+ if (!/^[0-9]+\.[0-9]+(?:\.[0-9]+)?[A-Za-z0-9.+_-]*$/.test(version) || version.length > 128) throw failed(executor, "Docker server version");
147
+ const uid = (await step(executor, "host uid", "id", ["-u"], runner)).stdout.trim();
148
+ const gid = (await step(executor, "host gid", "id", ["-g"], runner)).stdout.trim();
149
+ if (!/^[1-9][0-9]{0,9}$/.test(uid) || !/^[1-9][0-9]{0,9}$/.test(gid)) throw failed(executor, "non-root host uid:gid");
150
+ const images: ExecutorCheck["images"] = [];
151
+ for (const image of new Set([protocol.candidate.image, protocol.evaluator.image])) {
152
+ let present: ProcessResult;
153
+ try { present = await runOnExecutor(executor, "docker", ["image", "inspect", "--format", "{{.Id}}", image], runner); }
154
+ catch { throw failed(executor, "image availability"); }
155
+ if (present.timedOut || present.truncated) throw failed(executor, "image availability");
156
+ if (present.exitCode !== 0) await step(executor, "image pull", "docker", ["pull", image], runner, { timeoutMs: 300_000 });
157
+ images.push({ image, available: true });
158
+ }
159
+ return { executor, serverVersion: version, user: `${uid}:${gid}`, images, pull: "missing" };
160
+ }
161
+
162
+ export type Stage = "candidate" | "evaluator";
163
+ export interface ContainerPaths { snapshot: string; output: string; evaluator: string; result: string }
164
+ export interface ContainerOptions { protocol: Protocol; stage: Stage; studyId: string; attemptId: string; user: string; paths: ContainerPaths; seed?: number }
165
+ export function containerName(attemptId: string, stage: Stage): string {
166
+ if (!/^[a-f0-9]{16}$/.test(attemptId) || !["candidate", "evaluator"].includes(stage)) throw new Error("container identity: invalid attempt or stage; use a 16-hex attempt id and candidate or evaluator stage");
167
+ return `ares-${attemptId}-${stage}`;
168
+ }
169
+ /** Mounts are fixed by stage, never supplied as free-form Docker options. */
170
+ export function buildContainerArgs(options: ContainerOptions): string[] {
171
+ const p = validateProtocol(options.protocol), name = containerName(options.attemptId, options.stage);
172
+ const seed = options.seed === undefined ? p.seeds[0]! : options.seed;
173
+ if (!Number.isSafeInteger(seed) || !p.seeds.includes(seed)) throw new Error("container seed: undeclared or non-integer; choose a seed from protocol.seeds for ares attempt run --seed N");
174
+ if (!/^[A-Za-z0-9_-]{1,80}$/.test(options.studyId)) throw new Error("study id: invalid label; use the id from study.json");
175
+ if (!/^[1-9][0-9]{0,9}:[1-9][0-9]{0,9}$/.test(options.user)) throw new Error("container user: non-root uid:gid required; use the user reported by ares executor check");
176
+ const paths = Object.fromEntries(Object.entries(options.paths).map(([key, value]) => [key, directory(value, `container.paths.${key}`)])) as unknown as ContainerPaths;
177
+ // Prevent a broad snapshot/output mount from exposing evaluator data or result storage.
178
+ const sources = [paths.snapshot, paths.output, paths.evaluator, paths.result];
179
+ for (let a = 0; a < sources.length; a++) for (let b = a + 1; b < sources.length; b++) {
180
+ const x = sources[a]!, y = sources[b]!;
181
+ if (!x || !y || x === y || x.startsWith(y === "/" ? y : `${y}/`) || y.startsWith(x === "/" ? x : `${x}/`)) throw new Error("container mounts: overlapping sources; use separate snapshot, output, evaluator and result directories");
182
+ }
183
+ const argv = ["run", "--detach", "--pull", "missing", "--name", name, "--label", `ares.study=${options.studyId}`, "--label", `ares.attempt=${options.attemptId}`, "--label", `ares.stage=${options.stage}`,
184
+ "--network", "none", "--read-only", "--tmpfs", "/tmp", "--cap-drop", "ALL", "--security-opt", "no-new-privileges", "--user", options.user,
185
+ "--cpus", String(p.limits.cpus), "--memory", `${p.limits.memoryMb}m`, "--pids-limit", String(p.limits.pidsLimit), "--env", `ARES_SEED=${seed}`];
186
+ if (p.limits.gpus > 0) argv.push("--gpus", String(p.limits.gpus));
187
+ const mount = (source: string, target: string, readonly: boolean) => argv.push("--mount", `type=bind,src=${source},dst=${target}${readonly ? ",readonly" : ""}`);
188
+ if (options.stage === "candidate") {
189
+ mount(paths.snapshot, "/workspace", true); mount(paths.output, "/out", false);
190
+ argv.push("--workdir", "/workspace");
191
+ for (const [key, value] of Object.entries(p.candidate.env ?? {})) argv.push("--env", `${key}=${value}`);
192
+ } else {
193
+ mount(paths.evaluator, "/eval", true); mount(paths.output, "/candidate", true); mount(paths.result, "/result", false);
194
+ argv.push("--workdir", "/eval");
195
+ }
196
+ argv.push(p[options.stage].image, ...p[options.stage].command);
197
+ return argv;
198
+ }
199
+
200
+ /** Launch on the executor host; stdio is detached so SSH returns immediately. T3 reconciles deadlines as a backstop. */
201
+ export async function startWatchdog(executor: Executor, attemptId: string, stage: Stage, wallSeconds: number, runner: ProcessRunner = spawnRunner, containerId?: string): Promise<void> {
202
+ const name = containerName(attemptId, stage);
203
+ if (containerId !== undefined && !/^[a-f0-9]{64}$/.test(containerId)) throw new Error("watchdog container id: invalid; use the full id returned by the verified container start");
204
+ if (!Number.isSafeInteger(wallSeconds) || wallSeconds < 1 || wallSeconds > 604_800) throw new Error("watchdog deadline: invalid wallSeconds; use the validated protocol limit (1..604800 seconds)");
205
+ const script = `nohup sh -c ${shellQuote(`sleep ${wallSeconds}; docker kill ${containerId ?? name}`)} </dev/null >/dev/null 2>&1 &`;
206
+ await step(executor, "watchdog start", "sh", ["-c", script], runner);
207
+ }
@@ -0,0 +1,197 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { commit, onlyKeys, openStudy, plain, readTextBounded, requireExperiment, sha256, assertWritable, gateState, readLedger, requireGate, type Actor, type LedgerEvent } from "../study.ts";
4
+ import { readDesign, readProtocol, type Protocol } from "./protocol.ts";
5
+ import { listAttempts, type Attempt, type AttemptStatus } from "./attempts.ts";
6
+ import { ablateResultPath, ABLATE_BYTES, foldAblations, readClosedAblate } from "./ablate.ts";
7
+ import { INVESTIGATE_BYTES, investigateResultPath, metricValue, pairSamples, readClosedInvestigation, type PairedSamples, type Sample } from "./investigate.ts";
8
+ import { readSelection } from "./selection.ts";
9
+
10
+ export interface FinalSeeds { v: 1; seeds: number[] }
11
+ export const FINAL_SEEDS_BYTES = 65_536;
12
+ const compatible = "use an ares version that supports this final-seeds file, keeping design/final.json unchanged";
13
+ export function finalSeedsPath(dir: string): string { return join(dir, "design", "final.json"); }
14
+ export function validateFinalSeeds(raw: unknown, protocol: Protocol): FinalSeeds {
15
+ try {
16
+ if (!plain(raw)) throw new Error("final seeds: JSON object required");
17
+ if (raw.v !== 1) throw new Error(`final seeds.v: unsupported version; ${compatible}`);
18
+ onlyKeys(raw, ["v", "seeds"], "final seeds");
19
+ const max = Math.floor(protocol.seeds.length / 2);
20
+ if (!max) throw new Error("final seeds: protocol needs at least two declared seeds to reserve at most half");
21
+ if (!Array.isArray(raw.seeds) || !raw.seeds.length || raw.seeds.length > max) throw new Error(`final seeds.seeds: nonempty list of at most ${max} seeds required (half the declared protocol seeds)`);
22
+ if (raw.seeds.some(s => !Number.isSafeInteger(s) || !protocol.seeds.includes(s as number))) throw new Error("final seeds.seeds: undeclared seed; use only declared integer protocol seeds");
23
+ if (new Set(raw.seeds).size !== raw.seeds.length) throw new Error("final seeds.seeds: duplicate seed; name each reserved seed once");
24
+ return { v: 1, seeds: [...raw.seeds] as number[] };
25
+ } catch (error) {
26
+ const message = error instanceof Error ? error.message : "final seeds: invalid input";
27
+ throw new Error(message.includes(compatible) ? message : `${message}; correct the file or protocol and run ares final seeds <dir> --file PATH again`);
28
+ }
29
+ }
30
+ function record(dir: string, protocol: Protocol): { value: FinalSeeds; text: string } | undefined {
31
+ const file = finalSeedsPath(dir);
32
+ if (!existsSync(file)) return undefined;
33
+ let text: string;
34
+ try { text = readTextBounded(file, FINAL_SEEDS_BYTES); }
35
+ catch { throw new Error(`design/final.json: unreadable, non-regular, invalid UTF-8 or larger than ${FINAL_SEEDS_BYTES} bytes; restore a valid regular final-seeds file within this bound`); }
36
+ let raw: unknown;
37
+ try { raw = JSON.parse(text); } catch { throw new Error("design/final.json: invalid JSON; restore the last valid final-seeds file before continuing"); }
38
+ return { value: validateFinalSeeds(raw, protocol), text };
39
+ }
40
+ export function readFinalSeeds(dir: string, protocol: Protocol): FinalSeeds | undefined { return record(dir, protocol)?.value; }
41
+ export function finalSeedsHash(dir: string, protocol: Protocol): string | undefined {
42
+ const value = record(dir, protocol); return value === undefined ? undefined : sha256(value.text);
43
+ }
44
+ /** Caller holds the study lock; identical bytes never move the reservation event boundary. */
45
+ export function setFinalSeeds(dir: string, raw: unknown, protocol: Protocol, actor: Actor, readUsedSeeds: () => ReadonlySet<number>) {
46
+ requireExperiment(openStudy(dir).study);
47
+ const final = validateFinalSeeds(raw, protocol), previous = record(dir, protocol), text = JSON.stringify(final, null, 2) + "\n", hash = sha256(text), changed = previous?.text !== text;
48
+ if (changed) {
49
+ const used = readUsedSeeds();
50
+ const seen = final.seeds.find(seed => used.has(seed));
51
+ if (seen !== undefined) throw new Error(`final seeds: seed ${seen} was already used by an existing attempt; choose an unused declared seed`);
52
+ commit(dir, [[finalSeedsPath(dir), text, FINAL_SEEDS_BYTES]], [{ actor, type: "final.seeds.set", data: { sha256: hash, seeds: final.seeds } }]);
53
+ }
54
+ return { final, sha256: hash, changed };
55
+ }
56
+
57
+
58
+ export const FINAL_RESULT_BYTES = 1_048_576;
59
+ function finalError(reason: string): never { throw new Error(`final: ${reason}; inspect ares gate list and close the current investigate/ablate inputs before retrying final`); }
60
+ function resultBytes(file: string, bound: number, phase: string): string | undefined {
61
+ if (!existsSync(file)) return undefined;
62
+ let text: string;
63
+ try { text = readTextBounded(file, bound); } catch { finalError(`${phase} result is unreadable, invalid UTF-8 or oversized; restore its last valid bounded file`); }
64
+ let raw: unknown; try { raw = JSON.parse(text); } catch { finalError(`${phase} result JSON is malformed; restore its last valid file`); }
65
+ if (!plain(raw) || raw.v !== 1) throw new Error(`${phase} result: unsupported version; use an ares version that supports it, keeping the original file unchanged`);
66
+ return text;
67
+ }
68
+ /** Content changes reopen approval even before the changed result has been reclosed. */
69
+ export function finalGateBinding(dir: string): string | undefined {
70
+ const { study } = openStudy(dir); if (study.mode !== "experiment") return undefined;
71
+ const protocol = readProtocol(dir); if (!protocol || !readFinalSeeds(dir, protocol)) return undefined;
72
+ const rule = readSelection(dir, protocol); if (!rule) return undefined;
73
+ const investigate = resultBytes(investigateResultPath(dir, rule.run), INVESTIGATE_BYTES, "investigate");
74
+ if (investigate === undefined) return undefined;
75
+ const raw = JSON.parse(investigate) as Record<string, unknown>;
76
+ if (!plain(raw.winner) || typeof raw.winner.commit !== "string" || !/^[a-f0-9]{40}$/.test(raw.winner.commit)) return undefined;
77
+ const ablate = resultBytes(ablateResultPath(dir), ABLATE_BYTES, "ablate");
78
+ if (foldAblations(readLedger(dir)).length && ablate === undefined) return undefined;
79
+ const design = readDesign(dir); if (!design) return undefined;
80
+ return sha256(JSON.stringify([sha256(investigate), ablate === undefined ? null : sha256(ablate), design.binding]));
81
+ }
82
+ export function requireFinalInputs(dirArg: string) {
83
+ const { dir, study } = openStudy(dirArg); requireExperiment(study);
84
+ const design = readDesign(dir); if (!design) finalError("protocol missing; set an experiment protocol first");
85
+ const final = readFinalSeeds(dir, design.protocol); if (!final) finalError("reserved seeds missing; run ares final seeds --file PATH first");
86
+ const rule = readSelection(dir, design.protocol); if (!rule) finalError("selection missing; run ares selection --file PATH first");
87
+ const investigate = readClosedInvestigation(dir); if (!investigate.result.winner) finalError("investigation has no winner");
88
+ const required = foldAblations(readLedger(dir)).length > 0;
89
+ const ablate = required || existsSync(ablateResultPath(dir)) ? readClosedAblate(dir) : undefined;
90
+ const binding = finalGateBinding(dir); if (!binding) finalError("closed upstream results are incomplete");
91
+ return { dir, study, design, final, rule, investigate, ablate, binding, winner: investigate.result.winner.commit, baseline: rule.baseline.commit };
92
+ }
93
+ function winnerChanged(attempts: Attempt[], events: LedgerEvent[], winner: string, baseline: string, seeds: number[]): boolean {
94
+ let latest: string | undefined;
95
+ const history = new Map<string, string | undefined>();
96
+ for (const event of events) {
97
+ if (event.type === "investigate.closed") latest = typeof event.data.winner === "string" ? event.data.winner : undefined;
98
+ if (event.type === "attempt.started" && typeof event.data.id === "string") history.set(event.data.id, latest);
99
+ }
100
+ return attempts.some(a => {
101
+ if (a.finalWinner !== undefined && (typeof a.finalWinner !== "string" || !/^[a-f0-9]{40}$/.test(a.finalWinner))) finalError(`attempt ${a.id} has malformed final winner metadata; restore its valid record`);
102
+ const original = a.finalWinner ?? history.get(a.id);
103
+ return (original !== undefined && original !== winner) || (seeds.includes(a.seed) && a.commit !== winner && a.commit !== baseline);
104
+ });
105
+ }
106
+ /** Called under the study lock before snapshot resolution and again before record creation. */
107
+ export function authorizeFinalAttempt(dir: string, commit: string, seed: number): { binding: string; auto: boolean; winner: string } {
108
+ const input = requireFinalInputs(dir);
109
+ if (!input.final.seeds.includes(seed)) finalError(`seed ${seed} is not reserved; choose a seed from design/final.json`);
110
+ if (typeof commit !== "string" || !/^[a-fA-F0-9]{40}$/.test(commit)) finalError("commit must be a full 40-hex SHA; choose the winner or baseline frozen commit");
111
+ const normalized = commit.toLowerCase();
112
+ if (normalized !== input.winner && normalized !== input.baseline) finalError("commit is neither the investigate winner nor selection baseline; choose one of those frozen commits");
113
+ if (winnerChanged(listAttempts(input.dir, { run: "final" }).attempts, readLedger(input.dir), input.winner, input.baseline, input.final.seeds)) finalError("winner changed after a final attempt; approve the current final gate and run ares final close to record winner-changed, never start a second final");
114
+ const approvals = requireGate(dir, input.study, "final", () => input.binding);
115
+ return { binding: input.binding, auto: approvals.length > 0, winner: input.winner };
116
+ }
117
+ interface FinalAttempt { id: string; commit: string; seed: number; status: AttemptStatus; decisive: boolean; reason?: string; retryOf?: string }
118
+ export interface FinalResult {
119
+ v: 1; status: "qualified" | "not_qualified"; reason?: string; winner: string; baseline: string; seeds: number[];
120
+ pairs: PairedSamples["pairs"]; mean: number | null; baselineMean: number | null; delta: number | null;
121
+ decisive: FinalAttempt[]; attempts: FinalAttempt[]; investigateSha256: string; ablateSha256?: string; sealBinding: string; inputsSha256: string;
122
+ }
123
+ function canonical(value: unknown): string {
124
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
125
+ if (plain(value)) return `{${Object.keys(value).sort().map(k => `${JSON.stringify(k)}:${canonical(value[k])}`).join(",")}}`;
126
+ return JSON.stringify(value) ?? "null";
127
+ }
128
+ export function finalResultPath(dir: string): string { return join(dir, "runs", "final", "final.json"); }
129
+ /** Earliest start decides even on failure; newer retries never replace its outcome. */
130
+ function calculateFinal(input: ReturnType<typeof requireFinalInputs>): FinalResult {
131
+ const attempts = listAttempts(input.dir, { run: "final" }).attempts;
132
+ if (attempts.some(a => a.status === "running")) throw new Error("final close: a final attempt is running; run ares attempt reconcile before closing");
133
+ const events = readLedger(input.dir);
134
+ if (gateState("final", events, () => input.binding).state !== "approved") finalError("gate is not currently approved; run ares gate approve <dir> final after closing changed inputs");
135
+ const changedWinner = winnerChanged(attempts, events, input.winner, input.baseline, input.final.seeds);
136
+ const starts = new Map<string, { index: number; data: Record<string, unknown> }>();
137
+ events.forEach((e, index) => { if (e.type === "attempt.started" && typeof e.data.id === "string") { if (starts.has(e.data.id)) finalError(`duplicate start receipt for ${e.data.id}; restore the valid ledger`); starts.set(e.data.id, { index, data: e.data }); } });
138
+ const ordered = [...attempts].sort((a, b) => (starts.get(a.id)?.index ?? -1) - (starts.get(b.id)?.index ?? -1) || a.id.localeCompare(b.id));
139
+ const chosen = new Map<string, Attempt>(), rows: FinalAttempt[] = [];
140
+ for (const a of ordered) {
141
+ if (a.studyId !== input.study.id || typeof a.commit !== "string" || !/^[a-f0-9]{40}$/.test(a.commit) || !Number.isSafeInteger(a.seed)) finalError(`attempt ${a.id} has invalid study/commit/seed metadata`);
142
+ const required = input.final.seeds.includes(a.seed), start = starts.get(a.id);
143
+ if (required && (!start || start.data.run !== "final" || start.data.commit !== a.commit || start.data.seed !== a.seed)) finalError(`attempt ${a.id} has no matching start receipt; restore its ledger evidence`);
144
+ const key = `${a.commit}:${a.seed}`, decisive = required && !chosen.has(key);
145
+ if (decisive) chosen.set(key, a);
146
+ rows.push({ id: a.id, commit: a.commit, seed: a.seed, status: a.status, decisive, ...(!required ? { reason: "outside-final-pairs" } : !decisive ? { reason: "later-attempt" } : {}), ...(a.retryOf ? { retryOf: a.retryOf } : {}) });
147
+ }
148
+ if (!changedWinner) for (const commit of [input.winner, input.baseline]) for (const seed of input.final.seeds) if (!chosen.has(`${commit}:${seed}`)) finalError(`missing decisive attempt for commit ${commit} seed ${seed}; finish every reserved winner/baseline pair`);
149
+ const decisive = rows.filter(a => a.decisive), firstFailure = decisive.find(a => a.status !== "succeeded");
150
+ let reason: string | undefined = changedWinner ? "winner-changed" : [...chosen.values()].some(a => a.protocolHash !== input.investigate.result.protocolHash) ? "protocol-changed" : firstFailure?.status;
151
+ const samples = (commit: string): Sample[] => input.final.seeds.flatMap(seed => {
152
+ const a = chosen.get(`${commit}:${seed}`);
153
+ if (!a || a.status !== "succeeded" || a.protocolHash !== input.investigate.result.protocolHash) return [];
154
+ return [{ seed, attempt: a.id, value: metricValue(a, input.investigate.result.metric) }];
155
+ });
156
+ const paired = pairSamples(samples(input.winner), samples(input.baseline), input.investigate.result.metric.direction, input.winner);
157
+ if (!reason) {
158
+ if (paired.delta === null || paired.delta <= 0 || paired.delta < input.rule.minDelta) reason = "below-margin";
159
+ }
160
+ const result: FinalResult = { v: 1, status: reason ? "not_qualified" : "qualified", ...(reason ? { reason } : {}), winner: input.winner, baseline: input.baseline, seeds: input.final.seeds, pairs: paired.pairs, mean: paired.mean, baselineMean: paired.referenceMean, delta: paired.delta, decisive, attempts: rows, investigateSha256: input.investigate.sha256, ...(input.ablate ? { ablateSha256: input.ablate.sha256 } : {}), sealBinding: input.design.binding,
161
+ inputsSha256: sha256(canonical({ binding: input.binding, attempts: ordered.map(a => ({ record: sha256(canonical(a)), start: starts.get(a.id)?.index ?? null })) })) };
162
+ const text = JSON.stringify(result, null, 2) + "\n"; assertWritable(text, FINAL_RESULT_BYTES, "final result; archive the study and start a new bounded run");
163
+ return result;
164
+ }
165
+ /** Caller holds the study lock; calculation and output bytes are shared with the read-only closed reader. */
166
+ export function closeFinal(dirArg: string, actor: Actor): FinalResult {
167
+ const input = requireFinalInputs(dirArg), file = finalResultPath(input.dir), old = resultBytes(file, FINAL_RESULT_BYTES, "final");
168
+ const result = calculateFinal(input), text = JSON.stringify(result, null, 2) + "\n";
169
+ if (old !== text) commit(input.dir, [[file, text, FINAL_RESULT_BYTES]], [{ actor, type: "final.closed", data: { sha256: sha256(text), status: result.status, investigateSha256: result.investigateSha256 } }]);
170
+ return result;
171
+ }
172
+ /** Only the exact currently computable result, under current gate approval, is a closed source. */
173
+ export function readClosedFinal(dirArg: string): { result: FinalResult; sha256: string } {
174
+ const input = requireFinalInputs(dirArg), text = resultBytes(finalResultPath(input.dir), FINAL_RESULT_BYTES, "final");
175
+ if (text === undefined) throw new Error("final result: not closed; run ares final close before using final results");
176
+ const hash = sha256(text), receipt = readLedger(input.dir).findLast(event => event.type === "final.closed");
177
+ if (!receipt || receipt.data.sha256 !== hash) throw new Error("final result: bytes disagree with the latest close receipt; run ares final close again");
178
+ const result = calculateFinal(input);
179
+ if (text !== JSON.stringify(result, null, 2) + "\n") throw new Error("final result: stale inputs; approve current final inputs and run ares final close again");
180
+ return { result, sha256: hash };
181
+ }
182
+
183
+
184
+ /** Historical closure is sufficient: exploratory work never refreshes or rewrites final inputs. */
185
+ export function requirePosthocClosed(dir: string): void {
186
+ requireExperiment(openStudy(dir).study);
187
+ if (!readLedger(dir).some(event => event.type === "final.closed")) throw new Error("posthoc: final has not been closed; run ares final close <dir> before starting exploratory attempts");
188
+ }
189
+ export function showPosthoc(dirArg: string) {
190
+ const { dir, study } = openStudy(dirArg); requireExperiment(study);
191
+ const attempts = listAttempts(dir, { run: "posthoc" }).attempts.map(attempt => ({
192
+ id: attempt.id, commit: attempt.commit, seed: attempt.seed, status: attempt.status,
193
+ label: "exploratory" as const, metrics: attempt.metrics,
194
+ ...(attempt.reason ? { reason: attempt.reason } : {}), ...(attempt.retryOf ? { retryOf: attempt.retryOf } : {}),
195
+ }));
196
+ return { run: "posthoc" as const, label: "exploratory" as const, attempts };
197
+ }
@@ -0,0 +1,117 @@
1
+ import { boundedText, commit, onlyKeys, openStudy, plain, readLedger, requireExperiment, type Actor, type LedgerEvent } from "../study.ts";
2
+ import { listAttempts } from "./attempts.ts";
3
+
4
+ export const MAX_IDEAS = 1000, MAX_DECISION_ATTEMPTS = 200;
5
+ export interface IdeaInput { id: string; title: string; hypothesis: string; parent?: string; commit?: string }
6
+ export interface IdeaDecision { decision: "refine" | "pivot"; reason: string; attempts: string[]; commit?: string; cause?: string; retryWhen?: string }
7
+ export interface Idea { id: string; title: string; hypothesis: string; parent?: string; commits: string[]; state: "open" | "pivoted"; decisions: IdeaDecision[] }
8
+ const recovery = "; correct the idea input and retry ares idea add or ares idea decide";
9
+ function bad(reason: string): never { throw new Error(`idea: ${reason}${recovery}`); }
10
+ function name(raw: unknown, field: string): string {
11
+ if (typeof raw !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(raw)) bad(`${field}: use 1..64 letters, digits, underscores or hyphens, starting with a letter or digit`);
12
+ return raw;
13
+ }
14
+ function commitId(raw: unknown): string {
15
+ if (typeof raw !== "string" || !/^[a-fA-F0-9]{40}$/.test(raw)) bad("commit: full 40-hex SHA required");
16
+ return raw.toLowerCase();
17
+ }
18
+ function validateIdea(raw: unknown, strict: boolean): IdeaInput {
19
+ if (!plain(raw)) bad("JSON object required");
20
+ if (strict) onlyKeys(raw, ["id", "title", "hypothesis", "parent", "commit"], "idea");
21
+ return { id: name(raw.id, "id"), title: boundedText(raw.title, "idea.title", 500), hypothesis: boundedText(raw.hypothesis, "idea.hypothesis", 4000),
22
+ ...(raw.parent !== undefined ? { parent: name(raw.parent, "parent") } : {}), ...(raw.commit !== undefined ? { commit: commitId(raw.commit) } : {}) };
23
+ }
24
+ function pivotContext(raw: Record<string, unknown>, strict: boolean): Pick<IdeaDecision, "cause" | "retryWhen"> {
25
+ if (strict && raw.decision !== "pivot" && (raw.cause !== undefined || raw.retryWhen !== undefined)) bad("cause and retryWhen are pivot-only; omit --cause and --retry-when for refine");
26
+ const extra: { cause?: string; retryWhen?: string } = {};
27
+ for (const field of ["cause", "retryWhen"] as const) if (raw[field] !== undefined && raw.decision === "pivot") {
28
+ const value = boundedText(raw[field], `idea ${field}`, 2000);
29
+ if (Buffer.byteLength(value) > 2000) bad(`${field}: at most 2000 bytes required`);
30
+ extra[field] = value;
31
+ }
32
+ return extra;
33
+ }
34
+ function validateDecision(raw: unknown, strict: boolean): IdeaDecision {
35
+ if (!plain(raw)) bad("decision object required");
36
+ if (strict) onlyKeys(raw, ["decision", "reason", "attempts", "commit", "cause", "retryWhen"], "idea decision");
37
+ if (raw.decision !== "refine" && raw.decision !== "pivot") bad("decision: use refine or pivot");
38
+ // Reject new pivot commit options; keep older ledger entries readable on replay.
39
+ if (strict && raw.decision === "pivot" && raw.commit !== undefined) bad("pivot does not take --commit; omit --commit, or use refine to attach a commit");
40
+ const extra = pivotContext(raw, strict);
41
+ const reason = boundedText(raw.reason, "idea reason", 2000);
42
+ if (Buffer.byteLength(reason) > 2000) bad("reason: at most 2000 bytes required");
43
+ if (raw.attempts !== undefined && (!Array.isArray(raw.attempts) || raw.attempts.length > MAX_DECISION_ATTEMPTS || raw.attempts.some(a => typeof a !== "string" || !/^[a-f0-9]{16}$/.test(a)))) bad(`attempts: name at most ${MAX_DECISION_ATTEMPTS} 16-hex attempt ids from ares attempt list`);
44
+ if (raw.decision === "refine" && raw.commit === undefined) bad("refine requires --commit with a full frozen SHA");
45
+ return { decision: raw.decision, reason, ...extra, attempts: [...new Set((raw.attempts as string[] | undefined) ?? [])], ...(raw.commit !== undefined ? { commit: commitId(raw.commit) } : {}) };
46
+ }
47
+ function claim(ideas: Iterable<Idea>, id: string, frozen?: string): void {
48
+ if (!frozen) return;
49
+ for (const other of ideas) if (other.id !== id && other.commits.includes(frozen)) bad(`commit already belongs to idea ${other.id}; choose another commit or refine that idea`);
50
+ }
51
+ function evidenceRequired(target: Idea, input: IdeaDecision): void {
52
+ if (target.commits.length && !input.attempts.length) bad(`idea ${target.id} has commits; pass --attempts with terminal evidence from those commits`);
53
+ if (!target.commits.length && input.attempts.length) bad(`idea ${target.id} has no commit; omit --attempts when attaching its first commit or dropping it before testing`);
54
+ }
55
+ function safe<T>(fn: () => T): T {
56
+ try { return fn(); } catch (error) { const message = error instanceof Error ? error.message : "idea: invalid input"; throw new Error(message.includes(recovery) ? message : message + recovery); }
57
+ }
58
+ /** Replay the journal only; unknown event kinds and additive event fields are preserved in the ledger. */
59
+ export function foldIdeas(events: LedgerEvent[]): Idea[] {
60
+ return safe(() => {
61
+ const ideas = new Map<string, Idea>();
62
+ for (const event of events) {
63
+ if (event.type === "idea.added") {
64
+ const input = validateIdea(event.data.idea, false);
65
+ if (ideas.has(input.id)) bad(`duplicate id ${input.id}; choose a new idea id`);
66
+ if (ideas.size >= MAX_IDEAS) bad(`limit ${MAX_IDEAS} reached; start a new study for more ideas`);
67
+ if (input.parent !== undefined && !ideas.has(input.parent)) bad(`parent ${input.parent} does not exist; add its parent idea first`);
68
+ claim(ideas.values(), input.id, input.commit);
69
+ ideas.set(input.id, { id: input.id, title: input.title, hypothesis: input.hypothesis, ...(input.parent !== undefined ? { parent: input.parent } : {}), commits: input.commit ? [input.commit] : [], state: "open", decisions: [] });
70
+ } else if (event.type === "idea.decided") {
71
+ const id = name(event.data.id, "id"), input = validateDecision(event.data, false), target = ideas.get(id);
72
+ if (!target) bad(`unknown id ${id}; add the idea before deciding`);
73
+ if (target.state === "pivoted") bad(`idea ${id} is pivoted; add a new child idea instead of deciding it again`);
74
+ evidenceRequired(target, input);
75
+ if (input.decision === "refine") claim(ideas.values(), id, input.commit);
76
+ if (input.decision === "refine" && !target.commits.includes(input.commit!)) target.commits.push(input.commit!);
77
+ if (input.decision === "pivot") target.state = "pivoted";
78
+ target.decisions.push(input);
79
+ }
80
+ }
81
+ return [...ideas.values()];
82
+ });
83
+ }
84
+ /** Caller holds the study lock; the journal is the only write. */
85
+ export function addIdea(dir: string, raw: unknown, actor: Actor): Idea {
86
+ return safe(() => {
87
+ requireExperiment(openStudy(dir).study);
88
+ const input = validateIdea(raw, true), events = readLedger(dir), ideas = foldIdeas(events);
89
+ if (ideas.some(i => i.id === input.id)) bad(`duplicate id ${input.id}; choose a new idea id`);
90
+ if (ideas.length >= MAX_IDEAS) bad(`limit ${MAX_IDEAS} reached; start a new study for more ideas`);
91
+ if (input.parent !== undefined && !ideas.some(i => i.id === input.parent)) bad(`parent ${input.parent} does not exist; add its parent idea first`);
92
+ claim(ideas, input.id, input.commit);
93
+ const added = commit(dir, [], [{ actor, type: "idea.added", data: { idea: input } }]);
94
+ return foldIdeas([...events, ...added]).find(i => i.id === input.id)!;
95
+ });
96
+ }
97
+ /** Validate all evidence before committing the decision; a failed decision never changes the journal. */
98
+ export function decideIdea(dir: string, idArg: string, raw: unknown, actor: Actor): Idea {
99
+ return safe(() => {
100
+ const { study } = openStudy(dir); requireExperiment(study);
101
+ const id = name(idArg, "id"), input = validateDecision(raw, true), events = readLedger(dir), ideas = foldIdeas(events), target = ideas.find(i => i.id === id);
102
+ if (!target) bad(`unknown id ${id}; add the idea before deciding`);
103
+ if (target.state === "pivoted") bad(`idea ${id} is pivoted; add a new child idea instead of deciding it again`);
104
+ evidenceRequired(target, input);
105
+ if (input.decision === "refine") claim(ideas, id, input.commit);
106
+ const attempts = new Map((input.attempts.length ? listAttempts(dir).attempts : []).map(a => [a.id, a]));
107
+ for (const wanted of input.attempts) {
108
+ const attempt = attempts.get(wanted);
109
+ if (!attempt) bad(`attempt ${wanted} does not exist; choose an id from ares attempt list`);
110
+ if (attempt.studyId !== study.id) bad(`attempt ${wanted} belongs to another study; choose this study's attempt`);
111
+ if (attempt.status === "running") bad(`attempt ${wanted} is running; run ares attempt reconcile before deciding`);
112
+ if (!target.commits.includes(attempt.commit)) bad(`attempt ${wanted} belongs to a foreign commit; cite only this idea's existing commits`);
113
+ }
114
+ const added = commit(dir, [], [{ actor, type: "idea.decided", data: { id, ...input } }]);
115
+ return foldIdeas([...events, ...added]).find(i => i.id === id)!;
116
+ });
117
+ }